feat(p2p): implement OKX-style internal ledger escrow system

Phase 5 implementation - Internal Ledger Escrow (OKX Model):
- No blockchain transactions during P2P trades
- Blockchain tx only at deposit/withdraw
- Fast and fee-free P2P trading

Database:
- Add user_internal_balances table
- Add p2p_deposit_withdraw_requests table
- Add p2p_balance_transactions table
- Add lock_escrow_internal(), release_escrow_internal() functions
- Add process_deposit(), request_withdraw() functions

UI Components:
- Add InternalBalanceCard showing available/locked balances
- Add DepositModal for crypto deposits to P2P balance
- Add WithdrawModal for withdrawals from P2P balance
- Integrate balance card into P2PDashboard

Backend:
- Add process-withdrawal Edge Function
- Add verify-deposit Edge Function

Updated p2p-fiat.ts:
- createFiatOffer() uses internal balance lock
- confirmPaymentReceived() uses internal balance transfer
- Add internal balance management functions
This commit is contained in:
2025-12-12 00:41:11 +03:00
parent 98e03d11fd
commit 14f5e84d15
372 changed files with 3408 additions and 351 deletions
@@ -0,0 +1,309 @@
/**
* P2P Withdrawal Processing Edge Function
*
* This function processes pending withdrawal requests from the P2P internal balance system.
* It should be called by a cron job or triggered manually by admin.
*
* Security:
* - Uses service role key for database operations
* - Platform wallet private key stored in environment variables
* - All transactions are logged for auditing
*
* Flow:
* 1. Fetch pending withdrawal requests
* 2. For each request:
* a. Lock the request (status = 'processing')
* b. Execute blockchain transfer
* c. Update request with tx hash
* d. Deduct from user's internal balance
* e. Mark as completed
*/
// @ts-ignore - Deno imports
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
// @ts-ignore - Deno imports
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
// @ts-ignore - Polkadot imports for Deno
import { ApiPromise, WsProvider, Keyring } from "https://esm.sh/@polkadot/api@11.0.2";
// @ts-ignore - Deno imports
import { cryptoWaitReady } from "https://esm.sh/@polkadot/util-crypto@12.6.2";
// Configuration
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const PLATFORM_WALLET_SEED = Deno.env.get("PLATFORM_WALLET_SEED")!;
const RPC_ENDPOINT = Deno.env.get("RPC_ENDPOINT") || "wss://rpc.pezkuwichain.io:9944";
// Asset IDs
const ASSET_IDS: Record<string, number | null> = {
HEZ: null, // Native token
PEZ: 1,
};
// Decimals
const DECIMALS = 12;
interface WithdrawRequest {
id: string;
user_id: string;
token: string;
amount: number;
wallet_address: string;
status: string;
}
/**
* Process a single withdrawal request
*/
async function processWithdrawal(
api: ApiPromise,
keyring: Keyring,
supabase: ReturnType<typeof createClient>,
request: WithdrawRequest
): Promise<{ success: boolean; txHash?: string; error?: string }> {
const { id, token, amount, wallet_address } = request;
try {
console.log(`Processing withdrawal ${id}: ${amount} ${token} to ${wallet_address}`);
// 1. Get platform wallet from keyring
const platformWallet = keyring.addFromUri(PLATFORM_WALLET_SEED);
// 2. Calculate amount in planck (smallest unit)
const amountPlanck = BigInt(Math.floor(amount * Math.pow(10, DECIMALS)));
// 3. Build transaction based on token type
let tx;
if (token === "HEZ" || ASSET_IDS[token] === null) {
// Native token transfer
tx = api.tx.balances.transferKeepAlive(wallet_address, amountPlanck);
} else {
// Asset transfer
const assetId = ASSET_IDS[token];
if (assetId === undefined) {
throw new Error(`Unknown token: ${token}`);
}
tx = api.tx.assets.transfer(assetId, wallet_address, amountPlanck);
}
// 4. Sign and send transaction
const txHash = await new Promise<string>((resolve, reject) => {
let unsubscribe: () => void;
tx.signAndSend(platformWallet, { nonce: -1 }, ({ status, dispatchError }) => {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(" ")}`));
} else {
reject(new Error(dispatchError.toString()));
}
if (unsubscribe) unsubscribe();
return;
}
if (status.isInBlock) {
console.log(`Transaction in block: ${status.asInBlock.toString()}`);
}
if (status.isFinalized) {
const hash = tx.hash.toHex();
console.log(`Transaction finalized: ${hash}`);
resolve(hash);
if (unsubscribe) unsubscribe();
}
})
.then((unsub) => {
unsubscribe = unsub;
})
.catch(reject);
});
// 5. Update request with tx hash and complete status
const { error: updateError } = await supabase
.from("p2p_deposit_withdraw_requests")
.update({
status: "completed",
blockchain_tx_hash: txHash,
processed_at: new Date().toISOString(),
})
.eq("id", id);
if (updateError) {
console.error(`Failed to update request ${id}:`, updateError);
// Transaction was successful, but we couldn't update the database
// This should be handled manually
return { success: true, txHash, error: "Database update failed after successful tx" };
}
// 6. Log the withdrawal in balance transactions
const { error: logError } = await supabase.from("p2p_balance_transactions").insert({
user_id: request.user_id,
token,
transaction_type: "withdraw",
amount: -amount,
balance_before: 0, // Will be calculated by trigger
balance_after: 0, // Will be calculated by trigger
reference_type: "withdrawal",
reference_id: id,
description: `Withdrawal to ${wallet_address.slice(0, 8)}...${wallet_address.slice(-6)}`,
});
if (logError) {
console.error(`Failed to log transaction for ${id}:`, logError);
}
console.log(`Successfully processed withdrawal ${id}: ${txHash}`);
return { success: true, txHash };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
console.error(`Failed to process withdrawal ${id}:`, errorMessage);
// Update request with error
await supabase
.from("p2p_deposit_withdraw_requests")
.update({
status: "failed",
error_message: errorMessage,
processed_at: new Date().toISOString(),
})
.eq("id", id);
return { success: false, error: errorMessage };
}
}
/**
* Main handler
*/
serve(async (req: Request) => {
// CORS headers
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
"Content-Type": "application/json",
};
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers });
}
try {
// Verify authorization (should be called with service role key or admin JWT)
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers }
);
}
// Initialize Supabase client with service role
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
// Parse request body for optional filters
let requestBody: { limit?: number; requestId?: string } = {};
try {
requestBody = await req.json();
} catch {
// No body provided, use defaults
}
const { limit = 10, requestId } = requestBody;
// 1. Fetch pending withdrawal requests
let query = supabase
.from("p2p_deposit_withdraw_requests")
.select("*")
.eq("request_type", "withdraw")
.eq("status", "pending")
.order("created_at", { ascending: true })
.limit(limit);
// If specific request ID provided, fetch only that one
if (requestId) {
query = supabase
.from("p2p_deposit_withdraw_requests")
.select("*")
.eq("id", requestId)
.eq("request_type", "withdraw")
.in("status", ["pending", "failed"]);
}
const { data: pendingRequests, error: fetchError } = await query;
if (fetchError) {
throw new Error(`Failed to fetch pending requests: ${fetchError.message}`);
}
if (!pendingRequests || pendingRequests.length === 0) {
return new Response(
JSON.stringify({ message: "No pending withdrawal requests", processed: 0 }),
{ headers }
);
}
console.log(`Found ${pendingRequests.length} pending withdrawal requests`);
// 2. Lock the requests (set status to processing)
const requestIds = pendingRequests.map((r: WithdrawRequest) => r.id);
const { error: lockError } = await supabase
.from("p2p_deposit_withdraw_requests")
.update({ status: "processing" })
.in("id", requestIds);
if (lockError) {
throw new Error(`Failed to lock requests: ${lockError.message}`);
}
// 3. Initialize Polkadot API
console.log(`Connecting to blockchain: ${RPC_ENDPOINT}`);
await cryptoWaitReady();
const provider = new WsProvider(RPC_ENDPOINT);
const api = await ApiPromise.create({ provider });
await api.isReady;
const keyring = new Keyring({ type: "sr25519" });
// 4. Process each withdrawal
const results = [];
for (const request of pendingRequests as WithdrawRequest[]) {
const result = await processWithdrawal(api, keyring, supabase, request);
results.push({
id: request.id,
...result,
});
// Small delay between transactions to avoid nonce issues
await new Promise((resolve) => setTimeout(resolve, 1000));
}
// 5. Cleanup
await api.disconnect();
// 6. Return results
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
return new Response(
JSON.stringify({
message: `Processed ${results.length} withdrawal requests`,
processed: results.length,
success: successCount,
failed: failCount,
results,
}),
{ headers }
);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
console.error("Error processing withdrawals:", errorMessage);
return new Response(
JSON.stringify({ error: errorMessage }),
{ status: 500, headers }
);
}
});
@@ -0,0 +1,338 @@
/**
* P2P Deposit Verification Edge Function
*
* This function verifies deposit transactions on the blockchain and credits
* the user's internal P2P balance.
*
* Flow:
* 1. User sends tokens to platform wallet (from frontend)
* 2. User calls this function with tx hash
* 3. Function verifies transaction on-chain:
* - Confirms tx is finalized
* - Confirms correct recipient (platform wallet)
* - Confirms amount matches claimed amount
* - Confirms token type
* 4. Credits user's internal balance
* 5. Creates audit record
*/
// @ts-ignore - Deno imports
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
// @ts-ignore - Deno imports
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
// @ts-ignore - Polkadot imports for Deno
import { ApiPromise, WsProvider } from "https://esm.sh/@polkadot/api@11.0.2";
// Configuration
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const PLATFORM_WALLET_ADDRESS = Deno.env.get("PLATFORM_WALLET_ADDRESS") || "5DFwqK698vL4gXHEcanaewnAqhxJ2rjhAogpSTHw3iwGDwd3";
const RPC_ENDPOINT = Deno.env.get("RPC_ENDPOINT") || "wss://rpc.pezkuwichain.io:9944";
// Decimals
const DECIMALS = 12;
// Tolerance for amount verification (0.1%)
const AMOUNT_TOLERANCE = 0.001;
interface VerifyDepositRequest {
txHash: string;
token: "HEZ" | "PEZ";
expectedAmount: number;
}
/**
* Parse block events to find transfer details
*/
function findTransferInEvents(
api: ApiPromise,
events: Array<{ event: { section: string; method: string; data: unknown[] } }>,
token: string
): { from: string; to: string; amount: bigint } | null {
for (const { event } of events) {
// Native HEZ transfer
if (token === "HEZ" && event.section === "balances" && event.method === "Transfer") {
const [from, to, amount] = event.data as [
{ toString: () => string },
{ toString: () => string },
{ toBigInt: () => bigint }
];
return {
from: from.toString(),
to: to.toString(),
amount: amount.toBigInt(),
};
}
// Asset transfer (PEZ)
if (token === "PEZ" && event.section === "assets" && event.method === "Transferred") {
const [_assetId, from, to, amount] = event.data as [
unknown,
{ toString: () => string },
{ toString: () => string },
{ toBigInt: () => bigint }
];
return {
from: from.toString(),
to: to.toString(),
amount: amount.toBigInt(),
};
}
}
return null;
}
serve(async (req: Request) => {
// CORS headers
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
"Content-Type": "application/json",
};
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers });
}
try {
// Get user from JWT
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers }
);
}
// Initialize Supabase client with user's JWT
const supabaseAnon = createClient(
SUPABASE_URL,
Deno.env.get("SUPABASE_ANON_KEY")!,
{
global: {
headers: { Authorization: authHeader },
},
}
);
// Get current user
const { data: { user }, error: userError } = await supabaseAnon.auth.getUser();
if (userError || !user) {
return new Response(
JSON.stringify({ error: "Authentication failed" }),
{ status: 401, headers }
);
}
// Parse request body
const { txHash, token, expectedAmount }: VerifyDepositRequest = await req.json();
// Validate input
if (!txHash || !token || !expectedAmount) {
return new Response(
JSON.stringify({ error: "Missing required fields: txHash, token, expectedAmount" }),
{ status: 400, headers }
);
}
if (!["HEZ", "PEZ"].includes(token)) {
return new Response(
JSON.stringify({ error: "Invalid token type" }),
{ status: 400, headers }
);
}
if (expectedAmount <= 0) {
return new Response(
JSON.stringify({ error: "Invalid amount" }),
{ status: 400, headers }
);
}
// Initialize service role client for database operations
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
// Check if this tx hash has already been processed
const { data: existingDeposit } = await supabase
.from("p2p_deposit_withdraw_requests")
.select("id")
.eq("blockchain_tx_hash", txHash)
.single();
if (existingDeposit) {
return new Response(
JSON.stringify({ error: "This transaction has already been processed" }),
{ status: 400, headers }
);
}
console.log(`Verifying deposit: ${txHash} for ${expectedAmount} ${token}`);
// Connect to blockchain
const provider = new WsProvider(RPC_ENDPOINT);
const api = await ApiPromise.create({ provider });
await api.isReady;
try {
// Get block hash from transaction hash
// Note: In Substrate, we need to search for the extrinsic
// This is a simplified version - production should use indexer
const extrinsicHash = txHash;
// Try to get the extrinsic status
// In production, you'd use a block explorer API or indexer
// For now, we'll search recent blocks
const currentBlock = await api.rpc.chain.getBlock();
const currentBlockNumber = currentBlock.block.header.number.toNumber();
let foundTransfer = null;
let blockHash = null;
// Search last 100 blocks for the transaction
for (let i = 0; i < 100; i++) {
const blockNumber = currentBlockNumber - i;
if (blockNumber < 0) break;
const hash = await api.rpc.chain.getBlockHash(blockNumber);
const signedBlock = await api.rpc.chain.getBlock(hash);
const allRecords = await api.query.system.events.at(hash);
// Check each extrinsic in the block
signedBlock.block.extrinsics.forEach((extrinsic, index) => {
if (extrinsic.hash.toHex() === extrinsicHash) {
// Found the transaction! Get its events
const events = (allRecords as unknown as Array<{ phase: { isApplyExtrinsic: boolean; asApplyExtrinsic: { eq: (n: number) => boolean } }; event: { section: string; method: string; data: unknown[] } }>)
.filter(({ phase }) => phase.isApplyExtrinsic && phase.asApplyExtrinsic.eq(index))
.map(({ event }) => ({ event: { section: event.section, method: event.method, data: event.data as unknown[] } }));
// Check if transaction was successful
const success = events.some(
({ event }) =>
event.section === "system" && event.method === "ExtrinsicSuccess"
);
if (success) {
foundTransfer = findTransferInEvents(api, events, token);
blockHash = hash.toHex();
}
}
});
if (foundTransfer) break;
}
if (!foundTransfer) {
await api.disconnect();
return new Response(
JSON.stringify({
error: "Transaction not found or not finalized. Please wait a few minutes and try again.",
}),
{ status: 400, headers }
);
}
// Verify the transfer details
const { to, amount } = foundTransfer;
// Check recipient is platform wallet
if (to !== PLATFORM_WALLET_ADDRESS) {
await api.disconnect();
return new Response(
JSON.stringify({
error: "Transaction recipient is not the platform wallet",
}),
{ status: 400, headers }
);
}
// Convert amount to human-readable
const actualAmount = Number(amount) / Math.pow(10, DECIMALS);
// Check amount matches (with tolerance for rounding)
const amountDiff = Math.abs(actualAmount - expectedAmount) / expectedAmount;
if (amountDiff > AMOUNT_TOLERANCE) {
await api.disconnect();
return new Response(
JSON.stringify({
error: `Amount mismatch. Expected ${expectedAmount}, found ${actualAmount}`,
}),
{ status: 400, headers }
);
}
await api.disconnect();
// All checks passed! Credit the user's internal balance
// Create deposit request record
const { data: depositRequest, error: insertError } = await supabase
.from("p2p_deposit_withdraw_requests")
.insert({
user_id: user.id,
request_type: "deposit",
token,
amount: actualAmount,
wallet_address: foundTransfer.from,
blockchain_tx_hash: txHash,
status: "completed",
processed_at: new Date().toISOString(),
})
.select()
.single();
if (insertError) {
throw new Error(`Failed to create deposit record: ${insertError.message}`);
}
// Credit internal balance using the process_deposit function
const { data: balanceResult, error: balanceError } = await supabase.rpc(
"process_deposit",
{
p_user_id: user.id,
p_token: token,
p_amount: actualAmount,
p_tx_hash: txHash,
}
);
if (balanceError) {
throw new Error(`Failed to credit balance: ${balanceError.message}`);
}
// Parse result
const result = typeof balanceResult === "string" ? JSON.parse(balanceResult) : balanceResult;
if (!result.success) {
throw new Error(result.error || "Failed to credit balance");
}
console.log(`Successfully verified deposit ${txHash}: ${actualAmount} ${token}`);
return new Response(
JSON.stringify({
success: true,
message: `Successfully deposited ${actualAmount} ${token}`,
depositId: depositRequest.id,
amount: actualAmount,
token,
txHash,
blockHash,
}),
{ headers }
);
} catch (error) {
await api.disconnect();
throw error;
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
console.error("Error verifying deposit:", errorMessage);
return new Response(
JSON.stringify({ error: errorMessage }),
{ status: 500, headers }
);
}
});