From fed34dbe63f9135bd2266bab88cf81a1b72d23ae Mon Sep 17 00:00:00 2001 From: zkasuran Date: Tue, 9 Jun 2026 03:08:58 +0530 Subject: [PATCH 1/3] docs: add CCTP V2 integration guide for Arc Testnet Documents the Arc-specific details needed to move native USDC on and off Arc with CCTP V2, addressing #110: - Arc CCTP domain is 26 (verified against Circle's supported-blockchains list; the testnet shares the mainnet domain). - All Arc CCTP contracts are V2. The V1 depositForBurn selector (0x6fd3504e) reverts against the V2 TokenMessenger; the V2 selector is 0x8e0250ee. Both were checked by computing keccak of the signatures. - Burns sourced from Arc should use minFinalityThreshold 2000 (finalized); 1000 can leave the attestation stuck pending. - eth_estimateGas can return data:null for depositForBurn, so pass an explicit gasLimit. - How to fetch the attestation from the Iris API and the fee endpoint. Contract addresses are from Circle's EVM smart contracts reference and the TokenMessenger was confirmed to have code on Arc Testnet. Adds a link from the README documentation section. --- README.md | 1 + docs/cctp-v2-integration.md | 160 ++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 docs/cctp-v2-integration.md diff --git a/README.md b/README.md index 98318c4..c43a772 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Arc is an open EVM-compatible layer 1 built on [Malachite](https://github.com/ci - 🚀 **[Execution](crates/node/README.md)** - Execution binary and configuration - 🗳️ **[Consensus](crates/malachite-app/README.md)** - Consensus binary and configuration +- 💸 **[CCTP V2 Integration](docs/cctp-v2-integration.md)** - Move native USDC on and off Arc with Cross-Chain Transfer Protocol - More: see Arc [developer docs](https://docs.arc.network/arc/concepts/welcome-to-arc) for guides, APIs, and specs ## Install and Run a Node diff --git a/docs/cctp-v2-integration.md b/docs/cctp-v2-integration.md new file mode 100644 index 0000000..96e9c16 --- /dev/null +++ b/docs/cctp-v2-integration.md @@ -0,0 +1,160 @@ +# CCTP V2 Integration on Arc + +This guide covers Circle's Cross-Chain Transfer Protocol (CCTP) V2 on Arc +Testnet. It documents the Arc-specific values a developer needs to move native +USDC on and off Arc plus the integration details that are easy to get wrong. + +CCTP moves native USDC between chains by burning it on the source chain and +minting it on the destination chain, with no wrapped tokens and no liquidity +pools. The flow is the same on Arc as on any other supported chain: + +1. Approve the TokenMessenger to spend your USDC on the source chain. +2. Call `depositForBurn` on the source chain. This burns the USDC and emits a + `MessageSent` event. +3. Fetch the attestation for that burn from Circle's Iris API. +4. Call `receiveMessage` on the destination chain with the message and + attestation. This mints the USDC to the recipient. + +## Arc Testnet domain is 26 + +Every CCTP transfer identifies the destination chain by a numeric domain, not by +chain ID. Arc's CCTP domain is **26** (the testnet shares the mainnet domain). + +```ts +const ARC_DOMAIN = 26; +``` + +For reference, the domains used in the examples below: + +| Chain | CCTP domain | +| --- | --- | +| Ethereum (Sepolia) | 0 | +| Avalanche (Fuji) | 1 | +| Base (Sepolia) | 6 | +| Arc | 26 | + +The full list is in Circle's +[supported blockchains](https://developers.circle.com/cctp/cctp-supported-blockchains). + +## Contract addresses on Arc Testnet + +These are the CCTP V2 contracts deployed on Arc Testnet (domain 26), from +Circle's [EVM smart contracts](https://developers.circle.com/cctp/evm-smart-contracts) +reference: + +| Contract | Address | +| --- | --- | +| TokenMessengerV2 | `0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA` | +| MessageTransmitterV2 | `0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275` | +| TokenMinterV2 | `0xb43db544E2c27092c107639Ad201b3dEfAbcF192` | +| MessageV2 | `0xbaC0179bB358A8936169a63408C8481D582390C4` | + +USDC on Arc Testnet is the native gas token at +`0x3600000000000000000000000000000000000000`. + +## Use the V2 `depositForBurn`, not V1 + +All CCTP contracts on Arc are V2. The V2 `depositForBurn` takes seven +parameters: + +```solidity +function depositForBurn( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, // V2: who may call receiveMessage, or bytes32(0) for anyone + uint256 maxFee, // V2: max fee in burnToken units + uint32 minFinalityThreshold // V2: finality level to attest at +) external returns (uint64 nonce); +``` + +The V1 function had only four parameters +(`amount, destinationDomain, mintRecipient, burnToken`). The two ABIs have +different selectors: + +- V2 `depositForBurn`: `0x8e0250ee` +- V1 `depositForBurn`: `0x6fd3504e` + +If a library or copied example calls the V1 selector against Arc's V2 +TokenMessenger, the call reverts on-chain with no useful error data. If you are +porting code from an older Circle integration, confirm it is built against the +V2 ABI. + +## minFinalityThreshold: use 2000 for burns from Arc + +`minFinalityThreshold` controls the finality level Circle's attestation service +waits for before it signs the message: + +- **1000**: Fast Transfer (confirmed, not yet finalized) +- **2000**: Standard Transfer (finalized) + +A value above 2000 is capped to 2000. + +For burns sourced **from Arc Testnet**, use **2000**. A burn submitted with 1000 +can leave the attestation stuck in `pending` in the Iris API rather than +progressing to `complete`, so the destination mint never becomes available. The +other testnets in a typical setup (Ethereum Sepolia, Base Sepolia, Avalanche +Fuji) attest fine at 1000. + +```ts +// Burn sourced from Arc Testnet +const minFinalityThreshold = 2000; +``` + +Note also that Fast Transfers can carry a non-zero minimum fee depending on the +route, while finalized transfers to Arc currently do not. You can check the live +fee for a route with the Iris fees endpoint: + +```bash +# fees for a burn from Ethereum (0) to Arc (26) +curl https://iris-api-sandbox.circle.com/v2/burn/USDC/fees/0/26 +# -> [{"finalityThreshold":1000,"minimumFee":1},{"finalityThreshold":2000,"minimumFee":0}] +``` + +Your `maxFee` must be greater than or equal to the minimum fee for the route. A +lower `maxFee` reverts the burn on-chain. + +## Estimating gas for CCTP calls + +`eth_estimateGas` can fail for `depositForBurn` (and intermittently for the +preceding ERC-20 `approve`) on Arc Testnet, returning an internal error with +`data: null`. Clients that estimate gas automatically (ethers.js, viem, wagmi) +will throw before the transaction is ever submitted. + +Pass an explicit gas limit to skip estimation: + +```ts +const tx = await tokenMessenger.depositForBurn( + amount, destinationDomain, mintRecipient, burnToken, + destinationCaller, maxFee, minFinalityThreshold, + { gasLimit: 600_000n } // skip eth_estimateGas +); +``` + +A limit of 600,000 covers an `approve` plus a CCTP burn with headroom. Actual +gas used is typically in the 180,000 to 260,000 range and the unused portion is +refunded. + +## Fetching the attestation + +After the burn confirms, poll Circle's Iris API for the attestation, keyed by +the source domain and the burn transaction hash. On testnet the host is +`https://iris-api-sandbox.circle.com` (mainnet is `https://iris-api.circle.com`): + +```bash +# source domain 26 = Arc; pass the burn tx hash +curl "https://iris-api-sandbox.circle.com/v2/messages/26?transactionHash=" +``` + +Poll until the message `status` is `complete` and an `attestation` is present, +then submit the returned `message` and `attestation` to `receiveMessage` on the +destination chain's MessageTransmitterV2. The Iris API is rate limited to 35 +requests per second. + +## References + +- [CCTP supported blockchains](https://developers.circle.com/cctp/cctp-supported-blockchains) +- [CCTP EVM smart contracts](https://developers.circle.com/cctp/evm-smart-contracts) +- [CCTP contract interfaces](https://developers.circle.com/cctp/references/contract-interfaces) +- [CCTP technical guide](https://developers.circle.com/cctp/technical-guide) From 428dd65f0c453e8007f6c953868d99bd0354175e Mon Sep 17 00:00:00 2001 From: zkasuran <289388318+zkasuran@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:19:22 +0530 Subject: [PATCH 2/3] docs: correct the minFinalityThreshold clamping rule The guide said a value above 2000 is capped to 2000, which reads as if 1500 stays 1500. Circle's technical guide states the real rule: any value below 1000 is treated as 1000 and any value above 1000 is treated as 2000, so everything between rounds up to a finalized transfer. --- docs/cctp-v2-integration.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/cctp-v2-integration.md b/docs/cctp-v2-integration.md index 96e9c16..45f1c66 100644 --- a/docs/cctp-v2-integration.md +++ b/docs/cctp-v2-integration.md @@ -89,7 +89,10 @@ waits for before it signs the message: - **1000**: Fast Transfer (confirmed, not yet finalized) - **2000**: Standard Transfer (finalized) -A value above 2000 is capped to 2000. +Only these two thresholds exist. Per Circle's +[technical guide](https://developers.circle.com/cctp/technical-guide), any value +below 1000 is treated as 1000 and any value above 1000 is treated as 2000. A +value like 1500 does not stay at 1500, it rounds up to a finalized transfer. For burns sourced **from Arc Testnet**, use **2000**. A burn submitted with 1000 can leave the attestation stuck in `pending` in the Iris API rather than From 34a0eb33a6e5c5b9a74a5098f4c8d67604ac480e Mon Sep 17 00:00:00 2001 From: zkasuran <289388318+zkasuran@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:36:41 +0530 Subject: [PATCH 3/3] docs: fold in the CCTP review and re-verify every value Two months on, a recheck of every hard value in the guide against Circle's docs, the CCTP V2 contract source and Arc Testnet itself, plus the review points raised on the PR. Corrections: - Burns from domain 26 submitted with minFinalityThreshold 1000 are no longer stuck. Iris attests them at finalityThresholdExecuted 2000 and the message completes, which matches Circle listing Arc testnet as a standard transfer source with fast transfer N/A. The section now explains why 2000 is still the value to pass rather than claiming 1000 hangs. - Circle lists domain 26 as "Arc testnet" and supports Arc testnet only, so the claim that the testnet shares a mainnet domain is gone. - The fees endpoint returns basis points, not token units. maxFee is in burnToken units and the on-chain floor comes from getMinFeeAmount. - Three of the four reference links moved. Updated to their current paths. Additions: - USDC amounts are 6 decimals. The ERC-20 view uses 6 while native accounting uses 18, so the same balance reads a factor of 10^12 apart. - mintRecipient and destinationCaller are bytes32 and need left-padding, with the viem and ethers helpers and what each throws if you skip it. - A V1-selector revert carries no data at all, which is the tell. - Explicit gas applies to receiveMessage when Arc is the destination, and the 1559 base fee race on other destinations points at #153. - Iris polling cadence, the normal 404, and the explorer check for a message that stalls. --- docs/cctp-v2-integration.md | 179 +++++++++++++++++++++++++++++------- 1 file changed, 148 insertions(+), 31 deletions(-) diff --git a/docs/cctp-v2-integration.md b/docs/cctp-v2-integration.md index 45f1c66..771a23d 100644 --- a/docs/cctp-v2-integration.md +++ b/docs/cctp-v2-integration.md @@ -18,12 +18,23 @@ pools. The flow is the same on Arc as on any other supported chain: ## Arc Testnet domain is 26 Every CCTP transfer identifies the destination chain by a numeric domain, not by -chain ID. Arc's CCTP domain is **26** (the testnet shares the mainnet domain). +chain ID. Arc's CCTP domain is **26**. Circle lists that domain as "Arc testnet" +and says CCTP supports Arc testnet only, so there is no separate Arc mainnet +domain to switch to yet. ```ts const ARC_DOMAIN = 26; ``` +The deployment will tell you the same thing, which is the check to run if you +suspect the docs have moved on: + +```bash +cast call 0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275 \ + "localDomain()(uint32)" --rpc-url +# -> 26 +``` + For reference, the domains used in the examples below: | Chain | CCTP domain | @@ -34,12 +45,12 @@ For reference, the domains used in the examples below: | Arc | 26 | The full list is in Circle's -[supported blockchains](https://developers.circle.com/cctp/cctp-supported-blockchains). +[supported chains and domains](https://developers.circle.com/cctp/concepts/supported-chains-and-domains). ## Contract addresses on Arc Testnet These are the CCTP V2 contracts deployed on Arc Testnet (domain 26), from -Circle's [EVM smart contracts](https://developers.circle.com/cctp/evm-smart-contracts) +Circle's [contract addresses](https://developers.circle.com/cctp/references/contract-addresses) reference: | Contract | Address | @@ -49,8 +60,31 @@ reference: | TokenMinterV2 | `0xb43db544E2c27092c107639Ad201b3dEfAbcF192` | | MessageV2 | `0xbaC0179bB358A8936169a63408C8481D582390C4` | +The addresses cross-reference each other on chain, so you can check the table +without trusting it. `TokenMessengerV2.localMessageTransmitter()` returns the +MessageTransmitterV2 above and `localMinter()` returns the TokenMinterV2. + +## USDC amounts are 6 decimals, not 18 + USDC on Arc Testnet is the native gas token at -`0x3600000000000000000000000000000000000000`. +`0x3600000000000000000000000000000000000000`. Some address validators flag that +shape as a precompile or as a zero-padded typo. It is neither. The ERC-20 +interface on it behaves normally. + +One balance has two views. The ERC-20 interface reports 6 decimals, the same as +USDC everywhere else, while the node's native accounting works in 18-decimal +units. `eth_getBalance` and `balanceOf` on the same account return the same +holding a factor of 10^12 apart. + +CCTP only ever uses the ERC-20 view. `amount`, `maxFee` and the allowance you +give the TokenMessenger are all 6-decimal units: + +```ts +const amount = 1_000_000n; // 1 USDC, not 1_000_000_000_000_000_000n +``` + +Passing a native-unit figure asks to burn 10^12 times what you meant, so it +fails on the allowance or balance check rather than moving the wrong amount. ## Use the V2 `depositForBurn`, not V1 @@ -77,9 +111,39 @@ different selectors: - V1 `depositForBurn`: `0x6fd3504e` If a library or copied example calls the V1 selector against Arc's V2 -TokenMessenger, the call reverts on-chain with no useful error data. If you are -porting code from an older Circle integration, confirm it is built against the -V2 ABI. +TokenMessenger, the call reverts with no error data at all. That absence is the +tell. An `eth_call` carrying the V1 selector comes back as a bare +`execution reverted`, while the same call through the V2 ABI returns a decodable +reason string such as `No TokenMessenger for domain`. If a revert carries no +data, check the selector first. If you are porting code from an older Circle +integration, confirm it is built against the V2 ABI. + +## mintRecipient and destinationCaller are bytes32 + +Both are `bytes32`, not `address`, so a 20-byte address has to be left-padded to +32 bytes first: + +```ts +// viem +import { pad } from 'viem' +const mintRecipient = pad(recipient, { size: 32 }) + +// ethers v6 +const mintRecipient = ethers.zeroPadValue(recipient, 32) +``` + +Current clients refuse the unpadded value rather than sending it. viem throws +`AbiEncodingBytesSizeMismatchError` and ethers v6 throws `INVALID_ARGUMENT: +incorrect data length`, both while encoding, so the call never reaches the +chain. Hand-rolled ABI encoding has no such guard, which is where a +wrong-length recipient can still get through. + +`destinationCaller` takes the same 32-byte shape for a different job. +`bytes32(0)` lets any address submit `receiveMessage` for that message, which is +the right default for most integrations. Set it to a padded address and +MessageTransmitterV2 requires the caller to match, reverting with +`Invalid caller for message` for everyone else. That is how a relayer keeps +someone else from calling its mint. ## minFinalityThreshold: use 2000 for burns from Arc @@ -90,24 +154,48 @@ waits for before it signs the message: - **2000**: Standard Transfer (finalized) Only these two thresholds exist. Per Circle's -[technical guide](https://developers.circle.com/cctp/technical-guide), any value -below 1000 is treated as 1000 and any value above 1000 is treated as 2000. A -value like 1500 does not stay at 1500, it rounds up to a finalized transfer. - -For burns sourced **from Arc Testnet**, use **2000**. A burn submitted with 1000 -can leave the attestation stuck in `pending` in the Iris API rather than -progressing to `complete`, so the destination mint never becomes available. The -other testnets in a typical setup (Ethereum Sepolia, Base Sepolia, Avalanche -Fuji) attest fine at 1000. +[technical guide](https://developers.circle.com/cctp/references/technical-guide), +any value below 1000 is treated as 1000 and any value above 1000 is treated as +2000. A value like 1500 does not stay at 1500, it rounds up to a finalized +transfer. + +For burns sourced **from Arc Testnet**, use **2000**. Circle's supported chains +table lists Arc testnet as a standard transfer source and marks fast transfer +`N/A`. The attestation service acts on that. Messages sourced from domain 26 +that were submitted with `minFinalityThreshold: 1000` come back from the Iris +API with `finalityThresholdExecuted: 2000` and a `complete` status, so the +transfer is finalized either way. Ask for 2000 and the request matches the +outcome. Your polling code then reads one threshold and you are not building a +Fast Transfer path with its own fee on a route that cannot serve one. The other +testnets in a typical setup (Ethereum Sepolia, Base Sepolia, Avalanche Fuji) do +attest at 1000. ```ts // Burn sourced from Arc Testnet const minFinalityThreshold = 2000; ``` -Note also that Fast Transfers can carry a non-zero minimum fee depending on the -route, while finalized transfers to Arc currently do not. You can check the live -fee for a route with the Iris fees endpoint: +Because an Arc burn executes at 2000, MessageTransmitterV2 on the destination +routes it to `handleReceiveFinalizedMessage`, not +`handleReceiveUnfinalizedMessage`. A contract that receives Arc-sourced +transfers needs the finalized handler. + +## maxFee and route fees + +`maxFee` is denominated in `burnToken` units, so on Arc it is 6-decimal USDC. +The source TokenMessenger enforces a floor of +`amount * minFee / MIN_FEE_MULTIPLIER` and reverts the burn under it. It also +reverts if `maxFee` is greater than or equal to `amount`. Read the floor off the +contract instead of guessing: + +```bash +cast call 0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA \ + "getMinFeeAmount(uint256)(uint256)" 1000000 --rpc-url +# -> 0 (minFee is 0 on Arc Testnet, so maxFee: 0 passes for a burn from Arc) +``` + +Fast Transfers can carry a route fee, which the Iris fees endpoint returns per +finality threshold in basis points (1 = 0.01%): ```bash # fees for a burn from Ethereum (0) to Arc (26) @@ -115,15 +203,19 @@ curl https://iris-api-sandbox.circle.com/v2/burn/USDC/fees/0/26 # -> [{"finalityThreshold":1000,"minimumFee":1},{"finalityThreshold":2000,"minimumFee":0}] ``` -Your `maxFee` must be greater than or equal to the minimum fee for the route. A -lower `maxFee` reverts the burn on-chain. +Convert the rate before you pass it. One basis point on 10 USDC is +`10_000_000n * 1n / 10_000n`, so `maxFee = 1_000n`. The 2000 rows on the Arc +routes are 0 today, which is consistent with Arc's finalized-only attestation. ## Estimating gas for CCTP calls -`eth_estimateGas` can fail for `depositForBurn` (and intermittently for the -preceding ERC-20 `approve`) on Arc Testnet, returning an internal error with -`data: null`. Clients that estimate gas automatically (ethers.js, viem, wagmi) -will throw before the transaction is ever submitted. +`eth_estimateGas` is unreliable for CCTP writes on Arc Testnet. It returns an +internal error with `data: null` for `depositForBurn` +([#108](https://github.com/circlefin/arc-node/issues/108)) and fails across USDC +and CCTP write calls more generally +([#80](https://github.com/circlefin/arc-node/issues/80)), including the preceding +ERC-20 `approve`. Clients that estimate gas automatically (ethers.js, viem, +wagmi) will throw before the transaction is ever submitted. Pass an explicit gas limit to skip estimation: @@ -137,7 +229,14 @@ const tx = await tokenMessenger.depositForBurn( A limit of 600,000 covers an `approve` plus a CCTP burn with headroom. Actual gas used is typically in the 180,000 to 260,000 range and the unused portion is -refunded. +refunded. Do the same for `receiveMessage` when Arc is the destination chain. + +The mint on other destination chains has a different gas problem. A +`maxFeePerGas` estimated while you were still waiting on the attestation can be +under the base fee by the time the transaction lands, which fails with +`max fee per gas less than block base fee` +([#153](https://github.com/circlefin/arc-node/issues/153)). Re-estimate the fee +immediately before you send. Padding the estimate works too. ## Fetching the attestation @@ -152,12 +251,30 @@ curl "https://iris-api-sandbox.circle.com/v2/messages/26?transactionHash=`) and that the domain in the URL +is the source domain of the burn, not the destination. Circle's +[resolve attestation issues](https://developers.circle.com/cctp/howtos/resolve-stuck-attestation) +guide covers the rest. ## References -- [CCTP supported blockchains](https://developers.circle.com/cctp/cctp-supported-blockchains) -- [CCTP EVM smart contracts](https://developers.circle.com/cctp/evm-smart-contracts) +- [CCTP supported chains and domains](https://developers.circle.com/cctp/concepts/supported-chains-and-domains) +- [CCTP contract addresses](https://developers.circle.com/cctp/references/contract-addresses) - [CCTP contract interfaces](https://developers.circle.com/cctp/references/contract-interfaces) -- [CCTP technical guide](https://developers.circle.com/cctp/technical-guide) +- [CCTP technical guide](https://developers.circle.com/cctp/references/technical-guide) +- [CCTP fees](https://developers.circle.com/cctp/concepts/fees) +- [Resolve attestation issues](https://developers.circle.com/cctp/howtos/resolve-stuck-attestation) +- [USDC contract addresses](https://developers.circle.com/stablecoins/usdc-contract-addresses)