SPB Git

Repository Public

cctp.test.ts 35.0 KB 434 lines typescript

1// CCTP adapter against responses recorded from Circle's live Iris API and from the registry's public RPC endpoints2// on 2026-11-11 (fixtures/cctp). No network here: the HttpClient below serves the recordings.3import { readFileSync } from 'node:fs';4import { describe, expect, it } from 'vitest';5import { NETWORKS } from '@spw/core';6import { checkQuote, ProviderError, type AssetRef, type HttpClient, type ProviderContext, type SwapQuoteRequest } from '../src/index.ts';7import { createProviders } from '../src/providers/index.ts';8import { CCTP_CHAINS, CCTP_EVM, CCTP_SOLANA, CCTPX, cctpFee, createCctpProvider, decodeCctpMessageV2, evmAddressToBytes32, solanaAddressToBytes32, USDC_BURN_LIMIT } from '../src/providers/cctp.ts';910const fx = (name: string): any => JSON.parse(readFileSync(new URL(`./fixtures/cctp/${name}.json`, import.meta.url), 'utf8'));11const tok = (network: string, symbol: string): AssetRef => ({ network, asset: NETWORKS[network]!.tokens.find((t) => t.symbol === symbol)!.id });12const usdc = (n: string) => tok(n, 'USDC'), eurc = (n: string) => tok(n, 'EURC');13const EVM_A = '0x28C6c06298d514Db089934071355E5743bf21d60', EVM_B = '0x21a31Ee1afC51d94C2eFcCAa2092aD1028285549';14const SOL_A = '5tzFkiKscXHK5ZXCGbXZxdw7gTjjD1mBwuoFbhUvuAi9';15const addr = (n: string, alt = false) => (NETWORKS[n]!.family === 'evm' ? (alt ? EVM_B : EVM_A) : SOL_A);16const req = (from: AssetRef, to: AssetRef, amountIn: bigint, o: Partial<SwapQuoteRequest> = {}): SwapQuoteRequest => ({ from, to, amountIn, slippageBps: 50, ...o, sender: o.sender ?? addr(from.network), recipient: o.recipient ?? addr(to.network, true) });17const NOW = 1_794_359_750_000; // just after the EURC fixtures were issued, before they expire1819type Reply = unknown | ((url: string, body: unknown) => unknown);20const httpError = (status: number, body: unknown) => Object.assign(new ProviderError('cctp', 'http', `HTTP ${status}`), { status, body });21/** Scripted HttpClient: the first rule whose key is contained in the URL (and, for RPC, whose method matches) answers. */22function mock(rules: [string, Reply][]) {23  const calls: { method: 'GET' | 'POST'; url: string; body?: any; signal?: AbortSignal }[] = [];24  const answer = (url: string, body: unknown) => {25    for (const [key, reply] of rules) {26      const [u, m] = key.split('#');27      const methods = Array.isArray(body) ? (body as any[]).map((b) => b.method) : [(body as any)?.method];28      if (!url.includes(u!) || (m && !methods.includes(m))) continue;29      const v = typeof reply === 'function' ? (reply as (u: string, b: unknown) => unknown)(url, body) : reply;30      if (v instanceof Error) throw v;31      return structuredClone(v);32    }33    throw new Error(`unexpected request in test: ${url}`);34  };35  const http: HttpClient = {36    getJson: async (url, init) => { calls.push({ method: 'GET', url, signal: init?.signal }); return answer(url, undefined); },37    postJson: async (url, body, init) => { calls.push({ method: 'POST', url, body, signal: init?.signal }); return answer(url, body); },38  };39  return { http, calls };40}41const ctxOf = (http: HttpClient, signal = new AbortController().signal): ProviderContext => ({ http, signal, now: () => NOW });42/** recorded eth_gasPrice answers; a network without its own recording is served Base's (the arithmetic is what is under test) */43const gas = (network: string) => fx(`rpc-${['ethereum', 'base', 'avalanche'].includes(network) ? network : 'base'}-gasprice`);44/** the recordings for a plain EVM -> EVM USDC route */45const evmRules = (fees: string, src: string, dst: string, extra: [string, Reply][] = []): [string, Reply][] => [46  ...extra, ['/v2/burn/USDC/fees/', fx(fees)], ['/v2/fastBurn/USDC/allowance', fx('allowance')],47  [NETWORKS[src]!.rpc[0]!, gas(src)], [NETWORKS[dst]!.rpc[0]!, gas(dst)],48];49const provider = createCctpProvider();50const ETH_GAS = BigInt(gas('ethereum').result), BASE_GAS = BigInt(gas('base').result), AVAX_GAS = BigInt(gas('avalanche').result);5152describe('cctp: capabilities and pair filter', () => {53  it('is registered and keyless', () => {54    expect(createProviders().map((p) => p.id)).toContain('cctp');55    const c = provider.capabilities();56    expect(c).toMatchObject({ crossChain: true, needsKey: false, configured: true, kinds: ['bridge'], docs: 'https://developers.circle.com/cctp' });57    expect([...c.networks].sort()).toEqual(['arbitrum', 'avalanche', 'base', 'ethereum', 'hyperevm', 'optimism', 'polygon', 'solana']);58  });59  it('maps each wallet network to Circle\'s domain id and Fast availability', () => {60    expect(Object.fromEntries(Object.entries(CCTP_CHAINS).map(([n, c]) => [n, c.domain]))).toEqual({ ethereum: 0, avalanche: 1, optimism: 2, arbitrum: 3, solana: 5, base: 6, polygon: 7, hyperevm: 19 });61    expect(Object.entries(CCTP_CHAINS).filter(([, c]) => c.fastSource).map(([n]) => n).sort()).toEqual(['arbitrum', 'base', 'ethereum', 'optimism', 'solana']);62    for (const c of Object.values(CCTP_CHAINS)) expect(c.fastSource).toBe(c.fastSeconds !== null);63    // every CCTP network is in the wallet registry with a USDC deployment, and BNB Smart Chain (USYC only) is not served64    for (const n of Object.keys(CCTP_CHAINS)) expect(NETWORKS[n]!.tokens.some((t) => t.symbol === 'USDC')).toBe(true);65    expect(CCTP_CHAINS.bsc).toBeUndefined();66    expect(Object.entries(CCTP_CHAINS).filter(([, c]) => c.eurc).map(([n]) => n).sort()).toEqual(['avalanche', 'base', 'ethereum']);67  });68  it('accepts every USDC route between distinct CCTP networks and the three EURC networks', () => {69    const nets = Object.keys(CCTP_CHAINS);70    for (const a of nets) for (const b of nets) expect(provider.supportsPair(usdc(a), usdc(b))).toBe(a !== b);71    for (const a of ['ethereum', 'avalanche', 'base']) for (const b of ['ethereum', 'avalanche', 'base']) expect(provider.supportsPair(eurc(a), eurc(b))).toBe(a !== b);72    // EVM contract addresses are case-insensitive73    expect(provider.supportsPair({ network: 'base', asset: usdc('base').asset.toLowerCase() }, usdc('solana'))).toBe(true);74  });75  it('refuses anything that is not the registry USDC/EURC on a CCTP network', async () => {76    const no: [AssetRef, AssetRef][] = [77      [tok('ethereum', 'USDT'), usdc('base')], // another stablecoin78      [usdc('base'), tok('ethereum', 'USDT')],79      [{ network: 'polygon', asset: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' }, usdc('base')], // bridged USDC.e: not in the registry80      [{ network: 'solana', asset: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB' }, usdc('base')], // USDT mint81      [usdc('base'), { network: 'solana', asset: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1V' }], // wrong case = another mint82      [{ network: 'ethereum', asset: 'native' }, usdc('base')],83      [usdc('ethereum'), { network: 'bsc', asset: 'native' }],84      [usdc('ethereum'), { network: 'bsc', asset: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d' }], // Binance-Peg USDC85      [usdc('ethereum'), { network: 'stellar', asset: 'native' }],86      [usdc('ethereum'), { network: 'nowhere', asset: 'x' }],87      [usdc('ethereum'), usdc('ethereum')], // same network88      [usdc('ethereum'), eurc('base')], // not a swap89      [eurc('solana'), eurc('base')], [eurc('base'), eurc('solana')], // CCTP for non-USDC is EVM only90    ];91    const { http, calls } = mock([]);92    for (const [a, b] of no) {93      expect(provider.supportsPair(a, b)).toBe(false);94      expect(await provider.quote(req(a, b, 25_000_000n, { sender: EVM_A, recipient: EVM_B }), ctxOf(http))).toEqual([]);95    }96    expect(calls).toHaveLength(0);97  });98});99100describe('cctp: fee arithmetic in base units', () => {101  it('rounds the charged fee down and maxFee up, exactly', () => {102    expect(cctpFee(25_000_000n, 1)).toEqual({ fee: 2_500n, maxFee: 2_500n });103    expect(cctpFee(25_000_000n, 1.3)).toEqual({ fee: 3_250n, maxFee: 3_250n });104    expect(cctpFee(25_000_000n, 0)).toEqual({ fee: 0n, maxFee: 0n });105    // real Arbitrum burns at 1.4 bps and what Iris executed (maxFee -> feeExecuted): 14363 -> 14081, 1396 -> 1395, 243528 -> 243527106    expect(cctpFee(100_584_300n, 1.4)).toEqual({ fee: 14_081n, maxFee: 14_082n });107    expect(cctpFee(9_966_740n, 1.4)).toEqual({ fee: 1_395n, maxFee: 1_396n });108    expect(cctpFee(1_739_483_528n, 1.4)).toEqual({ fee: 243_527n, maxFee: 243_528n });109    expect(cctpFee(1n, 1)).toEqual({ fee: 0n, maxFee: 1n });110    // no float anywhere: 10 million USDC at 1.3 bps111    expect(cctpFee(USDC_BURN_LIMIT, 1.3)).toEqual({ fee: 1_300_000_000n, maxFee: 1_300_000_000n });112    expect(cctpFee(9_999_999_999_999n, 1.3)).toEqual({ fee: 1_299_999_999n, maxFee: 1_300_000_000n });113  });114  it('refuses fee values that are not plain small decimals', () => {115    for (const bad of [-1, 1e-7, Number.NaN, Number.POSITIVE_INFINITY, 1.2345678, 101, 5000]) expect(() => cctpFee(1_000_000n, bad)).toThrowError(ProviderError);116  });117});118119describe('cctp: USDC quotes', () => {120  it('ethereum -> base: a Standard and a Fast quote, every field from the recorded answers', async () => {121    const { http, calls } = mock(evmRules('fees-0-6', 'ethereum', 'base'));122    const r = req(usdc('ethereum'), usdc('base'), 25_000_000n);123    const ac = new AbortController();124    const quotes = await provider.quote(r, ctxOf(http, ac.signal));125    expect(quotes.map((q) => (q.raw as any).transfer)).toEqual(['standard', 'fast']);126    const [std, fast] = quotes as [typeof quotes[0], typeof quotes[0]];127128    expect(std.amountOut).toBe(25_000_000n); expect(std.minAmountOut).toBe(25_000_000n); expect(std.estimatedSeconds).toBe(19 * 60);129    expect(fast.amountOut).toBe(24_997_500n); expect(fast.minAmountOut).toBe(24_997_500n); expect(fast.estimatedSeconds).toBe(20);130    const srcGas = { kind: 'network', asset: { network: 'ethereum', asset: 'native' }, amount: 180_000n * ETH_GAS, includedInOutput: false, estimated: true };131    const dstGas = { kind: 'network', asset: { network: 'base', asset: 'native' }, amount: 200_000n * BASE_GAS, includedInOutput: false, estimated: true };132    expect(std.fees).toEqual([{ kind: 'protocol', asset: r.to, amount: 0n, includedInOutput: true, estimated: false }, srcGas, dstGas]);133    expect(fast.fees).toEqual([{ kind: 'protocol', asset: r.to, amount: 2_500n, includedInOutput: true, estimated: false }, srcGas, dstGas]);134    for (const q of quotes) {135      expect(checkQuote(q, r, NOW)).toBeNull();136      expect(q).toMatchObject({ provider: 'cctp', request: r, priceImpactBps: 0, expiresAt: NOW + 30_000, firmness: 'indicative', atomic: false, refundable: false });137      expect(q.executability.class).toBe('quote_only');138      expect(q.steps).toHaveLength(1);139      expect(q.steps[0]).toMatchObject({ kind: 'bridge', provider: 'cctp', from: r.from, to: r.to });140      expect(q.steps[0]!.contracts).toEqual([141        { network: 'ethereum', address: CCTP_EVM.tokenMessenger, role: 'spender' }, { network: 'ethereum', address: CCTP_EVM.tokenMessenger, role: 'bridge' },142        { network: 'base', address: CCTP_EVM.messageTransmitter, role: 'bridge' }, { network: 'base', address: CCTP_EVM.tokenMessenger, role: 'bridge' },143      ]);144      // the destination-side requirement is spelled out in the step145      expect(q.steps[0]!.via.join('\n')).toMatch(/mint on base: MessageTransmitterV2\.receiveMessage; the wallet submits the mint itself and needs ETH on base/);146    }147    expect(new Set(quotes.map((q) => q.id)).size).toBe(2);148    // the burn the signer would have to verify, argument by argument149    expect((fast.raw as any).burn).toMatchObject({150      network: 'ethereum', contract: CCTP_EVM.tokenMessenger,151      approval: { token: usdc('ethereum').asset, spender: CCTP_EVM.tokenMessenger, amount: '25000000' },152      args: { amount: '25000000', destinationDomain: 6, mintRecipient: `0x${'0'.repeat(24)}${EVM_B.slice(2).toLowerCase()}`, burnToken: usdc('ethereum').asset, destinationCaller: `0x${'0'.repeat(64)}`, maxFee: '2500', minFinalityThreshold: 1000 },153    });154    expect((std.raw as any).burn.args).toMatchObject({ maxFee: '0', minFinalityThreshold: 2000 });155    expect((std.raw as any).fees).toEqual(fx('fees-0-6')); // raw = the validated provider payload156    expect((std.raw as any).forwardingService).toEqual({ low: 53880, med: 54176, high: 56413 });157    expect((std.raw as any).mint).toMatchObject({ network: 'base', contract: CCTP_EVM.messageTransmitter, submittedBy: 'wallet' });158    // one round: fee schedule, Fast allowance, one gas price per side — each with the caller's abort signal159    expect(calls.map((c) => `${c.method} ${c.url}`).sort()).toEqual([160      'GET https://iris-api.circle.com/v2/burn/USDC/fees/0/6?forward=true', 'GET https://iris-api.circle.com/v2/fastBurn/USDC/allowance',161      `POST ${NETWORKS.base.rpc[0]}`, `POST ${NETWORKS.ethereum.rpc[0]}`,162    ].sort());163    for (const c of calls) expect(c.signal).toBe(ac.signal);164    expect(calls.filter((c) => c.method === 'POST').every((c) => c.body.method === 'eth_gasPrice')).toBe(true);165  });166167  it('applies a fractional basis-point fee exactly (arbitrum -> optimism at 1.4 bps)', async () => {168    const rules = evmRules('fees-3-2', 'arbitrum', 'optimism');169    const r = req(usdc('arbitrum'), usdc('optimism'), 100_584_300n);170    const [std, fast] = await provider.quote(r, ctxOf(mock(rules).http));171    expect(std!.amountOut).toBe(100_584_300n);172    expect(fast!.amountOut).toBe(100_584_300n - 14_081n); // what Iris executed for this very amount on mainnet173    expect(fast!.minAmountOut).toBe(100_584_300n - 14_082n); // maxFee, rounded up174    expect(fast!.fees[0]).toMatchObject({ kind: 'protocol', amount: 14_081n, includedInOutput: true });175    expect((fast!.raw as any)).toMatchObject({ feeBps: '1.4', protocolFee: '14081', maxFee: '14082' });176    expect(fast!.estimatedSeconds).toBe(8);177    expect(checkQuote(fast!, r, NOW)).toBeNull();178    // the requested slippage does not move a 1:1 mint: the only possible deduction is maxFee179    const [, loose] = await provider.quote({ ...r, slippageBps: 500 }, ctxOf(mock(rules).http));180    expect(loose!.minAmountOut).toBe(fast!.minAmountOut);181  });182183  it('offers Standard only from a source without Fast Transfer (polygon, avalanche, hyperevm) and does not ask for the allowance', async () => {184    for (const [src, dst, fees] of [['polygon', 'ethereum', 'fees-7-0'], ['avalanche', 'hyperevm', 'fees-1-19']] as const) {185      const { http, calls } = mock([['/v2/burn/USDC/fees/', fx(fees)], [NETWORKS[src].rpc[0]!, gas('avalanche')], [NETWORKS[dst].rpc[0]!, gas('ethereum')]]);186      const r = req(usdc(src), usdc(dst), 20_000_000n);187      const quotes = await provider.quote(r, ctxOf(http));188      expect(quotes).toHaveLength(1);189      expect(quotes[0]).toMatchObject({ amountOut: 20_000_000n, minAmountOut: 20_000_000n, estimatedSeconds: CCTP_CHAINS[src]!.standardSeconds });190      expect((quotes[0]!.raw as any).transfer).toBe('standard');191      expect(quotes[0]!.fees[1]).toMatchObject({ asset: { network: src, asset: 'native' }, amount: 180_000n * AVAX_GAS });192      expect(calls.some((c) => c.url.includes('allowance'))).toBe(false);193      expect(checkQuote(quotes[0]!, r, NOW)).toBeNull();194    }195  });196197  it('base -> solana: the mint needs SOL — fee, used-nonce rent, and the token account when it is missing', async () => {198    const run = async (batch: unknown, recipient = SOL_A) => {199      const m = mock([['/v2/burn/USDC/fees/', fx('fees-6-5')], ['/v2/fastBurn/USDC/allowance', fx('allowance')], [NETWORKS.base.rpc[0]!, gas('base')], [NETWORKS.solana.rpc[0]!, batch]]);200      const r = req(usdc('base'), usdc('solana'), 25_000_000n, { recipient });201      return { quotes: await provider.quote(r, ctxOf(m.http)), calls: m.calls, r };202    };203    const has = await run(fx('rpc-solana-mint-batch'));204    expect(has.quotes).toHaveLength(2);205    expect(has.quotes[1]!.amountOut).toBe(25_000_000n - 3_250n); // 1.3 bps206    expect(has.quotes[0]!.fees[2]).toEqual({ kind: 'network', asset: { network: 'solana', asset: 'native' }, amount: 5_000n + 695_960n, includedInOutput: false, estimated: true });207    expect(has.quotes[0]!.steps[0]!.contracts.slice(2)).toEqual([{ network: 'solana', address: CCTP_SOLANA.messageTransmitter, role: 'program' }, { network: 'solana', address: CCTP_SOLANA.tokenMessengerMinter, role: 'program' }]);208    expect((has.quotes[0]!.raw as any).burn.args.mintRecipient).toBeNull(); // the token account, derived at build time209    const sol = has.calls.find((c) => c.url === NETWORKS.solana.rpc[0])!;210    expect(sol.body.map((b: any) => [b.method, b.params[0]])).toEqual([['getMinimumBalanceForRentExemption', 9], ['getMinimumBalanceForRentExemption', 165], ['getTokenAccountsByOwner', SOL_A]]);211    expect(sol.body[2].params[1]).toEqual({ mint: usdc('solana').asset });212    for (const q of has.quotes) expect(checkQuote(q, has.r, NOW)).toBeNull();213214    const missing = await run(fx('rpc-solana-mint-batch-no-token-account'));215    expect(missing.quotes[0]!.fees[2]!.amount).toBe(5_000n + 695_960n + 1_488_440n);216    expect(missing.quotes[0]!.steps[0]!.via[3]).toMatch(/to create the recipient's USDC token account \(it does not exist yet/);217218    // a placeholder recipient (indicative quote): existence cannot be checked, so the account is priced in219    const rents = fx('rpc-solana-mint-batch-no-token-account').filter((a: any) => a.id < 2);220    const unknown = await run(rents, 'RECIPIENT');221    expect(unknown.quotes[0]!.fees[2]!.amount).toBe(5_000n + 695_960n + 1_488_440n);222    expect(unknown.calls.find((c) => c.url === NETWORKS.solana.rpc[0])!.body).toHaveLength(2);223  });224225  it('solana -> arbitrum: two signatures and the refundable MessageSent rent on the source side', async () => {226    const { http } = mock([['/v2/burn/USDC/fees/', fx('fees-5-3')], ['/v2/fastBurn/USDC/allowance', fx('allowance')], [NETWORKS.solana.rpc[0]!, fx('rpc-solana-burn-batch')], [NETWORKS.arbitrum.rpc[0]!, gas('base')]]);227    const r = req(usdc('solana'), usdc('arbitrum'), 40_000_000n);228    const [std, fast] = await provider.quote(r, ctxOf(http));229    expect(std!.estimatedSeconds).toBe(17); expect(fast!.estimatedSeconds).toBe(1);230    expect(fast!.amountOut).toBe(39_996_000n);231    const sol = { network: 'solana', asset: 'native' };232    expect(fast!.fees).toEqual([233      { kind: 'protocol', asset: r.to, amount: 4_000n, includedInOutput: true, estimated: false },234      { kind: 'network', asset: sol, amount: 10_000n, includedInOutput: false, estimated: true },235      { kind: 'network', asset: sol, amount: 2_824_480n, includedInOutput: false, estimated: true },236      { kind: 'network', asset: { network: 'arbitrum', asset: 'native' }, amount: 200_000n * BASE_GAS, includedInOutput: false, estimated: true },237    ]);238    expect(fast!.steps[0]!.contracts.slice(0, 2)).toEqual([{ network: 'solana', address: CCTP_SOLANA.tokenMessengerMinter, role: 'program' }, { network: 'solana', address: CCTP_SOLANA.messageTransmitter, role: 'program' }]);239    expect((fast!.raw as any).burn).toMatchObject({ approval: null, args: { destinationDomain: 3, mintRecipient: evmAddressToBytes32(EVM_B), burnToken: usdc('solana').asset } });240    expect(checkQuote(fast!, r, NOW)).toBeNull();241  });242243  it('drops the Fast quote when Circle\'s allowance cannot absorb the burn, and tiny or oversized amounts', async () => {244    const low = mock(evmRules('fees-0-6', 'ethereum', 'base', [['/v2/fastBurn/USDC/allowance', { allowance: 24.999999, lastUpdated: '2026-11-11T01:15:46.761Z' }]]));245    expect((await provider.quote(req(usdc('ethereum'), usdc('base'), 25_000_000n), ctxOf(low.http))).map((q) => (q.raw as any).transfer)).toEqual(['standard']);246    const exact = mock(evmRules('fees-0-6', 'ethereum', 'base', [['/v2/fastBurn/USDC/allowance', { allowance: 25, lastUpdated: 'x' }]]));247    expect(await provider.quote(req(usdc('ethereum'), usdc('base'), 25_000_000n), ctxOf(exact.http))).toHaveLength(2);248    // one base unit: maxFee (1) would not be below the amount, which the contract requires249    expect((await provider.quote(req(usdc('ethereum'), usdc('base'), 1n), ctxOf(mock(evmRules('fees-0-6', 'ethereum', 'base')).http))).map((q) => (q.raw as any).transfer)).toEqual(['standard']);250    const none = mock([]);251    expect(await provider.quote(req(usdc('ethereum'), usdc('base'), USDC_BURN_LIMIT + 1n), ctxOf(none.http))).toEqual([]);252    expect(await provider.quote(req(usdc('ethereum'), usdc('base'), 0n), ctxOf(none.http))).toEqual([]);253    expect(none.calls).toHaveLength(0);254    expect(await provider.quote(req(usdc('ethereum'), usdc('base'), USDC_BURN_LIMIT), ctxOf(mock(evmRules('fees-0-6', 'ethereum', 'base')).http))).toHaveLength(2);255  });256257  it('treats Iris\' "invalid domain" as no route and every other failure as a failure', async () => {258    const noRoute = mock(evmRules('fees-0-6', 'ethereum', 'base', [['/v2/burn/USDC/fees/', httpError(400, fx('error-invalid-domain'))]]));259    expect(await provider.quote(req(usdc('ethereum'), usdc('base'), 25_000_000n), ctxOf(noRoute.http))).toEqual([]);260    const cases: [Error, string][] = [[httpError(500, { error: 'boom' }), 'http'], [new ProviderError('cctp', 'rate_limited', '429'), 'rate_limited'], [new ProviderError('cctp', 'unavailable', 'fetch failed'), 'unavailable'], [new ProviderError('cctp', 'bad_response', 'non-JSON answer'), 'bad_response']];261    for (const [err, code] of cases) {262      const m = mock(evmRules('fees-0-6', 'ethereum', 'base', [['/v2/burn/USDC/fees/', err]]));263      await expect(provider.quote(req(usdc('ethereum'), usdc('base'), 25_000_000n), ctxOf(m.http))).rejects.toMatchObject({ code });264    }265    // the allowance endpoint failing is a failure too (a Fast quote is never advertised blind)266    const noAllowance = mock(evmRules('fees-0-6', 'ethereum', 'base', [['/v2/fastBurn/USDC/allowance', httpError(503, {})]]));267    await expect(provider.quote(req(usdc('ethereum'), usdc('base'), 25_000_000n), ctxOf(noAllowance.http))).rejects.toMatchObject({ code: 'http' });268  });269270  it('falls back to the registry\'s second RPC endpoint, and fails when no endpoint answers', async () => {271    const down = new ProviderError('cctp', 'unavailable', 'connect ECONNREFUSED');272    const second = mock(evmRules('fees-0-6', 'ethereum', 'base', [[NETWORKS.ethereum.rpc[0]!, down], [NETWORKS.ethereum.rpc[1]!, gas('ethereum')]]));273    const quotes = await provider.quote(req(usdc('ethereum'), usdc('base'), 25_000_000n), ctxOf(second.http));274    expect(quotes[0]!.fees[1]!.amount).toBe(180_000n * ETH_GAS);275    expect(second.calls.filter((c) => c.method === 'POST').map((c) => c.url)).toEqual(expect.arrayContaining([...NETWORKS.ethereum.rpc]));276    const dead = mock(evmRules('fees-0-6', 'ethereum', 'base', [[NETWORKS.base.rpc[0]!, down], [NETWORKS.base.rpc[1]!, down]]));277    await expect(provider.quote(req(usdc('ethereum'), usdc('base'), 25_000_000n), ctxOf(dead.http))).rejects.toMatchObject({ code: 'unavailable' });278  });279280  it('rejects malformed or hostile answers with a schema error', async () => {281    const good = fx('fees-0-6');282    const patched = (f: (a: any[]) => unknown) => { const a = structuredClone(good); return f(a) ?? a; };283    const badFees: unknown[] = [284      patched((a) => { a[0].minimumFee = -1; }), patched((a) => { a[0].minimumFee = '1'; }), patched((a) => { a[0].minimumFee = 1e-7; }),285      patched((a) => { a[0].minimumFee = 1.23456789; }), patched((a) => { a[0].minimumFee = 5000; }), patched((a) => { a[1].minimumFee = null; }),286      patched((a) => { delete a[0].minimumFee; }), patched((a) => { a[0].finalityThreshold = 500; }), patched((a) => { a[1].finalityThreshold = 1000; }),287      patched((a) => [a[0]]), // no Standard entry288      patched((a) => [...a, a[0]]), patched((a) => { a[0].forwardFee = { low: -1, med: 1.5, high: 'x' }; }),289      [], {}, null, 'ok', { finalityThreshold: 2000, minimumFee: 0 },290    ];291    for (const bad of badFees) {292      const m = mock(evmRules('fees-0-6', 'ethereum', 'base', [['/v2/burn/USDC/fees/', () => bad]]));293      await expect(provider.quote(req(usdc('ethereum'), usdc('base'), 25_000_000n), ctxOf(m.http)), JSON.stringify(bad)).rejects.toMatchObject({ code: 'schema' });294    }295    for (const bad of [{ allowance: -5, lastUpdated: 'x' }, { allowance: '1000000' }, { lastUpdated: 'x' }, { allowance: 1e21, lastUpdated: 'x' }, null]) {296      const m = mock(evmRules('fees-0-6', 'ethereum', 'base', [['/v2/fastBurn/USDC/allowance', () => bad]]));297      await expect(provider.quote(req(usdc('ethereum'), usdc('base'), 25_000_000n), ctxOf(m.http)), JSON.stringify(bad)).rejects.toMatchObject({ code: 'schema' });298    }299    for (const bad of [{ result: '12' }, { result: '0x' }, { result: '0x0' }, { result: `0x${'f'.repeat(20)}` }, { result: 41 }, { error: { code: -32000 } }, null]) {300      const m = mock(evmRules('fees-0-6', 'ethereum', 'base', [[NETWORKS.base.rpc[0]!, () => bad], [NETWORKS.base.rpc[1]!, () => bad]]));301      await expect(provider.quote(req(usdc('ethereum'), usdc('base'), 25_000_000n), ctxOf(m.http)), JSON.stringify(bad)).rejects.toMatchObject({ code: 'schema' });302    }303    for (const bad of [[{ id: 0, result: 695960.5 }, { id: 1, result: 1 }, { id: 2, result: { value: [] } }], [{ id: 0, result: -1 }, { id: 1, result: 1 }, { id: 2, result: { value: [] } }], [{ id: 0, result: 1 }, { id: 0, result: 1 }, { id: 2, result: { value: [] } }], [{ id: 0, result: 1 }, { id: 1, result: 1 }, { id: 2, result: {} }], [{ id: 0, result: 1 }]]) {304      const m = mock([['/v2/burn/USDC/fees/', fx('fees-6-5')], ['/v2/fastBurn/USDC/allowance', fx('allowance')], [NETWORKS.base.rpc[0]!, gas('base')], ['solana', () => bad]]);305      await expect(provider.quote(req(usdc('base'), usdc('solana'), 25_000_000n, { recipient: SOL_A }), ctxOf(m.http)), JSON.stringify(bad)).rejects.toMatchObject({ code: 'schema' });306    }307  });308309  it('propagates a timeout when the caller aborts', async () => {310    const ac = new AbortController();311    const hang = (signal?: AbortSignal) => new Promise<never>((_res, rej) => signal!.addEventListener('abort', () => rej(new ProviderError('cctp', 'timeout', 'This operation was aborted')), { once: true }));312    const http: HttpClient = { getJson: (_u, init) => hang(init?.signal), postJson: (_u, _b, init) => hang(init?.signal) };313    const p = provider.quote(req(usdc('ethereum'), usdc('base'), 25_000_000n), ctxOf(http, ac.signal));314    setTimeout(() => ac.abort(), 20);315    await expect(p).rejects.toMatchObject({ code: 'timeout' });316  });317});318319describe('cctp: EURC quotes (CCTP for non-USDC)', () => {320  const QUOTE = '/v2/quote/cctpx/';321  const byRequest = (std: Reply, fast: Reply): Reply => (_u: string, body: any) => { const v = body.requests.length ? fast : std; return typeof v === 'function' ? (v as any)() : v; };322  const rules = (std: Reply, fast: Reply, src = 'base', dst = 'ethereum'): [string, Reply][] => [[QUOTE, byRequest(std, fast)], [NETWORKS[src]!.rpc[0]!, gas('base')], [NETWORKS[dst]!.rpc[0]!, gas('ethereum')]];323324  it('base -> ethereum: full amount minted, Circle\'s fee paid upfront in ETH on the source chain', async () => {325    const { http, calls } = mock(rules(fx('eurc-quote-6-0-standard'), fx('eurc-quote-6-0-fast')));326    const r = req(eurc('base'), eurc('ethereum'), 25_000_000n);327    const quotes = await provider.quote(r, ctxOf(http));328    expect(quotes.map((q) => (q.raw as any).transfer)).toEqual(['standard', 'fast']);329    const [std, fast] = quotes as [typeof quotes[0], typeof quotes[0]];330    const eth = { network: 'base', asset: 'native' };331    const gasFees = [332      { kind: 'network', asset: eth, amount: 440_000n * BASE_GAS, includedInOutput: false, estimated: true },333      { kind: 'network', asset: { network: 'ethereum', asset: 'native' }, amount: 240_000n * ETH_GAS, includedInOutput: false, estimated: true },334    ];335    expect(std.fees).toEqual([{ kind: 'protocol', asset: eth, amount: 122_436_486_072_850n, includedInOutput: false, estimated: false }, ...gasFees]);336    expect(fast.fees).toEqual([337      { kind: 'protocol', asset: eth, amount: 1_366_555_249_465n, includedInOutput: false, estimated: false },338      { kind: 'protocol', asset: eth, amount: 122_436_486_072_850n, includedInOutput: false, estimated: false }, ...gasFees,339    ]);340    for (const q of quotes) {341      expect(q).toMatchObject({ amountOut: 25_000_000n, minAmountOut: 25_000_000n, priceImpactBps: 0, atomic: false, refundable: false, executability: { class: 'quote_only' } });342      expect(checkQuote(q, r, NOW)).toBeNull();343      expect(q.steps[0]!.contracts).toEqual([344        { network: 'base', address: CCTPX.eurcTokenManager, role: 'spender' }, { network: 'base', address: CCTPX.service, role: 'bridge' },345        { network: 'ethereum', address: CCTP_EVM.messageTransmitter, role: 'bridge' }, { network: 'ethereum', address: CCTPX.service, role: 'bridge' },346      ]);347    }348    // the provider's own validity349    expect(std.expiresAt).toBe(1_794_359_868_000); expect(fast.expiresAt).toBe(1_794_359_869_000);350    expect(std.estimatedSeconds).toBe(19 * 60); expect(fast.estimatedSeconds).toBe(8);351    expect((fast.raw as any).quote).toEqual(fx('eurc-quote-6-0-fast'));352    expect((fast.raw as any).burn).toMatchObject({ contract: CCTPX.service, approval: { token: eurc('base').asset, spender: CCTPX.eurcTokenManager, amount: '25000000' }, value: '123803041322315', minFinalityThreshold: 1000 });353    const posts = calls.filter((c) => c.url.includes(QUOTE));354    expect(posts.map((c) => c.url)).toEqual(Array(2).fill(`https://iris-api.circle.com/v2/quote/cctpx/${CCTPX.eurcTokenId}/6/0`));355    expect(posts.map((c) => c.body)).toEqual([356      { amount: '25000000', feeToken: `0x${'0'.repeat(40)}`, requests: [] },357      { amount: '25000000', feeToken: `0x${'0'.repeat(40)}`, requests: [{ type: 'PRE_FINALITY' }] },358    ]);359  });360361  it('avalanche -> base: Standard only, one quote request', async () => {362    const { http, calls } = mock([[QUOTE, fx('eurc-quote-1-6-standard')], [NETWORKS.avalanche.rpc[0]!, gas('avalanche')], [NETWORKS.base.rpc[0]!, gas('base')]]);363    const r = req(eurc('avalanche'), eurc('base'), 25_000_000n);364    const quotes = await provider.quote(r, ctxOf(http));365    expect(quotes).toHaveLength(1);366    expect(quotes[0]).toMatchObject({ amountOut: 25_000_000n, estimatedSeconds: 8 });367    expect(quotes[0]!.fees[0]).toEqual({ kind: 'protocol', asset: { network: 'avalanche', asset: 'native' }, amount: 29_776_083_849_452_121n, includedInOutput: false, estimated: false });368    expect(calls.filter((c) => c.url.includes(QUOTE))).toHaveLength(1);369    expect(checkQuote(quotes[0]!, r, NOW)).toBeNull();370  });371372  it('no route when Iris refuses the route; Standard only when it refuses the fast quote; failures otherwise', async () => {373    const refused = httpError(400, fx('eurc-error-token-not-on-route'));374    expect(await provider.quote(req(eurc('base'), eurc('ethereum'), 25_000_000n), ctxOf(mock(rules(refused, refused)).http))).toEqual([]);375    const stdOnly = await provider.quote(req(eurc('base'), eurc('ethereum'), 25_000_000n), ctxOf(mock(rules(fx('eurc-quote-6-0-standard'), httpError(400, { errorCode: 'FX_SYMBOL_NOT_CONFIGURED' }))).http));376    expect(stdOnly.map((q) => (q.raw as any).transfer)).toEqual(['standard']);377    await expect(provider.quote(req(eurc('base'), eurc('ethereum'), 25_000_000n), ctxOf(mock(rules(httpError(502, {}), fx('eurc-quote-6-0-fast'))).http))).rejects.toMatchObject({ code: 'http' });378    await expect(provider.quote(req(eurc('base'), eurc('ethereum'), 25_000_000n), ctxOf(mock(rules(fx('eurc-quote-6-0-standard'), httpError(500, {}))).http))).rejects.toMatchObject({ code: 'http' });379  });380381  it('rejects malformed or hostile quotes', async () => {382    const std = fx('eurc-quote-6-0-standard'), fast = fx('eurc-quote-6-0-fast');383    const patch = (base: any, f: (q: any) => void) => { const q = structuredClone(base); f(q); return q; };384    const badStd = [385      patch(std, (q) => { q.feeToken = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; }), // fee in another token386      patch(std, (q) => { q.feeTotalAmount = '122436486072851'; }), // items do not add up387      patch(std, (q) => { q.feeTotalAmount = '1.5'; }), patch(std, (q) => { q.items[0].amount = '-5'; }), patch(std, (q) => { q.items[0].amount = 122436486072850; }),388      patch(std, (q) => { q.items[0].args[0] = '0x3d26699fb5d40190fc3fa0dcbc1cd24e558355043c1997572ff9fd6efbb3fdca'; }), // quote for cirBTC389      patch(std, (q) => { q.items = fast.items; q.feeTotalAmount = fast.feeTotalAmount; }), // a fast fee nobody asked for390      patch(std, (q) => { q.items[0].type = 'FORWARD'; }),391      patch(std, (q) => { q.items[0].amount = q.feeTotalAmount = '2000000000000000000'; }), // 2 ETH for a 25 EURC transfer392      patch(std, (q) => { q.expiry.expiresAt = 1_794_359_749; }), // already expired393      patch(std, (q) => { q.expiry.mode = 'BLOCK'; }), patch(std, (q) => { delete q.signedQuote; }), patch(std, (q) => { q.signedQuote = 'not hex'; }), patch(std, (q) => { q.items = []; }), null, [],394    ];395    for (const bad of badStd) await expect(provider.quote(req(eurc('base'), eurc('ethereum'), 25_000_000n), ctxOf(mock(rules(bad, fast)).http)), JSON.stringify(bad).slice(0, 200)).rejects.toMatchObject({ code: 'schema' });396    const badFast = [397      patch(fast, (q) => { q.items[0].args[1] = '26000000'; }), // priced for another amount398      std, // the fast fee is missing399    ];400    for (const bad of badFast) await expect(provider.quote(req(eurc('base'), eurc('ethereum'), 25_000_000n), ctxOf(mock(rules(std, bad)).http))).rejects.toMatchObject({ code: 'schema' });401  });402});403404describe('cctp: recipient encoding and message parsing (what the signer will verify)', () => {405  it('encodes EVM and Solana recipients as bytes32', () => {406    expect(evmAddressToBytes32('0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d')).toBe('0x00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d');407    expect(() => evmAddressToBytes32('0x1234')).toThrow();408    expect(solanaAddressToBytes32('11111111111111111111111111111111')).toBe(`0x${'0'.repeat(64)}`);409    expect(solanaAddressToBytes32('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v')).toBe('0xc6fa7af3bedbad3a3d65f36aabc97431b1bbe4c2d2f6e0e47ca60203452f5d61');410    expect(() => solanaAddressToBytes32('0OIl')).toThrow();411  });412  it('decodes a real attested V2 burn message exactly as Iris does', () => {413    const m = fx('message-arbitrum-base-fast').messages[0], d = m.decodedMessage, b = d.decodedMessageBody;414    const got = decodeCctpMessageV2(m.message);415    const pad = (a: string) => evmAddressToBytes32(a);416    expect(got).toMatchObject({417      version: 1, sourceDomain: Number(d.sourceDomain), destinationDomain: Number(d.destinationDomain), nonce: d.nonce,418      sender: pad(d.sender), recipient: pad(d.recipient), destinationCaller: d.destinationCaller,419      minFinalityThreshold: Number(d.minFinalityThreshold), finalityThresholdExecuted: Number(d.finalityThresholdExecuted),420    });421    expect(got.body).toEqual({422      version: 1, burnToken: pad(b.burnToken), mintRecipient: pad(b.mintRecipient), amount: BigInt(b.amount), messageSender: pad(b.messageSender),423      maxFee: BigInt(b.maxFee), feeExecuted: BigInt(b.feeExecuted), expirationBlock: BigInt(b.expirationBlock), hookData: '0x',424    });425    // the checks the signer would apply before signing receiveMessage for this transfer426    expect(got).toMatchObject({ sourceDomain: CCTP_CHAINS.arbitrum!.domain, destinationDomain: CCTP_CHAINS.base!.domain, sender: pad(CCTP_EVM.tokenMessenger), recipient: pad(CCTP_EVM.tokenMessenger), destinationCaller: `0x${'0'.repeat(64)}` });427    expect(got.body.burnToken).toBe(pad(usdc('arbitrum').asset));428    expect(got.body.feeExecuted).toBe(cctpFee(got.body.amount, 1.4).fee);429    expect(got.body.feeExecuted <= got.body.maxFee).toBe(true);430    expect(() => decodeCctpMessageV2(m.message.slice(0, 300))).toThrow();431    expect(() => decodeCctpMessageV2('nope')).toThrow();432  });433});434
Files main
Menu

Theme