// process-withdraw Edge Function // Processes pending withdrawal requests by sending tokens from platform wallet to user wallets // This should be called by a cron job or manually by admins // SECURITY: This is a backend-only function - no user session needed import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; import { ApiPromise, WsProvider, Keyring } from 'npm:@pezkuwi/api@16.5.36'; import { cryptoWaitReady } from 'npm:@pezkuwi/util-crypto@14.0.25'; // CORS - Restricted for security (cron/admin only) const ALLOWED_ORIGINS = [ 'https://telegram.pezkuwichain.io', 'https://telegram.pezkiwi.app', 'https://supabase.com', ]; function getCorsHeaders(origin: string | null): Record { const allowedOrigin = origin && ALLOWED_ORIGINS.some((o) => origin.startsWith(o)) ? origin : ALLOWED_ORIGINS[0]; return { 'Access-Control-Allow-Origin': allowedOrigin, 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS', }; } // RPC endpoint — defaults to Asset Hub where user balances live const RPC_ENDPOINT = Deno.env.get('RPC_ENDPOINT') || 'wss://asset-hub-rpc.pezkuwichain.io'; // Token decimals const DECIMALS = 12; // PEZ asset ID const PEZ_ASSET_ID = 1; // Cache API connection let apiInstance: ApiPromise | null = null; async function getApi(): Promise { if (apiInstance && apiInstance.isConnected) { return apiInstance; } const provider = new WsProvider(RPC_ENDPOINT); apiInstance = await ApiPromise.create({ provider }); return apiInstance; } interface ProcessWithdrawRequest { adminKey?: string; // Optional admin key for manual processing requestId?: string; // Optional: process specific request batchSize?: number; // How many requests to process (default: 10) } serve(async (req) => { const origin = req.headers.get('origin'); const corsHeaders = getCorsHeaders(origin); // Handle CORS preflight if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } try { // Parse request body let body: ProcessWithdrawRequest = {}; try { const text = await req.text(); if (text) { body = JSON.parse(text); } } catch { // Empty body is OK for cron triggers } const { requestId, batchSize = 10 } = body; // Create Supabase service client const supabaseUrl = Deno.env.get('SUPABASE_URL')!; const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; const serviceClient = createClient(supabaseUrl, supabaseServiceKey); // Get platform wallet seed phrase from environment const escrowSeedPhrase = Deno.env.get('ESCROW_SEED_PHRASE'); if (!escrowSeedPhrase) { console.error('ESCROW_SEED_PHRASE not configured'); return new Response( JSON.stringify({ success: false, error: 'Platform wallet not configured' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } // Initialize crypto await cryptoWaitReady(); // Create keyring and add platform wallet const keyring = new Keyring({ type: 'sr25519' }); const platformWallet = keyring.addFromUri(escrowSeedPhrase); console.log(`Platform wallet address: ${platformWallet.address}`); // Connect to blockchain const api = await getApi(); console.log(`Connected to chain: ${api.runtimeChain.toString()}`); // Get pending withdrawal requests let query = serviceClient .from('p2p_deposit_withdraw_requests') .select('*') .eq('request_type', 'withdraw') .eq('status', 'pending') .order('created_at', { ascending: true }) .limit(batchSize); if (requestId) { query = serviceClient .from('p2p_deposit_withdraw_requests') .select('*') .eq('id', requestId) .eq('request_type', 'withdraw') .in('status', ['pending', 'failed']); // Allow retry of failed } const { data: pendingRequests, error: queryError } = await query; if (queryError) { console.error('Failed to query pending requests:', queryError); return new Response(JSON.stringify({ success: false, error: 'Failed to query requests' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } if (!pendingRequests || pendingRequests.length === 0) { return new Response( JSON.stringify({ success: true, message: 'No pending withdrawal requests', processed: 0 }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } console.log(`Processing ${pendingRequests.length} withdrawal requests`); const results: Array<{ requestId: string; success: boolean; txHash?: string; error?: string; }> = []; for (const request of pendingRequests) { try { console.log( `Processing withdrawal: ID=${request.id}, Token=${request.token}, Amount=${request.amount}, To=${request.wallet_address}` ); // Mark as processing await serviceClient .from('p2p_deposit_withdraw_requests') .update({ status: 'processing' }) .eq('id', request.id); // Convert amount to chain units const amountInUnits = BigInt(Math.floor(request.amount * Math.pow(10, DECIMALS))); let tx; if (request.token === 'HEZ') { // Native token transfer tx = api.tx.balances.transferKeepAlive(request.wallet_address, amountInUnits); } else if (request.token === 'PEZ') { // Asset transfer tx = api.tx.assets.transfer(PEZ_ASSET_ID, request.wallet_address, amountInUnits); } else { throw new Error(`Unsupported token: ${request.token}`); } // Sign and send transaction const txHash = await new Promise((resolve, reject) => { let resolved = false; tx.signAndSend(platformWallet, ({ status, txHash, dispatchError }) => { if (resolved) return; if (dispatchError) { resolved = true; 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())); } return; } if (status.isInBlock || status.isFinalized) { resolved = true; resolve(txHash.toHex()); } }).catch(reject); // Timeout after 60 seconds setTimeout(() => { if (!resolved) { resolved = true; reject(new Error('Transaction timeout')); } }, 60000); }); console.log(`Withdrawal TX sent: ${txHash}`); // Update user's locked balance (reduce locked, track withdrawal) const { error: balanceError } = await serviceClient.rpc('complete_withdraw', { p_user_id: request.user_id, p_token: request.token, p_amount: request.amount, p_tx_hash: txHash, p_request_id: request.id, }); // If complete_withdraw function doesn't exist, manually update if (balanceError && balanceError.message.includes('does not exist')) { // Manual update await serviceClient .from('user_internal_balances') .update({ locked_balance: serviceClient.raw(`locked_balance - ${request.amount}`), total_withdrawn: serviceClient.raw(`total_withdrawn + ${request.amount}`), last_withdraw_at: new Date().toISOString(), }) .eq('user_id', request.user_id) .eq('token', request.token); // Log transaction await serviceClient.from('p2p_balance_transactions').insert({ user_id: request.user_id, token: request.token, transaction_type: 'withdraw', amount: -request.amount, balance_before: 0, // We don't have this info here balance_after: 0, // We don't have this info here reference_type: 'withdraw_request', reference_id: request.id, description: `Withdrawal to ${request.wallet_address}: ${txHash}`, }); } // Update request status to completed await serviceClient .from('p2p_deposit_withdraw_requests') .update({ status: 'completed', blockchain_tx_hash: txHash, processed_at: new Date().toISOString(), }) .eq('id', request.id); results.push({ requestId: request.id, success: true, txHash, }); console.log(`Withdrawal completed: ${request.id} -> ${txHash}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error(`Withdrawal failed for ${request.id}:`, errorMessage); // Update request status to failed await serviceClient .from('p2p_deposit_withdraw_requests') .update({ status: 'failed', error_message: errorMessage, processed_at: new Date().toISOString(), }) .eq('id', request.id); // Don't refund locked balance on failure - admin should review // They can manually retry or refund results.push({ requestId: request.id, success: false, error: errorMessage, }); } } const successCount = results.filter((r) => r.success).length; const failCount = results.filter((r) => !r.success).length; return new Response( JSON.stringify({ success: true, message: `Processed ${results.length} requests: ${successCount} succeeded, ${failCount} failed`, processed: results.length, succeeded: successCount, failed: failCount, results, }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } catch (error) { console.error('Process withdraw error:', error); const origin = req.headers.get('origin'); return new Response( JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Internal server error', }), { status: 500, headers: { ...getCorsHeaders(origin), 'Content-Type': 'application/json' } } ); } });