Files
pezkuwi-telegram-miniapp/supabase/functions/process-withdraw/index.ts
T
pezkuwichain 480b5fe96b feat: P2P E2E test suite + edge function RPC fix + balance tx constraint update
- Fix process-withdraw and verify-deposit-telegram to use RPC_ENDPOINT
  env var defaulting to Asset Hub (wss://asset-hub-rpc.pezkuwichain.io)
- Add P2P E2E test script (scripts/p2p-e2e-test.py) covering full flow:
  offer creation, trade accept, payment, escrow release, cancel, visa
  user trade, and withdrawal request
- Update p2p_balance_transactions transaction_type check constraint
  to include withdraw_lock, withdraw_complete, dispute_refund
2026-02-23 21:21:12 +03:00

316 lines
10 KiB
TypeScript

// 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<string, string> {
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<ApiPromise> {
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<string>((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' } }
);
}
});