TESTING GUIDE • SOROBAN SDK v21

Smart Contract Testing Guide

Comprehensive testing strategy before mainnet deployment

🧪

Why Test?

Scientists use the scientific method: careful observation coupled with rigorous skepticism. If we aren’t rigorously skeptical about whether something works, we’re making assumptions. And as the saying goes: “Untested assumptions become bugs.”

💬
“If debugging is the process of removing software bugs, then programming must be the process of putting them in.”
— Edsger W. Dijkstra

For testing to be effective at identifying bugs early, it must be integrated with development—not an afterthought. Good tests:

Confirm Correctness
Verify software does what was intended the first time it is built
🔄
Enable Refactoring
Provide confidence to refactor knowing functionality hasn’t changed
🔍
Prove Scope
Help developers understand and prove the scope of a change
👥
Multiply Teams
Give others confidence to change code they have no prior experience with

Why Stellar Testing is Different

Stellar has been online since 2014, and when it added smart contracts in 2024, the focus was on a great testing experience from day one.

🧰
Single Language
Write contracts in Rust, test in Rust. Same APIs for unit tests, integration tests, fuzz tests. No context switching.
🎯
Real Runtime
Tests use the actual Soroban runtime that runs on Mainnet. No emulators or simulators playing catch-up with reality.
💻
IDE Support
VSCode, Cursor, RustRover, Vim, Zed—anything supporting rust-analyzer and LLDB gives you step-through debugging.
🌍
Rich Ecosystem
Stable Testnet, free RPCs, Friendbot, Lab (lab.stellar.org), lightweight Docker image with accelerated ledgers.
⚠️
A single bug could cost you or your users significant funds
Testing isn’t just unit tests—it’s a multi-layered approach. Integrate testing early in development, not as an afterthought. Be rigorously skeptical: test early, test often, and use multiple strategies to catch bugs before production.
📊

Testing Layers Overview

1
Unit Tests
Individual function verification — fast, local, Soroban SDK
2
Integration Tests
Multi-contract interaction testing — cross-contract calls
3
Fuzzing & Mutation
Automated edge-case discovery — random inputs, property tests & code mutation
4
Fork & Testnet Testing
Real-world conditions — snapshot create, Docker local chain & Stellar Testnet
5
Differential Testing
Compare deployed vs new contract behavior — test snapshots & event diffing
6
Security Audits
Professional review — manual + formal verification
7
Mainnet Launch
Deploy after all layers pass — monitor & incident response ready
1

Unit Tests

Foundation

Start with unit tests—small, focused tests that verify individual functions. On Stellar, these run locally using the Soroban SDK with the real Soroban environment (not an emulator), making them fast and reliable.

Rusttests/test_token.rs
use soroban_sdk::{Env, Address};

#[test]
fn test_token_initialize() {
    let env = Env::default();
    let contract_id = env.register(GoldToken, ());
    let client = GoldTokenClient::new(&env, &contract_id);

    let admin = Address::generate(&env);
    client.initialize(
        &admin,
        &7,
        &String::from_str(&env, "GoldToken"),
        &String::from_str(&env, "GOLD"),
    );

    assert_eq!(client.name(), String::from_str(&env, "GoldToken"));
    assert_eq!(client.symbol(), String::from_str(&env, "GOLD"));
    assert_eq!(client.decimals(), 7);
}
Rusttests/test_mint_transfer.rs
#[test]
fn test_mint_and_transfer() {
    let env = Env::default();
    env.mock_all_auths();

    let contract_id = env.register(GoldToken, ());
    let client = GoldTokenClient::new(&env, &contract_id);

    let admin = Address::generate(&env);
    let user1 = Address::generate(&env);
    let user2 = Address::generate(&env);

    client.initialize(&admin, &7, &"GoldToken".into(), &"GOLD".into());

    // Mint 1000 tokens to user1
    client.mint(&user1, &1000_0000000);
    assert_eq!(client.balance(&user1), 1000_0000000);

    // Transfer 250 from user1 to user2
    client.transfer(&user1, &user2, &250_0000000);
    assert_eq!(client.balance(&user1), 750_0000000);
    assert_eq!(client.balance(&user2), 250_0000000);
}
💡
env.mock_all_auths() bypasses authentication in tests so you can focus on logic. In production, real Stellar auth is enforced. Always test with and without mocked auth.
2

Integration Tests

Cross-Contract

Test how your contracts interact with each other. The Soroban SDK uses the real Soroban environment for all tests—there’s no difference in tooling between unit and integration tests. You’re always testing against the actual runtime, not a simulator.

💡
Two approaches: Load dependencies as compiled WASM files (recommended for external/deployed contracts), or import them natively as Rust crates (best for tightly coupled contracts you develop together). You can mix both in the same test suite.

📦 Approach 1: The WASM Way Recommended

Load dependency contracts as compiled WASM files. Ideal for testing against deployed contracts or external dependencies without needing source code access.

ShellFetch deployed contract WASM
# Fetch a deployed contract from Testnet/Mainnet
stellar contract fetch --id C... --out-file pause.wasm
Rusttests/integration_wasm.rs
#![cfg(test)]
use soroban_sdk::Env;
use crate::{Error, IncrementContract, IncrementContractClient};

// Import external contract from compiled WASM
mod pause {
    soroban_sdk::contractimport!(file = "pause.wasm");
}

#[test]
fn test_with_wasm_dependency() {
    let env = Env::default();

    // Register the WASM dependency
    let pause_id = env.register(pause::WASM, ());
    let pause_client = pause::Client::new(&env, &pause_id);

    // Register main contract with constructor arg
    let contract_id = env.register(
        IncrementContract,
        IncrementContractArgs::__constructor(&pause_id),
    );
    let client = IncrementContractClient::new(&env, &contract_id);

    // Test: unpaused works
    pause_client.set(&false);
    assert_eq!(client.increment(), 1);

    // Test: paused returns error
    pause_client.set(&true);
    assert_eq!(client.try_increment(), Err(Ok(Error::Paused)));

    // Test: unpaused again continues count
    pause_client.set(&false);
    assert_eq!(client.increment(), 2);
}
WASM Benefits
• Verifies exact on-chain behavior
• Works with Mainnet, Testnet, or local builds
• No source code access needed
• Avoids Rust/Cargo import complexity
⚠️
WASM Tradeoffs
• Cannot step-through debug into WASM
• Must re-fetch if dependency updates
• Less visibility into internal state
• Slower compilation than native

🔗 Approach 2: The Native Way Tightly Coupled

Import dependency contracts natively as Rust crates. Best for tightly coupled contracts you develop together, giving you full debugging capabilities across all contracts.

Rusttests/integration_dex_token.rs
#![cfg(test)]
use soroban_sdk::{Env, Address};

#[test]
fn test_dex_swap_with_token_contracts() {
    let env = Env::default();
    env.mock_all_auths();

    // Register contracts natively (import as Rust crates)
    let token_a_id = env.register(GoldToken, ());
    let token_b_id = env.register(GoldToken, ());
    let dex_id = env.register(McSwapDex, ());

    let token_a = GoldTokenClient::new(&env, &token_a_id);
    let token_b = GoldTokenClient::new(&env, &token_b_id);
    let dex = McSwapDexClient::new(&env, &dex_id);

    let lp = Address::generate(&env);
    let trader = Address::generate(&env);

    // Initialize tokens & mint
    token_a.initialize(&lp, &7, &"TokenA".into(), &"TKA".into());
    token_b.initialize(&lp, &7, &"TokenB".into(), &"TKB".into());
    token_a.mint(&lp, &10000_0000000);
    token_b.mint(&lp, &10000_0000000);
    token_a.mint(&trader, &100_0000000);

    // Create pool & add liquidity
    dex.create_pool(&token_a_id, &token_b_id, &30);
    dex.add_liquidity(
        &lp, &token_a_id, &token_b_id,
        &5000_0000000, &5000_0000000,
    );

    // Swap: trader sends 100 TKA, should receive ~98.7 TKB
    let received = dex.swap(
        &trader, &token_a_id, &token_b_id,
        &100_0000000, &95_0000000, // min_out
    );

    assert!(received > 95_0000000);
    assert_eq!(token_a.balance(&trader), 0);
    assert!(token_b.balance(&trader) > 95_0000000);
}
Native Benefits
• Full step-through debugging across all contracts
• Better code coverage in fuzzing
• Faster iteration during development
• IDE support for all contract code
⚠️
Native Tradeoffs
• Requires source code access
• Cargo workspace setup needed
• May diverge from deployed WASM
• More complex dependency management
🔗
Token ↔ DEX
Verify swaps correctly move tokens between accounts via AMM pools
🏛️
DAO ↔ Vault
Test governance proposals that execute treasury withdrawals
📈
Token ↔ Staking
Verify staking locks tokens and compound rewards accrue correctly
🛡️
Payment ↔ Token
Test cross-border payment flows with fee deductions
2b

