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 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-sdkmajor version tracks the protocol version (SDK 27 ↔ protocol 27). This rule outlives any specific release.- 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
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 (what stellar contract init generates):
[lib]
crate-type = ["lib", "cdylib"] # lib is needed for tests and fuzzing
[dependencies]
soroban-sdk = "27" # protocol 27, which mainnet runs at the time of writing.
# 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 = "27", 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:
#![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
# 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:
REPO=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.
# .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
// 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.