Repository Public
1// Cross-chain USDC through Circle CCTP V2 (burn on one EVM network, mint on another), wallet to itself only.2//3// There is NO third-party calldata here. Both transactions are built from the approved intent and pinned constants:4// leg 1 (source): USDC.approve(TokenMessengerV2, amount) + TokenMessengerV2.depositForBurn(amount, destinationDomain,5// mintRecipient = the wallet itself, burnToken = registry USDC, destinationCaller = 0, maxFee, minFinalityThreshold)6// leg 2 (destination): MessageTransmitterV2.receiveMessage(message, attestation)7// The only outside data is the attested message of leg 2 (from Circle's attestation service). It is parsed here byte by byte8// and must describe exactly the burn that was approved; whatever passes these checks can only mint the wallet's own USDC to9// the wallet's own address, or revert (the contract checks Circle's signatures and that the nonce is unused).10//11// Read from the verified sources (TokenMessengerV2, MessageTransmitterV2, MessageV2, BurnMessageV2; explorers + Circle's12// repository, 2026-11-10/11):13// - depositForBurn requires amount > 0, mintRecipient != 0, maxFee < amount, a registered remote messenger; it pulls exactly14// `amount` of burnToken from msg.sender, burns it, and emits a message whose body carries burnToken, mintRecipient, amount,15// messageSender = msg.sender and maxFee, with an empty hook;16// - receiveMessage checks the attestation, destinationDomain == local, destinationCaller (0 = anyone), version, unused nonce,17// then the messenger mints `amount - feeExecuted` to mintRecipient (feeExecuted <= maxFee enforced on-chain) and the fee to18// Circle's fee recipient. The caller of receiveMessage receives nothing and gives nothing but gas.19import { encodeFunctionData, erc20Abi, keccak256, type Hex, type TransactionSerializableEIP1559 } from 'viem';20import { baseUnits } from './amounts.ts';21import { getAsset, getNetwork } from './networks.ts';22import type { Intent } from './intent.ts';2324/** CCTP V2 on EVM: the same addresses on every chain (developers.circle.com/cctp/references/contract-addresses, read 2026-11-11). */25export const CCTP_TOKEN_MESSENGER = '0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d';26export const CCTP_MESSAGE_TRANSMITTER = '0x81D40F21F12A8F0E3252Bccb954D722d4c464B64';27export const CCTP_TOKEN_MINTER = '0xfd78EE919681417d192449715b2594ab58f5D002';28/** Both entry points are Circle's AdminUpgradableProxy (EIP-1967 slots); its 2 175-byte runtime code is identical on every network. */29export const CCTP_PROXY_SHA256 = '501b4a60e24ea82032b7442d75995dee3bdbfc17b25caffc3c955eadc8c2a418';30/** TokenMinterV2 is not a proxy; its runtime code is identical on every network. */31export const CCTP_MINTER_SHA256 = 'ddc1bfebb8465377e23aea86ef3135551130ae35426b759e69c1644735074be6';32export const EIP1967_IMPLEMENTATION_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc';33export const CCTP_FINALITY_FAST = 1000, CCTP_FINALITY_STANDARD = 2000;34/** The fee the owner may accept for a Fast transfer is bounded here whatever the API says (Circle charges 1–1.4 bps today). */35export const CCTP_MAX_FEE_BPS = 100n;3637export interface CctpPin {38 /** Circle's domain id (not a chain id) */39 domain: number;40 /** implementation behind each proxy: address and SHA-256 of its runtime bytecode. A change = an upgrade by Circle = refuse until re-reviewed. */41 messengerImpl: { address: string; codeSha256: string };42 transmitterImpl: { address: string; codeSha256: string };43 /** Circle offers Fast Transfer from this source (documented finality table) */44 fastSource: boolean;45 /** documented time for the attestation to become available, seconds (upper bound of the documented range) */46 standardSeconds: number; fastSeconds: number | null;47 reviewed: string;48}49const M6 = { address: '0x555e272506c06e7e559D57418563742aFe363Ec8', codeSha256: '81ca2a58e76495642226a9ba09391e5c4c6f8edf618f205dff274d1313144ffa' };50const SRC = 'proxy + implementation bytecode read on both registry RPC endpoints 2026-11-11 (scripts/cctp-pins.mts); verified source on the explorer compared with circlefin/evm-cctp-contracts';51/**52 * Networks where CCTP execution is enabled. Addresses are Circle's documented ones; the hashes were computed from the code53 * returned by both RPC endpoints of the registry. The API refuses to prepare or finish a transfer when any of them differs.54 */55export const CCTP_ENABLED: Record<string, CctpPin> = {56 ethereum: { domain: 0, messengerImpl: M6, transmitterImpl: { address: '0xa30c41865729C248067704d4DB9F38385cbe1186', codeSha256: 'b7b2a422acb8daa761be34977acc451fc3754cec19ec8e11d1cb572e3861307d' }, fastSource: true, standardSeconds: 19 * 60, fastSeconds: 20, reviewed: SRC },57 avalanche: { domain: 1, messengerImpl: M6, transmitterImpl: { address: '0x8689f0a21FE82b30E8f7684902788f65D891daE6', codeSha256: '6c541f8c79e433689fd64656e8a4c710f7f4ab44e6b0472cc934d8c52cadb303' }, fastSource: false, standardSeconds: 8, fastSeconds: null, reviewed: SRC },58 optimism: { domain: 2, messengerImpl: M6, transmitterImpl: { address: '0x1F33335414afB7305367452D00a0D024B0184EA8', codeSha256: '1c4986994c9f845d3c91adb6382b5c04ebe73819f599b45e83ee94cff64efba0' }, fastSource: true, standardSeconds: 19 * 60, fastSeconds: 8, reviewed: SRC },59 arbitrum: { domain: 3, messengerImpl: M6, transmitterImpl: { address: '0x7B93dB2bC72FAF642ceC0AdCAC4f91718831A1f4', codeSha256: 'bad293319d77ab3eed9877b254f57c176ef942e6f1afc7baf352e5685481a775' }, fastSource: true, standardSeconds: 19 * 60, fastSeconds: 8, reviewed: SRC },60 base: { domain: 6, messengerImpl: M6, transmitterImpl: { address: '0x7DB629F6aCc20bE49A0A7565C21cC178E9ac21e3', codeSha256: '5814f08b2cfc739969f545b292e0bac835b38011d5efbd936f921bde393d8fca' }, fastSource: true, standardSeconds: 19 * 60, fastSeconds: 8, reviewed: SRC },61 polygon: { domain: 7, messengerImpl: M6, transmitterImpl: { address: '0xd40518c1A3139e8F1F73F83E5De74E05C88F5aD3', codeSha256: '540cd1aece247c990c0a0d10491f717c966cd09cb78b7c6905f17d85b021fc4e' }, fastSource: false, standardSeconds: 8, fastSeconds: null, reviewed: SRC },62 // HyperEVM runs the newer messenger build with the Standard-fee switch (minFee, 0 today): the API reads getMinFeeAmount there63 hyperevm: { domain: 19, messengerImpl: { address: '0x1cCAFdFfBC1b7B5c499C97322F961b7D929a41B4', codeSha256: 'cff375318c7d5edac5e328ae8b49634f9127d68b88165cfd9c93f2ca6e8f2464' }, transmitterImpl: { address: '0x17eAEc8Bb0D05e8dA2E96aD69121fEe6dD62d313', codeSha256: 'e9423da6e41a7a11455980721a885b8150babbe1628871d8e5d7316fd3299ddf' }, fastSource: false, standardSeconds: 5, fastSeconds: null, reviewed: SRC },64};6566export const CCTP_ABI = [67 { type: 'function', name: 'depositForBurn', stateMutability: 'nonpayable', outputs: [], inputs: [68 { name: 'amount', type: 'uint256' }, { name: 'destinationDomain', type: 'uint32' }, { name: 'mintRecipient', type: 'bytes32' }, { name: 'burnToken', type: 'address' },69 { name: 'destinationCaller', type: 'bytes32' }, { name: 'maxFee', type: 'uint256' }, { name: 'minFinalityThreshold', type: 'uint32' }] },70 { type: 'function', name: 'receiveMessage', stateMutability: 'nonpayable', outputs: [{ name: 'success', type: 'bool' }], inputs: [{ name: 'message', type: 'bytes' }, { name: 'attestation', type: 'bytes' }] },71 { type: 'function', name: 'usedNonces', stateMutability: 'view', outputs: [{ type: 'uint256' }], inputs: [{ name: 'nonce', type: 'bytes32' }] },72 { type: 'function', name: 'getMinFeeAmount', stateMutability: 'view', outputs: [{ type: 'uint256' }], inputs: [{ name: 'amount', type: 'uint256' }] },73 { type: 'event', name: 'MessageSent', inputs: [{ name: 'message', type: 'bytes', indexed: false }] },74 { type: 'event', name: 'MessageReceived', inputs: [{ name: 'caller', type: 'address', indexed: true }, { name: 'sourceDomain', type: 'uint32', indexed: false }, { name: 'nonce', type: 'bytes32', indexed: true },75 { name: 'sender', type: 'bytes32', indexed: false }, { name: 'finalityThresholdExecuted', type: 'uint32', indexed: true }, { name: 'messageBody', type: 'bytes', indexed: false }] },76] as const;7778const ZERO32 = '0'.repeat(64);79const HEADER = 148, BODY = 228, MESSAGE_BYTES = HEADER + BODY; // a burn message without hook data is exactly 376 bytes80const BURN_GAS_MIN = 80_000n, BURN_GAS_MAX = 400_000n, MINT_GAS_MIN = 100_000n, MINT_GAS_MAX = 450_000n, APPROVE_GAS_MAX = 120_000n;81const eq = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();8283/** An EVM address as the bytes32 CCTP uses (left-padded), lowercase hex without 0x. */84export function cctpBytes32(address: string): string {85 if (!/^0x[0-9a-fA-F]{40}$/.test(address)) throw new Error('not an EVM address');86 return '0'.repeat(24) + address.slice(2).toLowerCase();87}8889/** The registry USDC of a network (the only burnable / mintable token here). */90export function cctpUsdc(network: string) {91 const n = getNetwork(network);92 const t = n.tokens.find((x) => x.symbol === 'USDC');93 if (!t || n.family !== 'evm') throw new Error(`no native USDC in the registry for ${n.name}`);94 return t;95}9697/** What an approved bridge commits to; the signer keeps this record to authorise the one matching mint later. */98export interface CctpBridge { fromNetwork: string; toNetwork: string; wallet: string; amount: string; maxFee: string; minFinalityThreshold: number }99100export function bridgeOf(i: Intent): CctpBridge {101 if (!i.bridge) throw new Error('not a bridge intent');102 return { fromNetwork: i.network, toNetwork: i.bridge.toNetwork, wallet: i.from, amount: i.amount, maxFee: i.bridge.maxFee, minFinalityThreshold: i.bridge.minFinalityThreshold };103}104105/** Check every field of a bridge intent against the pinned tables. Throws on any mismatch. */106export function verifyCctpBridge(i: Intent): { src: CctpPin; dst: CctpPin; amount: bigint; maxFee: bigint } {107 const n = getNetwork(i.network), b = i.bridge;108 if (n.family !== 'evm' || !i.evm || !b) throw new Error('not an EVM bridge intent');109 if (i.swap || i.xswap) throw new Error('an intent is a swap or a bridge, not both');110 if (b.provider !== 'cctp') throw new Error('unknown bridge provider');111 const src = CCTP_ENABLED[i.network], dst = CCTP_ENABLED[b.toNetwork];112 if (!src) throw new Error(`CCTP execution is not enabled on ${n.name}`);113 if (!dst) throw new Error('CCTP execution is not enabled on the destination network');114 if (b.toNetwork === i.network) throw new Error('source and destination are the same network');115 if (i.chainRef !== n.chainRef) throw new Error('chain id mismatch');116 if (b.toDomain !== dst.domain) throw new Error('destination domain does not match the destination network');117 if (b.messenger !== CCTP_TOKEN_MESSENGER) throw new Error('messenger is not the pinned TokenMessengerV2');118 if (b.transmitter !== CCTP_MESSAGE_TRANSMITTER) throw new Error('transmitter is not the pinned MessageTransmitterV2');119 if (!/^0x[0-9a-fA-F]{40}$/.test(i.from) || i.to !== i.from) throw new Error('a bridge must deliver to the wallet itself');120 const usdc = cctpUsdc(i.network); cctpUsdc(b.toNetwork);121 if (i.asset !== usdc.id || getAsset(i.network, i.asset).id !== usdc.id) throw new Error('only the registry USDC can be bridged');122 if (i.symbol !== 'USDC' || i.decimals !== usdc.decimals) throw new Error('asset metadata mismatch');123 const amount = baseUnits(i.amount), maxFee = baseUnits(b.maxFee);124 if (amount <= 0n) throw new Error('amount must be positive');125 if (maxFee >= amount) throw new Error('CCTP fee ceiling must be below the amount');126 if (maxFee * 10_000n > amount * CCTP_MAX_FEE_BPS) throw new Error('CCTP fee ceiling above 1 % of the amount');127 if (b.minFinalityThreshold !== CCTP_FINALITY_FAST && b.minFinalityThreshold !== CCTP_FINALITY_STANDARD) throw new Error('finality threshold must be 1000 (Fast) or 2000 (Standard)');128 if (b.minFinalityThreshold === CCTP_FINALITY_FAST && !src.fastSource) throw new Error('Fast Transfer is not offered from this network');129 return { src, dst, amount, maxFee };130}131132/** Calldata of the burn, rebuilt from the intent alone. */133export function cctpBurnData(i: Intent): Hex {134 const { dst, amount, maxFee } = verifyCctpBridge(i);135 return encodeFunctionData({ abi: CCTP_ABI, functionName: 'depositForBurn', args: [amount, dst.domain, `0x${cctpBytes32(i.from)}`, cctpUsdc(i.network).id as Hex, `0x${ZERO32}`, maxFee, i.bridge!.minFinalityThreshold] });136}137138/** The transactions the signer signs for the first leg: an exact-amount approval when asked, then the burn. */139export function buildCctpBurnTxs(i: Intent): { approve: TransactionSerializableEIP1559 | null; burn: TransactionSerializableEIP1559 } {140 const { amount } = verifyCctpBridge(i);141 const n = getNetwork(i.network), e = i.evm!, b = i.bridge!;142 const gas = baseUnits(e.gasLimit), maxFeePerGas = baseUnits(e.maxFeePerGas), tip = baseUnits(e.maxPriorityFeePerGas), approveGas = baseUnits(b.approveGas);143 if (tip > maxFeePerGas) throw new Error('priority fee above max fee');144 if (gas < BURN_GAS_MIN || gas > BURN_GAS_MAX) throw new Error('burn gas limit out of range');145 // approveGas 0 = no approval transaction: the existing allowance already covers the amount (if not, the burn simply reverts)146 if (approveGas !== 0n && (approveGas < 30_000n || approveGas > APPROVE_GAS_MAX)) throw new Error('approval gas out of range');147 if ((gas + approveGas) * maxFeePerGas > baseUnits(i.maxFee)) throw new Error('fee exceeds approved maximum');148 const common = { type: 'eip1559' as const, chainId: Number(n.chainRef), maxFeePerGas, maxPriorityFeePerGas: tip };149 const approve = approveGas === 0n ? null : {150 ...common, nonce: e.nonce, gas: approveGas, to: cctpUsdc(i.network).id as Hex, value: 0n,151 data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [CCTP_TOKEN_MESSENGER as Hex, amount] }), // exact amount, never unlimited152 };153 const burn = { ...common, nonce: e.nonce + (approve ? 1 : 0), gas, to: CCTP_TOKEN_MESSENGER as Hex, value: 0n, data: cctpBurnData(i) };154 return { approve, burn };155}156157export interface CctpMessage {158 version: number; sourceDomain: number; destinationDomain: number; nonce: string; sender: string; recipient: string; destinationCaller: string;159 minFinalityThreshold: number; finalityThresholdExecuted: number;160 body: { version: number; burnToken: string; mintRecipient: string; amount: bigint; messageSender: string; maxFee: bigint; feeExecuted: bigint; expirationBlock: bigint };161}162163const hexOk = (s: unknown): s is string => typeof s === 'string' && /^0x([0-9a-fA-F]{2})*$/.test(s);164165/** Parse a CCTP V2 burn message (header 148 bytes + body 228 bytes, no hook data). bytes32 fields are lowercase hex without 0x. */166export function decodeCctpMessage(message: string): CctpMessage {167 if (!hexOk(message)) throw new Error('message is not hex');168 const h = message.slice(2).toLowerCase();169 if (h.length !== MESSAGE_BYTES * 2) throw new Error(`message is ${h.length / 2} bytes, a burn message without hook is ${MESSAGE_BYTES}`);170 const at = (o: number, len: number) => h.slice(o * 2, (o + len) * 2);171 const u32 = (o: number) => parseInt(at(o, 4), 16), u256 = (o: number) => BigInt('0x' + at(o, 32));172 const B = HEADER;173 return {174 version: u32(0), sourceDomain: u32(4), destinationDomain: u32(8), nonce: at(12, 32), sender: at(44, 32), recipient: at(76, 32), destinationCaller: at(108, 32),175 minFinalityThreshold: u32(140), finalityThresholdExecuted: u32(144),176 body: { version: u32(B), burnToken: at(B + 4, 32), mintRecipient: at(B + 36, 32), amount: u256(B + 68), messageSender: at(B + 100, 32), maxFee: u256(B + 132), feeExecuted: u256(B + 164), expirationBlock: u256(B + 196) },177 };178}179180/** Inverse of decodeCctpMessage (tests and the fork proof build messages with it). */181export function encodeCctpMessage(m: CctpMessage): Hex {182 const u32 = (v: number) => v.toString(16).padStart(8, '0'), u256 = (v: bigint) => v.toString(16).padStart(64, '0');183 const b = m.body;184 const out = u32(m.version) + u32(m.sourceDomain) + u32(m.destinationDomain) + m.nonce + m.sender + m.recipient + m.destinationCaller + u32(m.minFinalityThreshold) + u32(m.finalityThresholdExecuted)185 + u32(b.version) + b.burnToken + b.mintRecipient + u256(b.amount) + b.messageSender + u256(b.maxFee) + u256(b.feeExecuted) + u256(b.expirationBlock);186 if (out.length !== MESSAGE_BYTES * 2 || !/^[0-9a-f]+$/.test(out)) throw new Error('malformed message fields');187 return `0x${out}`;188}189190/** keccak256 of the message bytes: what Circle's attesters sign, and what binds a mint to one message. */191export const cctpMessageHash = (message: string): string => keccak256(message as Hex).slice(2);192193/**194 * Leg 2: the attested message must be exactly the approved burn — same route, same token, same amount, same fee ceiling and195 * finality, burned by the wallet, minted to the wallet, no hook, callable by anyone. Returns the parsed message and what196 * the mint will credit. Throws on any mismatch.197 */198export function verifyCctpMint(b: CctpBridge, message: string, attestation: string): { msg: CctpMessage; minted: bigint; messageHash: string } {199 const src = CCTP_ENABLED[b.fromNetwork], dst = CCTP_ENABLED[b.toNetwork];200 if (!src || !dst || b.fromNetwork === b.toNetwork) throw new Error('bridge route is not enabled');201 const m = decodeCctpMessage(message);202 const messenger = cctpBytes32(CCTP_TOKEN_MESSENGER), wallet = cctpBytes32(b.wallet);203 if (m.version !== 1 || m.body.version !== 1) throw new Error('message is not CCTP V2');204 if (m.sourceDomain !== src.domain) throw new Error('message comes from another source domain');205 if (m.destinationDomain !== dst.domain) throw new Error('message is for another destination domain');206 if (m.nonce === ZERO32) throw new Error('message has no nonce (not attested)');207 if (m.sender !== messenger || m.recipient !== messenger) throw new Error('message is not between the pinned TokenMessengers');208 if (m.destinationCaller !== ZERO32) throw new Error('message restricts the destination caller');209 if (m.body.burnToken !== cctpBytes32(cctpUsdc(b.fromNetwork).id)) throw new Error('message burns another token');210 if (m.body.mintRecipient !== wallet) throw new Error('message mints to another address');211 if (m.body.messageSender !== wallet) throw new Error('message was not burned by this wallet');212 if (m.body.amount !== baseUnits(b.amount)) throw new Error('message carries another amount');213 if (m.body.maxFee !== baseUnits(b.maxFee)) throw new Error('message carries another fee ceiling');214 if (m.body.feeExecuted > m.body.maxFee) throw new Error('message charges more than the approved fee ceiling');215 if (m.minFinalityThreshold !== b.minFinalityThreshold) throw new Error('message asks for another finality threshold');216 if (m.finalityThresholdExecuted < b.minFinalityThreshold) throw new Error('message was attested below the approved finality');217 if (!hexOk(attestation)) throw new Error('attestation is not hex');218 const sigBytes = (attestation.length - 2) / 2;219 if (sigBytes < 65 || sigBytes > 65 * 8 || sigBytes % 65 !== 0) throw new Error('attestation is not a list of 65-byte signatures');220 return { msg: m, minted: m.body.amount - m.body.feeExecuted, messageHash: cctpMessageHash(message) };221}222223export interface CctpMintGas { nonce: number; gasLimit: string; maxFeePerGas: string; maxPriorityFeePerGas: string }224225/** The one transaction of the second leg. `feeCeiling` is the signer policy's native fee ceiling for the destination network. */226export function buildCctpMintTx(b: CctpBridge, message: string, attestation: string, g: CctpMintGas, feeCeiling: bigint): TransactionSerializableEIP1559 {227 verifyCctpMint(b, message, attestation);228 const n = getNetwork(b.toNetwork);229 if (!Number.isInteger(g.nonce) || g.nonce < 0) throw new Error('bad nonce');230 const gas = baseUnits(g.gasLimit), maxFeePerGas = baseUnits(g.maxFeePerGas), tip = baseUnits(g.maxPriorityFeePerGas);231 if (tip > maxFeePerGas) throw new Error('priority fee above max fee');232 if (gas < MINT_GAS_MIN || gas > MINT_GAS_MAX) throw new Error('mint gas limit out of range');233 if (gas * maxFeePerGas > feeCeiling) throw new Error('mint fee above the policy ceiling of the destination network');234 return { type: 'eip1559', chainId: Number(n.chainRef), nonce: g.nonce, gas, maxFeePerGas, maxPriorityFeePerGas: tip, to: CCTP_MESSAGE_TRANSMITTER as Hex, value: 0n,235 data: encodeFunctionData({ abi: CCTP_ABI, functionName: 'receiveMessage', args: [message as Hex, attestation as Hex] }) };236}237238const enc = new TextEncoder();239/**240 * Challenge for a passkey-approved RE-SIGNATURE of a mint (the first one is covered by the burn approval). Binds the bridge,241 * the message and the exact gas parameters. Returned as the string to hash with SHA-256 (the signer and the API hash it the same way).242 */243export function cctpMintRetryBytes(proposalId: string, messageHash: string, g: CctpMintGas): Uint8Array {244 return enc.encode(['SPW-BRIDGE-MINT-v1', proposalId, messageHash, String(g.nonce), g.gasLimit, g.maxFeePerGas, g.maxPriorityFeePerGas].join('\n'));245}246