diff --git a/skills/smart-contracts/SKILL.md b/skills/smart-contracts/SKILL.md index 8280c63..99903bd 100644 --- a/skills/smart-contracts/SKILL.md +++ b/skills/smart-contracts/SKILL.md @@ -1,6 +1,6 @@ --- name: smart-contracts -description: Stellar smart contract development (Rust, soroban-sdk). Entry point with project setup, contract anatomy, and build/deploy workflow, routing to three companion files in this directory — development.md (storage, auth, cross-contract calls, events, errors, upgrades, factories, troubleshooting), testing.md (unit, fuzz, property, fork, mutation, integration), and security.md (vulnerability classes, checklists, tooling, audits). Use when writing, testing, securing, or shipping Stellar smart contracts (formerly branded Soroban). +description: Stellar smart contract development (Rust, soroban-sdk). Entry point with project setup, contract anatomy, and build/deploy workflow, routing to three companion files in this directory — development.md (storage/TTL, authorization, cross-contract calls, tokens, events, errors, upgrades, fees, troubleshooting), testing.md (unit, fuzz, property, fork, mutation, integration), and security.md (vulnerability classes, checklists, tooling, audits). Use when writing, testing, reviewing, securing, debugging, or shipping Stellar smart contracts, including anything the user calls "Soroban" — Soroban contracts, soroban-sdk, Soroban auth/storage/TTL errors, SEP-41 tokens, or SAC integration from contract code. user-invocable: true argument-hint: "[contract task]" --- @@ -13,7 +13,7 @@ This file covers setup and the core workflow. The deep dives live alongside it | Task | File | |------|------| -| Storage, auth, cross-contract calls, events, errors, upgrades, factories, troubleshooting | [development.md](development.md) | +| Storage/TTL, authorization, constructors, cross-contract calls, tokens, events, errors, upgrades, factories, governance/DeFi patterns, fees/resources, troubleshooting | [development.md](development.md) | | Unit, integration, fuzz, property, fork, and mutation testing | [testing.md](testing.md) | | Security review, vulnerability classes, checklists, audit prep, tooling | [security.md](security.md) | @@ -21,44 +21,58 @@ This file covers setup and the core workflow. The deep dives live alongside it - Writing a Stellar smart contract in Rust - Setting up contract tests (any layer) - Reviewing a contract for security issues -- Architecting upgradeable contracts, factories, governance, or DeFi primitives +- Architecting upgradeable contracts, factories, custom accounts, or DeFi primitives - Debugging a contract-specific error (auth, storage, archival, resource limits) ## Related skills -- Assets, trustlines, and SAC bridge → `../assets/SKILL.md` +- Asset issuance, trustlines, and SAC deployment → `../assets/SKILL.md` - Frontend/wallets that call your contract → `../dapp/SKILL.md` - Chain data queries (RPC/Horizon) → `../data/SKILL.md` - ZK verification (BLS12-381, Groth16, Circom/Noir/RISC Zero) → `../zk-proofs/SKILL.md` - SEP/CAP standards and ecosystem links → `../standards/SKILL.md` +## Versions + +This skill was written against **protocol 27** (`soroban-sdk` v27, `rs-soroban-env` v27, `stellar-cli` v27). Version numbers in examples are illustrative — resolve the current ones from these sources rather than trusting any doc: + +- **`soroban-sdk` major version tracks the protocol version** (SDK 27 ↔ protocol 27). This rule outlives any specific release. +- Latest SDK release: [crates.io/crates/soroban-sdk](https://crates.io/crates/soroban-sdk) (or `cargo add soroban-sdk`, which resolves it). Pre-releases (`-rc.x`) exist only during a protocol rollout and must be pinned with the exact version string; [GitHub releases](https://github.com/stellar/rs-soroban-sdk/releases) lists them with changelogs. +- Networks upgrade by validator vote, testnet before mainnet — pin the SDK major matching the network you deploy to. Live protocol version: RPC `getVersionInfo` or [Stellar Lab](https://lab.stellar.org). +- Numeric network limits quoted here are mainnet settings at time of writing; they change by vote — [Stellar Lab's Network Limits page](https://lab.stellar.org/network-limits) and `stellar network settings --network mainnet` show the live values. + ## Platform constraints Contracts are Rust compiled to WebAssembly, run in a sandboxed host: - `#![no_std]` required — use `soroban_sdk` types (`String`, `Vec`, `Map`, `Symbol`), not the Rust standard library -- 64KB compiled contract size limit — use the release profile below -- `Symbol` is limited to 32 characters; `symbol_short!()` covers up to 9 +- Compile for the **`wasm32v1-none`** target (Rust ≥ 1.84) — the only Wasm target the Stellar runtime supports +- 128KB compiled contract size limit (network-configured) +- `Symbol` is limited to 32 characters (`a-zA-Z0-9_`); `symbol_short!()` covers up to 9 - Storage is rented: every entry has a TTL and can be archived — see [development.md](development.md#storage) -- No `delegatecall`, no classical cross-contract reentrancy — see [security.md](security.md) +- No `delegatecall`, and cross-contract reentrancy is blocked by the host — see [security.md](security.md) +- No I/O, no networking, no clock beyond the ledger timestamp — everything a contract can do hangs off `Env` ## Project setup ```bash stellar contract init my-contract # scaffolds a Cargo workspace with contracts/ cd my-contract +rustup target add wasm32v1-none # once per toolchain ``` -`Cargo.toml` essentials: +`Cargo.toml` essentials (what `stellar contract init` generates): ```toml [lib] -crate-type = ["cdylib"] +crate-type = ["lib", "cdylib"] # lib is needed for tests and fuzzing [dependencies] -soroban-sdk = "25.0.1" # check https://crates.io/crates/soroban-sdk for latest +soroban-sdk = "27.0.0-rc.1" # protocol 27; pre-releases need the exact version string. + # Mainnet is on protocol 26 at the time of writing — use "26" there + # until the network upgrades. Check crates.io for the latest. [dev-dependencies] -soroban-sdk = { version = "25.0.1", features = ["testutils"] } # match above +soroban-sdk = { version = "27.0.0-rc.1", features = ["testutils"] } # match above [profile.release] opt-level = "z" @@ -73,11 +87,13 @@ lto = true ## Contract anatomy -One compact example showing state, constructor, auth, TTL, and a typed error: +One compact example showing state, constructor, auth, TTL, a typed error, and an event: ```rust #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env}; +use soroban_sdk::{ + contract, contracterror, contractevent, contractimpl, contracttype, Address, Env, +}; #[contracttype] #[derive(Clone)] @@ -93,13 +109,22 @@ pub enum Error { NotInitialized = 1, } +// Emitted as topics ("incremented", by) with data {count} — #[topic] fields +// become topics, the rest go in the data payload. +#[contractevent] +pub struct Incremented { + #[topic] + pub by: Address, + pub count: u32, +} + #[contract] pub struct CounterContract; #[contractimpl] impl CounterContract { - // Runs once, atomically, at deploy time (Protocol 22+). Must be named - // `__constructor` and return (). Does not run again on upgrade. + // Runs once, atomically, at deploy time. Must be named `__constructor`. + // Does not run again on upgrade. pub fn __constructor(env: Env, admin: Address) { env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::Counter, &0u32); @@ -118,8 +143,9 @@ impl CounterContract { env.storage().instance().set(&DataKey::Counter, &count); // Extend TTL so contract state is not archived (threshold, extend-to) - env.storage().instance().extend_ttl(100, 518400); + env.storage().instance().extend_ttl(120 * 17280, 180 * 17280); + Incremented { by: admin, count }.publish(&env); Ok(count) } @@ -129,12 +155,13 @@ impl CounterContract { } ``` -Full patterns (three storage types, auth variants, cross-contract calls, events, custom types): [development.md](development.md). +Full patterns (three storage types, auth variants, cross-contract calls, tokens, custom types): [development.md](development.md). ## Build, deploy, invoke ```bash # Build optimized WASM → target/wasm32v1-none/release/*.wasm +# (optimization is on by default; --optimize=false to disable) stellar contract build # Create and fund an identity (testnet) @@ -162,6 +189,7 @@ To upload WASM without instantiating (e.g. for factories or upgrades), use `stel ## Minimal test ```rust +// src/test.rs — included from lib.rs with `mod test;` #![cfg(test)] use super::*; use soroban_sdk::{testutils::Address as _, Address, Env}; @@ -179,7 +207,7 @@ fn test_increment() { } ``` -Auth mocking, fuzzing, fork tests, and CI setup: [testing.md](testing.md). +Auth mocking, event assertions, fuzzing, fork tests, and CI setup: [testing.md](testing.md). ## Before mainnet diff --git a/skills/smart-contracts/development.md b/skills/smart-contracts/development.md index 3e1e4ac..10d9b82 100644 --- a/skills/smart-contracts/development.md +++ b/skills/smart-contracts/development.md @@ -2,48 +2,64 @@ Core and advanced patterns for Stellar smart contracts. For setup and the basic workflow, start at [SKILL.md](SKILL.md); for tests see [testing.md](testing.md); for security review see [security.md](security.md). -## Architecture +## Execution model -Contracts run as WebAssembly guests in a sandboxed host. The host provides storage, crypto, and cross-contract calls; guest code references host objects via handles, not direct memory. Practical consequences: +Contracts run as WebAssembly guests in a sandboxed host inside stellar-core. Each transaction gets its own host environment; each contract invoked (directly or nested) runs in its own guest VM inside that host. The host provides storage, crypto, auth, and cross-contract calls; guest code references host objects (`Vec`, `Map`, `Bytes`, `String`, `Address`, …) via integer handles — the host does the actual work and meters every step. Practical consequences: -- `#![no_std]` — use `soroban_sdk` collections and strings -- 64KB compiled size limit (see [Contract size](#contract-size) below) -- Rust is the supported language. Community alternatives (AssemblyScript, Solidity via Solang) exist but are not production-ready — see [migration docs](https://developers.stellar.org/docs/learn/migrate). +- `#![no_std]` — use `soroban_sdk` collections and strings; Rust is the supported language +- **A transaction is the atomicity boundary.** Returned errors, panics, missing auth, and budget exhaustion roll back *all* ledger changes, including writes made by nested calls before the failure. A caller can catch a callee's error (via `try_` client methods) and continue. +- **Reentrancy is blocked by the host.** A contract cannot be re-entered directly or indirectly through normal calls. External calls are still failure/budget/side-effect boundaries — order state updates deliberately. +- Contract state lives in storage, never in the `#[contract]` struct — `#[contract] pub struct Foo { x: i128 }` does not persist `x`. +- Everything a contract can reach hangs off `Env`: `storage()`, `events()`, `crypto()`, `ledger()`, `deployer()`, `prng()`, `current_contract_address()`, `invoke_contract()`. There is no I/O, no networking, no clock other than `env.ledger().timestamp()`. +- `env.prng()` is seeded from ledger state and **predictable** — never use it for keys, auth, or high-stakes randomness. ## Storage Three storage types with different costs and lifetimes. Choosing wrong is a top source of bugs and fee waste: -| Type | Lifetime | Use for | -|------|----------|---------| -| `instance()` | Tied to the contract instance, shared TTL | Admin address, global config, small global state | -| `persistent()` | Per-key TTL, archived when expired but **restorable** | User balances, anything that must survive | -| `temporary()` | Per-key TTL, deleted when expired, **not restorable** | Caches, session data, short-lived flags | +| Type | Lifetime | Expiry behavior | Use for | +|------|----------|-----------------|---------| +| `instance()` | One TTL shared with the contract instance | Archived with the contract, restorable | Admin address, config, small global state | +| `persistent()` | Per-key TTL | **Archived**, restorable | User balances, anything that must survive | +| `temporary()` | Per-key TTL | **Deleted permanently** | Caches, time-bounded data, oracle prices | ```rust env.storage().instance().set(&DataKey::Admin, &admin); env.storage().persistent().set(&DataKey::Balance(user), &balance); -env.storage().temporary().set(&DataKey::Cache(key), &value); +env.storage().temporary().set(&DataKey::Quote(pair), &price); ``` +All three support `get`, `set`, `has`, `remove`, `update`, `try_update`, and `extend_ttl`. Instance storage is a single ledger entry shared with the contract instance: cheap to keep all globals warm together (one `extend_ttl` bumps everything), but capped at the network's max entry size (**64KB serialized** on mainnet) — never put per-user or unbounded data there. + ### TTL management -Every entry has a TTL (in ledgers, ~5s each) and is archived when it expires. Extend proactively in functions that touch the data: +Every entry has a TTL counted in ledgers (~5s each, 17,280/day) and is archived (persistent/instance) or deleted (temporary) when it expires. Mainnet floors and ceiling (network-configured): new persistent entries start with ~120 days of TTL, temporary entries ~1 day, and no entry can exceed ~180 days (3,110,400 ledgers). ```rust -const MIN_TTL: u32 = 17280; // ~1 day -const EXTEND_TO: u32 = 518400; // ~30 days - -// extend_ttl(threshold, extend_to): only extends if TTL < threshold -env.storage().instance().extend_ttl(MIN_TTL, EXTEND_TO); -env.storage().persistent().extend_ttl(&DataKey::Balance(user), MIN_TTL, EXTEND_TO); +const DAY_IN_LEDGERS: u32 = 17280; +const BUMP_THRESHOLD: u32 = 30 * DAY_IN_LEDGERS; +const BUMP_TO: u32 = 120 * DAY_IN_LEDGERS; + +// extend_ttl(threshold, extend_to): no-op unless current TTL < threshold, +// then sets TTL to extend_to. Idempotent and floor-only — never shortens. +env.storage().instance().extend_ttl(BUMP_THRESHOLD, BUMP_TO); +env.storage().persistent().extend_ttl(&DataKey::Balance(user), BUMP_THRESHOLD, BUMP_TO); ``` -Archived persistent entries can be restored with a `RestoreFootprint` operation, but that costs an extra transaction — design TTL extension into hot paths instead. Details: [state archival docs](https://developers.stellar.org/docs/learn/fundamentals/contract-development/storage/state-archival). +Extending on every write to an entry ("active users pay for their own state") is the standard pattern for per-user data; extending instance TTL at the top of busy entry points keeps globals alive. Rent is charged at invocation time. + +Protocol 26 added bounded variants for persistent and instance storage — useful when callers shouldn't be able to force arbitrary rent costs: `extend_ttl_with_limits(&key, extend_to, min_extension, max_extension)`. + +Two facts that surprise people: + +- **Anyone can extend any entry's TTL** via the `ExtendFootprintTTLOp` transaction operation, no contract auth involved. Benevolent keep-alive services are possible — and TTL expiry is **not a security mechanism**. If data must become invalid after a deadline, store the deadline in the value and check it. +- **Archived is not gone.** Since protocol 23, archived persistent entries declared in a transaction's read-write footprint are restored automatically (you re-pay the storage rent); the CLI and SDKs handle this during simulation. Temporary entries have no such path — expired means deleted. + +Contract code should still never assume an entry it wrote is present later: use `get(...).unwrap_or(default)` or `has()` checks in flows where absence is meaningful. ### Typed storage keys -Always use a `#[contracttype]` enum for keys — ad-hoc symbols invite collisions: +Always use a `#[contracttype]` enum for keys — it centralizes the schema, prevents collisions, and adding variants is the forward-compatible way to grow storage: ```rust #[contracttype] @@ -55,10 +71,19 @@ pub enum DataKey { } ``` +Keep keys fine-grained (`Balance(Address)` per user, not one giant `Map`). Every transaction declares its read/write footprint upfront; transactions touching the same read-write entry serialize, while fine-grained keys let unrelated transactions run in parallel — and a giant map forces every caller to pay to read all of it. + +### Choosing storage — decision tree + +1. Must survive indefinitely even if untouched? → `persistent()` +2. Inherently time-bounded, and "expired" may equal "gone"? → `temporary()` (cheapest) +3. Global, small, read on most invocations? → `instance()` +4. Per-user or unbounded in count? → never `instance()`; `persistent()` if durable, `temporary()` if ephemeral + ## Data types ```rust -use soroban_sdk::{Address, Bytes, BytesN, Map, String, Symbol, Vec}; +use soroban_sdk::{Address, Bytes, BytesN, Map, String, Symbol, Vec, U256, I256}; let addr: Address = env.current_contract_address(); let sym: Symbol = symbol_short!("transfer"); // ≤9 chars; Symbol max is 32 @@ -68,32 +93,41 @@ let v: Vec = vec![&env, 1, 2, 3]; let m: Map = Map::new(&env); ``` -Custom types derive `#[contracttype]`: +- `i128` is the canonical token amount type (SEP-41). It admits negatives — validate `amount >= 0` on inputs. +- 256-bit math: `U256`/`I256` with `checked_add/sub/mul/pow/div/rem_euclid/shl/shr` returning `Option` (protocol 26+). +- `MuxedAddress` distinguishes sub-accounts of one address (exchanges, custodians); token `transfer` accepts it for the destination and a plain `Address` converts into it. +- `Timepoint` and `Duration` exist for time values; `env.ledger().timestamp()` is a `u64` in seconds. + +Custom types derive `#[contracttype]` and work as storage keys/values, arguments, and return values: ```rust #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct TokenMetadata { - pub name: String, - pub symbol: Symbol, - pub decimals: u32, +pub struct Position { + pub collateral: i128, + pub debt: i128, + pub last_update: u64, } ``` +Keep type names unique within a contract — all types crossing the ABI are exported flat into the contract spec. + ## Authorization -Authorization is opt-in and explicit. Call `require_auth()` on every address whose consent the operation needs: +Authorization is host-mediated and explicit. Call `require_auth()` on every address whose consent the operation needs: ```rust pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { from.require_auth(); - // or bind the auth to specific arguments: + // or bind the auth to specific arguments instead of all of them: // from.require_auth_for_args((&to, amount).into_val(&env)); // ... } ``` -Admin pattern: +`require_auth()` asks the host: did `from` authorize *this contract, this function, these exact arguments* in this transaction? `require_auth_for_args` narrows the signed payload to the args you pass — useful for partial commitments (sign a max amount, contract enforces `actual <= max`). + +**When to require auth** — for each `Address` parameter, ask whether the call spends, reduces, or reconfigures something that address owns. Reading public data: no auth. Crediting a balance: no auth. Debiting, approving, changing settings, or *consuming the address's authority in a downstream call*: auth required. The right address matters more than the call itself — `who.require_auth()` on a user-supplied `who` proves the caller controls `who`, not that `who` is allowed to do anything (load the admin from storage and auth *that* for privileged paths): ```rust fn require_admin(env: &Env) { @@ -102,11 +136,66 @@ fn require_admin(env: &Env) { } ``` -Authorization semantics (sub-invocations, custom accounts): [auth docs](https://developers.stellar.org/docs/learn/fundamentals/contract-development/authorization). +### Auth trees and cross-contract propagation + +A user signs a **tree of invocations**, not a single call: "I authorize `A.foo(args)`, and within it, `B.bar(args)`". `require_auth` deep in the call stack passes only if the actual call path and arguments match the signed tree, and each tree node matches at most once per transaction. Implications: + +- Auth does not cascade. `U` authorizing `A.foo` does not let every contract `A` calls claim `U`'s auth — the client must include inner calls (e.g. `token.transfer`) in the signed tree. SDKs build the tree automatically from simulation. +- Refactoring your call graph (moving a token call from one helper contract to another) changes the tree shape and silently breaks integrations that sign the old shape. +- **Re-auth at every layer that uses an address's authority.** If `deposit(user, amount)` calls `token.transfer(&user, ...)` but never calls `user.require_auth()`, anyone can call `deposit(victim, ...)` and consume a pre-signed inner auth. This is the most common real-world auth bug. + +Contracts as callers have one special rule: when contract A directly calls B, B may `require_auth` A's address — the direct call is the authorization. That covers B's immediate invocation only. If B will make a *deeper* call requiring A's auth, A must pre-authorize it: + +```rust +// In contract A, before calling B (which will call token.transfer from A's address): +env.authorize_as_current_contract(vec![&env, InvokerContractAuthEntry::Contract(SubContractInvocation { + context: ContractContext { + contract: token_id.clone(), + fn_name: Symbol::new(&env, "transfer"), + args: (a_address, b_address, amount).into_val(&env), + }, + sub_invocations: vec![&env], +})]); +b_client.do_thing(...); +``` + +Replay protection (nonces, expiration) is handled by the host for normal `require_auth` flows — contracts don't track nonces. Stellar accounts satisfy auth at their **medium** threshold. + +### Custom accounts (`__check_auth`) + +A contract address can act as an account (smart wallet, multisig, passkey wallet) by implementing `CustomAccountInterface`. The host calls `__check_auth` to evaluate the account's policy whenever that contract address is required to authorize something: + +```rust +use soroban_sdk::{auth::{Context, CustomAccountInterface}, crypto::Hash}; + +#[contractimpl] +impl CustomAccountInterface for MyAccount { + type Signature = BytesN<64>; + type Error = AccError; + + fn __check_auth( + env: Env, + signature_payload: Hash<32>, // host-computed digest of what is being authorized + signature: BytesN<64>, // your artifact type — anything #[contracttype] + auth_contexts: Vec, // the calls this auth would cover + ) -> Result<(), AccError> { + let pk: BytesN<32> = env.storage().instance().get(&DataKey::Owner).unwrap(); + env.crypto().ed25519_verify(&pk, &signature_payload.into(), &signature); + // optionally enforce policy from auth_contexts: spend limits, function allowlists… + Ok(()) + } +} +``` + +Rules: always verify `signature_payload` (verifying anything else authorizes arbitrary calls); use `auth_contexts` to enforce policies (each `Context::Contract` carries contract, fn_name, args); never `require_auth` the account's own address inside `__check_auth` (it's evaluating auth, not consuming it); keep it lean — its cost is added to every transaction the account signs. + +Protocol 27 (CAP-71) adds **auth delegation** for modular accounts: inside `__check_auth`, `env.custom_account().get_delegated_signers()` returns the delegate addresses the transaction supplied (unsanitized — check they are registered delegates of this account), and `env.custom_account().delegate_auth(&addr)` forwards the current authorization check to that G- or C-address. This lets an account delegate authentication to signer contracts without a separate auth entry per delegate; delegation can nest. + +Auth semantics reference: [authorization docs](https://developers.stellar.org/docs/learn/fundamentals/contract-development/authorization). ## Constructors -`__constructor` runs once, atomically, at deploy time (Protocol 22+). Prefer it over a separate `initialize` function — it removes the front-running window between deploy and init: +`__constructor` runs once, atomically, at deploy time. Prefer it over a separate `initialize` function — it removes the front-running window between deploy and init: ```rust pub fn __constructor(env: Env, admin: Address, initial_value: u32) { @@ -115,9 +204,9 @@ pub fn __constructor(env: Env, admin: Address, initial_value: u32) { } ``` -Rules: exact name `__constructor`, returns `()`, runs only at creation (not on upgrade), failure aborts the deployment atomically. Pass args at deploy time after the `--` separator (see [SKILL.md](SKILL.md#build-deploy-invoke)). +Rules: exact name `__constructor`; runs only at creation (not on upgrade); failure aborts the deployment atomically; pass args at deploy time after the `--` separator (see [SKILL.md](SKILL.md#build-deploy-invoke)). A contract deployed without a constructor can't retroactively gain one, so plan initialization at deploy time. -If you must support a guarded `initialize` instead (pre-Protocol-22 targets), check-and-set an `Initialized` flag — see [security.md](security.md#2-reinitialization-attacks). +If you must support a guarded `initialize` instead (legacy patterns), check-and-set an `Initialized` flag — see [security.md](security.md#3-reinitialization-attacks). ## Cross-contract calls @@ -137,34 +226,42 @@ pub fn deposit(env: Env, user: Address, token: Address, amount: i128) { } ``` -For Stellar assets, use the built-in SAC client — every asset has a Stellar Asset Contract: +For anything implementing the standard token interface (including every Stellar asset via its SAC), the SDK ships a client — no import needed: ```rust -use soroban_sdk::token::Client as TokenClient; +use soroban_sdk::token::TokenClient; -let token = TokenClient::new(&env, &asset_contract_id); +let token = TokenClient::new(&env, &token_address); token.transfer(&from, &to, &amount); ``` -SAC details and asset interop: `../assets/SKILL.md`. Validate addresses you call — see [security.md](security.md#3-arbitrary-contract-calls). +Untyped dynamic calls exist for when the interface isn't known at compile time: `env.invoke_contract::(&target, &symbol, args)`. Errors from a callee bubble up and abort unless you call through `try_` client methods. There is no `msg.sender` — pass the acting `Address` explicitly and `require_auth` it (see [Authorization](#authorization) for how auth traverses call trees). ## Events +Define events with the `#[contractevent]` macro (the older `env.events().publish(topics, data)` is deprecated): + ```rust use soroban_sdk::contractevent; -#[contractevent(topics = ["transfer"])] -pub struct TransferEvent { +// Topics: ("transfer", from, to) — the name (snake-cased) is the first topic by +// default, #[topic] fields follow in order. Non-topic fields form the data payload. +#[contractevent] +pub struct Transfer { + #[topic] pub from: Address, + #[topic] pub to: Address, pub amount: i128, } // in a contract function: -TransferEvent { from, to, amount }.publish(&env); +Transfer { from, to, amount }.publish(&env); ``` -Emit events for every state change you'll want to index or audit — events are much cheaper than storing queryable state. +Customize with `#[contractevent(topics = ["custom", "prefix"])]` (replaces the name topic) and `data_format = "map" | "vec" | "single-value"` (default `map`; `single-value` for a lone data field, which is what SEP-41 token events use). Topics are how indexers filter — put the event name and participant addresses there, payload in the data. + +Emit events for every state change you'll want to index or audit — events are much cheaper than storing queryable state. But they're ephemeral: RPC providers typically retain ~7 days, so anything needed long-term must be derivable from on-chain state. Diagnostic events (`log!`) are off by default on nodes and not part of consensus — never rely on them for logic. ## Error handling @@ -187,7 +284,45 @@ pub fn transfer(env: Env, from: Address, to: Address, amount: i128) -> Result<() } ``` -Returning `Result` gives callers (and clients generated for tests) typed errors via `try_` methods. Panics abort with less information — reserve them for invariant violations. +- Returning `Result` gives callers typed errors: generated clients expose both `foo()` (panics on error) and `try_foo()` returning `Result, InvokeError>` — `Ok(Err(e))` is your typed error, outer `Err` is a host-level failure (budget, bad auth, type mismatch). +- When panicking, use `panic_with_error!(&env, Error::X)`, not bare `panic!("msg")` — bare panics surface as opaque host errors clients can't match on. +- Error codes are public ABI. Never renumber across upgrades. +- An `Err` return or panic rolls back **all** state changes of the invocation, including nested calls' writes. + +## Tokens from the contract side + +Token semantics are load-bearing for any contract that moves funds. The interface is SEP-41; every classic Stellar asset also exposes it through its Stellar Asset Contract (SAC), so "token address" may mean a custom contract, a wrapped classic asset, or native XLM. + +```rust +use soroban_sdk::token::{TokenClient, StellarAssetClient}; + +let token = TokenClient::new(&env, &token_addr); // SEP-41: balance, transfer, +token.transfer(&from, &to, &amount); // approve, transfer_from, burn… + +let sac = StellarAssetClient::new(&env, &sac_addr); // SAC admin surface: +sac.mint(&to, &amount); // mint, clawback, set_admin, + // set_authorized, trust (p26+) +``` + +What to internalize: + +- Amounts are `i128`; reject negatives explicitly. `transfer`'s destination is a `MuxedAddress` (an `Address` converts into it), letting exchanges attach sub-account IDs. +- Auth pattern: `transfer`/`approve`/`burn` auth `from`; `transfer_from`/`burn_from` auth the `spender` (the allowance pre-authorized the `from` side). Reads need no auth. `mint`/`clawback`/`set_admin`/`set_authorized`/`trust` are SAC/admin surface, not SEP-41. +- **Allowances expire**: `approve(from, spender, amount, expiration_ledger)`. Convention is temporary storage keyed `(from, spender)` with TTL matching the expiration. Re-approving overwrites — the classic race applies (spender can front-run an allowance reduction and spend old + new); mitigate by approving to 0 first or using exact-amount auth instead of standing allowances. +- **SAC carries classic-asset semantics into contracts**: account (`G...`) balances live in trustlines (missing or unauthorized trustline → transfer fails; 64-bit balance cap), contract (`C...`) balances live in contract storage (full i128). Issuer flags apply — `AUTH_REQUIRED`, freezes via `AUTH_REVOCABLE`, clawback. Transfers to the issuer burn; from the issuer mint. A protocol that accepts arbitrary token addresses must survive all of this — see [security.md](security.md) for the token-consumer review checklist. +- Emit the standard token event shapes exactly — wallets and indexers depend on them (`soroban-token-sdk` ships these as ready-made `#[contractevent]` structs): + +| Event | Topics | Data | +|---|---|---| +| `transfer` | `("transfer", from, to)` | `amount: i128`, or map `{to_muxed_id, amount}` for muxed destinations | +| `approve` | `("approve", from, spender)` | vec `[amount: i128, expiration_ledger: u32]` | +| `mint` | `("mint", to)` | `amount`, or map `{to_muxed_id, amount}` | +| `burn` | `("burn", from)` | `amount` | +| `clawback` | `("clawback", from)` | `amount` | + + The SAC emits these same shapes with one addition: the SEP-0011 asset string (`CODE:ISSUER` or `native`) as a trailing topic. + +Custom token implementations, asset issuance, and SAC deployment: `../assets/SKILL.md`. Interface spec: [SEP-41](https://stellar.org/protocol/sep-41), [token interface docs](https://developers.stellar.org/docs/tokens/token-interface). ## Upgradeability @@ -203,10 +338,9 @@ pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { } ``` -- Upload the new WASM first (`stellar contract upload`), pass its hash here. -- Add a dedicated `migrate` entrypoint for storage migrations; make it idempotent and monotonic (`new_version > current_version`). -- `__constructor` does not re-run on upgrade. -- SEP-0049 standardizes upgradeable-contract interfaces; OpenZeppelin's [stellar-contracts](https://github.com/OpenZeppelin/stellar-contracts) ships an audited implementation. +- Upload the new WASM first (`stellar contract upload`), pass its hash here. The contract address and all storage survive; only the executable changes. `__constructor` does not re-run. +- **Storage schema is your problem.** New code reading an old key with a changed `#[contracttype]` shape fails to decode. Patterns: store a schema version and branch on it; add enum variants rather than reshaping existing ones; ship an idempotent, admin-gated `migrate` entrypoint enforcing `new_version > current_version`. +- SEP-0049 standardizes upgradeable-contract interfaces; OpenZeppelin's [stellar-contracts](https://github.com/OpenZeppelin/stellar-contracts) ships audited implementations. ## Factories @@ -228,15 +362,15 @@ impl Factory { } ``` -- Addresses are deterministic per (deployer, salt) — derive salts intentionally. +- Addresses are deterministic per (deployer, salt) — `deployed_address()` computes one without deploying; derive salts intentionally. - Authorize who may deploy; emit an event per deployment so instances are indexable. - Keep factory logic separate from instance business logic. ## Governance -For sensitive actions (upgrades, config changes), prefer a timelock: `propose_*` stores the pending action plus an execute-after ledger, `execute_*` enforces the delay, `cancel_*` lets governance abort. For multisig, separate proposer/approver/executor roles, store proposal state in persistent storage, and prevent replay (unique proposal IDs, expiry semantics, explicit events). +For sensitive actions (upgrades, config changes), prefer a timelock: `propose_*` stores the pending action plus an execute-after ledger, `execute_*` enforces the delay, `cancel_*` lets governance abort. For multisig, separate proposer/approver/executor roles, store proposal state in persistent storage, and prevent replay (unique proposal IDs, expiry semantics, explicit events). For signature-set policies, a custom account (`__check_auth`) is often cleaner than in-contract multisig. -## DeFi and compliance patterns +## DeFi patterns Condensed design rules — the details are application-specific: @@ -245,41 +379,60 @@ Condensed design rules — the details are application-specific: - **Oracles**: enforce freshness bounds, prefer multi-source/median feeds, add circuit breakers. - **Regulated tokens**: isolate allowlist/denylist and freeze/forced-transfer logic in dedicated entrypoints with strong auth, emit policy-decision events, never store PII on-chain. -Worked examples: [soroban-examples](https://github.com/stellar/soroban-examples) (liquidity pool, atomic swap, timelock, single-offer) and the [DeFi tutorials](https://developers.stellar.org/docs/build/apps/guestbook). +Worked examples: [soroban-examples](https://github.com/stellar/soroban-examples) (liquidity pool, atomic swap, timelock, single-offer). + +## Fees and resource limits + +Soroban transactions pay an inclusion fee (classic surge-priced mechanic) plus a resource fee based on declared consumption: CPU instructions, ledger reads/writes (entries and bytes), transaction size, events size, and rent. Rent/events are refundable if unused; instructions and I/O are charged as declared — so submitters simulate first to size the declaration, and a transaction that exceeds its declaration fails. If actual state diverges from simulation (concurrent writes), costs can shift — leave headroom. + +Current mainnet per-transaction ceilings (network-configured, change by validator vote — check the live values on [Stellar Lab's Network Limits page](https://lab.stellar.org/network-limits) or with `stellar network settings --network mainnet`): + +| Resource | Limit | +|---|---| +| CPU instructions | 400M | +| Memory | 40 MB | +| Ledger entries read / written | 200 / 200 | +| Bytes read / written | 200 KB / ~129 KB | +| Transaction size | ~129 KB | +| Contract events + return value | 16 KB | +| Contract WASM size | 128 KB | +| Ledger entry size (incl. whole instance storage) | 64 KB (keys 250 B) | + +Optimization rules that actually move the needle: + +- Minimize distinct ledger entries touched — each read/write has a fixed cost plus bytes, and everything touched must be in the footprint whether used or not +- Avoid unbounded loops over user-controlled collections (instruction budget + fee-griefing surface; cap or restructure) +- Reduce cross-contract calls in hot paths; each is full invocation overhead +- Use events instead of storage for data that only needs off-chain visibility +- Crypto verifications are the expensive primitive in `__check_auth` — bound signature counts +- Profile before optimizing: `stellar contract invoke ... --send=no` reports instructions, I/O, and fees without submitting ## Contract size -The 64KB limit is real and the release profile in [SKILL.md](SKILL.md#project-setup) is mandatory. If you still exceed it: +The 128KB limit is real and the release profile in [SKILL.md](SKILL.md#project-setup) is mandatory (`stellar contract build` also optimizes the WASM by default). If you still exceed it: ```bash -ls -la target/wasm32v1-none/release/*.wasm # check size +ls -la target/wasm32v1-none/release/*.wasm # check size cargo install cargo-bloat -cargo bloat --release --target wasm32v1-none # find heavy deps +cargo bloat --release --target wasm32v1-none # find heavy deps ``` -Then: split the contract, drop heavy dependencies, prefer `symbol_short!`, avoid large static data. - -## Resource optimization - -Fees are multidimensional (CPU instructions, ledger reads/writes, bytes, events, rent): - -- Minimize storage reads/writes; batch where possible -- Avoid unbounded loops over user-controlled collections -- Reduce cross-contract calls in hot paths -- Use events instead of storage for data that only needs off-chain visibility -- Profile with `stellar contract invoke ... --send=no` before optimizing blind +Then: drop heavy dependencies (full `serde`, `regex` — stay in no_std SDK idioms), split the contract, avoid large static data. ## Troubleshooting | Symptom | Cause | Fix | |---------|-------|-----| -| `contract exceeds maximum size` | WASM > 64KB | See [Contract size](#contract-size) | +| `contract exceeds maximum size` | WASM > 128KB | See [Contract size](#contract-size) | | `cannot find macro println` / `std` errors | Missing `#![no_std]` | Add as first line of `lib.rs`; use SDK types | -| Calls fail after inactivity, data "missing" | Storage TTL expired → archived | Extend TTLs proactively; restore archived entries | +| `can't find crate for core` targeting wasm | Missing target | `rustup target add wasm32v1-none` | +| `cargo test` fails inside `soroban-env-host` (`ed25519_dalek` trait errors) | A semver-loose transitive dep resolved to an incompatible major (e.g. ed25519-dalek 3.x, mid-2026) | Pin it back: `cargo update ed25519-dalek@3.0.0 --precise 2.2.0` | +| Calls fail after inactivity, data "missing" | Storage TTL expired → archived | Extend TTLs proactively; simulation auto-restores archived persistent entries | | Temporary data vanished | Wrong storage type | Use `persistent()` for data that must survive | | `Error: identity "alice" not found` | CLI identity missing | `stellar keys generate alice --network testnet --fund` | | `invalid argument format` on invoke | Wrong CLI arg syntax | Plain strings for addresses; JSON for complex types | | `transaction simulation failed` | Soroban tx not simulated/assembled | Simulate, then `assembleTransaction` before signing | +| Auth fails only in cross-contract flows | Signed auth tree doesn't match actual call path | Rebuild the tree from simulation; re-auth at each layer (see [Authorization](#authorization)) | | `tx_bad_auth` | Wrong network passphrase or signer | Match passphrase to network; check signing identity | | `tx_bad_seq` | Stale sequence number | Reload the account before building the tx | diff --git a/skills/smart-contracts/security.md b/skills/smart-contracts/security.md index eb3e32d..75334df 100644 --- a/skills/smart-contracts/security.md +++ b/skills/smart-contracts/security.md @@ -10,11 +10,13 @@ Assume the attacker controls: - Transaction ordering and timing - All accounts except those requiring signatures - The ability to deploy contracts that mimic your interface +- The shape of on-chain state they can legally create (entry counts, TTLs — they can even extend your entries' TTLs) ## What the platform rules out - **No `delegatecall`** — contracts cannot execute foreign bytecode in their own context; proxy-style hijacks don't exist. -- **No classical reentrancy** — execution is synchronous; the Ethereum-style cross-contract reentrancy class is absent (self-reentrancy is possible but rarely exploitable). +- **No reentrancy** — the host blocks reentrant calls, direct or indirect, on normal cross-contract paths. External calls remain failure/budget/side-effect boundaries — still order state updates deliberately. +- **Host-managed auth replay protection** — nonces and expirations on authorization entries are enforced by the host, not by your code. - **Explicit authorization** — `require_auth()` is opt-in, which means *forgetting it* is the failure mode to hunt for. ## Vulnerability classes @@ -35,9 +37,22 @@ pub fn withdraw(env: Env, to: Address, amount: i128) { } ``` -Every privileged path needs `require_auth()` on the right address. Auth variants: [development.md](development.md#authorization). +Every privileged path needs `require_auth()` on the right address — and "right" means the address with policy authority, loaded from storage, not a caller-supplied parameter (`who.require_auth()` on an arbitrary `who` proves nothing). Auth variants: [development.md](development.md#authorization). -### 2. Reinitialization attacks +### 2. Auth replay through middleware (missing outer `require_auth`) + +The cross-contract variant of #1, and the most common real-world auth bug: + +```rust +// BAD: never auths `user` here +pub fn settle(env: Env, user: Address, amount: i128) { + token_client.transfer(&user, &recipient, &amount); +} +``` + +If `user` has pre-signed an auth tree that includes the inner `token.transfer`, *anyone* can call `settle(user, ...)` and consume it. Re-authorize at **every** layer that exercises an address's authority, not just the deepest call. Review rule: for each entry point, enumerate every `Address` argument and ask whose authority each state change consumes. Auth-tree semantics: [development.md](development.md#auth-trees-and-cross-contract-propagation). + +### 3. Reinitialization attacks Only relevant if you use a guarded `initialize` instead of a `__constructor` (which can't re-run): @@ -45,13 +60,15 @@ Only relevant if you use a guarded `initialize` instead of a `__constructor` (wh // GOOD: refuses second call pub fn initialize(env: Env, admin: Address) { if env.storage().instance().has(&DataKey::Admin) { - panic!("already initialized"); + panic_with_error!(&env, Error::AlreadyInitialized); } env.storage().instance().set(&DataKey::Admin, &admin); } ``` -### 3. Arbitrary contract calls +Without the guard, anyone can call `initialize` first (or again) and capture admin. + +### 4. Arbitrary contract calls Calling whatever address a user passes lets an attacker substitute a contract that mimics the interface: @@ -60,14 +77,16 @@ Calling whatever address a user passes lets an attacker substitute a contract th pub fn swap(env: Env, token: Address, amount: i128) { let allowed: Vec
= env.storage().instance().get(&DataKey::AllowedTokens).unwrap(); if !allowed.contains(&token) { - panic!("token not allowed"); + panic_with_error!(&env, Error::TokenNotAllowed); } - let client = token::Client::new(&env, &token); + let client = TokenClient::new(&env, &token); // ... } ``` -### 4. Integer overflow/underflow +A user-supplied contract address is arbitrary code: it can fail, trap, burn budget, emit misleading events, or call other contracts (it just can't re-enter you). + +### 5. Integer overflow/underflow `overflow-checks = true` in the release profile catches overflows at runtime (panic), but explicit checked math fails cleaner and survives profile mistakes: @@ -75,13 +94,13 @@ pub fn swap(env: Env, token: Address, amount: i128) { let new_balance = balance.checked_add(amount).expect("overflow"); ``` -Also validate sign and range on inputs (`amount <= 0` checks) — `i128` amounts can be negative. +Also validate sign and range on inputs — `i128` amounts can be negative, and `transfer(from, to, -1000)` is a withdrawal from `to` if unchecked. For 256-bit types, use the `checked_*` methods (protocol 26+). -### 5. Storage key collisions +### 6. Storage key collisions Untyped keys can silently overwrite unrelated data. Always use a `#[contracttype]` key enum — see [development.md](development.md#typed-storage-keys). -### 6. Check-then-act races +### 7. Check-then-act races State can change between transactions. Do checks and state changes atomically within one invocation, and take slippage bounds (`min_out`) from the caller: @@ -90,44 +109,67 @@ pub fn swap(env: Env, user: Address, amount_in: i128, min_out: i128) { user.require_auth(); let amount_out = calculate_output(amount_in); if amount_out < min_out { - panic!("slippage exceeded"); + panic_with_error!(&env, Error::SlippageExceeded); } // update all state in this same invocation } ``` -### 7. TTL/archival failures +The token-allowance variant: `approve` overwrites, so reducing a non-zero allowance can be front-run (spend old, then spend new). Approve to 0 first, or use exact-amount auth instead of standing allowances. + +### 8. TTL and archival failures -If critical state expires, the contract breaks (or worse, behaves as if state never existed). Extend TTLs in hot paths and monitor entry TTLs in production — see [development.md](development.md#ttl-management). +Two distinct failure modes: -### 8. Trusting cross-contract return values +- **Critical state expires**: a temporary entry silently disappears (permanently), or persistent entries archive and add restoration friction. Extend TTLs in hot paths; monitor entry TTLs in production — see [development.md](development.md#ttl-management). +- **TTL used as a security mechanism**: anyone can extend any entry's TTL via `ExtendFootprintTTLOp`, so "this entry expires, therefore the permission ends" is broken by design. Store an explicit deadline in the value and check it. + +### 9. Trusting cross-contract return values Validate data from external contracts — allowlist oracles, sanity-check magnitudes, enforce freshness: ```rust let price: i128 = oracle_client.get_price(&asset); if price <= 0 || price > MAX_REASONABLE_PRICE { - panic!("invalid price"); + panic_with_error!(&env, Error::InvalidPrice); } ``` -## Classic-side risks (for contracts touching assets) +### 10. Resource exhaustion / fee griefing + +A function whose worst-case cost on attacker-shaped input exceeds network limits is a denial of service on legitimate users. Hunt for: loops bounded by user-controlled collections or entry counts, per-iteration storage reads or events, unbounded signature counts in `__check_auth`. Cap iteration counts explicitly and keep footprints small — limits and costs: [development.md](development.md#fees-and-resource-limits). + +### 11. Custom account (`__check_auth`) pitfalls + +- Verify `signature_payload` itself — verifying any other message authorizes arbitrary calls. +- Enforce policies from `auth_contexts` (spend limits, function allowlists); ignoring it makes "policy" wallets decorative. +- CAP-71 delegation: `get_delegated_signers()` returns **unsanitized** user input — verify each address is actually a registered delegate before calling `delegate_auth`. +- Details: [development.md](development.md#custom-accounts-__check_auth). + +## Token-consumer review (AMMs, vaults, escrows, lenders) + +Any contract that takes a token address must assume it may be native XLM's SAC, a wrapped classic asset's SAC, or a custom contract: -- **Trustline spoofing**: display and verify full asset code + issuer; honor curated lists (`stellar.toml`). -- **Clawback**: assets with `auth_clawback_enabled` can be seized by the issuer — check issuer flags before treating balances as final. -- Asset semantics live in `../assets/SKILL.md`. +- [ ] Which tokens are accepted — allowlisted or permissionless? (Permissionless ⇒ class #4 applies) +- [ ] Decimals handled? Query and store at registration; never assume 7. +- [ ] Received amount re-checked after `transfer` where it matters? (Fee-on-transfer or non-standard tokens deliver less than requested) +- [ ] SAC/classic quirks survivable — recipient missing a trustline, `AUTH_REQUIRED` assets, frozen accounts, issuer clawback? (Semantics: [development.md](development.md#tokens-from-the-contract-side)) +- [ ] `transfer_from` flows: does UX coordinate the allowance, and does the code auth the `spender` (not `from`)? +- [ ] Standard token event shapes emitted, so indexers/wallets see the flows? ## Checklists ### Contract -- [ ] All privileged functions require appropriate authorization +- [ ] All privileged functions require appropriate authorization, loaded from storage +- [ ] Every layer using an address's authority re-auths it (no middleware replay) - [ ] Initialization can only happen once (or uses `__constructor`) -- [ ] External contract calls validated/allowlisted +- [ ] External contract calls validated/allowlisted; return values sanity-checked - [ ] Arithmetic checked; input signs and ranges validated - [ ] Storage keys typed and collision-free -- [ ] Critical TTLs extended proactively; archival behavior considered -- [ ] Events emitted for auditable state changes +- [ ] Critical TTLs extended proactively; no TTL-as-security assumptions +- [ ] Loops and footprints bounded on attacker-shaped input +- [ ] Events emitted for auditable state changes (and error codes never renumbered) - [ ] Upgrade path gated, tested (happy + failure), and replay-safe - [ ] Emergency controls (pause) and incident runbook defined for value-bearing contracts diff --git a/skills/smart-contracts/testing.md b/skills/smart-contracts/testing.md index f08e359..2f25611 100644 --- a/skills/smart-contracts/testing.md +++ b/skills/smart-contracts/testing.md @@ -12,7 +12,11 @@ Layers, fastest first: ## Unit testing ```rust +// src/test.rs — a separate file included from lib.rs with `mod test;` +// (the layout `stellar contract init` scaffolds). The inner `#![cfg(test)]` +// gates this whole module; don't paste it into lib.rs itself. #![cfg(test)] +extern crate std; // contracts are no_std; tests can still use std explicitly use soroban_sdk::{ testutils::{Address as _, MockAuth, MockAuthInvoke}, Address, Env, @@ -26,12 +30,14 @@ fn test_basic() { let client = ContractClient::new(&env, &contract_id); let user = Address::generate(&env); - client.initialize(&user); + client.set_owner(&user); assert_eq!(client.get_value(), 0); } ``` -`env.register` takes constructor args as its second parameter — `env.register(Contract, (admin.clone(),))` for a contract with `__constructor(env, admin)`. +`env.register` takes constructor args as its second parameter — `env.register(Contract, (admin.clone(),))` for a contract with `__constructor(env, admin)`. Use `env.register_at(&address, Contract, args)` to pin a specific address. + +**Resource limits are enforced in tests** (SDK 25+): `Env::default()` applies mainnet CPU/memory limits to invocations, so a contract that would blow the budget in production fails in unit tests — that's a feature. For deliberately heavy tests: `env.cost_estimate().disable_resource_limits()`; to read consumption, `env.cost_estimate().resources()`. ### Authorization @@ -61,6 +67,8 @@ fn test_auth() { } ``` +`env.auths()` returns `(Address, AuthorizedInvocation)` pairs **for the most recent invocation only** — any later client call (even a read) resets it, so assert immediately after the call under test. Assert the exact tree (function, args, sub-invocations) on security-critical paths so a dropped `require_auth` fails the test. If a contract authorizes calls below the entry point (e.g. via `authorize_as_current_contract`), use `mock_all_auths_allowing_non_root_auth()`. + ### Time and ledger state ```rust @@ -72,16 +80,26 @@ env.ledger().set_timestamp(2500); ### Events +Events defined with `#[contractevent]` compare directly against emitted XDR: + ```rust -let events = env.events().all(); -assert_eq!(events.len(), 1); -// each event = (contract_id, topics: Vec, data: Val) +use soroban_sdk::{testutils::Events as _, Event as _}; + +let expected = Transfer { from: from.clone(), to: to.clone(), amount: 100 }; +assert_eq!( + env.events().all(), + std::vec![expected.to_xdr(&env, &contract_id)], +); ``` +`env.events().all()` returns the events of the **most recent invocation** as `ContractEvents` (like `env.auths()`, it resets on every call — assert right after the call under test); `event.to_xdr(&env, &contract_id)` builds the expected `xdr::ContractEvent` from the struct. + ### Storage TTL ```rust -let ttl = env.as_contract(&contract_id, || { +use soroban_sdk::testutils::storage::Persistent as _; // get_ttl lives on testutils traits + +let ttl: u32 = env.as_contract(&contract_id, || { env.storage().persistent().get_ttl(&DataKey::MyData) }); assert!(ttl > 0); @@ -120,6 +138,7 @@ stellar contract deploy --wasm ... --source-account my-key --network testnet - RPC: `https://soroban-testnet.stellar.org` · Horizon: `https://horizon-testnet.stellar.org` - Passphrase: `"Test SDF Network ; September 2015"` · Friendbot: `https://friendbot.stellar.org` - **Testnet resets quarterly** — everything is deleted. Script your deployments; never treat testnet state as durable. +- Testnet runs the next protocol version before mainnet — it's where you verify against an upcoming upgrade. ## Integration tests @@ -155,7 +174,7 @@ cargo install --locked cargo-fuzz cargo fuzz init ``` -`Cargo.toml` needs `crate-type = ["lib", "cdylib"]`; add `soroban-sdk = { version = "...", features = ["testutils"] }` to `fuzz/Cargo.toml`. +The default scaffold's `crate-type = ["lib", "cdylib"]` already exposes the lib target fuzzing needs; add `soroban-sdk = { version = "...", features = ["testutils"] }` to `fuzz/Cargo.toml`. ```rust // fuzz/fuzz_targets/fuzz_deposit.rs @@ -171,7 +190,6 @@ fuzz_target!(|amount: i128| { let client = ContractClient::new(&env, &contract_id); let user = Address::generate(&env); - client.initialize(&user); let _ = client.try_deposit(&user, &amount); // must never panic unexpectedly }); ``` @@ -200,7 +218,7 @@ Workflow: fuzz interactively to find deep bugs → convert findings to proptest Three techniques worth knowing; each is one command plus a doc link: -- **Test snapshots**: every test writes a JSON snapshot of events + final ledger state to `test_snapshots/`. Commit them — diffs expose unintended behavioral changes. [Docs](https://developers.stellar.org/docs/build/guides/testing/differential-tests-with-test-snapshots) +- **Test snapshots**: every test writes a JSON snapshot of events + final ledger state to `test_snapshots/`. Commit them — diffs expose unintended behavioral changes. (Disable per-env with `Env::new_with_config` if they're noise.) [Docs](https://developers.stellar.org/docs/build/guides/testing/differential-tests-with-test-snapshots) - **Fork testing**: `stellar snapshot create --address C... --output json --out snapshot.json`, then `Env::from_ledger_snapshot_file("snapshot.json")` to test against real network state. Also useful for upgrade rehearsals: `stellar contract fetch --id C... --out-file deployed.wasm`, register both old and new versions, compare behavior. [Docs](https://developers.stellar.org/docs/build/guides/testing/fork-testing) - **Mutation testing**: `cargo install --locked cargo-mutants && cargo mutants` — mutates your source and reports `MISSED` where tests didn't notice. [Docs](https://developers.stellar.org/docs/build/guides/testing/mutation-testing) @@ -211,7 +229,7 @@ stellar contract invoke --id CONTRACT_ID --source-account alice --network testne --send=no -- function_name --arg value ``` -Simulation reports CPU instructions, ledger reads/writes, and fees without submitting. +Simulation reports CPU instructions, ledger reads/writes, and fees without submitting. In unit tests, `env.cost_estimate().resources()` gives the same numbers programmatically. ## CI @@ -233,10 +251,10 @@ For integration jobs, run `stellar/quickstart` as a service container and deploy ## Checklist -- [ ] Unit tests cover all public functions, including error paths +- [ ] Unit tests cover all public functions, including error paths (`try_` methods) - [ ] Edge cases: zero amounts, max values, empty state - [ ] Authorization verified with `env.auths()` or specific `mock_auths` -- [ ] Events asserted +- [ ] Events asserted (`event.to_xdr` comparison) - [ ] Storage TTL behavior validated - [ ] Cross-contract interactions tested against real WASM - [ ] Fuzz targets for value-moving paths (deposit, withdraw, swap)