SPB Git

Repository Public

bridge.test.ts 31.0 KB 348 lines typescript

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
Files main
Menu

Theme