The testing crate you would have written yourself, on the third contract.
Testing infrastructure for Soroban contracts: property-test generators for money math, an event-assertion API, ledger time and TTL-expiry control, token test doubles, authorization matrices, and a coverage-aware CLI.
soroban-sdk ships testutils, which is adequate for basic unit tests. It
is not adequate for testing contracts that hold money. Every serious
Soroban contract repo independently rebuilds some subset of:
- Advancing ledger time correctly, including the interaction between
timestampandsequence - Asserting on emitted events without hand-decoding
Valtopics - Generating adversarial
i128values that actually find overflow bugs - Deploying and funding a Stellar Asset Contract token double for transfer tests
- Proving that every privileged entry point rejects unauthorized callers
- Simulating TTL expiry
soroban-testkit is that shared layer, as a dev-dependency crate plus a
small CLI.
Keywords: Soroban testing, smart contracts, Stellar, WASM testing, property testing, money math, event assertions, TTL control, test coverage, resource limits, audit tools.
If you're building a Soroban contract that handles tokens, transfers, or funds, or if you need property-test generators for money math, event-assertion APIs, or TTL expiry simulation, this crate is designed for you. Use it as a dev-dependency in your contract crate for unit and integration tests, or use the CLI for coverage analysis and static audits.
A small escrow contract, tested end to end: mint a token, release it to the seller, and check the event, the balances, and that no value was created or destroyed along the way.
use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env};
use soroban_testkit::prelude::*;
#[contract]
struct Escrow;
#[contractimpl]
impl Escrow {
pub fn release(env: Env, token: Address, from: Address, to: Address, amount: i128) {
from.require_auth();
soroban_sdk::token::TokenClient::new(&env, &token)
.transfer(&from, soroban_sdk::MuxedAddress::from(to), &amount);
#[allow(deprecated)]
env.events().publish((symbol_short!("release"),), amount);
}
}
# fn main() {
let env = TestEnv::new();
let token = env.token();
let [buyer, seller]: [soroban_sdk::Address; 2] =
env.addresses(2).try_into().unwrap();
// `mint` takes whole tokens and scales by 10^decimals (7 for a SAC);
// `mint_raw` takes base units (stroops) as-is. Both lines below fund
// the buyer with 100 tokens = 1_000_000_000 base units.
token.mint(&buyer, 100);
let other_buyer = env.address();
token.mint_raw(&other_buyer, 1_000_000_000);
assert_eq!(token.balance(&buyer), token.balance(&other_buyer));
let escrow_id = env.env().register(Escrow, ());
let client = EscrowClient::new(env.env(), &escrow_id);
env.env().mock_all_auths();
// Capture scope: `events` holds only what this closure's call emitted.
// Any later call, including a balance query, starts a new top-level
// invocation, so inspect `events` here rather than re-reading the log
// after subsequent calls. See EventLog's note on scope.
let (_, events) = env.events_during(|| {
client.release(&token.address(), &buyer, &seller, &1_000_000_000);
});
events
.from(&escrow_id)
.assert_emitted(symbol_short!("release"));
// Balances are asserted (in base units) only after the events above
// were read.
token.assert_balance(&buyer, 0);
token.assert_balance(&seller, 1_000_000_000);
Conservation {
deposited: 1_000_000_000,
withdrawn: 1_000_000_000,
refunded: 0,
remaining: 0,
}
.assert_holds();
# }
When a privileged entry point makes an authorized sub-invocation, put this
shape inside the invoke closure passed to AuthMatrix::entry_point. The
root invocation is the entry point under test; each nested invocation
belongs in its parent's MockAuthInvoke::sub_invokes slice. This lets
AuthMatrix check the same authorization shape that the contract executes
instead of granting one flat authorization to the whole call.
let result = Client::new(env, &parent_id)
.mock_auths(&[MockAuth {
address: &caller,
invoke: &MockAuthInvoke {
contract: &parent_id,
fn_name: "withdraw",
args: (child_id.clone(), amount).into_val(env),
sub_invokes: &[MockAuthInvoke {
contract: &child_id,
fn_name: "authorize",
args: (caller.clone(), amount).into_val(env),
sub_invokes: &[],
}],
},
}])
.try_withdraw(&child_id, &amount);
Use the generated client's actual argument tuple and try_* method for your
contract. The auth module documentation,
including the AuthMatrix Rustdoc,
contains the complete matrix contract and failure behavior; the
MockAuthInvoke tree above is only the nested-call part.
A companion binary, soroban-testkit-cli (installs as soroban-testkit),
for things that don't belong in a dev-dependency:
Install the companion binary via cargo install:
# From crates.io
cargo install soroban-testkit-cli --locked
# Or from a local workspace checkout
cargo install --path crates/cli --lockedVerify the installation:
soroban-testkit --helpTo validate CLI installation in a clean temporary environment without modifying your global Cargo binary cache:
# Create an isolated temporary directory
TMP_DIR=$(mktemp -d)
# Install into the temporary root
CARGO_INSTALL_ROOT="$TMP_DIR" cargo install --path crates/cli --locked
# Validate the binary runs in the isolated environment
"$TMP_DIR/bin/soroban-testkit" --help
# Clean up
rm -rf "$TMP_DIR"A reproducible smoke-test script is also provided at scripts/smoke_test_cli_install.sh (runnable via make smoke-test-install).
# Coverage for the WASM target (wraps cargo-llvm-cov, which needs no
# Soroban-specific flags since contract tests run natively).
soroban-testkit coverage --format html --open
# Empirically find how many recipients a batch operation can handle
# before it exceeds mainnet resource limits.
soroban-testkit limits --contract target/wasm32v1-none/release/my_contract.wasm \
--fn batch_payout --ramp recipients
# Static checks: missing require_auth, unchecked i128 arithmetic,
# storage reads with no TTL bump. Not a security product.
soroban-testkit audit ./src --strictsoroban-testkit audit is a linter with three heuristics, not a security
product. It can miss real bugs and flag non-bugs; a missing finding is not a
security guarantee. This is the same bounded claim made by the command output
and the Module 9 requirements in BUILD_SPEC.md.
See Audit command limitations.
limits ramps a numeric parameter directly, or generates addresses for a
Vec<Address> parameter — the common "how many recipients" question.
Every ramp attempt runs in its own subprocess: a real .wasm contract
that exceeds resource limits can abort the process outright rather than
return an error, and isolating each attempt is the only safe way to probe
past that boundary. Ledger read/write counts and transaction size aren't
reported (they come from a network-side simulated footprint this crate
doesn't produce); instructions and memory, measured locally, are. See
Limits command limitations.
A run prints the last successful ramp value and exactly two measured fields, both from the local SDK budget:
last successful recipients for "batch_payout": 37
instructions: 98213456
memory bytes: 4718592
(ledger reads/writes and transaction size are not measured by this command; see --help)
The numbers above are illustrative; the field names are not. instructions
is the CPU-instruction count and memory bytes the memory budget consumed by
one call at the reported value. Nothing else is measured.
These constraints follow from the current soroban-sdk test environment and
the project's network-free boundary. They are supported boundaries, not
claims about live-network behavior.
- TTL inspection and expiry use
soroban-sdk::testutilsin the current in-memory environment. They do not fetch a network's archival configuration or exercise restore-footprint operations. ttl_offollows the SDK behavior and panics when a persistent or temporary entry is missing or already expired. Instance storage has one TTL per contract, so itskeyargument is ignored.expire(kind)is intentionally coarse. It reads the environment's currentmax_entry_ttland advances the whole ledger beyond it;kinddocuments the caller's intent but does not restrict expiry to one entry or storage kind. Use a disposableTestEnvwhen unrelated state must survive.
Temporary storage is intentionally disposable: after its TTL expires, the entry is gone and a contract must handle the missing value. Persistent storage is different: an expired entry is archival state that can be brought back by a live-network restore-footprint workflow, then rewritten or extended. The testkit does not model that workflow.
The supported network-free check is therefore to test the contract's post-expiry behavior, and to test TTL extension while an entry is still present:
env.assert_bumps_ttl(&contract_id, StorageKind::Persistent, key, || {
client.touch_record_checked();
});
env.assert_survives_expiry(StorageKind::Temporary, || {
client.read_temp_checked(); // handles the missing entry
});
Do not treat expire as proof that persistent archival can be restored, or
that a temporary entry can be recovered. Those network and restore-footprint
semantics are outside the current SDK testutils boundary.
limitsregisters the supplied WASM in a fresh local SDK environment for every probe, allows mocked authorization, and reports local CPU-instruction and memory budgets. It is a diagnostic ceiling, not a network simulation or a guarantee that the same call fits a live transaction.- Ramp parameters are limited to integer types and
Vec<Address>. Other parameters receive fixed defaults only for the supported primitive types; a parameter namedtokenreceives a funded SAC as a documented heuristic. - Contracts must currently register with empty constructor arguments. Support for constructor arguments is tracked in #242.
- Ledger read/write counts and transaction size are unavailable because they require the network-side simulated footprint that this command deliberately does not produce.
audit checks source syntax for three local patterns:
missing-require-auth: a function takes anAddressparameter and its body never callsrequire_auth.unchecked-i128-arithmetic:+,-,*, or/on a value typedi128in the same function, instead of achecked_/saturating_/wrapping_method.missing-ttl-bump: a persistent or temporary storagegetwith noextend_ttlcall in the same function body.
It does not expand macros, follow calls into helper functions, or replace a
contract security review, so both false positives and false negatives are
expected. Every run that isn't --quiet, including one with no findings,
ends with the same disclaimer: a missing finding is not a security guarantee.
The supported surface today is deliberately in-process and network-free:
isolated test environments, ledger-clock control, adversarial money values,
event assertions, SAC token doubles, authorization matrices, TTL-expiry
checks, and the coverage, limits, and three-rule audit CLI commands.
The next increments are kept small and tracked in these curated issues:
| Area | Planned increment | Tracking issues |
|---|---|---|
| Ledger ergonomics | Sequence-oriented advancement and reusable clock checkpoints | #60, #61 |
| TTL assertions | Minimum-TTL and exact TTL-delta assertions | #187, #188 |
| Audit workflow | Rule selection, documented suppressions, baselines, and rule explanations | #215, #216, #220, #221 |
| Limits CLI | Constructor arguments for contracts that cannot be registered with () |
#242 |
| Real-contract validation | Recurring-contract and batch-payout authorization coverage | #266, #267 |
Issues describe intended work, not release promises. Mainnet/testnet forking,
deployment tooling, frontend testing, a test runner, a full benchmarking
framework, and network-dependent test behavior remain out of scope; see
BUILD_SPEC.md §3.
audit reports each finding's path relative to the audited PATH, with
/ separators, so soroban-testkit audit src run from the crate and
soroban-testkit audit my_contract/src run from its parent both print
lib.rs:12: .... When PATH is a single file, its file name is used.
--fail-under PCT makes coverage exit non-zero when line coverage is
below PCT. It is forwarded unchanged to cargo-llvm-cov as
--fail-under-lines PCT, so cargo-llvm-cov does the measuring and the
comparison; it combines with any --format. Requires
cargo install cargo-llvm-cov. Running the command locally makes no
network access.
# Exits 0 if line coverage is >= 80%, otherwise prints the report and
# fails with: error: coverage run failed (cargo llvm-cov exited with ...)
soroban-testkit coverage --fail-under 80
# Same threshold, writing lcov.info for an IDE or CI upload step.
soroban-testkit coverage --format lcov --fail-under 92.5
# What actually runs (shown with --verbose):
# cargo llvm-cov test --fail-under-lines 80
soroban-testkit --verbose coverage --fail-under 80In CI, run it as an ordinary step; its exit code gates the job.
These global flags go before or after the subcommand:
| Flag | Effect |
|---|---|
--no-color |
No ANSI color codes, including from the cargo that coverage runs. A non-empty NO_COLOR environment variable does the same. Color is only ever written to a terminal, so piped output is already plain. |
-q, --quiet |
Results and errors only: drops advisory notes such as the audit disclaimer and "no findings". Passes --quiet to cargo-llvm-cov. |
-v, --verbose |
Extra diagnostics on stderr (files scanned, commands run, each limits probe). Passes --verbose to cargo-llvm-cov. Stdout is unchanged. |
--quiet and --verbose cannot be combined. Findings, measured limits,
--json documents, and errors are printed at every level.
Contributors working without network access to crates.io can prefetch and verify dependencies ahead of time, then build and test fully offline:
# On a machine with network access: fetch every dependency into the
# local Cargo registry cache and vendor it alongside the workspace.
cargo vendor --locked vendor > cargo-vendor-config.toml
# Copy `vendor/` and `cargo-vendor-config.toml` to the offline machine,
# then point Cargo at the vendored sources instead of crates.io by
# appending the generated config to .cargo/config.toml:
mkdir -p .cargo
cat cargo-vendor-config.toml >> .cargo/config.tomlWith the vendor directory in place, cargo build, cargo test --workspace, and cargo doc --workspace all resolve dependencies from
vendor/ with no network access. cargo vendor --locked fails loudly if
Cargo.lock and the registry disagree, so re-run it after any dependency
bump rather than editing vendor/ by hand. Verify the result before
relying on it:
cargo test --workspace --offline--offline makes Cargo error immediately if any dependency would need a
network fetch, instead of silently falling back to it.
- Rust: the
stablechannel pinned inrust-toolchain.toml. There is no separate MSRV — the pinned stable toolchain is the only version CI runs against. See CONTRIBUTING.md's Rust edition policy for how and when that pin moves. soroban-sdk: the version pinned in[workspace.dependencies]in the rootCargo.tomlis the only version this crate is tested against for every PR. A weeklyscheduled-sdk-checkCI job additionally builds and tests the workspace against whatever version crates.io currently considers latest, independent of that pin, and opens a tracking issue if a newer release breaks compatibility — so drift is caught before a contributor hits it, without requiring every PR to track upstream.
This crate is under active development. See BUILD_SPEC.md for the build
plan and module boundaries, and ARCHITECTURE.md for a
contributor-facing map of the workspace and how its modules fit together.
soroban-fork does lazy
mainnet/testnet forking for tests. That is a different problem;
soroban-testkit does not attempt it.
Apache-2.0