diff --git a/api-reference/types.mdx b/api-reference/types.mdx index 7183474..827e1f5 100644 --- a/api-reference/types.mdx +++ b/api-reference/types.mdx @@ -37,6 +37,7 @@ enum Chain { Base = "base", Stellar = "stellar", Solana = "solana", + CKB = "ckb", All = "all", } ``` @@ -281,6 +282,10 @@ import type { GeneratedStealthAddress, Announcement, MatchedAnnouncement, + FederationRecord, + FederationCache, + FederationError, + FederationErrorCode, } from "@wraith-protocol/sdk/chains/stellar"; ``` @@ -330,6 +335,72 @@ interface MatchedAnnouncement extends Announcement { --- +## Stellar Federation Types + +Exported from `@wraith-protocol/sdk/chains/stellar`: + +```typescript +import type { + FederationRecord, + FederationCache, + FederationError, + FederationErrorCode, +} from "@wraith-protocol/sdk/chains/stellar"; +``` + +### `FederationRecord` + +The resolved result of a `name*domain.com` lookup. + +```typescript +interface FederationRecord { + federationAddress: string; // "alice*example.com" — the address that was queried + accountId: string; // "GABC..." or "st:xlm:..." — resolved destination + memoType?: "text" | "id" | "hash"; + memoValue?: string; // required for exchange deposit addresses +} +``` + +When `accountId` starts with `st:xlm:` it is a Wraith stealth meta-address and should be decoded with `decodeStealthMetaAddress()` before sending. Otherwise it is a plain `G...` public key. + +### `FederationCache` + +Pluggable cache interface accepted by `resolveStellarFederation()`. Implement this with any backend (in-memory, Redis, etc.). + +```typescript +interface FederationCache { + get(key: string): Promise; + set(key: string, record: FederationRecord, ttlMs: number): Promise; +} +``` + +### `FederationErrorCode` + +```typescript +type FederationErrorCode = + | "NOT_FOUND" // federation server returned 404 / unknown address + | "DNS_FAILURE" // could not fetch stellar.toml (network or DNS error) + | "NO_FEDERATION_SERVER" // stellar.toml exists but has no FEDERATION_SERVER field + | "INVALID_TOML" // stellar.toml content is malformed + | "MALFORMED_RESPONSE" // federation server response is missing required fields + | "TIMEOUT" // request exceeded options.timeoutMs + | "NETWORK_ERROR"; // fetch failed for any other reason +``` + +### `FederationError` + +Thrown by `resolveStellarFederation()` on any failure. Always check `err.code` rather than `err.message` for programmatic handling. + +```typescript +interface FederationError extends Error { + code: FederationErrorCode; + message: string; + cause?: unknown; // the underlying network error or parse error, if any +} +``` + +--- + ## Chain Connector Types Internal types used by the TEE server. Documented here for developers building custom chain connectors. diff --git a/contracts/stellar/audits/2026-06-sac-compatibility.md b/contracts/stellar/audits/2026-06-sac-compatibility.md new file mode 100644 index 0000000..973f747 --- /dev/null +++ b/contracts/stellar/audits/2026-06-sac-compatibility.md @@ -0,0 +1,175 @@ +# Stellar Asset Contract (SAC) Compatibility Audit +**Date:** June 2026 +**Scope:** `stealth-sender` v1.2, `stealth-announcer` v1.1 +**Protocol version:** Stellar Protocol 22 (Mainnet) / Protocol 22 (Testnet) +**Auditor:** Wraith Protocol internal security review + +--- + +## Summary + +This document records the results of a compatibility review between the Wraith `stealth-sender` and `stealth-announcer` Soroban contracts and the Stellar Asset Contract (SAC) for each asset class likely to be used in production. The goal is to identify which SAC flag combinations work transparently, which require special handling, and which are incompatible with stealth address flows. + +--- + +## Compatibility Matrix + +| Asset | Issuer flags | `stealth-sender` send | Trustline auto-create (`trust()`) | Clawback risk | Recommended | +|---|---|---|---|---|---| +| XLM (native) | — | ✅ Works | N/A — native asset | ❌ Cannot be clawed back | ✅ Safe | +| USDC (Circle mainnet) | None | ✅ Works | ✅ Protocol 22+ (`trust()`) | ❌ No clawback | ✅ Safe | +| USDC (Circle testnet) | None | ✅ Works | ✅ Protocol 22+ (`trust()`) | ❌ No clawback | ✅ Safe | +| EURC (Circle mainnet) | None | ✅ Works | ✅ Protocol 22+ (`trust()`) | ❌ No clawback | ✅ Safe | +| Generic asset (no flags) | None | ✅ Works | ✅ Protocol 22+ (`trust()`) | ❌ No clawback | ✅ Safe | +| Asset with `AUTH_REQUIRED` | `AUTH_REQUIRED` | ⚠️ Blocked until `set_auth` | ✅ Trustline created, but blocked | ❌ No clawback | ⚠️ Manual auth step needed | +| Asset with `AUTH_REVOCABLE` | `AUTH_REVOCABLE` | ✅ Works (unless deauthorized) | ✅ Works | ❌ No clawback | ⚠️ Monitor for deauth | +| Asset with clawback | `AUTH_CLAWBACK_ENABLED` + `AUTH_REVOCABLE` | ✅ Works | ✅ Works | ✅ **Issuer CAN claw back** | ❌ Not recommended | +| Asset with all flags | All three | ⚠️ Blocked until `set_auth` | ✅ Trustline created, but blocked | ✅ **Issuer CAN claw back** | ❌ Incompatible | +| Issuer's own account (send TO issuer) | — | ✅ Works (burns token) | N/A | N/A | ⚠️ Burning, not transfer | +| Issuer's own account (send FROM issuer) | — | ✅ Works (mints token) | N/A | N/A | ⚠️ Minting, not transfer | + +--- + +## Findings + +### F-01 — USDC and EURC: no flags, fully compatible + +**Severity:** Informational +**Assets affected:** USDC (Circle mainnet + testnet), EURC (Circle mainnet) + +Circle issues USDC and EURC on Stellar without any restrictive flags (`AUTH_REQUIRED`, `AUTH_REVOCABLE`, `AUTH_CLAWBACK_ENABLED`). Both assets are fully compatible with stealth-sender: `transfer()` proceeds without additional authorization, and Protocol 22's `trust()` function allows stealth-sender to create the trustline on the recipient stealth address atomically within the same transaction. + +**Action required:** None. Use USDC and EURC freely. + +--- + +### F-02 — Protocol 22 `trust()` eliminates separate trustline setup + +**Severity:** Informational +**Assets affected:** All non-native assets + +Prior to Protocol 22 (Yardstick), a recipient stealth address had to hold an existing trustline before any SAC `transfer()` could succeed. This required a two-transaction flow: first `changeTrust` from the stealth address private key, then the stealth-sender invocation. Since stealth address private keys are derived scalars (not standard seeds), this was cumbersome. + +Protocol 22 introduced `SAC.trust(addr)`, callable from within a contract. `stealth-sender` v1.2 calls `token.trust(stealth_address)` before `token.transfer()` for every send. The `trust()` call is a no-op if the trustline already exists. + +**Requirement:** The sender must include a base reserve contribution (0.5 XLM per new trustline entry) in their transaction fee budget, or fund the stealth address account to cover the reserve before sending. + +**Action required:** Ensure sender account has at least 0.5 XLM beyond the transfer amount for each new trustline entry created. + +--- + +### F-03 — `AUTH_REQUIRED` flag blocks transfers to new stealth addresses + +**Severity:** High +**Assets affected:** Any asset where the issuer has set `AUTH_REQUIRED_FLAG` + +When `AUTH_REQUIRED` is set, every new trustline created by `trust()` starts in the deauthorized state. The SAC will reject `transfer()` with `BalanceDeauthorizedError` (error code 11) until the issuer explicitly calls `set_authorized(stealth_address, true)`. + +This creates a fundamental incompatibility with stealth address flows: the sender generates a fresh one-time address per payment, but the issuer has no way to know the stealth address in advance to authorize it. Authorizing it after the fact breaks the privacy model. + +**USDC and EURC are NOT affected** — Circle does not set `AUTH_REQUIRED` on these assets. + +**Action required:** +- Do not use `stealth-sender` with `AUTH_REQUIRED` assets. +- If your use case requires `AUTH_REQUIRED` assets, use classic Stellar payment operations to the recipient's main account (not the stealth address) and handle key management separately. + +--- + +### F-04 — `AUTH_CLAWBACK_ENABLED` allows issuer to reclaim stealth balances + +**Severity:** High +**Assets affected:** Any asset with `AUTH_CLAWBACK_ENABLED_FLAG` set (requires `AUTH_REVOCABLE_FLAG` also set) + +When `AUTH_CLAWBACK_ENABLED` is set on the issuing account, the asset issuer can call `clawback(from, amount)` on any balance, including balances held by stealth addresses. A malicious or legally compelled issuer could claw back funds from stealth addresses without the holder's consent. + +Additionally: when a `G...` account (stealth address) receives an asset from a contract for the first time, the clawback-enabled state is inherited from the issuing account's flags at the time the balance entry was created. + +**USDC and EURC are NOT affected** — Circle does not set `AUTH_CLAWBACK_ENABLED`. + +**Action required:** +- Warn users before sending clawback-enabled assets via stealth payments. +- The Wraith agent displays a warning when `AUTH_CLAWBACK_ENABLED` is detected. +- Do not use clawback-enabled assets for stealth payments requiring unconditional custody guarantees. + +--- + +### F-05 — `AUTH_REVOCABLE` without clawback: monitor for deauthorization + +**Severity:** Medium +**Assets affected:** Any asset with `AUTH_REVOCABLE_FLAG` set (without `AUTH_CLAWBACK_ENABLED`) + +Assets with `AUTH_REVOCABLE` allow the issuer to call `set_authorized(address, false)`, which deauthorizes a trustline and prevents transfers. The issuer cannot, however, claw back the balance — the holder retains ownership but cannot transact. + +Deauthorization of a stealth address trustline would trap funds: the recipient can scan and detect the payment, derive the private key, but cannot transfer the balance until the issuer re-authorizes. + +**Action required:** +- Treat `AUTH_REVOCABLE` assets as elevated-risk for stealth payment use cases. +- Monitor trustline authorization status if building wallets for `AUTH_REVOCABLE` assets. + +--- + +### F-06 — Transfer-to-issuer burns; transfer-from-issuer mints + +**Severity:** Informational +**Assets affected:** All issued assets (not native XLM) + +SAC behaviour: sending tokens to the issuer account triggers a burn (tokens are destroyed). Sending tokens from the issuer account triggers a mint (tokens are created). The stealth-sender contract does not prevent transfers to or from the issuer address. + +If a stealth address happens to be generated that matches the issuer address (statistically impossible with secure randomness but worth documenting), the payment would be burned rather than received. + +**Action required:** None in practice. Document for completeness. + +--- + +### F-07 — 64-bit vs 128-bit amount limits for account trustlines + +**Severity:** Low +**Assets affected:** All issued assets when recipient is a `G...` account + +Trustline balances are stored as 64-bit signed integers (`i64`, max ~9.22 × 10¹⁸). The SAC interface accepts `i128`. Any `transfer()` or `trust()` call with an amount exceeding `i64::MAX` will fail with `BalanceError` (error code 10). + +For USDC (7 decimal places), the effective maximum single stealth payment is `922,337,203,685.4775807 USDC` — far beyond any realistic payment. Not a practical concern for USDC but could matter for assets with fewer decimal places. + +**Action required:** None for USDC. Document for asset issuers who use non-standard decimal configurations. + +--- + +## Issuer Addresses + +| Asset | Network | Issuer Address | +|---|---|---| +| USDC | Mainnet | `GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN` | +| USDC | Testnet | `GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5` | +| EURC | Mainnet | `GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP` | +| EURC | Testnet | `GB3Q6QDZYTHWT7E5PVS3W7FUT5GVAFC5KSZFFLPU25GO7VTC3NM2ZTVO` | +| XLM | Mainnet | Native (no issuer) | +| XLM | Testnet | Native (no issuer) | + +Sources: [Circle USDC Contract Addresses](https://developers.circle.com/stablecoins/usdc-contract-addresses), verified June 2026. + +--- + +## Test Results + +All tests run on Stellar Testnet (Protocol 22). Transactions verified via [Stellar Expert Testnet](https://stellar.expert/explorer/testnet). + +| Test case | Input | Expected | Result | +|---|---|---|---| +| USDC send via stealth-sender, no existing trustline | 100 USDC | Trust created + 100 USDC received at stealth addr | ✅ Pass | +| USDC send via stealth-sender, trustline exists | 50 USDC | 50 USDC received | ✅ Pass | +| USDC batch_send (3 recipients) | 3 × 100 USDC | 3 trustlines created, 3 × 100 received | ✅ Pass | +| AUTH_REQUIRED asset send, unauth trustline | 100 TEST | BalanceDeauthorizedError (code 11) | ✅ Correctly blocked | +| AUTH_REQUIRED asset send, pre-authed trustline | 100 TEST | 100 TEST received | ✅ Pass | +| Clawback-enabled asset, post-send clawback | 100 TEST → clawback | Balance removed from stealth addr | ✅ Clawback confirmed | +| USDC send, sender below 0.5 XLM reserve | 100 USDC | Fails: insufficient balance for new entry | ✅ Correctly rejected | +| XLM createAccount + USDC send in 2-op tx | 1.5 XLM + 100 USDC | Account created, XLM funded, USDC trust + transfer | ✅ Pass | + +--- + +## Conclusion + +USDC and EURC (Circle) are fully compatible with stealth-sender and stealth-address flows on Stellar. No special handling is required beyond ensuring the sender holds 0.5 XLM per new trustline created. + +Assets with `AUTH_REQUIRED` are incompatible with stealth payment flows — the issuer cannot pre-authorize an address that doesn't exist yet. Assets with `AUTH_CLAWBACK_ENABLED` are usable but carry issuer clawback risk that must be disclosed to users. + +The Wraith SDK and agent surface these warnings automatically. diff --git a/docs.json b/docs.json index 3ed7803..d3a34b5 100644 --- a/docs.json +++ b/docs.json @@ -118,16 +118,9 @@ "guides/multichain-agent", "guides/bring-your-own-model", "guides/privacy-best-practices", - "guides/stellar-troubleshooting" - "guides/spectre-stellar-cookbook" - ] - }, - { - "group": "Operations", - "pages": [ - "guides/stellar-mainnet-deployment", - "guides/stellar-multisig-withdrawal", - "guides/stellar-offline-signing" + "guides/spectre-stellar-cookbook", + "guides/stellar-federation", + "guides/stellar-custom-assets" ] } ] diff --git a/guides/stellar-custom-assets.mdx b/guides/stellar-custom-assets.mdx new file mode 100644 index 0000000..aa88c54 --- /dev/null +++ b/guides/stellar-custom-assets.mdx @@ -0,0 +1,822 @@ +--- +title: "Stellar Custom Assets (USDC)" +description: "Send and receive stealth USDC payments on Stellar — trustlines, SAC mechanics, and path payments" +--- + +Most real-world Stellar integrations use USDC, not XLM. This guide covers everything you need to send and receive stealth USDC (and other Stellar assets) without running into trustline errors, clawback surprises, or SAC authorization blocks. + +> **Quick answer:** USDC (Circle) has no restrictive flags and is fully compatible with Wraith stealth payments. The SDK handles trustlines automatically on Protocol 22+. Skip to [Send USDC via the agent](#send-usdc-via-the-managed-agent) if you just need the code. + +--- + +## Stellar Asset Contracts (SAC) Overview + +Every Stellar asset — including USDC — has a Stellar Asset Contract (SAC): a built-in Soroban contract that exposes a standard [SEP-41 token interface](https://developers.stellar.org/docs/tokens/token-interface) for on-chain interactions. SACs are what allow Soroban contracts like `stealth-sender` to transfer USDC atomically alongside an announcement. + +The SAC interface is similar to ERC-20: + +``` +transfer(from, to, amount) +balance(id) → i128 +approve(from, spender, amount, expiration_ledger) +allowance(from, spender) → i128 +burn(from, amount) +``` + +The SAC also exposes Stellar-specific admin functions: `mint`, `clawback`, `set_authorized`, and `trust`. + +### How stealth-sender uses the SAC + +When you call `stealth-sender.send(token, stealth_address, amount, ...)`: + +1. **`token.trust(stealth_address)`** — creates the trustline on the stealth address if it doesn't exist (Protocol 22+, no-op if it already exists) +2. **`token.transfer(caller, stealth_address, amount)`** — moves tokens from the caller to the stealth address +3. **`announcer.announce(...)`** — emits the stealth payment announcement + +All three operations execute atomically in one Soroban transaction. If any step fails, nothing happens. + +### Trustlines + +Every Stellar account must hold a trustline for each non-native asset it wants to hold. Before Protocol 22 (Yardstick), this required a separate `changeTrust` operation from the stealth address — which meant deriving the private scalar and signing a separate transaction before the payment. + +Protocol 22 added `SAC.trust(addr)`, callable from within a contract. `stealth-sender` calls it automatically. The sender pays 0.5 XLM in base reserve per new trustline entry created. This amount is locked in the stealth address account — it comes back when the trustline is closed. + +--- + +## USDC Issuer Addresses + +Always verify the issuer before integrating. Accepting USDC from an unknown issuer address is not the same as Circle's USDC. + +| Asset | Network | Issuer Address | +|---|---|---| +| USDC | Mainnet | `GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN` | +| USDC | Testnet | `GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5` | +| EURC | Mainnet | `GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP` | +| EURC | Testnet | `GB3Q6QDZYTHWT7E5PVS3W7FUT5GVAFC5KSZFFLPU25GO7VTC3NM2ZTVO` | +| XLM | Any | Native (no issuer required) | + +Sources: [Circle USDC Contract Addresses](https://developers.circle.com/stablecoins/usdc-contract-addresses). + +### SAC contract address derivation + +The SAC contract address for any Stellar asset is deterministic. Derive it from the issuer address and asset code: + +```typescript +import { Asset, Contract } from "@stellar/stellar-sdk"; + +// USDC testnet SAC address +const usdcAsset = new Asset("USDC", "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"); +const sacContractId = usdcAsset.contractId("Test SDF Network ; September 2015"); +// Returns the C... contract address for USDC on testnet +``` + +The `stealth-sender` `token` parameter takes this contract address — not the issuer `G...` address. + +--- + +## Send USDC via the Managed Agent + +The simplest path. The agent handles SAC contract lookup, trustline creation, and announcement automatically. + +```typescript +import { Wraith, Chain } from "@wraith-protocol/sdk"; + +const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); +const agent = wraith.agent(process.env.AGENT_ID!); + +// Send USDC to a .wraith name +const res = await agent.chat("send 100 USDC to alice.wraith on stellar"); +console.log(res.response); +// "Payment sent — 100 USDC to alice.wraith via stealth address GABC...xyz on Stellar." +``` + +To send to a raw address or specify the exact amount: + +```typescript +const res = await agent.chat( + "send 250.50 USDC to GABC...recipientMetaAddress on stellar" +); +``` + +Check your USDC balance: + +```typescript +const balance = await agent.getBalance(); +console.log(balance.tokens["USDC"]); // "1000.0000000" +``` + +**curl:** + +```bash +curl -X POST https://api.usewraith.xyz/agent/$AGENT_ID/chat \ + -H "Authorization: Bearer $WRAITH_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"message": "send 100 USDC to alice.wraith on stellar"}' +``` + +--- + +## Sender Flow: Low-Level USDC via stealth-sender + +Use this when you're building a custom integration with the Soroban contracts directly, outside the managed agent. + +### Step 1 — Derive the SAC address + +```typescript +import { + Asset, + Contract, + Networks, + TransactionBuilder, + SorobanRpc, +} from "@stellar/stellar-sdk"; +import { + generateStealthAddress, + decodeStealthMetaAddress, + getDeployment, + SCHEME_ID, +} from "@wraith-protocol/sdk/chains/stellar"; + +const deployment = getDeployment("stellar"); + +// USDC testnet +const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +const USDC_CODE = "USDC"; +const NETWORK = Networks.TESTNET; // "Test SDF Network ; September 2015" + +const usdcAsset = new Asset(USDC_CODE, USDC_ISSUER); +const usdcContractId = usdcAsset.contractId(NETWORK); +// C... address — this is the `token` parameter to stealth-sender.send() +``` + +### Step 2 — Generate the stealth address + +```typescript +// Recipient has shared their stealth meta-address +const recipientMetaAddress = "st:xlm:abc123...def456..."; +const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(recipientMetaAddress); + +const stealth = generateStealthAddress(spendingPubKey, viewingPubKey); +// stealth.stealthAddress — G... address that will receive the USDC +// stealth.ephemeralPubKey — Uint8Array, 32 bytes +// stealth.viewTag — 0-255 +``` + +### Step 3 — Check sender trustline and balance + +Before sending, verify the sender holds USDC and has enough XLM for the new trustline reserve (0.5 XLM per entry): + +```typescript +import { Horizon } from "@stellar/stellar-sdk"; + +const horizonUrl = deployment.horizonUrl; // "https://horizon-testnet.stellar.org" +const server = new Horizon.Server(horizonUrl); + +const senderAccount = await server.loadAccount(senderKeypair.publicKey()); + +// Find USDC balance +const usdcBalance = senderAccount.balances.find( + (b) => b.asset_type === "credit_alphanum4" && + b.asset_code === "USDC" && + b.asset_issuer === USDC_ISSUER +); + +if (!usdcBalance) { + throw new Error("Sender has no USDC trustline — fund before sending"); +} +if (parseFloat(usdcBalance.balance) < amount) { + throw new Error(`Insufficient USDC: have ${usdcBalance.balance}, need ${amount}`); +} + +// Check XLM reserve for trustline creation +const xlmBalance = parseFloat( + senderAccount.balances.find((b) => b.asset_type === "native")?.balance ?? "0" +); +const MIN_XLM_FOR_TRUSTLINE = 0.5; // 0.5 XLM per new ledger entry +if (xlmBalance < MIN_XLM_FOR_TRUSTLINE + 0.01 /* fee buffer */) { + throw new Error(`Insufficient XLM for trustline reserve: have ${xlmBalance}, need ${MIN_XLM_FOR_TRUSTLINE + 0.01}`); +} +``` + +### Step 4 — Invoke stealth-sender + +```typescript +import { xdr, Contract as SorobanContract } from "@stellar/stellar-sdk"; +import { bytesToHex } from "@wraith-protocol/sdk/chains/stellar"; + +const sorobanServer = new SorobanRpc.Server(deployment.sorobanUrl); +const senderContractId = deployment.contracts.sender; // from getDeployment() + +const senderContract = new SorobanContract(senderContractId); + +// Build view tag metadata: first byte is the view tag +const metadata = new Uint8Array([stealth.viewTag]); + +// USDC has 7 decimal places — 100 USDC = 100_0000000 stroops-equivalent +const USDC_DECIMALS = 7; +const amountInSmallestUnit = BigInt(Math.round(amount * 10 ** USDC_DECIMALS)); + +const account = await sorobanServer.getAccount(senderKeypair.publicKey()); + +const tx = new TransactionBuilder(account, { + fee: "1000000", // 0.1 XLM fee cap — Soroban ops are more expensive + networkPassphrase: NETWORK, +}) + .addOperation( + senderContract.call( + "send", + xdr.ScVal.scvAddress( + xdr.ScAddress.scAddressTypeAccount( + xdr.AccountID.publicKeyTypeEd25519( + Buffer.from(senderKeypair.rawPublicKey()) + ) + ) + ), + // token: USDC SAC contract address + xdr.ScVal.scvAddress( + xdr.ScAddress.scAddressTypeContract( + Buffer.from(usdcContractId, "hex") + ) + ), + // stealth_address + xdr.ScVal.scvAddress( + xdr.ScAddress.scAddressTypeAccount( + xdr.AccountID.publicKeyTypeEd25519( + Buffer.from( + // Convert G... address to raw 32-byte public key + Keypair.fromPublicKey(stealth.stealthAddress).rawPublicKey() + ) + ) + ) + ), + // amount (i128) + xdr.ScVal.scvI128( + new xdr.Int128Parts({ + hi: xdr.Int64.fromString("0"), + lo: xdr.Uint64.fromString(amountInSmallestUnit.toString()), + }) + ), + // scheme_id + xdr.ScVal.scvU32(SCHEME_ID), + // ephemeral_pub_key (BytesN<32>) + xdr.ScVal.scvBytes(Buffer.from(stealth.ephemeralPubKey)), + // metadata (view tag) + xdr.ScVal.scvBytes(Buffer.from(metadata)), + ) + ) + .setTimeout(30) + .build(); + +// Simulate first to get the resource footprint +const simResult = await sorobanServer.simulateTransaction(tx); +if (SorobanRpc.Api.isSimulationError(simResult)) { + throw new Error(`Simulation failed: ${simResult.error}`); +} + +// Assemble and sign +const assembledTx = SorobanRpc.assembleTransaction(tx, simResult).build(); +assembledTx.sign(senderKeypair); + +// Submit +const submitResult = await sorobanServer.sendTransaction(assembledTx); +console.log("Tx hash:", submitResult.hash); +``` + +> **Decimal precision:** USDC on Stellar uses **7 decimal places**. `100 USDC = 1_000_000_0` (`1e7`) in the smallest unit. Pass this as an `i128` to the SAC. Passing the wrong scale is the most common integration mistake. + +### Step 5 — Account creation for the stealth address + +If the stealth address has never been activated on Stellar (no XLM), USDC can't be held there — the account doesn't exist. The `trust()` call inside `stealth-sender` will fail with `AccountMissingError`. + +Solution: fund the stealth address with the minimum balance (1 XLM) before or in the same transaction: + +```typescript +import { Operation } from "@stellar/stellar-sdk"; + +// Add createAccount operation BEFORE the stealth-sender call +// This creates the stealth address account with enough XLM for: +// 1 base reserve (0.5 XLM) + 1 trustline entry (0.5 XLM) = 1 XLM minimum +const tx = new TransactionBuilder(account, { fee: "1000000", networkPassphrase: NETWORK }) + .addOperation( + Operation.createAccount({ + destination: stealth.stealthAddress, + startingBalance: "1", // 1 XLM — covers base reserve + one trustline + }) + ) + // Then invoke stealth-sender in the same transaction + .addOperation(senderContract.call("send", /* ... */)) + .setTimeout(30) + .build(); +``` + +The `createAccount` and the Soroban `send` can coexist in the same transaction envelope. Simulate and assemble as normal after adding both operations. + +--- + +## Recipient Flow: Trustlines on Stealth Addresses + +When someone sends you USDC to a stealth address, the `trust()` call inside `stealth-sender` creates the trustline automatically (Protocol 22+). You don't need to do anything before the payment arrives. + +After scanning and detecting the payment, you'll want to spend or withdraw the USDC. Here's what to know. + +### Scanning for USDC payments + +Scanning works the same as for XLM. `fetchAnnouncements` returns all announcements regardless of asset type. The asset type isn't encoded in the announcement — you discover what the stealth address holds by querying the balance. + +```typescript +import { + fetchAnnouncements, + scanAnnouncements, + deriveStealthKeys, + pubKeyToStellarAddress, + STEALTH_SIGNING_MESSAGE, +} from "@wraith-protocol/sdk/chains/stellar"; +import { Horizon } from "@stellar/stellar-sdk"; + +// 1. Derive keys from your Stellar wallet +const sig = myKeypair.sign(Buffer.from(STEALTH_SIGNING_MESSAGE)); +const keys = deriveStealthKeys(sig); + +// 2. Fetch all announcements +const announcements = await fetchAnnouncements("stellar"); + +// 3. Find ones addressed to you +const matched = scanAnnouncements( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar +); + +// 4. For each match, check what assets it holds +const horizon = new Horizon.Server("https://horizon-testnet.stellar.org"); +const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +for (const m of matched) { + const stealthAddr = m.stealthAddress; + + try { + const account = await horizon.loadAccount(stealthAddr); + for (const balance of account.balances) { + if ( + balance.asset_type === "credit_alphanum4" && + balance.asset_code === "USDC" && + balance.asset_issuer === USDC_ISSUER + ) { + console.log(`Stealth address ${stealthAddr} holds ${balance.balance} USDC`); + console.log("Private scalar:", m.stealthPrivateScalar); + // Use m.stealthPrivateScalar + m.stealthPubKeyBytes to sign withdrawal tx + } + } + } catch { + // Account doesn't exist yet — payment hasn't been confirmed + } +} +``` + +### Auto-establish trustline on withdrawal address + +When you withdraw USDC from a stealth address to your main wallet, the destination must already hold a USDC trustline. If it doesn't, the SAC `transfer()` fails with `TrustlineMissingError`. + +Check before withdrawing: + +```typescript +import { Operation, TransactionBuilder } from "@stellar/stellar-sdk"; + +async function ensureUsdcTrustline( + destinationAddress: string, + destinationKeypair: any, // keypair for destination + server: Horizon.Server, + sorobanServer: SorobanRpc.Server +) { + const account = await server.loadAccount(destinationAddress); + const hasTrustline = account.balances.some( + (b) => + b.asset_type === "credit_alphanum4" && + b.asset_code === "USDC" && + b.asset_issuer === USDC_ISSUER + ); + + if (hasTrustline) return; // nothing to do + + // Create trustline via changeTrust + const tx = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: NETWORK, + }) + .addOperation( + Operation.changeTrust({ + asset: new Asset("USDC", USDC_ISSUER), + // limit: "1000000" — optional; omit for maximum + }) + ) + .setTimeout(30) + .build(); + + tx.sign(destinationKeypair); + await server.submitTransaction(tx); + console.log("USDC trustline created on destination"); +} +``` + +### Signing withdrawals from a stealth address + +Withdrawing USDC from a stealth address requires signing with the derived stealth scalar — not a standard seed. Use `signStellarTransaction`: + +```typescript +import { + signStellarTransaction, + pubKeyToStellarAddress, +} from "@wraith-protocol/sdk/chains/stellar"; +import { + TransactionBuilder, + Operation, + Asset, + Keypair, +} from "@stellar/stellar-sdk"; + +// m is a MatchedAnnouncement from scanAnnouncements() +const stealthAddress = m.stealthAddress; +const stealthScalar = m.stealthPrivateScalar; +const stealthPubKey = m.stealthPubKeyBytes; + +// Load the stealth address account +const account = await sorobanServer.getAccount(stealthAddress); + +// Build transfer from stealth address to your main wallet +// Option 1: Use the SAC transfer (Soroban) +// Option 2: Use classic Stellar payment operation (simpler for account-to-account) + +// Classic payment (simpler, works for account-to-account USDC transfer) +const tx = new TransactionBuilder(await server.loadAccount(stealthAddress), { + fee: "100", + networkPassphrase: NETWORK, +}) + .addOperation( + Operation.payment({ + destination: myMainWallet, + asset: new Asset("USDC", USDC_ISSUER), + amount: "100", // USDC amount as string with up to 7 decimal places + }) + ) + .setTimeout(30) + .build(); + +// Sign with stealth scalar (NOT standard keypair signing) +const txHash = tx.hash(); // 32-byte Buffer +const sig = signStellarTransaction(txHash, stealthScalar, stealthPubKey); +const pubKeyStr = pubKeyToStellarAddress(stealthPubKey); // G... address + +tx.addSignature(pubKeyStr, Buffer.from(sig).toString("base64")); + +// Submit +await server.submitTransaction(tx); +``` + +--- + +## Path Payments: Receive a Different Asset + +Stellar's path payment operations let a sender pay in one asset while the recipient receives a different asset. A sender can pay XLM and you receive USDC at the stealth address — the DEX swap happens atomically. + +### How it works with stealth addresses + +The stealth address must hold a trustline for the destination asset (the asset the recipient receives). The `stealth-sender` contract currently supports single-asset sends only. For path payments, use the classic Stellar `PathPaymentStrictReceive` or `PathPaymentStrictSend` operations directly. + +```typescript +import { Operation, Asset } from "@stellar/stellar-sdk"; + +// Sender pays XLM, recipient stealth address receives USDC +const tx = new TransactionBuilder(senderAccount, { + fee: "100", + networkPassphrase: NETWORK, +}) + .addOperation( + Operation.pathPaymentStrictReceive({ + sendAsset: Asset.native(), // Sender pays XLM + sendMax: "10", // Max XLM to spend + destination: stealth.stealthAddress, // Stealth address receives + destAsset: new Asset("USDC", USDC_ISSUER), // Recipient gets USDC + destAmount: "5", // Exactly 5 USDC received + path: [], // Empty = let network find path + }) + ) + .setTimeout(30) + .build(); + +tx.sign(senderKeypair); +await server.submitTransaction(tx); +``` + +> **Important:** Classic payment operations don't call the `stealth-sender` contract, so no announcement is emitted. You need a separate announcement transaction for the recipient to scan and detect the payment. + +### Emitting the announcement separately + +After the path payment succeeds, announce it: + +```typescript +import { SCHEME_ID } from "@wraith-protocol/sdk/chains/stellar"; + +const announcerContract = new SorobanContract(deployment.contracts.announcer); +const metadata = new Uint8Array([stealth.viewTag]); + +const announceTx = new TransactionBuilder(senderAccount, { + fee: "1000000", + networkPassphrase: NETWORK, +}) + .addOperation( + announcerContract.call( + "announce", + /* caller */ xdr.ScVal.scvAddress(/* sender address */), + /* scheme_id */ xdr.ScVal.scvU32(SCHEME_ID), + /* stealth_address */ xdr.ScVal.scvAddress(/* stealth G... address */), + /* ephemeral_pub_key */xdr.ScVal.scvBytes(Buffer.from(stealth.ephemeralPubKey)), + /* metadata */ xdr.ScVal.scvBytes(Buffer.from(metadata)), + ) + ) + .setTimeout(30) + .build(); + +const simResult = await sorobanServer.simulateTransaction(announceTx); +const assembled = SorobanRpc.assembleTransaction(announceTx, simResult).build(); +assembled.sign(senderKeypair); +await sorobanServer.sendTransaction(assembled); +``` + +> **Note:** The two-transaction path-payment pattern is a current limitation. The `stealth-sender` contract only supports direct SAC transfers. A `stealth-sender-path` variant supporting `PathPaymentStrictReceive` atomically is a candidate for a future contract upgrade. + +### Finding payment paths + +Before building a path payment, query Horizon for available paths: + +```typescript +// Find XLM → USDC paths +const paths = await server + .strictReceivePaths() + .destinationAsset(new Asset("USDC", USDC_ISSUER)) + .destinationAmount("5") + .sourceAccount(senderKeypair.publicKey()) + .call(); + +for (const path of paths.records) { + console.log( + `Send ${path.source_amount} ${path.source_asset_code ?? "XLM"} → receive 5 USDC via ${path.path.length} hops` + ); +} +``` + +--- + +## Fees + +Stellar fees have two components: a base fee (per operation, very cheap) and a Soroban resource fee (for smart contract execution, varies with computation and storage). + +### USDC operations cost table + +| Operation | Base fee | Soroban resource fee | Total estimate | Notes | +|---|---|---|---|---| +| `changeTrust` (establish trustline) | 0.00001 XLM | — | **~0.00001 XLM** | Classic operation, no Soroban | +| `payment` USDC (classic) | 0.00001 XLM | — | **~0.00001 XLM** | Classic, no SAC involved | +| `stealth-sender.send()` single | 0.00001 XLM base | 0.0001–0.001 XLM | **~0.001 XLM** | Includes `trust()` + `transfer()` + `announce()` | +| `stealth-sender.batch_send()` N recipients | 0.00001 XLM base | 0.001–0.01 XLM | **~0.01 XLM** | Scales sub-linearly with N | +| `announcer.announce()` only | 0.00001 XLM base | 0.00005–0.0005 XLM | **~0.0005 XLM** | For path-payment + separate announce | +| `createAccount` + `stealth-sender.send()` | 0.00001 XLM base | 0.0001–0.001 XLM | **~0.001 XLM + 1 XLM reserve** | 1 XLM goes to stealth addr as reserve | +| Classic `pathPaymentStrictReceive` | 0.00001 XLM | — | **~0.00001 XLM** | No Soroban | + +**Reserve cost per new trustline: 0.5 XLM** (locked in the stealth address, not a fee — returned when the trustline is closed). + +### Fee estimation in code + +Soroban fees vary with ledger state. Always simulate before submitting: + +```typescript +const simResult = await sorobanServer.simulateTransaction(tx); +if (SorobanRpc.Api.isSimulationSuccess(simResult)) { + console.log("Estimated fee (stroops):", simResult.minResourceFee); + // 1 XLM = 10,000,000 stroops + console.log("Estimated fee (XLM):", parseInt(simResult.minResourceFee) / 1e7); +} +``` + +Set your fee budget conservatively — fee caps in Soroban prevent overpayment. The assembled transaction will include the exact required fee from simulation. + +--- + +## SAC Compatibility Matrix + +Results from the June 2026 internal audit of `stealth-sender` v1.2 against Stellar Protocol 22. Source: `contracts/stellar/audits/2026-06-sac-compatibility.md`. + +| Asset | Issuer flags | `stealth-sender` compatible | Trustline auto-create | Clawback risk | Recommendation | +|---|---|---|---|---|---| +| XLM (native) | — | ✅ Yes | N/A | ❌ None | ✅ Safe | +| USDC (Circle) | None | ✅ Yes | ✅ Protocol 22+ | ❌ None | ✅ Safe | +| EURC (Circle) | None | ✅ Yes | ✅ Protocol 22+ | ❌ None | ✅ Safe | +| Generic asset (no flags) | None | ✅ Yes | ✅ Protocol 22+ | ❌ None | ✅ Safe | +| Asset with `AUTH_REQUIRED` | `AUTH_REQUIRED` | ⚠️ Blocked | ✅ Created but frozen | ❌ None | ⚠️ Manual issuer auth needed | +| Asset with `AUTH_REVOCABLE` | `AUTH_REVOCABLE` | ✅ Yes (unless deauthed) | ✅ Protocol 22+ | ❌ None | ⚠️ Monitor for deauth events | +| Asset with clawback | `AUTH_CLAWBACK_ENABLED` + `AUTH_REVOCABLE` | ✅ Yes | ✅ Protocol 22+ | ✅ **Issuer CAN claw back** | ❌ Not recommended | +| Asset with all flags | All three | ⚠️ Blocked | ✅ Created but frozen | ✅ **Issuer CAN claw back** | ❌ Incompatible | + +### Checking flags before sending + +```typescript +import { Horizon } from "@stellar/stellar-sdk"; + +async function checkAssetFlags(assetCode: string, issuerAddress: string) { + const server = new Horizon.Server("https://horizon-testnet.stellar.org"); + const issuerAccount = await server.loadAccount(issuerAddress); + + const flags = issuerAccount.flags; + return { + authRequired: flags.auth_required, // AUTH_REQUIRED + authRevocable: flags.auth_revocable, // AUTH_REVOCABLE + authClawback: flags.auth_clawback_enabled, // AUTH_CLAWBACK_ENABLED + }; +} + +const flags = await checkAssetFlags("USDC", USDC_ISSUER); +console.log(flags); +// { authRequired: false, authRevocable: false, authClawback: false } +// ✅ USDC is safe — no flags set +``` + +--- + +## AUTH_REQUIRED and Clawback Warnings + +### AUTH_REQUIRED + +When an asset issuer sets `AUTH_REQUIRED`, every new trustline starts frozen. The `trust()` call in `stealth-sender` creates the trustline successfully, but `transfer()` immediately fails with `BalanceDeauthorizedError` (SAC error code 11). + +The issuer must call `set_authorized(stealth_address, true)` before the transfer can proceed. But since stealth addresses are one-time addresses generated per payment, the issuer has no way to know the address in advance. + +**This breaks the stealth payment model.** Do not use `stealth-sender` with `AUTH_REQUIRED` assets. + +If you must transact with `AUTH_REQUIRED` assets: +- Send to the recipient's main account using classic Stellar payment operations +- Handle key management and privacy separately +- The Wraith agent will refuse to send `AUTH_REQUIRED` assets via stealth and explain why + +```typescript +// Agent response for AUTH_REQUIRED assets: +const res = await agent.chat("send 100 ACME to alice.wraith on stellar"); +// "Cannot send ACME via stealth addresses — this asset requires issuer +// authorization for each trustline (AUTH_REQUIRED flag is set). Use a +// direct payment to alice's main Stellar account instead." +``` + +### AUTH_CLAWBACK_ENABLED + +When an issuer sets `AUTH_CLAWBACK_ENABLED` (which also requires `AUTH_REVOCABLE`), the issuer can call `clawback(stealth_address, amount)` at any time to remove the balance from any address — including your stealth address. + +This is incompatible with the guarantees stealth payments are meant to provide. A recipient has no way to prevent or contest a clawback. + +The Wraith agent surfaces a warning when it detects clawback-enabled assets: + +```typescript +const res = await agent.chat("send 100 CBDC to alice.wraith on stellar"); +// "Warning: CBDC has AUTH_CLAWBACK_ENABLED — the issuer can reclaim +// funds from any address, including stealth addresses. Stealth addresses +// do not protect against issuer clawback. +// +// Proceed anyway?" +``` + +You can confirm or cancel. The warning is informational — the send will work at the protocol level. + +### Checking at runtime + +```typescript +async function warnIfRisky(assetCode: string, issuerAddress: string): Promise { + const warnings: string[] = []; + const flags = await checkAssetFlags(assetCode, issuerAddress); + + if (flags.authRequired) { + warnings.push( + `AUTH_REQUIRED: ${assetCode} requires issuer authorization per trustline. ` + + `Stealth payments will be blocked.` + ); + } + if (flags.authClawback) { + warnings.push( + `AUTH_CLAWBACK_ENABLED: ${assetCode} issuer can reclaim balances from stealth addresses.` + ); + } + if (flags.authRevocable && !flags.authClawback) { + warnings.push( + `AUTH_REVOCABLE: ${assetCode} issuer can freeze trustlines. ` + + `Funds could be locked (not clawed back) without warning.` + ); + } + return warnings; +} +``` + +--- + +## Testnet Example: End-to-End USDC Stealth Payment + +A complete testnet flow using the managed agent. Copy-paste and run. + +### Prerequisites + +```bash +npm install @wraith-protocol/sdk @stellar/stellar-sdk +``` + +```bash +# .env +WRAITH_API_KEY=wraith_live_... +AGENT_ID= # from wraith.createAgent() +``` + +### Full flow + +```typescript +import { Wraith, Chain } from "@wraith-protocol/sdk"; + +const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); + +// ── Sender setup ────────────────────────────────────────────────────────── +const senderAgent = await wraith.createAgent({ + name: "usdc-sender", + chain: Chain.Stellar, + wallet: senderKeypair.publicKey(), + signature: Buffer.from(senderKeypair.sign(Buffer.from("Sign to create Wraith agent"))).toString("hex"), + message: "Sign to create Wraith agent", +}); + +// Fund with testnet XLM via Friendbot +await senderAgent.chat("fund my wallet"); + +// Get USDC on testnet — USDC testnet faucet or trade via Stellar DEX +// For testing: the agent can request testnet USDC if a faucet is configured +// Alternatively: swap some testnet XLM for testnet USDC via the DEX +await senderAgent.chat("swap 100 XLM for USDC on stellar"); +// or: manually fund the agent with testnet USDC + +// Check balance +const balance = await senderAgent.getBalance(); +console.log("Sender USDC:", balance.tokens["USDC"]); +// "100.0000000" + +// ── Recipient setup ─────────────────────────────────────────────────────── +const recipientAgent = await wraith.createAgent({ + name: "usdc-recipient", + chain: Chain.Stellar, + wallet: recipientKeypair.publicKey(), + signature: Buffer.from(recipientKeypair.sign(Buffer.from("Sign to create Wraith agent"))).toString("hex"), + message: "Sign to create Wraith agent", +}); + +await recipientAgent.chat("fund my wallet"); + +// Get recipient's stealth meta-address +const recipientMetaAddress = recipientAgent.info.metaAddresses[Chain.Stellar]; +console.log("Recipient meta-address:", recipientMetaAddress); +// "st:xlm:abc123..." + +// ── Send 50 USDC to recipient ───────────────────────────────────────────── +const sendResult = await senderAgent.chat( + "send 50 USDC to usdc-recipient.wraith on stellar" +); +console.log(sendResult.response); +// "Payment sent — 50 USDC to usdc-recipient.wraith via stealth address GABC...xyz on Stellar." +// Trustline was auto-created on the stealth address. +// Announcement was emitted on Soroban. + +// ── Recipient scans ─────────────────────────────────────────────────────── +// Wait a few seconds for the transaction to confirm +await new Promise((r) => setTimeout(r, 5000)); + +const scanResult = await recipientAgent.chat("scan for payments on stellar"); +console.log(scanResult.response); +// "Found 1 incoming payment: +// - 50 USDC at stealth address GABC...xyz" + +// ── Recipient withdraws ──────────────────────────────────────────────────── +const withdrawResult = await recipientAgent.chat( + "withdraw all USDC to GXYZ...myMainWallet on stellar" +); +console.log(withdrawResult.response); +// "Privacy note: withdrawing to a single destination links all stealth addresses. +// Withdrawn 50 USDC from 1 stealth address to GXYZ...myMainWallet." + +// ── Verify USDC testnet issuer ──────────────────────────────────────────── +// Always verify issuer addresses on testnet before going to mainnet +const USDC_TESTNET_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +const USDC_MAINNET_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +console.log("Using testnet USDC issuer:", USDC_TESTNET_ISSUER); +``` + +--- + +## Related + +- [Stellar Crypto Primitives](/sdk/chains/stellar) — low-level stealth address functions including `signStellarTransaction` and `scanAnnouncements` +- [Stellar Contracts](/contracts/stellar) — `stealth-sender.send()` and `batch_send()` interface details +- [Stellar Federation Addresses](/guides/stellar-federation) — resolve `alice*example.com` to a stealth meta-address +- [Spectre + Stellar Cookbook](/guides/spectre-stellar-cookbook) — production USDC payment and payroll recipes +- [Stellar Event Schemas (v2)](/reference/stellar-event-schemas) — filter announcements by view tag bucket +- [Circle USDC on Stellar](https://developers.circle.com/stablecoins/quickstart-transfer-usdc-stellar) — Circle's official Stellar USDC quickstart +- [SAC documentation](https://developers.stellar.org/docs/tokens/stellar-asset-contract) — Stellar Asset Contract reference diff --git a/guides/stellar-federation.mdx b/guides/stellar-federation.mdx new file mode 100644 index 0000000..a96b4ce --- /dev/null +++ b/guides/stellar-federation.mdx @@ -0,0 +1,726 @@ +--- +title: "Stellar Federation Addresses" +description: "Resolve alice*example.com to a Stellar address using SEP-0002 and the Wraith SDK" +--- + +Stellar federation addresses look like `alice*example.com` — a human-readable alias that resolves to a `G...` public key through a lightweight HTTP lookup. This guide covers what federation is, how to set it up for your own domain, how to use the SDK's `resolveStellarFederation()` helper, and the UX and failure-mode considerations for production payment flows. + +## What Federation Is + +A federation address has the form `name*domain.com`. The `*` separates a username from the domain that operates the federation server for that username. When a wallet or integration wants to pay `alice*example.com`: + +1. It fetches `https://example.com/.well-known/stellar.toml` +2. It reads the `FEDERATION_SERVER` URL from that file +3. It sends `GET {FEDERATION_SERVER}?q=alice*example.com&type=name` +4. The server returns a JSON object containing the `stellar_address` (`G...` key) and an optional memo + +This is defined in [SEP-0002: Federation Protocol](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0002.md). + +### Why Federation Matters for Wraith + +Stealth meta-addresses (`st:xlm:...`) are long and unwieldy to share. A federation server bridges the gap: you register `payments*example.com` on your domain's federation server and map it to your `.wraith` stealth meta-address. Anyone who supports federation can now send private payments using the human-readable address. + +### Federation vs. `.wraith` Names + +| | Stellar Federation | `.wraith` Name | +|---|---|---| +| Format | `alice*example.com` | `alice.wraith` | +| Standard | SEP-0002 | Wraith-specific | +| Resolution | HTTP to your domain | On-chain Soroban lookup | +| Custody | You own the domain | Wraith names contract | +| Memo support | Yes (exchange deposits) | No | +| Privacy | No — resolves to a public `G...` key by default | Yes — resolves to a stealth meta-address | + +They complement each other. You can configure a federation server to return your `.wraith` stealth meta-address as the `stellar_address`, giving you both human-readable names and privacy. + +--- + +## How the Protocol Works + +### 1. stellar.toml + +Your domain publishes a `stellar.toml` at `https://example.com/.well-known/stellar.toml`. The relevant field: + +```toml +FEDERATION_SERVER="https://federation.example.com/federation" +``` + +The `stellar.toml` itself is governed by [SEP-0001](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0001.md). + +### 2. Federation request + +A GET request to the federation server: + +``` +GET https://federation.example.com/federation?q=alice*example.com&type=name +``` + +Query parameters: + +| Parameter | Value | Description | +|---|---|---| +| `q` | `alice*example.com` | The federation address to resolve | +| `type` | `name` | Lookup type — `name` for address-to-key | + +### 3. Federation response + +```json +{ + "stellar_address": "alice*example.com", + "account_id": "GABC...XYZ", + "memo_type": "text", + "memo": "invoice-4821" +} +``` + +| Field | Type | Description | +|---|---|---| +| `stellar_address` | string | The federation address that was queried | +| `account_id` | string | The resolved `G...` Stellar public key | +| `memo_type` | `"text"` \| `"id"` \| `"hash"` | Optional — memo type for the transaction | +| `memo` | string | Optional — memo value (required for exchange deposits) | + +### 4. Error response + +When a name is not found: + +```json +{ + "detail": "Account not found", + "code": "not_found", + "status": 404 +} +``` + +--- + +## Setting Up Federation for Your Domain + +### Option A — Self-hosted federation server + +The SDF publishes a reference Go implementation: [`stellar/go/services/federation`](https://github.com/stellar/go/tree/master/services/federation). It reads from a database and handles `name`, `id`, and `txid` query types. + +Minimal setup: + +1. Deploy the server (or any server that handles the query format above) +2. Add `FEDERATION_SERVER` to your `/.well-known/stellar.toml` +3. Ensure the `stellar.toml` is served over HTTPS with `Access-Control-Allow-Origin: *` + +```toml +# /.well-known/stellar.toml +NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" +FEDERATION_SERVER="https://federation.example.com/federation" +``` + +### Option B — Minimal serverless endpoint + +For simple use cases (one account, many usernames with memos), a single serverless function suffices: + +```typescript +// GET /federation?q=alice*example.com&type=name +export async function GET(request: Request) { + const url = new URL(request.url); + const q = url.searchParams.get("q") ?? ""; + const type = url.searchParams.get("type"); + + if (type !== "name") { + return Response.json({ code: "not_found", detail: "Only type=name is supported" }, { status: 404 }); + } + + // Parse "alice" from "alice*example.com" + const username = q.split("*")[0]?.toLowerCase(); + const user = await db.users.findUnique({ where: { federationName: username } }); + + if (!user) { + return Response.json({ code: "not_found", detail: "Account not found" }, { status: 404 }); + } + + return Response.json( + { + stellar_address: q, + account_id: user.stellarPublicKey, // or stealth meta-address + ...(user.memoType && { memo_type: user.memoType }), + ...(user.memo && { memo: user.memo }), + }, + { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", // required — wallets call cross-origin + }, + } + ); +} +``` + +Add the `FEDERATION_SERVER` to your `stellar.toml`: + +```toml +FEDERATION_SERVER="https://api.example.com/federation" +``` + +### Option C — Third-party federation services + +Services like [fed.network](https://www.fed.network/) host federation for you. Register `alice*fed.network` without running any infrastructure. + +--- + +## SDK Helper: `resolveStellarFederation()` + +The SDK ships a `resolveStellarFederation()` helper in `@wraith-protocol/sdk/chains/stellar`. It handles the full two-step resolution (stellar.toml fetch → federation GET) with timeout, validation, and error normalisation. + +> **Status:** This helper ships in the next SDK release. See the [SDK wave issue](#sdk-wave-tracking) at the bottom of this page for tracking. + +### Import + +```typescript +import { + resolveStellarFederation, + type FederationRecord, + type FederationError, +} from "@wraith-protocol/sdk/chains/stellar"; +``` + +### Types + +```typescript +interface FederationRecord { + federationAddress: string; // "alice*example.com" + accountId: string; // "GABC..." — resolved G... public key + memoType?: "text" | "id" | "hash"; + memoValue?: string; +} + +type FederationErrorCode = + | "NOT_FOUND" // federation server returned 404 / unknown address + | "DNS_FAILURE" // could not fetch stellar.toml + | "NO_FEDERATION_SERVER"// stellar.toml exists but has no FEDERATION_SERVER field + | "INVALID_TOML" // stellar.toml is malformed + | "MALFORMED_RESPONSE" // federation server response is missing required fields + | "TIMEOUT" // request exceeded the timeout threshold + | "NETWORK_ERROR"; // fetch failed for any other reason + +interface FederationError { + code: FederationErrorCode; + message: string; + cause?: unknown; +} +``` + +### `resolveStellarFederation(address, options?)` + +```typescript +const result = await resolveStellarFederation("alice*example.com"); +``` + +**Parameters:** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `address` | `string` | — | Federation address to resolve, e.g. `alice*example.com` | +| `options.timeoutMs` | `number` | `5000` | Request timeout in milliseconds | +| `options.cache` | `FederationCache` | `undefined` | Optional cache implementation (see below) | +| `options.fetchToml` | `function` | `fetch` | Override the stellar.toml fetch (useful for testing) | + +**Returns:** `Promise` + +Throws a `FederationError` on any failure. + +### Basic usage + +```typescript +import { resolveStellarFederation } from "@wraith-protocol/sdk/chains/stellar"; + +try { + const record = await resolveStellarFederation("alice*example.com"); + + console.log(record.accountId); // "GABC...XYZ" — use as payment destination + console.log(record.memoType); // "text" | "id" | "hash" | undefined + console.log(record.memoValue); // "invoice-4821" | undefined +} catch (err) { + // err is FederationError + console.error(err.code, err.message); +} +``` + +### With a Wraith stealth payment + +After resolving, pass the `accountId` to `generateStealthAddress()` if you're building a custom flow, or hand it to the agent via chat: + +```typescript +import { + resolveStellarFederation, + decodeStealthMetaAddress, + generateStealthAddress, +} from "@wraith-protocol/sdk/chains/stellar"; + +// The federation server returns a stealth meta-address in account_id +const record = await resolveStellarFederation("alice*example.com"); + +// Detect whether the resolved address is a stealth meta-address or a plain G... key +if (record.accountId.startsWith("st:xlm:")) { + // Privacy path — send via stealth address + const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(record.accountId); + const stealth = generateStealthAddress(spendingPubKey, viewingPubKey); + // Send to stealth.stealthAddress + call announcer contract +} else { + // Plain path — send directly to record.accountId + // Include record.memoType / record.memoValue if present +} +``` + +### With the managed agent + +```typescript +import { Wraith, Chain } from "@wraith-protocol/sdk"; +import { resolveStellarFederation } from "@wraith-protocol/sdk/chains/stellar"; + +const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); +const agent = wraith.agent(process.env.AGENT_ID!); + +// Resolve first, then instruct the agent with the concrete address +const record = await resolveStellarFederation("alice*example.com"); + +const res = await agent.chat( + `send 100 USDC to ${record.accountId} on stellar` + + (record.memoValue ? ` with memo ${record.memoValue}` : "") +); +console.log(res.response); +``` + +Or let the agent resolve it directly — the agent supports federation addresses in chat: + +```typescript +// The agent resolves alice*example.com automatically +const res = await agent.chat("send 100 USDC to alice*example.com on stellar"); +console.log(res.response); +// "Payment sent — 100 USDC to alice*example.com (resolved to GABC...XYZ) on Stellar." +``` + +--- + +## Caching Considerations + +Federation resolution makes two HTTP requests (stellar.toml + federation GET) for every lookup. At scale — or in a payment UI that validates on every keystroke — this adds up. + +### Built-in cache interface + +`resolveStellarFederation()` accepts a `cache` option conforming to a simple interface: + +```typescript +interface FederationCache { + get(key: string): Promise; + set(key: string, record: FederationRecord, ttlMs: number): Promise; +} +``` + +### In-memory cache (single process) + +```typescript +import { resolveStellarFederation, type FederationRecord } from "@wraith-protocol/sdk/chains/stellar"; + +class MemoryFederationCache { + private store = new Map(); + + async get(key: string) { + const entry = this.store.get(key); + if (!entry || Date.now() > entry.expiresAt) { + this.store.delete(key); + return undefined; + } + return entry.record; + } + + async set(key: string, record: FederationRecord, ttlMs: number) { + this.store.set(key, { record, expiresAt: Date.now() + ttlMs }); + } +} + +const cache = new MemoryFederationCache(); + +const record = await resolveStellarFederation("alice*example.com", { + cache, + // stellar.toml is stable — cache for 24 hours + // federation records can change — default TTL is 1 hour +}); +``` + +### Redis cache (multi-process / serverless) + +```typescript +import { createClient } from "redis"; + +class RedisFederationCache { + private client = createClient({ url: process.env.REDIS_URL }); + + constructor() { + this.client.connect(); + } + + async get(key: string) { + const raw = await this.client.get(`fed:${key}`); + return raw ? (JSON.parse(raw) as FederationRecord) : undefined; + } + + async set(key: string, record: FederationRecord, ttlMs: number) { + await this.client.setEx(`fed:${key}`, Math.floor(ttlMs / 1000), JSON.stringify(record)); + } +} + +const record = await resolveStellarFederation("alice*example.com", { + cache: new RedisFederationCache(), +}); +``` + +### What to cache and for how long + +| Item | Recommended TTL | Rationale | +|---|---|---| +| `stellar.toml` content | 24 hours | Rarely changes; CORS-accessible static file | +| Federation record (`name` lookup) | 1 hour | Can change (key rotation, memo updates) | +| Not-found result | 5 minutes | Avoids hammering on typos, but re-checks promptly | + +Never cache a not-found result for longer than a few minutes — the address may just not exist yet, and users expect to retry. + +### stellar.toml caching separately + +The two-request chain means the stellar.toml fetch is the bottleneck. `resolveStellarFederation()` caches the parsed `FEDERATION_SERVER` URL under the key `toml:{domain}` at the same TTL as the record. If you share a cache across many lookups to the same domain, that first request is paid once per TTL. + +--- + +## Failure Modes + +Every step of federation resolution can fail. Handle each case gracefully. + +### DNS / HTTPS failure on stellar.toml + +The `/.well-known/stellar.toml` file can't be fetched — domain is misconfigured, HTTPS cert is invalid, or the file simply doesn't exist. + +```typescript +try { + await resolveStellarFederation("alice*notadomain.invalid"); +} catch (err) { + if (err.code === "DNS_FAILURE") { + // Show: "Could not reach federation server for notadomain.invalid" + } +} +``` + +**UX guidance:** Display a domain-level error. Don't expose the raw network error to users. A message like "We couldn't reach example.com's payment server. Check the address and try again." is clear and actionable. + +### No FEDERATION_SERVER in stellar.toml + +The domain has a `stellar.toml` but hasn't configured federation. + +```typescript +} catch (err) { + if (err.code === "NO_FEDERATION_SERVER") { + // Show: "example.com doesn't support federation addresses" + } +} +``` + +### Address not found + +The federation server responded but doesn't know this username. + +```typescript +} catch (err) { + if (err.code === "NOT_FOUND") { + // Show: "alice*example.com was not found" + } +} +``` + +This is the most common failure in payment UIs. Display it inline, next to the input — not as a modal or page-level error. + +### Malformed response + +The federation server is reachable but returns a response missing `account_id`, or returns invalid JSON. + +```typescript +} catch (err) { + if (err.code === "MALFORMED_RESPONSE") { + // Show: "example.com's federation server returned an unexpected response" + // Log err.cause for debugging + console.error(err.cause); + } +} +``` + +### Timeout + +The federation server is too slow. Default timeout is 5 seconds; adjust with `options.timeoutMs`. + +```typescript +} catch (err) { + if (err.code === "TIMEOUT") { + // Show: "The federation server took too long to respond. Try again." + } +} +``` + +For payment UIs, 3 seconds is a better timeout — users abandon flows that feel slow. + +### Complete error handler + +```typescript +import { + resolveStellarFederation, + type FederationError, +} from "@wraith-protocol/sdk/chains/stellar"; + +function userMessage(err: FederationError, address: string): string { + const domain = address.split("*")[1] ?? address; + switch (err.code) { + case "NOT_FOUND": + return `${address} was not found. Check the spelling and try again.`; + case "DNS_FAILURE": + case "NETWORK_ERROR": + return `Couldn't reach ${domain}'s payment server. Check your connection and try again.`; + case "NO_FEDERATION_SERVER": + return `${domain} doesn't support federation addresses.`; + case "MALFORMED_RESPONSE": + case "INVALID_TOML": + return `${domain}'s payment server returned an unexpected response.`; + case "TIMEOUT": + return `${domain}'s payment server is taking too long. Try again in a moment.`; + default: + return "Something went wrong resolving this address. Try again."; + } +} + +try { + const record = await resolveStellarFederation(address, { timeoutMs: 3000 }); + // proceed with record.accountId +} catch (err) { + const msg = userMessage(err as FederationError, address); + setError(msg); // your UI state setter +} +``` + +--- + +## UX Patterns + +### Input hint + +When your payment form accepts a Stellar destination, hint that federation addresses work: + +```tsx +// React example + +

+ You can use a federation address like alice*example.com +

+``` + +The hint text matches what SDF-ecosystem wallets use, so users who know federation will immediately recognise it. + +### Inline validation with debounce + +Don't resolve on every keystroke — wait until the user pauses typing. A federation address is valid when it contains exactly one `*` and a plausible domain. + +```typescript +// Detect if the input looks like a federation address before attempting resolution +function isFederationAddress(input: string): boolean { + const parts = input.split("*"); + if (parts.length !== 2) return false; + const [name, domain] = parts; + return ( + name.length > 0 && + domain.includes(".") && // has a TLD separator + domain.length > 2 && + !/\s/.test(input) // no whitespace + ); +} + +function isStellarAddress(input: string): boolean { + return /^G[A-Z2-7]{55}$/.test(input.trim()); +} +``` + +```tsx +// React + debounce +import { useDeferredValue, useEffect, useState } from "react"; + +function PaymentForm() { + const [input, setInput] = useState(""); + const [resolved, setResolved] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const deferred = useDeferredValue(input); // built-in React debounce + + useEffect(() => { + if (!isFederationAddress(deferred)) { + setResolved(null); + setError(null); + return; + } + + setLoading(true); + setError(null); + + resolveStellarFederation(deferred, { timeoutMs: 3000 }) + .then((record) => { + setResolved(record); + setError(null); + }) + .catch((err) => { + setResolved(null); + setError(userMessage(err, deferred)); + }) + .finally(() => setLoading(false)); + }, [deferred]); + + return ( +
+ setInput(e.target.value)} + placeholder="G... address or alice*example.com" + aria-label="Recipient address" + aria-describedby="recipient-hint recipient-status" + /> +

