Repository Public
1// Live READ-ONLY check of the CCTP quote adapter. Nothing is built, signed or broadcast.2// 1. on-chain: every pinned CCTP V2 contract/program has code and reports the expected domain, version and wiring3// 2. Iris: the live fee schedule for every route among the wallet's CCTP networks (USDC) and the EURC routes4// 3. adapter: real quotes for realistic small transfers, with latency, checked against a market reference (CoinGecko)5// Iris allows 40 requests/s and blocks an offender for five minutes: the route sweep is paced. Run once, not in a loop.6// npx tsx scripts/smoke-swap-cctp.mts7import { NETWORKS, findProgramAddress, formatUnits, getNetwork } from '@spw/core';8import { httpClient, checkQuote, type AssetRef, type SwapQuote, type SwapQuoteRequest } from '../packages/swap/src/index.ts';9import { CCTP_API, CCTP_CHAINS, CCTP_EVM, CCTP_SOLANA, CCTPX, USDC_BURN_LIMIT, createCctpProvider, evmAddressToBytes32, solanaAddressToBytes32 } from '../packages/swap/src/providers/cctp.ts';1011let failed = 0;12let quiet: string[] | null = null; // when set, passing checks are collected (one summary line per chain) instead of printed13const ok = (cond: boolean, label: string, detail = '') => {14 if (!cond) failed++;15 if (cond && quiet) { quiet.push(label.replace(/ 0x[0-9a-fA-F]{40}| [1-9A-HJ-NP-Za-km-z]{43,44}(?= )/g, '') + (detail.startsWith('=') || /USDC:|bytes/.test(detail) ? ` ${detail.split(':')[0]}` : '')); return; }16 console.log(` ${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`);17};18const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));19const token = (network: string, symbol: string): AssetRef => ({ network, asset: getNetwork(network).tokens.find((t) => t.symbol === symbol)!.id });20const chains = Object.entries(CCTP_CHAINS);2122async function rpc(network: string, method: string, params: unknown[]): Promise<any> {23 let last: unknown;24 for (const url of getNetwork(network).rpc) {25 try {26 const r = await (await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(10_000) })).json() as { result?: unknown; error?: unknown };27 if (r.error) throw new Error(JSON.stringify(r.error));28 return r.result;29 } catch (e) { last = e; }30 }31 throw last;32}33const call = (network: string, to: string, data: string) => rpc(network, 'eth_call', [{ to, data }, 'latest']) as Promise<string>;34const word = (v: number | bigint | string) => (typeof v === 'string' ? v.replace(/^0x/, '') : v.toString(16)).padStart(64, '0');35const asAddress = (w: string) => `0x${w.slice(-40)}`;36const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();37// function selectors (keccak-256 of the signature, first four bytes)38const SEL = { localDomain: '0x8d3638f4', version: '0x54fd4d50', messageBodyVersion: '0x9cdbb181', localMessageTransmitter: '0x2c121921', localMinter: '0xcb75c11c', remoteTokenMessengers: '0x82a5e665', burnLimitsPerMessage: '0xa56ec632', resolveTokenManager: '0xf1761e84', resolveTokenAddress: '0x5bc8ccc6' };3940console.log('== 1. On-chain check of the pinned CCTP V2 deployments ==');41for (const [network, c] of chains) {42 const net = getNetwork(network);43 quiet = [];44 try {45 if (net.family === 'evm') {46 for (const [name, address] of Object.entries(CCTP_EVM)) { const code = await rpc(network, 'eth_getCode', [address, 'latest']) as string; ok(code.length > 2, `${name} ${address} has code`, `${(code.length - 2) / 2} bytes`); }47 ok(Number(BigInt(await call(network, CCTP_EVM.messageTransmitter, SEL.localDomain))) === c.domain, 'MessageTransmitterV2.localDomain()', `= ${c.domain}`);48 ok(BigInt(await call(network, CCTP_EVM.messageTransmitter, SEL.version)) === 1n, 'MessageTransmitterV2.version()', '= 1 (the V2 message format)');49 ok(BigInt(await call(network, CCTP_EVM.tokenMessenger, SEL.messageBodyVersion)) === 1n, 'TokenMessengerV2.messageBodyVersion()', '= 1');50 ok(same(asAddress(await call(network, CCTP_EVM.tokenMessenger, SEL.localMessageTransmitter)), CCTP_EVM.messageTransmitter), 'TokenMessengerV2.localMessageTransmitter()', 'is the pinned MessageTransmitterV2');51 ok(same(asAddress(await call(network, CCTP_EVM.tokenMessenger, SEL.localMinter)), CCTP_EVM.tokenMinter), 'TokenMessengerV2.localMinter()', 'is the pinned TokenMinterV2');52 const limit = BigInt(await call(network, CCTP_EVM.tokenMinter, SEL.burnLimitsPerMessage + word(token(network, 'USDC').asset)));53 ok(limit > 0n && limit <= USDC_BURN_LIMIT, 'TokenMinterV2.burnLimitsPerMessage(registry USDC)', `${formatUnits(limit, 6)} USDC: the registry token is the burnable one`);54 const peers: string[] = [];55 for (const [other, oc] of chains) {56 if (other === network) continue;57 const remote = await call(network, CCTP_EVM.tokenMessenger, SEL.remoteTokenMessengers + word(oc.domain));58 const expected = getNetwork(other).family === 'evm' ? evmAddressToBytes32(CCTP_EVM.tokenMessenger) : solanaAddressToBytes32(CCTP_SOLANA.tokenMessengerMinter);59 if (same(remote, expected)) peers.push(other); else ok(false, `remoteTokenMessengers(${oc.domain})`, `${remote} is not the pinned ${other} messenger`);60 }61 ok(peers.length === chains.length - 1, 'remoteTokenMessengers(domain) is the pinned messenger of every other wallet network', peers.join(', '));62 if (c.eurc) {63 const code = await rpc(network, 'eth_getCode', [CCTPX.service, 'latest']) as string;64 ok(code.length > 2, `CrossChainTokenService ${CCTPX.service} has code`);65 ok(same(asAddress(await call(network, CCTPX.service, SEL.resolveTokenManager + word(CCTPX.eurcTokenId))), CCTPX.eurcTokenManager), 'resolveTokenManager(EURC)', 'is the pinned TokenManager');66 ok(same(asAddress(await call(network, CCTPX.service, SEL.resolveTokenAddress + word(CCTPX.eurcTokenId))), token(network, 'EURC').asset), 'resolveTokenAddress(EURC)', 'is the registry EURC');67 }68 } else {69 for (const [name, address] of Object.entries(CCTP_SOLANA)) { const a = await rpc(network, 'getAccountInfo', [address, { encoding: 'base64', dataSlice: { offset: 0, length: 0 } }]); ok(a?.value?.executable === true, `${name} ${address} is an executable program`, `owner ${a?.value?.owner}`); }70 const state = findProgramAddress([new TextEncoder().encode('message_transmitter')], CCTP_SOLANA.messageTransmitter);71 const acc = await rpc(network, 'getAccountInfo', [state, { encoding: 'base64' }]);72 const data = Buffer.from(acc.value.data[0], 'base64');73 // anchor discriminator (8) + owner, pending_owner, attester_manager, pauser (4 × 32) + paused (1) + local_domain u32 + version u3274 ok(acc.value.owner === CCTP_SOLANA.messageTransmitter && data.readUInt32LE(137) === c.domain, `MessageTransmitterV2 state ${state}: local_domain`, `= ${data.readUInt32LE(137)}`);75 ok(data.readUInt32LE(141) === 1, 'MessageTransmitterV2 state: version', `= ${data.readUInt32LE(141)}`);76 const mint = token(network, 'USDC').asset;77 const local = findProgramAddress([new TextEncoder().encode('local_token'), Buffer.from(solanaAddressToBytes32(mint).slice(2), 'hex')], CCTP_SOLANA.tokenMessengerMinter);78 const lt = await rpc(network, 'getAccountInfo', [local, { encoding: 'base64', dataSlice: { offset: 0, length: 0 } }]);79 ok(lt?.value?.owner === CCTP_SOLANA.tokenMessengerMinter, `local_token account for the registry USDC mint exists (${local})`);80 }81 } catch (e) { ok(false, 'chain check', (e as Error).message?.slice(0, 160)); }82 console.log(`${network} (domain ${c.domain}): ${quiet.length} checks ok — ${quiet.join('; ')}`);83 quiet = null;84}8586console.log('\n== 2. Live Iris fee schedule among the wallet\'s CCTP networks ==');87const iris = async (path: string, init?: RequestInit) => { const r = await fetch(`${CCTP_API}${path}`, { ...init, signal: AbortSignal.timeout(10_000) }); return { status: r.status, body: await r.json() as any }; };88const names = chains.map(([n]) => n);89console.log('USDC Fast Transfer fee, bps (rows: source, columns: destination; "-" = Fast not offered from that source; Standard is 0 bps on every route below unless shown)');90console.log(''.padEnd(11) + names.map((n) => n.slice(0, 9).padStart(10)).join(''));91let routes = 0; const forward: string[] = [];92for (const [s, sc] of chains) {93 let row = s.padEnd(11);94 for (const [d, dc] of chains) {95 if (s === d) { row += '·'.padStart(10); continue; }96 const r = await iris(`/v2/burn/USDC/fees/${sc.domain}/${dc.domain}?forward=true`); await sleep(120);97 if (r.status !== 200) { failed++; row += `E${r.status}`.padStart(10); continue; }98 routes++;99 const fast = r.body.find((x: any) => x.finalityThreshold === 1000), std = r.body.find((x: any) => x.finalityThreshold === 2000);100 row += ((sc.fastSource ? String(fast?.minimumFee) : '-') + (std?.minimumFee ? `/s${std.minimumFee}` : '')).padStart(10);101 if (!sc.fastSource && fast?.minimumFee) { failed++; row += '!'; } // a non-zero Fast fee from a chain we pinned as not Fast-capable would mean the table is stale102 if (s === 'ethereum') forward.push(`${d} ${formatUnits(BigInt(std.forwardFee.med), 6)}`);103 }104 console.log(row);105}106ok(routes === chains.length * (chains.length - 1), `${routes} USDC routes answered by Iris`);107console.log(` Forwarding Service fee (Circle submits the mint), USDC, from ethereum to: ${forward.join(' | ')}`);108const allowance = await iris('/v2/fastBurn/USDC/allowance');109console.log(` Fast Transfer allowance now: ${allowance.body.allowance} USDC (updated ${allowance.body.lastUpdated})`);110const tokens = await iris(`/v2/cctpx/tokens?tokenid=${CCTPX.eurcTokenId}`);111const deployments: any[] = tokens.body.data?.[0]?.deployments ?? [];112for (const [n, c] of chains.filter(([, c]) => c.eurc)) { const d = deployments.find((x) => x.domainId === c.domain); ok(!!d && d.enabled && same(d.tokenAddress, token(n, 'EURC').asset) && same(d.tokenManagerAddress, CCTPX.eurcTokenManager), `EURC on ${n}: Iris lists the registry token and the pinned TokenManager, enabled`); }113const eurcElsewhere = chains.filter(([n, c]) => !c.eurc && deployments.some((x) => x.domainId === c.domain) && getNetwork(n).tokens.some((t) => t.symbol === 'EURC'));114ok(eurcElsewhere.length === 0, 'no other wallet network has both a registry EURC and a CCTP EURC deployment');115const keys = await iris('/v2/publicKeys');116console.log(` attester public keys: ${keys.body.publicKeys.filter((k: any) => k.cctpVersion === 2).length} for V2, ${keys.body.publicKeys.filter((k: any) => k.cctpVersion === 1).length} for V1`);117118console.log('\n== 3. Adapter quotes (live) ==');119const cg = await (await fetch('https://api.coingecko.com/api/v3/simple/price?ids=usd-coin,euro-coin,ethereum,solana,avalanche-2,polygon-ecosystem-token,hyperliquid&vs_currencies=usd', { signal: AbortSignal.timeout(10_000) })).json() as Record<string, { usd: number }>;120const USD: Record<string, number> = { USDC: cg['usd-coin']!.usd, EURC: cg['euro-coin']!.usd, ETH: cg.ethereum!.usd, SOL: cg.solana!.usd, AVAX: cg['avalanche-2']!.usd, POL: cg['polygon-ecosystem-token']!.usd, HYPE: cg.hyperliquid!.usd };121console.log(` CoinGecko: ${Object.entries(USD).map(([k, v]) => `${k} $${v}`).join(', ')}`);122ok(Math.abs(USD.USDC! - 1) < 0.01, 'USDC is at its peg on the market', `$${USD.USDC}`);123const def = (a: AssetRef) => { const n = NETWORKS[a.network]!; return a.asset === 'native' ? n.native : n.tokens.find((t) => t.id.toLowerCase() === a.asset.toLowerCase())!; };124const usd = (a: AssetRef, amount: bigint) => (Number(amount) / 10 ** def(a).decimals) * USD[def(a).symbol]!; // display only125const EVM_A = '0x28C6c06298d514Db089934071355E5743bf21d60', SOL_A = '5tzFkiKscXHK5ZXCGbXZxdw7gTjjD1mBwuoFbhUvuAi9'; // public exchange accounts, used only as sender/recipient of a quote126const who = (n: string) => (getNetwork(n).family === 'evm' ? EVM_A : SOL_A);127const provider = createCctpProvider(), http = httpClient('cctp');128const cases: [string, string, string, string][] = [129 ['USDC', 'ethereum', 'base', '25'], ['USDC', 'base', 'solana', '25'], ['USDC', 'solana', 'arbitrum', '40'], ['USDC', 'arbitrum', 'optimism', '50'],130 ['USDC', 'polygon', 'avalanche', '20'], ['USDC', 'avalanche', 'hyperevm', '30'], ['USDC', 'hyperevm', 'ethereum', '50'], ['USDC', 'optimism', 'polygon', '0.000001'],131 ['EURC', 'base', 'ethereum', '25'], ['EURC', 'avalanche', 'base', '25'],132];133for (const [sym, from, to, human] of cases) {134 const f = token(from, sym), t = token(to, sym), amountIn = BigInt(Math.round(Number(human) * 1e6));135 const req: SwapQuoteRequest = { from: f, to: t, amountIn, sender: who(from), recipient: who(to), slippageBps: 50 };136 const t0 = performance.now();137 let quotes: SwapQuote[] = [];138 try { quotes = await provider.quote(req, { signal: AbortSignal.timeout(8_000), http, now: Date.now }); }139 catch (e) { ok(false, `${human} ${sym} ${from} -> ${to}`, `${(e as { code?: string }).code} ${(e as Error).message}`); continue; }140 const ms = Math.round(performance.now() - t0);141 console.log(`${human} ${sym} ${from} -> ${to}: ${quotes.length} quote(s) in ${ms} ms`);142 for (const q of quotes) {143 const bad = checkQuote(q, req, Date.now());144 const rate = Number(q.amountOut) / Number(amountIn), raw = q.raw as { transfer: string; forwardingService?: { med: number } | null };145 const extra = q.fees.filter((x) => !x.includedInOutput).map((x) => `${formatUnits(x.amount, def(x.asset).decimals)} ${def(x.asset).symbol} on ${x.asset.network} ($${usd(x.asset, x.amount).toFixed(4)}, ${x.kind}${x.estimated ? ', est.' : ''})`);146 const proto = q.fees.filter((x) => x.includedInOutput).map((x) => `${formatUnits(x.amount, 6)} ${sym}`);147 console.log(` ${raw.transfer.padEnd(8)} out ${formatUnits(q.amountOut, 6)} (min ${formatUnits(q.minAmountOut, 6)}) ~${q.estimatedSeconds} s | deducted at mint: ${proto.join(', ') || 'nothing'} | paid on top: ${extra.join('; ')}${raw.forwardingService ? ` | forwarding alternative ${formatUnits(BigInt(raw.forwardingService.med), 6)} USDC` : ''}`);148 // the market reference for a 1:1 burn-and-mint of the same asset is 1; the only deviation allowed is the protocol fee (≤ 0.5 %)149 ok(bad === null && rate <= 1 && rate >= 0.995 && q.minAmountOut <= q.amountOut, ` structurally valid, rate ${rate.toFixed(6)} within [0.995, 1] of the 1:1 reference (${sym} market price $${USD[sym]})`, bad ?? '');150 }151 if (quotes.length) console.log(` ${quotes[0]!.steps[0]!.via[3]}`);152 await sleep(300);153}154console.log('refusals (no HTTP call is made for a pair the adapter does not serve):');155const usdt = token('ethereum', 'USDT'), bridgedPolygonUsdc: AssetRef = { network: 'polygon', asset: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' };156ok(!provider.supportsPair(usdt, token('base', 'USDC')), ' USDT -> USDC');157ok(!provider.supportsPair(bridgedPolygonUsdc, token('base', 'USDC')), ' bridged USDC.e on Polygon (not in the registry)');158ok(!provider.supportsPair(token('ethereum', 'USDC'), { network: 'bsc', asset: 'native' }), ' USDC -> BNB Smart Chain');159ok(!provider.supportsPair(token('solana', 'EURC'), token('base', 'EURC')), ' EURC from Solana (CCTP for non-USDC is EVM only)');160ok(!provider.supportsPair(token('ethereum', 'USDC'), token('ethereum', 'USDC')), ' same network');161162console.log(failed ? `\n${failed} check(s) FAILED` : '\nall checks passed');163process.exit(failed ? 1 : 0);164