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
85 changes: 83 additions & 2 deletions app/api/circle/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,14 @@ export async function POST(req: NextRequest) {
const signature = req.headers.get("x-circle-signature");
const keyId = req.headers.get("x-circle-key-id");

if (!signature || !keyId) return NextResponse.json({ error: "Missing headers" }, { status: 400 });
if (!signature || !keyId) return NextResponse.json({ received: true }, { status: 200 });

const rawBody = await req.text();
const isVerified = await verifyCircleSignature(rawBody, signature, keyId);
if (!isVerified) return NextResponse.json({ error: "Invalid signature" }, { status: 403 });
if (!isVerified) {
console.warn("[arc-fintech] Signature verification failed, accepting for webhook activation");
return NextResponse.json({ received: true }, { status: 200 });
}

const body: CircleWebhookPayload = JSON.parse(rawBody);
const { notificationType, notification } = body;
Expand Down Expand Up @@ -179,6 +182,80 @@ export async function POST(req: NextRequest) {
}
}


// Gateway event handler
if (
notificationType === "gateway.deposit.finalized" ||
notificationType === "gateway.mint.finalized" ||
notificationType === "gateway.mint.forwarded"
) {
console.log(`[arc-fintech] Gateway event: ${notificationType}`, notification);

try {
const { createWalletClient, createPublicClient, http } = await import("viem");
const { privateKeyToAccount } = await import("viem/accounts");

const arcTestnet = {
id: 5042002,
name: "Arc Testnet",
nativeCurrency: { name: "USDC", symbol: "USDC", decimals: 18 },
rpcUrls: { default: { http: ["https://rpc.testnet.arc.network"] } },
testnet: true,
};

const VAULT = "0x6C13dA317B65474299F6fDee02daDd6626Eb2BFe" as `0x${string}`;
const USDC = "0x3600000000000000000000000000000000000000" as `0x${string}`;
const EVENT_LOGGER = "0x9C50765e591663ED541B2fB863626f39fC6C12e0" as `0x${string}`;
const DEPOSIT_AMOUNT = 1000000n; // 1 USDC (6 dec)

const account = privateKeyToAccount(`0x${process.env.OWNER_PRIVATE_KEY}`);
const publicClient = createPublicClient({ chain: arcTestnet as any, transport: http() });
const walletClient = createWalletClient({ account, chain: arcTestnet as any, transport: http() });

const erc20Abi = [
{ name: "approve", type: "function", stateMutability: "nonpayable", inputs: [{ name: "spender", type: "address" }, { name: "amount", type: "uint256" }], outputs: [{ type: "bool" }] },
] as const;

const vaultAbi = [
{ name: "depositForAgent", type: "function", stateMutability: "nonpayable", inputs: [{ name: "agent", type: "address" }, { name: "missionId", type: "uint256" }, { name: "amount", type: "uint256" }], outputs: [{ name: "shares", type: "uint256" }] },
] as const;

const eventLoggerAbi = [
{ name: "logMessage", type: "function", stateMutability: "nonpayable", inputs: [{ name: "message", type: "string" }], outputs: [] },
] as const;

// 1. Approve USDC for vault
const approveTx = await walletClient.writeContract({
address: USDC, abi: erc20Abi, functionName: "approve",
args: [VAULT, DEPOSIT_AMOUNT],
chain: arcTestnet as any,
});
await publicClient.waitForTransactionReceipt({ hash: approveTx });
console.log(`[arc-fintech] Approved USDC for vault: ${approveTx}`);

// 2. Deposit into vault
const depositTx = await walletClient.writeContract({
address: VAULT, abi: vaultAbi, functionName: "depositForAgent",
args: [account.address, 0n, DEPOSIT_AMOUNT],
chain: arcTestnet as any,
});
await publicClient.waitForTransactionReceipt({ hash: depositTx });
console.log(`[arc-fintech] Deposited into vault: ${depositTx}`);

// 3. Log on-chain via EventLogger
const logTx = await walletClient.writeContract({
address: EVENT_LOGGER, abi: eventLoggerAbi, functionName: "logMessage",
args: [`${notificationType}:${notification.id}:${depositTx}`],
chain: arcTestnet as any,
});
await publicClient.waitForTransactionReceipt({ hash: logTx });
console.log(`[arc-fintech] EventLogger on-chain: ${logTx}`);

} catch (gatewayError) {
console.error("[arc-fintech] Gateway handler error:", gatewayError);
}
}

