diff --git a/supabase/functions/request-withdraw-telegram/index.ts b/supabase/functions/request-withdraw-telegram/index.ts index bd309a7..8c61894 100644 --- a/supabase/functions/request-withdraw-telegram/index.ts +++ b/supabase/functions/request-withdraw-telegram/index.ts @@ -1,10 +1,12 @@ // request-withdraw-telegram Edge Function -// For Telegram MiniApp users - creates a withdrawal request with session token auth -// The actual blockchain TX is handled by process-withdraw (cron/admin) +// For Telegram MiniApp users - creates withdrawal request AND processes blockchain TX +// Uses @pezkuwi/api for Asset Hub blockchain transactions import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; import { createHmac } from 'https://deno.land/std@0.177.0/node/crypto.ts'; +import { ApiPromise, WsProvider, Keyring } from 'npm:@pezkuwi/api@16.5.36'; +import { cryptoWaitReady } from 'npm:@pezkuwi/util-crypto@14.0.25'; // CORS - Production domain only const ALLOWED_ORIGINS = [ @@ -24,6 +26,18 @@ function getCorsHeaders(origin: string | null): Record { }; } +// Platform hot wallet address +const PLATFORM_WALLET = '5H18ZZBU4LwPYbeEZ1JBGvibCU2edhhM8HNUtFi7GgC36CgS'; + +// RPC endpoint — Asset Hub +const RPC_ENDPOINT = 'wss://asset-hub-rpc.pezkuwichain.io'; + +// Token decimals +const DECIMALS = 12; + +// PEZ asset ID +const PEZ_ASSET_ID = 1; + // Minimum withdrawal amounts const MIN_WITHDRAW: Record = { HEZ: 1, @@ -92,6 +106,121 @@ function verifyLegacyToken(token: string): number | null { } } +// 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; +} + +// Send tokens from hot wallet to user wallet +async function sendTokens( + api: ApiPromise, + privateKey: string, + toAddress: string, + token: string, + amount: number +): Promise<{ success: boolean; txHash?: string; error?: string }> { + try { + await cryptoWaitReady(); + + const keyring = new Keyring({ type: 'sr25519' }); + const hotWallet = keyring.addFromUri(privateKey); + + // Verify hot wallet address matches expected + if (hotWallet.address !== PLATFORM_WALLET) { + return { + success: false, + error: 'CRITICAL: Private key does not match platform wallet address!', + }; + } + + // Convert amount to chain units (12 decimals) + const amountBN = BigInt(Math.floor(amount * Math.pow(10, DECIMALS))); + + console.log(`Sending ${amount} ${token}: ${hotWallet.address} → ${toAddress}`); + + let tx; + if (token === 'HEZ') { + tx = api.tx.balances.transferKeepAlive(toAddress, amountBN); + } else if (token === 'PEZ') { + tx = api.tx.assets.transfer(PEZ_ASSET_ID, toAddress, amountBN); + } else { + return { success: false, error: 'Invalid token' }; + } + + // Fetch nonce + const accountInfo = await api.query.system.account(hotWallet.address); + const nonce = accountInfo.nonce; + + // Sign and send transaction + return new Promise((resolve) => { + let txHash: string; + + tx.signAndSend(hotWallet, { nonce }, (result: { txHash: { toHex: () => string }; status: { isInBlock: boolean; asInBlock: { toHex: () => string }; isFinalized: boolean }; dispatchError: { isModule: boolean; asModule: unknown; toString: () => string } | undefined; isError: boolean }) => { + txHash = result.txHash.toHex(); + + if (result.status.isInBlock) { + console.log(`TX in block: ${result.status.asInBlock.toHex()}`); + } + + if (result.status.isFinalized) { + const dispatchError = result.dispatchError; + + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + resolve({ + success: false, + txHash, + error: `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`, + }); + } else { + resolve({ + success: false, + txHash, + error: dispatchError.toString(), + }); + } + } else { + resolve({ success: true, txHash }); + } + } + + if (result.isError) { + resolve({ + success: false, + txHash, + error: 'Transaction failed', + }); + } + }).catch((error: Error) => { + resolve({ success: false, error: error.message }); + }); + + // Timeout after 60 seconds + setTimeout(() => { + resolve({ + success: false, + txHash, + error: 'Transaction timeout - please check explorer for status', + }); + }, 60000); + }); + } catch (error) { + console.error('Send tokens error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + serve(async (req) => { const origin = req.headers.get('origin'); const corsHeaders = getCorsHeaders(origin); @@ -157,7 +286,7 @@ serve(async (req) => { const { data: { users: existingUsers }, } = await serviceClient.auth.admin.listUsers({ perPage: 1000 }); - const authUser = existingUsers?.find((u) => u.email === telegramEmail); + const authUser = existingUsers?.find((u: { email?: string }) => u.email === telegramEmail); if (!authUser) { return new Response( @@ -262,20 +391,118 @@ serve(async (req) => { ); } + const requestId = requestResult.request_id; + console.log( - `Withdraw request created: TelegramID=${telegramId}, Amount=${amount} ${token}, Fee=${fee}, Net=${netAmount}, Wallet=${walletAddress}` + `Withdraw request created: ID=${requestId}, TelegramID=${telegramId}, Amount=${amount} ${token}, Fee=${fee}, Net=${netAmount}, Wallet=${walletAddress}` ); + // ==================== PROCESS BLOCKCHAIN TX ==================== + + // Get hot wallet private key + const hotWalletPrivateKey = Deno.env.get('PLATFORM_PRIVATE_KEY'); + + if (!hotWalletPrivateKey) { + // No private key configured — leave as pending for admin to process + console.warn('PLATFORM_PRIVATE_KEY not configured. Withdrawal left as pending.'); + return new Response( + JSON.stringify({ + success: true, + requestId, + amount, + fee, + netAmount, + token, + status: 'pending', + message: 'Withdrawal request created. Will be processed by admin.', + }), + { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Update request status to processing + await serviceClient + .from('p2p_deposit_withdraw_requests') + .update({ status: 'processing' }) + .eq('id', requestId); + + console.log(`Processing withdrawal: ${netAmount} ${token} to ${walletAddress}`); + + // Connect to blockchain and send tokens + const api = await getApi(); + + const sendResult = await sendTokens(api, hotWalletPrivateKey, walletAddress, token, netAmount); + + if (!sendResult.success) { + // TX failed — refund locked balance back to available + console.error(`Withdrawal TX failed: ${sendResult.error}`); + + await serviceClient.rpc('refund_escrow_internal', { + p_user_id: userId, + p_token: token, + p_amount: amount, + p_reference_type: 'withdraw_failed', + p_reference_id: requestId, + }); + + await serviceClient + .from('p2p_deposit_withdraw_requests') + .update({ + status: 'failed', + error_message: sendResult.error, + processed_at: new Date().toISOString(), + }) + .eq('id', requestId); + + return new Response( + JSON.stringify({ + success: false, + error: sendResult.error || 'Blockchain transaction failed', + txHash: sendResult.txHash, + }), + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // TX success — complete the withdrawal in DB + const { error: completeError } = await serviceClient.rpc('complete_withdraw', { + p_user_id: userId, + p_token: token, + p_amount: amount, + p_tx_hash: sendResult.txHash, + p_request_id: requestId, + }); + + if (completeError) { + console.error('Failed to complete withdrawal in DB:', completeError); + // TX was sent but DB update failed — log for manual reconciliation + } + + // Update request record with TX details + await serviceClient + .from('p2p_deposit_withdraw_requests') + .update({ + status: 'completed', + blockchain_tx_hash: sendResult.txHash, + fee_amount: fee, + net_amount: netAmount, + processed_at: new Date().toISOString(), + }) + .eq('id', requestId); + + console.log(`Withdrawal successful: ${sendResult.txHash}`); + return new Response( JSON.stringify({ success: true, - requestId: requestResult.request_id, - amount, + txHash: sendResult.txHash, + requestId, + amount: netAmount, fee, - netAmount, token, - status: 'pending', - message: 'Withdrawal request created. Processing will begin shortly.', + walletAddress, + status: 'completed', + message: 'Withdrawal processed successfully', }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); @@ -283,7 +510,10 @@ serve(async (req) => { console.error('Edge function error:', error); const origin = req.headers.get('origin'); return new Response( - JSON.stringify({ success: false, error: 'Internal server error' }), + JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + }), { status: 500, headers: { ...getCorsHeaders(origin), 'Content-Type': 'application/json' } } ); }