|
| 1 | +import type { VercelRequest, VercelResponse } from '@vercel/node'; |
| 2 | +import { supabaseAdmin } from '../_lib/supabase'; |
| 3 | +import { captureApiError } from '../_lib/sentry'; |
| 4 | + |
| 5 | +const PROCESSED_IDS = new Set<string>(); |
| 6 | + |
| 7 | +interface GatewayWebhookEvent { |
| 8 | + id: string; |
| 9 | + type: string; |
| 10 | + timestamp: string; |
| 11 | + data: { |
| 12 | + sourceChain?: string; |
| 13 | + sourceDomain?: number; |
| 14 | + destinationChain?: string; |
| 15 | + destinationDomain?: number; |
| 16 | + sender?: string; |
| 17 | + recipient?: string; |
| 18 | + amount?: string; |
| 19 | + token?: string; |
| 20 | + sourceTxHash?: string; |
| 21 | + destinationTxHash?: string; |
| 22 | + status?: string; |
| 23 | + }; |
| 24 | +} |
| 25 | + |
| 26 | +export default async function handler(req: VercelRequest, res: VercelResponse) { |
| 27 | + if (req.method !== 'POST') { |
| 28 | + return res.status(405).json({ error: 'Method not allowed' }); |
| 29 | + } |
| 30 | + |
| 31 | + try { |
| 32 | + const event = req.body as GatewayWebhookEvent; |
| 33 | + |
| 34 | + if (!event?.id || !event?.type) { |
| 35 | + return res.status(400).json({ error: 'Invalid webhook payload' }); |
| 36 | + } |
| 37 | + |
| 38 | + if (PROCESSED_IDS.has(event.id)) { |
| 39 | + return res.status(200).json({ ok: true, deduplicated: true }); |
| 40 | + } |
| 41 | + PROCESSED_IDS.add(event.id); |
| 42 | + |
| 43 | + // Keep set bounded |
| 44 | + if (PROCESSED_IDS.size > 10_000) { |
| 45 | + const first = PROCESSED_IDS.values().next().value; |
| 46 | + if (first) PROCESSED_IDS.delete(first); |
| 47 | + } |
| 48 | + |
| 49 | + console.log(`[Gateway Webhook] ${event.type} id=${event.id}`); |
| 50 | + |
| 51 | + switch (event.type) { |
| 52 | + case 'gateway.deposit.finalized': |
| 53 | + await handleDepositFinalized(event); |
| 54 | + break; |
| 55 | + case 'gateway.mint.finalized': |
| 56 | + await handleMintFinalized(event); |
| 57 | + break; |
| 58 | + case 'gateway.mint.forwarded': |
| 59 | + await handleMintForwarded(event); |
| 60 | + break; |
| 61 | + default: |
| 62 | + console.log(`[Gateway Webhook] Unknown event type: ${event.type}`); |
| 63 | + } |
| 64 | + |
| 65 | + return res.status(200).json({ ok: true }); |
| 66 | + } catch (error) { |
| 67 | + captureApiError(error, 'gateway-webhook'); |
| 68 | + console.error('[Gateway Webhook] Error:', error); |
| 69 | + return res.status(500).json({ error: 'Internal server error' }); |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +async function handleDepositFinalized(event: GatewayWebhookEvent) { |
| 74 | + if (!supabaseAdmin) return; |
| 75 | + |
| 76 | + const { sender, amount, sourceTxHash, sourceDomain } = event.data; |
| 77 | + if (!sourceTxHash || !sender) return; |
| 78 | + |
| 79 | + const direction = sourceDomain === 0 ? 'to_arc' : 'to_sepolia'; |
| 80 | + |
| 81 | + await supabaseAdmin.from('site_bridges').upsert({ |
| 82 | + tx_hash: sourceTxHash, |
| 83 | + wallet_address: sender.toLowerCase(), |
| 84 | + amount_usd: amount ? parseFloat(amount) : 0, |
| 85 | + direction, |
| 86 | + status: 'deposit_finalized', |
| 87 | + updated_at: new Date().toISOString(), |
| 88 | + }, { onConflict: 'tx_hash' }); |
| 89 | + |
| 90 | + console.log(`[Gateway Webhook] Deposit finalized: ${sourceTxHash} from ${sender}`); |
| 91 | +} |
| 92 | + |
| 93 | +async function handleMintFinalized(event: GatewayWebhookEvent) { |
| 94 | + if (!supabaseAdmin) return; |
| 95 | + |
| 96 | + const { recipient, amount, destinationTxHash, sourceTxHash, destinationDomain } = event.data; |
| 97 | + if (!recipient) return; |
| 98 | + |
| 99 | + const txHash = sourceTxHash || destinationTxHash; |
| 100 | + if (!txHash) return; |
| 101 | + |
| 102 | + // Update site_bridges with mint completion |
| 103 | + const { error: bridgeError } = await supabaseAdmin |
| 104 | + .from('site_bridges') |
| 105 | + .update({ |
| 106 | + status: 'complete', |
| 107 | + mint_tx_hash: destinationTxHash, |
| 108 | + updated_at: new Date().toISOString(), |
| 109 | + }) |
| 110 | + .eq('wallet_address', recipient.toLowerCase()) |
| 111 | + .eq('status', 'deposit_finalized') |
| 112 | + .order('created_at', { ascending: false }) |
| 113 | + .limit(1); |
| 114 | + |
| 115 | + if (bridgeError) { |
| 116 | + console.error('[Gateway Webhook] site_bridges update error:', bridgeError.message); |
| 117 | + } |
| 118 | + |
| 119 | + // Update circle_transactions if there's a matching pending bridge tx |
| 120 | + const { data: pendingTxs } = await supabaseAdmin |
| 121 | + .from('circle_transactions') |
| 122 | + .select('circle_tx_id') |
| 123 | + .eq('wallet_address', recipient.toLowerCase()) |
| 124 | + .eq('tx_type', 'bridge-burn') |
| 125 | + .in('status', ['PENDING', 'SENT', 'CONFIRMED']) |
| 126 | + .order('created_at', { ascending: false }) |
| 127 | + .limit(1); |
| 128 | + |
| 129 | + if (pendingTxs?.[0]) { |
| 130 | + await supabaseAdmin |
| 131 | + .from('circle_transactions') |
| 132 | + .update({ |
| 133 | + status: 'COMPLETE', |
| 134 | + tx_hash: destinationTxHash || undefined, |
| 135 | + updated_at: new Date().toISOString(), |
| 136 | + }) |
| 137 | + .eq('circle_tx_id', pendingTxs[0].circle_tx_id); |
| 138 | + } |
| 139 | + |
| 140 | + console.log(`[Gateway Webhook] Mint finalized: ${destinationTxHash} to ${recipient}`); |
| 141 | +} |
| 142 | + |
| 143 | +async function handleMintForwarded(event: GatewayWebhookEvent) { |
| 144 | + console.log(`[Gateway Webhook] Mint forwarded: ${event.data.destinationTxHash}`); |
| 145 | +} |
0 commit comments