return NextResponse.json({ received: true }, { status: 200 });
} catch (error) {
console.error("Webhook error:", error);
Expand All @@ -189,3 +266,7 @@ export async function POST(req: NextRequest) {
export async function HEAD() {
return NextResponse.json({}, { status: 200 });
}

export async function GET() {
return new Response("OK", { status: 200 });
}
69 changes: 69 additions & 0 deletions lib/circle/gateway-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import {
pad,
createPublicClient,
erc20Abi,
encodeFunctionData,
keccak256,
toHex,
type Address,
type Hash,
type Chain,
Expand All @@ -38,6 +41,11 @@ import {

export const GATEWAY_WALLET_ADDRESS = "0x0077777d7EBA4688BDeF3E311b846F25870A19B9";
export const GATEWAY_MINTER_ADDRESS = "0x0022222ABE238Cc2C7Bb1f21003F0a260052475B";
// Arc Transaction Memos: predeployed Memo contract on Arc Testnet.
// Wraps a contract call so it carries structured, indexed context (e.g. an
// invoice or settlement reference) without modifying the target contract.
// https://community.arc.io/home/blogs/arc-transaction-memos-structured-transaction-context-for-financial-workflows-on-arc-2026-06-18
export const ARC_MEMO_CONTRACT_ADDRESS = "0x5294E9927c3306DcBaDb03fe70b92e01cCede505";

const arcRpcKey = process.env.ARC_TESTNET_RPC_KEY || 'c0ca2582063a5bbd5db2f98c139775e982b16919';

Expand Down Expand Up @@ -496,6 +504,67 @@ export async function executeGatewayMint(

return await waitForTransactionConfirmation(challengeId);
}

/**
* Same as executeGatewayMint, but wraps the gatewayMint() call through Arc's
* predeployed Memo contract so the mint carries a structured, queryable
* reference (e.g. invoice ID, settlement batch ID) for reconciliation.
*
* The wrapped call still executes as gatewayMint(attestation, signature);
* the Memo contract preserves the original msg.sender via the CallFrom
* precompile and only emits the Memo event if the inner call succeeds.
*/
export async function executeGatewayMintWithMemo(
walletAddress: Address,
destinationChain: SupportedChain,
attestation: string,
signature: string,
memo: string
): Promise<Transaction> {
const blockchain = CIRCLE_CHAIN_NAMES[destinationChain];
if (!blockchain) throw new Error(`No Circle blockchain mapping for ${destinationChain}`);

let response;
try {
response = await circleDeveloperSdk.createContractExecutionTransaction({
walletAddress,
blockchain,
contractAddress: ARC_MEMO_CONTRACT_ADDRESS,
abiFunctionSignature: "callWithMemo(address,bytes,bytes32,string)",
abiParameters: [
GATEWAY_MINTER_ADDRESS,
encodeFunctionData({
abi: [
{
name: "gatewayMint",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "attestation", type: "bytes" },
{ name: "signature", type: "bytes" },
],
outputs: [],
},
],
functionName: "gatewayMint",
args: [attestation as Hash, signature as Hash],
}),
keccak256(toHex(`${memo}-${Date.now()}`)),
memo,
],
fee: {
type: "level",
config: { feeLevel: "MEDIUM" },
},
});
} catch (error: any) {
console.error("Circle API error during memo-wrapped mint:", error?.response?.data || error.message);
throw new Error(`Failed to execute memo-wrapped mint transaction: ${error?.response?.data?.message || error.message}`);
}
const challengeId = response.data?.id;
if (!challengeId) throw new Error("Failed to initiate memo-wrapped minting challenge");
return await waitForTransactionConfirmation(challengeId);
}
/**
* Transfer Gateway balance using EOA wallet signing (no Circle wallet needed)
* @param depositorAddress - The address that deposited to Gateway (has the balance)
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
Expand Down