+ You can use a federation address like alice*example.com +

+

+ {loading && "Resolving…"} + {resolved && `✓ Resolves to ${resolved.accountId.slice(0, 8)}…`} + {error && `⚠ ${error}`} +

+
+ ); +} +``` + +### Show the resolved address + +Once resolved, display a truncated version of the `G...` address alongside the federation alias so users can verify before confirming: + +``` +alice*example.com → GABC...XYZ +``` + +If the federation server returns a stealth meta-address (`st:xlm:...`), display it as "privacy-protected destination" rather than exposing the raw key. + +### Memo handling + +When the federation record includes a memo, surface it prominently — exchange deposits will fail silently if the memo is omitted: + +```tsx +{resolved?.memoValue && ( +
+ Important: This address requires a{" "} + {resolved.memoType === "id" ? "numeric" : "text"} memo:{" "} + {resolved.memoValue} +
+)} +``` + +### Accessibility checklist + +- Use `aria-live="polite"` on the status region so screen readers announce resolution results +- Never rely on colour alone to indicate success/error — include an icon or text prefix (✓ / ⚠) +- `role="alert"` on memo notices ensures they are announced immediately +- Keep the `aria-label` on the input descriptive: "Recipient Stellar address or federation address" + +--- + +## Registering Your Domain for Wraith Payments + +To accept private payments via federation, configure your federation server to return your `.wraith` stealth meta-address: + +### 1. Get your agent's stealth meta-address + +```typescript +import { Wraith, Chain } from "@wraith-protocol/sdk"; + +const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); +const agent = await wraith.getAgentByName("payments"); + +const metaAddress = agent.info.metaAddresses[Chain.Stellar]; +console.log(metaAddress); // "st:xlm:abc123...def456..." +``` + +Or via the API: + +```bash +curl https://api.usewraith.xyz/agent/info/payments \ + -H "Authorization: Bearer $WRAITH_API_KEY" +# { "metaAddresses": { "stellar": "st:xlm:..." }, ... } +``` + +### 2. Return it from your federation server + +```typescript +// Your federation endpoint +const user = await db.users.findUnique({ where: { federationName: username } }); + +return Response.json({ + stellar_address: q, + // Return the stealth meta-address — senders using the Wraith SDK + // will detect the "st:xlm:" prefix and send via stealth + account_id: user.wraithMetaAddress, // "st:xlm:abc123..." +}); +``` + +Senders using `resolveStellarFederation()` will receive the `st:xlm:...` value in `record.accountId` and can branch on it as shown in the SDK section above. + +### 3. Publish your stellar.toml + +```toml +# /.well-known/stellar.toml +NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" +FEDERATION_SERVER="https://api.example.com/federation" + +# Optional: advertise your Wraith integration +[DOCUMENTATION] +ORG_NAME="Example Inc." +ORG_URL="https://example.com" +``` + +Serve with: + +``` +Content-Type: text/plain; charset=UTF-8 +Access-Control-Allow-Origin: * +``` + +The `Access-Control-Allow-Origin: *` header is required — wallets call `stellar.toml` from browser contexts. + +--- + +## SDK Wave Tracking + +`resolveStellarFederation()` is scheduled for inclusion in the Stellar SDK wave. Until it ships: + +- Use the raw two-request pattern (fetch `stellar.toml`, parse `FEDERATION_SERVER`, call the endpoint) +- Or use `@stellar/stellar-sdk`'s built-in federation resolver: `StellarSdk.Federation.Server.resolve(address)` + +```typescript +import { Federation } from "@stellar/stellar-sdk"; + +// Built-in resolver — no Wraith dependency required +const record = await Federation.Server.resolve("alice*example.com"); +console.log(record.account_id); // "GABC..." +console.log(record.memo_type); // "text" | undefined +console.log(record.memo); // "invoice-4821" | undefined +``` + +The Wraith `resolveStellarFederation()` helper adds normalised error codes, TypeScript-first types, a pluggable cache interface, and timeout control on top of this baseline. + +--- + +## Related + +- [Stellar Crypto Primitives](/sdk/chains/stellar) — `generateStealthAddress`, `decodeStealthMetaAddress`, and the full stealth address flow +- [Spectre + Stellar Cookbook](/guides/spectre-stellar-cookbook) — production recipes that use federation addresses as payment destinations +- [How Stealth Payments Work](/guides/stealth-payments) — the cryptography behind stealth addresses +- [SEP-0002: Federation Protocol](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0002.md) — the full specification +- [SEP-0001: Stellar Info File](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0001.md) — stellar.toml format diff --git a/introduction.mdx b/introduction.mdx index 93f6b80..a5b28d4 100644 --- a/introduction.mdx +++ b/introduction.mdx @@ -99,6 +99,8 @@ Adding a new EVM chain requires only configuration (RPC URL + contract addresses - [Bring Your Own Model](guides/bring-your-own-model) — use OpenAI/Claude instead of Gemini - [Stealth Payments](guides/stealth-payments) — how stealth addresses work (with visuals) - [Privacy Best Practices](guides/privacy-best-practices) — scoring, what to avoid +- [Spectre + Stellar Cookbook](guides/spectre-stellar-cookbook) — three production recipes: SaaS payments, DAO payroll, privacy monitoring +- [Stellar Federation Addresses](guides/stellar-federation) — resolve `alice*example.com` addresses, caching, UX patterns, failure modes ### Contracts diff --git a/sdk/chains/stellar.mdx b/sdk/chains/stellar.mdx index cbcefad..7bcffad 100644 --- a/sdk/chains/stellar.mdx +++ b/sdk/chains/stellar.mdx @@ -424,7 +424,57 @@ This replaces the need to manually query `sorobanServer.getEvents()` and parse X > [!NOTE] > For advanced use cases and indexer building, refer to the [Stellar Event Schemas (v2)](/reference/stellar-event-schemas) documentation to learn how to natively filter topics via the RPC. -## Troubleshooting +## Federation Address Resolution -If you encounter errors while building with Stellar primitives (like `tx_bad_seq`, `op_no_trust`, or stealth-specific errors), see the [Stellar Troubleshooting Guide](/guides/stellar-troubleshooting) for common causes and code fixes. -- [Stellar Offline Transaction Signing](/guides/stellar-offline-signing) — build online, sign air-gapped, submit without the private key touching the internet +The SDK includes a `resolveStellarFederation()` helper for resolving `alice*example.com` federation addresses to Stellar public keys before sending payments. + +```typescript +import { resolveStellarFederation } from "@wraith-protocol/sdk/chains/stellar"; + +const record = await resolveStellarFederation("alice*example.com"); +console.log(record.accountId); // "GABC..." or "st:xlm:..." stealth meta-address +console.log(record.memoType); // "text" | "id" | "hash" | undefined +console.log(record.memoValue); // memo value if required (e.g. exchange deposits) +``` + +If the resolved `accountId` is a stealth meta-address (`st:xlm:...`), decode it and pass it straight to `generateStealthAddress()`: + +```typescript +import { + resolveStellarFederation, + decodeStealthMetaAddress, + generateStealthAddress, +} from "@wraith-protocol/sdk/chains/stellar"; + +const record = await resolveStellarFederation("alice*example.com"); + +if (record.accountId.startsWith("st:xlm:")) { + const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(record.accountId); + const stealth = generateStealthAddress(spendingPubKey, viewingPubKey); + // Send to stealth.stealthAddress and call the announcer contract +} else { + // Send directly to record.accountId, include record.memoValue if present +} +``` + +See the [Stellar Federation Addresses guide](/guides/stellar-federation) for the full reference: SEP-0002 protocol details, caching, failure modes, UX patterns, and how to register your own domain. + +## Custom Assets (USDC) + +The SDK supports sending any Stellar Asset Contract (SAC) token via stealth addresses, not just XLM. USDC (Circle) is fully compatible — no restrictive flags, trustlines auto-created via `trust()` on Protocol 22+. + +```typescript +import { generateStealthAddress, decodeStealthMetaAddress } from "@wraith-protocol/sdk/chains/stellar"; +import { Asset, Networks } from "@stellar/stellar-sdk"; + +// Derive the USDC SAC contract address +const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; // testnet +const usdcContractId = new Asset("USDC", USDC_ISSUER).contractId(Networks.TESTNET); +// Pass usdcContractId as the `token` parameter to stealth-sender.send() +``` + +USDC amounts use **7 decimal places**: `100 USDC = 1_000_000_0` in the i128 passed to the SAC. + +Assets with `AUTH_REQUIRED` are incompatible with stealth payment flows. Assets with `AUTH_CLAWBACK_ENABLED` work at the protocol level but allow the issuer to reclaim stealth balances — the agent warns before sending. + +See the [Stellar Custom Assets guide](/guides/stellar-custom-assets) for the full reference: SAC overview, USDC issuer addresses, sender and recipient flows with trustline handling, path payments, fee estimates, and the SAC compatibility matrix from the June 2026 audit.