mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-04-22 00:47:55 +00:00
480b5fe96b
- 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
316 lines
10 KiB
TypeScript
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' } }
|
|
);
|
|
}
|
|
});
|