Multi-Contract Authorization

Auth Testing

When multiple contracts are involved in a transaction, env.auths() captures all authorizations that occurred during the invocation, including nested contract calls. The sub_invocations field shows the exact flow of cross-contract auth.

📊
env.auths() verifies 3 things: (1) All required authorizations occurred, (2) No unexpected authorizations happened, (3) The authorization tree nesting is correct. If any auth is missing or extra, the assertion fails.
Rusttests/test_multi_auth.rs
use soroban_sdk::{Env, Address, Symbol};
use soroban_sdk::testutils::{AuthorizedFunction, AuthorizedInvocation};

#[test]
fn test_swap_authorization_tree() {
    let env = Env::default();

    let token_a_id = env.register(GoldToken, ());
    let token_b_id = env.register(GoldToken, ());
    let dex_id = env.register(McSwapDex, ());
    let trader = Address::generate(&env);

    // mock_all_auths tracks auth WITHOUT enforcing it
    env.mock_all_auths();

    // ... setup tokens, pool, mint to trader ...

    dex.swap(&trader, &token_a_id, &token_b_id,
        &100_0000000, &95_0000000);

    // Verify the FULL authorization tree
    assert_eq!(
        env.auths(),
        std::vec![(
            trader.clone(),
            AuthorizedInvocation {
                // Root: trader authorizes DEX.swap()
                function: AuthorizedFunction::Contract((
                    dex_id.clone(),
                    Symbol::new(&env, "swap"),
                    (trader.clone(), token_a_id.clone(),
                     token_b_id.clone(),
                     100_0000000_i128,
                     95_0000000_i128).into_val(&env),
                )),
                // Nested: DEX calls token_a.transfer()
                sub_invocations: std::vec![
                    AuthorizedInvocation {
                        function: AuthorizedFunction::Contract((
                            token_a_id.clone(),
                            Symbol::new(&env, "transfer"),
                            (trader.clone(), dex_id.clone(),
                             100_0000000_i128).into_val(&env),
                        )),
                        sub_invocations: std::vec![],
                    }
                ],
            }
        )]
    );
}

💸 Token Transfers in Cross-Contract Calls

When your DEX/Staking/DAO contract calls token.transfer() internally, the token contract requires authorization from the sender, not from the calling contract. This creates a nested auth tree that you must verify.

Rusttests/test_token_auth_crosscontract.rs
#[test]
fn test_staking_locks_tokens_with_auth() {
    let env = Env::default();
    env.mock_all_auths();

    let token_id = env.register(GoldToken, ());
    let staking_id = env.register(PlatinumStake, ());
    let token = GoldTokenClient::new(&env, &token_id);
    let staking = PlatinumStakeClient::new(&env, &staking_id);
    let user = Address::generate(&env);

    // Setup: mint tokens to user
    token.mint(&user, &5000_0000000);

    // Stake: user deposits 1000 tokens into staking contract
    staking.stake(&user, &1000_0000000, &1); // tier 1

    // Auth tree: user authorizes staking.stake()
    //   └── which internally calls token.transfer(user, staking, 1000)
    assert_eq!(
        env.auths(),
        std::vec![(
            user.clone(),
            AuthorizedInvocation {
                function: AuthorizedFunction::Contract((
                    staking_id.clone(),
                    Symbol::new(&env, "stake"),
                    (user.clone(), 1000_0000000_i128,
                     1_u32).into_val(&env),
                )),
                sub_invocations: std::vec![
                    AuthorizedInvocation {
                        function: AuthorizedFunction::Contract((
                            token_id.clone(),
                            Symbol::new(&env, "transfer"),
                            (user.clone(), staking_id.clone(),
                             1000_0000000_i128).into_val(&env),
                        )),
                        sub_invocations: std::vec![],
                    }
                ],
            }
        )]
    );

    // Verify balances moved
    assert_eq!(token.balance(&user), 4000_0000000);
    assert_eq!(token.balance(&staking_id), 1000_0000000);
}

