Skip to content

Commit b9d9c68

Browse files
committed
feat: add Circle Gateway webhook endpoints for bridge notifications
- api/webhooks/gateway.ts — receives deposit.finalized, mint.finalized, mint.forwarded events - api/webhooks/subscribe.ts — manages webhook subscriptions via Circle API - Updates site_bridges and circle_transactions on bridge completion
1 parent 0a4b684 commit b9d9c68

2 files changed

Lines changed: 196 additions & 0 deletions

File tree

api/webhooks/gateway.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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+
}

api/webhooks/subscribe.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { VercelRequest, VercelResponse } from '@vercel/node';
2+
import { handleCors } from '../_lib/cors';
3+
4+
const GATEWAY_NOTIFICATIONS_API = 'https://api.circle.com/v2/notifications/subscriptions/permissionless';
5+
6+
export default async function handler(req: VercelRequest, res: VercelResponse) {
7+
if (handleCors(req, res)) return;
8+
9+
const apiKey = process.env.CircleAPI?.trim();
10+
if (!apiKey) {
11+
return res.status(500).json({ error: 'CircleAPI not configured' });
12+
}
13+
14+
const { action } = req.query;
15+
16+
try {
17+
if (req.method === 'GET' || action === 'list') {
18+
const response = await fetch(GATEWAY_NOTIFICATIONS_API, {
19+
headers: { 'Authorization': `Bearer ${apiKey}` },
20+
});
21+
const data = await response.json();
22+
return res.status(200).json(data);
23+
}
24+
25+
if (req.method === 'POST' && action === 'create') {
26+
const webhookUrl = req.body?.webhookUrl;
27+
if (!webhookUrl) {
28+
return res.status(400).json({ error: 'webhookUrl required' });
29+
}
30+
31+
const response = await fetch(GATEWAY_NOTIFICATIONS_API, {
32+
method: 'POST',
33+
headers: {
34+
'Authorization': `Bearer ${apiKey}`,
35+
'Content-Type': 'application/json',
36+
},
37+
body: JSON.stringify({
38+
endpoint: webhookUrl,
39+
notificationTypes: ['gateway.*'],
40+
}),
41+
});
42+
const data = await response.json();
43+
return res.status(response.ok ? 201 : response.status).json(data);
44+
}
45+
46+
return res.status(400).json({ error: 'Use GET to list or POST with action=create' });
47+
} catch (error) {
48+
console.error('[Webhook Subscribe] Error:', error);
49+
return res.status(500).json({ error: 'Failed to manage subscription' });
50+
}
51+
}

0 commit comments

Comments
 (0)