Library of audited Soroban smart-contract building blocks for the Stellar
network. Contracts target wasm32v1-none; library crates are no_std.
packages/
├── access/ # access_control, ownable, role_transfer
├── accounts/ # smart-account policies (multisig, thresholds)
├── contract-utils/ # pausable, upgradeable, math, crypto, merkle_distributor
├── fee-abstraction/ # fee forwarder
├── governance/ # governor, timelock, votes
├── macros/ # only_owner / only_role / when_paused, etc.
├── tokens/ # fungible + non_fungible + rwa + vault
└── zk-email/ # zk-email auth primitives
examples/ # one example crate per feature; cdylib only
audits/ # security audit reports
Architecture.md # in-depth design doc — read this first for context
CONTRIBUTING.md # workflow, PR rules, AI-usage policy
Each library package follows the same shape:
<package>/src/<module>/
├── mod.rs # docstring, trait (#[contracttrait]), errors, constants, events
├── storage.rs # #[contracttype] storage keys + free functions
└── test.rs # #[cfg(test)] mod test;
# Format (NIGHTLY required — rustfmt.toml uses unstable_features)
cargo +nightly fmt --all -- --check
# Lint (warnings are errors in CI)
cargo clippy --release --locked --all-targets -- -D warnings
# Test the workspace
cargo test
# WASM release build (per-package; see CI note in .github/workflows/generic.yml)
cargo build --target wasm32v1-none --release --package <name>
# Coverage (CI threshold: 90% line coverage)
cargo llvm-cov --workspace --fail-under-lines 90When iterating, prefer --package <name> to avoid rebuilding the whole
workspace.
These are non-obvious rules where AI-generated drafts most often go wrong.
For the full checklist see .claude/skills/code-quality.md.
-
Three tiers:
instance(singletons),persistent(long-lived per-key),temporary(time-bounded — allowances, pending transfers). -
Library-owned
persistent/temporaryreads extend TTL; writes do not. Canonical pattern:if let Some(value) = e.storage().persistent().get::<_, T>(&key) { e.storage().persistent().extend_ttl(&key, FOO_TTL_THRESHOLD, FOO_EXTEND_AMOUNT); value } else { Default::default() }
or, if we are not going to use the value:
if e.storage().persistent().has(&key) { e.storage().persistent().extend_ttl(&key, FOO_TTL_THRESHOLD, FOO_EXTEND_AMOUNT); }
Argument order is always
(&key, TTL_THRESHOLD, EXTEND_AMOUNT). -
instanceTTL is the contract developer's responsibility, not the library's. Libraries exposeINSTANCE_TTL_THRESHOLDandINSTANCE_EXTEND_AMOUNTas defaults but never callinstance().extend_ttl()themselves. -
Constants follow the trio:
const DAY_IN_LEDGERS: u32 = 17280; pub const FOO_EXTEND_AMOUNT: u32 = N * DAY_IN_LEDGERS; pub const FOO_TTL_THRESHOLD: u32 = FOO_EXTEND_AMOUNT - DAY_IN_LEDGERS;
panic_with_error!(e, ErrorEnum::Variant)— never barepanic!/unwrap()in non-test code. (expect("...")is fine when the message explains why the value is guaranteed to exist.)- Never call
require_auth()twice on the same address in one invocation — Soroban panics. This is why#[has_role]/#[has_any_role]exist alongside#[only_role]/#[only_any_role]:only_*injectsrequire_auth(). Use when the body doesn't already require auth.has_*does NOT injectrequire_auth(). Use when the body (or theBase::helper it delegates to) already does.
- High-level fn does auth + emits event. Low-level
_no_authsibling does neither and acceptscaller: &Addresspurely for the event. Don't mix the two halves. - Functions that intentionally skip auth (e.g.
Base::mint,pausable::pause,set_admin,set_owner,set_metadata) MUST carry a# Security Warningdoc block explaining the missing check.
Token-style traits use an associated type to enforce mutually-exclusive extensions at compile time:
#[contracttrait]
pub trait FungibleToken {
type ContractType: ContractOverrides;
fn transfer(e: &Env, from: Address, to: MuxedAddress, amount: i128) {
Self::ContractType::transfer(e, &from, &to, amount);
}
}In contract impl blocks:
#[contractimpl(contracttrait)]onimpl <Trait> for Contract— picks up default method bodies from the#[contracttrait].#[contractimpl](no arg) on the contract's inherentimplblock (the one with__constructorand contract-specific helpers).
Reversing the two is a common mistake.
- Errors:
#[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)], PascalCase variants. Stay within the package's existing numeric range (pausable 1000s, fungible 100s, access-control 2000s, ownable 2100s, …) — don't invent new ranges. - Events:
#[contractevent]struct (PascalCase, past-tense or noun) + snake_casepub fn emit_<event>(e: &Env, ...)helper that.publish(e)s. Don't call.publish(e)directly fromstorage.rs— funnel through the helper inmod.rs. - Section delimiters in
mod.rs:// ################## ERRORS ##################/CONSTANTS/EVENTS. Instorage.rs:QUERY STATE/CHANGE STATE/LOW-LEVEL HELPERS. The 18-hash form is canonical — variations like// === ... ===are violations.
- Library crates are
#![no_std]. Don't reach forstd. - All packages have
[package.metadata.stellar] cargo_inherit = true. - Workspace fields use
field.workspace = trueshorthand; library crates setcrate-type = ["lib", "cdylib"], examples set["cdylib"]only. - Two-step transfer pattern (initiate + accept with
live_until_ledger) for any owner/admin handover. Reuserole_transferrather than rolling a new one. __constructoris the only place where one-shot setters (set_owner,set_admin, ...) may be called without auth.- Reads are free; writes are expensive. Computation is cheap; developer
experience is expensive. Optimise accordingly — the
enumerableandconsecutiveextensions are the reference for the right balance.
- Test files start with
extern crate std;. - Setup:
Env::default(),Address::generate(&e),e.register(MockContract, ())(library) ore.register(ExampleContract, (...))(example with constructor args). - Auth:
e.mock_all_auths(). Don't hand-build auth payloads unless the test is specifically about the auth machinery. - Library-internal calls go inside
e.as_contract(&address, || { ... }); example tests go through the generatedExampleContractClient. - Event assertions: compare the emitted entry against the typed
#[contractevent]struct serialized with.to_xdr(&e, &address):Uselet events = e.events().all(); assert_eq!(events.events().len(), 1); assert_eq!( events.events().first().unwrap(), &Transfer { from: from.clone(), to: to.clone(), amount }.to_xdr(&e, &address), );
.first()for index 0 and.get(i)for later events (clippy'sget_firstrejects.get(0)). Hand-decoding topics/data out ofe.events().all()field-by-field is a violation — build the event struct and letto_xdrproduce the wire form. - Panic tests use the numeric form:
#[should_panic(expected = "Error(Contract, #<code>)")].
/code-quality— review the current package/file against the full checklist. Lists violations and offers to apply fixes (all, a subset, or none), then runscargo +nightly fmt/cargo clippy/cargo test. Local maintainer tool — not wired into CI. See.claude/skills/code-quality.md./release-prep— version bumps, README updates, build for a release cut. See.claude/skills/release-prep.md.
CONTRIBUTING.md is the canonical reference for the human workflow,
including the start-with-an-issue rule and the explicit AI-usage
policy. Two points worth reinforcing:
- New features need a discussed issue first. PRs without an associated issue may be redirected.
- AI output is a first draft. Treat it that way: read every line, run
cargo +nightly fmt,cargo clippy, andcargo testlocally before opening a PR. The maintainers close low-effort, unreviewed AI submissions without detailed review.