SPB Git

Repository Public

cctp.test.ts 16.9 KB 199 lines typescript

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

Theme