Repository Public
1// Cross-chain USDC through Circle CCTP V2 (docs/swap/providers/cctp.md): burn on the source network, attestation by Circle,2// mint on the destination — always from the wallet to the wallet's own address.3// The API prepares, simulates, polls and relays. It cannot make the signer sign anything else than (1) the burn the owner4// approved with a passkey and (2) the one receiveMessage that matches that approved burn (packages/core/src/cctp.ts).5import { createHash, randomUUID } from 'node:crypto';6import type { FastifyInstance } from 'fastify';7import { generateAuthenticationOptions } from '@simplewebauthn/server';8import { decodeEventLog, encodeFunctionData, erc20Abi, hexToBigInt, hexToNumber, numberToHex } from 'viem';9import { z } from 'zod';10import {11 bridgeOf, buildCctpBurnTxs, buildCctpMintTx, CCTP_ABI, CCTP_ENABLED, CCTP_FINALITY_FAST, CCTP_FINALITY_STANDARD, CCTP_MESSAGE_TRANSMITTER, CCTP_MINTER_SHA256, CCTP_PROXY_SHA256,12 CCTP_TOKEN_MESSENGER, CCTP_TOKEN_MINTER, cctpBurnData, cctpMintRetryBytes, cctpUsdc, checkPolicy, EIP1967_IMPLEMENTATION_SLOT, formatUnits, getNetwork, IntentSchema, intentHash,13 parseUnits, toB64u, toHex, verifyCctpMint, type CctpMintGas, type Intent, type NetworkDef,14} from '@spw/core';15import { listCreds } from './auth.ts';16import { adapterFor } from './chains/index.ts';17import { http, rpcAny, rpcQuorum } from './chains/rpc.ts';18import { config, UserError } from './config.ts';19import { audit, sql } from './db.ts';20import { addressFor, signer } from './signer-client.ts';21import { broadcastNow, move, view, type ProposalRow } from './transfers.ts';22// v8 — Solana as source or destination (domain 5): everything Solana-specific lives in bridge-sol.ts23import { cctpEnd, type SolBudget } from '@spw/core';24import { advanceSolBurn, advanceSolMint, advanceSolReclaims, bridgeSolRoutes, cctpSolGuards, createSolBurnProposal, solBurnFacts, solDestination, solMintRetryOptions, solQuoteExtras, solStandardMinFee, type SolBridgeInfo } from './bridge-sol.ts';2526// TEST ONLY: a fork has no real attestation service. Never set in production (loud warning, like SPW_RPC_OVERRIDE).27const IRIS = process.env.SPW_IRIS_OVERRIDE ?? 'https://iris-api.circle.com';28if (process.env.SPW_IRIS_OVERRIDE) console.warn(`\n*** SPW_IRIS_OVERRIDE is set: CCTP attestations come from ${IRIS}, NOT from Circle. This must never happen in production. ***\n`);2930/** Gas units pinned above what real mainnet transactions used (dossier §4): burn 104–114k, mint 154–187k. */31const GAS = { approve: 70_000n, burnUnsimulated: 200_000n, mintPlan: 240_000n, mintCap: 450_000n };32const sha = (hex: string) => createHash('sha256').update(Buffer.from(hex.slice(2), 'hex')).digest('hex');33const eqAddr = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();34const now = () => Date.now();3536export type BridgePhase = 'burn' | 'attesting' | 'ready' | 'mint_sent' | 'mint_failed' | 'done';37export interface BridgeState {38 provider: 'cctp'; toNetwork: string; fromDomain: number; toDomain: number; speed: 'standard' | 'fast'; maxFee: string; minFinalityThreshold: number; expectedSeconds: number; needsApproval: boolean; simulated: boolean;39 destGasNeed: string; phase: BridgePhase;40 /** why the transfer is waiting, when it is (machine code + detail for the owner) */41 hold?: string | null; holdDetail?: string | null;42 polls: number; nextAt: number; attestingSince?: number; irisStatus?: string | null; delayReason?: string | null;43 message?: string; attestation?: string; nonce?: string; messageHash?: string; feeExecuted?: string; minted?: string; expirationBlock?: string;44 /** gas parameters are stored BEFORE the signer is asked, so that a crash in between repeats the identical request */45 mint?: { evm?: CctpMintGas; /** v8: a mint on Solana */ sol?: SolBudget; txid?: string; raw?: string; sentAt?: number; lastBroadcastAt?: number; signatures?: number };46 retry?: { evm?: CctpMintGas; sol?: SolBudget; expiresAt: number };47 /** v8: Solana side of a Solana ↔ EVM transfer (event-account deposit and its reclaim; destination token account) */48 sol?: SolBridgeInfo;49 mintedBy?: 'wallet' | 'other'; mintTxid?: string | null; doneAt?: number;50 /** hash of a Fast message whose attestation expired: Circle's answer is ignored until it carries a new one */51 expiredHash?: string;52}5354// ---------------------------------------------------------------------------------------------------------------------55// Chain guards5657/** Pinned proxies, pinned implementations behind them, pinned minter — on every RPC source — and a wallet address without code. */58export async function cctpGuards(n: NetworkDef, me: string): Promise<void> {59 const pin = CCTP_ENABLED[n.id];60 if (!pin) throw new UserError('bridge_not_enabled', `CCTP transfers are not enabled on ${n.name}`, 422);61 const q = <T>(m: string, p: unknown[]) => rpcQuorum<T>(n.rpc, m, p);62 const changed = (what: string) => new UserError('contract_changed', `${what} on ${n.name} is not the reviewed one (Circle may have upgraded it); CCTP transfers are refused until it is reviewed again`, 503);63 for (const [name, proxy, impl] of [['TokenMessengerV2', CCTP_TOKEN_MESSENGER, pin.messengerImpl], ['MessageTransmitterV2', CCTP_MESSAGE_TRANSMITTER, pin.transmitterImpl]] as const) {64 if (sha(await q<string>('eth_getCode', [proxy, 'latest'])) !== CCTP_PROXY_SHA256) throw changed(`the ${name} proxy`);65 const slot = await q<string>('eth_getStorageAt', [proxy, EIP1967_IMPLEMENTATION_SLOT, 'latest']);66 if (!/^0x0{24}[0-9a-fA-F]{40}$/.test(slot) || !eqAddr('0x' + slot.slice(26), impl.address)) throw changed(`the ${name} implementation address`);67 if (sha(await q<string>('eth_getCode', [impl.address, 'latest'])) !== impl.codeSha256) throw changed(`the ${name} implementation`);68 }69 if (sha(await q<string>('eth_getCode', [CCTP_TOKEN_MINTER, 'latest'])) !== CCTP_MINTER_SHA256) throw changed('the TokenMinterV2');70 // an EIP-7702 delegation on the wallet address would run someone's code on every coin received71 if ((await q<string>('eth_getCode', [me, 'latest'])) !== '0x') throw new UserError('account_has_code', 'this wallet address carries code (delegation); transfers are refused', 503);72}7374async function gasPrice(n: NetworkDef): Promise<{ baseFee: bigint; tip: bigint; maxFeePerGas: bigint }> {75 const block = await rpcAny<{ baseFeePerGas?: `0x${string}` }>(n.rpc, 'eth_getBlockByNumber', ['latest', false]);76 if (!block.baseFeePerGas) throw new UserError('rpc_degraded', 'network did not report a base fee', 503);77 const baseFee = hexToBigInt(block.baseFeePerGas);78 let tip = 1_000_000n; try { tip = hexToBigInt(await rpcAny<`0x${string}`>(n.rpc, 'eth_maxPriorityFeePerGas', [])); } catch { /* floor */ }79 if (n.id === 'polygon' && tip < 30_000_000_000n) tip = 30_000_000_000n; // Polygon PoS enforces a 30 gwei minimum tip80 return { baseFee, tip, maxFeePerGas: baseFee * 2n + tip };81}82const nativeBalance = async (n: NetworkDef, a: string) => hexToBigInt(await rpcAny<`0x${string}`>(n.rpc, 'eth_getBalance', [a, 'latest']));8384// ---------------------------------------------------------------------------------------------------------------------85// Circle's attestation service (Iris): public, keyless, read-only for us except the re-attestation request of an expired Fast message.8687const FeeResp = z.array(z.object({ finalityThreshold: z.number().int(), minimumFee: z.number().min(0).max(100) }).passthrough()).min(1).max(8);88const MsgResp = z.object({ messages: z.array(z.object({ status: z.string().max(60), message: z.string().max(4000).nullish(), attestation: z.string().max(2000).nullish(), delayReason: z.string().max(200).nullish(), cctpVersion: z.number().nullish() }).passthrough()).max(20) }).passthrough();8990export async function iris(path: string, init?: RequestInit): Promise<{ status: number; json: unknown }> {91 let r: Response;92 try { r = await http(IRIS + path, init); } catch { throw new UserError('provider_unavailable', 'Circle\'s attestation service did not answer', 503); }93 return { status: r.status, json: await r.json().catch(() => null) };94}9596/** CCTP fee (USDC base units, rounded up) Circle currently asks for this route and speed. bps may be fractional (1.3). */97export async function cctpFee(n: NetworkDef, dst: NetworkDef, amount: bigint, speed: 'standard' | 'fast'): Promise<bigint> {98 const r = await iris(`/v2/burn/USDC/fees/${cctpEnd(n.id)!.domain}/${cctpEnd(dst.id)!.domain}`);99 const p = FeeResp.safeParse(r.json);100 if (r.status !== 200 || !p.success) throw new UserError('provider_bad_answer', 'Circle\'s fee schedule could not be read', 502);101 const e = p.data.find((x) => x.finalityThreshold === (speed === 'fast' ? CCTP_FINALITY_FAST : CCTP_FINALITY_STANDARD));102 if (!e) throw new UserError('provider_bad_answer', 'Circle\'s fee schedule has no entry for this speed', 502);103 const micro = BigInt(Math.round(e.minimumFee * 10_000)); // bps × 10⁴104 let fee = (amount * micro + 99_999_999n) / 100_000_000n;105 if (speed === 'standard') { // newer messenger builds can enforce an on-chain minimum for Standard (HyperEVM has the switch, at 0 today)106 const min = n.family === 'sol' ? await solStandardMinFee(n, amount) // v8: the Solana program's own minimum (token_messenger.min_fee, 0 today)107 : await rpcAny<`0x${string}`>(n.rpc, 'eth_call', [{ to: CCTP_TOKEN_MESSENGER, data: encodeFunctionData({ abi: CCTP_ABI, functionName: 'getMinFeeAmount', args: [amount] }) }, 'latest']).then(hexToBigInt, () => 0n);108 if (min > fee) fee = min;109 }110 return fee;111}112113export async function fastAllowance(): Promise<bigint | null> {114 const r = await iris('/v2/fastBurn/USDC/allowance').catch(() => null);115 const a = (r?.json as { allowance?: unknown } | null)?.allowance;116 return typeof a === 'number' && a >= 0 ? BigInt(Math.floor(a)) * 1_000_000n : null;117}118119// ---------------------------------------------------------------------------------------------------------------------120// Preparation121122export async function destinationGas(dst: NetworkDef, me: string): Promise<{ need: bigint; balance: bigint; price: Awaited<ReturnType<typeof gasPrice>> }> {123 const price = await gasPrice(dst);124 return { need: GAS.mintPlan * price.maxFeePerGas, balance: await nativeBalance(dst, me), price };125}126export const noGas = (dst: NetworkDef, need: bigint, have: bigint) => new UserError('destination_gas',127 `the wallet holds ${formatUnits(have, dst.native.decimals)} ${dst.native.symbol} on ${dst.name}; about ${formatUnits(need, dst.native.decimals)} is needed there to receive the USDC. Nothing was burned: add ${dst.native.symbol} on ${dst.name} first`, 422);128129const Create = z.strictObject({ fromNetwork: z.string().max(24), toNetwork: z.string().max(24), amount: z.string().min(1).max(60), speed: z.enum(['standard', 'fast']).default('standard') });130const Quote = z.object({ from: z.string().max(24), to: z.string().max(24), amount: z.string().min(1).max(60) });131132function route(from: string, to: string): { n: NetworkDef; dst: NetworkDef } {133 if (!cctpEnd(from) || !cctpEnd(to)) throw new UserError('bridge_not_enabled', 'CCTP transfers are not enabled between these networks', 422); // v8: the seven EVM networks and Solana134 if (from === to) throw new UserError('same_network', 'choose two different networks');135 return { n: getNetwork(from), dst: getNetwork(to) };136}137138export function bridgeRoutes(app: FastifyInstance): void {139 app.get('/api/v1/bridge/execution', async () => ({ enabled: true, provider: 'cctp', asset: 'USDC', networks: [...Object.keys(CCTP_ENABLED), 'solana'], messenger: CCTP_TOKEN_MESSENGER, transmitter: CCTP_MESSAGE_TRANSMITTER }));140141 // What a transfer would cost and need, for both speeds. Signs nothing, prepares nothing.142 app.get('/api/v1/bridge/quote', async (req) => {143 const q = Quote.parse(req.query); const { n, dst } = route(q.from, q.to);144 const usdc = { decimals: 6 }; let amount: bigint; // USDC has six decimals on every CCTP network, Solana included145 try { amount = parseUnits(q.amount, usdc.decimals); } catch (e) { throw new UserError('bad_amount', (e as Error).message); }146 if (amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');147 const st = await signer.status(), pin = cctpEnd(n.id)!;148 const destMe = addressFor(st, dst.id); // v8: the wallet's own address on the destination (the same one between EVM networks)149 const [dg, stdFee, fastFee, allowance, solana] = await Promise.all([dst.family === 'sol' ? solDestination(dst, destMe) : destinationGas(dst, destMe), cctpFee(n, dst, amount, 'standard'), pin.fastSource ? cctpFee(n, dst, amount, 'fast') : Promise.resolve(null), pin.fastSource ? fastAllowance() : Promise.resolve(null),150 solQuoteExtras(n, dst, st)]);151 const opt = (speed: 'standard' | 'fast', fee: bigint, seconds: number, available: boolean) => ({ speed, available, fee: fee.toString(), feeDisplay: formatUnits(fee, usdc.decimals), minReceive: (amount - fee).toString(), minReceiveDisplay: formatUnits(amount - fee, usdc.decimals), seconds });152 return { from: n.id, to: dst.id, fromName: n.name, toName: dst.name, amount: amount.toString(), symbol: 'USDC',153 options: [opt('standard', stdFee, pin.standardSeconds, stdFee < amount), ...(fastFee !== null && pin.fastSeconds !== null ? [opt('fast', fastFee, pin.fastSeconds, fastFee < amount && fastFee > 0n && allowance !== null && allowance >= amount)] : [])],154 destinationGas: { symbol: dst.native.symbol, need: dg.need.toString(), needDisplay: formatUnits(dg.need, dst.native.decimals), balance: dg.balance.toString(), balanceDisplay: formatUnits(dg.balance, dst.native.decimals), enough: dg.balance >= dg.need },155 ...(solana ? { solana } : {}) };156 });157158 // Prepare a firm transfer and offer it for passkey approval. The approval covers both legs.159 app.post('/api/v1/bridge/proposals', async (req) => {160 const idem = String(req.headers['idempotency-key'] ?? '');161 if (!/^[A-Za-z0-9-]{16,64}$/.test(idem)) throw new UserError('idempotency', 'Idempotency-Key header required');162 const b = Create.parse(req.body); const { n, dst } = route(b.fromNetwork, b.toNetwork);163 if (n.family === 'sol') return createSolBurnProposal({ n, dst, amount: b.amount, speed: b.speed }, idem); // v8: the burn is a Solana transaction (bridge-sol.ts)164 const toSol = dst.family === 'sol'; // v8: the mint will be a Solana transaction, to the wallet's own USDC token account165 const usdc = cctpUsdc(n.id), pin = CCTP_ENABLED[n.id]!, dpin = cctpEnd(dst.id)!;166 let amount: bigint;167 try { amount = parseUnits(b.amount, usdc.decimals); } catch (e) { throw new UserError('bad_amount', (e as Error).message); }168 if (amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');169 if (b.speed === 'fast' && !pin.fastSource) throw new UserError('fast_unavailable', `Circle does not offer Fast Transfer from ${n.name}; Standard takes about ${pin.standardSeconds} s there`, 422);170 const st = await signer.status(true);171 if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);172 const me = addressFor(st, n.id);173 const destMe = addressFor(st, dst.id);174 if (!toSol && destMe !== me) throw new UserError('unsupported_network', 'the destination network is not served by the same address', 503);175 const [busy] = await sql<{ id: string }[]>`select id from proposals where network = ${n.id} and state in ('approved', 'signed', 'broadcast') limit 1`;176 if (busy) throw new UserError('pending_tx', 'a previous transfer or swap on this network is not confirmed yet; wait for it before starting another', 409);177 await cctpGuards(n, me);178 if (toSol) await cctpSolGuards(dst, destMe); else await cctpGuards(dst, me);179 const bal = await adapterFor(n.id).balances(me);180 if (bal[usdc.id] === undefined) throw new UserError('rpc_degraded', 'token balance could not be read; transfer refused', 503);181 if (bal[usdc.id]! < amount) throw new UserError('insufficient_funds', 'insufficient USDC balance');182 // the destination must be able to pay for the mint BEFORE anything is burned183 // v8, toward Solana: the wallet's USDC token account must already exist (the burn names it and cannot be redirected), and SOL must cover the mint184 const sd = toSol ? await solDestination(dst, destMe) : null;185 if (sd && !sd.tokenAccount.exists) throw new UserError('token_account_missing', `the wallet has no USDC token account on ${dst.name} yet (${sd.tokenAccount.address}). Nothing was burned: create it first, then start the transfer again`, 422);186 const dg = sd ?? await destinationGas(dst, me);187 if (dg.balance < dg.need) throw noGas(dst, dg.need, dg.balance);188 if (dg.need > BigInt(st.policy.maxFee[dst.id] ?? '0')) throw new UserError('policy', `the network fee on ${dst.name} is above the signer's fee ceiling right now; the mint could not be signed`, 422);189 const fee = await cctpFee(n, dst, amount, b.speed);190 if (b.speed === 'fast') {191 if (fee === 0n) throw new UserError('fast_unavailable', 'Circle quotes no Fast fee for this route right now; use Standard', 422);192 const allowance = await fastAllowance();193 if (allowance === null || allowance < amount) throw new UserError('fast_unavailable', 'Circle\'s Fast Transfer allowance does not cover this amount right now; use Standard', 422);194 }195 if (fee >= amount) throw new UserError('bad_amount', 'the amount does not cover Circle\'s fee');196 const allowance = hexToBigInt(await rpcAny<`0x${string}`>(n.rpc, 'eth_call', [{ to: usdc.id, data: encodeFunctionData({ abi: erc20Abi, functionName: 'allowance', args: [me as `0x${string}`, CCTP_TOKEN_MESSENGER as `0x${string}`] }) }, 'latest']));197 const needApprove = allowance < amount;198 const nonce = hexToNumber(await rpcQuorum<`0x${string}`>(n.rpc, 'eth_getTransactionCount', [me, 'pending']));199 if (nonce !== hexToNumber(await rpcAny<`0x${string}`>(n.rpc, 'eth_getTransactionCount', [me, 'latest']))) throw new UserError('pending_tx', 'a previous transaction from this address is still pending on this network', 409);200 const price = await gasPrice(n), t0 = Math.floor(now() / 1000), id = randomUUID();201 const draft = (gas: bigint): Intent => IntentSchema.parse({202 v: 1, proposalId: id, keyId: st.keyId, network: n.id, chainRef: n.chainRef, asset: usdc.id, symbol: 'USDC', decimals: usdc.decimals, amount: amount.toString(), from: me, to: me,203 maxFee: ((gas + (needApprove ? GAS.approve : 0n)) * price.maxFeePerGas).toString(), createdAt: t0, expiresAt: t0 + 75, policyVersion: st.policy.version,204 bridge: { provider: 'cctp', toNetwork: dst.id, toDomain: dpin.domain, maxFee: fee.toString(), minFinalityThreshold: b.speed === 'fast' ? CCTP_FINALITY_FAST : CCTP_FINALITY_STANDARD,205 messenger: CCTP_TOKEN_MESSENGER, transmitter: CCTP_MESSAGE_TRANSMITTER, approveGas: (needApprove ? GAS.approve : 0n).toString(), ...(toSol ? { recipient: destMe } : {}) },206 evm: { nonce, gasLimit: gas.toString(), maxFeePerGas: price.maxFeePerGas.toString(), maxPriorityFeePerGas: price.tip.toString() },207 });208 let gas = GAS.burnUnsimulated, simulated = false;209 if (!needApprove) { // the node's own estimate is also the simulation; with an approval pending the burn is simulated again once it is mined210 const est = await rpcAny<`0x${string}`>(n.rpc, 'eth_estimateGas', [{ from: me, to: CCTP_TOKEN_MESSENGER, data: cctpBurnData(draft(gas)) }]).then(hexToBigInt, (e: Error) => { throw new UserError('simulation_failed', `the burn would fail right now: ${e.message.slice(0, 120)}`, 422); });211 gas = (est * 13n) / 10n + 20_000n; simulated = true;212 }213 const intent = draft(gas), maxFee = BigInt(intent.maxFee);214 try { buildCctpBurnTxs(intent); } catch (e) { throw new UserError('simulation_failed', (e as Error).message, 422); } // exactly what the signer will rebuild215 if ((bal.native ?? 0n) < maxFee) throw new UserError('insufficient_gas', `not enough ${n.native.symbol} on ${n.name} to pay the network fee`);216 const verdict = checkPolicy(st.policy, intent, BigInt(st.spentToday[`${n.id}:USDC`] ?? '0'), t0, st.halted);217 if (!verdict.ok) throw new UserError('policy', verdict.reason, 422);218 const hash = intentHash(intent);219 const state: BridgeState = { provider: 'cctp', toNetwork: dst.id, fromDomain: pin.domain, toDomain: dpin.domain, speed: b.speed, maxFee: fee.toString(), minFinalityThreshold: intent.bridge!.minFinalityThreshold,220 expectedSeconds: b.speed === 'fast' ? pin.fastSeconds! : pin.standardSeconds, needsApproval: needApprove, simulated, destGasNeed: dg.need.toString(), phase: 'burn', polls: 0, nextAt: 0,221 ...(sd ? { sol: { recipient: destMe, tokenAccount: sd.tokenAccount.address, nonceRent: sd.nonceRent.toString() } } : {}) };222 const [p] = await sql<ProposalRow[]>`insert into proposals (id, idempotency_key, state, kind, network, asset, symbol, decimals, amount, from_addr, to_addr, note, fee_estimate, max_fee, warnings, intent, intent_hash, bridge, expires_at)223 values (${id}, ${idem}, 'pending_approval', 'bridge', ${n.id}, ${usdc.id}, 'USDC', ${usdc.decimals}, ${amount.toString()}, ${me}, ${me}, ${`bridge → ${dst.name}`}, ${((gas + (needApprove ? GAS.approve : 0n)) * (price.baseFee + price.tip)).toString()}, ${maxFee.toString()},224 ${sql.json(needApprove ? ['swap_needs_approval'] : [])}, ${sql.json(intent as never)}, ${toHex(hash)}, ${sql.json(state as never)}, ${new Date(intent.expiresAt * 1000)})225 on conflict (idempotency_key) do nothing returning *`;226 if (!p) throw new UserError('conflict', 'duplicate request', 409);227 await audit('bridge.prepared', 'owner', { id, from: n.id, to: dst.id, amount: amount.toString(), speed: b.speed, cctpMaxFee: fee.toString(), hash: toHex(hash) });228 const creds = await listCreds();229 const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge: new Uint8Array(hash), timeout: 70_000, allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });230 if (options.challenge !== toB64u(hash)) throw new Error('challenge binding mismatch');231 return { proposal: view(p), options };232 });233234 const load = async (id: string): Promise<ProposalRow> => {235 const [p] = z.uuid().safeParse(id).success ? await sql<ProposalRow[]>`select * from proposals where id = ${id} and kind = 'bridge'` : [];236 if (!p) throw new UserError('not_found', 'transfer not found', 404);237 return p;238 };239240 // "Finish": run the next step now instead of waiting for the worker. Safe to call at any time, any number of times.241 app.post('/api/v1/bridge/proposals/:id/finish', async (req) => {242 const p = await load((req.params as { id: string }).id);243 if (p.state === 'signed') return view(await advanceBridgeBurn(p));244 return view(await advanceBridge(p, { manual: true }));245 });246247 // A mint transaction that reverted or was replaced can only be signed again with the owner's passkey.248 app.post('/api/v1/bridge/proposals/:id/retry-mint/options', async (req) => {249 let p = await load((req.params as { id: string }).id);250 p = await advanceBridge(p, { manual: true });251 const b = p.bridge!;252 if (b.phase !== 'mint_failed' || !b.message || !b.messageHash) throw new UserError('bad_state', 'this transfer does not need a new mint signature', 409);253 if (getNetwork(b.toNetwork).family === 'sol') return solMintRetryOptions(p, b); // v8254 const evm = await mintGas(p, b);255 if ('hold' in evm) throw new UserError(evm.hold, evm.detail, 422);256 p = await save(p, { ...b, retry: { evm: evm.evm, expiresAt: now() + 120_000 } });257 const challenge = new Uint8Array(createHash('sha256').update(cctpMintRetryBytes(p.id, b.messageHash, evm.evm)).digest());258 const creds = await listCreds();259 const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge, timeout: 100_000, allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });260 return { proposal: view(p), options };261 });262 app.post('/api/v1/bridge/proposals/:id/retry-mint', async (req) => {263 const body = z.object({ assertion: z.record(z.string(), z.unknown()) }).parse(req.body);264 let p = await load((req.params as { id: string }).id);265 const b = p.bridge!;266 if (b.phase !== 'mint_failed' || !b.retry || b.retry.expiresAt < now() || !b.message || !b.attestation) throw new UserError('bad_state', 'ask for a new approval first', 409);267 const signed = await signer.bridgeMint({ proposalId: p.id, message: b.message, attestation: b.attestation, ...(b.retry.sol ? { sol: b.retry.sol } : { evm: b.retry.evm! }), assertion: body.assertion });268 p = await save(p, { ...b, retry: undefined, hold: null, holdDetail: null, phase: 'mint_sent', mint: { ...(b.retry.sol ? { sol: b.retry.sol } : { evm: b.retry.evm }), txid: signed.txid, raw: signed.raw, sentAt: now(), lastBroadcastAt: now(), signatures: (b.mint?.signatures ?? 1) + 1 } });269 await audit('bridge.mint_resigned', 'owner', { id: p.id, txid: signed.txid });270 if (config.broadcast) await adapterFor(b.toNetwork).broadcast(signed.raw).catch(() => {});271 return view(p);272 });273 bridgeSolRoutes(app, load); // v8: reclaim of the event-account deposit, creation of the wallet's USDC token account on Solana274}275276// ---------------------------------------------------------------------------------------------------------------------277// Leg 1 after signing: mine the exact-amount approval if there is one, simulate the burn against the real state, relay it.278279export async function advanceBridgeBurn(p: ProposalRow): Promise<ProposalRow> {280 if (p.kind !== 'bridge' || p.state !== 'signed' || !p.raw || !p.intent) return p;281 if (getNetwork(p.network).family === 'sol') return advanceSolBurn(p); // v8282 const n = getNetwork(p.network), a = adapterFor(p.network);283 const fail = (error: string) => move(p, 'failed', { error });284 if (!config.broadcast) return broadcastNow(p);285 if (p.expires_at && Date.now() - p.expires_at.getTime() > 8 * 60_000) return fail('the transfer could not be sent in time; nothing was burned');286 if (p.pre_raw && p.pre_state !== 'confirmed') {287 await a.broadcast(p.pre_raw).catch(() => {});288 const rc = await rpcAny<{ status: `0x${string}` } | null>(n.rpc, 'eth_getTransactionReceipt', [p.pre_txid]).catch(() => null);289 if (!rc) { if (p.pre_state !== 'sent') await sql`update proposals set pre_state = 'sent', updated_at = now() where id = ${p.id}`; return p; }290 if (hexToNumber(rc.status) !== 1) return fail('the token approval reverted; nothing was burned');291 await sql`update proposals set pre_state = 'confirmed', updated_at = now() where id = ${p.id}`;292 await audit('bridge.approval_confirmed', 'chain', { id: p.id, txid: p.pre_txid });293 }294 // last gate before the burn leaves: it must succeed against the current chain state295 const sim = await rpcAny<string>(n.rpc, 'eth_call', [{ from: p.from_addr, to: CCTP_TOKEN_MESSENGER, data: cctpBurnData(p.intent) }, 'latest']).then(() => null, (e: Error & { rpcCode?: number }) => (e.rpcCode !== undefined ? e.message : 'unreachable'));296 if (sim === 'unreachable') return p; // try again on the next tick297 if (sim !== null) return fail(`the burn would revert now (${sim.slice(0, 100)}); it was not sent${p.pre_raw ? '. The exact-amount approval stays in place for the next attempt' : ''}`);298 return broadcastNow(p);299}300301// ---------------------------------------------------------------------------------------------------------------------302// Leg 2: attestation, then mint. Every step is idempotent and driven only by what is stored in the row, so it resumes after303// a restart, days later, from the worker or from the "finish" button.304305export async function save(p: ProposalRow, b: BridgeState): Promise<ProposalRow> {306 const [r] = await sql<ProposalRow[]>`update proposals set bridge = ${sql.json(b as never)}, updated_at = now() where id = ${p.id} returning *`;307 return r!;308}309export const wait = (p: ProposalRow, b: BridgeState, hold: string, detail: string, ms: number) => save(p, { ...b, hold, holdDetail: detail.slice(0, 300), nextAt: now() + ms });310/** 5 s at first, then slower, never above five minutes — and never giving up: burned funds stay mintable for ever. */311const backoff = (polls: number, speed: string) => Math.min(300_000, (speed === 'fast' ? 4_000 : 15_000) * Math.ceil((polls + 1) / 8));312313async function usedNonce(dst: NetworkDef, nonce: string): Promise<boolean> {314 const r = await rpcQuorum<`0x${string}`>(dst.rpc, 'eth_call', [{ to: CCTP_MESSAGE_TRANSMITTER, data: encodeFunctionData({ abi: CCTP_ABI, functionName: 'usedNonces', args: [`0x${nonce}`] }) }, 'latest']);315 return hexToBigInt(r) !== 0n;316}317318/** Someone else submitted the mint (it is permissionless): look for its transaction, best effort, in recent blocks. */319async function findMint(dst: NetworkDef, nonce: string): Promise<string | null> {320 try {321 const head = hexToNumber(await rpcAny<`0x${string}`>(dst.rpc, 'eth_blockNumber', []));322 const logs = await rpcAny<{ transactionHash: string; topics: string[] }[]>(dst.rpc, 'eth_getLogs', [{ address: CCTP_MESSAGE_TRANSMITTER, fromBlock: numberToHex(Math.max(0, head - 2000)), toBlock: 'latest', topics: [null, null, `0x${nonce}`] }]);323 return logs[0]?.transactionHash ?? null;324 } catch { return null; }325}326327export async function done(p: ProposalRow, b: BridgeState, by: 'wallet' | 'other', txid: string | null): Promise<ProposalRow> {328 const minted = b.minted ?? null;329 await sql`update proposals set out_amount = ${minted} where id = ${p.id}`;330 const r = await save(p, { ...b, phase: 'done', hold: null, holdDetail: null, retry: undefined, mintedBy: by, mintTxid: txid, doneAt: now() });331 await audit('bridge.done', 'chain', { id: p.id, to: b.toNetwork, minted, feeExecuted: b.feeExecuted ?? null, mintedBy: by, txid });332 return r;333}334335/** v8: the wallet's address on the destination network — the source address between EVM networks, the named recipient when Solana is one end. */336const destAddr = (p: ProposalRow): string => p.intent?.bridge?.recipient ?? p.from_addr;337/** Gas parameters for a mint right now, or the reason why it cannot be sent yet. */338async function mintGas(p: ProposalRow, b: BridgeState): Promise<{ evm: CctpMintGas } | { hold: string; detail: string }> {339 const dst = getNetwork(b.toNetwork), me = destAddr(p);340 const [busy] = await sql<{ id: string }[]>`select id from proposals where network = ${dst.id} and state in ('approved', 'signed', 'broadcast') limit 1`;341 if (busy) return { hold: 'destination_busy', detail: `another transaction is in flight on ${dst.name}` };342 const nonce = hexToNumber(await rpcQuorum<`0x${string}`>(dst.rpc, 'eth_getTransactionCount', [me, 'pending']));343 if (nonce !== hexToNumber(await rpcAny<`0x${string}`>(dst.rpc, 'eth_getTransactionCount', [me, 'latest']))) return { hold: 'destination_busy', detail: `a previous transaction is still pending on ${dst.name}` };344 const data = encodeFunctionData({ abi: CCTP_ABI, functionName: 'receiveMessage', args: [b.message as `0x${string}`, b.attestation as `0x${string}`] });345 const est = await rpcAny<`0x${string}`>(dst.rpc, 'eth_estimateGas', [{ from: me, to: CCTP_MESSAGE_TRANSMITTER, data }]).then(hexToBigInt, (e: Error & { rpcCode?: number }) => { if (e.rpcCode === undefined) throw e; return e.message; });346 if (typeof est === 'string') return { hold: 'mint_would_fail', detail: `the mint would revert on ${dst.name} now: ${est.slice(0, 140)}` };347 let gas = (est * 13n) / 10n + 20_000n; if (gas > GAS.mintCap) gas = GAS.mintCap;348 const price = await gasPrice(dst), st = await signer.status();349 const ceiling = BigInt(st.policy.maxFee[dst.id] ?? '0');350 let maxFeePerGas = price.maxFeePerGas;351 if (gas * maxFeePerGas > ceiling) maxFeePerGas = ceiling / gas; // stay under the signer's ceiling when the current price still fits352 if (maxFeePerGas < price.baseFee + price.tip) return { hold: 'fee_above_policy', detail: `the network fee on ${dst.name} is above the signer's fee ceiling right now` };353 const balance = await nativeBalance(dst, me);354 if (balance < gas * maxFeePerGas) return { hold: 'destination_gas', detail: `add ${dst.native.symbol} on ${dst.name}: ${formatUnits(gas * maxFeePerGas, 18)} is needed to receive the USDC, the wallet holds ${formatUnits(balance, 18)}` };355 return { evm: { nonce, gasLimit: gas.toString(), maxFeePerGas: maxFeePerGas.toString(), maxPriorityFeePerGas: (price.tip > maxFeePerGas ? maxFeePerGas : price.tip).toString() } };356}357358export async function advanceBridge(p: ProposalRow, o: { manual?: boolean } = {}): Promise<ProposalRow> {359 if (p.kind !== 'bridge' || !p.bridge || !p.intent || !p.txid) return p;360 let b = p.bridge;361 if (b.phase === 'done' || !['confirming', 'finalized', 'reorged'].includes(p.state)) return p; // the burn must be mined and successful first362 if (!o.manual && b.nextAt > now()) return p;363 const dst = getNetwork(b.toNetwork), bridge = bridgeOf(p.intent);364365 if (b.phase === 'burn') { p = await save(p, b = { ...b, phase: 'attesting', attestingSince: now(), polls: 0, nextAt: 0 }); await audit('bridge.burned', 'chain', { id: p.id, txid: p.txid }); }366 // v8, a burn on Solana: read when its event account was created — the refundable deposit can be reclaimed five days later367 if (getNetwork(p.network).family === 'sol' && b.sol?.eventAccount && !b.sol.createdAt) { const f = await solBurnFacts(p, b).catch(() => null); if (f) p = await save(p, b = f); }368369 if (b.phase === 'attesting') {370 let r; try { r = await iris(`/v2/messages/${b.fromDomain}?transactionHash=${p.txid}`); } catch (e) { return wait(p, b, 'attestation_service', (e as Error).message, 20_000); }371 const parsed = MsgResp.safeParse(r.json);372 const late = now() - (b.attestingSince ?? now()) > (b.expectedSeconds * 3 + 1800) * 1000;373 const pending = (status: string, reason: string | null) => save(p, { ...b, polls: b.polls + 1, nextAt: now() + backoff(b.polls, b.speed), irisStatus: status, delayReason: reason,374 hold: late ? 'attestation_late' : null, holdDetail: late ? 'Circle has not attested this burn yet. The funds are not lost: the mint stays possible whenever the attestation arrives' : null });375 if (r.status === 404 || !parsed.success) return pending(r.status === 404 ? 'not_found' : `http_${r.status}`, null);376 // the answer is untrusted: keep only a message that is exactly the approved burn (the signer re-checks the same way)377 let hit: { message: string; attestation: string; v: ReturnType<typeof verifyCctpMint> } | null = null, why = '';378 for (const m of parsed.data.messages) {379 if (m.status !== 'complete' || !m.message || !m.attestation) continue;380 try { const v = verifyCctpMint(bridge, m.message, m.attestation); if (v.messageHash === b.expiredHash) continue; hit = { message: m.message.toLowerCase(), attestation: m.attestation.toLowerCase(), v }; break; } catch (e) { why = (e as Error).message; }381 }382 if (!hit) {383 const m0 = parsed.data.messages[0];384 if (why) return save(p, { ...b, polls: b.polls + 1, nextAt: now() + 120_000, irisStatus: 'mismatch', hold: 'message_mismatch', holdDetail: `Circle's answer does not describe the approved transfer (${why}); it was not used` });385 return pending(m0?.status ?? 'empty', m0?.delayReason ?? null);386 }387 p = await save(p, b = { ...b, phase: 'ready', hold: null, holdDetail: null, irisStatus: 'complete', delayReason: null, nextAt: 0, message: hit.message, attestation: hit.attestation, nonce: hit.v.msg.nonce, messageHash: hit.v.messageHash,388 feeExecuted: hit.v.msg.body.feeExecuted.toString(), minted: hit.v.minted.toString(), expirationBlock: hit.v.msg.body.expirationBlock.toString(), mint: undefined });389 await audit('bridge.attested', 'circle', { id: p.id, messageHash: b.messageHash, nonce: b.nonce, feeExecuted: b.feeExecuted });390 }391392 if (dst.family === 'sol') return advanceSolMint(p, b, bridge); // v8: the mint is a Solana transaction (bridge-sol.ts)393394 if (b.phase === 'ready' || b.phase === 'mint_failed') {395 let used: boolean; try { used = await usedNonce(dst, b.nonce!); } catch (e) { return wait(p, b, 'rpc_degraded', (e as Error).message, 20_000); }396 if (used) return done(p, b, 'other', await findMint(dst, b.nonce!)); // already minted (the contract allows it once): success397 if (b.phase === 'mint_failed') return p; // needs the owner's passkey (retry-mint)398 try { await cctpGuards(dst, destAddr(p)); } catch (e) { return wait(p, b, (e as UserError).code ?? 'rpc_degraded', (e as Error).message, 60_000); }399 // a Fast attestation expires about a day after the burn: ask Circle for a fresh one, then mint with it400 if (b.expirationBlock && b.expirationBlock !== '0') {401 const head = await rpcAny<`0x${string}`>(dst.rpc, 'eth_blockNumber', []).then(hexToBigInt, () => null);402 if (head !== null && head >= BigInt(b.expirationBlock) && !b.mint?.evm) {403 await iris(`/v2/reattest/0x${b.nonce}`, { method: 'POST' }).catch(() => null);404 await audit('bridge.reattest', 'system', { id: p.id, nonce: b.nonce });405 return save(p, { ...b, phase: 'attesting', expiredHash: b.messageHash, polls: 0, nextAt: now() + 10_000, hold: 'attestation_expired', holdDetail: 'the Fast attestation expired; a new one was requested from Circle' });406 }407 }408 // gas parameters are chosen once and stored before the signer is asked: an interrupted attempt repeats the same request409 let evm = b.mint?.evm;410 if (!evm) {411 let g; try { g = await mintGas(p, b); } catch (e) { return wait(p, b, (e as UserError).code ?? 'rpc_degraded', (e as Error).message, 20_000); }412 if ('hold' in g) return wait(p, b, g.hold, g.detail, g.hold === 'destination_gas' ? 60_000 : 30_000);413 try { buildCctpMintTx(bridge, b.message!, b.attestation!, g.evm, BigInt((await signer.status()).policy.maxFee[dst.id] ?? '0')); } catch (e) { return wait(p, b, 'mint_would_fail', (e as Error).message, 120_000); }414 evm = g.evm; p = await save(p, b = { ...b, hold: null, holdDetail: null, mint: { evm } });415 }416 let signed;417 try { signed = await signer.bridgeMint({ proposalId: p.id, message: b.message!, attestation: b.attestation!, evm }); }418 catch (e) {419 const code = (e as UserError).code ?? '';420 if (code === 'signer_unreachable') return wait(p, b, code, (e as Error).message, 15_000);421 // the signer refuses a second passkey-free signature and anything that is not the approved transfer422 return save(p, { ...b, phase: 'mint_failed', hold: code || 'signer_refused', holdDetail: (e as Error).message.slice(0, 300), nextAt: now() + 60_000 });423 }424 p = await save(p, b = { ...b, phase: 'mint_sent', hold: null, holdDetail: null, nextAt: 0, mint: { evm, txid: signed.txid, raw: signed.raw, sentAt: now(), lastBroadcastAt: now(), signatures: b.mint?.signatures ?? 1 } });425 await audit('bridge.mint_signed', 'system', { id: p.id, to: dst.id, txid: signed.txid, messageHash: signed.messageHash, replayed: signed.replayed });426 if (config.broadcast) await adapterFor(dst.id).broadcast(signed.raw).catch(() => {});427 }428429 if (b.phase === 'mint_sent' && b.mint?.txid && config.broadcast) {430 const m = b.mint;431 const rc = await rpcAny<{ status: `0x${string}`; blockNumber: `0x${string}`; logs: { address: string; topics: [`0x${string}`, ...`0x${string}`[]]; data: `0x${string}` }[] } | null>(dst.rpc, 'eth_getTransactionReceipt', [m.txid]).catch(() => null);432 if (rc && hexToNumber(rc.status) === 1) {433 const head = await rpcAny<`0x${string}`>(dst.rpc, 'eth_blockNumber', []).then(hexToNumber, () => 0);434 if (head - hexToNumber(rc.blockNumber) + 1 < dst.minConf) return save(p, { ...b, nextAt: now() + 5_000 });435 // what the wallet actually received, from the USDC Transfer event of the mint436 const usdc = cctpUsdc(dst.id).id; let got: bigint | null = null;437 for (const l of rc.logs) { if (!eqAddr(l.address, usdc)) continue;438 try { const ev = decodeEventLog({ abi: erc20Abi, data: l.data, topics: l.topics }); if (ev.eventName === 'Transfer' && eqAddr(ev.args.to, destAddr(p)) && /^0x0{40}$/.test(ev.args.from)) got = ev.args.value; } catch { /* another event */ } }439 return done(p, { ...b, minted: (got ?? BigInt(b.minted ?? '0')).toString() }, 'wallet', m.txid!);440 }441 let used = false; try { used = await usedNonce(dst, b.nonce!); } catch { return save(p, { ...b, nextAt: now() + 10_000 }); }442 if (rc) { // our transaction reverted: either someone minted first (success) or it must be signed again443 if (used) return done(p, b, 'other', await findMint(dst, b.nonce!));444 await audit('bridge.mint_reverted', 'chain', { id: p.id, txid: m.txid });445 return save(p, { ...b, phase: 'mint_failed', hold: 'mint_reverted', holdDetail: `the mint transaction reverted on ${dst.name}; the USDC is still waiting. Approve a new attempt with the passkey` });446 }447 if (used) { // minted, and no receipt for our transaction a moment ago: it may just have been included — look again before crediting someone else448 const again = await rpcAny<{ status: `0x${string}` } | null>(dst.rpc, 'eth_getTransactionReceipt', [m.txid]).catch(() => undefined);449 if (again === undefined || (again && hexToNumber(again.status) === 1)) return save(p, { ...b, nextAt: now() + 3_000 });450 return done(p, b, 'other', await findMint(dst, b.nonce!));451 }452 const latest =await rpcAny<`0x${string}`>(dst.rpc, 'eth_getTransactionCount', [destAddr(p), 'latest']).then(hexToNumber, () => null);453 if (latest !== null && latest > m.evm!.nonce && now() - (m.sentAt ?? 0) > 90_000)454 return save(p, { ...b, phase: 'mint_failed', hold: 'mint_replaced', holdDetail: `another transaction took the place of the mint on ${dst.name}; the USDC is still waiting. Approve a new attempt with the passkey` });455 if (now() - (m.lastBroadcastAt ?? 0) > 45_000) { await adapterFor(dst.id).broadcast(m.raw!).catch(() => {}); return save(p, { ...b, mint: { ...m, lastBroadcastAt: now() }, nextAt: now() + 5_000 }); }456 return save(p, { ...b, nextAt: now() + 4_000 });457 }458 return p;459}460461/** Worker tick: every bridge whose burn is mined and whose mint is not done. */462export async function advanceBridges(): Promise<void> {463 const rows = await sql<ProposalRow[]>`select * from proposals where kind = 'bridge' and state in ('confirming', 'finalized', 'reorged') and coalesce(bridge->>'phase', '') <> 'done' order by created_at`;464 for (const p of rows) { try { await advanceBridge(p); } catch (e) { console.log(`[worker] bridge ${p.id}: ${(e as Error).message}`); } }465 await advanceSolReclaims().catch((e) => console.log(`[worker] reclaims: ${(e as Error).message}`)); // v8: reclaim transactions that were sent466}467