🔐 require_auth() vs require_auth_for_args()

Two methods for enforcing authorization in your contracts. Choosing the right one matters for security and composability in multi-contract systems.

🔒
require_auth()
Authorizes the entire function call with all arguments automatically. Simpler to use but less granular.

Use when: The function has a single actor and all args define the action (e.g., transfer(from, to, amount)).

The auth payload = function name + all args.
🔑
require_auth_for_args()
Authorizes only specific arguments you choose. More granular control over what the user signs.

Use when: The function has parameters the signer shouldn’t need to commit to (e.g., a callback address, debug flag, or oracle reference).

The auth payload = function name + custom args.
Rustsrc/auth_examples.rs
// ━━━ require_auth() ━━━
// User signs: transfer(from, to, amount) — ALL args
pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
    from.require_auth();
    // Auth payload: ("transfer", from, to, amount)
    // ✅ Simple, covers all args automatically
}

// ━━━ require_auth_for_args() ━━━
// User signs ONLY (to, amount) — not the oracle_ref
pub fn swap_with_oracle(
    env: Env,
    user: Address,
    to_token: Address,
    amount: i128,
    oracle_ref: Address, // user shouldn't have to sign this
) {
    user.require_auth_for_args(
        (to_token.clone(), amount).into_val(&env)
    );
    // Auth payload: ("swap_with_oracle", to_token, amount)
    // ✅ User commits to what they swap, not which oracle
}

// ━━━ Multi-party: Atomic Swap (both users sign) ━━━
pub fn atomic_swap(
    env: Env,
    user_a: Address,
    token_a: Address,
    amount_a: i128,
    min_recv_a: i128,
    user_b: Address,
    token_b: Address,
    amount_b: i128,
    min_recv_b: i128,
) {
    // Each user authorizes their side independently
    user_a.require_auth_for_args(
        (token_a.clone(), amount_a, min_recv_a).into_val(&env)
    );
    user_b.require_auth_for_args(
        (token_b.clone(), amount_b, min_recv_b).into_val(&env)
    );
    // ✅ Neither user signs the other's details
}
Rusttests/test_auth_for_args.rs
#[test]
fn test_atomic_swap_dual_auth() {
    let env = Env::default();
    env.mock_all_auths();

    let user_a = Address::generate(&env);
    let user_b = Address::generate(&env);

    // ... setup contracts, mint tokens ...

    dex.atomic_swap(
        &user_a, &token_a_id, &100_0000000, &90_0000000,
        &user_b, &token_b_id, &95_0000000, &85_0000000,
    );

    // Two auths: one per user
    let auths = env.auths();
    assert_eq!(auths.len(), 2);

    // User A signed (token_a, 100, min_recv 90)
    assert_eq!(auths[0].0, user_a);
    // User B signed (token_b, 95, min_recv 85)
    assert_eq!(auths[1].0, user_b);
}

#[test]
#[should_panic]
fn test_atomic_swap_one_party_unauthorized() {
    let env = Env::default();
    // NO mock — real auth enforced
    // Only user_a signs, user_b doesn't — should fail
    dex.atomic_swap(...);
}

🔍 Simulating Auth Before Submitting

Before submitting a transaction to the network, you can simulate it to discover all required authorizations. This lets you build the correct auth entries without trial and error.

ShellStellar CLI — Simulate & Inspect Auth
# Simulate a contract call to see required auth entries
stellar contract invoke \
  --id CDLZ7XBHP4QK3R2WMNTF6EHJG9SVX7KQ \
  --source trader \
  --network testnet \
  --simulate-only \
  -- swap \
  --trader GBXF4ITB...QPZRD \
  --token_a CDLZ7XBH...X7KQ \
  --token_b CBVS4PAY...9TRN \
  --amount_in 1000000000 \
  --min_out 950000000

# Output shows all required AuthorizationEntry items:
# ├── Root: trader authorizes swap()
# │   └── Sub: token_a.transfer(trader → dex, 100.0)
# Response includes: simulated tx, min resource fee, auth entries
RustSDK — Preflight Simulation
use stellar_sdk::Server;

// Build the transaction without signing
let tx = TransactionBuilder::new(source_account)
    .add_operation(
        InvokeContractOp::new(dex_id, "swap", args)
    )
    .build();

// Simulate to discover auth requirements
let server = Server::new("https://soroban-testnet.stellar.org");
let sim = server.simulate_transaction(&tx).await?;

// sim.results[0].auth contains all AuthorizationEntry items
// Each entry shows: credentials, root_invocation, sub_invocations
for auth_entry in &sim.results[0].auth {
    println!("Auth required: {:?}", auth_entry);
}

// Attach the auth entries and sign
let prepared = server.prepare_transaction(&tx).await?;
let signed = prepared.sign(&keypair);
let result = server.send_transaction(&signed).await?;
Why Simulate First
• Discover all auth entries before signing
• Calculate exact resource fees
• Catch errors without spending XLM
• Required for multi-party signing flows
📝
Simulation Returns
auth[] — all AuthorizationEntry items
minResourceFee — exact fee needed
results[] — return values
latestLedger — expiration window
🛡️
Testing pattern: Use mock_all_auths() during development for speed, then write dedicated tests without mocked auth to verify access control. For multi-party scenarios (atomic swaps), each party's auth is independent—simulate first, then gather signatures from all parties before submitting.
🚨
Never Skip Auth Tests
Missing or incorrect require_auth() is the #1 smart contract vulnerability. Always test: (1) unauthorized users get rejected, (2) auth tree matches expected sub_invocations, (3) multi-party flows require all signatures.
2c

Mocking vs Real Dependencies

Prefer Real
💡
Key Insight: You Generally Don’t Need Mocks
The Soroban SDK makes it just as easy to test against real contracts as against mocks. Because all tests use the real Soroban Environment (not an emulator), there’s no difference in tooling between unit and integration tests—so you might as well test with real dependencies.

Unlike other ecosystems where integration tests are slow and mocks are necessary, Stellar encourages testing with real dependencies. The SDK handles contract calls defensively, automatically stopping execution if an unexpected error or type is returned.

✅ Strategy 1: Fetch & Test Real Deployed Contracts

Fetch deployed contracts from Mainnet or Testnet using the Stellar CLI, then use them directly in tests. This verifies exact on-chain behavior.

ShellFetch real dependency
# Fetch a deployed contract WASM from the network
stellar contract fetch --id C... --out-file pause.wasm
Rusttests/test_with_real_dependency.rs
#![cfg(test)]
use soroban_sdk::Env;

// Import the real deployed contract
mod pause {
    soroban_sdk::contractimport!(file = "pause.wasm");
}

#[test]
fn test_with_real_pause_contract() {
    let env = Env::default();

    // Register the REAL contract — not a mock
    let pause_id = env.register(pause::WASM, ());
    let pause_client = pause::Client::new(&env, &pause_id);

    // Test with the actual dependency implementation
    pause_client.set(&false);
    assert_eq!(client.increment(), 1);

    pause_client.set(&true);
    assert_eq!(client.try_increment(), Err(Ok(Error::Paused)));
}

🔗 Strategy 2: Native Registration for Local Contracts

Register local contracts as native Rust code instead of compiling to WASM. Faster compilation, full debugging, and better visibility into internal state.

Rusttests/test_native_registration.rs
#[test]
fn test_dex_with_real_token_native() {
    let env = Env::default();
    env.mock_all_auths();

    // Native registration — real contracts, not mocks
    let token_id = env.register(GoldToken, ());
    let dex_id = env.register(McSwapDex, ());

    let token = GoldTokenClient::new(&env, &token_id);
    let dex = McSwapDexClient::new(&env, &dex_id);

    // Full debugging across both contracts
    // Step into token.transfer() from dex.swap()
    let user = Address::generate(&env);
    token.mint(&user, &1000_0000000);
    assert_eq!(token.balance(&user), 1000_0000000);
}

🛡️ Strategy 3: Code Defensively for Cross-Contract Errors

Any external contract call could fail. The Soroban SDK automatically stops execution if an unexpected error or type is returned. Use try_ methods to handle failures gracefully instead of panicking.

Rusttests/test_defensive_cross_contract.rs
#[test]
fn test_swap_handles_token_error() {
    let env = Env::default();
    env.mock_all_auths();

    let token_id = env.register(GoldToken, ());
    let dex_id = env.register(McSwapDex, ());
    let token = GoldTokenClient::new(&env, &token_id);
    let dex = McSwapDexClient::new(&env, &dex_id);
    let user = Address::generate(&env);

    // User has 0 tokens — transfer should fail
    let result = dex.try_swap(
        &user, &token_id, &other_token_id,
        &100_0000000, &0,
    );
    assert!(result.is_err());
    // ✅ Verify the error is propagated correctly
    // ✅ No need for mocks — real token contract fails naturally
}

#[test]
fn test_swap_insufficient_output() {
    let env = Env::default();
    env.mock_all_auths();

    // ... setup pool with real contracts ...

    // Swap with unrealistically high min_out — should fail
    let result = dex.try_swap(
        &user, &token_a_id, &token_b_id,
        &100_0000000,
        &999_0000000, // min_out > possible
    );
    assert!(result.is_err());
    // ✅ Real AMM math triggers natural slippage error
}

⚠️ Strategy 4: When to Actually Mock (Rare Cases)

Mocks are only useful when it’s genuinely difficult to test against the real dependency. In Soroban, this is rare—but there are a few valid cases:

Rusttests/test_rare_mock_cases.rs
// RARE CASE: Simulate impossible states for edge-case testing
// E.g., an oracle returning an extreme price that wouldn't
// occur naturally, to test overflow protection

#[contract]
pub struct ExtremeOracle;

#[contractimpl]
impl ExtremeOracle {
    pub fn get_price(_env: Env, _asset: Address) -> i128 {
        i128::MAX // Extreme value — test overflow handling
    }
}

#[test]
fn test_dex_handles_extreme_oracle_price() {
    let env = Env::default();
    let oracle_id = env.register(ExtremeOracle, ());
    dex.set_oracle(&oracle_id);

    // DEX must handle extreme price without overflow panic
    let result = dex.try_get_quote(&xlm_id, &usdc_id, &100);
    assert!(result.is_err());
    // ✅ Stub justified — impossible to get i128::MAX from real oracle
}

// RARE CASE: Test behavior when a dependency is permanently broken
#[contract]
pub struct BrokenOracle;

#[contractimpl]
impl BrokenOracle {
    pub fn get_price(_env: Env, _asset: Address) -> i128 {
        panic!("contract destroyed")
    }
}

#[test]
fn test_graceful_degradation_on_broken_dep() {
    let env = Env::default();
    let oracle_id = env.register(BrokenOracle, ());
    dex.set_oracle(&oracle_id);

    // Contract should fall back gracefully, not cascade-panic
    let result = dex.try_get_quote(&xlm_id, &usdc_id, &100);
    assert!(result.is_err());
}
Prefer Real
Default approach. Fetch deployed WASM or register native. Verifies actual behavior, catches real bugs, zero maintenance overhead.
🔗
Native Registration
Local contracts. Full debugging, fast iteration. Use env.register(Contract, ()) for contracts you develop together.
🛡️
Defensive Testing
Error paths. Use try_ methods on real contracts. Natural failures test your error handling without mocks.
⚠️
Mock Only When Needed
Rare. Only for impossible states (i128::MAX, broken deps). If you can trigger the condition with a real contract, don’t mock.
🎯
Decision rule: Can you achieve the same test outcome using a real contract with controlled state? If yes, use the real contract. If the state is physically impossible to create (overflows, destroyed contracts, timing attacks), then a minimal stub is justified.
3

Fuzzing & Mutation Testing

Advanced

Go beyond manually written test cases. These automated techniques discover edge cases you'd never think of.

🎲
Fuzzing
What: Generates random/semi-random inputs automatically and feeds them to your contract functions.

When: Finding unexpected panics, overflows, or logic errors with edge-case inputs (e.g., u128::MAX, zero amounts, empty addresses).

Tool: cargo-fuzz with libFuzzer
🧬
Mutation Testing
What: Automatically modifies your source code (e.g., changes + to -, swaps > with >=) and checks if your tests catch the mutation.

When: Verifying that your tests actually catch bugs, not just pass. If a mutant survives, your tests have a blind spot.

Tool: cargo-mutants
Rustfuzz/fuzz_targets/fuzz_increment.rs
#![no_main]
use libfuzzer_sys::fuzz_target!;
use soroban_sdk::{
    testutils::arbitrary::{arbitrary, Arbitrary},
    Env,
};

#[derive(Debug, Arbitrary)]
pub struct Input {
    pub by: u64,
}

