CCTP (Circle Cross-Chain Transfer Protocol) — execution dossier
Provider id cctp · adapter packages/swap/src/providers/cctp.ts · tests packages/swap/test/cctp.test.ts ·
live check scripts/smoke-swap-cctp.mts. Everything below was read from Circle's documentation and source on
2026-11-10 and, where marked checked, confirmed on mainnet the same day. The quote adapter itself only quotes.
Execution status (2026-11-11): USDC between the seven EVM networks is executable by the wallet itself — burn, attestation, mint, always to the wallet's own address — see §12. Solana legs and EURC remain quote-only. No real transfer has been broadcast yet; the proof is on local forks (§12.7).
CCTP is not a swap: a Circle stablecoin is burned on the source chain, Circle's attestation service (Iris) signs the burn message, and the same amount is minted on the destination chain by whoever submits that signed message. There is no pool, no price and no slippage; the only deductions are Circle's fee and gas on both sides.
1. What is served
| Wallet network | CCTP domain | Fast Transfer as source | Standard attestation | Fast attestation | EURC | Forwarding Service destination |
|---|---|---|---|---|---|---|
| ethereum | 0 | yes | ~15–19 min (~65 blocks) | ~20 s (2 blocks) | yes | yes |
| avalanche | 1 | n/a | ~8 s | — | yes | yes |
| optimism | 2 | yes | ~15–19 min (~65 ETH blocks) | ~8 s | no | yes |
| arbitrum | 3 | yes | ~15–19 min (~65 ETH blocks) | ~8 s | no | yes |
| solana | 5 | yes | ~17 s (32 blocks) | ~0.4–0.6 s | no (EVM only) | yes |
| base | 6 | yes | ~15–19 min (~65 ETH blocks) | ~8 s | yes | yes |
| polygon | 7 | n/a | ~8 s | — | no | yes |
| hyperevm | 19 | n/a | ~5 s | — | no | yes |
- USDC: all 56 ordered pairs among these eight networks, in both directions (checked: Iris answers every pair, and
each chain's
TokenMessengerV2.remoteTokenMessengers(domain)is the pinned messenger of the seven others). - EURC: the six ordered pairs among ethereum, avalanche and base, through the separate "CCTP for non-USDC" product (§8).
- Only the registry's own USDC / EURC deployments qualify. The adapter resolves both assets through
resolveAsset()and refuses anything else (bridged USDC.e, another mint, another symbol) without any HTTP call. - Domains are Circle identifiers, not chain ids. Source: https://developers.circle.com/cctp/concepts/supported-chains-and-domains.
Times: https://developers.circle.com/cctp/concepts/finality-and-block-confirmations. The adapter reports the upper
bound of the documented range (19 min = 1140 s; Solana Fast rounded up to 1 s) as
estimatedSeconds. That figure is the time for the attestation to become available; it excludes the inclusion of the burn and of the mint. - "n/a" = Circle does not offer Fast from that source because Standard is already fast. Iris still returns a
finalityThreshold: 1000entry withminimumFee: 0for those sources; the adapter emits Standard only.
Not served, and why:
| Network | Reason |
|---|---|
| bsc | Domain 17 exists but carries USYC only: "USDC: supported on all CCTP domains except BNB Smart Chain". |
| stellar, sui, aptos | CCTP chains, but the wallet registry has no USDC there. Stellar additionally needs CctpForwarder and has 7-decimal USDC. Adding them means registry work first. |
| tron, xrpl, cosmos, near, ton, UTXO chains | No CCTP deployment. (Noble, domain 4, is V1-only and is being retired.) |
V2 only
What Circle now calls "CCTP" is CCTP V2. CCTP V1 ("Legacy") is being retired: deprecation "begins October 31, 2026 and completes on December 1, 2026"; message limits tighten until no new burn can be initiated, pending redemptions stay available (https://developers.circle.com/cctp/migration-from-v1-to-v2). The adapter therefore never quotes V1 and the signer must refuse V1 targets. V1 is documented in §9 only so those calls can be recognised.
2. Contracts and programs (pinned)
CCTP V2, EVM — the same address on every chain (https://developers.circle.com/cctp/references/contract-addresses):
| Contract | Address | Role |
|---|---|---|
| TokenMessengerV2 | 0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d |
entry point of the burn; spender of the USDC approval |
| MessageTransmitterV2 | 0x81D40F21F12A8F0E3252Bccb954D722d4c464B64 |
emits the message on the source, receiveMessage on the destination |
| TokenMinterV2 | 0xfd78EE919681417d192449715b2594ab58f5D002 |
burns / mints; holds the per-message burn limit |
| MessageV2 (helper) | 0xec546b6B005471ECf012e5aF77FBeC07e0FD8f78 |
address ↔ bytes32 helpers, not needed |
| TokenMessengerWithFees | 0x71f54F818671cD0D7ea140Da213e5C8b5C92a408 |
upfront-fee wrapper, not used (§5) |
CCTP V2, Solana (https://developers.circle.com/cctp/references/solana-programs): MessageTransmitterV2
CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC, TokenMessengerMinterV2 CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe.
Source code: https://github.com/circlefin/evm-cctp-contracts (src/v2/), https://github.com/circlefin/solana-cctp-contracts
(programs/v2/). Audits (ChainSecurity, OtterSec) are linked from https://developers.circle.com/cctp/references/technical-guide.
Checked on-chain by the smoke script for all eight networks: code present at each address; MessageTransmitterV2.localDomain()
equals the pinned domain; version() = 1 and TokenMessengerV2.messageBodyVersion() = 1 (the V2 formats);
localMessageTransmitter() and localMinter() point at the pinned contracts; TokenMinterV2.burnLimitsPerMessage(registry USDC)
= 10 000 000 USDC (so the registry token is the burnable one and the documented limit holds); on Solana both programs
are executable, the message_transmitter state account reports local_domain 5 / version 1, and the local_token
account of the registry mint exists.
Upgradeability — a trust statement, not a detail (read 2026-11-11 from the verified sources and from both registry
RPC endpoints of each network with scripts/cctp-pins.mts; full hashes in packages/core/src/cctp.ts).
TokenMessengerV2andMessageTransmitterV2areAdminUpgradableProxycontracts (Circle's fork of OpenZeppelin's transparent proxy, solc 0.7.6, EIP-1967 slots;admin()andimplementation()are public views). The 2 175-byte proxy runtime code is identical on all seven networks (SHA-256501b4a60e24ea82032b7442d75995dee3bdbfc17b25caffc3c955eadc8c2a418).upgradeTo,upgradeToAndCallandchangeAdminare callable only by the address in the EIP-1967 admin slot, with no timelock or delay in the proxy. On every network that admin is an address without code (an externally owned account from the chain's point of view — whether it is a hardware/MPC key or a plain key cannot be seen on-chain), and it is a different address per network and per contract.TokenMinterV2is not a proxy (implementation slot empty; 9 295 bytes, identical on all seven networks, SHA-256ddc1bfebb8465377e23aea86ef3135551130ae35426b759e69c1644735074be6). Its owner can change the token controller / burn limits.- Implementations (explorer-verified sources, solc 0.7.6, 100 000 optimizer runs):
| Network | TokenMessengerV2 implementation | SHA-256 (start) | MessageTransmitterV2 implementation | SHA-256 (start) |
|---|---|---|---|---|
| ethereum | 0x555e272506c06e7e559d57418563742afe363ec8 |
81ca2a58e7649564… |
0xa30c41865729c248067704d4db9f38385cbe1186 |
b7b2a422acb8daa7… |
| base | 0x555e272506c06e7e559d57418563742afe363ec8 |
81ca2a58e7649564… |
0x7db629f6acc20be49a0a7565c21cc178e9ac21e3 |
5814f08b2cfc7399… |
| arbitrum | 0x555e272506c06e7e559d57418563742afe363ec8 |
81ca2a58e7649564… |
0x7b93db2bc72faf642cec0adcac4f91718831a1f4 |
bad293319d77ab3e… |
| optimism | 0x555e272506c06e7e559d57418563742afe363ec8 |
81ca2a58e7649564… |
0x1f33335414afb7305367452d00a0d024b0184ea8 |
1c4986994c9f845d… |
| polygon | 0x555e272506c06e7e559d57418563742afe363ec8 |
81ca2a58e7649564… |
0xd40518c1a3139e8f1f73f83e5de74e05c88f5ad3 |
540cd1aece247c99… |
| avalanche | 0x555e272506c06e7e559d57418563742afe363ec8 |
81ca2a58e7649564… |
0x8689f0a21fe82b30e8f7684902788f65d891dae6 |
6c541f8c79e43368… |
| hyperevm | 0x1ccafdffbc1b7b5c499c97322f961b7d929a41b4 |
cff375318c7d5eda… |
0x17eaec8bb0d05e8da2e96ad69121fee6dd62d313 |
e9423da6e41a7a11… |
The messenger implementation is the same contract at the same address on six networks; HyperEVM runs a newer build
with the Standard-fee switch (minFee, getMinFeeAmount; minFee() = 0 on 2026-11-11). The explorer sources of that
build are byte-identical to circlefin/evm-cctp-contracts master (a92a2b4e, 2026-06-18); the six-network build is the
same code without the min-fee feature (no minFee, no getMinFeeAmount, older initialize) and nothing else differs.
The transmitter implementation has the same 26 source files on all seven networks; the bytecode differs only by the
immutable localDomain.
- Who can change what (addresses on 2026-11-11; none of the proxy admins carries code):
| Network | Messenger proxy admin | Transmitter proxy admin | Messenger owner | Transmitter owner | Attester manager |
|---|---|---|---|---|---|
| ethereum | 0xa820a620f638114f3eb45df87382383322ddbc49 |
0xb7569e9eb64eaed5c4166e510b4f6ff54ef42258 |
0x2329BAF15f86023e5B0f5592e230D02a51116CD5 |
0xB43aA882f497c0Ecb395A5a54f8fa29d12289F65 |
0xc16b03F84563C1467017C9F7216F8ad6098dc95a |
| base | 0x88acf681fb9a1dfce5ac83391991895c54cf24cc |
0x19b4b317e6ea4643f1507c372630483092d0abff |
0x24253f1CA7540CfCb3DdDa890d3b94434786E379 |
0x9c563B7B08C5506C4aae279E1f66658D5f9fD7B8 |
0xDf265e0329F6A08a772B48191b33bDC624499b84 |
| arbitrum | 0xe825a926c4196878c898ba8b78bc3396570909be |
0x15fb0197b0b8ae18a5f283ba8b002d4c6abb127f |
0xe2e39E7D80fD21Fa39eB04BdaD36D0B2C65b818F |
0x5054056a0d4F3106B7a8Ad33dD3FD2E8c3d1524d |
0x49420615d90a11cE317eb1eEfBB5C6e2Bf366195 |
| optimism | 0x14085cbf8634a9d279378eb3bf40240e80647681 |
0x1cc76b72d180f8768a5e3d2ff8b1dfb593c1ecd3 |
0xdf31F6B761d5D47D8Cea36EE33cd11c7b071096A |
0xb9dC4d38138FD53D645DaEa92D1c2294BBD5Ba50 |
0x4D6C6325998FCE054B4C4420428b86B28BD8db74 |
| polygon | 0xaf904ead44750e2e970f205e9d1e0c6ca8caa643 |
0xcb37aa4d4be3abdadc9f7a677ba806b7ff4aea15 |
0x97E89576ef93A3072DC73aB05a16951760124bb6 |
0xD7b229FDcdF9dEA893236F23509754FB5C6A0457 |
0x190f7ef6F3502710392439b9C11A6D5B118df46e |
| avalanche | 0xe4a786348cf3df7ef5dc9b28f658e22c816f723f |
0xdd4bfb4b809e68c0973d7eb6d160d680183c8b5c |
0x548f6CE934755c9b135FBE3FfD2F8D7fa330e5FB |
0x81Be209E803e5bBE90a17aC13d65510Bf60800Ed |
0x9d4C65ACa5bF3a0A7012d8CeFEA6fcF9FB6d63B8 |
| hyperevm | 0xec6c5c1b27e66701e7b79747caa2322127e0d4f2 |
0xe11c4e6efee7205325a2219771c474527210c5ab |
0x419d9cf7341f571C2F4Be894d73B065f103f3719 |
0x3D1b1E2D06B3F3FB7637bC73b69b6b0B0dFa79C1 |
0x7925e7E92Cfb0b74EF9EBBdBC9E45c3622cf4834 |
- Attesters: 2 enabled on every network, the same two keys everywhere (
0x725b06F73ff761eF5390e39315e2BfbF60d33F96,0x52Ed4cBff8DcE6A19748043F3240ec03c834bCeF), signature threshold 2. The attester manager of a network can enable or disable attesters and change the threshold: whoever controls it can mint on that network. This is the trust assumption of CCTP itself, not of the wallet.
What the wallet pins, and the trade-off. The API refuses to prepare a transfer, and refuses to send the mint, unless on
both RPC sources of the network: the two proxies hash to the pinned proxy code, each EIP-1967 implementation slot holds
the pinned implementation address, each implementation hashes to its pinned value, the minter hashes to its pinned value,
and the wallet address carries no code. Consequence: the day Circle upgrades a contract, CCTP transfers stop on that
network until someone reads the new implementation and updates CCTP_ENABLED (run node scripts/cctp-pins.mts to see
the new values). A transfer already burned waits in "ready" with the reason shown; it is not lost — the mint is
permissionless (destinationCaller = 0) and can be submitted from any other tool with the public message and attestation —
and it resumes by itself once the pins are updated. What the pin does not give: the signer has no chain access, so
the check lives in the API (as for the KyberSwap router); and Circle could upgrade between the check and the inclusion of
the transaction (seconds). The same party issues and can freeze USDC, so this adds no new trusted party.
3. Circle API (Iris)
Host https://iris-api.circle.com (mainnet; sandbox https://iris-api-sandbox.circle.com), version prefix /v2.
Keyless. Rate limit: 40 requests per second; exceeding it blocks all requests for five minutes (HTTP 429). One quote
costs two requests (USDC) — fee schedule and Fast allowance — far below the wallet's own token bucket.
| Endpoint | Use | Shape (recorded in test/fixtures/cctp/) |
|---|---|---|
GET /v2/burn/USDC/fees/{src}/{dst}[?forward=true][&includeRecipientSetup=true] |
fee schedule of a route | [{finalityThreshold: 1000|2000, minimumFee: <bps, may be fractional>, forwardFee?: {low, med, high}}]; 400 {"error":"Invalid source/destination domain id"} for an unserved pair |
GET /v2/fastBurn/USDC/allowance |
remaining global Fast allowance | {allowance: <USDC, decimal>, lastUpdated} |
GET /v2/messages/{srcDomain}?transactionHash=… (or nonce=) |
message + attestation of a burn | §6 |
POST /v2/reattest/{nonce} |
new attestation for an expired Fast message, or finality upgrade | — |
GET /v2/publicKeys |
attester keys per CCTP version | {publicKeys:[{publicKey, cctpVersion}]} (2 keys for V2 today) |
POST /v2/quote/cctpx/{tokenId}/{src}/{dst} |
signed fee quote, non-USDC assets | §8 |
GET /v2/cctpx/tokens, GET /v2/cctpx/allowances |
non-USDC token deployments / Fast allowances | §8 |
/v2/fastBurn/USDC/fees is deprecated; V1 used /v1/attestations/{hash} and /v1/messages/{domain}/{txHash}.
Reference: https://developers.circle.com/cctp/references/technical-guide#api-hosts-and-endpoints.
Terms: the endpoints are public and unauthenticated; the documentation states no usage restriction beyond the rate
limit. Circle's general constraints still apply to the asset itself — USDC can be frozen by the issuer, and
TokenMessengerV2 has its own denylist (notDenylistedCallers; a denylist_account PDA on Solana) and pause switch.
No affiliate or integrator fee exists in the protocol ("CCTP has no built-in mechanism for adding a custom application
fee").
4. Fees
Source: https://developers.circle.com/cctp/concepts/fees. "For USDC, CCTP charges fees on Fast Transfers only. Standard Transfers are free." The fee is "deducted from the transferred amount when USDC is minted on the destination".
Live schedule among the wallet's networks (2026-11-11, from the smoke script; "do not hardcode" — the adapter reads it on every quote):
| Source | Fast fee to every wallet destination | Standard |
|---|---|---|
| ethereum, solana | 1 bps | 0 |
| base, optimism | 1.3 bps | 0 |
| arbitrum | 1.4 bps | 0 |
| avalanche, polygon, hyperevm | Fast not offered | 0 |
Rounding (not documented; measured). For real Fast burns from Arbitrum at 1.4 bps, Iris's feeExecuted is
floor(amount × bps / 10 000):
| Source tx (Arbitrum) | amount | maxFee | feeExecuted | amount × 1.4 / 10⁴ |
|---|---|---|---|---|
0x2ff6976b…47aaac (recorded as a fixture) |
100 584 300 | 14 363 | 14 081 | 14 081.80 |
0x93677e89…43adb7 |
9 966 740 | 1 396 | 1 395 | 1 395.34 |
0x7d737c74…b97f90 |
1 739 483 528 | 243 528 | 243 527 | 243 527.69 |
So the adapter reports fee = floor(…) as the protocol FeeItem (includedInOutput: true, asset = destination USDC),
amountOut = amountIn − fee, and plans maxFee = ceil(…), hence minAmountOut = amountIn − maxFee. The contract
enforces feeExecuted ≤ maxFee at mint, so minAmountOut is a hard guarantee, not an estimate. A maxFee equal to
the rounded-up fee is what integrators submit in practice (second and third rows). If the fee rises between quote and
burn, maxFee is too small and the transfer is attested as Standard instead (slower, no fee, no loss); if it
falls, less is deducted. The request's slippageBps is deliberately not applied: there is no price to slip.
-
Standard fee switch: some
TokenMessengerV2deployments can enforce a minimum on-chain fee for Standard (getMinFeeAmount). None of the wallet's eight networks has it (the fee-switch build is on Sei only). The adapter already applies thefinalityThreshold: 2000minimumFeewith the same arithmetic if it ever becomes non-zero. -
Fast allowance: Fast burns draw on a global Circle allowance (53 M USDC at the time of the check). The adapter emits a Fast quote only when the allowance covers
amountIn. -
Limits: 10 000 000 USDC per burn (on-chain
burnLimitsPerMessage);maxFee < amountis required by the contract, so a one-base-unit Fast transfer does not exist. -
Gas, both sides, as
networkFeeItems (includedInOutput: false,estimated: true), from oneeth_gasPriceper EVM side against the registry's RPC endpoints (second endpoint on failure) times pinned gas units:Step Pinned units Measured on mainnet 2026-11-10 USDC approve60 000 not sampled (standard ERC-20 approve) depositForBurn120 000 104 521 – 114 174 (Arbitrum, direct calls) receiveMessage(USDC)200 000 154 526 – 187 119 (Arbitrum) crossChainTransfer(non-USDC)380 000 242 200 – 369 453 (Ethereum, all tokens) receiveMessage(non-USDC)240 000 212 638 – 229 733 (Ethereum) OP-stack L1 data fees (base, optimism) are not included; priority fees are not included.
-
Solana, read live with
getMinimumBalanceForRentExemption(rent is no longer the constant older documents quote): source = 2 signatures × 5 000 lamports + rent of the 428-byteMessageSentaccount (2 824 480 lamports, refundable after five days throughreclaim_event_account); destination = 5 000 lamports + rent of the 9-byteused_noncerecord (695 960 lamports, not refundable) + rent of a 165-byte token account (1 488 440 lamports) when the recipient has no USDC token account — the adapter checks that withgetTokenAccountsByOwnerin the same RPC batch and prices it in when the account is missing or the recipient is a placeholder. Sizes checked against real transactions (5snQDybx…: 2 signatures, account of 428 bytes;mLPkABDq…: +695 960 lamports).
5. Who submits the mint
Without a relayer the destination mint is a second transaction that the wallet must submit and pay in the destination
chain's coin. This is the practical constraint of CCTP for a wallet: a user bridging USDC to a chain where they hold
no gas cannot finish alone. The adapter models it as a network FeeItem on the destination chain and writes it into
the step ("mint on base: …; the wallet submits the mint itself and needs ETH on base for it").
Alternatives Circle offers, not used in this phase:
- Forwarding Service (https://developers.circle.com/cctp/concepts/forwarding-service): the burn is made with
depositForBurnWithHookand the fixed 32-byte hook0x636374702d666f72776172640000000000000000000000000000000000000000("cctp-forward", version 0, length 0); Circle then broadcasts the mint and takes a fee from the minted amount (gas + USD 0.05 service fee;maxFeemust cover the Fast fee and the forwarding fee). The live fee is returned by the fee endpoint with?forward=trueand kept inraw.forwardingService(USDC base units; from ethereum on 2026-11-11: ~0.05–0.09 USDC to L2s/Solana, ~0.87 USDC to Ethereum). To Solana the recipient's token account must exist, or the hook must carry the ATA-creation extension and the fee must be asked withincludeRecipientSetup=true. This is the natural second step for the wallet; its verification story is in §7.4. - Upfront fees (
TokenMessengerWithFees, signed quote, fee paid in the source coin so the full amount is minted): needs a Circle-signed blob the signer cannot rebuild. Not used.
6. Message and attestation
GET /v2/messages/{srcDomain}?transactionHash={burnTx} — 404 {"error":"Message not found…"} until Iris has seen
the burn (normal: keep polling), then
{ "messages": [ { "status": "pending_confirmations" | "complete", "message": "0x…", "attestation": "0x…",
"eventNonce": "0x…", "cctpVersion": 2, "decodedMessage": { … }, "delayReason": null,
"destinationMintTxHash": "0x…" } ], "sourceTxHash": "0x…" }A real answer is recorded in test/fixtures/cctp/message-arbitrum-base-fast.json. decodedMessage is a convenience
and must not be trusted: the signer parses message itself. Layout
(https://developers.circle.com/cctp/references/technical-guide#message-format), implemented and tested against that
fixture as decodeCctpMessageV2():
| Header field | Offset | Bytes | Body field (offset from 148) | Offset | Bytes | |
|---|---|---|---|---|---|---|
| version (= 1) | 0 | 4 | version (= 1) | 0 | 4 | |
| sourceDomain | 4 | 4 | burnToken | 4 | 32 | |
| destinationDomain | 8 | 4 | mintRecipient | 36 | 32 | |
| nonce (assigned by Iris) | 12 | 32 | amount | 68 | 32 | |
| sender (source TokenMessenger) | 44 | 32 | messageSender (who burned) | 100 | 32 | |
| recipient (destination TokenMessenger) | 76 | 32 | maxFee | 132 | 32 | |
| destinationCaller | 108 | 32 | feeExecuted | 164 | 32 | |
| minFinalityThreshold | 140 | 4 | expirationBlock | 196 | 32 | |
| finalityThresholdExecuted | 144 | 4 | hookData | 228 | rest |
The attestation is a concatenation of 65-byte ECDSA signatures (r, s, v) over keccak256(message); the contract checks
them against its enabled attesters (signature threshold, ascending signer order). Verifying it off-chain against
/v2/publicKeys is optional (https://developers.circle.com/cctp/references/attestation-verification): a bad
attestation only makes the mint revert.
expirationBlock: 0 for Standard (checked on a real Standard message) — the attestation never expires. Fast messages
carry a block about 24 h ahead on the destination chain (on Arbitrum it is an Ethereum L1 block number); after it,
POST /v2/reattest/{nonce} yields a fresh attestation, "no deadline after which re-attestation becomes unavailable".
7. Execution path and what the signer can verify
7.1 EVM source — two transactions
-
Approval:
USDC.approve(spender, amount)— selector0x095ea7b3,to= the registry USDC of the source network,spender= TokenMessengerV2 (it callstransferFrom(sender → TokenMinter)then burns),amount= exactly the burn amount. Never an unlimited allowance;value= 0. Nothing else needs an approval for USDC. -
Burn:
to= TokenMessengerV2,value= 0, calldata = selector0x8e0250ee+ seven static words (228 bytes):depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold)Selector checked against live mainnet transactions. Contract-side requirements (source
TokenMessengerV2.sol):amount > 0,mintRecipient ≠ 0,maxFee < amount, a remote TokenMessenger registered for the domain, caller not denylisted, amount within the burn limit.
Every argument is derivable from the approved intent, so the signer can rebuild the calldata byte for byte:
| Field | The signer requires |
|---|---|
to |
the pinned TokenMessengerV2 (same constant on all seven EVM networks), on the intent's chain id |
amount |
= intent amount (and = the approval's amount) |
destinationDomain |
= CCTP_CHAINS[intent.destinationNetwork].domain, from its own pinned table, ≠ the source domain |
mintRecipient |
EVM destination: 0x000000000000000000000000 ‖ address of the wallet's own address on the destination network. Solana destination: the 32 raw bytes of the associated token account of (wallet's Solana address, registry USDC mint) — derived by the signer with associatedTokenAddress(), never taken from the API |
burnToken |
= the registry USDC of the source network |
destinationCaller |
= bytes32(0) |
maxFee |
≤ the fee ceiling shown to the user in the intent (0 for Standard); < amount |
minFinalityThreshold |
exactly 1000 (Fast) or 2000 (Standard), as approved |
| selector | 0x8e0250ee only — depositForBurnWithHook (0x779b432d), the V1 selectors and the fee wrapper are refused |
The Solana-recipient trap. For a Solana destination mintRecipient is the token account, not the wallet
address. If the wallet address itself were encoded, receive_message could never succeed (the user_token_account
passed to the program "must match the mintRecipient" and must be a token account), and CCTP V2 has no way to redirect
a burn (replaceDepositForBurn(): "No V2 equivalent available"). The burned USDC would be unrecoverable. This is the
single most important check on the burn, and the reason the signer must derive the account itself.
Why destinationCaller must be zero. A non-zero value restricts receiveMessage to that one address ("Invalid
caller for message" otherwise). It exists for integrators that wrap the mint in their own contract. For a wallet it can
only hurt: a wrong value locks the mint behind an address the user may not control, and even the correct one removes
the safety net that anyone can finish the transfer. Zero keeps the mint permissionless, and harmless: whoever
submits it, USDC goes to mintRecipient.
Why hooks must be absent. hookData is opaque metadata that CCTP itself does not execute; it only matters to a
destination contract that reads it. With mintRecipient = the wallet's own account nothing reads it, so the only
safe value is none — which is exactly what depositForBurn produces (hookData.length == 0, checked at parse time
as "message length = 376 bytes"). The one hook with protocol-level meaning is the Forwarding Service's (§7.4).
7.2 Solana source — one transaction, two signatures
Program TokenMessengerMinterV2, Anchor instruction deposit_for_burn: data = discriminator d73c3d2e723780b0
(sha256("global:deposit_for_burn")[0..8]) + Borsh amount u64 LE ‖ destination_domain u32 LE ‖ mint_recipient [32] ‖ destination_caller [32] ‖ max_fee u64 LE ‖ min_finality_threshold u32 LE = 96 bytes. Accounts, in order (from
deposit_for_burn.rs; #[event_cpi] appends the last two):
| # | Account | Derivation | Signer / writable |
|---|---|---|---|
| 1 | owner | the wallet | signer |
| 2 | event_rent_payer | the wallet | signer, writable |
| 3 | sender_authority_pda | ["sender_authority"] @ TokenMessengerMinterV2 |
— |
| 4 | burn_token_account | the wallet's USDC associated token account | writable |
| 5 | denylist_account | ["denylist_account", owner] @ TokenMessengerMinterV2 (must not exist) |
— |
| 6 | message_transmitter | ["message_transmitter"] @ MessageTransmitterV2 |
writable |
| 7 | token_messenger | ["token_messenger"] @ TokenMessengerMinterV2 |
— |
| 8 | remote_token_messenger | ["remote_token_messenger", domain as decimal string] @ TokenMessengerMinterV2 |
— |
| 9 | token_minter | ["token_minter"] @ TokenMessengerMinterV2 |
— |
| 10 | local_token | ["local_token", mint] @ TokenMessengerMinterV2 |
writable |
| 11 | burn_token_mint | the registry USDC mint | writable |
| 12 | message_sent_event_data | a fresh keypair (any uninitialised non-PDA address) | signer, writable |
| 13 | message_transmitter_program | CCTPV2Sm4… |
— |
| 14 | token_messenger_minter_program | CCTPV2vPZ… |
— |
| 15 | token_program | SPL Token | — |
| 16 | system_program | — | — |
| 17 | event_authority | ["__event_authority"] @ TokenMessengerMinterV2 |
— |
| 18 | program | CCTPV2vPZ… |
— |
The seed spelling of rows 3 and 8 is taken from the V2 source and from the receive-side table in Circle's docs; it has
not been exercised by a built transaction yet and must be confirmed by simulation in the next phase (only
message_transmitter and local_token were derived and read on mainnet by the smoke script).
Consequences for the signer: (a) no token approval — the owner signs the burn of its own token account; (b) a second,
ephemeral signature is needed for account 12. The signer should generate that keypair itself, sign, and discard it:
the account only stores the event, and its rent comes back to event_rent_payer through reclaim_event_account
(discriminator 5ec6b49f83ec0fae) five days later — a follow-up transaction the wallet should offer; (c) the
argument checks are those of §7.1 (mint_recipient for an EVM destination is the 20-byte address left-padded to 32
bytes; destination_caller = Pubkey::default()); (d) any extra instruction other than compute-budget ones is refused.
7.3 The mint leg
EVM destination: to = MessageTransmitterV2, value = 0, receiveMessage(bytes message, bytes attestation), selector
0x57ecfd28 (checked on mainnet), standard ABI encoding of two dynamic byte strings. The bytes come from Iris, so
this is the one place where provider data enters a transaction. The signer parses message (§6) and requires:
| Check | Why |
|---|---|
version = 1, body version = 1, length = 376 bytes (no hook) |
the format it knows; no hook |
sourceDomain, destinationDomain = the intent's networks; to is on the destination chain |
right route |
sender and recipient = the pinned TokenMessenger of each side (bytes32) |
a burn message of CCTP, not an arbitrary message |
destinationCaller = 0 |
as burned |
body burnToken = registry USDC of the source; mintRecipient = the wallet's own destination account; amount = the intent amount; messageSender = the wallet's source address |
this is our burn |
feeExecuted ≤ maxFee ≤ approved ceiling; finalityThresholdExecuted ≥ what was approved, or the user is told it was attested Fast/Standard |
the fee the user accepted |
| attestation length is a multiple of 65 | shape |
What the signature on this transaction can cost is only gas: receiveMessage moves no funds of the caller, and the
mint goes to the message's mintRecipient whatever the caller. A forged message cannot pass the on-chain attester
check. The signer therefore bounds gas (limit and fee cap from the intent) and applies the table above so that the
wallet only ever pays to finish its own transfer. The API pairs the mint with its burn through transactionHash; the
signer does not need that link, the message content is self-describing.
Solana destination: program MessageTransmitterV2, instruction receive_message (discriminator 26907fe11fe1ee19),
data = Borsh message: Vec<u8> ‖ attestation: Vec<u8> (u32 LE length prefixes). Accounts (receive_message.rs):
payer (signer, writable), caller (signer), authority_pda ["message_transmitter_authority", receiver program],
message_transmitter, used_nonce ["used_nonce", message[12..44]] (created here, rent paid by payer),
receiver = TokenMessengerMinterV2, system program, event authority, program; then the remaining accounts Circle lists:
token_messenger, remote_token_messenger ["remote_token_messenger", sourceDomain], token_minter, local_token,
token_pair ["token_pair", sourceDomain, source token bytes], user_token_account (must equal mintRecipient),
custody ["custody", mint], SPL Token program, the TokenMessengerMinter event authority and program id. The
recipient's token account must exist: the transaction is prefixed with an idempotent create-associated-token-account
instruction when it does not. Same message checks as above, with mintRecipient = the signer-derived token account.
7.4 Path to executability: verified
- Intent: add a CCTP section (destination network, transfer speed, fee ceiling, the wallet's destination address as derived from the same key set) so the user approves the route, not calldata.
packages/core:buildCctpApprove,buildCctpBurn(EVM, Solana) andbuildCctpMintthat take only the intent (+message/attestationfor the mint) and the pinned tables; unit tests against the recorded mainnet calldata.- Signer: rebuild and compare, with the tables of §7.1–7.3; refuse V1, hooks, wrappers, non-zero
destinationCaller, allowances above the amount. - API: tracking through
GET /v2/messages, state machine burned → attested → minted, re-attestation, resumable from the burn transaction hash alone (the transfer must survive a lost session: the attestation is public). - Destination gas: refuse to start a transfer when the wallet cannot pay the mint, or use the Forwarding Service.
Forwarding is verifiable to the same degree: the hook is a constant,
mintRecipientis unchanged, and the mint needs no second signature; what changes is thatmaxFeethen includes Circle's forwarding fee (a live number the signer can only bound against the approved ceiling) and that completion depends on Circle's relayer — with the permissionless self-mint still available as a fallback, sincedestinationCallerstays zero. - Simulation of each built transaction before it is proposed (Solana account lists in particular).
Until then the quotes say quote_only, with the reason attached.
8. EURC ("CCTP for non-USDC")
EURC does not go through TokenMessengerV2 (/v2/burn/EURC/fees does not exist). It uses a separate product built on
the same message transmitter and attestation service (https://developers.circle.com/cctp/expanded-assets), EVM
only. Iris lists EURC as enabled on Ethereum, Avalanche, Base (the registry's three EVM deployments, checked
against GET /v2/cctpx/tokens), plus World Chain and Arc.
| Item | Value |
|---|---|
| CrossChainTokenService | 0x431871229103b780868f8C6BB820cd16ECf942BC (same on every chain) |
| EURC TokenManager — the approval spender | 0x8c27579e24f9f19d96724e19fc059dacd1469e10 |
| EURC token id | 0x6ca9e29fa53becc29becaf4a90b9ca7a995ad4d2234880da13ca38c657fb241c |
Addresses: https://developers.circle.com/cctp/expanded-assets/references/contract-addresses; checked:
resolveTokenManager(tokenId) and resolveTokenAddress(tokenId) return the pinned manager and the registry EURC on
all three networks.
Fees (https://developers.circle.com/cctp/expanded-assets/references/fees): a transfer fee of USD 0.25 on Standard and
Fast, plus the Fast fee (1 bp from Ethereum, 1–1.3 bps from Base, none from Avalanche), paid in the source chain's
coin at transfer time, "so the recipient receives the full transferred amount". The exact figure comes from
POST /v2/quote/cctpx/{tokenId}/{src}/{dst} with {amount, feeToken: 0x0, requests: [] | [{type:"PRE_FINALITY"}]},
which returns {signedQuote, issuedAt, expiry:{mode:"TIMESTAMP", expiresAt}, feeTotalAmount, feeToken, items:[{type: "PROTOCOL"|"PRE_FINALITY", amount, args, argsHash}], nonce}; quotes are valid 120 s. The adapter maps each item to a
protocol FeeItem in the source coin (includedInOutput: false, not an estimate), sets amountOut = minAmountOut = amountIn and expiresAt to the quote's own expiry, and rejects a quote whose items do not add up, name another token
or amount, or carry a fee type that was not requested. When Iris refuses the fast quote (allowance or FX pricing
unavailable — "there is no automatic fallback") only Standard is returned.
Execution: EURC.approve(TokenManager, amount), then on the service (selector 0x780c5acf, checked on mainnet)
crossChainTransfer(bytes32 tokenId, uint256 amount, uint32 destinationDomain, bytes destinationAddress, bytes32 destinationCaller, uint32 minFinalityThreshold, (bytes signedQuote, address refundAddress) claim, bool autoExecuteHookData, bytes hookData) with msg.value = feeTotalAmount;
destinationAddress is the raw 20-byte address (not padded). The mint is MessageTransmitterV2.receiveMessage on the
destination, as for USDC. The signer can check every argument except signedQuote, a Circle-signed blob it cannot
rebuild; it can bound what that blob costs (msg.value ≤ approved fee) and nothing more. That makes EURC opaque at
best, and the message body format of this product (decodedCctpXMessageBody) has not been analysed here. EURC is
quoted so the router can show the route; its execution is a separate piece of work.
9. CCTP V1 (legacy) — for recognition only
Retiring by 1 December 2026; never quoted, never to be signed. Addresses (https://developers.circle.com/cctp/v1/evm-smart-contracts):
| Network | TokenMessenger | MessageTransmitter |
|---|---|---|
| ethereum | 0xBd3fa81B58Ba92a82136038B25aDec7066af3155 |
0x0a992d191DEeC32aFe36203Ad87D7d289a738F81 |
| avalanche | 0x6B25532e1060CE10cc3B0A99e5683b91BFDe6982 |
0x8186359aF5F57FbB40c6b14A588d2A59C0C29880 |
| optimism | 0x2B4069517957735bE00ceE0fadAE88a26365528f |
0x4D41f22c5a0e5c74090899E5a8Fb597a8842b3e8 |
| arbitrum | 0x19330d10D9Cc8751218eaf51E8885D058642E08A |
0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca |
| base | 0x1682Ae6375C4E4A97e4B583BC394c861A46D8962 |
0xAD09780d193884d503182aD4588450C416D6F9D4 |
| polygon | 0x9daF8c91AEFAE50b9c0E69629D3F6Ca40cA3B3FE |
0xF3be9355363857F3e001be68856A2f96b4C39Ba9 |
| solana | CCTPiPYPc6AsJuwueEnWgSgucamXDZwBd53dQ11YiKX3 (TokenMessengerMinter) |
CCTPmbSD7gX1bxKPAmg77w8oFzNFpaQiQUWD43TKaecd |
HyperEVM has no V1. ABI: depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken) (0x6fd3504e), depositForBurnWithCaller(…, bytes32 destinationCaller) (0xf856ddb6),
replaceDepositForBurn(…), receiveMessage(bytes, bytes) (same selector as V2). No fee, no maxFee, no finality
choice (Standard only). Message: header of 116 bytes (version 0, source, destination, uint64 sequential nonce at
offset 12, sender 20, recipient 52, destinationCaller 84), body version 0 with burnToken / mintRecipient / amount /
messageSender at 4 / 36 / 68 / 100. Attestations: GET /v1/attestations/{keccak256(message)}.
10. Failure and recovery
| Situation | What happens | Recovery |
|---|---|---|
| Approval confirmed, burn never sent | An exact-amount allowance to TokenMessengerV2 remains | Send the burn later, or approve(spender, 0) |
Burn reverts (fee above maxFee on a fee-switch chain, denylist, pause, limit) |
Nothing is burned | None needed |
Fast fee rose above maxFee, or the Fast allowance is exhausted |
The burn is attested at finality instead | Wait the Standard time; no fee is charged |
Attestation not available (404 / pending_confirmations) |
Normal until the source chain reaches the threshold; delayReason explains anomalies |
Keep polling GET /v2/messages |
| Mint transaction fails for gas | Nothing is consumed; the nonce is unused | Resubmit — by the wallet or by anyone |
| "Nonce already used" | The mint already happened (someone relayed it) | Check the recipient balance |
Fast attestation past expirationBlock |
Mint reverts "Message expired and must be re-signed" | POST /v2/reattest/{nonce}, poll, mint; no deadline |
| Solana recipient token account missing | receive_message reverts |
Create the account, resubmit |
| Wallet has no gas on the destination | The transfer waits, attested | Fund the destination account, or have anyone submit message + attestation |
| Destination contract paused / recipient frozen by Circle | Mint reverts | Outside the wallet's control |
Wrong mintRecipient (e.g. a Solana wallet address instead of its token account) |
The burn cannot be redirected in V2 | None — prevented only by the signer's check (§7.1) |
| Session lost between burn and mint | Nothing is lost: message and attestation are public | Recover from the burn transaction hash |
Refundability. refundable: false, and that is exact: there is no path by which burned USDC returns to the sender
on the source chain. It is equally exact that the funds are not lost when the mint is not submitted: they remain
mintable — only to mintRecipient, by anyone holding the public attestation — with no expiry for Standard and
unlimited re-attestation for Fast. The quote carries this sentence in raw.ifMintNeverSubmitted. atomic: false: at
least two transactions on two chains (three with the EVM approval).
11. What is verified, and what is not (quote adapter)
Verified live (smoke script, 2026-11-11): the pinned addresses, domains, versions and wiring on all eight networks; the 10 M burn limit; the fee schedule of the 56 USDC routes; the EURC deployments and resolvers; ten quotes with latency (117–334 ms per quote, one round of 2–4 parallel requests); selectors, gas ranges, Solana account sizes and the fee rounding against real transactions; the message layout against a real attested message.
Not verified: the Solana account lists and PDA seeds of §7.2–7.3 come from source reading (no Solana transaction was
built); estimatedSeconds is Circle's documented average, not a measurement; the behaviour "insufficient maxFee ⇒
attested as Standard" is Circle's statement; the non-USDC message body has not been analysed; rounding was observed on
one source chain (Arbitrum). For the EVM execution path see §12.8.
12. Execution: USDC between EVM networks (implemented 2026-11-11)
Scope: USDC only; Ethereum, Base, Arbitrum, Optimism, Polygon, Avalanche, HyperEVM (CCTP_ENABLED, 42 ordered pairs);
sender and recipient are always the wallet's own EVM address (the same on every EVM network). No hooks, no destination
caller, no swap leg, no Solana, no EURC, no Forwarding Service.
Code: packages/core/src/cctp.ts (pins, intent verification, both transaction builders, message parser),
services/isolated-signer/src/signer.ts (sign bridge branch, signBridgeMint), services/api/src/bridge.ts (routes,
guards, state machine), apps/web/src/bridge.tsx + screens/Swap.tsx + screens/Activity.tsx.
12.1 Intent
The intent gains an optional bridge section (hashed with fixed field order; intents without it hash exactly as before):
provider: 'cctp', toNetwork, toDomain, maxFee (Circle's fee ceiling in USDC base units; 0 for Standard),
minFinalityThreshold (1000 or 2000), messenger, transmitter (must equal the pinned constants), approveGas.
asset/amount are the USDC burned on network; to must equal from. The owner's passkey signs the SHA-256 of all
of it: one approval commits to the whole transfer, both legs.
12.2 First leg (source)
The signer builds, from the intent and its own tables only — there is no provider calldata at all:
USDC.approve(TokenMessengerV2, amount) (exact amount, omitted when the allowance already covers it) and
depositForBurn(amount, domain(toNetwork), bytes32(own address), registry USDC, 0, maxFee, minFinalityThreshold).
Refused: recipient ≠ own address, any token other than the registry USDC of the source, unpinned messenger/transmitter,
a domain that is not the destination network's, a network outside CCTP_ENABLED, maxFee ≥ amount or > 1 % of it,
a threshold other than 1000/2000, Fast from a source where Circle does not offer it, a swap section alongside.
Policy applies as for a send: per-transfer and daily USDC limits of the source network, native fee ceiling of the
source, approval window, halt, allowlist (if the owner uses one, "<source>:<own address>" must be in it — a bridge has
no other destination). The amount counts toward the day's spend on the source.
12.3 Second leg (destination) and how it is authorised
MessageTransmitterV2.receiveMessage(message, attestation), sent from the wallet's own address. It moves no value out of
the wallet (the caller pays gas only; the mint goes to the message's mintRecipient whoever calls).
Decision: the burn approval covers the mint; no second passkey prompt in the normal case. When it signs the burn, the
signer stores the approved bridge (state.bridges[proposalId] = route, wallet, amount, fee ceiling, finality, burn tx
hash) durably, before the signature leaves. Later, POST /bridge/mint signs a mint only if:
- the proposal id is a stored, passkey-approved bridge of this wallet (not expired by time: it must remain mintable);
- the message parses as a 376-byte CCTP V2 burn message (no hook) and every field equals the approved burn: version 1
(header and body), source domain, destination domain, sender and recipient = the pinned TokenMessenger,
destinationCaller= 0, burn token = registry USDC of the source,mintRecipient= the wallet,messageSender= the wallet, amount,maxFee,minFinalityThreshold; andfeeExecuted ≤ maxFee,finalityThresholdExecuted ≥the approved threshold, non-zero nonce; the attestation is 1–8 signatures of 65 bytes; - the target is the pinned MessageTransmitterV2 on the destination chain id, value 0, gas 100 000–450 000, and
gas × maxFeePerGas ≤the policy's native fee ceiling of the destination network; - no mint was signed for this bridge yet, and this message (keccak256) has not served another bridge.
Why this is safe without a second approval. The worst a compromised API can obtain is one signature per approved bridge on a transaction that either mints the wallet's own burned USDC to the wallet's own address or reverts; its cost is bounded by the destination fee ceiling. It cannot change recipient, amount, token or route (2), cannot aim the signature at another contract or chain (3), cannot farm signatures (4), and a forged or foreign message cannot pass the contract's attester check anyway. The signer cannot verify the attestation itself (no chain access, attesters can rotate): a bad attestation only wastes that one bounded fee. A stolen approval therefore buys strictly less than what the owner approved. A second passkey prompt would add nothing to the safety of the funds and would make "finish days later from another device" depend on the owner being present for a step that only delivers his money.
Exactly once, and resumable:
- an identical request (same message, attestation, nonce, gas) returns the same signed bytes again — the API stores the gas parameters before asking, so a crash between the signer's answer and the database write repeats the same request and does not consume a second signature;
- any other request for a bridge that already has a mint signature is refused, unless it carries a passkey assertion
over SHA-256(
SPW-BRIDGE-MINT-v1, proposal id, message hash, nonce, gas limit, fee cap, tip) — the "approve a new mint" action, for a mint transaction that reverted or was replaced (at most 6 signatures per bridge); - the bridge record has no expiry; it is pruned 90 days after a mint signature;
- while the signer is halted it signs no mint (anyone can still submit the public message from another tool).
Binding to the burn: the record holds the burn transaction hash (audit trail) and the mint signature is bound to the message hash. The signer cannot check that a message came from that transaction (the message does not contain it, and the signer has no chain access); two identical transfers (same route and amount) are interchangeable at this level, which is harmless: each record signs one mint, each message serves one record, and every message mints only to the wallet.
12.4 API life cycle (kind: 'bridge' in proposals, column bridge for the second leg)
prepare (POST /api/v1/bridge/proposals) → passkey approval (existing /transfers/proposals/:id/approve) → sign →
exact approval mined → burn simulated again (eth_call) → broadcast → receipt (confirming/finalized = the burn) →
phase attesting (poll GET /v2/messages/{domain}?transactionHash=, 4–15 s at first, slower later, at most every 5 min,
never abandoned; flagged attestation_late after 3× the documented time + 30 min) → ready → mint signed and sent
(mint_sent) → receipt with the network's confirmation count → done (amount read from the USDC Transfer event).
Worker loop every 5 s; POST /api/v1/bridge/proposals/:id/finish runs the next step immediately and is safe at any time.
Guards at prepare: both networks' pins (§2), wallet address without code on both, one operation in flight on the source,
nonce agreement on the source, USDC balance, source gas, signer policy pre-check, destination gas (below), Circle's
fee from Iris (+ on-chain getMinFeeAmount for Standard where the messenger has it), Fast allowance ≥ amount.
At mint time: usedNonces(nonce) on both RPC sources (already minted ⇒ success, nothing signed), pins of the destination
again, Fast expiry (expirationBlock passed ⇒ POST /v2/reattest/{nonce} and back to attesting), no other operation in
flight on the destination and nonce agreement, eth_estimateGas as simulation, fee ceiling, native balance.
The Iris answer is untrusted: the API keeps only a message that passes the same verifyCctpMint as the signer; anything
else is ignored with the reason shown (message_mismatch).
"Nonce already used": the contract mints a message once. If someone else submitted the mint (it is permissionless), the
API sees usedNonces = 1 before signing, or sees its own transaction revert and then usedNonces = 1; both end as done
with mintedBy: other and, when found in the last 2 000 blocks, the third party's transaction hash. The amount recorded in
that case is the one computed from the attested message (amount − feeExecuted), not read from a receipt.
12.5 Destination gas
The mint is paid in the destination network's coin. The wallet refuses to burn when the destination cannot pay: at
prepare, native balance on the destination must cover 240 000 gas × (2 × base fee + tip) there, and that amount must fit
under the signer's fee ceiling for that network; otherwise destination_gas ("Nothing was burned: add ETH on … first").
The Swap screen shows the need and the balance before the button and disables it. If the gas disappears between burn and
mint, the transfer waits in ready with the same message and finishes when gas is back. Nothing is ever topped up
automatically.
12.6 Cost and time of a real transfer
- Circle's fee: Standard 0; Fast per the live schedule (2026-11-11: 1 bps from Ethereum, 1.3 bps from Base and Optimism, 1.4 bps from Arbitrum; not offered from Avalanche, Polygon, HyperEVM). The approved ceiling is the fee rounded up; what is taken is shown after the mint. 10 USDC Fast from Base: at most 0.0013 USDC.
- Gas, measured on forks with the real contracts: approval ≈ 55 000,
depositForBurn≈ 109 000 (source coin),receiveMessage≈ 171 000 (destination coin). OP-stack L1 data fees come on top (Base, Optimism). In money this is cents on the L2s, Polygon, Avalanche and HyperEVM, and can be a few dollars when Ethereum is the source or the destination at ordinary gas prices — the review sheet shows the ceiling in the source coin, the quote card the estimate for the destination. - Time until the attestation exists, per Circle's documentation: Standard ≈ 15–19 min from Ethereum, Base, Arbitrum, Optimism (hard finality on Ethereum, ~65 blocks); ≈ 8 s from Avalanche and Polygon; ≈ 5 s from HyperEVM. Fast ≈ 20 s from Ethereum, ≈ 8 s from Base, Arbitrum, Optimism. Add the inclusion of the burn before, and after it the wallet's polling delay (seconds at first; up to a minute or more late in a 19-minute wait) and the inclusion of the mint.
- Limits: the signer's per-transfer and daily USDC caps of the source network (50 / 100 USDC by default); Circle's own 10 M USDC per burn.
12.7 Proof on forks (npx tsx scripts/fork-cctp.mts <source> <destination>)
Two anvil forks, fresh random keys, nothing sent to a real network. Real bytecode throughout. Run on 2026-11-11 for
base→arbitrum, arbitrum→ethereum, ethereum→optimism, optimism→polygon, polygon→avalanche, avalanche→hyperevm,
hyperevm→base (every enabled network once as source and once as destination; results in docs/V2-IMPLEMENTATION-STATUS.md).
- Part 0, the only exercise of Circle's real attestation service, read-only: recent public messages are fetched
from
iris-api.circle.comand their signatures verified (count = on-chain threshold, recovered signers enabled on-chain, ascending order). Also covered offline by a unit test on a recorded real message. - Part 1, signer level: refusals on the first leg (other recipient, other amount, amount over the limit, other token,
unpinned messenger/transmitter, wrong domain); real approval + burn (balance −amount, total supply −amount, allowance 0);
refusals on the second leg (mintRecipient ≠ wallet, other destination domain, other amount, other token, destination
caller, unapproved fee, unknown bridge); on-chain refusal of non-attester and single signatures; mint (+amount);
second request refused, identical request replayed, a passkey-approved second mint transaction reverts on-chain; Fast
fee split (wallet / Circle's fee recipient) and on-chain refusal of a fee above
maxFee. - Part 2, full stack (API routes, worker steps, signer over HTTP, passkey): destination without gas refused; Standard by the worker alone; Fast with Circle's live fee; attestation withheld then "finish"; mint relayed by a third party.
- What is simulated, and only on the destination fork: Circle cannot attest a burn that exists only on a fork, so two
test attester keys are enabled by impersonating the transmitter's attester manager, and a local stand-in signs the
fork's own
MessageSentbytes (nonce, executed finality, executed fee filled in as Circle does). The contract's signature checks run unmodified against those keys. Signer fee ceilings are multiplied by 20 inside the fork script only (anvil reports a 1 gwei tip); production limits are unchanged.
12.8 Not verified (execution)
- No real transfer has been made. In particular the real pairing "our burn → Iris returns a complete message whose
bytes pass
verifyCctpMint" has never run end to end; the message format is checked against real messages of other senders only. - Iris behaviours taken from the documentation, not observed:
pending_confirmations→complete,delayReason, Fast → Standard degradation whenmaxFeeis too low,POST /v2/reattest(the expiry path is implemented and has no fork or unit test of the re-attested message), rate-limit handling (the API polls far below 40 req/s). - The Standard fee switch (
getMinFeeAmount> 0) has never been seen non-zero; the code path is untested on-chain. - Contracts were not recompiled from source: "verified" rests on the explorers' verification and on a file-by-file comparison with Circle's repository. Proxy sources were read on Ethereum only (the proxy bytecode is identical everywhere). Constructor arguments on HyperEVM come back empty from the explorer API.
- Who holds the proxy-admin, owner and attester-manager keys is unknown beyond "an address without code".
- The USDC contract itself (also an upgradeable proxy, with blacklist and pause) is outside these pins, as for sends.
- Real gas prices: the destination-gas estimate uses 240 000 gas × (2 × base fee + tip); on OP-stack networks the L1 data fee of the mint is not included in the check (the transaction carries ~600 bytes of calldata).
- The web screens were type-checked and built, not exercised in a browser (no Playwright run for this feature).
- Polling stops only at
done: a burn whose proposal later turnsfailed/replaced(deep reorg of the source after the burn was seen) is no longer advanced automatically.