Repository Public
1import { createHash } from 'node:crypto';2import { mkdtempSync } from 'node:fs';3import { tmpdir } from 'node:os';4import { join } from 'node:path';5import { describe, expect, it } from 'vitest';6import { decodeFunctionData, erc20Abi, parseTransaction, recoverTransactionAddress, type Hex } from 'viem';7import { CCTP_ABI, CCTP_ENABLED, CCTP_MESSAGE_TRANSMITTER, CCTP_TOKEN_MESSENGER, cctpBytes32, cctpMintRetryBytes, encodeCctpMessage, intentHash, NETWORKS, verifyAuditChain, type CctpMessage, type Intent } from '@spw/core';8import { TestPasskey } from '../../../tests/helpers/passkey.ts';9import { deriveKeys } from '../src/keys.ts';10import { authHeader, createSignerServer } from '../src/server.ts';11import { Signer } from '../src/signer.ts';12import { Store } from '../src/store.ts';1314// Public BIP-39 test vector mnemonic — holds no funds, never used outside tests.15const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');16const RP = 'wallet.test', ORIGIN = 'https://wallet.test', ME = keys.addresses.evm, OTHER = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';17const usdc = (n: string) => NETWORKS[n]!.tokens.find((t) => t.symbol === 'USDC')!.id;1819async function setup() {20 const dir = mkdtempSync(join(tmpdir(), 'spw-bridge-')), nowRef = { t: Math.floor(Date.now() / 1000) };21 const open = () => { const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json')); return { store, signer: new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => nowRef.t) }; };22 const { store, signer } = open(), pk = new TestPasskey(RP, ORIGIN);23 store.state.setupTokenHash = createHash('sha256').update('t').digest('hex');24 await signer.enroll({ credential: pk.credential(), setupToken: 't' });25 return { store, signer, pk, nowRef, reopen: open };26}27let seq = 0;28function intent(now: number, o: Partial<Intent> = {}, bridge: Partial<NonNullable<Intent['bridge']>> = {}): Intent {29 seq += 1;30 return { v: 1, proposalId: `7a1c1f0e-8f5f-4b1e-9c56-${String(seq).padStart(12, '0')}`, keyId: keys.keyId, network: 'base', chainRef: '8453', asset: usdc('base'), symbol: 'USDC', decimals: 6, amount: '10000000',31 from: ME, to: ME, maxFee: '270000000000000', createdAt: now, expiresAt: now + 75, policyVersion: 3,32 bridge: { provider: 'cctp', toNetwork: 'arbitrum', toDomain: 3, maxFee: '0', minFinalityThreshold: 2000, messenger: CCTP_TOKEN_MESSENGER, transmitter: CCTP_MESSAGE_TRANSMITTER, approveGas: '70000', ...bridge },33 evm: { nonce: 3, gasLimit: '200000', maxFeePerGas: '1000000000', maxPriorityFeePerGas: '1000000' }, ...o };34}35function messageFor(i: Intent, o: Partial<CctpMessage> = {}, body: Partial<CctpMessage['body']> = {}, nonce = 'cd'.repeat(32)): Hex {36 const b = i.bridge!, m = cctpBytes32(CCTP_TOKEN_MESSENGER);37 return encodeCctpMessage({ version: 1, sourceDomain: CCTP_ENABLED[i.network]!.domain, destinationDomain: b.toDomain, nonce, sender: m, recipient: m, destinationCaller: '0'.repeat(64),38 minFinalityThreshold: b.minFinalityThreshold, finalityThresholdExecuted: b.minFinalityThreshold, ...o,39 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 } });40}41const ATT = `0x${'22'.repeat(130)}`;42const GAS = { nonce: 4, gasLimit: '240000', maxFeePerGas: '100000000', maxPriorityFeePerGas: '1000000' };43const sha = (b: Uint8Array) => new Uint8Array(createHash('sha256').update(b).digest());4445describe('bridge, first leg (passkey approval of the whole transfer)', () => {46 it('signs the exact approval and the burn it built itself; both recover to the wallet; the bridge is recorded', async () => {47 const { signer, store, pk, nowRef } = await setup(), i = intent(nowRef.t);48 const out = await signer.sign(i, pk.assert(intentHash(i)));49 const burn = parseTransaction(out.raw as Hex), approve = parseTransaction(out.pre!.raw as Hex);50 expect(await recoverTransactionAddress({ serializedTransaction: out.raw as never })).toBe(ME);51 expect(await recoverTransactionAddress({ serializedTransaction: out.pre!.raw as never })).toBe(ME);52 expect(decodeFunctionData({ abi: erc20Abi, data: approve.data! }).args).toEqual([CCTP_TOKEN_MESSENGER, 10_000000n]);53 expect(approve.to!.toLowerCase()).toBe(usdc('base').toLowerCase()); expect(approve.nonce).toBe(3);54 expect(burn.to!.toLowerCase()).toBe(CCTP_TOKEN_MESSENGER.toLowerCase()); expect(burn.nonce).toBe(4); expect(burn.value ?? 0n).toBe(0n);55 expect(decodeFunctionData({ abi: CCTP_ABI, data: burn.data! }).args).toEqual([10_000000n, 3, `0x${cctpBytes32(ME)}`, usdc('base'), `0x${'0'.repeat(64)}`, 0n, 2000]);56 expect(store.state.bridges![i.proposalId]).toMatchObject({ burnTxid: out.txid, bridge: { fromNetwork: 'base', toNetwork: 'arbitrum', wallet: ME, amount: '10000000', maxFee: '0', minFinalityThreshold: 2000 } });57 expect(store.state.spent[`${new Date(nowRef.t * 1000).toISOString().slice(0, 10)}|base:USDC`]).toBe('10000000'); // counted like a send58 expect(signer.status().bridges).toHaveLength(1);59 });60 it.each([61 ['another recipient', { to: OTHER }, {}, 'self_send'],62 ['another amount than the one approved', {}, {}, 'bad_approval'],63 ['another token', { asset: NETWORKS.base.tokens.find((t) => t.symbol === 'LINK')!.id }, {}, 'unsupported'],64 ['an unpinned messenger', {}, { messenger: OTHER }, 'unsupported'],65 ['an unpinned transmitter', {}, { transmitter: OTHER }, 'unsupported'],66 ['a destination outside CCTP', {}, { toNetwork: 'bsc', toDomain: 17 }, 'unsupported'],67 ['an amount above the per-transfer limit', { amount: '50000001' }, {}, 'policy'],68 ['a network fee above the policy ceiling', { maxFee: '400000000000000' }, {}, 'policy'],69 ['a CCTP fee above 1 %', {}, { maxFee: '100001', minFinalityThreshold: 1000 }, 'unsupported'],70 ] as [string, Partial<Intent>, Partial<NonNullable<Intent['bridge']>>, string][])('refuses %s', async (name, o, b, code) => {71 const { signer, store, pk, nowRef } = await setup(), i = intent(nowRef.t, o, b);72 const approved = name.startsWith('another amount') ? { ...i, amount: '9000000' } : i; // the owner approved 9 USDC, the API asks for 1073 await expect(signer.sign(i, pk.assert(intentHash(approved)))).rejects.toMatchObject({ code });74 expect(store.state.bridges ?? {}).toEqual({});75 expect(store.state.spent).toEqual({});76 });77 it('a bridge cannot be replayed, signed without a passkey, or signed while halted', async () => {78 const { signer, pk, nowRef } = await setup(), i = intent(nowRef.t);79 await expect(signer.sign(i, new TestPasskey(RP, ORIGIN).assert(intentHash(i)))).rejects.toMatchObject({ code: 'unknown_credential' });80 await signer.sign(i, pk.assert(intentHash(i)));81 await expect(signer.sign(i, pk.assert(intentHash(i)))).rejects.toMatchObject({ code: 'replay' });82 signer.halt();83 const j = intent(nowRef.t);84 await expect(signer.sign(j, pk.assert(intentHash(j)))).rejects.toMatchObject({ code: 'policy' });85 });86});8788describe('bridge, second leg (the one matching mint)', () => {89 async function burned(bridge: Partial<NonNullable<Intent['bridge']>> = {}) {90 const s = await setup(), i = intent(s.nowRef.t, {}, bridge);91 await s.signer.sign(i, s.pk.assert(intentHash(i)));92 return { ...s, i };93 }94 it('signs exactly the receiveMessage of the approved burn, to the pinned transmitter on the destination chain, without a second passkey', async () => {95 const { signer, i, store } = await burned(), msg = messageFor(i);96 const out = await signer.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm: GAS });97 const tx = parseTransaction(out.raw as Hex);98 expect(await recoverTransactionAddress({ serializedTransaction: out.raw as never })).toBe(ME);99 expect(tx.to!.toLowerCase()).toBe(CCTP_MESSAGE_TRANSMITTER.toLowerCase()); expect(tx.chainId).toBe(42161); expect(tx.value ?? 0n).toBe(0n); expect(tx.nonce).toBe(4);100 expect(decodeFunctionData({ abi: CCTP_ABI, data: tx.data! })).toMatchObject({ functionName: 'receiveMessage', args: [msg, ATT] });101 expect(out).toMatchObject({ minted: '10000000', replayed: false });102 expect(store.state.bridges![i.proposalId]!.mint).toMatchObject({ txid: out.txid, signatures: 1 });103 expect(verifyAuditChain(store.state.journal)).toBeNull();104 expect(store.state.journal.map((j) => j.type)).toContain('bridge.mint.ok');105 });106 it.each([107 ['whose mintRecipient is not the wallet', {}, { mintRecipient: cctpBytes32(OTHER) }],108 ['for another destination domain', { destinationDomain: 2 }, {}],109 ['from another source domain', { sourceDomain: 0 }, {}],110 ['with another amount', {}, { amount: 10_000001n }],111 ['with another burn token', {}, { burnToken: cctpBytes32(OTHER) }],112 ['burned by another account', {}, { messageSender: cctpBytes32(OTHER) }],113 ['with a destination caller', { destinationCaller: cctpBytes32(OTHER) }, {}],114 ['charging a fee although none was approved', {}, { feeExecuted: 1n }],115 ['of CCTP V1', { version: 0 }, {}],116 ] as [string, Partial<CctpMessage>, Partial<CctpMessage['body']>][])('refuses a message %s, and the bridge stays mintable', async (_n, o, body) => {117 const { signer, i } = await burned();118 await expect(signer.signBridgeMint({ proposalId: i.proposalId, message: messageFor(i, o, body), attestation: ATT, evm: GAS })).rejects.toMatchObject({ code: 'unsupported' });119 expect((await signer.signBridgeMint({ proposalId: i.proposalId, message: messageFor(i), attestation: ATT, evm: GAS })).replayed).toBe(false);120 });121 it('refuses an unknown bridge, a bridge that was never approved, a fee above the destination ceiling, hook data, and a halted signer', async () => {122 const { signer, i, nowRef } = await burned(), msg = messageFor(i);123 await expect(signer.signBridgeMint({ proposalId: '7a1c1f0e-0000-4000-8000-000000000000', message: msg, attestation: ATT, evm: GAS })).rejects.toMatchObject({ code: 'not_found' });124 const never = intent(nowRef.t); // prepared, never approved125 await expect(signer.signBridgeMint({ proposalId: never.proposalId, message: messageFor(never), attestation: ATT, evm: GAS })).rejects.toMatchObject({ code: 'not_found' });126 await expect(signer.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm: { ...GAS, maxFeePerGas: '100000000000' } })).rejects.toMatchObject({ code: 'unsupported', message: expect.stringMatching(/policy ceiling/) });127 await expect(signer.signBridgeMint({ proposalId: i.proposalId, message: msg + 'ff', attestation: ATT, evm: GAS })).rejects.toMatchObject({ code: 'unsupported' });128 await expect(signer.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm: { ...GAS, gasLimit: 'x' } as never })).rejects.toMatchObject({ code: 'bad_request' });129 signer.halt();130 await expect(signer.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm: GAS })).rejects.toMatchObject({ code: 'policy' });131 });132 it('a second mint request for the same bridge: identical → same bytes, anything else → refused without the passkey', async () => {133 const { signer, i, store, pk } = await burned(), msg = messageFor(i);134 const first = await signer.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm: GAS });135 const again = await signer.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm: GAS });136 expect(again).toMatchObject({ raw: first.raw, txid: first.txid, replayed: true });137 for (const evm of [{ ...GAS, nonce: 5 }, { ...GAS, maxFeePerGas: '200000000' }, { ...GAS, gasLimit: '250000' }])138 await expect(signer.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm })).rejects.toMatchObject({ code: 'replay' });139 await expect(signer.signBridgeMint({ proposalId: i.proposalId, message: messageFor(i, {}, {}, 'ee'.repeat(32)), attestation: ATT, evm: GAS })).rejects.toMatchObject({ code: 'replay' });140 expect(store.state.bridges![i.proposalId]!.mint!.signatures).toBe(1);141 // with the owner's passkey over the exact new parameters, one more signature is possible (stuck or replaced transaction)142 const evm = { ...GAS, nonce: 6 }, h = store.state.bridges![i.proposalId]!.mint!.messageHash;143 await expect(signer.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm, assertion: pk.assert(sha(cctpMintRetryBytes(i.proposalId, h, GAS))) })).rejects.toMatchObject({ code: 'bad_approval' }); // approval of other parameters144 await expect(signer.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm, assertion: new TestPasskey(RP, ORIGIN).assert(sha(cctpMintRetryBytes(i.proposalId, h, evm))) })).rejects.toMatchObject({ code: 'unknown_credential' });145 const retry = await signer.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm, assertion: pk.assert(sha(cctpMintRetryBytes(i.proposalId, h, evm))) });146 expect(parseTransaction(retry.raw as Hex).nonce).toBe(6); expect(store.state.bridges![i.proposalId]!.mint!.signatures).toBe(2);147 });148 it('one message serves one bridge: two identical transfers cannot both be "minted" with the same message', async () => {149 const s = await setup(), a = intent(s.nowRef.t), b = intent(s.nowRef.t, { evm: { nonce: 5, gasLimit: '200000', maxFeePerGas: '1000000000', maxPriorityFeePerGas: '1000000' } });150 await s.signer.sign(a, s.pk.assert(intentHash(a))); await s.signer.sign(b, s.pk.assert(intentHash(b)));151 const msg = messageFor(a);152 await s.signer.signBridgeMint({ proposalId: a.proposalId, message: msg, attestation: ATT, evm: GAS });153 await expect(s.signer.signBridgeMint({ proposalId: b.proposalId, message: msg, attestation: ATT, evm: { ...GAS, nonce: 5 } })).rejects.toMatchObject({ code: 'replay' });154 expect((await s.signer.signBridgeMint({ proposalId: b.proposalId, message: messageFor(b, {}, {}, 'ef'.repeat(32)), attestation: ATT, evm: { ...GAS, nonce: 5 } })).replayed).toBe(false);155 });156 it('survives a signer restart and days of waiting: the approval does not expire, the single signature is not forgotten', async () => {157 const { i, nowRef, reopen } = await burned({ maxFee: '1300', minFinalityThreshold: 1000 });158 nowRef.t += 9 * 86400;159 const restarted = reopen().signer, msg = messageFor(i, {}, { feeExecuted: 1300n, expirationBlock: 777n });160 const out = await restarted.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm: GAS });161 expect(out.minted).toBe('9998700');162 nowRef.t += 86400;163 const again = reopen().signer;164 expect((await again.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm: GAS })).raw).toBe(out.raw);165 await expect(again.signBridgeMint({ proposalId: i.proposalId, message: msg, attestation: ATT, evm: { ...GAS, nonce: 8 } })).rejects.toMatchObject({ code: 'replay' });166 });167 it('is reachable through the authenticated gateway only, as its own narrow route', async () => {168 const { signer, i } = await burned(), secret = 's'.repeat(40);169 const srv = createSignerServer(signer, secret).listen(0, '127.0.0.1'); await new Promise((r) => srv.once('listening', r));170 const url = `http://127.0.0.1:${(srv.address() as { port: number }).port}`, body = JSON.stringify({ proposalId: i.proposalId, message: messageFor(i), attestation: ATT, evm: GAS });171 try {172 expect((await fetch(`${url}/bridge/mint`, { method: 'POST', body })).status).toBe(401);173 const ok = await fetch(`${url}/bridge/mint`, { method: 'POST', body, headers: { 'x-spw-auth': authHeader(secret, 'POST', '/bridge/mint', body) } });174 expect(ok.status).toBe(200); expect((await ok.json() as { replayed: boolean }).replayed).toBe(false);175 const bad = JSON.stringify({ proposalId: i.proposalId, message: messageFor(i, {}, { mintRecipient: cctpBytes32(OTHER) }), attestation: ATT, evm: GAS });176 const no = await fetch(`${url}/bridge/mint`, { method: 'POST', body: bad, headers: { 'x-spw-auth': authHeader(secret, 'POST', '/bridge/mint', bad) } });177 expect(no.status).toBe(422); expect((await no.json() as { message: string }).message).toMatch(/another address/);178 } finally { srv.close(); }179 });180});181