Skip to content

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

67 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

soroban-testkit

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 timestamp and sequence
  • Asserting on emitted events without hand-decoding Val topics
  • Generating adversarial i128 values 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.

For Soroban developers

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.

Example

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();
# }

Modeling nested authorization

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.

CLI

A companion binary, soroban-testkit-cli (installs as soroban-testkit), for things that don't belong in a dev-dependency:

Installation

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 --locked

Verify the installation:

soroban-testkit --help

Clean environment validation

To 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).

Usage

# Coverage for native contract tests (wraps cargo-llvm-cov, which needs no
# Soroban-specific flags since unit/integration tests run natively in the
# test harness, not against the compiled .wasm).
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 --strict

Failure examples

Each command exits non-zero on failure, with the reason on stderr (#249):

# coverage: cargo-llvm-cov not installed, or the run itself fails
$ soroban-testkit coverage
error: failed to run `cargo llvm-cov` (No such file or directory); is cargo-llvm-cov installed? try `cargo install cargo-llvm-cov`
$ soroban-testkit coverage --fail-under 90
error: coverage run failed (cargo llvm-cov exited with exit status: 101)

# limits: unknown --fn or --ramp parameter name
$ soroban-testkit limits --contract target/wasm32v1-none/release/my_contract.wasm \
  --fn batch_payout --ramp nonexistent_param
error: "batch_payout" has no parameter named "nonexistent_param"

# audit --strict: exits non-zero when findings exist (printed above this line)
$ soroban-testkit audit ./src --strict
error: 2 audit finding(s) (--strict)

soroban-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.

Known limitations

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 helpers

  • TTL inspection and expiry use soroban-sdk::testutils in the current in-memory environment. They do not fetch a network's archival configuration or exercise restore-footprint operations.
  • ttl_of follows the SDK behavior and panics when a persistent or temporary entry is missing or already expired. Instance storage has one TTL per contract, so its key argument is ignored.
  • expire(kind) is intentionally coarse. It reads the environment's current max_entry_ttl and advances the whole ledger beyond it; kind documents the caller's intent but does not restrict expiry to one entry or storage kind. Use a disposable TestEnv when unrelated state must survive.

Expiry versus archival

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.

Limits command

  • limits registers 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 named token receives 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 command

audit checks source syntax for three local patterns:

  • missing-require-auth: a function takes an Address parameter and its body never calls require_auth.
  • unchecked-i128-arithmetic: +, -, *, or / on a value typed i128 in the same function, instead of a checked_/saturating_/wrapping_ method.
  • missing-ttl-bump: a persistent or temporary storage get with no extend_ttl call 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.

Roadmap

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.

Coverage thresholds

--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 80

In CI, run it as an ordinary step; its exit code gates the job.

Output controls

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.

Offline install

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.toml

With 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.

Supported versions

  • Rust: the stable channel pinned in rust-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 root Cargo.toml is the only version this crate is tested against for every PR. A weekly scheduled-sdk-check CI 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.

Status

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.

Prior art

soroban-fork does lazy mainnet/testnet forking for tests. That is a different problem; soroban-testkit does not attempt it.

License

Apache-2.0

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages