Comprehensive testing strategy before mainnet deployment
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.”
For testing to be effective at identifying bugs early, it must be integrated with development—not an afterthought. Good tests:
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.
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.
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); }
#[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); }
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.
Load dependency contracts as compiled WASM files. Ideal for testing against deployed contracts or external dependencies without needing source code access.
# Fetch a deployed contract from Testnet/Mainnet
stellar contract fetch --id C... --out-file pause.wasm
#![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); }
Import dependency contracts natively as Rust crates. Best for tightly coupled contracts you develop together, giving you full debugging capabilities across all contracts.
#![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); }
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.
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![], } ], } )] ); }
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.
#[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); }
Two methods for enforcing authorization in your contracts. Choosing the right one matters for security and composability in multi-contract systems.
transfer(from, to, amount)).// ━━━ 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 }
#[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(...); }
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.
# 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
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?;
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.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.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.
Fetch deployed contracts from Mainnet or Testnet using the Stellar CLI, then use them directly in tests. This verifies exact on-chain behavior.
# Fetch a deployed contract WASM from the network
stellar contract fetch --id C... --out-file pause.wasm
#![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))); }
Register local contracts as native Rust code instead of compiling to WASM. Faster compilation, full debugging, and better visibility into internal state.
#[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); }
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.
#[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 }
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:
// 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()); }
env.register(Contract, ()) for contracts you develop together.try_ methods on real contracts. Natural failures test your error handling without mocks.Go beyond manually written test cases. These automated techniques discover edge cases you'd never think of.
#![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"), } } });
soroban_sdk::testutils::arbitrary for structured random input generation.# 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
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.
stellar snapshot create to capture contract + data from Mainnet. Load into Env for the most realistic integration test possible.# 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
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.
# Snapshot a contract and its data from Mainnet/Testnet
stellar snapshot create \
--address C... \
--output json \
--out snapshot.json
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); }
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.
# Fetch the currently deployed version
stellar contract fetch --id C... --out-file contract.wasm
#![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(), ) }, ); }
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.
#![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 }
Before mainnet, get professional audits from firms specializing in smart contract security. They identify vulnerabilities through both manual review and formal verification.
Complete every item before deploying to mainnet. Click items to mark them as done.
Target coverage for each smart contract in the platform: