diff --git a/site/src/app/styles.scss b/site/src/app/styles.scss index 58d6aa4..7b0b65b 100644 --- a/site/src/app/styles.scss +++ b/site/src/app/styles.scss @@ -314,7 +314,16 @@ } @each $filter - in ("Smart Contracts", "Agentic Payments", Frontend, Assets, APIs, ZK, Ecosystem) + in ( + "Smart Contracts", + "Agentic Payments", + Frontend, + Assets, + APIs, + ZK, + "Cross-Chain", + Ecosystem + ) { .SkillsLanding__filterPanel[data-active-filter="#{$filter}"] .SkillsLanding__filterItem[data-category="#{$filter}"] { diff --git a/site/src/data/skills.ts b/site/src/data/skills.ts index 54e8c3f..22b1ea5 100644 --- a/site/src/data/skills.ts +++ b/site/src/data/skills.ts @@ -22,6 +22,7 @@ type FilterType = | "Assets" | "APIs" | "ZK" + | "Cross-Chain" | "Ecosystem"; /** @@ -36,6 +37,7 @@ export const FILTERS: readonly FilterType[] = [ "Assets", "APIs", "ZK", + "Cross-Chain", "Ecosystem", ] as const; @@ -127,6 +129,13 @@ export const SKILL_CARD_SOURCES: readonly SkillCardSource[] = [ description: "Pick the right SEP or CAP for your feature, with ecosystem projects, curated reference links, and MCPs.", }, + { + source: "skills/cross-chain/SKILL.md", + category: "Cross-Chain", + title: "Cross-Chain (CCTP, Axelar)", + description: + "Bridge native USDC with Circle CCTP, pass messages and tokens with Axelar GMP/ITS, and route intent-based swaps with NEAR Intents.", + }, ] as const; /** diff --git a/skills/cross-chain/SKILL.md b/skills/cross-chain/SKILL.md new file mode 100644 index 0000000..219cdd9 --- /dev/null +++ b/skills/cross-chain/SKILL.md @@ -0,0 +1,61 @@ +--- +name: cross-chain +description: Cross-chain interoperability for Stellar. Entry point with a rail-selection decision table and shared pitfalls, routing to two companion files — cctp.md (Circle CCTP V2, native USDC burn-and-mint between Stellar and EVM/Solana chains, domain 27, the CctpForwarder requirement for Stellar recipients) and axelar.md (Axelar GMP for Soroban contracts calling contracts on other chains, and the Interchain Token Service for multichain tokens). Also covers NEAR Intents (intent-based cross-chain swaps into XLM or Stellar USDC) at the routing level. Use when bridging USDC to or from Stellar, sending messages between a Stellar contract and another blockchain, making a token exist on multiple chains, or adding cross-chain swaps to an app. +user-invocable: true +argument-hint: "[cross-chain task]" +--- + +# Cross-Chain on Stellar + +Stellar connects to other blockchains over several production rails, each built for a different job. Picking the wrong rail wastes engineering effort; picking the right one is a routing decision, not a research project. This file routes; the deep dives live alongside it — **read the file that matches the task**: + +| You want to | Use | Where | +|---|---|---| +| Move **native USDC** between Stellar and an EVM chain or Solana (no wrapped assets, no liquidity pools) | Circle CCTP V2 | [cctp.md](cctp.md) | +| Have a Stellar contract **call a contract on another chain**, or receive calls from one (arbitrary payloads) | Axelar GMP | [axelar.md](axelar.md) | +| Make a token — new or an existing Stellar asset — **exist on multiple chains** | Axelar ITS | [axelar.md](axelar.md) | +| **Swap any asset cross-chain** (BTC, ETH, SOL, … → XLM or Stellar USDC) without integrating a bridge yourself | NEAR Intents | [below](#near-intents-intent-based-swaps) | + +Rules of thumb: if the asset is USDC and both ends are CCTP chains, CCTP is the cheapest and most direct (it burns and mints Circle-native USDC — nothing wrapped, nothing pooled). If you need logic, not just value, on the far chain, that is message passing — Axelar GMP is the rail for it. If you control a token and want it multichain, that is ITS. If the user just wants "turn my X on chain A into Y on Stellar" and you don't want bridge plumbing at all, quote it through NEAR Intents. + +## When to use this skill + +- Bridging USDC between Stellar and Ethereum, Base, Arbitrum, Solana, or another CCTP-supported chain +- Receiving bridged USDC into a Stellar account or contract (and not bricking the funds — see the forwarder warning below) +- Writing a Soroban contract that sends messages to or receives messages from contracts on other chains +- Deploying an interchain token, or connecting an existing Stellar asset to other ecosystems +- Adding a "deposit from any chain" or cross-chain swap flow to a wallet or dapp + +## Related skills + +- Trustlines, SAC deployment, asset anatomy → `../assets/SKILL.md` +- Writing the Soroban contracts that send/receive messages → `../smart-contracts/SKILL.md` +- Frontend transaction building, Freighter signing, RPC submission → `../dapp/SKILL.md` +- Watching for the destination-side mint or contract events → `../data/SKILL.md` +- Paying AI agents (x402/MPP) rather than bridging → `../agentic-payments/SKILL.md` + +## Pitfalls shared by every rail + +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 every supported chain except Stellar (which uses 7); 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)). +3. **Classic Stellar recipients need a trustline first.** A `G…` account cannot receive an issued asset (USDC included) without a trustline to that asset. Bridged funds destined for an account without one will not land. Check and provision before starting the transfer — see `../assets/SKILL.md`. +4. **Cross-chain is asynchronous.** Every rail has a wait: CCTP waits for finality plus Circle's attestation (seconds to ~15 minutes depending on chain and finality threshold), Axelar waits for validator confirmation, intents wait for a market maker. Build UIs and agents around polling a status, never around "submit and assume". +5. **Testnet first, always.** Every rail here except NEAR Intents has a testnet deployment (intents are filled by real market makers — mainnet only; rehearse with dry quotes and a dust-sized swap instead). Do the full round-trip on testnet before touching mainnet — cross-chain mistakes are frequently unrecoverable by design (burns are final, and some misencodings permanently strand funds). + +## NEAR Intents (intent-based swaps) + +> **Status-sensitive.** Chain and asset support changes frequently — verify Stellar's current status in the [NEAR Intents docs](https://docs.near-intents.org/) before building. + +[NEAR Intents](https://docs.near-intents.org/) is an intent protocol: the user states an outcome ("swap 0.1 BTC to USDC on Stellar"), market makers compete to execute it. Stellar (XLM and USDC) is a supported destination and source, which makes this the shortest path to "deposit from any chain" UX — there is no bridge contract to integrate on the Stellar side at all. + +Integration is the [1Click API](https://docs.near-intents.org/near-intents/integration/distribution-channels/1click-api): `POST /v0/quote` with the asset pair returns a price and a **deposit address**; send funds there and market makers carry out the swap. `GET /v0/tokens` lists supported assets (the Stellar entries: XLM and USDC, 7-decimal), and a quote with `"dry": true` returns pricing and an ETA without creating a deposit commitment — probe pairs freely, commit later. Official SDKs exist for TypeScript, Go, and Rust. + +Three Stellar-specific facts (verified against the live API): + +- **Stellar deposits are MEMO mode only, and the API enforces it.** A Stellar-origin quote must set `"depositMode": "MEMO"` — without it `/v0/quote` rejects with `Incorrect depositMode for originAsset from stellar chain`. The quote then returns a deposit address **plus a memo** (`depositAddress` + `depositMemo`), and the memo is what routes your funds to your swap. A deposit without the memo is not credited — treat the memo as part of the address, and refuse to display one without the other. +- **There is no testnet.** Intents are filled by real market makers with real liquidity, so the rehearsal path is dry quotes followed by a dust-sized real swap — not a testnet round trip. +- Registering for an API key via the partners portal removes the default integrator fee; anonymous use works but is surcharged (it shows up in the quoted spread). + +For anything deeper (quote parameters, slippage, refund handling), work from the live 1Click API docs rather than this file — the protocol iterates quickly. diff --git a/skills/cross-chain/axelar.md b/skills/cross-chain/axelar.md new file mode 100644 index 0000000..a9e6455 --- /dev/null +++ b/skills/cross-chain/axelar.md @@ -0,0 +1,165 @@ +# Axelar on Stellar — GMP and Interchain Tokens + +[Axelar](https://docs.axelar.dev/) connects Stellar to EVM chains and the wider Axelar ecosystem through its amplifier stack. Two products matter here: + +- **GMP (General Message Passing)** — a Soroban contract sends arbitrary payloads to a contract on another chain, or receives and executes payloads from one. +- **ITS (Interchain Token Service)** — tokens that exist on multiple chains: mint new ones, or connect an existing Stellar token. + +Both are live on Stellar testnet and mainnet. The Stellar contracts are Rust/Soroban and live in [axelar-amplifier-stellar](https://github.com/axelarnetwork/axelar-amplifier-stellar) — `stellar-axelar-gateway`, `stellar-axelar-gas-service`, and the ITS contracts. Every signature in this file is checked against those sources, which occasionally run ahead of the docs site; when they disagree, the source wins. + +> **Addresses and chain names:** resolve the current Gateway, Gas Service, and ITS addresses from [`axelar-chains-config/info/mainnet.json`](https://github.com/axelarnetwork/axelar-contract-deployments/blob/main/axelar-chains-config/info/mainnet.json) / [`testnet.json`](https://github.com/axelarnetwork/axelar-contract-deployments/blob/main/axelar-chains-config/info/testnet.json) in the axelar-contract-deployments repo — the [docs directory](https://docs.axelar.dev/resources/contract-addresses/mainnet/) is built from it but doesn't always render every chain. Chain **names** are deployment-versioned too: the same file's `axelarId` is the exact string `destination_chain` wants (Stellar is `stellar` on mainnet but currently `stellar-2026-q1-2` on testnet). Don't hardcode either from any tutorial, including this one. + +## GMP: sending a message from Stellar + +Two calls, in order — gas first, then the message. Cross-chain execution is paid up front on the source chain. + +**1. Pay gas** on the Gas Service. The `token` parameter is a struct of `{ address, amount }` — which token you're paying with and how much: + +```rust +fn pay_gas( + env: Env, + sender: Address, + destination_chain: String, + destination_address: String, + payload: Bytes, + spender: Address, + token: Token, + metadata: Bytes, +) -> Result<(), ContractError>; +``` + +**2. Call the Gateway** with the same chain/address/payload triple: + +```rust +pub fn call_contract( + env: Env, + caller: Address, + destination_chain: String, + destination_address: String, + payload: Bytes, +); +``` + +Notes that save debugging time: + +- In `pay_gas`, `sender` is the address that will make the follow-up `call_contract` call (from a contract, `env.current_contract_address()`); `spender` is who pays. Getting `sender` wrong orphans the gas payment from the message. +- `destination_chain` is Axelar's registered chain name (a string), not a chain ID — take the exact spelling from the `axelarId` field in the chains-config file above. A misspelled (or outdated) chain name fails downstream, not at call time. +- `destination_address` is a string in the destination chain's own format (for EVM, the `0x…` hex address). +- `payload` is raw `Bytes`. Axelar does not define the codec — you do. For EVM counterparties the convention is ABI encoding, so encode/decode with an ABI library on both ends and version your payload format from day one. +- Gas is paid in XLM through the native-asset SAC (derive its address: `stellar contract id asset --asset native --network `). Underpaid gas strands the message until topped up; overpayment is refundable through the Gas Service, but don't budget around a prompt automatic refund. +- Estimate gas and track delivery through the Axelarscan GMP API — `https://api.gmp.axelarscan.io` (mainnet) / `https://testnet.api.gmp.axelarscan.io` (testnet): `POST` `{"method": "estimateGasFee", …}` to price the cross-chain leg, `{"method": "searchGMP", …}` to poll a message's status by tx hash. +- Delivery times are asymmetric: Stellar → EVM executes in under a minute, but EVM-L2 → Stellar first waits out the L2's **L1 finality** (~25–30 minutes on testnet). A message sitting unexecuted that long is normal, not stuck — check `searchGMP` before topping up gas. + +## GMP: receiving a message on Stellar + +Do **not** hand-implement the executable interface — the source marks it "DO NOT IMPLEMENT THIS MANUALLY!". The supported pattern is the `AxelarExecutable` derive macro plus a `CustomAxelarExecutable` impl with two functions: `__gateway` (which gateway to trust) and `__execute` (your logic). The macro generates the public `execute` entrypoint and **guarantees the gateway's `validate_message` has already succeeded** before `__execute` runs. Verbatim from Axelar's own [example contract](https://github.com/axelarnetwork/axelar-amplifier-stellar/tree/main/contracts/stellar-axelar-example): + +```rust +use stellar_axelar_gateway::executable::{AxelarExecutableInterface, CustomAxelarExecutable}; +use stellar_axelar_std::AxelarExecutable; + +#[contract] +#[derive(AxelarExecutable)] +pub struct AxelarExample; + +impl CustomAxelarExecutable for AxelarExample { + type Error = AxelarExampleError; + + fn __gateway(env: &Env) -> Address { + storage::gateway(env) + } + + fn __execute( + env: &Env, + source_chain: String, + message_id: String, + source_address: String, + payload: Bytes, + ) -> Result<(), Self::Error> { + // your logic — the message is already gateway-validated here + Ok(()) + } +} +``` + +Two compile-verified requirements the example doesn't spell out: `AxelarExecutableInterface` must be in scope (the derive-generated code references it — omitting the import fails with E0405), and your error enum must define a `NotApproved` variant, because the generated `execute` maps the gateway's validation failure onto it. Two more live in Cargo.toml: the derive macro only exists behind a feature flag — `stellar-axelar-std = { version = "…", features = ["derive"] }` — and consuming the gateway/gas-service crates as dependencies requires their `library` feature (e.g. `stellar-axelar-gateway = { version = "…", features = ["library"] }`). The example contract's own Cargo.toml in the amplifier repo is the reference for current versions. + +Under the hood the generated `execute` calls the Gateway's `validate_message`, which authenticates the exact message (chain, id, sender, payload hash) and flips it to executed so it cannot replay: + +```rust +fn validate_message( + env: Env, + caller: Address, // must be the message's intended destination contract + source_chain: String, + message_id: String, + source_address: String, + payload_hash: BytesN<32>, +) -> bool; +``` + +One thing the macro does **not** do for you: check `source_chain`/`source_address` against an allowlist of counterpart contracts you trust. Axelar authenticates *that* the message came from that sender, not *whether* you should listen to them — do that check first thing in `__execute`. + +Axelar's [Stellar GMP guide](https://docs.axelar.dev/dev/general-message-passing/stellar-gmp/intro/), the worked [GMP example docs](https://docs.axelar.dev/dev/general-message-passing/stellar-gmp/gmp-example/), and the `stellar-axelar-example` contract are the scaffolding starting points. The same example also shows `#[derive(InterchainTokenExecutable)]` + `CustomInterchainTokenExecutable` — the receiving-side hook for ITS transfers that carry a data payload. + +## ITS: tokens on multiple chains + +ITS on Stellar operates in **hub mode**: token messages route through Axelar's ITS Hub rather than chain-to-chain. Components: the `InterchainTokenService` contract (coordination), a `TokenManager` per token (mint/burn/lock), and `InterchainToken` (a Stellar token interface implementation — meaning ITS-deployed tokens are Soroban contracts, addressable like any `C…` token). + +**Mint a new multichain token** — deploy locally, then extend it to each destination chain (each remote deployment pays its own gas): + +```rust +fn deploy_interchain_token( + env: &Env, deployer: Address, salt: BytesN<32>, + token_metadata: TokenMetadata, initial_supply: i128, minter: Option
, +) -> Result, ContractError>; // returns the token_id + +fn deploy_remote_interchain_token( + env: &Env, caller: Address, salt: BytesN<32>, + destination_chain: String, gas_token: Option, +) -> Result, ContractError>; +``` + +**Connect an existing Stellar token** (canonical registration) — works for any Stellar token, SACs of classic assets included. Anyone can deploy the trustless canonical representation to a trusted chain. Naming quirk from the source: if the token name exceeds 32 characters the symbol is used as the name, and natively issued Stellar assets always deploy with the symbol as the name: + +```rust +fn register_canonical_token( + env: &Env, token_address: Address, +) -> Result, ContractError>; + +fn deploy_remote_canonical_token( + env: &Env, token_address: Address, destination_chain: String, + spender: Address, gas_token: Option, +) -> Result, ContractError>; +``` + +**Move tokens** — one call, addressed by the `token_id` the deploy/registration returned: + +```rust +fn interchain_transfer( + env: &Env, caller: Address, token_id: BytesN<32>, + destination_chain: String, destination_address: Bytes, amount: i128, + metadata: Option, gas_token: Option, +) -> Result<(), ContractError>; +``` + +`destination_address` is `Bytes` in the destination chain's format; `metadata` optionally triggers contract execution on arrival (GMP piggybacked on a token transfer — the receiver implements `InterchainTokenExecutable`); `gas_token` prepays the cross-chain leg. + +**Operational controls** — per-token rate limits, worth setting for anything with real value. Callable by the contract operator and by per-token approved flow limiters: + +```rust +fn set_flow_limit( + env: &Env, caller: Address, token_id: BytesN<32>, flow_limit: Option, +) -> Result<(), ContractError>; +fn flow_limit(env: &Env, token_id: BytesN<32>) -> Option; +fn flow_out_amount(env: &Env, token_id: BytesN<32>) -> i128; // current-epoch outflow +fn flow_in_amount(env: &Env, token_id: BytesN<32>) -> i128; +``` + +## ITS pitfalls + +- **`token_id` is the identity, not the address.** The same token has different contract addresses per chain but one `BytesN<32>` token id — persist the id, derive addresses from it. +- **Decimals don't auto-reconcile across ecosystems.** Classic-asset SACs are 7-decimal, but a canonical registration accepts any Soroban token, and ITS tokens carry whatever `TokenMetadata` declared — read `decimals()` from the token instead of assuming 7, and think through amount scaling before wiring UIs to EVM counterparts (test a dust-sized transfer first). +- **Remote deployments and transfers both prepay gas** via `gas_token` — budget one gas payment per destination chain, not one total. +- **Flow limits fail closed.** A transfer that would exceed the window's limit is rejected, not queued — surface that error distinctly from "bridge is broken". + +Full walkthroughs: Axelar's [Stellar ITS guide](https://docs.axelar.dev/dev/send-tokens/stellar/intro/). diff --git a/skills/cross-chain/cctp.md b/skills/cross-chain/cctp.md new file mode 100644 index 0000000..9c19561 --- /dev/null +++ b/skills/cross-chain/cctp.md @@ -0,0 +1,221 @@ +# CCTP V2 on Stellar — native USDC between chains + +Circle's [Cross-Chain Transfer Protocol](https://developers.circle.com/cctp) moves USDC by **burning it on the source chain and minting native USDC on the destination**. No wrapped tokens, no liquidity pools, no slippage — the amount out is the amount in minus an optional fee. Stellar is CCTP **domain 27**. + +The lifecycle is always the same three steps, in both directions: + +1. **Burn** on the source chain (`deposit_for_burn` / `depositForBurn`). +2. **Attest**: Circle's Iris service observes the burn and signs an attestation. +3. **Mint** on the destination chain by submitting the message + attestation. + +Read this file top to bottom before writing any code: CCTP on Stellar has one mistake class that **permanently destroys funds**, and it lives in step 1 of the inbound direction. + +## Contracts and addresses + +Stellar CCTP runs on three Soroban contracts ([Circle's contract reference](https://developers.circle.com/cctp/references/stellar-contracts) is canonical): + +| Contract | Role | +|---|---| +| `TokenMessengerMinter` | Burns USDC outbound (`deposit_for_burn`, `deposit_for_burn_with_hook`); mints inbound. Consolidates EVM's `TokenMessengerV2` + `TokenMinterV2`. | +| `MessageTransmitter` | Message bus: emits messages, verifies attestations, enforces nonce uniqueness (`receive_message`, `is_nonce_used`). | +| `CctpForwarder` | Receives inbound mints and atomically forwards to the real recipient (`mint_and_forward`). **Required for Stellar recipients** — see below. | + +**Mainnet** (domain 27): + +| Contract | Address | +|---|---| +| `TokenMessengerMinter` | `CAE2G5Z77UP7GYPYGFOWFGW7C7J6I4YP2AFGSADRKQY62SYUFLPNFTXL` | +| `MessageTransmitter` | `CACMENFFJPJMSDAJQLX4R7K3SFZIW2LJSE3R2UMLGSWHFHS353FVXAZV` | +| `CctpForwarder` | `CBZL2IH7F6BIDAA3WBNXYKIXSATJGMSW7K5P5MJ6STX5RXN47TZJDF5T` | +| USDC (SAC) | `CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75` (`USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN`) | + +**Testnet** (domain 27): + +| Contract | Address | +|---|---| +| `TokenMessengerMinter` | `CDNG7HXAPBWICI2E3AUBP3YZWZELJLYSB6F5CC7WLDTLTHVM74SLRTHP` | +| `MessageTransmitter` | `CBJ6MTCKKZG73PMDZCJMSFRD7DQEMI4FKDH7CGDSV4W6FHCRBCQAVVJY` | +| `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: --network `. Testnet USDC comes from [faucet.circle.com](https://faucet.circle.com) (pick "Stellar Testnet") — your account needs the trustline first. There is also decent USDC liquidity on the testnet DEX, so a `pathPaymentStrictReceive` from XLM often gets you a test amount without touching the faucet. + +## The one rule that saves funds: use `CctpForwarder` for Stellar recipients + +CCTP message fields carry **raw 32-byte address payloads** — no `strkey` type marker. The protocol therefore cannot tell a `G…` account from a `C…` contract, and it **always assumes `mintRecipient` is a contract**. Sending USDC to a Stellar user account "directly" mints into an address nobody controls. + +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 the **hook data bytes**. There is no named field — "`forwardRecipient`" is just what the docs call that stretch of the byte layout below. + +Get either field wrong and the funds are **permanently stuck — there is no recovery path**: + +- 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 contract invocation. Any failure reverts the whole thing. + +### Hook data layout + +| Bytes | Type | Data | +|---|---|---| +| 0–23 | `bytes24` | Magic, Circle-reserved. All zero bytes for Stellar-inbound transfers; chains served by Circle's forwarding service use a `cctp-forward` magic instead | +| 24–27 | `uint32` (BE) | Version; set to `0` | +| 28–31 | `uint32` (BE) | `L`: length of `forwardRecipient` in bytes | +| `32..(32+L-1)` | `bytes` | the recipient `strkey` (`forwardRecipient`), UTF-8 bytes | +| `(32+L)..` | `bytes` | Optional integrator payload; omit if unused | + +Builder, from [Circle's reference](https://developers.circle.com/cctp/references/stellar). The integer fields are **big-endian** — Circle's own reference builder writes them with `writeUInt32BE`. Validate the strkey before encoding; a typo here is a fund-loss bug, not a UX bug: + +```typescript +import { StrKey } from "@stellar/stellar-sdk"; + +function buildCctpForwarderHookData(forwardRecipientStrkey: string): `0x${string}` { + const isValid = + StrKey.isValidEd25519PublicKey(forwardRecipientStrkey) || + StrKey.isValidContract(forwardRecipientStrkey) || + StrKey.isValidMed25519PublicKey(forwardRecipientStrkey); + if (!isValid) throw new Error(`Invalid forward recipient: ${forwardRecipientStrkey}`); + + const recipientBytes = Buffer.from(forwardRecipientStrkey, "utf8"); + const hookData = Buffer.alloc(32 + recipientBytes.length); // bytes 0-23 stay zero (magic) + hookData.writeUInt32BE(0, 24); // hook version = 0 + hookData.writeUInt32BE(recipientBytes.length, 28); // recipient byte length + recipientBytes.copy(hookData, 32); // recipient strkey as UTF-8 + return `0x${hookData.toString("hex")}`; +} + +// The forwarder itself is a C… contract; decode it for mintRecipient/destinationCaller: +function contractStrkeyToBytes32(strkey: string): `0x${string}` { + if (!StrKey.isValidContract(strkey)) throw new Error(`Invalid contract strkey: ${strkey}`); + return `0x${Buffer.from(StrKey.decodeContract(strkey)).toString("hex")}`; +} +``` + +## USDC precision: 7 decimals vs 6 + +Stellar represents USDC in **seven**-decimal subunits; every other CCTP chain uses **six**, and the `amount` in a CCTP message is **always six-decimal**. Direction determines the handling: + +- **Stellar as source**: the burn debits only through the sixth decimal; the seventh digit stays in the sender's account. Bridging `0.1234567` USDC burns `0.1234560`, leaves `0.0000007` behind in the source account, and the message `amount` is `123456`. +- **Stellar as destination**: the six-decimal message amount is scaled ×10. A message `amount` of `123456` mints `0.1234560` (= `1234560` seven-decimal subunits). + +Practical consequences: pass 7-decimal subunits (`i128`) to Stellar-side calls and 6-decimal units everywhere off-chain; never derive one from the other with floats; and when verifying "did the full amount arrive", compare in message units, not source-chain units. + +## Stellar → EVM/Solana (outbound) + +Two Stellar transactions in the plain flow — an allowance, then the burn — because `TokenMessengerMinter` pulls funds via `transfer_from`: + +1. `approve` the USDC SAC with `TokenMessengerMinter` as spender. Most basic version: approve exactly the transfer amount, with a short `live_until_ledger` (current ledger + ~100 ≈ 8 minutes) so nothing lingers. If an existing allowance already covers the amount (`allowance(from, spender)`), skip this step — or collapse approve + burn into one transaction with the wrapper below. +2. Call `deposit_for_burn`. Verified argument order (Soroban): + +```typescript +import { Address, Contract, TransactionBuilder, nativeToScVal, BASE_FEE } from "@stellar/stellar-sdk"; + +const tmm = new Contract(TOKEN_MESSENGER_MINTER); +const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase }) + .addOperation( + tmm.call( + "deposit_for_burn", + Address.fromString(caller).toScVal(), // the burning account; must authorize the call + nativeToScVal(amount, { type: "i128" }), // 7-decimal Stellar subunits + nativeToScVal(destinationDomain, { type: "u32" }), // e.g. 0 Ethereum, 5 Solana, 6 Base, 26 Arc + bytesN32(mintRecipient), // 32-byte recipient: EVM address left-padded to 32; Solana: the USDC ATA's raw bytes + 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 + ), + ) + .setTimeout(60) + .build(); +// then: simulate, sign, submit (see ../dapp/SKILL.md) +``` + +On `finalityThreshold`: outbound-from-Stellar transfers always execute at **Standard** — Stellar finality is already effectively instant, so there is nothing for Fast to accelerate. Iris reports `finalityThresholdExecuted: 2000` even when you request `1000`, with no fast fee charged. + +3. Poll Iris for the attestation (below). +4. Submit `receiveMessage(message, attestation)` on the destination chain's `MessageTransmitterV2`. With `destinationCaller` zeroed this is permissionless — anyone (the recipient, your backend, a relayer) can submit it. + +Recipient encoding: for EVM, left-pad the 20-byte address to 32 bytes. For Solana, `mintRecipient` is the recipient's **USDC associated token account** (not their wallet address) as raw 32 bytes. (On Solana, as on Stellar, messenger and minter are consolidated into a single `TokenMessengerMinter` program — don't go looking for two separate contracts.) + +Destination-side reference: CCTP V2 uses the **same contract addresses on every EVM chain** (one exception, EDGE — the authoritative list is [Circle's EVM contract addresses](https://developers.circle.com/cctp/evm-smart-contracts)): + +| Contract | All EVM testnets | All EVM mainnets | +|---|---|---| +| `MessageTransmitterV2` — `receiveMessage(bytes message, bytes attestation)` | `0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275` | `0x81D40F21F12A8F0E3252Bccb954D722d4c464B64` | +| `TokenMessengerV2` — `depositForBurn` / `depositForBurnWithHook` | `0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA` | `0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d` | + +Pass Iris's `message` and `attestation` hex to `receiveMessage` verbatim. Domain IDs are Circle-assigned — Ethereum 0, Solana 5, Base 6, Arc 26, Stellar 27; the full table is in [supported chains and domains](https://developers.circle.com/cctp/concepts/supported-chains-and-domains) (a mainnet listing covers its official testnet too). + +**One-signature variant.** The two-transaction dance (approve, then burn) collapses into one Soroban transaction with a ~40-line wrapper contract, because Soroban's auth tree lets a single signature authorize both nested calls. From the [reference demo](https://github.com/ElliotFriend/stellar-cctp-demo) (`contracts/stellar/cctp-wrapper/`), verbatim: + +```rust +pub fn approve_and_deposit( + env: Env, caller: Address, usdc: Address, tmm: Address, amount: i128, + destination_domain: u32, mint_recipient: BytesN<32>, destination_caller: BytesN<32>, + max_fee: i128, min_finality_threshold: u32, +) { + caller.require_auth(); + let expiration_ledger = (env.ledger().sequence() + 50).next_multiple_of(50); + token::Client::new(&env, &usdc).approve(&caller, &tmm, &amount, &expiration_ledger); + TmmClient::new(&env, &tmm).deposit_for_burn( + &caller, &amount, &destination_domain, &mint_recipient, + &usdc, &destination_caller, &max_fee, &min_finality_threshold, + ); +} +``` + +One wallet prompt, one network fee, and the allowance expires after 50–99 ledgers (the expression rounds up to the next multiple of 50) so nothing lingers. + +## EVM/Solana → Stellar (inbound) + +This is the direction where the [forwarder rule](#the-one-rule-that-saves-funds-use-cctpforwarder-for-stellar-recipients) applies. Prerequisite: a `G…` recipient needs a **USDC trustline** before anything can land. + +1. On the source chain, call `depositForBurnWithHook` with: + - `mintRecipient` = `CctpForwarder` (as bytes32, via the [`contractStrkeyToBytes32` helper above](#hook-data-layout)) + - `destinationCaller` = `CctpForwarder` (same value) + - `destinationDomain` = `27` + - `hookData` = the recipient strkey, encoded per the [hook data layout](#hook-data-layout) + - `burnToken` = the source chain's USDC token address + - `amount`/`maxFee` in the source chain's 6-decimal USDC units + - `minFinalityThreshold` = `1000` (Fast) or `2000` (Standard) +2. Poll Iris for the attestation. +3. On Stellar, invoke `mint_and_forward(message: Bytes, attestation: Bytes)` on `CctpForwarder`, passing the raw message and attestation bytes from Iris. One atomic invocation validates, mints, and pays the recipient. + +## Iris: polling for the attestation + +``` +GET https://iris-api-sandbox.circle.com/v2/messages/{sourceDomain}?transactionHash={hash} # testnet +GET https://iris-api.circle.com/v2/messages/{sourceDomain}?transactionHash={hash} # mainnet +``` + +Poll until `status` is `"complete"`, then read `message` and `attestation` (both hex). The same hosts also serve the fee schedule — `GET /v2/burn/USDC/fees/{sourceDomain}/{destDomain}` returns the `minimumFee` per finality threshold; use it to set `maxFee` instead of guessing, and to predict the exact minted amount (`amount − feeExecuted`). Field-level gotchas, all verified against live behavior: + +- **Fields holding Stellar payloads return `null`.** In `decodedMessage`/`decodedMessageBody`, the API decodes EVM-address fields normally but returns `null` for fields carrying 32-byte Stellar payloads — on a Stellar→EVM leg, `sender`/`burnToken` come back `null` while `recipient`/`mintRecipient`/`destinationCaller` arrive populated; on an EVM→Stellar leg it's the reverse. The transfer is fine either way; parse the raw `message` hex if you need the Stellar-side addresses. +- **Normalize hex hashes to lowercase, but never touch Solana signatures.** Solana tx signatures are base58 and case-sensitive — lowercasing one makes Iris 404 forever. Only lowercase hashes matching `/^(0x)?[0-9a-fA-F]+$/`. +- `delayReason: "insufficient_fee"` means your `maxFee` didn't cover the Fast tier — the transfer falls back to Standard finality rather than failing. +- Attestation latency is dominated by source-chain finality: seconds on fast-finality chains, up to ~15 minutes for Standard transfers from Ethereum-derived chains. `finalityThreshold` 1000 (Fast) vs 2000 (Standard) trades fee for speed. + +## Worked reference implementation + +The best way to see all of this run is [**ElliotFriend/stellar-cctp-demo**](https://github.com/ElliotFriend/stellar-cctp-demo) — 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. + +Worth studying in the source: + +| What | Where | +|---|---| +| Hook-data encoding ("the most important code in the repo" — get it wrong and funds are lost) | `src/lib/evm/cctp.ts` | +| Stellar-side burns: direct, wrapper, wrapper-with-hook | `src/lib/stellar/cctp.ts` | +| The one-signature Soroban wrapper | `contracts/stellar/cctp-wrapper/` | +| Iris polling with the hash-normalization gotcha | `src/lib/circle/iris.ts` | +| Chain/domain/address registry | `src/lib/config.ts` | +| EVM-side UX ladder: 2-tx approve, 1-tx EIP-2612 permit wrapper, 1-click EIP-5792 `wallet_sendCalls` | `src/lib/evm/cctp.ts`, `contracts/evm/cctp-wrapper/` | + +To run it: `pnpm install && pnpm run dev`, Freighter on Stellar Testnet + any injected EVM wallet, testnet USDC from [faucet.circle.com](https://faucet.circle.com), testnet XLM from [Stellar Lab](https://lab.stellar.org/account/fund). It defaults to Arc (Circle's L1) because attestation takes seconds there and gas is paid in USDC — the fastest feedback loop for learning the protocol. + +## Limitations and status notes + +- **USDC only** on Stellar CCTP today. Across CCTP as a whole, USDC is supported on every domain except BNB Smart Chain, and the only other supported token is USYC (Ethereum and BNB only) — EURC is not a CCTP token anywhere. Check [Circle's supported-tokens table](https://developers.circle.com/cctp/concepts/supported-chains-and-domains#supported-tokens) before promising an asset. +- `TokenMessengerMinter` exposes `handle_receive_unfinalized_message` (Fast Burn) at the interface level; check Circle's current Fast-transfer support matrix for Stellar before relying on sub-finality mints. +- Domains are assigned by Circle ([supported chains and domains](https://developers.circle.com/cctp/concepts/supported-chains-and-domains)); Stellar is 27, and new chains appear regularly.