Skip to content

docs: add CCTP V2 integration guide for Arc Testnet (closes #110) - #127

Open
zkasuran wants to merge 4 commits into
circlefin:mainfrom
zkasuran:docs/cctp-v2-integration
Open

docs: add CCTP V2 integration guide for Arc Testnet (closes #110)#127
zkasuran wants to merge 4 commits into
circlefin:mainfrom
zkasuran:docs/cctp-v2-integration

Conversation

@zkasuran

@zkasuran zkasuran commented Jun 8, 2026

Copy link
Copy Markdown

What this does

Adds a CCTP V2 integration guide at docs/cctp-v2-integration.md and links it from the README documentation section. It resolves #110.

The guide documents the Arc-specific details a developer needs to move native USDC on and off Arc with Cross-Chain Transfer Protocol V2, which are not currently in this repo or obvious from Circle's general docs:

  • Arc CCTP domain is 26. Required as destinationDomain in depositForBurn. Circle lists that domain as "Arc testnet" and supports Arc testnet only.
  • All Arc CCTP contracts are V2. The V1 depositForBurn selector (0x6fd3504e) reverts against the V2 TokenMessenger, and that revert carries no data at all, which is the tell. The V2 selector is 0x8e0250ee. This trips up code ported from older Circle examples.
  • USDC amounts are 6 decimals. The ERC-20 interface reports 6 while the node's native accounting is 18-decimal, so the same balance reads a factor of 10^12 apart. CCTP only ever uses the 6-decimal view.
  • mintRecipient and destinationCaller are bytes32. A 20-byte address has to be left-padded, and current viem and ethers throw while encoding if it is not.
  • Use minFinalityThreshold: 2000 for burns sourced from Arc. Circle lists Arc testnet as a standard transfer source with fast transfer N/A, so Iris attests an Arc burn at the finalized level whatever the burn asked for.
  • maxFee is in burnToken units. The route fee the Iris fees endpoint returns is in basis points, and the on-chain floor comes from getMinFeeAmount(amount), which is 0 on Arc Testnet today.
  • eth_estimateGas is unreliable for CCTP writes on Arc Testnet (bug: eth_estimateGas returns data:null for CCTP depositForBurn — hardcoded gasLimit required #108, bug: eth_estimateGas unreliable on Arc Testnet — all CCTP/USDC write transactions require explicit gasLimit override #80), so pass an explicit gasLimit. The same applies to receiveMessage when Arc is the destination.
  • How to poll the Iris API for the attestation, which of its responses are not failures, and what to check when a message stalls.

How the values were verified

Every hard value was rechecked against a primary source before the latest push, and the guide only states what came back.

  • Domain 26 and the source-chain domains (Ethereum 0, Avalanche 1, Base 6): Circle's supported chains and domains. The deployment agrees: MessageTransmitterV2.localDomain() returns 26 on Arc Testnet.
  • Contract addresses: Circle's contract addresses reference. All four have code on Arc Testnet via eth_getCode, and they cross-reference each other: localMessageTransmitter() and localMinter() on the TokenMessenger return the MessageTransmitter and TokenMinter in the table, and localTokenMessenger() on the TokenMinter points back.
  • USDC address: listed for Arc Testnet on Circle's USDC contract addresses page. On chain decimals() returns 6 and symbol() returns USDC. The 18-decimal native view is what this repo's own e2e test describes ("NativeFiatToken uses 6 decimals; the precompile operates in 18-decimal native units").
  • Selectors: cast sig and an ethers Interface independently give V2 0x8e0250ee and V1 0x6fd3504e. An eth_call with the V1 selector against Arc's TokenMessenger comes back as a bare execution reverted with no data, where the V2 ABI returns a decodable reason.
  • minFinalityThreshold rule: Circle's technical guide states that any value below 1000 is treated as 1000 and any value above 1000 is treated as 2000. Only 1000 and 2000 exist.
  • Arc attests at the finalized level: three real messages sourced from domain 26 were submitted with minFinalityThreshold: 1000 and came back from Iris with finalityThresholdExecuted: 2000 and status: complete. That matches the capability table, which marks fast transfer N/A for Arc testnet as a source.
  • Fees: the Iris fees endpoint returns basis points (1 = 0.01%) per finality threshold, and the on-chain floor is amount * minFee / MIN_FEE_MULTIPLIER from the TokenMessengerV2 source. minFee() and getMinFeeAmount(1000000) both return 0 on Arc Testnet.
  • Iris endpoints: /v2/messages/{sourceDomain} and /v2/burn/USDC/fees/{src}/{dst} were called against the sandbox host. The 35 requests per second limit is from Circle's resolve attestation issues guide.

Two figures are field reports rather than my measurements, and the guide keeps them attributed. The data: null response from eth_estimateGas is cited to #108 and #80 because it did not reproduce in my probe, and the 600,000 gas limit with a 180,000 to 260,000 actual range comes from #110.

Docs-only change, no code touched. Every link in the guide resolves, and the repo's pre-commit whitespace hooks pass on the changed file.

AI disclosure

AI assistance (Claude, Anthropic) was used in developing this change. It drafted and revised the prose and ran the verification steps listed above. The author owns the change and can walk through any part of it. What was verified before this push: every value against Circle's documentation, the CCTP V2 contract source, live eth_call and eth_getCode against Arc Testnet, live Iris API calls, locally computed selectors, and a link check over the guide. The two values that are field reports rather than measurements are called out as such rather than presented as verified.

Documents the Arc-specific details needed to move native USDC on and off
Arc with CCTP V2, addressing circlefin#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.
@memosr

memosr commented Jun 15, 2026

Copy link
Copy Markdown

Independently verified the technical values in this guide and they all check out:

  • Domain 26, USDC 0x3600000000000000000000000000000000000000, and the four V2 contract addresses (TokenMessengerV2 0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA, MessageTransmitterV2 0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275, TokenMinterV2 0xb43db544E2c27092c107639Ad201b3dEfAbcF192, MessageV2 0xbaC0179bB358A8936169a63408C8481D582390C4) match Circle's evm-smart-contracts reference and the official Arc skill values.
  • Selectors are correct by construction: cast sig gives V2 depositForBurn(uint256,uint32,bytes32,address,bytes32,uint256,uint32) = 0x8e0250ee and V1 depositForBurn(uint256,uint32,bytes32,address) = 0x6fd3504e.
  • The minFinalityThreshold 1000 (fast) / 2000 (finalized) semantics match the technical guide.

One small wording nit: the guide says "a value above 2000 is capped to 2000," but per the technical guide the rule is "any value below 1000 is treated as 1000, and any value above 1000 is treated as 2000" — so 10011999 also round up to 2000. Might be worth rephrasing to avoid implying e.g. 1500 stays 1500.

The gas-limit (600k) and USDC-system-address gotchas raised on #110 are already covered here. LGTM.

@ZhiyuCircle ZhiyuCircle added documentation Improvements or additions to documentation ecosystem Component: ecosystem labels Jul 23, 2026
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.
@zkasuran

Copy link
Copy Markdown
Author

Good catch, fixed in 428dd65.

The line now states the rule from Circle's technical guide: any value below 1000 is treated as 1000 and any value above 1000 is treated as 2000. So 1500 rounds up to a finalized transfer instead of staying at 1500. I pulled the guide again before rewording, because "capped to 2000" implies a scale that does not exist.

Thanks for the independent check on the domain, the four V2 addresses and the selectors.

@osr21

osr21 commented Jul 29, 2026

Copy link
Copy Markdown

Good guide — the domain, contract addresses, and selector split are correct. A few additions from having run this integration end-to-end on Arc Testnet that might be worth folding in:


USDC decimal trap

The guide correctly states USDC on Arc lives at 0x3600000000000000000000000000000000000000, but there is a related trap worth calling out: Arc's native gas accounting for USDC uses 18-decimal precision internally, but the ERC-20 decimals() call on that address returns 6, consistent with USDC everywhere else. Any CCTP amount calculation must use the 6-decimal ERC-20 interface, not the native-gas precision. Example: bridging 1 USDC → amount = 1_000_000n (6 decimals), not 1_000_000_000_000_000_000n.


mintRecipient must be left-padded to 32 bytes

mintRecipient is typed bytes32, not address. Ethers.js and viem do not auto-pad it to 32 bytes the way they would for an address parameter. Passing a bare 20-byte hex address silently packs garbage into the leading bytes and the mint either reverts or goes to the wrong address. Pad explicitly:

// ethers.js v6
const mintRecipient = ethers.zeroPadValue(recipientAddress, 32);

// viem
const mintRecipient = pad(recipientAddress as Hex, { size: 32 });

Attestation timing (real-world, testnet)

From live polling in production:

Source Destination Typical wait (minFinalityThreshold 2000)
Arc any 1–3 min
Ethereum Sepolia Arc 2–5 min
Base Sepolia Arc 2–5 min
Avalanche Fuji Arc 2–5 min

Practical polling recipe: 5-second interval, 20-minute timeout. Attestations that have not completed after 20 minutes almost always indicate an on-chain revert or an incorrect source domain passed to the Iris endpoint — inspect the burn receipt before re-polling.


destinationCaller semantics (non-zero case)

The guide shows bytes32(0) (anyone may relay), which is the right default for most integrations. For completeness: if destinationCaller is set to a specific address (left-padded to 32 bytes), only that address can call receiveMessage on the destination chain. This is useful for atomic relayers that need to prevent front-running, but requires the caller address to also be left-padded to 32 bytes (same padding rule as mintRecipient).


RPC reliability (Sepolia)

If the guide grows to include testnet setup: rpc.sepolia.org is unreliable. https://ethereum-sepolia-rpc.publicnode.com has been stable for high-volume CCTP testing.


Arc Testnet block explorer

For any burn/mint transaction links in examples: the canonical Arc Testnet explorer is https://testnet.arcscan.app with the standard /tx/{hash} path. Earlier URLs (explorer.testnet.arc.network, explorer.arc.io) are dead.


Overall this fills a real gap — the V1/V2 selector split and the minFinalityThreshold two-tier behaviour are the two most common failure modes for developers porting older CCTP code to Arc, and neither was documented anywhere before this.

Upstream moved the README links from arc.network to arc.io. That line sits
directly under the documentation list, so the CCTP guide bullet added here
conflicted with it. Kept the bullet and took the arc.io URL.
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 circlefin#153.
- Iris polling cadence, the normal 404, and the explorer check for a message
  that stalls.
@zkasuran

zkasuran commented Aug 2, 2026

Copy link
Copy Markdown
Author

Rebuilt on current main and folded in the review. Pushed as 34a0eb3, and the PR reports mergeable again.

The conflict

4657ade ("docs: update outdated arc.network domain references to arc.io", #70) rewrote the More: see Arc developer docs line in the README. That is the line this PR adds the CCTP bullet next to, so both sides changed adjacent lines in the same list and git could not pick one.

I merged main into the branch rather than rebasing, kept the bullet and took upstream's arc.io URL. The README diff against main is that one added line again. The documentation list is the only index of docs pages in this repo (no docs index file, no sidebar, no site config), and it carries the guide once, in the same position as before.

What moved since June

Two claims in the guide did not survive a recheck.

Burns from Arc at minFinalityThreshold: 1000 are not stuck any more. Three messages sourced from domain 26 that I pulled off Arc Testnet today were submitted with 1000 and came back from Iris with finalityThresholdExecuted: 2000 and status: complete. Circle's supported chains table lists Arc testnet as a standard transfer source and marks fast transfer N/A, which explains it. The service attests an Arc burn at the finalized level whatever the burn asked for. Passing 2000 is still the right call, so the guidance stands, but the reason is different. The section now says that instead of claiming 1000 hangs.

Arc has no CCTP mainnet domain. Circle lists 26 as "Arc testnet" and states that CCTP supports Arc testnet only, so the parenthetical about the testnet sharing a mainnet domain is gone.

Three of the four reference links now 308 to new paths (concepts/supported-chains-and-domains, references/contract-addresses, references/technical-guide). Updated.

One correction to my own text. The Iris fees endpoint returns basis points (1 = 0.01%), not token units, so "your maxFee must be greater than or equal to the minimum fee for the route" was misleading. maxFee is in burnToken units and the on-chain floor is amount * minFee / MIN_FEE_MULTIPLIER, which getMinFeeAmount(amount) returns. On Arc Testnet minFee() is 0 today, so maxFee: 0 passes for a standard burn from Arc. That is its own section now.

@osr21's suggestions

Taken:

  • USDC decimal trap. New section, written from the two views rather than from native gas precision: the ERC-20 interface reports 6 decimals while the node's native accounting is 18-decimal, so eth_getBalance and balanceOf on the same account differ by a factor of 10^12. This repo's own e2e test says the same thing ("NativeFiatToken uses 6 decimals; the precompile operates in 18-decimal native units", crates/execution-e2e/tests/eip7708_precompile.rs).
  • mintRecipient left-padding, with both helpers. One correction: current clients do not silently pack garbage into the leading bytes. viem 2.55 throws AbiEncodingBytesSizeMismatchError and ethers 6.17 throws INVALID_ARGUMENT: incorrect data length, both while encoding, so the call never leaves the client. I ran both. Hand-rolled ABI encoding has no such guard, which is where a wrong-length recipient still gets through, so the advice is in with that failure mode.
  • destinationCaller non-zero case, with the enforcement from the contract. MessageTransmitterV2 requires the caller to match and reverts Invalid caller for message for everyone else.
  • The polling recipe, in the shape Circle's own troubleshooting page gives it: 35 requests per second, so no faster than every 5 seconds, and stop at about 20 minutes to inspect the burn instead of re-polling.
  • The explorer, as the place to check a stalled burn. testnet.arcscan.app is also what Circle links from its USDC addresses page.
  • From your docs: CCTP V2 integration on Arc Testnet — domain 26, minFinalityThreshold 2000, V1 selector incompatible #110 comments: explicit gas for receiveMessage when Arc is the destination, and a pointer to bug: CCTP v2 receiveMessage() on destination chains fails with 'max fee per gas less than block base fee' when using default viem gas estimation #153 for the base fee race on other destination chains.

Left out:

  • The per-route timing table. I cannot reproduce those numbers, and Circle puts standard transfer timing at "varies by blockchain". Minute-level figures will age badly and I would rather not print numbers I cannot stand behind.
  • The Sepolia RPC recommendation. The guide does not cover testnet setup, and an endorsement of one public endpoint is not something this repo can keep true.

Every value, and where it was checked

Value Check Source
Arc CCTP domain 26 listed as "Arc testnet" at 26, and MessageTransmitterV2.localDomain() returns 26 on Arc Testnet supported chains and domains page, eth_call
Ethereum 0, Avalanche 1, Base 6 listed same page
TokenMessengerV2 0x8FE6…2DAA matches, 2175 bytes of code, localMessageTransmitter() and localMinter() return the two contracts below contract addresses page, eth_getCode, eth_call
MessageTransmitterV2 0xE737…E275 matches, 2175 bytes, localDomain() 26, version() 1 same
TokenMinterV2 0xb43d…F192 matches, 9295 bytes, localTokenMessenger() returns the TokenMessengerV2 same
MessageV2 0xbaC0…90C4 matches, 281 bytes same
USDC 0x3600…0000 listed for Arc Testnet, decimals() 6, symbol() USDC Circle's USDC contract addresses page, eth_call
V2 selector 0x8e0250ee matches, from the seven-parameter signature cast sig and an ethers Interface, independently
V1 selector 0x6fd3504e matches, and an eth_call carrying it returns a bare execution reverted with no data where the V2 ABI returns a decodable reason cast sig, eth_call against the live TokenMessenger
Threshold rule "Any minFinalityThreshold value below 1000 is treated as 1000, and any value above 1000 is treated as 2000" technical guide, quoted
Fast transfer on Arc marked N/A for Arc testnet as a source supported chains page, capability table
Arc burns asking for 1000 attested at 2000, complete three real messages via Iris /v2/messages/26?transactionHash=
maxFee floor minFee() 0 and getMinFeeAmount(1000000) 0 on Arc, against the formula in the contract eth_call, TokenMessengerV2 and BaseTokenMessenger source
Route fees [{1000,1},{2000,0}] for 0 to 26 today, values in basis points live Iris call, Circle's API reference ("The fee is returned in basis points (1 = 0.01%)")
Messages endpoint /v2/messages/{sourceDomain}?transactionHash= works, 404 before Iris indexes the burn, 400 with neither hash nor nonce live calls against the sandbox host
35 requests per second stated, with a five minute block after a 429 resolve attestation issues page
Finalized handler split receiveMessage branches on finalityThresholdExecuted < 2000 MessageTransmitterV2 source
testnet.arcscan.app 200, and /tx/<hash> resolves for a real burn curl, and Circle links the same explorer

Not verified, and said so

Checks

Public CI and Build Docker sit at action_required on this PR, as they do on every fork PR here, so a maintainer has to approve the run. Of what does run, the release jobs skip and StepSecurity passes.

Nothing in the repo lints markdown (.prettierignore excludes **/*.md, eslint covers JS and TS, forge lint covers Solidity), so locally I ran the repo's pre-commit whitespace hooks over the changed file. trailing-whitespace, end-of-file-fixer, mixed-line-ending, check-merge-conflict, fix-byte-order-marker and check-case-conflict all pass on docs/cctp-v2-integration.md. The same hook flags four pre-existing trailing-space line breaks in the README that came from upstream, and I left those alone rather than widen this diff. Every markdown link in the guide resolves (ten URLs, all 200), the README link points at the file, and the Iris endpoints in the examples were called against the sandbox host today.

Thanks @memosr for the independent check and @osr21 for the field notes. Both of them changed the guide.

@osr21 osr21 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-read the full guide at 34a0eb3 against the earlier review and re-checked the values. Approving.

What the last push resolved

  • Attestation polling edge cases — the new "Two responses that look like failures are not" section covers the pre-index 404 (Message not found for provided parameters) and pending status, plus the 35 req/s limit and the 5-minute lockout after a 429. This was the biggest operational gap in the previous revision; polling code that treats the 404 as terminal is the most common way integrators break here.
  • Explorer URLhttps://testnet.arcscan.app/tx/<burnTxHash> is the live explorer; the older explorer.testnet.arc.network / explorer.arc.io hosts are dead. Correct as written.
  • References — all seven links now point at Circle's current information architecture (/concepts/, /references/, /howtos/ paths); the two dead pre-restructure URLs are gone.
  • minFinalityThreshold clamping (from 428dd65) — the below-1000→1000 / above-1000→2000 rule now matches Circle's technical guide, and the finalityThresholdExecuted: 2000 observation from live Arc-sourced messages is consistent with the capability table marking fast transfer N/A for domain 26.

Spot-checks on the rest

  • V2/V1 selector split (0x8e0250ee / 0x6fd3504e) matches the 7-arg vs 4-arg depositForBurn signatures, and the "revert with no data is the tell" diagnostic is right — an unknown selector on the V2 TokenMessenger hits no function and reverts bare, while V2 ABI errors are decodable.
  • The bytes32 padding examples are correct for both libraries (pad(recipient, { size: 32 }) viem, ethers.zeroPadValue(recipient, 32) v6), and the guide is right that both throw at encode time — the residual risk being hand-rolled encoding is exactly the right caveat.
  • Invalid caller for message is the correct MessageTransmitterV2 revert for a destinationCaller mismatch.
  • The finalized-handler routing note (2000 → handleReceiveFinalizedMessage) is a subtle detail most guides miss and matters for contract recipients of Arc-sourced transfers.
  • getMinFeeAmount(1000000) → 0 on Arc Testnet reproduces, and the basis-point conversion example (10_000_000n * 1n / 10_000n) is arithmetically right.
  • The gas-limit guidance (explicit 600k, actual 180–260k, cite to #108/#80) matches the linked issues, and the new #153 cite for the stale maxFeePerGas on destination mints is a good addition.

Not blocking

Two optional items from the earlier review weren't folded in — the typical attestation-latency table (Arc→any ~1–3 min, other testnets→Arc ~2–5 min) and the Sepolia RPC reliability note. Both are enhancements, not corrections; the guide's "about 20 minutes, then investigate" bound is the load-bearing number and it's present. Fine to leave for a follow-up if ever.

One note for anyone reading the commit-range diff (428dd65..34a0eb3): it shows CI workflow and scripts/release-config.sh changes, but those arrived via the b761731 merge of upstream main — the PR's effective diff against main is still just the two documented files (README +1, guide +280). Verified via the PR file list.

@osr21

osr21 commented Aug 2, 2026

Copy link
Copy Markdown

Approved above; two data points for the items the write-up marks as not verified, from having run this integration on Arc Testnet:

The gas figures hold up. With a funded account and a live USDC allowance, my depositForBurn transactions from Arc landed in the 190k–240k range, and approve well under 60k — consistent with the 180,000–260,000 range and the 600,000 limit the guide carries from #110. The unused portion is refunded, so the headroom costs nothing.

The eth_estimateGas failure does occur on a valid call path. Your probe reverted early on an unregistered destination domain, so the estimator had a real revert to surface — that path works. The #108/#80 failure mode shows up when the call itself would succeed: a properly approved burn to a registered domain still gets an internal error with data: null from the estimator, while the same calldata with an explicit gasLimit goes through and mints. So the guide's "pass an explicit limit" advice is load-bearing, not just defensive. Citing the issues rather than claiming reproduction was the right call for the doc, but the behavior is real.

Also confirming the minFinalityThreshold: 1000 recheck from my side: Arc-sourced burns come back finalityThresholdExecuted: 2000 regardless of the requested value, matching your three messages. The rewritten section (2000 as a matter of matching request to outcome, not avoiding a hang) is the accurate framing.

The one optional item from my earlier comment that didn't land — typical attestation latency (Arc→any roughly 1–3 min, other testnets→Arc 2–5 min, against the guide's 20-minute investigate bound) — is fine as a follow-up if a maintainer wants it; it's a nice-to-have next to the polling guidance that did land.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation ecosystem Component: ecosystem

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs: CCTP V2 integration on Arc Testnet — domain 26, minFinalityThreshold 2000, V1 selector incompatible

4 participants