fuzz_target!(|input: Input| {
    let env = Env::default();
    let id = env.register(IncrementContract, ());
    let client = IncrementContractClient::new(&env, &id);

    // Property: each increment returns a value greater than the last
    let mut last: Option<u32> = None;
    for _ in input.by.. {
        match client.try_increment() {
            Ok(Ok(current)) => assert!(Some(current) > last),
            Err(Ok(_)) => {} // Expected contract error
            Ok(Err(_)) => panic!("success with wrong type"),
            Err(Err(_)) => panic!("unrecognised error"),
        }
    }
});
💡
Property testing: Fuzzing isn’t just crash detection. Write property tests that assert invariants must hold regardless of input—e.g., “all balances never become negative” or “increment always returns a value greater than the last.” Use soroban_sdk::testutils::arbitrary for structured random input generation.
ShellTerminal Commands
# Run fuzzing for 5 minutes
cargo +nightly fuzz run fuzz_increment -- -max_total_time=300

# Fuzz coverage report
cargo +nightly fuzz coverage fuzz_increment

# Run mutation testing to verify test quality
cargo install cargo-mutants
cargo mutants --package gold-token

# Check code coverage with cargo-llvm-cov
cargo install cargo-llvm-cov
cargo llvm-cov --html
4

Local & Testnet Testing

Environment

Use a local blockchain for rapid iteration. Stellar provides a lightweight Docker image with accelerated ledger closes. Then deploy to Testnet and run extended without issues before considering mainnet.

Phase 1 — Local Dev
Standalone Docker Container
Accelerated ledgers, instant feedback, no network dependency. Run all unit + integration tests here.
Phase 2 — Testnet Deploy
Stellar Testnet Deployment
Deploy all 6 contracts, fund via Friendbot, test real transaction flows with actual Horizon API calls.
Phase 3 — Soak Testing
Extended Testnet Monitoring
Run for days/weeks. Monitor gas usage, state growth, edge cases under real network conditions. Automated test scripts.
Phase 4 — Fork Testing
Snapshot & Test Against Mainnet State
Use stellar snapshot create to capture contract + data from Mainnet. Load into Env for the most realistic integration test possible.
ShellLocal Stellar Quickstart
# Start local Stellar network with Docker
docker run --rm -it \
  -p 8000:8000 \
  --name stellar \
  stellar/quickstart:testing \
  --standalone \
  --enable-soroban-rpc

# Build and deploy to local
soroban contract build
soroban contract deploy \
  --wasm target/wasm32-unknown-unknown/release/gold_token.wasm \
  --source alice \
  --network standalone

# Deploy to Testnet
soroban contract deploy \
  --wasm target/wasm32-unknown-unknown/release/gold_token.wasm \
  --source deployer \
  --network testnet

📸 Fork Testing with Ledger Snapshots

Pull real contract state from Mainnet or Testnet into your tests. The snapshot includes the contract WASM and its storage data, so your test runs against the exact same state as production.

ShellCreate a Ledger Snapshot
# Snapshot a contract and its data from Mainnet/Testnet
stellar snapshot create \
  --address C... \
  --output json \
  --out snapshot.json
Rusttests/fork_test.rs
use soroban_sdk::{Env, Address};
use crate::{IncrementContract, IncrementContractClient};

#[test]
fn test_against_mainnet_state() {
    // Load the snapshot — contract + all its stored data
    let env = Env::from_ledger_snapshot_file("snapshot.json");

    // Reference the deployed contract by its real address
    let pause_id = Address::from_str(&env, "C...");

    // Register YOUR contract against the real dependency state
    let contract_id = env.register(
        IncrementContract,
        IncrementContractArgs::__constructor(&pause_id),
    );
    let client = IncrementContractClient::new(&env, &contract_id);

    // Test against real Mainnet data — most realistic test possible
    assert_eq!(client.increment(), 1);
}
🎯
When to fork test: Contract upgrades (test new code against real state), migration scripts, verifying behavior against real production data, or testing how your contract interacts with existing deployed dependencies.
5

Differential Testing

Compare Versions

Differential testing compares two implementations to discover differences in behavior. The goal is to prove that a new version, refactored code, or updated SDK does not change observable functionality.

ShellFetch deployed contract for comparison
# Fetch the currently deployed version
stellar contract fetch --id C... --out-file contract.wasm
Rusttests/differential_test.rs
#![cfg(test)]
use crate::{IncrementContract, IncrementContractClient};
use soroban_sdk::{testutils::Events as _, Env};

