// verify-deposit Edge Function // OKX-level security: Verifies blockchain transactions before crediting balances // Uses @pezkuwi/api for blockchain verification import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' import { createClient } from 'npm:@supabase/supabase-js@2' import { ApiPromise, WsProvider } from 'npm:@pezkuwi/api@16.5.11' // Allowed origins for CORS const ALLOWED_ORIGINS = [ 'https://app.pezkuwichain.io', 'https://www.pezkuwichain.io', 'https://pezkuwichain.io', ] function getCorsHeaders(origin: string | null) { const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0] return { 'Access-Control-Allow-Origin': allowedOrigin, 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Credentials': 'true', } } // Platform hot wallet address (PRODUCTION) - Treasury_3 const PLATFORM_WALLET = '5H18ZZBU4LwPYbeEZ1JBGvibCU2edhhM8HNUtFi7GgC36CgS' // RPC endpoint for PezkuwiChain const RPC_ENDPOINT = 'wss://rpc.pezkuwichain.io' // Token decimals const DECIMALS = 12 // PEZ asset ID const PEZ_ASSET_ID = 1 interface DepositRequest { txHash: string token: 'HEZ' | 'PEZ' expectedAmount: number } // 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 } // Verify transaction on blockchain using @pezkuwi/api async function verifyTransactionOnChain( txHash: string, token: string, expectedAmount: number ): Promise<{ valid: boolean; actualAmount?: number; from?: string; error?: string }> { try { // Validate transaction hash format (0x + 64 hex chars) if (!txHash.match(/^0x[a-fA-F0-9]{64}$/)) { return { valid: false, error: 'Invalid transaction hash format' } } const api = await getApi() // Get block hash from extrinsic hash // In Substrate, we need to find which block contains this extrinsic // Method 1: Query recent blocks to find the extrinsic const latestHeader = await api.rpc.chain.getHeader() const latestBlockNumber = latestHeader.number.toNumber() // Search last 100 blocks for the transaction const searchDepth = 100 let foundBlock = null let foundExtrinsicIndex = -1 for (let i = 0; i < searchDepth; i++) { const blockNumber = latestBlockNumber - i if (blockNumber < 0) break const blockHash = await api.rpc.chain.getBlockHash(blockNumber) const signedBlock = await api.rpc.chain.getBlock(blockHash) // Check each extrinsic in the block for (let j = 0; j < signedBlock.block.extrinsics.length; j++) { const ext = signedBlock.block.extrinsics[j] const extHash = ext.hash.toHex() if (extHash === txHash) { foundBlock = { hash: blockHash, number: blockNumber, block: signedBlock } foundExtrinsicIndex = j break } } if (foundBlock) break } if (!foundBlock) { return { valid: false, error: 'Transaction not found in recent blocks. It may be too old or not yet finalized.' } } // Get events for this block const apiAt = await api.at(foundBlock.hash) const events = await apiAt.query.system.events() // Find transfer events for our extrinsic const extrinsicEvents = events.filter((event) => { const { phase } = event return phase.isApplyExtrinsic && phase.asApplyExtrinsic.toNumber() === foundExtrinsicIndex }) // Check for success const successEvent = extrinsicEvents.find((event) => api.events.system.ExtrinsicSuccess.is(event.event) ) if (!successEvent) { const failedEvent = extrinsicEvents.find((event) => api.events.system.ExtrinsicFailed.is(event.event) ) if (failedEvent) { return { valid: false, error: 'Transaction failed on-chain' } } return { valid: false, error: 'Transaction status unknown' } } // Find transfer event let transferEvent = null let from = '' let to = '' let amount = BigInt(0) if (token === 'HEZ') { // Native token transfer (balances.Transfer) transferEvent = extrinsicEvents.find((event) => api.events.balances.Transfer.is(event.event) ) if (transferEvent) { const [fromAddr, toAddr, value] = transferEvent.event.data from = fromAddr.toString() to = toAddr.toString() amount = BigInt(value.toString()) } } else if (token === 'PEZ') { // Asset transfer (assets.Transferred) transferEvent = extrinsicEvents.find((event) => api.events.assets.Transferred.is(event.event) ) if (transferEvent) { const [assetId, fromAddr, toAddr, value] = transferEvent.event.data // Verify it's the correct asset if (assetId.toNumber() !== PEZ_ASSET_ID) { return { valid: false, error: 'Wrong asset transferred' } } from = fromAddr.toString() to = toAddr.toString() amount = BigInt(value.toString()) } } if (!transferEvent) { return { valid: false, error: 'No transfer event found in transaction' } } // Verify recipient is platform wallet if (to !== PLATFORM_WALLET) { return { valid: false, error: `Transaction recipient (${to}) does not match platform wallet` } } // Convert amount to human readable const actualAmount = Number(amount) / Math.pow(10, DECIMALS) // Verify amount matches (allow 0.1% tolerance) const tolerance = expectedAmount * 0.001 if (Math.abs(actualAmount - expectedAmount) > tolerance) { return { valid: false, error: `Amount mismatch. Expected: ${expectedAmount}, Actual: ${actualAmount}`, actualAmount } } // Check if block is finalized const finalizedHash = await api.rpc.chain.getFinalizedHead() const finalizedHeader = await api.rpc.chain.getHeader(finalizedHash) const finalizedNumber = finalizedHeader.number.toNumber() if (foundBlock.number > finalizedNumber) { return { valid: false, error: 'Transaction not yet finalized. Please wait a few more blocks.' } } return { valid: true, actualAmount, from } } catch (error) { console.error('Blockchain verification error:', error) return { valid: false, error: `Verification failed: ${error.message}` } } } serve(async (req) => { const corsHeaders = getCorsHeaders(req.headers.get('Origin')) // Handle CORS preflight if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }) } try { // Get authorization header const authHeader = req.headers.get('Authorization') if (!authHeader) { return new Response( JSON.stringify({ success: false, error: 'Missing authorization header' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // Create Supabase clients const supabaseUrl = Deno.env.get('SUPABASE_URL')! const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')! const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! // User client (to get user ID) const userClient = createClient(supabaseUrl, supabaseAnonKey, { global: { headers: { Authorization: authHeader } } }) // Service role client (to process deposit) const serviceClient = createClient(supabaseUrl, supabaseServiceKey) // Get current user const { data: { user }, error: userError } = await userClient.auth.getUser() if (userError || !user) { return new Response( JSON.stringify({ success: false, error: 'Unauthorized' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // Parse request body const body: DepositRequest = await req.json() const { txHash, token, expectedAmount } = body // Validate input if (!txHash || !token || !expectedAmount) { return new Response( JSON.stringify({ success: false, error: 'Missing required fields: txHash, token, expectedAmount' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } if (!['HEZ', 'PEZ'].includes(token)) { return new Response( JSON.stringify({ success: false, error: 'Invalid token. Must be HEZ or PEZ' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } if (expectedAmount <= 0) { return new Response( JSON.stringify({ success: false, error: 'Amount must be greater than 0' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // Check if TX hash already processed const { data: existingRequest } = await serviceClient .from('p2p_deposit_withdraw_requests') .select('id, status') .eq('blockchain_tx_hash', txHash) .single() if (existingRequest) { if (existingRequest.status === 'completed') { return new Response( JSON.stringify({ success: false, error: 'This transaction has already been processed', existingRequestId: existingRequest.id }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } } // Create or update deposit request (processing status) const { data: depositRequest, error: requestError } = await serviceClient .from('p2p_deposit_withdraw_requests') .upsert({ user_id: user.id, request_type: 'deposit', token, amount: expectedAmount, wallet_address: PLATFORM_WALLET, blockchain_tx_hash: txHash, status: 'processing' }, { onConflict: 'blockchain_tx_hash' }) .select() .single() if (requestError) { console.error('Failed to create deposit request:', requestError) return new Response( JSON.stringify({ success: false, error: 'Failed to create deposit request' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // Verify transaction on blockchain console.log(`Verifying deposit: TX=${txHash}, Token=${token}, Amount=${expectedAmount}`) const verification = await verifyTransactionOnChain(txHash, token, expectedAmount) if (!verification.valid) { // Update request status to failed await serviceClient .from('p2p_deposit_withdraw_requests') .update({ status: 'failed', error_message: verification.error, processed_at: new Date().toISOString() }) .eq('id', depositRequest.id) return new Response( JSON.stringify({ success: false, error: verification.error || 'Transaction verification failed' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // Transaction verified! Process deposit using service role const { data: processResult, error: processError } = await serviceClient .rpc('process_deposit', { p_user_id: user.id, p_token: token, p_amount: verification.actualAmount || expectedAmount, p_tx_hash: txHash, p_request_id: depositRequest.id }) if (processError) { console.error('Failed to process deposit:', processError) await serviceClient .from('p2p_deposit_withdraw_requests') .update({ status: 'failed', error_message: processError.message, processed_at: new Date().toISOString() }) .eq('id', depositRequest.id) return new Response( JSON.stringify({ success: false, error: 'Failed to process deposit' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } if (!processResult?.success) { return new Response( JSON.stringify({ success: false, error: processResult?.error || 'Deposit processing failed' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // Success! console.log(`Deposit successful: User=${user.id}, Amount=${verification.actualAmount || expectedAmount} ${token}`) return new Response( JSON.stringify({ success: true, amount: verification.actualAmount || expectedAmount, token, newBalance: processResult.new_balance, txHash, message: 'Deposit verified and credited successfully' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } catch (error) { console.error('Edge function error:', error) return new Response( JSON.stringify({ success: false, error: 'Internal server error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } })