Repository Public
1// CCTP transfer life cycle against a scripted chain and a scripted attestation service (no network): the real API routes,2// worker steps and signer process boundary. What the real contracts do is proven separately on forks (scripts/fork-cctp.mts).3import { createHash } from 'node:crypto';4import { mkdtempSync, readFileSync } from 'node:fs';5import { createServer, type Server } from 'node:http';6import { tmpdir } from 'node:os';7import { join } from 'node:path';8import { createTestDb } from './test-db.ts';9import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';10import { decodeFunctionData, encodeAbiParameters, keccak256, numberToHex, pad, parseTransaction, toFunctionSelector, type Hex } from 'viem';11import { CCTP_ABI, CCTP_ENABLED, CCTP_MESSAGE_TRANSMITTER, CCTP_TOKEN_MESSENGER, CCTP_TOKEN_MINTER, cctpBytes32, cctpMintRetryBytes, EIP1967_IMPLEMENTATION_SLOT, encodeCctpMessage, NETWORKS, type CctpMessage, type Intent } from '@spw/core';12import { TestPasskey } from '../../../tests/helpers/passkey.ts';13import { deriveKeys } from '../../isolated-signer/src/keys.ts';14import { createSignerServer } from '../../isolated-signer/src/server.ts';15import { Signer } from '../../isolated-signer/src/signer.ts';16import { Store } from '../../isolated-signer/src/store.ts';1718const ORIGIN = 'https://wallet.test', RP = 'wallet.test', SECRET = 'b'.repeat(48);19const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'), ME = keys.addresses.evm;20const owner = new TestPasskey(RP, ORIGIN);21const CODE = JSON.parse(readFileSync(new URL('./fixtures/cctp-code.json', import.meta.url), 'utf8')) as Record<string, string>; // real mainnet bytecode, so the pinned hashes are exercised22const usdc = (n: string) => NETWORKS[n]!.tokens.find((t) => t.symbol === 'USDC')!.id;23const SEL = { allowance: '0xdd62ed3e', used: toFunctionSelector('usedNonces(bytes32)'), minFee: toFunctionSelector('getMinFeeAmount(uint256)'), burn: '0x8e0250ee', receive: '0x57ecfd28' };24const TRANSFER = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';2526/** One scripted EVM chain behind a JSON-RPC endpoint. */27class Chain {28 head = 1000; nonce = 0; native = 10n ** 17n; allowance = 0n; pending: { hash: string; raw: string }[] = []; receipts = new Map<string, any>(); used = new Set<string>();29 implSlot: Record<string, string>; burnReverts = false; mintReverts: string | null = null; sent: string[] = [];30 constructor(public id: string) { const p = CCTP_ENABLED[id]!; this.implSlot = { [CCTP_TOKEN_MESSENGER.toLowerCase()]: p.messengerImpl.address, [CCTP_MESSAGE_TRANSMITTER.toLowerCase()]: p.transmitterImpl.address }; }31 /** include what is pending; `status` 0 = the transaction reverts */32 mine(status = 1, blocks = 20) {33 for (const t of this.pending) {34 const tx = parseTransaction(t.raw as Hex); this.head += 1; this.nonce = Math.max(this.nonce, tx.nonce! + 1);35 const logs: any[] = [];36 if (status === 1 && tx.to?.toLowerCase() === CCTP_MESSAGE_TRANSMITTER.toLowerCase()) {37 const [message] = decodeFunctionData({ abi: CCTP_ABI, data: tx.data! }).args as [Hex, Hex];38 const nonce = message.slice(2 + 24, 2 + 24 + 64), amount = BigInt('0x' + message.slice(2 + (148 + 68) * 2, 2 + (148 + 100) * 2)), fee = BigInt('0x' + message.slice(2 + (148 + 164) * 2, 2 + (148 + 196) * 2));39 this.used.add(nonce);40 logs.push({ address: usdc(this.id), topics: [TRANSFER, pad('0x0', { size: 32 }), pad(ME as Hex, { size: 32 }).toLowerCase()], data: numberToHex(amount - fee, { size: 32 }) });41 }42 this.receipts.set(t.hash, { status: numberToHex(status), blockNumber: numberToHex(this.head), blockHash: keccak256(t.hash as Hex), transactionHash: t.hash, logs });43 }44 this.pending = []; this.head += blocks;45 }46 rpc(method: string, p: any[]): unknown {47 const err = (message: string) => { throw Object.assign(new Error(message), { rpc: true }); };48 switch (method) {49 case 'eth_chainId': return numberToHex(Number(NETWORKS[this.id]!.chainRef));50 case 'eth_blockNumber': return numberToHex(this.head);51 case 'eth_getBlockByNumber': return { number: numberToHex(this.head), baseFeePerGas: '0x989680' };52 case 'eth_maxPriorityFeePerGas': return '0xf4240';53 case 'eth_getBalance': return numberToHex(this.native);54 case 'eth_getTransactionCount': return numberToHex(p[1] === 'pending' ? this.nonce + this.pending.length : this.nonce);55 case 'eth_getCode': { const a = String(p[0]).toLowerCase();56 if (a === CCTP_TOKEN_MESSENGER.toLowerCase() || a === CCTP_MESSAGE_TRANSMITTER.toLowerCase()) return CODE.proxy;57 if (a === CCTP_TOKEN_MINTER.toLowerCase()) return CODE.minter;58 return CODE[a] ?? '0x'; }59 case 'eth_getStorageAt': return p[1] === EIP1967_IMPLEMENTATION_SLOT ? pad((this.implSlot[String(p[0]).toLowerCase()] ?? '0x0') as Hex, { size: 32 }).toLowerCase() : pad('0x0', { size: 32 });60 case 'eth_estimateGas': case 'eth_call': { const d = String(p[0].data), sel = d.slice(0, 10);61 if (sel === SEL.allowance) return numberToHex(this.allowance, { size: 32 });62 if (sel === SEL.used) return numberToHex(this.used.has(d.slice(10, 74)) ? 1 : 0, { size: 32 });63 if (sel === SEL.minFee) return err('execution reverted');64 if (sel === SEL.burn) return this.burnReverts || this.allowance < 1n ? err('execution reverted: ERC20: transfer amount exceeds allowance') : method === 'eth_call' ? '0x' : '0x1a000';65 if (sel === SEL.receive) { const nonce = d.slice(10 + 64 * 4 + 24, 10 + 64 * 4 + 24 + 64); // message bytes start after two offsets + length66 if (this.used.has(nonce)) return err('execution reverted: Nonce already used');67 return this.mintReverts ? err(`execution reverted: ${this.mintReverts}`) : method === 'eth_call' ? encodeAbiParameters([{ type: 'bool' }], [true]) : '0x2a000'; }68 return err('execution reverted'); }69 case 'eth_sendRawTransaction': { const hash = keccak256(p[0] as Hex); if (!this.pending.some((x) => x.hash === hash) && !this.receipts.has(hash)) { this.pending.push({ hash, raw: p[0] }); this.sent.push(p[0]); }70 const tx = parseTransaction(p[0] as Hex); if (tx.to?.toLowerCase() === usdc(this.id).toLowerCase()) this.allowance = 10n ** 30n; // the exact approval, once mined (close enough for a script)71 return hash; }72 case 'eth_getTransactionReceipt': return this.receipts.get(String(p[0])) ?? null;73 case 'eth_getLogs': return [];74 default: return err(`unscripted ${method}`);75 }76 }77}78const chains: Record<string, Chain> = {};79const iris: { messages: Map<string, unknown>; fees: unknown; allowance: number; calls: string[] } = { messages: new Map(), fees: [{ finalityThreshold: 1000, minimumFee: 1.3 }, { finalityThreshold: 2000, minimumFee: 0 }], allowance: 5e7, calls: [] };80let fake: Server, signerSrv: Server, app: any, sql: any, worker: any, bridge: any, adapters: any, cookie = '', store: Store, signerDir: string;8182async function api(method: string, path: string, body?: unknown, idem?: string) {83 const r = await app.inject({ method, url: path, headers: { ...(cookie ? { cookie } : {}), ...(method !== 'GET' ? { origin: ORIGIN, 'x-spw-csrf': '1', 'content-type': 'application/json' } : {}), ...(idem ? { 'idempotency-key': idem } : {}) }, payload: body === undefined ? undefined : JSON.stringify(body) });84 return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })(), setCookie: r.headers['set-cookie'] as string | undefined };85}86const row = async (id: string) => (await sql`select * from proposals where id = ${id}`)[0];87const getP = async (id: string) => (await api('GET', `/api/v1/transfers/proposals/${id}`)).json;88const finish = async (id: string) => (await api('POST', `/api/v1/bridge/proposals/${id}/finish`, {})).json;89/** what Circle would attest for this burn */90function attested(i: Intent, o: Partial<CctpMessage> = {}, body: Partial<CctpMessage['body']> = {}, nonce = createHash('sha256').update(i.proposalId).digest('hex')) {91 const b = i.bridge!, m = cctpBytes32(CCTP_TOKEN_MESSENGER);92 const message = encodeCctpMessage({ version: 1, sourceDomain: CCTP_ENABLED[i.network]!.domain, destinationDomain: b.toDomain, nonce, sender: m, recipient: m, destinationCaller: '0'.repeat(64), minFinalityThreshold: b.minFinalityThreshold,93 finalityThresholdExecuted: b.minFinalityThreshold, ...o, body: { version: 1, burnToken: cctpBytes32(i.asset), mintRecipient: cctpBytes32(i.from), amount: BigInt(i.amount), messageSender: cctpBytes32(i.from), maxFee: BigInt(b.maxFee), feeExecuted: 0n, expirationBlock: 0n, ...body } });94 return { messages: [{ status: 'complete', message, attestation: `0x${'33'.repeat(130)}`, cctpVersion: 2, delayReason: null }] };95}96/** prepare + approve + mine approval and burn: returns the proposal once the burn is final on the source */97async function burn(amount = '10', speed: 'standard' | 'fast' = 'standard') {98 const prep = await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount, speed }, crypto.randomUUID());99 expect(prep.status, JSON.stringify(prep.json)).toBe(200);100 const id = prep.json.proposal.id as string;101 const ap = await api('POST', `/api/v1/transfers/proposals/${id}/approve`, { assertion: owner.assert(Buffer.from(prep.json.options.challenge, 'base64url')) });102 expect(ap.status, JSON.stringify(ap.json)).toBe(200);103 for (let k = 0; k < 4 && (await getP(id)).state !== 'finalized'; k++) { chains.base!.mine(); await worker.reconcile(); }104 const p = await getP(id); expect(p.state).toBe('finalized');105 return { id, intent: (await row(id)).intent as Intent, prep: prep.json };106}107108beforeAll(async () => {109 for (const id of ['base', 'arbitrum']) chains[id] = new Chain(id);110 fake = createServer((req, res) => {111 let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {112 const send = (code: number, o: unknown) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };113 const url = new URL(req.url!, 'http://x');114 if (url.pathname.startsWith('/rpc/')) { const j = JSON.parse(body); try { send(200, { jsonrpc: '2.0', id: j.id, result: chains[url.pathname.slice(5)]!.rpc(j.method, j.params) }); } catch (e) { send(200, { jsonrpc: '2.0', id: j.id, error: { code: 3, message: (e as Error).message } }); } return; }115 iris.calls.push(`${req.method} ${url.pathname}${url.search}`);116 if (url.pathname.startsWith('/v2/burn/USDC/fees/')) return send(200, iris.fees);117 if (url.pathname === '/v2/fastBurn/USDC/allowance') return send(200, { allowance: iris.allowance });118 if (url.pathname.startsWith('/v2/messages/')) { const m = iris.messages.get(url.searchParams.get('transactionHash') ?? ''); return m ? send(200, m) : send(404, { error: 'Message not found for provided parameters' }); }119 if (url.pathname.startsWith('/v2/reattest/')) return send(200, { message: 'Re-attestation successfully requested for nonce.' });120 send(404, {});121 });122 }).listen(0, '127.0.0.1');123 await new Promise((r) => fake.once('listening', r));124 const base = `http://127.0.0.1:${(fake.address() as { port: number }).port}`;125 signerDir = mkdtempSync(join(tmpdir(), 'spw-bridge-api-'));126 store = new Store(join(signerDir, 'state.json'), join(signerDir, 'policy.json'));127 store.state.setupTokenHash = createHash('sha256').update('bridge-setup-token').digest('hex');128 signerSrv = createSignerServer(new Signer(keys, store, { rpId: RP, origin: ORIGIN }), SECRET).listen(0, '127.0.0.1');129 await new Promise((r) => signerSrv.once('listening', r));130 Object.assign(process.env, { SPW_ORIGIN: ORIGIN, SPW_RP_ID: RP, DATABASE_URL: (await createTestDb('bridge')).url, SPW_SIGNER_SECRET: SECRET, SPW_SIGNER_URL: `http://127.0.0.1:${(signerSrv.address() as { port: number }).port}`,131 SPW_BROADCAST: 'on', SPW_WORKERS: 'off', SPW_IRIS_OVERRIDE: base, SPW_RPC_OVERRIDE: JSON.stringify({ base: [`${base}/rpc/base`, `${base}/rpc/base`], arbitrum: [`${base}/rpc/arbitrum`, `${base}/rpc/arbitrum`] }) });132 const db = await import('../src/db.ts'); sql = db.sql;133 await sql.unsafe('drop schema public cascade; create schema public;'); await db.migrate();134 await import('../src/rpc-override.ts');135 adapters = (await import('../src/chains/index.ts')).adapters;136 adapters.base.balances = async () => ({ native: chains.base!.native, [usdc('base')]: 100_000000n });137 app = await (await import('../src/server.ts')).buildServer({ swapProviders: [] });138 worker = await import('../src/worker.ts'); bridge = await import('../src/bridge.ts');139 const o = (await api('POST', '/api/v1/auth/setup/options', {})).json;140 const s = await api('POST', '/api/v1/auth/setup/verify', { challengeId: o.challengeId, response: owner.register(o.options.challenge), setupToken: 'bridge-setup-token' });141 expect(s.status, JSON.stringify(s.json)).toBe(200); cookie = String(s.setCookie).split(';')[0]!;142});143afterAll(async () => { await app?.close(); signerSrv?.close(); fake?.close(); await sql?.end(); });144beforeEach(async () => {145 // each test starts from a quiet wallet: nothing in flight, fresh daily counters146 await sql`update proposals set state = 'failed' where state not in ('finalized', 'failed', 'expired', 'rejected', 'replaced')`;147 await sql`update proposals set bridge = jsonb_set(bridge, '{phase}', '"done"') where kind = 'bridge'`;148 store.state.spent = {}; store.save();149 for (const c of Object.values(chains)) { c.mine(); c.native = 10n ** 17n; c.burnReverts = false; c.mintReverts = null; c.sent = []; }150 chains.base!.allowance = 0n; iris.calls = [];151 for (const c of Object.values(chains)) { const p = CCTP_ENABLED[c.id]!; c.implSlot = { [CCTP_TOKEN_MESSENGER.toLowerCase()]: p.messengerImpl.address, [CCTP_MESSAGE_TRANSMITTER.toLowerCase()]: p.transmitterImpl.address }; }152});153154describe('preparation', () => {155 it('is closed without a session and validates the route', async () => {156 const saved = cookie; cookie = '';157 expect((await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount: '10' }, crypto.randomUUID())).status).toBe(401);158 expect((await api('GET', '/api/v1/bridge/quote?from=base&to=arbitrum&amount=10')).status).toBe(401);159 cookie = saved;160 expect((await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount: '10' })).json.error).toBe('idempotency');161 for (const [b, code] of [[{ fromNetwork: 'base', toNetwork: 'base', amount: '10' }, 'same_network'], [{ fromNetwork: 'base', toNetwork: 'bsc', amount: '10' }, 'bridge_not_enabled'], [{ fromNetwork: 'tron', toNetwork: 'base', amount: '10' }, 'bridge_not_enabled'], [{ fromNetwork: 'solana', toNetwork: 'solana', amount: '10' }, 'same_network'], // v8: Solana ↔ EVM is enabled (bridge-sol.test.ts)162 [{ fromNetwork: 'base', toNetwork: 'arbitrum', amount: '0' }, 'bad_amount'], [{ fromNetwork: 'base', toNetwork: 'arbitrum', amount: '1000' }, 'insufficient_funds'], [{ fromNetwork: 'base', toNetwork: 'arbitrum', amount: '60' }, 'policy']] as const)163 expect((await api('POST', '/api/v1/bridge/proposals', b, crypto.randomUUID())).json.error, JSON.stringify(b)).toBe(code);164 expect((await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount: '10', to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' }, crypto.randomUUID())).status).toBe(400); // no recipient field exists165 });166 it('refuses BEFORE any approval when the destination has no gas, and says what to do', async () => {167 chains.arbitrum!.native = 0n;168 const r = await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount: '10' }, crypto.randomUUID());169 expect(r.status).toBe(422); expect(r.json.error).toBe('destination_gas'); expect(r.json.message).toMatch(/Nothing was burned: add ETH on Arbitrum One first/);170 expect(await sql`select 1 from proposals where kind = 'bridge' and state = 'pending_approval'`).toHaveLength(0);171 const q = (await api('GET', '/api/v1/bridge/quote?from=base&to=arbitrum&amount=10')).json;172 expect(q.destinationGas).toMatchObject({ symbol: 'ETH', enough: false });173 expect(q.options.map((o: any) => [o.speed, o.fee, o.seconds])).toEqual([['standard', '0', 1140], ['fast', '1300', 8]]); // 1.3 bps of 10 USDC, rounded up174 });175 it('refuses when Circle upgraded a contract (implementation address or code is not the pinned one), on either side', async () => {176 chains.arbitrum!.implSlot[CCTP_MESSAGE_TRANSMITTER.toLowerCase()] = '0x00000000000000000000000000000000000000aa';177 let r = await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount: '10' }, crypto.randomUUID());178 expect(r.json.error).toBe('contract_changed'); expect(r.json.message).toMatch(/MessageTransmitterV2 implementation address on Arbitrum One/);179 chains.arbitrum!.implSlot[CCTP_MESSAGE_TRANSMITTER.toLowerCase()] = CCTP_ENABLED.base!.transmitterImpl.address; // a real Circle contract, but not the reviewed one for this chain180 r = await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount: '10' }, crypto.randomUUID());181 expect(r.json.error).toBe('contract_changed');182 chains.base!.implSlot[CCTP_TOKEN_MESSENGER.toLowerCase()] = CCTP_ENABLED.base!.transmitterImpl.address;183 r = await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount: '10' }, crypto.randomUUID());184 expect(r.json.error).toBe('contract_changed'); expect(r.json.message).toMatch(/TokenMessengerV2 implementation address on Base/);185 });186 it('Fast needs a source that offers it, a fee quote and Circle\'s allowance', async () => {187 iris.allowance = 5;188 expect((await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount: '10', speed: 'fast' }, crypto.randomUUID())).json.error).toBe('fast_unavailable');189 iris.allowance = 5e7;190 });191});192193describe('life cycle', () => {194 it('burn → attestation pending → attested → mint → done; visible in activity and in the audit chain', async () => {195 const { id, intent, prep } = await burn('10');196 expect(prep.proposal).toMatchObject({ kind: 'bridge', state: 'pending_approval', bridge: { stage: 'review', toNetwork: 'arbitrum', speed: 'standard', cctpMaxFeeDisplay: '0', expectedSeconds: 1140, needsApproval: true, destGasSymbol: 'ETH' } });197 // approval then burn, both to the pinned contracts, on the source chain198 const [a, b] = chains.base!.sent.map((r) => parseTransaction(r as Hex));199 expect(a!.to!.toLowerCase()).toBe(usdc('base').toLowerCase()); expect(b!.to!.toLowerCase()).toBe(CCTP_TOKEN_MESSENGER.toLowerCase()); expect(b!.nonce).toBe(a!.nonce! + 1);200 expect((await getP(id)).bridge).toMatchObject({ stage: 'burning', approvalState: 'confirmed' });201 await bridge.advanceBridges(); // Circle has not seen the burn yet202 let p = await getP(id); expect(p.bridge).toMatchObject({ stage: 'attesting', irisStatus: 'not_found', attested: false });203 iris.messages.set(p.txid, { messages: [{ status: 'pending_confirmations', message: '0x', attestation: 'PENDING', cctpVersion: 2, delayReason: null }] });204 p = await finish(id); expect(p.bridge).toMatchObject({ stage: 'attesting', irisStatus: 'pending_confirmations' });205 expect(chains.arbitrum!.sent).toHaveLength(0);206 iris.messages.set(p.txid, attested(intent));207 p = await finish(id); expect(p.bridge).toMatchObject({ stage: 'minting', attested: true, hold: null });208 const mint = parseTransaction(chains.arbitrum!.sent[0] as Hex);209 expect(mint.to!.toLowerCase()).toBe(CCTP_MESSAGE_TRANSMITTER.toLowerCase()); expect(mint.chainId).toBe(42161);210 chains.arbitrum!.mine();211 p = await finish(id);212 expect(p.bridge).toMatchObject({ stage: 'done', receivedDisplay: '10', mintedBy: 'wallet', mintTxid: keccak256(chains.arbitrum!.sent[0] as Hex), canFinish: false });213 expect(chains.arbitrum!.sent).toHaveLength(1);214 await finish(id); await bridge.advanceBridges(); expect(chains.arbitrum!.sent).toHaveLength(1); // finishing again does nothing215 const act = (await api('GET', '/api/v1/activity')).json.items.find((x: any) => x.id === id);216 expect(act).toMatchObject({ kind: 'bridge', bridge: { stage: 'done' } });217 const audit = (await api('GET', '/api/v1/security/audit?limit=200')).json;218 expect(audit.intact).toBe(true);219 for (const t of ['bridge.prepared', 'bridge.approval_confirmed', 'bridge.burned', 'bridge.attested', 'bridge.mint_signed', 'bridge.done']) expect(audit.events.map((e: any) => e.type)).toContain(t);220 });221 it('Fast: the fee ceiling is approved, the fee Circle took is shown, the wallet receives amount − fee', async () => {222 const { id, intent } = await burn('20', 'fast');223 expect(intent.bridge).toMatchObject({ maxFee: '2600', minFinalityThreshold: 1000 });224 iris.messages.set((await getP(id)).txid, attested(intent, {}, { feeExecuted: 2599n, expirationBlock: 99_999_999n }));225 await finish(id); chains.arbitrum!.mine();226 expect((await finish(id)).bridge).toMatchObject({ stage: 'done', receivedDisplay: '19.997401', feeExecutedDisplay: '0.002599', speed: 'fast' });227 });228 it('restart between the two legs: API state is in the database, the approval is in the signer\'s file; the mint still happens days later', async () => {229 const { id, intent } = await burn('10');230 await bridge.advanceBridges();231 // the signer process restarts (new instance from the same files); the API keeps nothing in memory for a bridge232 signerSrv.close();233 const reborn = new Store(join(signerDir, 'state.json'), join(signerDir, 'policy.json'));234 expect(reborn.state.bridges![id]).toMatchObject({ bridge: { toNetwork: 'arbitrum', amount: '10000000' } });235 const port = Number(new URL(process.env.SPW_SIGNER_URL!).port);236 signerSrv = createSignerServer(new Signer(keys, reborn, { rpId: RP, origin: ORIGIN }), SECRET).listen(port, '127.0.0.1');237 await new Promise((r) => signerSrv.once('listening', r));238 store = reborn;239 await sql`update proposals set created_at = now() - interval '6 days', bridge = jsonb_set(bridge, '{attestingSince}', to_jsonb(${Date.now() - 6 * 86400_000}::bigint)) where id = ${id}`;240 iris.messages.set((await getP(id)).txid, attested(intent));241 await sql`update proposals set bridge = jsonb_set(bridge, '{nextAt}', '0') where id = ${id}`;242 await bridge.advanceBridges(); // the worker alone, no button243 chains.arbitrum!.mine(); await sql`update proposals set bridge = jsonb_set(bridge, '{nextAt}', '0') where id = ${id}`; await bridge.advanceBridges();244 expect((await getP(id)).bridge).toMatchObject({ stage: 'done', mintedBy: 'wallet' });245 });246 it('attestation never arriving: polled with a growing delay, flagged late, never failed, never minted', async () => {247 const { id } = await burn('10');248 for (let k = 0; k < 30; k++) await finish(id);249 let r = await row(id);250 expect(r.bridge).toMatchObject({ phase: 'attesting', polls: 30 }); expect(r.bridge.nextAt - Date.now()).toBeGreaterThan(40_000); // backs off251 const before = iris.calls.length; await bridge.advanceBridges(); expect(iris.calls.length).toBe(before); // the worker respects the delay252 await sql`update proposals set bridge = jsonb_set(bridge, '{attestingSince}', to_jsonb(${Date.now() - 3 * 86400_000}::bigint)) where id = ${id}`;253 const p = await finish(id);254 expect(p.state).toBe('finalized'); expect(p.bridge).toMatchObject({ stage: 'attesting', hold: 'attestation_late', canFinish: true });255 expect(p.bridge.holdDetail).toMatch(/not lost/);256 expect(chains.arbitrum!.sent).toHaveLength(0);257 expect(store.state.bridges![id]!.mint).toBeUndefined(); // the signer was never asked258 });259 it('an attestation answer that is not the approved burn is not used (other recipient, other amount), and the right one still works', async () => {260 const { id, intent } = await burn('10'), txid = (await getP(id)).txid;261 iris.messages.set(txid, attested(intent, {}, { mintRecipient: cctpBytes32('0x70997970C51812dc3A010C7d01b50e0d17dc79C8') }));262 let p = await finish(id); expect(p.bridge).toMatchObject({ stage: 'attesting', hold: 'message_mismatch', attested: false }); expect(p.bridge.holdDetail).toMatch(/another address/);263 iris.messages.set(txid, attested(intent, {}, { amount: 9_000000n }));264 p = await finish(id); expect(p.bridge.holdDetail).toMatch(/another amount/);265 iris.messages.set(txid, { messages: [{ status: 'complete', message: 'garbage', attestation: '0x00' }, ...attested(intent).messages] }); // several messages in one answer: only the matching one is kept266 p = await finish(id); expect(p.bridge).toMatchObject({ stage: 'minting', hold: null });267 });268 it('destination gas missing at mint time: waits with a clear message, signs nothing, finishes once the owner adds gas', async () => {269 const { id, intent } = await burn('10');270 iris.messages.set((await getP(id)).txid, attested(intent));271 chains.arbitrum!.native = 0n;272 let p = await finish(id);273 expect(p.bridge).toMatchObject({ stage: 'ready', hold: 'destination_gas', canFinish: true }); expect(p.bridge.holdDetail).toMatch(/add ETH on Arbitrum One/);274 expect(store.state.bridges![id]!.mint).toBeUndefined(); expect(chains.arbitrum!.sent).toHaveLength(0);275 chains.arbitrum!.native = 10n ** 16n;276 p = await finish(id); expect(p.bridge.stage).toBe('minting');277 chains.arbitrum!.mine(); expect((await finish(id)).bridge.stage).toBe('done');278 });279 it('mint already done by someone else (it is permissionless): detected before signing, recorded as success, nothing is signed', async () => {280 const { id, intent } = await burn('10'), att = attested(intent);281 iris.messages.set((await getP(id)).txid, att);282 chains.arbitrum!.used.add(att.messages[0]!.message.slice(2 + 24, 2 + 24 + 64));283 const p = await finish(id);284 expect(p.bridge).toMatchObject({ stage: 'done', mintedBy: 'other', receivedDisplay: '10' });285 expect(store.state.bridges![id]!.mint).toBeUndefined(); expect(chains.arbitrum!.sent).toHaveLength(0);286 });287 it('someone mints while our transaction is pending: ours reverts ("Nonce already used") and the transfer is still a success', async () => {288 const { id, intent } = await burn('10'), att = attested(intent);289 iris.messages.set((await getP(id)).txid, att);290 expect((await finish(id)).bridge.stage).toBe('minting');291 chains.arbitrum!.used.add(att.messages[0]!.message.slice(2 + 24, 2 + 24 + 64));292 chains.arbitrum!.mine(0);293 expect((await finish(id)).bridge).toMatchObject({ stage: 'done', mintedBy: 'other' });294 });295 it('mint reverted for another reason: the single passkey-free signature is spent; a new one needs the owner\'s passkey over the new parameters', async () => {296 const { id, intent } = await burn('10');297 iris.messages.set((await getP(id)).txid, attested(intent));298 await finish(id); chains.arbitrum!.mine(0);299 let p = await finish(id);300 expect(p.bridge).toMatchObject({ stage: 'mint_failed', hold: 'mint_reverted', needsPasskey: true });301 await finish(id); await bridge.advanceBridges(); expect(chains.arbitrum!.sent).toHaveLength(1); // no silent second attempt302 // a compromised API asking the signer directly for other parameters is refused303 const { signer } = await import('../src/signer-client.ts'); const r = await row(id);304 await expect(signer.bridgeMint({ proposalId: id, message: r.bridge.message, attestation: r.bridge.attestation, evm: { ...r.bridge.mint.evm, nonce: r.bridge.mint.evm.nonce + 1 } })).rejects.toMatchObject({ code: 'signer_replay' });305 const o = await api('POST', `/api/v1/bridge/proposals/${id}/retry-mint/options`, {});306 expect(o.status, JSON.stringify(o.json)).toBe(200);307 const evm = (await row(id)).bridge.retry.evm;308 expect(o.json.options.challenge).toBe(createHash('sha256').update(cctpMintRetryBytes(id, r.bridge.messageHash, evm)).digest('base64url'));309 expect((await api('POST', `/api/v1/bridge/proposals/${id}/retry-mint`, { assertion: new TestPasskey(RP, ORIGIN).assert(Buffer.from(o.json.options.challenge, 'base64url')) })).status).toBe(422);310 const ok = await api('POST', `/api/v1/bridge/proposals/${id}/retry-mint`, { assertion: owner.assert(Buffer.from(o.json.options.challenge, 'base64url')) });311 expect(ok.status, JSON.stringify(ok.json)).toBe(200); expect(ok.json.bridge.stage).toBe('minting');312 expect(chains.arbitrum!.sent).toHaveLength(2);313 chains.arbitrum!.mine(); p = await finish(id);314 expect(p.bridge).toMatchObject({ stage: 'done', mintedBy: 'wallet' });315 expect(store.state.bridges![id]!.mint!.signatures).toBe(2);316 });317 it('an interrupted mint (answer lost after the signer signed) repeats the identical request and gets the same bytes: one signature', async () => {318 const { id, intent } = await burn('10');319 iris.messages.set((await getP(id)).txid, attested(intent));320 await finish(id);321 const sentRaw = chains.arbitrum!.sent[0]!, r = await row(id);322 // rewind the API's row to "parameters chosen, nothing signed yet", as after a crash between the signer's answer and the database write323 await sql`update proposals set bridge = ${sql.json({ ...r.bridge, phase: 'ready', mint: { evm: r.bridge.mint.evm } })} where id = ${id}`;324 const p = await finish(id);325 expect(p.bridge.stage).toBe('minting'); expect((await row(id)).bridge.mint.raw).toBe(sentRaw);326 expect(store.state.bridges![id]!.mint!.signatures).toBe(1);327 });328 it('a burn that would revert after the approval is not sent; a tampered row cannot change what the signer signs', async () => {329 const prep = await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount: '10' }, crypto.randomUUID());330 const id = prep.json.proposal.id;331 // a compromised API/database redirects the transfer after the owner saw it: the passkey approval no longer matches332 await sql`update proposals set intent = jsonb_set(intent, '{to}', '"0x70997970C51812dc3A010C7d01b50e0d17dc79C8"') where id = ${id}`;333 const bad = await api('POST', `/api/v1/transfers/proposals/${id}/approve`, { assertion: owner.assert(Buffer.from(prep.json.options.challenge, 'base64url')) });334 expect(bad.status).toBe(422); expect((await getP(id)).state).toBe('failed');335 const prep2 = await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount: '10' }, crypto.randomUUID());336 await api('POST', `/api/v1/transfers/proposals/${prep2.json.proposal.id}/approve`, { assertion: owner.assert(Buffer.from(prep2.json.options.challenge, 'base64url')) });337 chains.base!.burnReverts = true; chains.base!.mine(); await worker.reconcile();338 const p = await getP(prep2.json.proposal.id);339 expect(p.state).toBe('failed'); expect(p.error).toMatch(/burn would revert now .* not sent/);340 expect(chains.base!.sent).toHaveLength(1); // only the approval left341 });342 it('one operation in flight: a second bridge on the source is refused while the first is being approved or burned', async () => {343 const prep = await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount: '10' }, crypto.randomUUID());344 await api('POST', `/api/v1/transfers/proposals/${prep.json.proposal.id}/approve`, { assertion: owner.assert(Buffer.from(prep.json.options.challenge, 'base64url')) });345 expect((await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'arbitrum', amount: '10' }, crypto.randomUUID())).json.error).toBe('pending_tx');346 });347});348