mod deployed {
    soroban_sdk::contractimport!(file = "contract.wasm");
}

#[test]
fn differential_test() {
    let env = Env::default();
    assert_eq!(
        // Baseline: the currently deployed contract
        {
            let contract_id = env.register(deployed::WASM, ());
            let client = IncrementContractClient::new(&env, &contract_id);
            (
                // Return values
                (client.increment(), client.increment(), client.increment()),
                // Events emitted
                env.events.all(),
            )
        },
        // New: the changed or refactored contract
        {
            let contract_id = env.register(IncrementContract, ());
            let client = IncrementContractClient::new(&env, &contract_id);
            (
                // Return values (must match baseline)
                (client.increment(), client.increment(), client.increment()),
                // Events (must match baseline)
                env.events.all(),
            )
        },
    );
}
🔄
When to Use
• Upgrading a contract version
• Refactoring without functional changes
• Updating SDK or dependencies
• Migrating between toolchains
🔍
What Gets Compared
• Return values (exact match)
• Events emitted (order + data)
• State changes (storage writes)
• Authorization requirements

📸 Automatic Test Snapshots

All contracts built with the Soroban Rust SDK generate a test snapshot at the end of every test involving the Soroban Environment. Snapshots are written as JSON files to the test_snapshots/ directory and should be committed to version control.

Rusttests/test_with_snapshots.rs
#![cfg(test)]
use soroban_sdk::Env;
use crate::{Contract, ContractClient};

#[test]
fn test_abc() {
    let env = Env::default();
    let contract_id = env.register_contract(None, Contract);
    let client = ContractClient::new(&env, &contract_id);

    assert_eq!(client.increment(), 1);

    // At the end of the test, Env automatically writes:
    // test_snapshots/test_abc.1.json
}
📝
Snapshot as Change Detector
If a test snapshot changes when you didn’t expect it—such as after updating an SDK or refactoring—it’s a signal that observable contract behavior may have changed too. Review the diff carefully before committing.
6

Security Audits

Critical

Before mainnet, get professional audits from firms specializing in smart contract security. They identify vulnerabilities through both manual review and formal verification.

🔍
Certora
Formal Verification
🛡️
OtterSec
Manual + Automated
🔎
Halborn
Penetration Testing
⚖️
OpenZeppelin
Comprehensive Audit
💰
Stellar Community Fund Audit Bank
SCF-funded projects can apply for subsidized security audits through the Audit Bank program. This covers professional security review from approved audit firms.
📝
Manual Review
Line-by-line code review by security experts. Catches logic bugs, access control issues, reentrancy
🧮
Formal Verification
Mathematically prove correctness. Guarantees invariants hold for ALL possible inputs, not just test cases
7

Pre-Mainnet Checklist

Launch Ready

Complete every item before deploying to mainnet. Click items to mark them as done.

All unit tests passing (100%)
cargo test -- --nocapture with all modules
Integration tests for all contract pairs
Token↔DEX, Token↔Staking, DAO↔Vault, Payment↔Token
Fuzz testing completed (no crashes)
cargo fuzz run for all entry points, minimum 1 hour each
Mutation testing >80% kill rate
cargo mutants shows tests catch most introduced bugs
Code coverage >90%
cargo tarpaulin confirms all critical paths tested
Testnet deployment running 2+ weeks stable
All 6 contracts deployed and exercised on Stellar Testnet
Professional security audit completed
At least one audit firm reviewed all contracts, all findings resolved
Source code verified on-chain
Published and verifiable WASM matches deployed contract
Secure key management configured
Multi-sig admin, hardware wallet signing, no single point of failure
Project governance plan documented
Upgrade procedures, emergency pause, incident response plan
Gas optimization verified
All functions within resource limits, no excessive state bloat
Edge cases & error handling reviewed
Zero amounts, max values, unauthorized access, re-entrancy safe
0 of 12 complete0%
📊

Platform Test Coverage

Target coverage for each smart contract in the platform:

Gold Token (SEP-41)94%
Payment Contract91%
DEX/AMM Contract87%
Governance DAO85%
Staking Contract82%
Multi-Sig Vault88%
Smart Contract Testing Guide v1.0.0 • Soroban SDK v21 • Stellar Testnet