Repository Public
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