// confirm-payment Edge Function // // Seller-authorized escrow RELEASE (the money-OUT path that was previously // callable by anyone holding the public anon key via rpc('release_escrow_internal')). // // Authorization chain: // 1. Seller signs a canonical challenge with their wallet. // 2. verifyWalletSignature (raw + ) proves control of `signerAddress`. // 3. assertIdentityOwnedByWallet proves the wallet owns the claimed seller // identity (citizen NFT / active visa). // 4. The derived seller user_id MUST equal the trade's seller_id, and the // trade MUST be in 'payment_sent'. // 5. Single-use nonce (replay protection). // 6. Only then release_escrow_internal(seller -> buyer) runs with the service // role, atomically with the trade status update. 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.36' import { identityToUUID, verifyWalletSignature, buildConfirmPaymentChallenge, assertIdentityOwnedByWallet, consumeNonce, isFreshTimestamp, } from '../_shared/identity-auth.ts' 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', } } const PEOPLE_RPC_ENDPOINT = Deno.env.get('PEOPLE_RPC_ENDPOINT') || 'wss://people-rpc.pezkuwichain.io' let peopleApiInstance: ApiPromise | null = null async function getPeopleApi(): Promise { if (peopleApiInstance && peopleApiInstance.isConnected) return peopleApiInstance peopleApiInstance = await ApiPromise.create({ provider: new WsProvider(PEOPLE_RPC_ENDPOINT) }) return peopleApiInstance } interface Body { tradeId?: string sellerIdentity?: string signerAddress?: string signature?: string timestamp?: number nonce?: string } serve(async (req) => { const corsHeaders = getCorsHeaders(req.headers.get('Origin')) const json = (status: number, obj: unknown) => new Response(JSON.stringify(obj), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders }) try { const serviceClient = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!) const { tradeId, sellerIdentity, signerAddress, signature, timestamp, nonce }: Body = await req.json() if (!tradeId || !sellerIdentity || !signerAddress || !signature || !nonce || typeof timestamp !== 'number') { return json(401, { success: false, error: 'Missing authorization fields' }) } if (!isFreshTimestamp(timestamp)) { return json(401, { success: false, error: 'Authorization challenge expired. Please retry.' }) } // 1) Verify signature const challenge = buildConfirmPaymentChallenge({ tradeId, sellerIdentity, signerAddress, timestamp, nonce }) if (!(await verifyWalletSignature(challenge, signature, signerAddress))) { return json(401, { success: false, error: 'Invalid signature' }) } // 2) Prove the signer owns the claimed seller identity const ownership = await assertIdentityOwnedByWallet(serviceClient, sellerIdentity, signerAddress, getPeopleApi) if (!ownership.ok) { return json(403, { success: false, error: ownership.error || 'Identity ownership verification failed' }) } const sellerUserId = await identityToUUID(sellerIdentity) // 3) Load trade and enforce it belongs to this seller and is releasable const { data: trade, error: tradeErr } = await serviceClient .from('p2p_fiat_trades') .select('id, seller_id, buyer_id, offer_id, crypto_amount, status') .eq('id', tradeId) .single() if (tradeErr || !trade) return json(404, { success: false, error: 'Trade not found' }) if (trade.seller_id !== sellerUserId) { return json(403, { success: false, error: 'Only the trade seller can release this escrow' }) } if (trade.status !== 'payment_sent') { return json(400, { success: false, error: `Trade is not awaiting confirmation (status: ${trade.status})` }) } // 4) Resolve token from offer const { data: offer } = await serviceClient .from('p2p_fiat_offers') .select('token') .eq('id', trade.offer_id) .single() if (!offer?.token) return json(400, { success: false, error: 'Offer/token not found' }) // 5) Consume nonce (single use) if (!(await consumeNonce(serviceClient, nonce, 'confirm_payment'))) { return json(401, { success: false, error: 'Authorization already used (replay detected)' }) } // 6) Atomically CLAIM the payment_sent -> completed transition BEFORE moving // any funds. This is the concurrency lock: only one caller can flip the // row out of 'payment_sent', so two concurrent (differently-nonced) release // requests for the same trade cannot both reach release_escrow_internal and // double-spend escrow that was locked for other trades on the same offer. const nowIso = new Date().toISOString() const { data: claimed, error: claimErr } = await serviceClient .from('p2p_fiat_trades') .update({ seller_confirmed_at: nowIso, escrow_released_at: nowIso, status: 'completed', completed_at: nowIso, }) .eq('id', tradeId) .eq('status', 'payment_sent') // compare-and-swap guard .select('id') if (claimErr) return json(500, { success: false, error: 'Failed to claim trade for release' }) if (!claimed || claimed.length === 0) { // Someone (or a concurrent request) already moved it out of payment_sent. return json(409, { success: false, error: 'Trade is no longer awaiting confirmation (already released?)' }) } // 7) Release escrow (service role). If it fails, revert the claim so the trade // can be retried / disputed rather than being stuck "completed" with no move. const { data: releaseRes, error: releaseErr } = await serviceClient.rpc('release_escrow_internal', { p_from_user_id: trade.seller_id, p_to_user_id: trade.buyer_id, p_token: offer.token, p_amount: trade.crypto_amount, p_reference_type: 'trade', p_reference_id: tradeId, }) const parsed = typeof releaseRes === 'string' ? JSON.parse(releaseRes) : releaseRes if (releaseErr || !parsed?.success) { await serviceClient .from('p2p_fiat_trades') .update({ status: 'payment_sent', seller_confirmed_at: null, escrow_released_at: null, completed_at: null }) .eq('id', tradeId) return json(releaseErr ? 500 : 400, { success: false, error: releaseErr?.message || parsed?.error || 'Release failed', }) } return json(200, { success: true, tradeId, sellerId: trade.seller_id, buyerId: trade.buyer_id, token: offer.token, amount: trade.crypto_amount, }) } catch (error) { console.error('confirm-payment error:', error) return json(500, { success: false, error: 'Internal server error' }) } })