Repository Public
1import { readFileSync } from 'node:fs';2import { describe, expect, it } from 'vitest';3import { decodeFunctionData, erc20Abi, keccak256, recoverAddress, type Hex } from 'viem';4import {5 bridgeOf, buildCctpBurnTxs, buildCctpMintTx, buildEvmSwapTxs, buildEvmTx, canonicalBytes, CCTP_ABI, CCTP_ENABLED, CCTP_MESSAGE_TRANSMITTER, CCTP_TOKEN_MESSENGER, cctpBurnData, cctpBytes32, cctpMessageHash,6 cctpMintRetryBytes, checkPolicy, decodeCctpMessage, DEFAULT_POLICY, encodeCctpMessage, intentHash, NETWORKS, toHex, verifyCctpBridge, verifyCctpMint, type CctpMessage, type Intent,7} from '../src/index.ts';89const ME = '0x1111111111111111111111111111111111111111', OTHER = '0x2222222222222222222222222222222222222222';10const usdc = (n: string) => NETWORKS[n]!.tokens.find((t) => t.symbol === 'USDC')!.id;11const now = 1_800_000_000;12function intent(o: Partial<Intent> = {}, bridge: Partial<NonNullable<Intent['bridge']>> = {}): Intent {13 return { v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a22', keyId: 'spw-0123456789abcdef', network: 'base', chainRef: '8453', asset: usdc('base'), symbol: 'USDC', decimals: 6, amount: '25000000',14 from: ME, to: ME, maxFee: '400000000000000', createdAt: now, expiresAt: now + 75, policyVersion: 3,15 bridge: { provider: 'cctp', toNetwork: 'arbitrum', toDomain: 3, maxFee: '0', minFinalityThreshold: 2000, messenger: CCTP_TOKEN_MESSENGER, transmitter: CCTP_MESSAGE_TRANSMITTER, approveGas: '70000', ...bridge },16 evm: { nonce: 5, gasLimit: '200000', maxFeePerGas: '1000000000', maxPriorityFeePerGas: '1000000' }, ...o };17}18/** The attested message Circle would return for the burn of `i` (nonce, executed finality and fee are filled in by Circle). */19function messageFor(i: Intent, o: Partial<CctpMessage> = {}, body: Partial<CctpMessage['body']> = {}): Hex {20 const b = i.bridge!, m = cctpBytes32(CCTP_TOKEN_MESSENGER);21 return encodeCctpMessage({ version: 1, sourceDomain: CCTP_ENABLED[i.network]!.domain, destinationDomain: b.toDomain, nonce: 'ab'.repeat(32), sender: m, recipient: m, destinationCaller: '0'.repeat(64),22 minFinalityThreshold: b.minFinalityThreshold, finalityThresholdExecuted: b.minFinalityThreshold, ...o,23 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 } });24}25const ATT = `0x${'11'.repeat(130)}` as Hex;26const GAS = { nonce: 9, gasLimit: '240000', maxFeePerGas: '100000000', maxPriorityFeePerGas: '1000000' };2728describe('CCTP pins', () => {29 it('every enabled network is an EVM network with registry USDC, a distinct domain and pinned implementations', () => {30 const domains = new Set<number>();31 for (const [id, p] of Object.entries(CCTP_ENABLED)) {32 expect(NETWORKS[id]!.family).toBe('evm'); expect(usdc(id)).toMatch(/^0x[0-9a-fA-F]{40}$/);33 expect(domains.has(p.domain)).toBe(false); domains.add(p.domain);34 for (const impl of [p.messengerImpl, p.transmitterImpl]) { expect(impl.address).toMatch(/^0x[0-9a-fA-F]{40}$/); expect(impl.codeSha256).toMatch(/^[0-9a-f]{64}$/); }35 if (p.fastSource) expect(p.fastSeconds).not.toBeNull();36 }37 expect(Object.keys(CCTP_ENABLED).sort()).toEqual(['arbitrum', 'avalanche', 'base', 'ethereum', 'hyperevm', 'optimism', 'polygon']);38 expect(CCTP_ENABLED.bsc).toBeUndefined(); // domain 17 carries no USDC39 });40});4142describe('CCTP message format', () => {43 const fx = JSON.parse(readFileSync(new URL('../../swap/test/fixtures/cctp/message-arbitrum-base-fast.json', import.meta.url), 'utf8')).messages[0] as { message: string; attestation: string; decodedMessage: any };44 it('decodes a real attested mainnet message exactly as Circle describes it, and re-encodes it byte for byte', () => {45 const m = decodeCctpMessage(fx.message), d = fx.decodedMessage, b = d.decodedMessageBody;46 expect([m.version, m.sourceDomain, m.destinationDomain, m.minFinalityThreshold, m.finalityThresholdExecuted]).toEqual([1, Number(d.sourceDomain), Number(d.destinationDomain), Number(d.minFinalityThreshold), Number(d.finalityThresholdExecuted)]);47 expect(`0x${m.nonce}`).toBe(d.nonce); expect(m.sender).toBe(cctpBytes32(d.sender)); expect(m.recipient).toBe(cctpBytes32(d.recipient)); expect(m.destinationCaller).toBe('0'.repeat(64));48 expect(m.body.burnToken).toBe(cctpBytes32(b.burnToken)); expect(m.body.mintRecipient).toBe(cctpBytes32(b.mintRecipient)); expect(m.body.messageSender).toBe(cctpBytes32(b.messageSender));49 expect([m.body.amount, m.body.maxFee, m.body.feeExecuted, m.body.expirationBlock].map(String)).toEqual([b.amount, b.maxFee, b.feeExecuted, b.expirationBlock]);50 expect(encodeCctpMessage(m)).toBe(fx.message.toLowerCase());51 expect(cctpMessageHash(fx.message)).toBe(keccak256(fx.message as Hex).slice(2));52 });53 it('the real attestation is two signatures over keccak256(message) by the attesters enabled on-chain, in ascending order', async () => {54 const digest = keccak256(fx.message as Hex), sigs = fx.attestation.slice(2);55 expect(sigs.length).toBe(130 * 2);56 const a = await recoverAddress({ hash: digest, signature: `0x${sigs.slice(0, 130)}` }), b = await recoverAddress({ hash: digest, signature: `0x${sigs.slice(130)}` });57 // getEnabledAttester(0..1) on every enabled network on 2026-11-11 (scripts/cctp-pins.mts)58 expect([a, b].sort()).toEqual(['0x52Ed4cBff8DcE6A19748043F3240ec03c834bCeF', '0x725b06F73ff761eF5390e39315e2BfbF60d33F96']);59 expect(BigInt(a) < BigInt(b)).toBe(true);60 });61 it('that real message verifies as the mint of the transfer it came from, and of no other', () => {62 const b = { fromNetwork: 'arbitrum', toNetwork: 'base', wallet: '0xb143f6ca55358fac056eca7ac1a228a1f018b048', amount: '100584300', maxFee: '14363', minFinalityThreshold: 1000 };63 const v = verifyCctpMint(b, fx.message, fx.attestation);64 expect(v.minted).toBe(100584300n - 14081n);65 expect(() => verifyCctpMint({ ...b, wallet: ME }, fx.message, fx.attestation)).toThrow(/another address/);66 expect(() => verifyCctpMint({ ...b, toNetwork: 'optimism' }, fx.message, fx.attestation)).toThrow(/destination domain/);67 expect(() => verifyCctpMint({ ...b, amount: '100584301' }, fx.message, fx.attestation)).toThrow(/another amount/);68 });69 it('refuses anything that is not a 376-byte burn message (hook data, truncation, garbage)', () => {70 expect(() => decodeCctpMessage(fx.message + 'ab')).toThrow(/376/);71 expect(() => decodeCctpMessage(fx.message.slice(0, 300))).toThrow();72 expect(() => decodeCctpMessage('nope')).toThrow(/hex/);73 expect(() => decodeCctpMessage(fx.message.slice(0, -1))).toThrow();74 });75});7677describe('bridge intent', () => {78 it('builds the exact-amount approval and the burn from the intent alone', () => {79 const i = intent(), { approve, burn } = buildCctpBurnTxs(i);80 expect(approve!.nonce).toBe(5); expect(approve!.to).toBe(usdc('base')); expect(approve!.value).toBe(0n);81 expect(decodeFunctionData({ abi: erc20Abi, data: approve!.data! }).args).toEqual([CCTP_TOKEN_MESSENGER, 25_000000n]); // exact, never unlimited82 expect(burn.nonce).toBe(6); expect(burn.to).toBe(CCTP_TOKEN_MESSENGER); expect(burn.value).toBe(0n); expect(burn.chainId).toBe(8453);83 const d = decodeFunctionData({ abi: CCTP_ABI, data: burn.data! });84 expect(d.functionName).toBe('depositForBurn');85 expect(d.args).toEqual([25_000000n, 3, `0x${cctpBytes32(ME)}`, usdc('base'), `0x${'0'.repeat(64)}`, 0n, 2000]);86 expect(burn.data!.slice(0, 10)).toBe('0x8e0250ee'); // selector seen in real mainnet burns (dossier §7.1)87 expect((burn.data!.length - 2) / 2).toBe(4 + 7 * 32);88 // allowance already in place: a single transaction at the first nonce89 const t = buildCctpBurnTxs(intent({}, { approveGas: '0' }));90 expect(t.approve).toBeNull(); expect(t.burn.nonce).toBe(5);91 });92 it('Fast: fee ceiling and threshold 1000 go into the calldata', () => {93 const d = decodeFunctionData({ abi: CCTP_ABI, data: cctpBurnData(intent({}, { maxFee: '3250', minFinalityThreshold: 1000 })) });94 expect(d.args!.slice(5)).toEqual([3250n, 1000]);95 });96 it.each([97 ['another recipient', { to: OTHER }, {}, /wallet itself/],98 ['another token (USDT is not in CCTP)', { asset: NETWORKS.ethereum.tokens.find((t) => t.symbol === 'USDT')!.id, network: 'ethereum', chainRef: '1', symbol: 'USDT' }, {}, /registry USDC/],99 ['a token that only claims to be USDC', { asset: OTHER }, {}, /registry USDC|not allowlisted|unknown/i],100 ['the native coin', { asset: 'native', symbol: 'ETH', decimals: 18 }, {}, /registry USDC/],101 ['wrong decimals', { decimals: 18 }, {}, /metadata/],102 ['zero amount', { amount: '0' }, {}, /positive|below/],103 ['wrong chain id', { chainRef: '1' }, {}, /chain id/],104 ['an unpinned messenger', {}, { messenger: OTHER }, /pinned TokenMessengerV2/],105 ['the V1 messenger', {}, { messenger: '0x1682Ae6375C4E4A97e4B583BC394c861A46D8962' }, /pinned TokenMessengerV2/],106 ['an unpinned transmitter', {}, { transmitter: OTHER }, /pinned MessageTransmitterV2/],107 ['a destination that is the source', {}, { toNetwork: 'base', toDomain: 6 }, /same network/],108 ['a domain that is not the destination network\'s', {}, { toDomain: 0 }, /destination domain/],109 ['a network without CCTP USDC (BNB Chain)', {}, { toNetwork: 'bsc', toDomain: 17 }, /not enabled/],110 ['Solana (not in scope)', {}, { toNetwork: 'solana', toDomain: 5 }, /not enabled/],111 ['a fee ceiling equal to the amount', {}, { maxFee: '25000000', minFinalityThreshold: 1000 }, /below the amount/],112 ['a fee ceiling above 1 %', {}, { maxFee: '250001', minFinalityThreshold: 1000 }, /1 %/],113 ['a finality threshold Circle does not define', {}, { minFinalityThreshold: 500 }, /1000 .* or 2000/],114 ['Fast from a network where Circle does not offer it', { network: 'polygon', chainRef: '137', asset: usdc('polygon') }, { minFinalityThreshold: 1000, maxFee: '1' }, /Fast Transfer is not offered/],115 ['a swap section next to the bridge', { swap: { provider: 'kyberswap', toAsset: 'native', minOut: '1', router: OTHER, dataHash: '0'.repeat(64), approveGas: '0' } }, {}, /not both|exclude/],116 ] as [string, Partial<Intent>, Partial<NonNullable<Intent['bridge']>>, RegExp][])('refuses %s', (_n, o, b, re) => {117 expect(() => buildCctpBurnTxs(intent(o, b))).toThrow(re);118 });119 it('bounds gas and fee like a swap', () => {120 expect(() => buildCctpBurnTxs(intent({ evm: { nonce: 5, gasLimit: '500000', maxFeePerGas: '1000000000', maxPriorityFeePerGas: '1' } }))).toThrow(/gas limit/);121 expect(() => buildCctpBurnTxs(intent({}, { approveGas: '500000' }))).toThrow(/approval gas/);122 expect(() => buildCctpBurnTxs(intent({ maxFee: '1000' }))).toThrow(/fee exceeds/);123 expect(() => buildCctpBurnTxs(intent({ evm: { nonce: 5, gasLimit: '200000', maxFeePerGas: '1', maxPriorityFeePerGas: '2' } }))).toThrow(/priority/);124 });125 it('the approval hash commits to every bridge field, and a bridge is neither a transfer nor a swap', () => {126 const base = toHex(intentHash(intent()));127 for (const b of [{ toNetwork: 'optimism', toDomain: 2 }, { toDomain: 2 }, { maxFee: '1' }, { minFinalityThreshold: 1000 }, { messenger: OTHER }, { transmitter: OTHER }, { approveGas: '0' }] as Partial<NonNullable<Intent['bridge']>>[])128 expect(toHex(intentHash(intent({}, b)))).not.toBe(base);129 const { bridge: _b, ...plain } = intent({ to: OTHER });130 expect(toHex(intentHash(plain as Intent))).not.toBe(base);131 expect(new TextDecoder().decode(canonicalBytes(plain as Intent))).not.toContain('bridge.'); // intents without a bridge hash exactly as before132 expect(() => buildEvmTx(intent())).toThrow(/bridge intent/);133 expect(() => buildEvmSwapTxs(intent(), '0x00')).toThrow();134 expect(() => intentHash({ ...intent(), network: 'solana', chainRef: 'x' } as Intent)).toThrow();135 });136 it('policy applies as for a send: per-transfer and daily limits on the source, fee ceiling, expiry, halt', () => {137 const i = intent({ maxFee: '270000000000000' });138 expect(checkPolicy(DEFAULT_POLICY, i, 0n, now, false)).toEqual({ ok: true });139 expect(checkPolicy(DEFAULT_POLICY, intent({ amount: '50000001', maxFee: '270000000000000' }), 0n, now, false)).toMatchObject({ ok: false, reason: expect.stringMatching(/per-transaction/) });140 expect(checkPolicy(DEFAULT_POLICY, i, 80_000000n, now, false)).toMatchObject({ ok: false, reason: expect.stringMatching(/daily/) });141 expect(checkPolicy(DEFAULT_POLICY, intent(), 0n, now, false)).toMatchObject({ ok: false, reason: expect.stringMatching(/fee ceiling/) }); // 0.0004 ETH > 0.0003 on Base142 expect(checkPolicy(DEFAULT_POLICY, i, 0n, now + 76, false).ok).toBe(false);143 expect(checkPolicy(DEFAULT_POLICY, i, 0n, now, true).ok).toBe(false);144 expect(checkPolicy({ ...DEFAULT_POLICY, allowlist: ['base:' + OTHER] }, i, 0n, now, false).ok).toBe(false);145 });146});147148describe('mint leg: the attested message must be exactly the approved burn', () => {149 const i = intent({}, { maxFee: '3250', minFinalityThreshold: 1000 }), b = bridgeOf(i);150 it('accepts the genuine message and builds one receiveMessage to the pinned transmitter of the destination', () => {151 const msg = messageFor(i, {}, { feeExecuted: 3249n, expirationBlock: 123n });152 const v = verifyCctpMint(b, msg, ATT);153 expect(v.minted).toBe(25_000000n - 3249n); expect(v.messageHash).toBe(keccak256(msg).slice(2));154 const tx = buildCctpMintTx(b, msg, ATT, GAS, 300_000_000_000_000n);155 expect(tx.to).toBe(CCTP_MESSAGE_TRANSMITTER); expect(tx.chainId).toBe(42161); expect(tx.value).toBe(0n); expect(tx.nonce).toBe(9);156 const d = decodeFunctionData({ abi: CCTP_ABI, data: tx.data! });157 expect(d.functionName).toBe('receiveMessage'); expect(d.args).toEqual([msg, ATT]);158 expect(tx.data!.slice(0, 10)).toBe('0x57ecfd28');159 // a Fast request attested at full finality is fine (Circle degraded it to Standard: no fee)160 expect(verifyCctpMint(b, messageFor(i, { finalityThresholdExecuted: 2000 }), ATT).minted).toBe(25_000000n);161 });162 it.each([163 ['mintRecipient is not the wallet', {}, { mintRecipient: cctpBytes32(OTHER) }, /another address/],164 ['burned by someone else', {}, { messageSender: cctpBytes32(OTHER) }, /not burned by this wallet/],165 ['another destination domain', { destinationDomain: 2 }, {}, /destination domain/],166 ['another source domain', { sourceDomain: 0 }, {}, /source domain/],167 ['another amount', {}, { amount: 25_000001n }, /another amount/],168 ['another burn token', {}, { burnToken: cctpBytes32(OTHER) }, /another token/],169 ['another fee ceiling', {}, { maxFee: 9999n }, /fee ceiling/],170 ['a fee above the ceiling', {}, { feeExecuted: 3251n }, /more than the approved/],171 ['a restricted destination caller', { destinationCaller: cctpBytes32(OTHER) }, {}, /destination caller/],172 ['a sender that is not the TokenMessenger', { sender: cctpBytes32(OTHER) }, {}, /pinned TokenMessengers/],173 ['a recipient contract that is not the TokenMessenger', { recipient: cctpBytes32(OTHER) }, {}, /pinned TokenMessengers/],174 ['CCTP V1 (version 0)', { version: 0 }, {}, /not CCTP V2/],175 ['another body version', {}, { version: 2 }, /not CCTP V2/],176 ['an unattested message (zero nonce)', { nonce: '0'.repeat(64) }, {}, /no nonce/],177 ['another requested finality', { minFinalityThreshold: 2000, finalityThresholdExecuted: 2000 }, {}, /another finality/],178 ['attested below the approved finality', { finalityThresholdExecuted: 500 }, {}, /below the approved finality/],179 ] as [string, Partial<CctpMessage>, Partial<CctpMessage['body']>, RegExp][])('refuses a message with %s', (_n, o, body, re) => {180 expect(() => buildCctpMintTx(b, messageFor(i, o, body), ATT, GAS, 10n ** 18n)).toThrow(re);181 });182 it('refuses a malformed attestation, a Standard approval attested early, hook data, and gas outside the bounds', () => {183 const msg = messageFor(i);184 for (const att of ['0x', `0x${'11'.repeat(64)}`, `0x${'11'.repeat(66)}`, 'zz', `0x${'11'.repeat(65 * 9)}`]) expect(() => verifyCctpMint(b, msg, att)).toThrow(/attestation/);185 const std = intent();186 expect(() => verifyCctpMint(bridgeOf(std), messageFor(std, { finalityThresholdExecuted: 1000 }), ATT)).toThrow(/below the approved finality/);187 expect(() => verifyCctpMint(b, msg + '00', ATT)).toThrow(/376/);188 expect(() => buildCctpMintTx(b, msg, ATT, { ...GAS, gasLimit: '900000' }, 10n ** 18n)).toThrow(/gas limit/);189 expect(() => buildCctpMintTx(b, msg, ATT, GAS, 1000n)).toThrow(/policy ceiling/);190 expect(() => buildCctpMintTx(b, msg, ATT, { ...GAS, maxPriorityFeePerGas: '100000001' }, 10n ** 18n)).toThrow(/priority/);191 expect(() => buildCctpMintTx({ ...b, toNetwork: 'base' }, msg, ATT, GAS, 10n ** 18n)).toThrow(/not enabled/);192 });193 it('the retry challenge binds the bridge, the message and the gas parameters', () => {194 const a = Buffer.from(cctpMintRetryBytes('id-1', 'aa', GAS)).toString();195 for (const x of [cctpMintRetryBytes('id-2', 'aa', GAS), cctpMintRetryBytes('id-1', 'ab', GAS), cctpMintRetryBytes('id-1', 'aa', { ...GAS, nonce: 10 }), cctpMintRetryBytes('id-1', 'aa', { ...GAS, maxFeePerGas: '2' })])196 expect(Buffer.from(x).toString()).not.toBe(a);197 });198});199