diff --git a/README.md b/README.md index bf681bf..b544adb 100644 --- a/README.md +++ b/README.md @@ -79,12 +79,12 @@ Copy the `skills/` directory contents to your assistant's skills location. skills/ ├── smart-contracts/ # Stellar smart contracts — SKILL.md router + development/testing/security files ├── dapp/ # Frontend — SKILL.md router + react / data-fetching / smart-accounts files -├── assets/SKILL.md # Stellar Assets, trustlines, SAC bridge +├── assets/ # Stellar Assets, trustlines, SAC bridge — SKILL.md + pre-listing file ├── data/ # Stellar RPC (preferred) — SKILL.md router + horizon (legacy) file ├── agentic-payments/ # AI/machine payments — SKILL.md router + x402 / mpp files ├── zk-proofs/SKILL.md # ZK verification (BLS12-381/BN254 Groth16, UltraHonk), Circom/Noir/RISC Zero ├── standards/ # SEPs & CAPs — SKILL.md router + ecosystem / resources files -└── cross-chain/ # Cross-chain — SKILL.md router + cctp file +└── cross-chain/ # Cross-chain — SKILL.md router + cctp / axelar / layerzero files ``` Each sub-skill is a self-contained Agent Skill with its own frontmatter. Larger skills follow [Anthropic's progressive-disclosure guidance](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices): a sub-500-line `SKILL.md` router with a task-to-file table, plus companion files (one level deep) that load only when the task needs them. Cross-references link related skills (e.g., the `agentic-payments` skill points to `smart-contracts` for the SACs the protocols call, and to `assets` for USDC). The AI reads only the files relevant to the task at hand. @@ -118,7 +118,7 @@ Contributions are welcome! Please ensure any updates reflect current Stellar eco ## Evaluations -[`evals/`](evals/README.md) holds ~3 task scenarios for each of seven skills (plus cross-skill routing checks and a negative control), each encoding a mistake agents actually make without the skill. Three grading tiers: machine-checkable compile checks, LLM-judged behavior assertions, and skill-trigger checks. Two gaps are open: `cross-chain` has no scenarios yet, and no baseline transcripts are committed, so the set is still unvalidated. See [evals/README.md](evals/README.md) for the format, how to run them, and what the missing baselines mean. +[`evals/`](evals/README.md) holds ~3 task scenarios for each of the eight skills (plus cross-skill routing checks and a negative control), each encoding a mistake agents actually make without the skill. Three grading tiers: machine-checkable compile checks, LLM-judged behavior assertions, and skill-trigger checks. One gap is open: no baseline transcripts are committed, so the set is still unvalidated. See [evals/README.md](evals/README.md) for the format, how to run them, and what the missing baselines mean. ## Resources diff --git a/evals/README.md b/evals/README.md index be01c37..90d4896 100644 --- a/evals/README.md +++ b/evals/README.md @@ -1,6 +1,6 @@ # Skill Evaluations -Representative task scenarios for seven of the eight skills in this repo, following [Anthropic's evaluation-driven skill authoring guidance](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices#evaluation-and-iteration). `cross-chain` landed after this set was written and has no scenarios yet. Each scenario encodes a mistake an agent actually makes *without* the skill — several come from real failure modes (the #41 compile bugs, documented pitfalls in agentic-payments, the ZK curve trap), not imagined ones. Run them before publishing skill changes so regressions get caught here instead of by users. +Representative task scenarios for all eight skills in this repo, following [Anthropic's evaluation-driven skill authoring guidance](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices#evaluation-and-iteration). `cross-chain` landed after this set was written; its scenarios arrived with the USDT0 rail. Each scenario encodes a mistake an agent actually makes *without* the skill — several come from real failure modes (the #41 compile bugs, documented pitfalls in agentic-payments, the ZK curve trap), not imagined ones. Run them before publishing skill changes so regressions get caught here instead of by users. ## Scenario format diff --git a/evals/scenarios/assets/04-sac-admin-handover.json b/evals/scenarios/assets/04-sac-admin-handover.json new file mode 100644 index 0000000..8842409 --- /dev/null +++ b/evals/scenarios/assets/04-sac-admin-handover.json @@ -0,0 +1,13 @@ +{ + "skills": [ + "assets" + ], + "query": "We want our Stellar token minted only by our contract, and the issuer account locked. What is the order of operations?", + "expected_behavior": [ + "Calls `set_admin` on the SAC first, while the issuer can still sign, because the issuer is the SAC's initial admin", + "Locks the issuer (master key weight 0) only after that handover", + "Warns that locking the issuer first strands the SAC admin at the locked issuer forever, since nobody can authorize the first `set_admin`", + "Keeps the two controls apart: the issuer lock stops classic minting, the SAC admin decides who mints through the contract", + "Does not claim a locked issuer disables the SAC's `mint` under the new admin" + ] +} diff --git a/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json b/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json new file mode 100644 index 0000000..0519f61 --- /dev/null +++ b/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json @@ -0,0 +1,15 @@ +{ + "skills": [ + "cross-chain" + ], + "query": "Our users hold USDT on Arbitrum. Let them move it into their Stellar accounts.", + "expected_behavior": [ + "Routes to USDT0 over LayerZero's OFT rail, not CCTP (which carries USDC only) and not a hand-rolled wrapped-asset bridge", + "Requires an account (`G…`) recipient's USDT0 trustline to exist before anything inbound is sent, and does not impose that trustline on a contract (`C…`) recipient, whose SAC balance lives in contract storage", + "Requires a `C…` recipient to be deployed before the source chain sends, because `resolve_address` falls back to a `G…` account when no contract with those 32 bytes exists, and that account can never be given a trustline", + "Checks the recipient contract's instance TTL as well, because delivery is asynchronous and an archived instance takes the same account fallback", + "Pins the asset by code and issuer and derives the SAC with `stellar contract id asset` instead of copying an address from a website", + "Names Stellar's LayerZero endpoint ID 30600 and encodes the recipient as the decoded 32-byte strkey payload (Ed25519 public key for `G…`, contract ID hash for `C…`), not the strkey string and not with its version byte or checksum", + "Does not treat the issuer's missing home_domain / stellar.toml as evidence the asset is fake" + ] +} diff --git a/evals/scenarios/cross-chain/02-usdt0-send-loses-digit.json b/evals/scenarios/cross-chain/02-usdt0-send-loses-digit.json new file mode 100644 index 0000000..96c7b1b --- /dev/null +++ b/evals/scenarios/cross-chain/02-usdt0-send-loses-digit.json @@ -0,0 +1,13 @@ +{ + "skills": [ + "cross-chain" + ], + "query": "When I send USDT0 off Stellar the last digit of my amount disappears. What is going wrong?", + "expected_behavior": [ + "Explains the SAC's 7 local decimals against the OFT's shared_decimals of 6, so decimal_conversion_rate is 10 and the remainder below 6 decimals cannot cross", + "Says nothing is broken: the OFT removes that remainder before it builds the message", + "Makes the remainder's destination conditional on the route's OFT fee — it stays with the sender when effective_fee_bps(dst_eid) is 0, and is absorbed into the fee when the rate is above 0", + "Tells the user to trust quote_oft's OFTReceipt (amount_sent_ld and amount_received_ld) over their own input amount", + "Does not propose scaling the amount with floating-point arithmetic" + ] +} diff --git a/evals/scenarios/cross-chain/03-usdt0-testnet-rehearsal.json b/evals/scenarios/cross-chain/03-usdt0-testnet-rehearsal.json new file mode 100644 index 0000000..cf04abb --- /dev/null +++ b/evals/scenarios/cross-chain/03-usdt0-testnet-rehearsal.json @@ -0,0 +1,12 @@ +{ + "skills": [ + "cross-chain" + ], + "query": "Before we ship, rehearse our USDT0 bridge integration end to end on Stellar testnet.", + "expected_behavior": [ + "States that USDT0 publishes no Stellar testnet deployment, so a USDT0 testnet round trip is not available", + "Offers the real alternatives: read-only quote_oft and quote_send simulation, then a dust-sized mainnet transfer", + "Mentions that LayerZero's own testnet endpoint (EID 40600) can carry your own OApp or OFT, and says to confirm one delivered testnet message before trusting it as a rehearsal path", + "Does not invent a USDT0 testnet contract address or claim a testnet deployment exists" + ] +} diff --git a/evals/scenarios/cross-chain/04-usdt0-stellar-to-ethereum.json b/evals/scenarios/cross-chain/04-usdt0-stellar-to-ethereum.json new file mode 100644 index 0000000..bb81f16 --- /dev/null +++ b/evals/scenarios/cross-chain/04-usdt0-stellar-to-ethereum.json @@ -0,0 +1,15 @@ +{ + "skills": [ + "cross-chain" + ], + "query": "Write up what happens to the tokens when our treasury moves USDT0 from Stellar to Ethereum, and then back.", + "expected_behavior": [ + "Does not describe the route as burn-and-mint on both ends: the OFT mode is per leg", + "Says the Stellar leg is a MintBurn OFT, so `send` burns on the SAC and an inbound message mints through the SAC manager", + "Says the Ethereum leg is an OFT Adapter over canonical Tether USDT, so it unlocks USDT on arrival and locks USDT on the way back, with an ERC-20 approval to the adapter first", + "Tells the reader to confirm the mode per route — `oft_type()` on Stellar, and the `OFT` versus `OFT Adapter` entry on USDT0's deployments page", + "Quotes with `quote_oft` and `quote_send` rather than assuming the sent amount equals the received amount", + "Discovers the route with `min_amount_ld` set to 0, because both quotes enforce that floor and panic `SlippageExceeded`, then re-runs `quote_oft` and `quote_send` with the treasury's accepted minimum immediately before `send`", + "Keeps the two units apart: `min_amount_ld` is USDT0 in 7 decimals, while the messaging fee is XLM in stroops" + ] +} diff --git a/evals/scenarios/cross-chain/05-oapp-fee-quote.json b/evals/scenarios/cross-chain/05-oapp-fee-quote.json new file mode 100644 index 0000000..45d080e --- /dev/null +++ b/evals/scenarios/cross-chain/05-oapp-fee-quote.json @@ -0,0 +1,14 @@ +{ + "skills": [ + "cross-chain" + ], + "query": "In our Stellar OApp, how do we quote and pay the LayerZero messaging fee?", + "expected_behavior": [ + "Quotes with `__quote` and passes the returned `MessagingFee` to `__lz_send`, rather than guessing an amount", + "Names both fee fields: `native_fee` in XLM and `zro_fee` in the ZRO token", + "Says `__lz_send` pays ZRO whenever `zro_fee` is not 0", + "Separates the two no-ZRO failures: `__quote` with `pay_in_zro = true` fails in the endpoint with `EndpointError::ZroUnavailable`, while `__lz_send` fails earlier in `__pay_zro` with `OAppError::ZroTokenUnavailable` and never reaches the endpoint", + "Passes `pay_in_zro = false` unless a ZRO token is read on that endpoint", + "Re-quotes per send instead of reusing an earlier fee number" + ] +} diff --git a/evals/scenarios/routing/04-usdt0-collateral.json b/evals/scenarios/routing/04-usdt0-collateral.json new file mode 100644 index 0000000..ae81afa --- /dev/null +++ b/evals/scenarios/routing/04-usdt0-collateral.json @@ -0,0 +1,20 @@ +{ + "skills": [ + "assets", + "cross-chain" + ], + "query": "Is USDT0 safe to accept as collateral in our Stellar lending protocol?", + "expected_behavior": [ + "Uses the assets pre-listing checks for the asset side and cross-chain/layerzero.md for the bridge side", + "Tests the issuer lock by the master key weight (the first byte of the account entry's thresholds), not by the signer list alone, and treats the extra signers' combined weight against the thresholds", + "Reports auth_revocable and auth_clawback_enabled as risks to model: balances can be frozen or clawed back", + "Derives the SAC from the asset rather than trusting a published address, and does not disqualify the asset for having no stellar.toml", + "Goes past oft_type's MintBurn address and enumerates the SAC admin contract's authority: get_existing_roles, then get_role_member for each role, plus get_role_admin and owner", + "Does not treat get_existing_roles as the role list: it returns only roles with a member today, so the answer queries MINTER_ROLE, CLAWBACK_ROLE, BLACKLISTER_ROLE and ADMIN_MANAGER_ROLE by name and reports each one's admin role, because an empty role's admin can still grant it", + "Enumerates the members of every admin role it finds, not only the members of the four roles themselves, and lists those accounts as holders of the authority they can grant", + "Reads the issuer's flags as a bitmask (10 = auth_revocable + auth_clawback_enabled) rather than expecting named boolean fields from the ledger entry", + "Says an empty role is not a safe role, because the owner can grant any role at any time, and models the owner (the OneSig contract) as holding every role", + "Does not stop at 'the owner is a multisig': reads the OneSig's own get_signers and threshold, reports the quorum (3 of 5 on 2026-08-28), and notes the signers are secp256k1 keys that can replace themselves", + "Marks ultimate control unresolved if an owner contract's governance cannot be read, rather than reporting the asset as reviewed" + ] +} diff --git a/site/src/data/skills.ts b/site/src/data/skills.ts index c368684..e26bb16 100644 --- a/site/src/data/skills.ts +++ b/site/src/data/skills.ts @@ -132,9 +132,9 @@ export const SKILL_CARD_SOURCES: readonly SkillCardSource[] = [ { source: "skills/cross-chain/SKILL.md", category: "Cross-Chain", - title: "Cross-Chain (CCTP, Axelar)", + title: "Cross-Chain (CCTP, Axelar, LayerZero)", description: - "Bridge native USDC with Circle CCTP, pass messages and tokens with Axelar GMP/ITS, and route intent-based swaps with NEAR Intents.", + "Bridge native USDC with Circle CCTP and native USDT with USDT0, pass messages and tokens with Axelar GMP/ITS and LayerZero OApp/OFT, and route intent-based swaps with NEAR Intents.", }, ] as const; diff --git a/skills/assets/SKILL.md b/skills/assets/SKILL.md index e32d832..ea6c489 100644 --- a/skills/assets/SKILL.md +++ b/skills/assets/SKILL.md @@ -1,6 +1,6 @@ --- name: assets -description: Stellar Assets (classic) + trustlines + Stellar Asset Contract (SAC) bridge to smart contracts. Covers asset issuance, distribution, authorization flags, clawback, regulated assets, trustline management, and the SAC interop layer that exposes classic assets as SEP-41 contract tokens. Use when tokenizing real-world assets, issuing stablecoins, managing trustlines, or bridging classic assets to smart contracts. +description: Stellar Assets (classic) + trustlines + Stellar Asset Contract (SAC) bridge to smart contracts. Covers asset issuance, distribution, authorization flags, clawback, regulated assets, trustline management, the SAC interop layer that exposes classic assets as SEP-41 contract tokens, and read-only due diligence on an asset before listing it. Use when tokenizing real-world assets, issuing stablecoins, managing trustlines, bridging classic assets to smart contracts, or deciding whether to list, display or accept an asset as collateral. user-invocable: true argument-hint: "[asset task]" --- @@ -15,6 +15,7 @@ Stellar's native token mechanism: classic asset issuance, trustlines, and the St - Managing issuer flags (auth required, auth revocable, clawback) - Bridging a classic asset into a smart contract via SAC - Building regulated-asset flows (compliance, KYC, freeze) +- Reviewing an asset before you list it, display it, or take it as collateral → [pre-listing.md](pre-listing.md) ## Related skills - Custom token contracts (when classic isn't enough) → `../smart-contracts/SKILL.md` @@ -406,6 +407,26 @@ SAC implements the standard SEP-41 token interface: > compatibility of a normal asset (DEX, anchors, wallets, trustlines) while > still layering on custom logic. Prefer a custom contract token only when > you need token behavior the SAC genuinely cannot express. +> +> **This runs in production today.** USDT0 +> (`USDT0:GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q`) is a +> classic asset whose SAC admin is a contract: a role-gated manager that +> forwards `mint`, `clawback`, `set_authorized`, and `set_admin` to the SAC, +> with a cross-chain bridge holding only the minter role. See +> [setting a custom SAC admin](https://developers.stellar.org/docs/build/guides/tokens/custom-sac-admin) +> for the pattern and `../cross-chain/layerzero.md` for that deployment. +> +> One hard prerequisite: **lock the issuer** (master key weight `0`). +> Payments from a classic issuer are minting, so an unlocked issuer can mint +> outside the contract and bypass the role model entirely. +> +> **Do it in this order: `set_admin` first, then lock the issuer.** A SAC's +> admin starts as the issuer account, and only the *current* admin can +> authorize the first `set_admin`. Lock the issuer before that call and the +> admin stays the locked issuer forever, because nobody can sign the handover +> ([SAC admin guide](https://developers.stellar.org/docs/build/guides/tokens/custom-sac-admin)). +> After the handover the SAC still mints under the new admin, so lock the +> issuer then. ### Use SAC When: - Need a Stellar asset inside a smart contract @@ -460,6 +481,27 @@ const stats = await server // - flags: issuer flags ``` +### Pre-Listing Check (read-only) + +Before you list an asset, display it, or accept it as collateral, answer seven +questions from the ledger itself: the issuer's lock and flags, the SAC +derivation, what the contract says it wraps, its admin, any bridge on top, that +admin's roles, and finally whoever controls the admin. Every step simulates +only, and none of them needs a key. + +**The full recipe, with commands and a worked USDT0 example, is in +[pre-listing.md](pre-listing.md).** Read it before you sign off on an asset. + +Two rules decide most reviews: + +- **Walk the chain until it ends at keys.** A locked issuer with a contract + admin is a normal design, not a red flag. It moves the trust question to that + contract's roles, and then to whoever controls that contract. "The owner is a + multisig" is a governance layer, not an answer. +- **Read state, never reputation.** Derive the SAC yourself, decode the issuer's + `flags` bitmask, and treat role membership as live state. A missing + `stellar.toml` is not evidence of a fake asset. + ## SEP Standards for Assets ### SEP-0001 (stellar.toml) @@ -513,3 +555,14 @@ Standard contract interface for NFTs on Stellar. Reference implementations avail - Be cautious of assets with clawback enabled - Verify stellar.toml from authoritative source - Use well-known asset lists for common tokens +- **Some live assets have no `stellar.toml` at all.** A missing `home_domain` + is not evidence of a scam — USDT0 ships without one. Validate by issuer + plus SAC derivation, never by the presence of `home_domain` or a + `[[CURRENCIES]]` entry +- **When `AUTH_REVOCABLE` and `AUTH_CLAWBACK_ENABLED` are both set, check the + issuer lock *and* the admin roles before listing the asset.** Those flags + mean balances can be frozen or clawed back. A contract SAC admin does not + contain that power on its own: an issuer whose master key still signs can + mint, freeze and claw back directly, whatever the admin contract allows. So + confirm the master key weight is `0` (see [pre-listing.md](pre-listing.md)), + then identify who holds the admin contract's roles diff --git a/skills/assets/pre-listing.md b/skills/assets/pre-listing.md new file mode 100644 index 0000000..f12af25 --- /dev/null +++ b/skills/assets/pre-listing.md @@ -0,0 +1,167 @@ +# Pre-Listing Check — read-only asset due diligence + +Before you list an asset, display it, or accept it as collateral, answer seven +questions from the ledger itself. Everything below **simulates only** — +`--send=no` never signs or submits, and no step needs a key. + +The questions walk one chain: the issuer, then the SAC, then the SAC's admin, +then whoever controls that admin. Stop early and you report a governance layer +as if it were an authority. + +USDT0 as the worked example (an asset with a locked issuer, a contract admin, +and no `stellar.toml`): + +```bash +ISSUER=GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q +SAC=CBSJZEIO5C7KC2SF3MKSNXXJSW5G3VTNBX4ATMKUI3B2MR4JKM4R26YF + +# 1. Is the issuer locked, and which flags are set? +# Read: thresholds and signers (see "Step 1" below — the signer list +# alone does not answer this), flags, and whether home_domain exists. +# flags is one number here, not the named booleans Horizon returns: +# 1 auth_required, 2 auth_revocable, 4 auth_immutable, +# 8 auth_clawback_enabled. USDT0's issuer reads 10, so it is revocable +# and clawback-enabled, and auth_immutable is not set. +stellar ledger entry fetch account --account $ISSUER --network mainnet + +# 2. Does the SAC address actually derive from this asset? +# Derive it yourself — never trust a SAC address from a website. +stellar contract id asset --asset USDT0:$ISSUER --network mainnet # must equal $SAC + +# 3. Does the contract agree about what it wraps? +stellar contract invoke --id $SAC --source-account alice \ + --network mainnet --send=no -- name # "USDT0:GATISXX6…" +stellar contract invoke --id $SAC --source-account alice \ + --network mainnet --send=no -- symbol # "USDT0" +stellar contract invoke --id $SAC --source-account alice \ + --network mainnet --send=no -- decimals # 7 for every classic asset + +# 4. Who administers it? The instance entry carries the executable and admin. +stellar ledger entry fetch contract-data --contract $SAC --instance \ + --output json-formatted --network mainnet + +# 5. Is it bridged? Then read the bridge contract too. +# USDT0's LayerZero OFT: does it wrap this SAC, at what precision, in +# which mode, and is it halted right now? +OFT=CBOWOLFSDM5PZXNFIVDMP5NZ7U2GSIHED6H6R446QOHF266XINKUMMF6 + +stellar contract invoke --id $OFT --source-account alice \ + --network mainnet --send=no -- token # must equal $SAC +stellar contract invoke --id $OFT --source-account alice \ + --network mainnet --send=no -- shared_decimals # 6: dust below it is dropped +stellar contract invoke --id $OFT --source-account alice \ + --network mainnet --send=no -- oft_type # MintBurn() +stellar contract invoke --id $OFT --source-account alice \ + --network mainnet --send=no -- endpoint # the LayerZero endpoint +stellar contract invoke --id $OFT --source-account alice \ + --network mainnet --send=no -- is_paused # true stops every transfer + +# 6. Who can mint, freeze, claw back, or move the admin? Ask the admin +# contract from step 4. Roles are live state — read them, don't assume. +MANAGER=CA3GUWLOS3QKN6WNRAELSUDSKLDTVTWEDJ3KLGAJG3SIWGA5L3KZYWGJ + +stellar contract invoke --id $MANAGER --source-account alice \ + --network mainnet --send=no -- owner # grants and revokes any role +stellar contract invoke --id $MANAGER --source-account alice \ + --network mainnet --send=no -- get_existing_roles # roles with >= 1 member + +# get_existing_roles lists only roles that have a member today. Ask about +# the empty ones by name as well, and add anything new the call returned. +# Both views below are safe on an empty role: None, and 0. +for ROLE in MINTER_ROLE CLAWBACK_ROLE BLACKLISTER_ROLE ADMIN_MANAGER_ROLE; do + stellar contract invoke --id $MANAGER --source-account alice \ + --network mainnet --send=no -- get_role_admin --role "$ROLE" + stellar contract invoke --id $MANAGER --source-account alice \ + --network mainnet --send=no -- get_role_member_count --role "$ROLE" +done + +# Every role that get_role_admin named is now part of the list too: its +# members can grant the role it administers, even while that role is empty. +# Walk index 0 .. count-1 for each role that has members. get_role_member +# panics with IndexOutOfBounds past the count. +stellar contract invoke --id $MANAGER --source-account alice \ + --network mainnet --send=no -- get_role_member --role MINTER_ROLE --index 0 + +# 7. Step 6 ends at an owner. If that owner is a contract, it is another +# governance layer, not an answer — read its own quorum too. +# USDT0's owner is a LayerZero OneSig multisig. +ONESIG=CBCZ5CETG3XR5MZVDC7QBDOTIH6P7MOLUH2SSC52J3NVBYIV45D4QKR6 + +stellar contract invoke --id $ONESIG --source-account alice \ + --network mainnet --send=no -- get_signers # 20-byte secp256k1 addresses +stellar contract invoke --id $ONESIG --source-account alice \ + --network mainnet --send=no -- threshold # signatures needed to act + +# Threshold is also in the instance entry, so this command confirms it with +# no ABI. Signers is a separate persistent entry and needs its own key, so +# read it with --key-xdr or from a block explorer. +stellar ledger entry fetch contract-data --contract $ONESIG --instance \ + --output json-formatted --network mainnet +``` + +Reading the results: + +- **Step 1 hinges on the master key, not the signer list.** In the ledger entry, + `thresholds` is a 4-byte hex string and its **first** byte is the master key + weight; the other three are the low, medium and high thresholds. The `signers` + list holds only the *extra* signers, so "every listed signer has weight 0" + proves nothing on its own — an account with a live master key and no extra + signers has an empty `signers` list. Locked means the master weight is `0` + *and* the extra signers cannot reach the medium or high threshold *together*. + Stellar adds up the weights of every signature on a transaction, so test the + sum, not the largest single signer. + USDT0's issuer reads `thresholds` `00000000` with no extra signers. (Horizon differs: + its `/accounts` response folds the master key into its own `signers` array.) +- **`flags` is a bitmask here, not four booleans.** The ledger entry carries the + raw `u32`: `1` auth_required, `2` auth_revocable, `4` auth_immutable, `8` + auth_clawback_enabled. USDT0's issuer reads `10`, which is auth_revocable plus + auth_clawback_enabled. Horizon's `/accounts` names the same four flags for + you, so decode the number or read Horizon — but do not expect named fields + from `stellar ledger entry fetch account`. +- **Step 2 is the identity check**, not the domain. A real SAC's contract + instance has a `stellar_asset` executable rather than a Wasm hash, and its + `name()`/`symbol()` report the wrapped asset. Per the + [SAC docs](https://developers.stellar.org/docs/tokens/stellar-asset-contract#contract-interface), + a contract address and a current `admin` value are not by themselves proof + of provenance — verify the executable first, because `set_admin` can move + administration at any time. +- **A locked issuer plus a contract admin is a deliberate design**, not a red + flag: it is how a classic asset gets programmable minting. But it moves the + trust question to that contract's roles, so identify the role holders. +- **An unlocked issuer with clawback enabled is the actual risk.** The issuer + can then mint and claw back directly, whatever any admin contract says. +- **Step 5 names the bridge's minter, not every minter.** `oft_type` returning + `MintBurn(
)` means that address mints on credit, so it must be the + SAC admin from step 4 or a role holder on it. It does not enumerate the + others — that is step 6. A `shared_decimals` below the SAC's 7 also means + sends drop the extra digits. See `../cross-chain/layerzero.md` for that rail. +- **Step 6 names who can act on the SAC.** Three parties can act on a role, not + one: its **members** (walk `get_role_member` from index `0` to + `get_role_member_count - 1`), the members of its **admin role** if + `get_role_admin` returns one, and the **owner**, always. +- **An empty role is not a safe role.** LayerZero's SAC manager gates `mint`, + `clawback`, `set_authorized` and `set_admin` behind `MINTER_ROLE`, + `CLAWBACK_ROLE`, `BLACKLISTER_ROLE` and `ADMIN_MANAGER_ROLE`. A role with no + members blocks nobody permanently, because the owner fills it in one + transaction. Model the owner as holding every role. +- **`get_existing_roles` is not the role list.** It returns only roles that have + at least one member right now, so an empty `CLAWBACK_ROLE` never appears in + it. A role's admin role is stored separately from its members, and it survives + an empty role: whoever holds that admin role can grant the empty role without + the owner. Iterating the returned list alone therefore hides real authority. + Query all four roles by name, plus any others that call reports, and then + enumerate the members of every admin role you find. Those members belong in + the answer next to the role's own members. +- **Step 7 exists because an owner can be a contract.** "The owner is a + multisig" is not a finding. Walk the chain until it ends at keys, and record + the quorum you found. If the owner is a contract you cannot read — no source, + no view functions, no storage you can decode — then say so and mark ultimate + control **unresolved**. Do not report the asset as reviewed. +- **USDT0 on 2026-08-28**: `get_existing_roles` returns `MINTER_ROLE` only, its + one member is the OFT, it has no admin role, and the owner is the OneSig + contract `CBCZ5CET…`. That OneSig holds **5 signers with a threshold of 3**, + so any 3 of them can mint, claw back, blacklist or move the SAC admin. The + signers are Ethereum-style 20-byte secp256k1 addresses, not Stellar keys, so a + Stellar-only review never sees them. They can also replace themselves: + `set_signer` and `set_threshold` need the same quorum, nobody else. + Re-read all of this — it is live state. diff --git a/skills/cross-chain/SKILL.md b/skills/cross-chain/SKILL.md index 7bd57ae..e48b2b1 100644 --- a/skills/cross-chain/SKILL.md +++ b/skills/cross-chain/SKILL.md @@ -1,6 +1,6 @@ --- 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. +description: Cross-chain interoperability for Stellar. Entry point with a rail-selection decision table and shared pitfalls, routing to three 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), axelar.md (Axelar GMP for Soroban contracts calling contracts on other chains, and the Interchain Token Service for multichain tokens), and layerzero.md (LayerZero V2 OApp messaging with configurable DVN security, OFT omnichain tokens, and USDT0 — native USDT on Stellar). Also covers NEAR Intents (intent-based cross-chain swaps into XLM or Stellar USDC) at the routing level. Use when bridging USDC or USDT 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]" --- @@ -12,15 +12,17 @@ Stellar connects to other blockchains over several production rails, each built | 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) | +| Move **native USDT** between Stellar and an EVM chain | USDT0, a LayerZero OFT | [layerzero.md](layerzero.md#usdt0-native-usdt-on-stellar) | +| Have a Stellar contract **call a contract on another chain**, or receive calls from one (arbitrary payloads) | Axelar GMP or LayerZero OApp | [axelar.md](axelar.md), [layerzero.md](layerzero.md) | +| Make a token — new or an existing Stellar asset — **exist on multiple chains** | Axelar ITS or LayerZero OFT | [axelar.md](axelar.md), [layerzero.md](layerzero.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. +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 the asset is USDT, the answer is USDT0 over LayerZero OFT — a different rail, and one whose shape changes per leg: Stellar burns and mints, while the Ethereum leg locks and unlocks canonical USDT through an OFT Adapter, so check the route's OFT mode before you describe the flow. If you need logic, not just value, on the far chain, that is message passing — two rails do it, and the [comparison at the end of layerzero.md](layerzero.md#choosing-between-axelar-gmp-and-layerzero-oapp) helps you pick between Axelar's shared validator security and LayerZero's app-configured DVN sets. If you control a token and want it multichain, that is ITS or OFT on the same split. 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 +- Bridging USDT between Stellar and an EVM chain (USDT0 over LayerZero OFT) - Receiving bridged USDC into a Stellar account or contract (and not bricking the funds — see the forwarder warning below) - Writing a Stellar smart 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 @@ -39,10 +41,10 @@ Rules of thumb: if the asset is USDC and both ends are CCTP chains, CCTP is the 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 Stellar 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)). +2. **Decimals differ.** Classic Stellar assets and their SACs use 7 decimals, but other Stellar 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)). LayerZero OFTs add a second layer: an OFT declares `shared_decimals` (6 for USDT0) alongside the token's local decimals, and **silently drops the remainder below that precision on send** — quote with `quote_oft` and trust its `OFTReceipt`, not your input amount (see [layerzero.md](layerzero.md#decimals-7-local-6-shared)). 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). +5. **Testnet first, always.** 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). Two rails cannot be rehearsed that way: NEAR Intents is filled by real market makers, and **USDT0 publishes no testnet deployment** even though LayerZero's testnet endpoint exists. For both, the rehearsal path is read-only quotes (`quote_oft` and `quote_send` for USDT0, dry quotes for intents) followed by a dust-sized real transfer. LayerZero also has a testnet endpoint (EID `40600`) you can point your own OApp or OFT at, but confirm one testnet send actually delivers before you rely on it — see the [status notes](layerzero.md#limitations-and-status-notes). ## NEAR Intents (intent-based swaps) diff --git a/skills/cross-chain/layerzero.md b/skills/cross-chain/layerzero.md new file mode 100644 index 0000000..f56dfec --- /dev/null +++ b/skills/cross-chain/layerzero.md @@ -0,0 +1,296 @@ +# LayerZero V2 on Stellar — omnichain messaging, OFT, and USDT0 + +[LayerZero](https://docs.layerzero.network/v2/developers/stellar/overview) is an omnichain messaging protocol whose Stellar endpoint went live on mainnet in July 2026, connecting Stellar to 100+ chains. Its defining trait versus other rails: **security is per-application configurable** — each OApp chooses which DVNs (Decentralized Verifier Networks) must attest to its messages and which executor delivers them. + +The rail carries production traffic today: **USDT0**, Tether's USDT delivered as a LayerZero OFT, is the first production OFT on Stellar's endpoint. If the task is "bridge USDT to or from Stellar", jump to [USDT0](#usdt0-native-usdt-on-stellar) — it is the whole answer. + +> **Status-sensitive.** This is the newest rail on Stellar, and its addresses have already moved once (the testnet endpoint was redeployed during August 2026). Resolve current addresses, DVN availability, and pathway support from LayerZero's [deployed contracts page](https://docs.layerzero.network/v2/deployments/deployed-contracts) — backed by `https://metadata.layerzero-api.com/v1/metadata/deployments`, the canonical machine-readable source — before building. + +## Endpoint and addresses + +Verified against the LayerZero metadata API on 2026-08-27. + +| | Mainnet | Testnet | +|---|---|---| +| Endpoint ID (EID) | `30600` | `40600` | +| `EndpointV2` | `CCQLLRE5JBAWYCW3KTWOIWLMFDUOKROQVZNSALQMGOSXNW3ERUOWTZGK` | `CALTBA5S6GRJEHAXFP45LGGLKWWAF7HTZCPNUBUJF2HWWRRLQNV35AIV` | +| `SendUln302` / `ReceiveUln302` | `CCV4HEII3UC65THWGSRM2DVIJLB6HS6YMUHDTTHUECX2RHTP5FA2GOBA` | `CCMLPCAWCPIIMXOHJJKU3NZLOFTT2O6QTB2UUFPN6SEHLK35QRHVKKMB` | +| `Executor` | `CCEGV7LM6X736RQBPUD4F34HBUUR7OANXLPYUEDQWTPTYX36KSPSAJYM` | `CCAVZ7ESAV3PDJ6PISRCXVZCXFFH4NGI7K7MVMZZDR33LC5AD3UPZATT` | +| `ExecutorHelper` | `CB54JXWG3X77YFAFLYQMRUIMLAEP6XUBBWVQLQYVK2CEOA6PGNQUDRAO` | `CANJCMHRRXEBM46675TSPJIFUVPW7PDOCBLMWS7QPO5TCBHBL7JC4CDL` | + +Two Stellar-specific details in that data: the send and receive ULN 302 libraries **share a single contract address** on Stellar (one contract, both roles), and the remaining components — `Treasury`, `PriceFeed`, `BlockedMessageLib`, `DvnFeeLib`, `ExecutorFeeLib` — resolve from the same metadata entry rather than being hardcoded here. + +**DVNs live on Stellar mainnet** (active, non-deprecated entries): LayerZero Labs, Horizen, Nethermind, Canary, and USDT0. Your OApp's security config selects which of these, and how many, must verify each message. Stellar DVN identifiers in the metadata are 32-byte hex values, not `C…` strkeys — don't try to parse one as an address. + +## USDT0: native USDT on Stellar + +USDT0 is USDT moved over LayerZero's OFT standard. There is no wrapped token and no pool. On Stellar it is a **classic asset** with a locked issuer whose SAC admin is a contract. + +**The debit and credit shape is per leg, not global.** Stellar's deployment is a `MintBurn` OFT: it burns on send and mints on receive. Ethereum's is an **OFT Adapter** (`0x6C96dE32CEa08842dcc4058c14d3aaAD7Fa41dee`) over canonical Tether USDT (`0xdAC17F958D2ee523a2206206994597C13D831ec7`, confirmed by the adapter's `token()`). That leg locks and unlocks the reserve instead: a send to Ethereum unlocks USDT, and a send from Ethereum locks it, so the sender there approves the adapter first. Read the far chain's entry on the [deployments page](https://docs.usdt0.to/technical-documentation/deployments) — it says `OFT` or `OFT Adapter` — and read `oft_type()` on Stellar. Never promise burn-and-mint on both ends of a route. + +| Surface | Address | +|---|---| +| Classic asset | `USDT0:GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q` | +| SAC (the OFT's `token()`) | `CBSJZEIO5C7KC2SF3MKSNXXJSW5G3VTNBX4ATMKUI3B2MR4JKM4R26YF` | +| OFT | `CBOWOLFSDM5PZXNFIVDMP5NZ7U2GSIHED6H6R446QOHF266XINKUMMF6` | +| SAC manager (mint/burn adapter, and the SAC's admin) | `CA3GUWLOS3QKN6WNRAELSUDSKLDTVTWEDJ3KLGAJG3SIWGA5L3KZYWGJ` | +| OneSig (owns the SAC manager) | `CBCZ5CETG3XR5MZVDC7QBDOTIH6P7MOLUH2SSC52J3NVBYIV45D4QKR6` | + +Addresses per [USDT0's deployments page](https://docs.usdt0.to/technical-documentation/deployments), which lists the token, OFT, and OneSig. The SAC matches Horizon's own derivation for the asset, and the SAC manager is the contract the OFT mints through — confirmed from a live inbound transfer on mainnet. USDT0 is part of Everdawn Labs Limited. + +### The rules that save funds + +1. **An account (`G…`) recipient needs a USDT0 trustline before anything inbound lands.** A `G…` account cannot hold an issued asset without one. This is the single most common inbound failure. A contract (`C…`) recipient needs none — but only while that contract exists. SAC balances for contracts live in contract storage, not in a trustline. The OFT decides which one you get from the 32-byte recipient: it resolves to a contract address when a contract with that ID exists, and to a `G…` account otherwise. **Confirm the recipient contract is deployed before the source chain sends** — see below. +2. **Pin the code *and* the issuer.** `USDT0` is a 5-character code (`credit_alphanum12`), and asset code alone identifies nothing. Verify the SAC by derivation — `stellar contract id asset --asset USDT0:GATISXX6… --network mainnet` must return `CBSJZEIO…`. +3. **There is no `stellar.toml`.** The issuer publishes no `home_domain`, so every check keyed on `home_domain` or a SEP-1 `[[CURRENCIES]]` entry fails on a legitimate, live asset. Validate by issuer plus SAC derivation instead. See `../assets/pre-listing.md` for the full pre-listing recipe. +4. **Dust below 6 decimals is dropped on send** — see below. + +### Decimals: 7 local, 6 shared + +The SAC uses Stellar's 7 decimals. The OFT's `shared_decimals()` is **6**, the precision USDT0 uses on every chain. `decimal_conversion_rate()` is therefore `10 ^ (7 - 6)` = **10**, and the OFT removes any remainder before it builds the message. Where that remainder ends up depends on the route's OFT fee, so read `effective_fee_bps(dst_eid)` before you promise a user anything: + +- **No fee on the route** (`effective_fee_bps` is `0`): send `1.0000001` USDT0 (`amount_ld` = `10000001`) and `amount_sent_ld` is `10000000`. The trailing `0.0000001` **stays in your account**. It is not lost, but it is not sent either. +- **A fee on the route** (`effective_fee_bps` above `0`): the OFT debits your full `amount_ld` instead, and the rounded remainder is absorbed into the fee rather than left behind. `amount_sent_ld` is your whole input, and `amount_received_ld` is the dust-adjusted amount after the fee. +- Fee basis points are live configuration (see the table below), so never hardcode the first case. +- Quote first and compare. `quote_oft` returns an `OFTReceipt` whose `amount_sent_ld` and `amount_received_ld` are already dust- and fee-adjusted — treat those, not your input, as the truth. +- Never derive one side from the other with floats, and test with amounts that exercise the seventh digit. + +### Inbound: EVM → Stellar + +1. For a `G…` recipient, the trustline must exist first (rule 1 above). A `C…` recipient needs none, but it must already be deployed on Stellar. +2. Send on the source chain against USDT0's OFT there, with Stellar's EID `30600` and the recipient as its **decoded 32-byte strkey payload** — see below. +3. LayerZero's DVNs verify, then the executor delivers. On Stellar the delivery lands as `ExecutorHelper.execute`, which sub-invokes `lz_receive` on the OFT; the OFT credits through the SAC manager (it holds `MINTER_ROLE`), which mints on the SAC. + +**Encoding the recipient is the fund-critical step.** The message carries a raw 32-byte payload, and the Stellar OFT feeds it straight to `resolve_address` (`oft-core/src/utils.rs`). Decode the strkey and send **only its payload**: the Ed25519 public key for a `G…` account, the contract ID hash for a `C…` contract. Never send the strkey string itself, and never keep its version byte or its 2-byte checksum. Any of those gives the OFT 32 different bytes, which it resolves anyway: the credit either lands on an address you do not control, or fails on a missing trustline and leaves the message undelivered. **Do not copy the CCTP pattern here** — CCTP carries the recipient strkey as UTF-8 hook data ([cctp.md](cctp.md#hook-data-layout)). LayerZero does not. It wants the raw payload: + +```ts +import { StrKey } from "@stellar/stellar-sdk"; + +function stellarRecipientToBytes32(strkey: string): `0x${string}` { + let raw; + if (StrKey.isValidContract(strkey)) { + raw = StrKey.decodeContract(strkey); // C… → contract ID hash + } else if (StrKey.isValidEd25519PublicKey(strkey)) { + raw = StrKey.decodeEd25519PublicKey(strkey); // G… → Ed25519 public key + } else { + throw new Error(`Not a G… or C… address: ${strkey}`); + } + return `0x${Buffer.from(raw).toString("hex")}`; // 32 bytes, no version, no checksum +} +``` + +**A `C…` strkey that parses is not proof the contract is there.** `StrKey.isValidContract` reads the string; `resolve_address` reads the ledger. If that contract is not deployed when the message is delivered, the OFT reads the same 32 bytes as an Ed25519 account and credits a `G…` address instead. Those bytes are a contract ID hash, so no one holds the matching secret key. That account can never sign a `changeTrust`, so it never gets a USDT0 trustline and the delivery keeps failing — while the source chain has already burned or locked the tokens. LayerZero states the assumption in `oft-core/src/utils.rs`: the sender "is expected to deploy the destination contract beforehand". Deploy it first, and confirm it on the ledger immediately before the send: + +```bash +RECIPIENT="CAAAA...ABCD" # the contract you deployed to receive USDT0 + +# An instance entry means the contract exists. An error means do not send. +stellar ledger entry fetch contract-data --contract "$RECIPIENT" --instance \ + --output json-formatted --network mainnet +``` + +Existence is read when the message is delivered, not when you send it. That gap matters twice. Never point a route at a contract you have not deployed yet. And check the instance's remaining TTL in the entry above: an instance that is archived between your send and the delivery is not live state either, so the same `G…` fallback applies. Extend it with `stellar contract extend` and leave margin for source finality, DVN verification, and executor latency. + +Muxed (`M…`) addresses do not fit. An `M…` strkey decodes to 40 bytes — the `G…` key plus an 8-byte id — and the OFT reads 32. Sending the `G…` half works, but the id is gone, so a custodian loses the sub-account it routes on. Give each sub-account its own `G…` account, or credit it from your own records off the `oft_received` event. CCTP differs here: its hook data accepts an `M…` strkey directly. + +Nothing on the Stellar side needs to be signed by the recipient. Watch for the `oft_received` event on the OFT — its topics are `["oft_received", guid, src_eid, to]` and its data carries `amount_received_ld`. + +### Outbound: Stellar → EVM + +The OFT exposes the standard OFT surface. Verified signatures (LayerZero's Stellar OFT crates, and the live mainnet `send` invocation): + +```rust +// oft-core types +pub struct SendParam { + pub dst_eid: u32, // destination endpoint id, e.g. 30101 Ethereum + pub to: BytesN<32>, // EVM address left-padded to 32 bytes + pub amount_ld: i128, // 7-decimal Stellar subunits + pub min_amount_ld: i128, // slippage floor, also 7-decimal + pub extra_options: Bytes, + pub compose_msg: Bytes, // empty unless composing + pub oft_cmd: Bytes, // empty for a plain transfer +} +pub struct OFTLimit { pub min_amount_ld: i128, pub max_amount_ld: i128 } +pub struct OFTReceipt { pub amount_sent_ld: i128, pub amount_received_ld: i128 } + +// The three calls, in order +fn quote_oft(env, from: &Address, send_param: &SendParam) + -> (OFTLimit, Vec, OFTReceipt); +fn quote_send(env, from: &Address, send_param: &SendParam, pay_in_zro: bool) + -> MessagingFee; // { native_fee: i128, zro_fee: i128 } +fn send(env, from: &Address, send_param: &SendParam, fee: &MessagingFee, + refund_address: &Address) -> (MessagingReceipt, OFTReceipt); +``` + +Order matters: `quote_oft` tells you what will actually arrive, `quote_send` prices the message, `send` executes it. The quotes are read-only, and `send` is shown below with `--send=no` so all three simulate. Run them in that order before you ask a user to sign anything: + +```bash +# --send=no simulates. The two quotes never sign; the third command would. +OFT=CBOWOLFSDM5PZXNFIVDMP5NZ7U2GSIHED6H6R446QOHF266XINKUMMF6 +SENDER=$(stellar keys address alice) # the account that pays and signs +EVM_TO=0x1234...abcd # the EVM recipient, 20-byte hex +TO=000000000000000000000000${EVM_TO#0x} # left-padded to 32 bytes + +# Discover with a zero floor. min_amount_ld is enforced by the quotes too, +# so a real floor here hides the answer behind SlippageExceeded. +DISCOVER='{"dst_eid":30101,"to":"'"$TO"'","amount_ld":"10000000","min_amount_ld":"0","extra_options":"","compose_msg":"","oft_cmd":""}' + +# 1. What actually arrives? Returns (OFTLimit, Vec, OFTReceipt). +stellar contract invoke --id "$OFT" --source-account alice \ + --network mainnet --send=no \ + -- quote_oft --from "$SENDER" --send_param "$DISCOVER" + +# 2. Show the user amount_received_ld, get their floor, then rebuild the +# parameter with it. This is the value you send with. +read -r MIN_AMOUNT # the user's floor, in USDT0 7-decimal units +PARAM='{"dst_eid":30101,"to":"'"$TO"'","amount_ld":"10000000","min_amount_ld":"'"$MIN_AMOUNT"'","extra_options":"","compose_msg":"","oft_cmd":""}' + +# 3. Re-quote with that floor. Fee configuration is live, so this is the +# receipt the user signs against. An error here means the route now +# costs more than the floor: go back to the user, never to a lower floor +# you picked yourself. +stellar contract invoke --id "$OFT" --source-account alice \ + --network mainnet --send=no \ + -- quote_oft --from "$SENDER" --send_param "$PARAM" + +# 4. What does the message cost? Returns MessagingFee { native_fee, zro_fee }. +stellar contract invoke --id "$OFT" --source-account alice \ + --network mainnet --send=no \ + -- quote_send --from "$SENDER" --pay_in_zro false --send_param "$PARAM" + +# 5. The send itself. NATIVE_FEE is the stroop figure step 4 returned; quote +# it every time. Drop --send=no only when the user agreed to sign. +read -r NATIVE_FEE # paste the native_fee from step 4 +FEE='{"native_fee":"'"$NATIVE_FEE"'","zro_fee":"0"}' + +stellar contract invoke --id "$OFT" --source-account alice \ + --network mainnet --send=no \ + -- send --from "$SENDER" --send_param "$PARAM" \ + --fee "$FEE" --refund_address "$SENDER" +``` + +**Never discover a route with a real slippage floor.** `quote_oft` and `quote_send` both call `__debit_view`, which asserts `amount_received_ld >= min_amount_ld` and panics `SlippageExceeded` (`oft/src/oft.rs`). A route charging more than your floor then returns an error instead of a receipt, and you cannot tell an expensive route from a broken one. Quote with `0`, show the user what arrives, and re-run both quotes with their floor immediately before `send`. `min_amount_ld` is a USDT0 amount in the token's 7 decimals. The messaging fee is XLM in stroops. They are different units — never carry a number from one into the other. + +**Two fees, two denominations. Do not confuse them.** + +- **The LayerZero messaging fee is XLM.** `quote_send` returns a `MessagingFee`; `native_fee` is XLM in stroops, and `send` transfers it to the endpoint through the native SAC. It tracks the destination route, the DVN set, and executor pricing, so quote every send and never reuse a number from a previous one. `refund_address` receives any excess. +- **The OFT fee, when one is configured, is charged in USDT0 itself.** If `effective_fee_bps(dst_eid)` is above `0`, `send` transfers that share of the token to the OFT's fee deposit address, and `quote_oft` reports it as an `OFTFeeDetail` and a lower `amount_received_ld`. So a sender may need XLM *and* more USDT0 than the amount that arrives. Read `effective_fee_bps(dst_eid)` for the route in hand instead of assuming the rate is zero. + +One transaction does the whole outbound leg: `send` burns `amount_received_ld` on the SAC, transfers any OFT fee, and pays the messaging fee inside a single auth tree, so the sender signs once. + +### State you must read live, never hardcode + +Peers, pause state, fee basis points, and rate limits are configuration. They change without notice, and a stale copy in a prompt is a failed transfer. + +| Question | Call | +|---|---| +| Is this pathway wired up? | `peer(eid)` → `Option>`; `None` means no route | +| Is the OFT halted? | `is_paused()` | +| Is a fee charged on this route? | `default_fee_bps()`, `fee_bps(dst_eid)`, `effective_fee_bps(dst_eid)` | +| Will my amount fit the throttle? | `rate_limit_config(direction, eid)`, `rate_limit_capacity(direction, eid)` — `direction` is `Inbound` or `Outbound` | +| What mode is the OFT in? | `oft_type()` → `MintBurn()` for USDT0; the alternative is `LockUnlock` | + +Storage keys mirror these names (`Peer(eid)`, `FeeBps(eid)`, `RateLimit(direction, eid)`, `EnforcedOptions(eid, msg_type)`), so `stellar ledger entry fetch contract-data --contract "$OFT" --key-xdr "$KEY"` reads them without any invocation at all. + +Pathway coverage grows: mainnet traffic in late August 2026 already spanned Ethereum (`30101`), Polygon (`30109`), Arbitrum (`30110`) and several more in both directions. Resolve the destination with `peer(eid)` for the route the user actually asked for. + +### Tracking a transfer + +[LayerZero Scan](https://layerzeroscan.com) follows a message end to end — source transaction, DVN verification, destination delivery — on both networks. It is the equivalent of polling Iris in the CCTP flow. Timing is driven by source-chain finality plus DVN and executor latency: minutes, in practice. Don't promise a number. + +### Limitations and status notes + +- **No USDT0 testnet deployment.** USDT0's deployments page lists no Stellar testnet entry (checked 2026-08-27), so a testnet round trip of USDT0 itself is not available. The rehearsal path is therefore read-only `quote_oft` and `quote_send` simulation, then a **dust-sized real mainnet transfer** — that transfer is how you prove the flow. Do it before you move a user's balance, and match the ["testnet first" rule](SKILL.md#pitfalls-shared-by-every-rail). +- **A testnet rehearsal is not guaranteed.** You can deploy your own OApp or OFT against the testnet endpoint (EID `40600`), but testnet sends failed with `#1213 UnsupportedMessageLib` in August 2026 — the required DVN did not support the endpoint's only registered message library. That endpoint has been redeployed since, and this file does not record a successful testnet send after it. Send one small testnet message and confirm delivery before you treat testnet as a rehearsal path. +- Fee basis points, rate limits, and the pause flag are live configuration — see the table above. +- Anything deeper on deployment and wiring belongs to the [Stellar OFT docs](https://docs.layerzero.network/v2/developers/stellar/oft/overview) and the [OFT standard](https://docs.layerzero.network/v2/concepts/applications/oft-standard). + +## OApp: the Soroban contract pattern + +Source of truth: [LayerZero-Labs/monorepo-external](https://github.com/LayerZero-Labs/monorepo-external) — the protocol contracts (`contracts/protocol/stellar/`), the OApp packages (`apps/oapp-app/contracts/stellar/`), the OFT and SAC-manager contracts (`apps/oft-app/contracts/stellar/`), and the worked reference at `apps/project-types/omni-counter-app/contracts/stellar/`. There is no Stellar package in the public `LayerZero-v2` repo and no LayerZero Stellar crate on crates.io — work from this monorepo. + +Every import path, argument order, and trait signature below was read out of that monorepo at commit `3f1cf3adadca88aa7a4ee5a7ee251c8b7fefcf2f` (2026-08-26), whose `rust-toolchain.toml` pins Rust `1.90.0` and the `wasm32v1-none` target. Pin the same revision when you copy it, because these crates are not versioned on crates.io. + +It is a skeleton, not a compiled artifact. Signature comparison does not catch macro expansion, feature, dependency, or target errors, and no build was run against this snippet. Compile it yourself for `wasm32v1-none` before you trust it, and confirm `lz_receive` appears in the exported interface. + +```rust +use common_macros::{contract_impl, lz_contract}; +use endpoint_v2::{MessagingFee, Origin}; +use oapp::{ + oapp_core::{init_ownable_oapp, OAppCore}, + oapp_receiver::{LzReceiveInternal, OAppReceiver}, + oapp_sender::{FeePayer, OAppSenderInternal}, +}; +use oapp_macros::oapp; +use soroban_sdk::{Address, Bytes, BytesN, Env}; + +#[lz_contract] +#[oapp] +pub struct MyOApp; + +#[contract_impl] +impl MyOApp { + pub fn __constructor(env: &Env, owner: &Address, endpoint: &Address, delegate: &Address) { + init_ownable_oapp::(env, owner, endpoint, delegate); + } + + // Fee estimation: always quote before sending. + pub fn quote(env: &Env, dst_eid: u32, message: &Bytes, options: &Bytes, pay_in_zro: bool) -> MessagingFee { + Self::__quote(env, dst_eid, message, options, pay_in_zro) + } + + pub fn send(env: &Env, caller: &Address, dst_eid: u32, message: &Bytes, options: &Bytes, fee: &MessagingFee) { + caller.require_auth(); + // FeePayer::Verified marks the caller as already authorized, so the + // send path doesn't trigger a second require_auth in Soroban's auth tree. + Self::__lz_send(env, dst_eid, message, options, &FeePayer::Verified(caller.clone()), fee, caller); + } +} + +impl LzReceiveInternal for MyOApp { + fn __lz_receive( + env: &Env, + origin: &Origin, // src_eid, sender (bytes32), nonce + guid: &BytesN<32>, + message: &Bytes, + _extra_data: &Bytes, + _executor: &Address, + value: i128, + ) { + // Your logic. The generated lz_receive has already validated the peer + // and cleared the payload on the endpoint before this runs. + } +} +``` + +The shape rhymes with Axelar's derive pattern, and the same division of labor applies: + +- The `#[oapp]` macro generates the public surface (`OAppCore`, sender internals, the `lz_receive` entrypoint, options handling). The generated `lz_receive` does peer validation and `endpoint.clear()` **before** dispatching to your `__lz_receive` — don't reimplement either. +- **`custom = [receiver]` is a footgun.** Passing `#[oapp(custom = [receiver])]` tells the macro to *skip* generating the receiver surface; unless you then supply your own `#[contract_impl(contracttrait)] impl OAppReceiver` (as the counter example does, to customize `next_nonce`), the contract **compiles cleanly but exports no `lz_receive` at all** — an OApp that silently cannot receive. Use plain `#[oapp]` unless you're deliberately taking that surface over. +- **Peers must be set on both sides.** `set_peer(&dst_eid, &Some(remote_oapp_bytes32), &caller)` on Stellar, and the mirror call on the destination OApp. A message from an unset peer never reaches `__lz_receive`. +- **Fees are quoted, then paid — in XLM, or in ZRO.** Quote with `__quote(dst_eid, message, options, pay_in_zro)` into a `MessagingFee { native_fee, zro_fee }` and pass that value to `__lz_send`; underquoting fails the send. `__lz_send` pays ZRO whenever `fee.zro_fee` is not `0`. Both steps need a ZRO token set on the endpoint (`zro()` → `Option
`), and **they fail in different places with different errors**: + - `__quote` with `pay_in_zro = true` reaches the endpoint, which panics `EndpointError::ZroUnavailable`. + - `__lz_send` with a non-zero `zro_fee` never reaches the endpoint. `__pay_zro` runs first and panics `OAppError::ZroTokenUnavailable`. + + Don't match on the wrong one. Pass `pay_in_zro = false` unless you read a ZRO token on the endpoint you use. +- **Auth is Soroban-native.** `require_auth()` replaces EVM's `msg.sender` checks throughout, and `FeePayer::{Verified, Unverified}` exists specifically to avoid double-auth in the auth tree. +- The counter example additionally shows **ordered-nonce enforcement** (`origin.nonce` bookkeeping plus the endpoint's `skip`), **composed messages** (`send_compose` for A→B→C flows), and an **ABA round-trip** (receive triggers a send back) — read it before designing anything stateful. + +## OFT: omnichain tokens of your own + +OFT is LayerZero's token standard, the analogue of Axelar's ITS. The Stellar contracts live at `apps/oft-app/contracts/stellar/` in the monorepo, in three pieces worth knowing apart: + +- `oft-core` — the shared logic: decimal conversion, `quote_oft`/`quote_send`/`send`, message building. +- `oft` — the deployable contract, with the `pausable`, `oft_fee`, and `rate_limiter` extensions bolted on. +- `sac-manager` — the piece that makes an **existing classic Stellar asset** work as a MintBurn OFT. It becomes the SAC's admin and forwards `mint`, `clawback`, `set_authorized`, and `set_admin` under role gates (`MINTER_ROLE`, `CLAWBACK_ROLE`, `BLACKLISTER_ROLE`, `ADMIN_MANAGER_ROLE`). This is exactly the USDT0 arrangement. + +One hard prerequisite from the SAC-manager design: **the issuer account must be locked** (master key weight `0`). Payments from a classic issuer are minting, so an unlocked issuer can bypass the role model entirely and the trust story collapses. Lock it *after* `set_admin` hands the SAC to the manager, never before: the issuer is the SAC's first admin, and only it can authorize that first handover. USDT0's issuer is locked. Verify that before you trust any contract-administered classic asset — see `../assets/SKILL.md`. + +## Choosing between Axelar GMP and LayerZero OApp + +Both move arbitrary payloads between Stellar contracts and other chains; neither is strictly better. + +- **Security model**: Axelar messages are verified by its proof-of-stake validator network — one shared model for everyone. LayerZero lets each application pick its own DVN set — more control, and more responsibility (a weak DVN config is your problem). +- **Token standard**: existing Stellar assets connect via Axelar's canonical ITS registration, or via an OFT with a SAC manager as above. New multichain tokens work well in either. +- **Track record on Stellar**: Axelar's Stellar contracts have been live longer; LayerZero's endpoint arrived in July 2026 but now carries production USDT0 traffic. Coverage differs too — check each protocol's chain list for the chains you actually need. +- **Ecosystem gravity**: if your team already runs OApps or ITS integrations elsewhere, staying on that stack usually beats mixing rails.