From 84196c01f8529f38e0896963667f2e219a45b1f9 Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Thu, 27 Aug 2026 18:01:53 +0000 Subject: [PATCH 01/14] Add USDT0 (LayerZero OFT) to the cross-chain and assets skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores layerzero.md, pulled in 631db31 because the rail could not be verified end to end, and anchors it on USDT0 — now live on the mainnet endpoint and the first production OFT on Stellar. Every address and signature re-checked on 2026-08-27: - Endpoint components from the LayerZero metadata API. The testnet endpoint moved since the original file (CBQOTWFU -> CALTBA5S), so the stale value is replaced. - USDT0's classic asset, SAC, OFT, SAC manager and OneSig against Horizon, the ledger, and USDT0's deployments page. - The OFT surface (SendParam, quote_oft, quote_send, send, OFTReceipt) against LayerZero's Stellar crates and a live mainnet send. - The OApp skeleton's import paths and __lz_send argument order against monorepo-external at HEAD. The pathway list from the issue is deliberately not copied in: mainnet traffic already spans more EIDs than it names. The file teaches peer(eid) instead, alongside is_paused, fee_bps and rate_limit_config as live reads. assets gains the other half of the story: USDT0 as the live example of a contract-administered classic asset, why the issuer must be locked for that model to hold, that a missing stellar.toml is not a red flag, and a read-only pre-listing recipe that verifies an asset from the ledger. --- site/src/data/skills.ts | 4 +- skills/assets/SKILL.md | 74 +++++++++++ skills/cross-chain/SKILL.md | 14 ++- skills/cross-chain/layerzero.md | 212 ++++++++++++++++++++++++++++++++ 4 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 skills/cross-chain/layerzero.md diff --git a/site/src/data/skills.ts b/site/src/data/skills.ts index 8eb8f26..0551aca 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..5c2deff 100644 --- a/skills/assets/SKILL.md +++ b/skills/assets/SKILL.md @@ -406,6 +406,19 @@ 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`) before +> handing administration to a contract. Payments from a classic issuer are +> minting, so an unlocked issuer can mint outside the contract and bypass +> the role model entirely. ### Use SAC When: - Need a Stellar asset inside a smart contract @@ -460,6 +473,59 @@ 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 four +questions from the ledger itself. Everything below **simulates only** — +`--send=no` never signs or submits, and no step needs a key. + +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? +# Look for: signers all weight 0 (locked), auth_revocable, +# auth_clawback_enabled, auth_immutable, and whether home_domain exists. +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 +``` + +Reading the results: + +- **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. +- If the asset is bridged, read the bridge contract too — for a LayerZero + OFT: `token`, `shared_decimals`, `oft_type`, `endpoint`, and `is_paused`. + See `../cross-chain/layerzero.md`. + ## SEP Standards for Assets ### SEP-0001 (stellar.toml) @@ -513,3 +579,11 @@ 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, find out + who holds the admin role before listing the asset.** Those flags mean + balances can be frozen or clawed back, and if the SAC admin is a contract + the real authority is whoever holds its roles — not the issuer account diff --git a/skills/cross-chain/SKILL.md b/skills/cross-chain/SKILL.md index 7bd57ae..ed3702e 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 — same burn-and-mint shape, different rail. 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 — and for LayerZero specifically, you can still exercise your own OApp or OFT on testnet EID `40600` before trusting the mainnet path. ## 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..e4603ae --- /dev/null +++ b/skills/cross-chain/layerzero.md @@ -0,0 +1,212 @@ +# 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 by burn-and-mint over LayerZero's OFT standard. There is no wrapped token and no pool: the OFT burns on the source chain and mints on the destination. On Stellar it is a **classic asset** with a locked issuer whose SAC admin is a contract. + +| 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. **The 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. +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/SKILL.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: + +- Send `1.0000001` USDT0 (`amount_ld` = `10000001`): `amount_sent_ld` is `10000000` and the trailing `0.0000001` **stays in your account**. It is not lost, but it is not sent either. +- Quote first and compare. `quote_oft` returns an `OFTReceipt` whose `amount_sent_ld` and `amount_received_ld` are already dust-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. The recipient's trustline must exist first (rule 1 above). +2. Send on the source chain against USDT0's OFT there, with Stellar's EID `30600` and the recipient encoded as a 32-byte value. +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. + +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. Both quotes are read-only — simulate them before you ask a user to sign anything: + +```bash +# Read-only. --send=no simulates and never signs or submits. +stellar contract invoke --id CBOWOLFSDM5PZXNFIVDMP5NZ7U2GSIHED6H6R446QOHF266XINKUMMF6 \ + --source-account alice --network mainnet --send=no \ + -- quote_send --from --pay_in_zro false \ + --send_param '{"dst_eid":30101,"to":"<32-byte hex>","amount_ld":"10000000","min_amount_ld":"9950000","extra_options":"","compose_msg":"","oft_cmd":""}' +``` + +**Fees are paid in XLM.** `quote_send` returns a `MessagingFee`; `native_fee` is XLM in stroops, and `send` transfers it to the endpoint through the native SAC. A recent mainnet send of `0.5` USDT0 to Arbitrum cost `4864058` stroops (about `0.486` XLM) — quote it, never assume it. `refund_address` receives any excess. + +One transaction does the whole outbound leg: `send` burns on the SAC and pays the 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 --key-xdr ` 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. Rehearse instead with LayerZero's own testnet endpoint (EID `40600`) and your own OApp or OFT, and keep mainnet USDT0 work to read-only simulation until the flow is proven. +- **Testnet sends were previously blocked** by `#1213 UnsupportedMessageLib` (the required DVN did not support the endpoint's only registered message library, August 2026). The testnet endpoint has been redeployed since, so re-check current status rather than treating that error as permanent. +- 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. + +The skeleton below compiles to `wasm32v1-none` with the full receive surface exported, and every import path and argument order in it was re-checked against the monorepo on 2026-08-27. + +```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 the chain's native token** (XLM on Stellar). Quote with `__quote` into a `MessagingFee` and pass it to `__lz_send`; underquoting fails the send. +- **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. 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. From 90281528856758df265c90f73a36233f39f207f8 Mon Sep 17 00:00:00 2001 From: kaankacar <103106776+kaankacar@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:14:53 +0000 Subject: [PATCH 02/14] Address review: drop unverified claims, add the OFT read steps Remove the observed mainnet fee amount. The issue keeps fee numbers out of the file on purpose, and a single quote anchors estimates. Say what was actually checked against monorepo-external at HEAD (import paths, argument order, trait signatures) instead of claiming the skeleton compiles. The compile is the reader's step. Make the testnet rehearsal conditional. Testnet sends failed with #1213 UnsupportedMessageLib in August 2026 and no successful send is recorded since the endpoint was redeployed. Add the read-only OFT invocations to the pre-listing recipe, so step 5 runs instead of only naming the methods. List all three cross-chain companion files in the README tree. --- README.md | 2 +- skills/assets/SKILL.md | 25 +++++++++++++++++++++---- skills/cross-chain/SKILL.md | 2 +- skills/cross-chain/layerzero.md | 8 ++++---- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index bf681bf..4ecec1b 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ skills/ ├── 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. diff --git a/skills/assets/SKILL.md b/skills/assets/SKILL.md index 5c2deff..bb85e30 100644 --- a/skills/assets/SKILL.md +++ b/skills/assets/SKILL.md @@ -475,7 +475,7 @@ const stats = await server ### Pre-Listing Check (read-only) -Before you list an asset, display it, or accept it as collateral, answer four +Before you list an asset, display it, or accept it as collateral, answer five questions from the ledger itself. Everything below **simulates only** — `--send=no` never signs or submits, and no step needs a key. @@ -506,6 +506,22 @@ stellar contract invoke --id $SAC --source-account alice \ # 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 ``` Reading the results: @@ -522,9 +538,10 @@ Reading the results: 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. -- If the asset is bridged, read the bridge contract too — for a LayerZero - OFT: `token`, `shared_decimals`, `oft_type`, `endpoint`, and `is_paused`. - See `../cross-chain/layerzero.md`. +- **Step 5 tells you who can mint.** `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. A `shared_decimals` below the SAC's 7 also means + sends drop the extra digits. See `../cross-chain/layerzero.md` for that rail. ## SEP Standards for Assets diff --git a/skills/cross-chain/SKILL.md b/skills/cross-chain/SKILL.md index ed3702e..3295911 100644 --- a/skills/cross-chain/SKILL.md +++ b/skills/cross-chain/SKILL.md @@ -44,7 +44,7 @@ These bite regardless of which rail you pick. Each companion file adds rail-spec 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.** 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 — and for LayerZero specifically, you can still exercise your own OApp or OFT on testnet EID `40600` before trusting the mainnet path. +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 index e4603ae..98724ff 100644 --- a/skills/cross-chain/layerzero.md +++ b/skills/cross-chain/layerzero.md @@ -96,7 +96,7 @@ stellar contract invoke --id CBOWOLFSDM5PZXNFIVDMP5NZ7U2GSIHED6H6R446QOHF266XINK --send_param '{"dst_eid":30101,"to":"<32-byte hex>","amount_ld":"10000000","min_amount_ld":"9950000","extra_options":"","compose_msg":"","oft_cmd":""}' ``` -**Fees are paid in XLM.** `quote_send` returns a `MessagingFee`; `native_fee` is XLM in stroops, and `send` transfers it to the endpoint through the native SAC. A recent mainnet send of `0.5` USDT0 to Arbitrum cost `4864058` stroops (about `0.486` XLM) — quote it, never assume it. `refund_address` receives any excess. +**Fees are paid in XLM.** `quote_send` returns a `MessagingFee`; `native_fee` is XLM in stroops, and `send` transfers it to the endpoint through the native SAC. The fee 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. One transaction does the whole outbound leg: `send` burns on the SAC and pays the fee inside a single auth tree, so the sender signs once. @@ -122,8 +122,8 @@ Pathway coverage grows: mainnet traffic in late August 2026 already spanned Ethe ### 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. Rehearse instead with LayerZero's own testnet endpoint (EID `40600`) and your own OApp or OFT, and keep mainnet USDT0 work to read-only simulation until the flow is proven. -- **Testnet sends were previously blocked** by `#1213 UnsupportedMessageLib` (the required DVN did not support the endpoint's only registered message library, August 2026). The testnet endpoint has been redeployed since, so re-check current status rather than treating that error as permanent. +- **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. Keep mainnet USDT0 work to read-only simulation until the flow is proven. +- **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). @@ -131,7 +131,7 @@ Pathway coverage grows: mainnet traffic in late August 2026 already spanned Ethe 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. -The skeleton below compiles to `wasm32v1-none` with the full receive surface exported, and every import path and argument order in it was re-checked against the monorepo on 2026-08-27. +Every import path, argument order, and trait signature below was checked against the monorepo at HEAD on 2026-08-27. It is a skeleton, not a compiled artifact: build 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}; From 525add7e5113184205316f8e4fc1bb2459620b35 Mon Sep 17 00:00:00 2001 From: kaankacar Date: Fri, 28 Aug 2026 17:02:56 +0000 Subject: [PATCH 03/14] fix: separate the two USDT0 fees and tighten the issuer-lock check Copilot's second pass found three real defects. The dust example only held while the route charged no OFT fee. The Stellar OFT keeps the remainder with the sender when effective_fee_bps is 0, and debits the full amount_ld into the fee when it is not, so the example is now conditional. "Fees are paid in XLM" covered only the LayerZero messaging fee. The OFT fee is separate and denominated in the token, so a sender can need both. The pre-listing recipe read the signer list to decide whether an issuer is locked. The ledger entry keeps the master key weight in thresholds and never lists it under signers, so that test passed an issuer with a live master key. Also pin the monorepo revision and toolchain behind the OApp skeleton, and stop claiming oft_type enumerates every minter. --- skills/assets/SKILL.md | 25 +++++++++++++++++++------ skills/cross-chain/layerzero.md | 19 +++++++++++++------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/skills/assets/SKILL.md b/skills/assets/SKILL.md index bb85e30..852d30f 100644 --- a/skills/assets/SKILL.md +++ b/skills/assets/SKILL.md @@ -487,8 +487,9 @@ ISSUER=GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q SAC=CBSJZEIO5C7KC2SF3MKSNXXJSW5G3VTNBX4ATMKUI3B2MR4JKM4R26YF # 1. Is the issuer locked, and which flags are set? -# Look for: signers all weight 0 (locked), auth_revocable, -# auth_clawback_enabled, auth_immutable, and whether home_domain exists. +# Read: thresholds and signers (see "Step 1" below — the signer list +# alone does not answer this), auth_revocable, auth_clawback_enabled, +# auth_immutable, and whether home_domain exists. stellar ledger entry fetch account --account $ISSUER --network mainnet # 2. Does the SAC address actually derive from this asset? @@ -526,6 +527,15 @@ stellar contract invoke --id $OFT --source-account alice \ 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* no remaining signer can reach the medium or high threshold. USDT0's + issuer reads `thresholds` `00000000` with no extra signers. (Horizon differs: + its `/accounts` response folds the master key into its own `signers` array.) - **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 @@ -538,10 +548,13 @@ Reading the results: 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 tells you who can mint.** `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. A `shared_decimals` below the SAC's 7 also means - sends drop the extra digits. See `../cross-chain/layerzero.md` for that rail. +- **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: on LayerZero's SAC manager the owner grants and revokes + `MINTER_ROLE`, so finish the trust question by listing the role holders and + the owner. A `shared_decimals` below the SAC's 7 also means sends drop the + extra digits. See `../cross-chain/layerzero.md` for that rail. ## SEP Standards for Assets diff --git a/skills/cross-chain/layerzero.md b/skills/cross-chain/layerzero.md index 98724ff..f3b310b 100644 --- a/skills/cross-chain/layerzero.md +++ b/skills/cross-chain/layerzero.md @@ -45,10 +45,12 @@ Addresses per [USDT0's deployments page](https://docs.usdt0.to/technical-documen ### 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: +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: -- Send `1.0000001` USDT0 (`amount_ld` = `10000001`): `amount_sent_ld` is `10000000` and the trailing `0.0000001` **stays in your account**. It is not lost, but it is not sent either. -- Quote first and compare. `quote_oft` returns an `OFTReceipt` whose `amount_sent_ld` and `amount_received_ld` are already dust-adjusted — treat those, not your input, as the truth. +- **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 @@ -96,9 +98,12 @@ stellar contract invoke --id CBOWOLFSDM5PZXNFIVDMP5NZ7U2GSIHED6H6R446QOHF266XINK --send_param '{"dst_eid":30101,"to":"<32-byte hex>","amount_ld":"10000000","min_amount_ld":"9950000","extra_options":"","compose_msg":"","oft_cmd":""}' ``` -**Fees are paid in XLM.** `quote_send` returns a `MessagingFee`; `native_fee` is XLM in stroops, and `send` transfers it to the endpoint through the native SAC. The fee 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. +**Two fees, two denominations. Do not confuse them.** -One transaction does the whole outbound leg: `send` burns on the SAC and pays the fee inside a single auth tree, so the sender signs once. +- **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 @@ -131,7 +136,9 @@ Pathway coverage grows: mainnet traffic in late August 2026 already spanned Ethe 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 checked against the monorepo at HEAD on 2026-08-27. It is a skeleton, not a compiled artifact: build it yourself for `wasm32v1-none` before you trust it, and confirm `lz_receive` appears in the exported interface. +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}; From aef2fd8758697575bfb7b24ef10b39db83763072 Mon Sep 17 00:00:00 2001 From: kaankacar Date: Fri, 28 Aug 2026 17:13:03 +0000 Subject: [PATCH 04/14] fix: add the cross-chain evals and the missing OFT command shapes Copilot's third pass caught two gaps against the repo's own rules. The contributing guide asks for a matching scenario under evals/ when a change touches what a skill teaches, and cross-chain had no scenarios at all. Add three for the USDT0 rail (inbound routing, the dust question, the testnet rehearsal) and one cross-skill scenario for the collateral question, then correct both READMEs, which still described cross-chain as uncovered. The outbound section showed a command shape for quote_send only. Add quote_oft and send, all three simulated with --send=no, and replace the placeholder: inside a bash fence that is a stdin redirect, not an argument. Also require the issuer-lock check alongside the role check in the security list. A contract admin does not contain an issuer that can still sign. --- README.md | 2 +- evals/README.md | 2 +- .../01-usdt-arbitrum-to-stellar.json | 13 +++++++ .../02-usdt0-send-loses-digit.json | 13 +++++++ .../03-usdt0-testnet-rehearsal.json | 12 +++++++ .../routing/04-usdt0-collateral.json | 14 ++++++++ skills/assets/SKILL.md | 11 +++--- skills/cross-chain/layerzero.md | 35 +++++++++++++++---- 8 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json create mode 100644 evals/scenarios/cross-chain/02-usdt0-send-loses-digit.json create mode 100644 evals/scenarios/cross-chain/03-usdt0-testnet-rehearsal.json create mode 100644 evals/scenarios/routing/04-usdt0-collateral.json diff --git a/README.md b/README.md index 4ecec1b..c61817d 100644 --- a/README.md +++ b/README.md @@ -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/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..a6db3f0 --- /dev/null +++ b/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json @@ -0,0 +1,13 @@ +{ + "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 the recipient's USDT0 trustline to exist before anything inbound is sent", + "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 Stellar recipient as a 32-byte value", + "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/routing/04-usdt0-collateral.json b/evals/scenarios/routing/04-usdt0-collateral.json new file mode 100644 index 0000000..a924c57 --- /dev/null +++ b/evals/scenarios/routing/04-usdt0-collateral.json @@ -0,0 +1,14 @@ +{ + "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", + "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 to the SAC admin contract's role holders, because the owner can grant MINTER_ROLE" + ] +} diff --git a/skills/assets/SKILL.md b/skills/assets/SKILL.md index 852d30f..fe30dbc 100644 --- a/skills/assets/SKILL.md +++ b/skills/assets/SKILL.md @@ -613,7 +613,10 @@ Standard contract interface for NFTs on Stellar. Reference implementations avail 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, find out - who holds the admin role before listing the asset.** Those flags mean - balances can be frozen or clawed back, and if the SAC admin is a contract - the real authority is whoever holds its roles — not the issuer account +- **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 the pre-listing check above), then + identify who holds the admin contract's roles diff --git a/skills/cross-chain/layerzero.md b/skills/cross-chain/layerzero.md index f3b310b..98e5edf 100644 --- a/skills/cross-chain/layerzero.md +++ b/skills/cross-chain/layerzero.md @@ -88,14 +88,35 @@ 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. Both quotes are read-only — simulate them before you ask a user to sign anything: +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 -# Read-only. --send=no simulates and never signs or submits. -stellar contract invoke --id CBOWOLFSDM5PZXNFIVDMP5NZ7U2GSIHED6H6R446QOHF266XINKUMMF6 \ - --source-account alice --network mainnet --send=no \ - -- quote_send --from --pay_in_zro false \ - --send_param '{"dst_eid":30101,"to":"<32-byte hex>","amount_ld":"10000000","min_amount_ld":"9950000","extra_options":"","compose_msg":"","oft_cmd":""}' +# --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 +PARAM='{"dst_eid":30101,"to":"'"$TO"'","amount_ld":"10000000","min_amount_ld":"9950000","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 "$PARAM" + +# 2. 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" + +# 3. The send itself. NATIVE_FEE is the stroop figure step 2 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 2 +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" ``` **Two fees, two denominations. Do not confuse them.** @@ -117,7 +138,7 @@ Peers, pause state, fee basis points, and rate limits are configuration. They ch | 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 --key-xdr ` reads them without any invocation at all. +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. From 38325cdb7e1abf6863edbd77035e6df1db5b06c6 Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Fri, 28 Aug 2026 17:23:47 +0000 Subject: [PATCH 05/14] fix: scope the USDT0 OFT mode and the trustline rule per leg The Ethereum leg is an OFT Adapter over canonical USDT, so it locks and unlocks rather than burning and minting. Contract recipients hold SAC balances in contract storage and need no trustline. --- .../cross-chain/01-usdt-arbitrum-to-stellar.json | 2 +- .../cross-chain/04-usdt0-stellar-to-ethereum.json | 13 +++++++++++++ skills/cross-chain/SKILL.md | 2 +- skills/cross-chain/layerzero.md | 8 +++++--- 4 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 evals/scenarios/cross-chain/04-usdt0-stellar-to-ethereum.json diff --git a/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json b/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json index a6db3f0..c3ce62c 100644 --- a/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json +++ b/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json @@ -5,7 +5,7 @@ "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 the recipient's USDT0 trustline to exist before anything inbound is sent", + "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", "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 Stellar recipient as a 32-byte value", "Does not treat the issuer's missing home_domain / stellar.toml as evidence the asset is fake" 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..cca3e8d --- /dev/null +++ b/evals/scenarios/cross-chain/04-usdt0-stellar-to-ethereum.json @@ -0,0 +1,13 @@ +{ + "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" + ] +} diff --git a/skills/cross-chain/SKILL.md b/skills/cross-chain/SKILL.md index 3295911..e48b2b1 100644 --- a/skills/cross-chain/SKILL.md +++ b/skills/cross-chain/SKILL.md @@ -17,7 +17,7 @@ Stellar connects to other blockchains over several production rails, each built | 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 the asset is USDT, the answer is USDT0 over LayerZero OFT — same burn-and-mint shape, different rail. 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. +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 diff --git a/skills/cross-chain/layerzero.md b/skills/cross-chain/layerzero.md index 98e5edf..e70b337 100644 --- a/skills/cross-chain/layerzero.md +++ b/skills/cross-chain/layerzero.md @@ -24,7 +24,9 @@ Two Stellar-specific details in that data: the send and receive ULN 302 librarie ## USDT0: native USDT on Stellar -USDT0 is USDT moved by burn-and-mint over LayerZero's OFT standard. There is no wrapped token and no pool: the OFT burns on the source chain and mints on the destination. On Stellar it is a **classic asset** with a locked issuer whose SAC admin is a contract. +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 | |---|---| @@ -38,7 +40,7 @@ Addresses per [USDT0's deployments page](https://docs.usdt0.to/technical-documen ### The rules that save funds -1. **The 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. +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: 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. 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/SKILL.md` for the full pre-listing recipe. 4. **Dust below 6 decimals is dropped on send** — see below. @@ -55,7 +57,7 @@ The SAC uses Stellar's 7 decimals. The OFT's `shared_decimals()` is **6**, the p ### Inbound: EVM → Stellar -1. The recipient's trustline must exist first (rule 1 above). +1. For a `G…` recipient, the trustline must exist first (rule 1 above). A `C…` recipient needs none. 2. Send on the source chain against USDT0's OFT there, with Stellar's EID `30600` and the recipient encoded as a 32-byte value. 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. From 79a81c19d0ca91e66c2cfc6bb75a11bd6dc545a0 Mon Sep 17 00:00:00 2001 From: kaankacar Date: Fri, 28 Aug 2026 17:37:51 +0000 Subject: [PATCH 06/14] fix: order the SAC admin handover and cover the ZRO fee path set_admin must run before the issuer is locked; the issuer is the SAC's first admin and nobody else can authorize that first handover. Also make the OApp fee bullet cover pay_in_zro, and test the issuer lock against the extra signers' combined weight. --- .../assets/04-sac-admin-handover.json | 13 ++++++++++++ .../cross-chain/05-oapp-fee-quote.json | 13 ++++++++++++ .../routing/04-usdt0-collateral.json | 2 +- skills/assets/SKILL.md | 21 +++++++++++++------ skills/cross-chain/layerzero.md | 4 ++-- 5 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 evals/scenarios/assets/04-sac-admin-handover.json create mode 100644 evals/scenarios/cross-chain/05-oapp-fee-quote.json 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/05-oapp-fee-quote.json b/evals/scenarios/cross-chain/05-oapp-fee-quote.json new file mode 100644 index 0000000..67a35d1 --- /dev/null +++ b/evals/scenarios/cross-chain/05-oapp-fee-quote.json @@ -0,0 +1,13 @@ +{ + "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, and that the endpoint fails with `ZroUnavailable` when no ZRO token is set on it", + "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 index a924c57..1c6463d 100644 --- a/evals/scenarios/routing/04-usdt0-collateral.json +++ b/evals/scenarios/routing/04-usdt0-collateral.json @@ -6,7 +6,7 @@ "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", + "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 to the SAC admin contract's role holders, because the owner can grant MINTER_ROLE" diff --git a/skills/assets/SKILL.md b/skills/assets/SKILL.md index fe30dbc..3910ad9 100644 --- a/skills/assets/SKILL.md +++ b/skills/assets/SKILL.md @@ -415,10 +415,17 @@ SAC implements the standard SEP-41 token interface: > [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`) before -> handing administration to a contract. Payments from a classic issuer are -> minting, so an unlocked issuer can mint outside the contract and bypass -> the role model entirely. +> 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 @@ -533,8 +540,10 @@ Reading the results: 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* no remaining signer can reach the medium or high threshold. USDT0's - issuer reads `thresholds` `00000000` with no extra signers. (Horizon differs: + *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.) - **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 diff --git a/skills/cross-chain/layerzero.md b/skills/cross-chain/layerzero.md index e70b337..e1ad12b 100644 --- a/skills/cross-chain/layerzero.md +++ b/skills/cross-chain/layerzero.md @@ -218,7 +218,7 @@ The shape rhymes with Axelar's derive pattern, and the same division of labor ap - 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 the chain's native token** (XLM on Stellar). Quote with `__quote` into a `MessagingFee` and pass it to `__lz_send`; underquoting fails the send. +- **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`. The endpoint rejects that payment unless a ZRO token is set on it (`zro()` → `Option
`, error `ZroUnavailable`). 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. @@ -230,7 +230,7 @@ OFT is LayerZero's token standard, the analogue of Axelar's ITS. The Stellar con - `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. USDT0's issuer is locked. Verify that before you trust any contract-administered classic asset — see `../assets/SKILL.md`. +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 From e8a0fd7daf6553bde3c89a81644631b671592819 Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Fri, 28 Aug 2026 17:51:48 +0000 Subject: [PATCH 07/14] fix: separate the two ZRO failures and pin the recipient encoding __quote with pay_in_zro = true reaches the endpoint and panics EndpointError::ZroUnavailable. __lz_send never gets that far: __pay_zro runs first and panics OAppError::ZroTokenUnavailable. Naming one error for both makes a correct handler look wrong. The inbound recipe said "a 32-byte value", which is ambiguous on the one step that loses funds. Spell out the decode: the Ed25519 public key for a G address, the contract ID hash for a C address, no version byte and no checksum. Point away from the CCTP hook-data pattern, which is the opposite shape. Also finish the pre-listing recipe. Step 5 told the reader to list the admin contract's role holders without showing how. Add step 6 with the read-only RBAC calls, and record that the owner and any role-admin holder can grant a role, so an empty role is not a safe role. Align the testnet note with the router and the rehearsal scenario: the proving step is a dust-sized mainnet transfer, not an open condition. --- .../01-usdt-arbitrum-to-stellar.json | 2 +- .../cross-chain/05-oapp-fee-quote.json | 3 +- .../routing/04-usdt0-collateral.json | 3 +- skills/assets/SKILL.md | 42 ++++++++++++++++--- skills/cross-chain/layerzero.md | 27 ++++++++++-- 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json b/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json index c3ce62c..111cbcb 100644 --- a/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json +++ b/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json @@ -7,7 +7,7 @@ "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", "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 Stellar recipient as a 32-byte value", + "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/05-oapp-fee-quote.json b/evals/scenarios/cross-chain/05-oapp-fee-quote.json index 67a35d1..45d080e 100644 --- a/evals/scenarios/cross-chain/05-oapp-fee-quote.json +++ b/evals/scenarios/cross-chain/05-oapp-fee-quote.json @@ -6,7 +6,8 @@ "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, and that the endpoint fails with `ZroUnavailable` when no ZRO token is set on it", + "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 index 1c6463d..2d22803 100644 --- a/evals/scenarios/routing/04-usdt0-collateral.json +++ b/evals/scenarios/routing/04-usdt0-collateral.json @@ -9,6 +9,7 @@ "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 to the SAC admin contract's role holders, because the owner can grant MINTER_ROLE" + "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", + "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" ] } diff --git a/skills/assets/SKILL.md b/skills/assets/SKILL.md index 3910ad9..0dd9c7a 100644 --- a/skills/assets/SKILL.md +++ b/skills/assets/SKILL.md @@ -482,7 +482,7 @@ const stats = await server ### Pre-Listing Check (read-only) -Before you list an asset, display it, or accept it as collateral, answer five +Before you list an asset, display it, or accept it as collateral, answer six questions from the ledger itself. Everything below **simulates only** — `--send=no` never signs or submits, and no step needs a key. @@ -530,6 +530,24 @@ 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 + +# Then, for every role that list returns: +ROLE=MINTER_ROLE +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 +stellar contract invoke --id $MANAGER --source-account alice \ + --network mainnet --send=no -- get_role_member --role $ROLE --index 0 ``` Reading the results: @@ -560,10 +578,24 @@ Reading the results: - **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: on LayerZero's SAC manager the owner grants and revokes - `MINTER_ROLE`, so finish the trust question by listing the role holders and - the owner. A `shared_decimals` below the SAC's 7 also means sends drop the - extra digits. See `../cross-chain/layerzero.md` for that rail. + 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 is where the trust question actually gets answered.** Three parties + can act, not one: + 1. The **role holders** themselves. Walk `get_role_member` from index `0` to + `get_role_member_count - 1` for each role. + 2. The holders of the **admin role**, if `get_role_admin` returns one. They + grant and revoke that role, so they can grant it to themselves. + 3. The **owner**, always. The owner can grant or revoke any role. + So **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 — the owner fills it in one transaction. + Model the owner as holding every role. + 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…`. So the OneSig signers are the real authority over minting, + clawback and blacklisting. Re-read it — this is live state. ## SEP Standards for Assets diff --git a/skills/cross-chain/layerzero.md b/skills/cross-chain/layerzero.md index e1ad12b..f449f35 100644 --- a/skills/cross-chain/layerzero.md +++ b/skills/cross-chain/layerzero.md @@ -58,9 +58,26 @@ The SAC uses Stellar's 7 decimals. The OFT's `shared_decimals()` is **6**, the p ### Inbound: EVM → Stellar 1. For a `G…` recipient, the trustline must exist first (rule 1 above). A `C…` recipient needs none. -2. Send on the source chain against USDT0's OFT there, with Stellar's EID `30600` and the recipient encoded as a 32-byte value. +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}` { + const raw = StrKey.isValidContract(strkey) + ? StrKey.decodeContract(strkey) // C… → contract ID hash + : StrKey.isValidEd25519PublicKey(strkey) + ? StrKey.decodeEd25519PublicKey(strkey) // G… → Ed25519 public key + : (() => { throw new Error(`Not a G… or C… address: ${strkey}`); })(); + return `0x${Buffer.from(raw).toString("hex")}`; // 32 bytes, no version, no checksum +} +``` + +Muxed (`M…`) addresses have no 32-byte form the OFT can resolve — resolve them to the underlying `G…` account first. + 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 @@ -150,7 +167,7 @@ Pathway coverage grows: mainnet traffic in late August 2026 already spanned Ethe ### 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. Keep mainnet USDT0 work to read-only simulation until the flow is proven. +- **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). @@ -218,7 +235,11 @@ The shape rhymes with Axelar's derive pattern, and the same division of labor ap - 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`. The endpoint rejects that payment unless a ZRO token is set on it (`zro()` → `Option
`, error `ZroUnavailable`). Pass `pay_in_zro = false` unless you read a ZRO token on the endpoint you use. +- **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. From e85680eaeb240f9a57cd8733889848b773752e94 Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Fri, 28 Aug 2026 17:53:11 +0000 Subject: [PATCH 08/14] fix: say what an M address costs on the OFT rail An M strkey decodes to 40 bytes and the OFT reads 32, so the muxed id is dropped and a custodian loses the sub-account it routes on. Say that, give the two workarounds, and note that CCTP hook data takes M directly so the two rails are not confused. --- skills/cross-chain/layerzero.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/cross-chain/layerzero.md b/skills/cross-chain/layerzero.md index f449f35..c9f9ba7 100644 --- a/skills/cross-chain/layerzero.md +++ b/skills/cross-chain/layerzero.md @@ -76,7 +76,7 @@ function stellarRecipientToBytes32(strkey: string): `0x${string}` { } ``` -Muxed (`M…`) addresses have no 32-byte form the OFT can resolve — resolve them to the underlying `G…` account first. +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`. From 8186d4c25eb532d648c153c9f2bc956a4d7af6c0 Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Fri, 28 Aug 2026 17:54:09 +0000 Subject: [PATCH 09/14] style: unfold the recipient decode into plain branches --- skills/cross-chain/layerzero.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/skills/cross-chain/layerzero.md b/skills/cross-chain/layerzero.md index c9f9ba7..33ce025 100644 --- a/skills/cross-chain/layerzero.md +++ b/skills/cross-chain/layerzero.md @@ -67,11 +67,14 @@ The SAC uses Stellar's 7 decimals. The OFT's `shared_decimals()` is **6**, the p import { StrKey } from "@stellar/stellar-sdk"; function stellarRecipientToBytes32(strkey: string): `0x${string}` { - const raw = StrKey.isValidContract(strkey) - ? StrKey.decodeContract(strkey) // C… → contract ID hash - : StrKey.isValidEd25519PublicKey(strkey) - ? StrKey.decodeEd25519PublicKey(strkey) // G… → Ed25519 public key - : (() => { throw new Error(`Not a G… or C… address: ${strkey}`); })(); + 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 } ``` From b03d077ad53b96eb3351e07cb5cfaf3a7feb19a3 Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Fri, 28 Aug 2026 17:54:34 +0000 Subject: [PATCH 10/14] style: flatten the step 6 notes into plain bullets --- skills/assets/SKILL.md | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/skills/assets/SKILL.md b/skills/assets/SKILL.md index 0dd9c7a..0ae6e3a 100644 --- a/skills/assets/SKILL.md +++ b/skills/assets/SKILL.md @@ -581,21 +581,18 @@ Reading the results: 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 is where the trust question actually gets answered.** Three parties - can act, not one: - 1. The **role holders** themselves. Walk `get_role_member` from index `0` to - `get_role_member_count - 1` for each role. - 2. The holders of the **admin role**, if `get_role_admin` returns one. They - grant and revoke that role, so they can grant it to themselves. - 3. The **owner**, always. The owner can grant or revoke any role. - So **an empty role is not a safe role.** LayerZero's SAC manager gates - `mint`, `clawback`, `set_authorized` and `set_admin` behind `MINTER_ROLE`, + 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 — the owner fills it in one transaction. - Model the owner as holding every role. - 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…`. So the OneSig signers are the real authority over minting, - clawback and blacklisting. Re-read it — this is live state. + members blocks nobody permanently, because the owner fills it in one + transaction. Model the owner as holding every role. +- **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…`. So the OneSig signers are the real authority over + minting, clawback and blacklisting. Re-read this — it is live state. ## SEP Standards for Assets From 46b9a05317984145b382d042a1a24dd23dbfc2e3 Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Fri, 28 Aug 2026 18:10:30 +0000 Subject: [PATCH 11/14] =?UTF-8?q?fix:=20require=20a=20deployed=20C?= =?UTF-8?q?=E2=80=A6=20recipient=20and=20resolve=20the=20OneSig=20quorum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A C… recipient only skips the trustline while that contract exists. If it is not deployed at delivery, resolve_address reads the same 32 bytes as an Ed25519 account, and that account can never sign a changeTrust. The pre-listing recipe stopped at 'the owner is a OneSig contract', which names a governance layer rather than an authority. Step 7 reads the owner's own quorum, and says to mark ultimate control unresolved when it cannot be read. --- .../01-usdt-arbitrum-to-stellar.json | 1 + .../routing/04-usdt0-collateral.json | 4 +- skills/assets/SKILL.md | 38 +++++++++++++++---- skills/cross-chain/layerzero.md | 16 +++++++- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json b/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json index 111cbcb..c2020de 100644 --- a/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json +++ b/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json @@ -6,6 +6,7 @@ "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", "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/routing/04-usdt0-collateral.json b/evals/scenarios/routing/04-usdt0-collateral.json index 2d22803..dc720ad 100644 --- a/evals/scenarios/routing/04-usdt0-collateral.json +++ b/evals/scenarios/routing/04-usdt0-collateral.json @@ -10,6 +10,8 @@ "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", - "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" + "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/skills/assets/SKILL.md b/skills/assets/SKILL.md index 0ae6e3a..b6c659c 100644 --- a/skills/assets/SKILL.md +++ b/skills/assets/SKILL.md @@ -482,7 +482,7 @@ const stats = await server ### Pre-Listing Check (read-only) -Before you list an asset, display it, or accept it as collateral, answer six +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. @@ -548,6 +548,21 @@ stellar contract invoke --id $MANAGER --source-account alice \ --network mainnet --send=no -- get_role_member_count --role $ROLE stellar contract invoke --id $MANAGER --source-account alice \ --network mainnet --send=no -- get_role_member --role $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 + +# The same two values are in the ledger entries, with no ABI: +# Threshold in the instance entry, Signers in a persistent one. +stellar ledger entry fetch contract-data --contract $ONESIG --instance \ + --output json-formatted --network mainnet ``` Reading the results: @@ -580,19 +595,28 @@ Reading the results: 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 is where the trust question actually gets answered.** 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. +- **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. +- **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…`. So the OneSig signers are the real authority over - minting, clawback and blacklisting. Re-read this — it is live state. + 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. ## SEP Standards for Assets diff --git a/skills/cross-chain/layerzero.md b/skills/cross-chain/layerzero.md index 33ce025..38c438b 100644 --- a/skills/cross-chain/layerzero.md +++ b/skills/cross-chain/layerzero.md @@ -40,7 +40,7 @@ Addresses per [USDT0's deployments page](https://docs.usdt0.to/technical-documen ### 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: 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. +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/SKILL.md` for the full pre-listing recipe. 4. **Dust below 6 decimals is dropped on send** — see below. @@ -57,7 +57,7 @@ The SAC uses Stellar's 7 decimals. The OFT's `shared_decimals()` is **6**, the p ### Inbound: EVM → Stellar -1. For a `G…` recipient, the trustline must exist first (rule 1 above). A `C…` recipient needs none. +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. @@ -79,6 +79,18 @@ function stellarRecipientToBytes32(strkey: string): `0x${string}` { } ``` +**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. Re-check right before the send, and never point a route at a contract you have not deployed yet. + 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`. From de8ffe10a1c3b56359e70b681c68174553df2d4e Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Fri, 28 Aug 2026 18:15:08 +0000 Subject: [PATCH 12/14] fix: quote with a zero floor and ask about the empty roles by name Both quotes call __debit_view, which asserts amount_received_ld >= min_amount_ld. A discovery quote carrying a real floor therefore panics SlippageExceeded on any route that costs more, and hides the receipt. get_existing_roles returns only roles that have a member today. A role's admin role is stored separately and outlives an empty role, so iterating that list alone misses whoever can grant CLAWBACK_ROLE. --- .../04-usdt0-stellar-to-ethereum.json | 3 ++- .../routing/04-usdt0-collateral.json | 1 + skills/assets/SKILL.md | 26 ++++++++++++++----- skills/cross-chain/layerzero.md | 20 ++++++++++---- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/evals/scenarios/cross-chain/04-usdt0-stellar-to-ethereum.json b/evals/scenarios/cross-chain/04-usdt0-stellar-to-ethereum.json index cca3e8d..d899a49 100644 --- a/evals/scenarios/cross-chain/04-usdt0-stellar-to-ethereum.json +++ b/evals/scenarios/cross-chain/04-usdt0-stellar-to-ethereum.json @@ -8,6 +8,7 @@ "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" + "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-quotes with the treasury's accepted minimum immediately before `send`" ] } diff --git a/evals/scenarios/routing/04-usdt0-collateral.json b/evals/scenarios/routing/04-usdt0-collateral.json index dc720ad..819accc 100644 --- a/evals/scenarios/routing/04-usdt0-collateral.json +++ b/evals/scenarios/routing/04-usdt0-collateral.json @@ -10,6 +10,7 @@ "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", "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/skills/assets/SKILL.md b/skills/assets/SKILL.md index b6c659c..d747e54 100644 --- a/skills/assets/SKILL.md +++ b/skills/assets/SKILL.md @@ -540,14 +540,20 @@ stellar contract invoke --id $MANAGER --source-account alice \ stellar contract invoke --id $MANAGER --source-account alice \ --network mainnet --send=no -- get_existing_roles # roles with >= 1 member -# Then, for every role that list returns: -ROLE=MINTER_ROLE +# 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 + +# Then 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_admin --role $ROLE -stellar contract invoke --id $MANAGER --source-account alice \ - --network mainnet --send=no -- get_role_member_count --role $ROLE -stellar contract invoke --id $MANAGER --source-account alice \ - --network mainnet --send=no -- get_role_member --role $ROLE --index 0 + --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. @@ -604,6 +610,12 @@ Reading the results: `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. - **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, diff --git a/skills/cross-chain/layerzero.md b/skills/cross-chain/layerzero.md index 38c438b..bb25530 100644 --- a/skills/cross-chain/layerzero.md +++ b/skills/cross-chain/layerzero.md @@ -130,21 +130,29 @@ 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 -PARAM='{"dst_eid":30101,"to":"'"$TO"'","amount_ld":"10000000","min_amount_ld":"9950000","extra_options":"","compose_msg":"","oft_cmd":""}' + +# 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 "$PARAM" + -- 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 minimum the user accepts, in stroops +PARAM='{"dst_eid":30101,"to":"'"$TO"'","amount_ld":"10000000","min_amount_ld":"'"$MIN_AMOUNT"'","extra_options":"","compose_msg":"","oft_cmd":""}' -# 2. What does the message cost? Returns MessagingFee { native_fee, zro_fee }. +# 3. 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" -# 3. The send itself. NATIVE_FEE is the stroop figure step 2 returned; quote +# 4. The send itself. NATIVE_FEE is the stroop figure step 3 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 2 +read -r NATIVE_FEE # paste the native_fee from step 3 FEE='{"native_fee":"'"$NATIVE_FEE"'","zro_fee":"0"}' stellar contract invoke --id "$OFT" --source-account alice \ @@ -153,6 +161,8 @@ stellar contract invoke --id "$OFT" --source-account alice \ --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`. + **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. From 48148d793043e60552262b623942044b96e53459 Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Fri, 28 Aug 2026 18:18:12 +0000 Subject: [PATCH 13/14] fix: decode the issuer flags and walk the admin roles' members The raw account entry carries flags as a u32 bitmask, not the four named booleans Horizon returns, so the recipe now decodes it: USDT0's issuer reads 10. An admin role's members can grant the role they administer, so they belong in the answer next to the role's own members. --- .../routing/04-usdt0-collateral.json | 2 ++ skills/assets/SKILL.md | 23 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/evals/scenarios/routing/04-usdt0-collateral.json b/evals/scenarios/routing/04-usdt0-collateral.json index 819accc..ae81afa 100644 --- a/evals/scenarios/routing/04-usdt0-collateral.json +++ b/evals/scenarios/routing/04-usdt0-collateral.json @@ -11,6 +11,8 @@ "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/skills/assets/SKILL.md b/skills/assets/SKILL.md index d747e54..6641625 100644 --- a/skills/assets/SKILL.md +++ b/skills/assets/SKILL.md @@ -495,8 +495,11 @@ 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), auth_revocable, auth_clawback_enabled, -# auth_immutable, and whether home_domain exists. +# 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? @@ -550,8 +553,10 @@ for ROLE in MINTER_ROLE CLAWBACK_ROLE BLACKLISTER_ROLE ADMIN_MANAGER_ROLE; do --network mainnet --send=no -- get_role_member_count --role "$ROLE" done -# Then walk index 0 .. count-1 for each role that has members. -# get_role_member panics with IndexOutOfBounds past the count. +# 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 @@ -584,6 +589,12 @@ Reading the results: 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 @@ -615,7 +626,9 @@ Reading the results: 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. + 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, From 54485c0e3566888110dbe49f18e59773f3a55e3c Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Fri, 28 Aug 2026 18:29:59 +0000 Subject: [PATCH 14/14] refactor: move the pre-listing recipe into a companion file README asks for a SKILL.md under ~500 lines with deep dives in companion files. The pre-listing recipe pushed assets/SKILL.md to 709 lines, so it now lives in assets/pre-listing.md behind a short routed summary. Also from review: re-quote quote_oft with the accepted floor before pricing the message, name min_amount_ld's units (USDT0 7-decimal, not stroops), and check the recipient contract's TTL, because an instance archived before delivery takes the same G-address fallback. --- README.md | 2 +- .../01-usdt-arbitrum-to-stellar.json | 1 + .../04-usdt0-stellar-to-ethereum.json | 3 +- skills/assets/SKILL.md | 177 ++---------------- skills/assets/pre-listing.md | 167 +++++++++++++++++ skills/cross-chain/layerzero.md | 22 ++- 6 files changed, 204 insertions(+), 168 deletions(-) create mode 100644 skills/assets/pre-listing.md diff --git a/README.md b/README.md index c61817d..b544adb 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ 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 diff --git a/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json b/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json index c2020de..0519f61 100644 --- a/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json +++ b/evals/scenarios/cross-chain/01-usdt-arbitrum-to-stellar.json @@ -7,6 +7,7 @@ "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/04-usdt0-stellar-to-ethereum.json b/evals/scenarios/cross-chain/04-usdt0-stellar-to-ethereum.json index d899a49..bb81f16 100644 --- a/evals/scenarios/cross-chain/04-usdt0-stellar-to-ethereum.json +++ b/evals/scenarios/cross-chain/04-usdt0-stellar-to-ethereum.json @@ -9,6 +9,7 @@ "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-quotes with the treasury's accepted minimum immediately before `send`" + "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/skills/assets/SKILL.md b/skills/assets/SKILL.md index 6641625..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` @@ -483,165 +484,23 @@ const stats = await server ### Pre-Listing Check (read-only) 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. +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. -USDT0 as the worked example (an asset with a locked issuer, a contract admin, -and no `stellar.toml`): +**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. -```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 - -# The same two values are in the ledger entries, with no ABI: -# Threshold in the instance entry, Signers in a persistent one. -stellar ledger entry fetch contract-data --contract $ONESIG --instance \ - --output json-formatted --network mainnet -``` +Two rules decide most reviews: -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. +- **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 @@ -705,5 +564,5 @@ Standard contract interface for NFTs on Stellar. Reference implementations avail 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 the pre-listing check above), then - identify who holds the admin contract's roles + 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/layerzero.md b/skills/cross-chain/layerzero.md index bb25530..f56dfec 100644 --- a/skills/cross-chain/layerzero.md +++ b/skills/cross-chain/layerzero.md @@ -42,7 +42,7 @@ Addresses per [USDT0's deployments page](https://docs.usdt0.to/technical-documen 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/SKILL.md` for the full pre-listing recipe. +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 @@ -89,7 +89,7 @@ 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. Re-check right before the send, and never point a route at a contract you have not deployed yet. +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. @@ -142,17 +142,25 @@ stellar contract invoke --id "$OFT" --source-account alice \ # 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 minimum the user accepts, in stroops +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. What does the message cost? Returns MessagingFee { native_fee, zro_fee }. +# 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" -# 4. The send itself. NATIVE_FEE is the stroop figure step 3 returned; quote +# 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 3 +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 \ @@ -161,7 +169,7 @@ stellar contract invoke --id "$OFT" --source-account alice \ --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`. +**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.**