Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ SUPABASE_SECRET_KEY=
CIRCLE_API_KEY=
# 32-byte hex (64 chars). Register once with Circle before use
CIRCLE_ENTITY_SECRET=
# Optional extra shared secret. When set, Circle webhook requests must include
# Authorization: Bearer <CIRCLE_WEBHOOK_SECRET> in addition to Circle signatures.
CIRCLE_WEBHOOK_SECRET=
# Circle blockchain identifier for Arc Testnet
CIRCLE_BLOCKCHAIN=ARC-TESTNET

Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,14 @@ cp .env.example .env.local
```

- `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` /
`SUPABASE_SECRET_KEY` - from `npm run db:status`.
`SUPABASE_SECRET_KEY` - from `npm run db:status`.
- `CIRCLE_API_KEY` - from the Circle console.
- `CIRCLE_ENTITY_SECRET` - your 32-byte hex entity secret. Must be registered
with Circle once before use.
with Circle once before use.
- `CIRCLE_WEBHOOK_SECRET` - optional extra shared secret for the Circle webhook
endpoint. When set, webhook requests must include
`Authorization: Bearer <CIRCLE_WEBHOOK_SECRET>` in addition to Circle's
`X-Circle-Signature` and `X-Circle-Key-Id` headers.
- `CIRCLE_BLOCKCHAIN` - Circle blockchain identifier (default `ARC-TESTNET`).
- `KIT_KEY` - Circle App Kit key.
- `NEXT_PUBLIC_ARC_CHAIN` - App Kit chain identifier (default `Arc_Testnet`).
Expand Down
60 changes: 60 additions & 0 deletions src/app/api/webhooks/circle/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@
*/

import { z } from "zod";
import crypto from "crypto";

import { getFxBalances } from "@/lib/circle/wallets";
import { serverEnv } from "@/lib/config";
import { createAdminClient } from "@/lib/supabase/admin";

const publicKeyCache = new Map<string, string>();

// Circle sends a HEAD request to verify the endpoint is reachable.
export function HEAD() {
return new Response(null, { status: 200 });
Expand Down Expand Up @@ -56,6 +59,17 @@ export async function POST(request: Request) {
return new Response("Bad Request", { status: 400 });
}

const signature = request.headers.get("x-circle-signature");
const keyId = request.headers.get("x-circle-key-id");
if (!signature || !keyId) {
return new Response("Missing Circle signature headers", { status: 400 });
}

const isVerified = await verifyCircleSignature(rawBody, signature, keyId, env.CIRCLE_API_KEY);
if (!isVerified) {
return new Response("Invalid Circle signature", { status: 403 });
}

let body: unknown;
try {
body = JSON.parse(rawBody);
Expand Down Expand Up @@ -136,3 +150,49 @@ async function handleInboundComplete(walletId?: string, destinationAddress?: str
console.error("[webhook/circle] balance update failed", err);
}
}

async function verifyCircleSignature(
body: string,
signature: string,
keyId: string,
apiKey: string,
): Promise<boolean> {
try {
const publicKey = await getCirclePublicKey(keyId, apiKey);
const verifier = crypto.createVerify("SHA256");
verifier.update(body, "utf8");
verifier.end();
return verifier.verify(publicKey, Buffer.from(signature, "base64"));
} catch (err) {
console.warn("[webhook/circle] signature verification failed", err);
return false;
}
}

async function getCirclePublicKey(keyId: string, apiKey: string): Promise<string> {
const cached = publicKeyCache.get(keyId);
if (cached) return cached;

const response = await fetch(`https://api.circle.com/v2/notifications/publicKey/${keyId}`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(`failed to fetch Circle public key: ${response.status}`);
}

const data: unknown = await response.json();
const publicKey = z
.object({ data: z.object({ publicKey: z.string().min(1) }) })
.parse(data).data.publicKey;
const pem = [
"-----BEGIN PUBLIC KEY-----",
...(publicKey.match(/.{1,64}/g) ?? []),
"-----END PUBLIC KEY-----",
].join("\n");

publicKeyCache.set(keyId, pem);
return pem;
}