Add official cross-chain skill: CCTP V2, Axelar GMP/ITS, NEAR Intents - #67
Add official cross-chain skill: CCTP V2, Axelar GMP/ITS, NEAR Intents#67kaankacar wants to merge 11 commits into
Conversation
New official skill following the smart-contracts router pattern: a SKILL.md decision table plus shared pitfalls, with companion deep dives for Circle CCTP V2 (domain 27, CctpForwarder requirement, hook data layout, 6-vs-7 decimal semantics, Iris polling, both transfer directions) and Axelar (GMP send/receive signatures, ITS deployment and canonical registration, flow limits). NEAR Intents is covered at the routing level with status-sensitive wording. CCTP contract addresses are cross-checked between Circle's reference and the tested demo at ElliotFriend/stunning-octo-carnival, which the skill features as its worked reference implementation. Site: new Cross-Chain filter category and official skill card. Closes #66.
|
There was a problem hiding this comment.
Pull request overview
Adds official guidance for cross-chain development on Stellar.
Changes:
- Adds routing guidance for CCTP, Axelar, and NEAR Intents.
- Documents CCTP and Axelar integration flows.
- Adds the Cross-Chain site category and skill card.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
skills/cross-chain/SKILL.md |
Adds rail selection and shared pitfalls. |
skills/cross-chain/cctp.md |
Documents CCTP V2 flows and safety requirements. |
skills/cross-chain/axelar.md |
Documents Axelar GMP and ITS APIs. |
site/src/data/skills.ts |
Registers the category and skill card. |
site/src/app/styles.scss |
Enables Cross-Chain filtering. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Checked every signature against axelarnetwork/axelar-amplifier-stellar. The docs site had drifted from the source in five places: - deploy_remote_token does not exist; the real name is deploy_remote_interchain_token - the receive pattern is the AxelarExecutable derive macro plus CustomAxelarExecutable (__gateway/__execute) — the source forbids implementing the executable interface manually, and validation runs before __execute, generated - interchain_transfer's payload parameter is metadata, not data - set_flow_limit takes a caller and is open to approved flow limiters, not operator-only - deploy_interchain_token's first parameter is deployer, not caller Also added the canonical-token naming quirk and the InterchainTokenExecutable receiving hook, both from source.
The 7-decimal rule holds for classic assets and their SACs, not for Soroban token contracts (ITS tokens declare their own decimals) — scoped in both SKILL.md and the ITS pitfalls. Flow getters take env: &Env per the ITS interface. The wrapper allowance prose now states the real 50-99 ledger expiry range instead of ~50.
A demo contract built against the amplifier crates at current main (wasm32v1-none and native) surfaced two requirements the example didn't state: AxelarExecutableInterface must be imported alongside CustomAxelarExecutable, and the contract's error enum needs a NotApproved variant for the derive-generated execute to map validation failures onto.
|
This is awesome! Can you also mention https://docs.layerzero.network/v2/developers/stellar/overview and how LayerZero works for building Omnichain Applications (OApps) and Omnichain Fungible Tokens (OFTs) that connect Stellar to any LayerZero-supported chain. |
The Stellar endpoint went live on mainnet in July 2026 (EID 30600, testnet 40600), making LayerZero the second message-passing rail. New layerzero.md covers the endpoint addresses (pulled from LayerZero's metadata API), the four live DVNs (LayerZero Labs, Horizen, Nethermind, Canary, confirmed via the same API), the OApp Soroban pattern with names verified against the omni-counter example in LayerZero-Labs/monorepo-external, OFT at routing depth, and a neutral Axelar-vs-LayerZero comparison. The router decision table now offers both messaging rails, and the site card mentions all three protocols.
|
Scope addition: LayerZero V2 went live on Stellar mainnet in July (EID 30600), so the skill now covers it as the second message-passing rail ( |
…footgun Compile verification against the monorepo crates caught a real bug: the skeleton paired #[oapp(custom = [receiver])] with only an LzReceiveInternal impl. custom = [receiver] skips generating the receiver surface, so that contract compiles cleanly but exports no lz_receive at all. The counter example pairs the flag with its own OAppReceiver impl, which the condensation had dropped. The skeleton now uses plain #[oapp] (verified: full receive surface exported in the wasm) and a new bullet documents the footgun. Also fixed the snippet imports: added the soroban_sdk types it needs, dropped the two it doesn't.
|
LayerZero got the same compile-verification treatment as Axelar: a fresh minimal OApp built from only what layerzero.md documents, compiled to wasm32v1-none against the monorepo crates (their omni-counter also built in place as a baseline), plus an independent re-check of the metadata API facts (EIDs, endpoint addresses, the shared ULN address, and exactly four live DVNs). One real bug surfaced and is fixed in the latest commit: the skeleton's |
ElliotFriend
left a comment
There was a problem hiding this comment.
these look REALLY good!! i left some notes and thoughts, especially on the CCTP skill. the layerzero and axelar ones i'm less familiar with, but the structures of those skills make sense to me, at least.
i don't think i'd say any of my comments are blockers, but i do think some of them are pretty useful things to include.
| | `CctpForwarder` | `CA66Q2WFBND6V4UEB7RD4SAXSVIWMD6RA4X3U32ELVFGXV5PJK4T4VSZ` | | ||
| | USDC (SAC) | `CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA` (`USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5`) | | ||
|
|
||
| Derive the USDC SAC address for any network yourself rather than trusting a doc: `stellar contract id asset --asset USDC:<ISSUER> --network <net>`. Testnet USDC comes from [faucet.circle.com](https://faucet.circle.com) (pick "Stellar Testnet") — your account needs the trustline first. |
There was a problem hiding this comment.
might also be worth noting that there's a bunch of liquidity on testnet, and you can often get a pretty decent test amount with a pathPayment
There was a problem hiding this comment.
Added! Fun fact: that's exactly how I funded the test account when stress-testing this file — a pathPaymentStrictReceive got 5 USDC for ~2.7 XLM, no faucet needed.
| | `TokenMessengerMinter` | `CDNG7HXAPBWICI2E3AUBP3YZWZELJLYSB6F5CC7WLDTLTHVM74SLRTHP` | | ||
| | `MessageTransmitter` | `CBJ6MTCKKZG73PMDZCJMSFRD7DQEMI4FKDH7CGDSV4W6FHCRBCQAVVJY` | | ||
| | `CctpForwarder` | `CA66Q2WFBND6V4UEB7RD4SAXSVIWMD6RA4X3U32ELVFGXV5PJK4T4VSZ` | | ||
| | USDC (SAC) | `CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA` (`USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5`) | |
There was a problem hiding this comment.
the USDC SAC address might be nice to include for mainnet, as well?
There was a problem hiding this comment.
Good call — added the mainnet SAC + issuer to the table (derived it with stellar contract id asset to be safe).
| When transferring **to** Stellar, on the source-chain burn: | ||
|
|
||
| - Set **both** `mintRecipient` **and** `destinationCaller` to the `CctpForwarder` address (decoded to 32 bytes). | ||
| - Put the real recipient's `strkey` (`G…`, `M…`, or `C…`) into **hook data** as `forwardRecipient`. |
There was a problem hiding this comment.
the "as forwardRecipient part makes it read like the hook data should be an object or dictionary or something. in reality, there isn't a field, it's just an arrangement of bytes, and the hex-encoded StrKey is a part of those bytes.
There was a problem hiding this comment.
Thank you, reworded — it now says the strkey goes into the hook data bytes, and that "forwardRecipient" is just the docs' name for that stretch of the byte layout, not a field.
| - Wrong `destinationCaller` → the forwarder cannot complete the transfer. | ||
| - `mintRecipient` set to a user or muxed account → USDC never reaches the forwarder. | ||
|
|
||
| The forwarder flow is non-custodial: `mint_and_forward(message, attestation)` verifies the message, calls `receive_message` (minting to the forwarder), and pays out to `forwardRecipient` — all in one atomic Soroban invocation. Any failure reverts the whole thing. |
There was a problem hiding this comment.
| The forwarder flow is non-custodial: `mint_and_forward(message, attestation)` verifies the message, calls `receive_message` (minting to the forwarder), and pays out to `forwardRecipient` — all in one atomic Soroban invocation. Any failure reverts the whole thing. | |
| The forwarder flow is non-custodial: `mint_and_forward(message, attestation)` verifies the message, calls `receive_message` (minting to the forwarder), and pays out to `forwardRecipient` — all in one atomic contract invocation. Any failure reverts the whole thing. |
|
|
||
| | Bytes | Type | Data | | ||
| |---|---|---| | ||
| | 0–23 | `bytes24` | Magic, Circle-reserved; use all zero bytes | |
There was a problem hiding this comment.
might be worthwhile to note that it's "use all zero bytes" for Stellar-inbound, specifically. there's the cctp-forward that you can put in there, when the transfer is destined for a chain that supports the forwarding service.
There was a problem hiding this comment.
Nice catch — scoped it to Stellar-inbound and noted that forwarding-service chains use a cctp-forward magic instead.
| Address.fromString(USDC_SAC).toScVal(), | ||
| bytesN32(destinationCaller), // 32 zero bytes = anyone may complete the mint (permissionless) | ||
| nativeToScVal(maxFee, { type: "i128" }), // fee budget, 7-decimal subunits (100_000 ≈ $0.01) | ||
| nativeToScVal(finalityThreshold, { type: "u32" }), // 1000 = Fast, 2000 = Standard |
There was a problem hiding this comment.
it's probably a good idea to note that Stellar only supports standard transfer times on outbound transfers. because we already have fast finality, there's really no point to using fast transfers, and the CCTP transfers don't actually make use of any fast transferring things when it's a stellar-outbound transfer.
There was a problem hiding this comment.
Great point, added. I actually saw this live in a testnet run: requested 1000 and Iris came back with finalityThresholdExecuted: 2000, zero fee.
|
|
||
| Poll until `status` is `"complete"`, then read `message` and `attestation` (both hex). Field-level gotchas, all verified against live behavior: | ||
|
|
||
| - **Stellar legs return `null` address fields.** In `decodedMessage`/`decodedMessageBody`, `sender`, `recipient`, `mintRecipient`, etc. are `null` when Stellar is involved — the API can't decode 32-byte Stellar payloads either. The transfer is fine; parse the raw `message` hex if you need the addresses. |
There was a problem hiding this comment.
might be worth double-checking as of now. i think maybe some of those might've begun showing up?
There was a problem hiding this comment.
You're right, some of them do show up now — verified against a live Stellar→Arc transfer: recipient/mintRecipient/destinationCaller decode fine, only the fields holding Stellar payloads are null. Rewrote the bullet to match (first of the two new commits).
|
|
||
| ## Worked reference implementation | ||
|
|
||
| The best way to see all of this run is [**ElliotFriend/stunning-octo-carnival**](https://github.com/ElliotFriend/stunning-octo-carnival) — a SvelteKit demo that bridges testnet USDC Stellar ↔ EVM (Arc, Base Sepolia) and Stellar ↔ Solana, making every step visible on one screen: burn, attestation, mint. It is the source several snippets above were adapted from, and it's tested end-to-end on testnet in both directions. |
There was a problem hiding this comment.
again, the URL has changed for that repo
|
|
||
| ## Limitations and status notes | ||
|
|
||
| - **USDC only** on Stellar CCTP today — EURC is not confirmed. Verify current asset support in [Circle's docs](https://developers.circle.com/cctp) before promising it. |
There was a problem hiding this comment.
i think EURC is explicitly not enabled for any blockchain? I think the only other CCTP token that's available is USYC and that's only on Ethereum and BNB.
edit: source
There was a problem hiding this comment.
Thank you for the source! Fixed — EURC isn't a CCTP token anywhere; noted USDC on all domains except BNB, and USYC only on Ethereum/BNB.
| These bite regardless of which rail you pick. Each companion file adds rail-specific ones. | ||
|
|
||
| 1. **Address formats do not translate.** Stellar addresses are `strkey` strings (`G…` accounts, `C…` contracts, `M…` muxed); EVM uses 20-byte hex; Solana uses base58. Every rail defines its own encoding for foreign addresses (CCTP: raw 32-byte payloads; Axelar: strings + bytes payloads). Never paste an address from one chain into a field meant for another — encode it the way the rail specifies, and validate with the SDK (`StrKey.isValidEd25519PublicKey` / `isValidContract`) before encoding. | ||
| 2. **Decimals differ.** Classic Stellar assets and their SACs use 7 decimals, but other Soroban token contracts (ITS-deployed tokens included) declare their own — call `decimals()` instead of assuming. USDC is 6 on EVM chains and Solana; EVM tokens are commonly 18; CCTP messages are always 6-decimal. Convert at every boundary and test with amounts that exercise the last digit (see the worked decimal examples in [cctp.md](cctp.md#usdc-precision-7-decimals-vs-6)). |
There was a problem hiding this comment.
| 2. **Decimals differ.** Classic Stellar assets and their SACs use 7 decimals, but other Soroban token contracts (ITS-deployed tokens included) declare their own — call `decimals()` instead of assuming. USDC is 6 on EVM chains and Solana; EVM tokens are commonly 18; CCTP messages are always 6-decimal. Convert at every boundary and test with amounts that exercise the last digit (see the worked decimal examples in [cctp.md](cctp.md#usdc-precision-7-decimals-vs-6)). | |
| 2. **Decimals differ.** Classic Stellar assets and their SACs use 7 decimals, but other Soroban token contracts (ITS-deployed tokens included) declare their own — call `decimals()` instead of assuming. USDC is 6 on every supported chain except Stelllar (which uses 7 decimal places); EVM tokens are commonly 18; CCTP messages are always 6-decimal. Convert at every boundary and test with amounts that exercise the last digit (see the worked decimal examples in [cctp.md](cctp.md#usdc-precision-7-decimals-vs-6)). |
There was a problem hiding this comment.
Applied — with the "Stelllar" typo ironed out :)
Stress-tested the file by having a fresh agent bridge 2 USDC from Stellar testnet to Arc with only this skill as documentation. Every Stellar-side instruction survived contact with the live network; the gaps were all on the destination side, closed here: - destination contract reference: MessageTransmitterV2/TokenMessengerV2 are address-uniform across EVM chains, so list both (testnet + mainnet) with the receiveMessage(bytes,bytes) call and a link to Circle's contract-addresses page - domain examples now include Arc (26) and link the full domain table - document the Iris fee-schedule endpoint (/v2/burn/USDC/fees/...) so maxFee can be chosen instead of guessed, and the minted amount predicted exactly - scope the Iris null-fields note to fields that actually hold Stellar payloads: on an outbound leg the EVM-side fields decode fine
- mainnet USDC SAC address (+ issuer) in the mainnet table - note testnet DEX liquidity as a faucet alternative - hook data: clarify forwardRecipient is a byte range, not a field; scope the all-zero magic to Stellar-inbound (forwarding-service chains use a cctp-forward magic); state the uint32 fields are big-endian per Circle's reference builder - decimals: the seventh digit stays in the source account; SKILL.md pitfall now says 6 decimals everywhere except Stellar - approve step: spell out the most basic version (exact amount, short expiration), when to skip it, and the wrapper alternative - annotate the caller arg; note Stellar-outbound always executes at Standard finality, no fast fee - Solana consolidates messenger+minter into one program, like Stellar - inbound: complete the depositForBurnWithHook arg list (burnToken, minFinalityThreshold), link the contractStrkeyToBytes32 helper, fix the hook-layout link text - tokens: EURC is not a CCTP token anywhere; USDC on all domains except BNB, USYC only Ethereum/BNB - update links to the renamed demo repo (stellar-cctp-demo)
Verified the file end-to-end on testnet: a bidirectional GMP round trip Stellar <-> Base Sepolia (Soroban AxelarExecutable receiver + minimal EVM counterpart), 46s outbound, ~27min inbound. Every documented signature and both line-82 compile gotchas held exactly as written. What follows closes the gaps that had to be learned from outside the file: - name the gas/status APIs: the Axelarscan GMP API endpoints and the estimateGasFee / searchGMP methods - Cargo feature flags without which the documented pattern doesn't compile: stellar-axelar-std 'derive', gateway/gas-service 'library' - pay_gas sender-vs-spender semantics (sender must be the address that makes the follow-up call_contract) - gas is XLM via the native-asset SAC; overpayment refundable but not promptly automatic - delivery-time asymmetry: sub-minute outbound vs ~25-30min inbound behind L2->L1 finality - point address resolution at axelar-chains-config/info/*.json and extend the don't-hardcode warning to chain names, which are also deployment-versioned (testnet Stellar is 'stellar-2026-q1-2')
Live verification on Stellar testnet isn't possible right now: the deployment's required DVN doesn't support the endpoint's only registered message library, so every send reverts (#1213 UnsupportedMessageLib), and the pathway set is still in flux while the integration lands. Mainnet works but has only exchanged messages with two chains so far. Rather than ship a file we can't verify end-to-end the way cctp.md and axelar.md were, LayerZero gets its own PR once the testnet deployment stabilizes. The OApp skeleton and its compile-verified notes (incl. the custom=[receiver] footgun) are preserved in the branch history for that follow-up.
|
Two updates after putting the remaining rails through the same live testnet verification cctp.md got: axelar.md — verified end-to-end, small additions ( layerzero.md — pulled out of this PR, follow-up coming ( Net: this PR is now CCTP + Axelar GMP/ITS + NEAR Intents routing, all live-verified. |
Probed the API without moving funds: XLM and USDC are the two Stellar entries in /v0/tokens; a 20 XLM -> Base USDC quote priced at ~0.4% spread with a 27s ETA; and a non-dry quote returned the depositAddress + depositMemo pair exactly as the MEMO-mode note promised. - name the enforcement: Stellar-origin quotes must set depositMode: "MEMO" or /v0/quote rejects outright — the concept was documented, the required parameter wasn't - document dry quotes (pricing/ETA with no deposit commitment) and GET /v0/tokens as the support probe - state plainly that NEAR Intents has no testnet, and carve it out of the shared 'every rail has a testnet' pitfall accordingly
Closes #66.
What's in this PR
skills/cross-chain/SKILL.md(60 lines) — router: a rail-selection decision table (native USDC -> CCTP; contract calls -> Axelar GMP; multichain tokens -> Axelar ITS; intent-based swaps -> NEAR Intents), the five pitfalls every rail shares (address formats, decimals, trustlines, asynchrony, testnet-first), and the NEAR Intents section at routing depth with status-sensitive wording (including the Stellar MEMO-mode deposit requirement, where a missing memo means an uncredited deposit).skills/cross-chain/cctp.md(207 lines) — contracts and addresses for domain 27 (mainnet + testnet), the fund-loss rule (CCTP carries raw 32-byte addresses, assumesmintRecipientis a contract, so Stellar recipients requireCctpForwarderas bothmintRecipientanddestinationCallerwith the real recipient in hook data), the hook byte layout with Circle's validated builder, 7-vs-6 decimal semantics in both directions with worked examples, full outbound and inbound flows including the verifieddeposit_for_burnargument order, the one-signature Soroban wrapper pattern, and Iris polling with its quirks (null address fields on Stellar legs, the base58-vs-hex hash normalization trap,insufficient_feedelays).skills/cross-chain/axelar.md(141 lines) — GMP send (pay_gasthencall_contract) and receive (Executable+validate_message, plus the allowlist caveat that Axelar authenticates the sender, not whether you should trust them), and ITS (new interchain tokens, canonical registration of existing Stellar tokens,interchain_transfer, flow limits, hub mode). Addresses deliberately resolve from Axelar's directory instead of being hardcoded.FilterType,FILTERS, the scss@eachlist) and an officialSKILL_CARD_SOURCESentry.Sources and verification
pnpm lint:ts,pnpm lint, andpnpm buildpass; the card server-renders inout/index.htmlwithdata-category="Cross-Chain", andllms.txtpicks up the router plus both companion files.Heads-up
#49 and #55 also touch
skills.ts,FILTERS, andstyles.scss(Wallets and DeFi categories). Whichever lands last takes a small append conflict; happy to resolve on whichever side needs it.