Stellar Smart Contracts
Guide for building Stellar smart contracts in Rust. Smart contracts on Stellar were formerly branded "Soroban" — the platform name is retired, but the Rust SDK (soroban-sdk) and several tool names keep the prefix.
This file covers setup and the core workflow. The deep dives live alongside it — read the file that matches the task:
| Task | File |
|---|---|
| Storage/TTL, authorization, constructors, cross-contract calls, tokens, events, errors, upgrades, factories, governance/DeFi patterns, fees/resources, troubleshooting | development.md |
| Unit, integration, fuzz, property, fork, and mutation testing | testing.md |
| Security review, vulnerability classes, checklists, audit prep, tooling | security.md |
When to use this skill
- Writing a Stellar smart contract in Rust
- Setting up contract tests (any layer)
- Reviewing a contract for security issues
- Architecting upgradeable contracts, factories, custom accounts, or DeFi primitives
- Debugging a contract-specific error (auth, storage, archival, resource limits)
Related skills
- 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 29 (soroban-sdk v29, rs-soroban-env v29, stellar-cli v28). Version numbers in examples are illustrative — resolve the current ones from these sources rather than trusting any doc:
soroban-sdkmajor version tracks the protocol version (SDK 29 ↔ protocol 29). This rule outlives any specific release.- Build with
stellar contract build(stellar-cliv25.2.0+). Fromsoroban-sdk28, a plaincargo buildfor a Wasm target stops with an error, because the CLI must strip unreachable spec entries after compiling. Nativecargo testis unaffected. - Latest SDK release: 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 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
getVersionInfoor Stellar Lab. - Numeric network limits quoted here are mainnet settings at time of writing; they change by vote — Stellar Lab's Network Limits page and
stellar network settings --network mainnetshow the live values.
Platform constraints
Contracts are Rust compiled to WebAssembly, run in a sandboxed host:
#![no_std]required — usesoroban_sdktypes (String,Vec,Map,Symbol), not the Rust standard library- Compile for the
wasm32v1-nonetarget (Rust ≥ 1.84) — the only Wasm target the Stellar runtime supports - 128KB compiled contract size limit (network-configured)
Symbolis 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
- No
delegatecall, and cross-contract reentrancy is blocked by the host — see security.md - No I/O, no networking, no clock beyond the ledger timestamp — everything a contract can do hangs off
Env
Project setup
bashstellar contract init my-contract # scaffolds a Cargo workspace with contracts/ cd my-contract rustup target add wasm32v1-none # once per toolchain
Cargo.toml essentials (what stellar contract init generates):
toml[lib] crate-type = ["lib", "cdylib"] # lib is needed for tests and fuzzing [dependencies] soroban-sdk = "29" # match the protocol of the network you deploy to. # Pre-releases (`-rc.x`) exist only during a rollout and need the # exact version string. Check crates.io for the latest release, and # the live protocol version of your target network, before deploying. [dev-dependencies] soroban-sdk = { version = "29", features = ["testutils"] } # match above [profile.release] opt-level = "z" overflow-checks = true debug = 0 strip = "symbols" debug-assertions = false panic = "abort" codegen-units = 1 lto = true
Contract anatomy
One compact example showing state, constructor, auth, TTL, a typed error, and an event:
rust#![no_std] use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, Address, Env, }; #[contracttype] #[derive(Clone)] pub enum DataKey { Admin, Counter, } #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] 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. 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); } pub fn increment(env: Env) -> Result<u32, Error> { let admin: Address = env .storage() .instance() .get(&DataKey::Admin) .ok_or(Error::NotInitialized)?; admin.require_auth(); let count: u32 = env.storage().instance().get(&DataKey::Counter).unwrap_or(0); let count = count + 1; env.storage().instance().set(&DataKey::Counter, &count); // Extend TTL so contract state is not archived (threshold, extend-to) env.storage().instance().extend_ttl(120 * 17280, 180 * 17280); Incremented { by: admin, count }.publish(&env); Ok(count) } pub fn get_count(env: Env) -> u32 { env.storage().instance().get(&DataKey::Counter).unwrap_or(0) } }
Full patterns (three storage types, auth variants, cross-contract calls, tokens, custom types): 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) stellar keys generate alice --network testnet --fund # Deploy (constructor args go after the `--`) stellar contract deploy \ --wasm target/wasm32v1-none/release/my_contract.wasm \ --source-account alice \ --network testnet \ -- \ --admin alice # Invoke stellar contract invoke \ --id CONTRACT_ID \ --source-account alice \ --network testnet \ -- \ increment
To upload WASM without instantiating (e.g. for factories or upgrades), use stellar contract upload (the older stellar contract install is a deprecated alias).
Verify your build
Nothing on-chain ties a deployed WASM to its source. SEP-0055 (Draft) closes that gap: build in GitHub Actions, stamp the source repository into the WASM's contractmetav0 section, and publish a GitHub build attestation over the binary — anyone can then walk from the on-chain WASM hash back to the commit and the workflow run that produced it. Verifiers read the attestation from api.github.com/repos/<owner>/<repo>/attestations/sha256:<wasm-hash> unauthenticated, so the source repository has to be public for that "anyone" to hold. On the Free, Pro, and Team plans a private or internal repo cannot publish an attestation at all. Enterprise Cloud can, but only accounts with repository access read it, so no user of your contract can. Make it a standard step before any mainnet deploy; it costs one workflow file, so there's no reason to reserve it for user-facing contracts.
Two metadata entries carry it (stellar contract build --meta key=value, read back with stellar contract info meta --wasm <file>):
source_repo=github:<owner>/<repo>— where the source liveshome_domain=<domain>— domain serving your SEP-1stellar.toml, for org/token lookup (optional; bare domain, no scheme or path)
soroban-build-workflow does the whole job — build with the metadata, optimize, attest, publish a release with the WASM attached, and register the build with stellar.expert's contract validation.
Resolve its version yourself instead of copying a pin out of any documentation, this file included. Pins go stale, and the newest tag in that repo is not always a published release:
bashREPO=stellar-expert/soroban-build-workflow TAG=$(gh release view --repo "$REPO" --json tagName --jq .tagName) # latest release SHA=$(gh api "repos/$REPO/commits/$TAG" --jq .sha) # its commit gh api "repos/$REPO/contents/.github/workflows/release.yml?ref=$SHA" \ -H "Accept: application/vnd.github.raw" # the file you will pin
Read release.yml at that SHA, never at the tag. A tag can move between the two steps, and then you review one commit and pin another. It should check out the tagged commit, build from source, and attest the same file it uploads. If it doesn't, stop and tell the user rather than pinning it anyway. Then pin that full SHA, because this workflow runs with your repository token, OIDC, and attestation permissions.
Tell the user where that pin stops. It freezes release.yml, not the actions release.yml calls. Those sit on mutable tags of their own (actions/checkout@v4, the CLI, the attest action) and resolve at run time under the same permissions, and a caller cannot pin them. Fork the workflow if that residual risk is unacceptable.
yaml# .github/workflows/release.yml — verified build on every version tag name: Build and Release on: push: tags: ["v*"] permissions: # inherited by the called workflow, which cannot elevate them contents: write # create the release, attach the WASM id-token: write # sign the attestation attestations: write # publish it jobs: release: # the SHA you resolved and reviewed; keep the version in the comment uses: stellar-expert/soroban-build-workflow/.github/workflows/release.yml@<commit-sha> # vX.Y.Z with: release_name: ${{ github.ref_name }} home_domain: example.com # optional package: my-contract # optional — omit to build the working directory secrets: release_token: ${{ secrets.GITHUB_TOKEN }} # created by GitHub, nothing to configure
Then deploy the WASM attached to that release, not a local rebuild — compilation environments vary, and a hash that differs by one byte matches no attestation.
The result shows up in Stellar Lab's contract explorer and on stellar.expert. Be precise with users about what it proves: a specific workflow run built this binary from this commit. It says nothing about whether that code is safe or was ever reviewed.
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}; #[test] fn test_increment() { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); let contract_id = env.register(CounterContract, (admin.clone(),)); let client = CounterContractClient::new(&env, &contract_id); assert_eq!(client.increment(), 1); assert_eq!(client.get_count(), 1); }
Auth mocking, event assertions, fuzzing, fork tests, and CI setup: testing.md.
Before mainnet
Work through the checklists in security.md — authorization, reinitialization, arithmetic, storage TTLs, and cross-contract validation are the recurring failure modes. Ship the deploy through a verified build so the on-chain WASM hash traces back to a reviewable commit.


