2025-08-18·14 min read

Fuzzing Rust smart contracts: CosmWasm, Solana, and cargo-fuzz

Fuzzing Rust Smart Contracts: CosmWasm, Solana, and cargo-fuzz

Author: kn0t | Guide

Rust's type system catches a lot of bugs at compile time. But "a lot" isn't "all." Integer overflows, logic errors, serialization mismatches, and incorrect state transitions slip through just fine. If you're building smart contracts in Rust — whether for CosmWasm, Solana, or anything else — fuzzing is how you find what the compiler can't.

This guide covers the practical tools and patterns for fuzzing Rust-based smart contracts across ecosystems.

cargo-fuzz and libFuzzer: the foundation

Every Rust fuzzing setup builds on the same core: LLVM's libFuzzer, exposed through cargo-fuzz. It's coverage-guided, it's fast, and it works with any Rust code.

Getting started

# Install cargo-fuzz
cargo install cargo-fuzz

# Initialize fuzz targets in your project
cd your-contract
cargo fuzz init

# This creates:
# fuzz/
#   ├── Cargo.toml
#   └── fuzz_targets/
#       └── fuzz_target_1.rs

Writing your first fuzz target

// fuzz/fuzz_targets/fuzz_target_1.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
use your_contract::msg::{ExecuteMsg, InstantiateMsg};
use cosmwasm_std::from_json;

fuzz_target!(|data: &[u8]| {
    // Try to deserialize fuzzer bytes as an ExecuteMsg
    if let Ok(msg) = from_json::<ExecuteMsg>(data) {
        // If it parses, does our validation handle it correctly?
        let _ = validate_execute_msg(&msg);
    }
});

This is the simplest pattern, deserialize random bytes and see if your code panics. It's a good starting point but it's also pretty shallow. The fuzzer spends most of its time generating invalid JSON.

Structured fuzzing with Arbitrary

Better approach: use the arbitrary crate to generate structured inputs directly:

use libfuzzer_sys::fuzz_target;
use arbitrary::Arbitrary;

#[derive(Arbitrary, Debug)]
struct FuzzInput {
    deposit_amount: u128,
    withdraw_amount: u128,
    fee_rate: u16,
    time_delta: u64,
}

fuzz_target!(|input: FuzzInput| {
    // Now every input is structurally valid
    // The fuzzer can focus on finding edge cases in your logic

    if input.fee_rate > 10000 { return; } // Skip invalid fee rates
    if input.deposit_amount == 0 { return; }

    let mut state = ContractState::new(input.fee_rate);
    state.deposit(input.deposit_amount);

    // Simulate time passing
    state.advance_time(input.time_delta);

    // Try to withdraw -- should never underflow
    let result = state.withdraw(input.withdraw_amount);

    match result {
        Ok(withdrawn) => {
            assert!(withdrawn <= input.deposit_amount + state.accrued_interest());
        }
        Err(_) => {
            // Errors are fine -- panics are not
        }
    }
});

CosmWasm-specific testing patterns

CosmWasm has some of the best testing infrastructure in crypto. The multi-test framework lets you simulate an entire blockchain environment in Rust, and cw-orch takes it further with deployment orchestration.

Multi-test for integration testing

use cosmwasm_std::{Addr, Coin, Uint128};
use cw_multi_test::{App, ContractWrapper, Executor};

fn setup_app() -> (App, Addr) {
    let mut app = App::default();

    // Store contract code
    let code = ContractWrapper::new(
        crate::contract::execute,
        crate::contract::instantiate,
        crate::contract::query,
    );
    let code_id = app.store_code(Box::new(code));

    // Instantiate
    let contract_addr = app
        .instantiate_contract(
            code_id,
            Addr::unchecked("creator"),
            &InstantiateMsg {
                admin: "admin".to_string(),
                fee_bps: 30, // 0.3%
            },
            &[],
            "vault",
            None,
        )
        .unwrap();

    (app, contract_addr)
}

#[test]
fn fuzz_deposit_withdraw_cycle() {
    let (mut app, contract) = setup_app();

    // Use proptest or quickcheck for randomized inputs
    for i in 0..1000 {
        let amount = (i * 137 + 42) % 1_000_000 + 1; // Pseudo-random
        let user = format!("user_{}", i % 10);

        // Fund user
        app.init_modules(|router, _, storage| {
            router.bank.init_balance(
                storage,
                &Addr::unchecked(&user),
                vec![Coin::new(amount as u128, "uatom")],
            ).unwrap();
        });

        // Deposit
        let deposit_result = app.execute_contract(
            Addr::unchecked(&user),
            contract.clone(),
            &ExecuteMsg::Deposit {},
            &[Coin::new(amount as u128, "uatom")],
        );

        if deposit_result.is_ok() {
            // Query balance
            let balance: BalanceResponse = app
                .wrap()
                .query_wasm_smart(
                    &contract,
                    &QueryMsg::Balance { address: user.clone() },
                )
                .unwrap();

            // Withdraw everything
            let withdraw_result = app.execute_contract(
                Addr::unchecked(&user),
                contract.clone(),
                &ExecuteMsg::Withdraw {
                    amount: balance.amount
                },
                &[],
            );

            // If deposit succeeded, full withdrawal should succeed too
            assert!(
                withdraw_result.is_ok(),
                "Deposit succeeded but withdraw failed for amount {}",
                balance.amount
            );
        }
    }
}

Combining cargo-fuzz with multi-test

This is where things get powerful. Use cargo-fuzz to drive the multi-test environment:

#![no_main]
use libfuzzer_sys::fuzz_target;
use arbitrary::Arbitrary;
use cosmwasm_std::{Addr, Coin, Uint128};
use cw_multi_test::{App, Executor};

#[derive(Arbitrary, Debug)]
enum FuzzAction {
    Deposit { user_idx: u8, amount: u64 },
    Withdraw { user_idx: u8, amount: u64 },
    UpdateFee { new_fee: u16 },
    AdvanceBlock { blocks: u32 },
}

#[derive(Arbitrary, Debug)]
struct FuzzSequence {
    actions: Vec<FuzzAction>,
}

fuzz_target!(|sequence: FuzzSequence| {
    if sequence.actions.len() > 50 { return; }

    let (mut app, contract) = setup_app();
    let users: Vec<String> = (0..5)
        .map(|i| format!("user_{}", i))
        .collect();

    // Track expected state for invariant checking
    let mut total_deposited: u128 = 0;

    for action in &sequence.actions {
        match action {
            FuzzAction::Deposit { user_idx, amount } => {
                let user = &users[(*user_idx as usize) % users.len()];
                let amt = (*amount as u128) + 1;

                fund_user(&mut app, user, amt);

                if let Ok(_) = app.execute_contract(
                    Addr::unchecked(user),
                    contract.clone(),
                    &ExecuteMsg::Deposit {},
                    &[Coin::new(amt, "uatom")],
                ) {
                    total_deposited += amt;
                }
            }
            FuzzAction::Withdraw { user_idx, amount } => {
                let user = &users[(*user_idx as usize) % users.len()];
                let _ = app.execute_contract(
                    Addr::unchecked(user),
                    contract.clone(),
                    &ExecuteMsg::Withdraw {
                        amount: Uint128::new(*amount as u128),
                    },
                    &[],
                );
            }
            FuzzAction::UpdateFee { new_fee } => {
                let fee = (*new_fee) % 10001; // Cap at 100%
                let _ = app.execute_contract(
                    Addr::unchecked("admin"),
                    contract.clone(),
                    &ExecuteMsg::UpdateFee { fee_bps: fee },
                    &[],
                );
            }
            FuzzAction::AdvanceBlock { blocks } => {
                let advance = (*blocks % 1000) + 1;
                app.update_block(|b| {
                    b.height += advance as u64;
                    b.time = b.time.plus_seconds(advance as u64 * 6);
                });
            }
        }
    }

    // INVARIANT: contract balance should match tracked deposits minus withdrawals
    let contract_balance = app.wrap()
        .query_balance(&contract, "uatom")
        .unwrap();

    // Contract should never hold more than was deposited
    // (fees might reduce it, but nothing should create tokens from thin air)
    assert!(
        contract_balance.amount.u128() <= total_deposited,
        "Contract holds more than was deposited! Has: {}, Deposited: {}",
        contract_balance.amount,
        total_deposited
    );
});

This is the gold standard for CosmWasm fuzzing. You get structured, meaningful inputs driving a realistic multi-contract environment, with invariant checks after each sequence.

Common Rust vulnerability patterns

Rust prevents memory corruption, but smart contract bugs aren't about memory. Here's what to fuzz for:

Integer overflow (yes, even in Rust)

Rust panics on overflow in debug mode and wraps in release mode. Smart contracts often use checked_* or saturating_* arithmetic, but not always consistently:

// Bug: this wraps in release mode if not caught
fn calculate_shares(deposit: u128, total_supply: u128, total_assets: u128) -> u128 {
    if total_supply == 0 {
        return deposit;
    }
    // This can overflow if deposit * total_supply > u128::MAX
    deposit * total_supply / total_assets
}

// Fixed version
fn calculate_shares_safe(
    deposit: u128,
    total_supply: u128,
    total_assets: u128,
) -> Result<u128, ContractError> {
    if total_supply == 0 {
        return Ok(deposit);
    }
    // Use u256 intermediate or checked math
    let numerator = U256::from(deposit) * U256::from(total_supply);
    let shares = numerator / U256::from(total_assets);

    Ok(shares.as_u128())
}

Fuzz this by generating large numbers close to u128::MAX and checking that the function either returns a correct result or a proper error, never wraps silently.

Serialization mismatches

Borsh, JSON, Bincode, each serialization format has edge cases:

#[derive(Serialize, Deserialize)]
struct PoolState {
    pub reserve_a: u128,
    pub reserve_b: u128,
    pub lp_supply: u128,
    pub fee_bps: u16,     // Watch out: field ordering matters for Borsh
    pub is_paused: bool,
}

// Fuzz target: serialize then deserialize and check round-trip
fuzz_target!(|state: PoolState| {
    let bytes = borsh::to_vec(&state).unwrap();
    let decoded: PoolState = borsh::from_slice(&bytes).unwrap();

    assert_eq!(state.reserve_a, decoded.reserve_a);
    assert_eq!(state.reserve_b, decoded.reserve_b);
    assert_eq!(state.lp_supply, decoded.lp_supply);
    assert_eq!(state.fee_bps, decoded.fee_bps);
    assert_eq!(state.is_paused, decoded.is_paused);
});

This catches schema evolution bugs where you add a field and forget to update all serialization paths.

Unsafe blocks

Any unsafe in a smart contract is a red flag. Fuzz the boundaries around unsafe code aggressively:

// If you MUST use unsafe (and you probably shouldn't)
pub fn fast_copy(src: &[u8], dst: &mut [u8], len: usize) {
    assert!(len <= src.len() && len <= dst.len()); // MUST check bounds
    unsafe {
        std::ptr::copy_nonoverlapping(
            src.as_ptr(),
            dst.as_mut_ptr(),
            len
        );
    }
}

// Fuzz it hard
fuzz_target!(|data: &[u8]| {
    if data.len() < 4 { return; }
    let len = u16::from_le_bytes([data[0], data[1]]) as usize;
    let src = &data[2..];
    let mut dst = vec![0u8; data.len()];

    // This should never segfault or corrupt memory
    let _ = std::panic::catch_unwind(|| {
        fast_copy(src, &mut dst, len);
    });
});

Proptest for property-based testing

Proptest is the Rust-native property testing library. It's not coverage-guided like cargo-fuzz, but it generates structured inputs with built-in shrinking, when it finds a failure, it minimizes the input to the simplest case.

use proptest::prelude::*;

proptest! {
    #[test]
    fn deposit_then_withdraw_preserves_balance(
        deposit_amount in 1u128..=1_000_000_000_000u128,
        withdraw_pct in 1u64..=100u64,
    ) {
        let mut state = VaultState::new();
        state.deposit("user1", deposit_amount).unwrap();

        let withdraw_amount = deposit_amount * withdraw_pct as u128 / 100;
        if withdraw_amount > 0 {
            state.withdraw("user1", withdraw_amount).unwrap();

            let remaining = state.balance_of("user1");
            let expected = deposit_amount - withdraw_amount;

            // Account for possible rounding -- allow 1 unit tolerance
            prop_assert!(
                remaining >= expected.saturating_sub(1)
                && remaining <= expected + 1,
                "Balance mismatch: got {}, expected ~{}",
                remaining, expected
            );
        }
    }
}

Proptest's shrinking is incredibly useful. Instead of getting a failing input with amount = 340282366920938463463374607431768211455, it'll shrink it down to the minimal amount that triggers the bug, maybe amount = 2. That makes debugging much faster.

Cross-ecosystem comparison

CosmWasm

  • Best testing story of any Rust smart contract ecosystem
  • Multi-test gives you a full simulated chain in Rust
  • cw-orch adds deployment orchestration for complex multi-contract setups
  • Use cargo-fuzz + multi-test for the best coverage

Solana

  • Trident for Anchor programs, cargo-fuzz for native
  • Account model makes harness setup painful
  • solana-program-test provides the runtime, but it's slower than CosmWasm's multi-test
  • Focus on CPI interactions and account validation

General Rust

  • cargo-fuzz works everywhere
  • proptest for unit-level property testing
  • Use #[cfg(fuzzing)] to add instrumentation hooks your harness can use

Practical Workflow

Here's the workflow I follow for every Rust smart contract audit:

  1. Extract pure functions, math, serialization, validation logic. Fuzz these directly with cargo-fuzz. No blockchain runtime needed.

  2. Write proptest properties, for each function, what should always be true? Deposit/withdraw roundtrips, fee calculations, state transitions.

  3. Build integration harness, use multi-test (CosmWasm) or ProgramTest (Solana) for full-stack fuzzing with structured inputs.

  4. Run for hours, not minutes. Rust compilation is slow, but fuzzing execution is fast. Let it run. Coverage-guided fuzzing needs time to find deep bugs.

  5. Check coverage, use cargo-cov or LLVM's source-based coverage to see what you're missing. Add harness code to reach uncovered branches.

The fundamentals of fuzzing apply regardless of ecosystem. Coverage feedback, corpus management, property design, it's all the same theory, just different tools. If you're solid on the concepts, picking up a new ecosystem's toolchain is just a matter of reading docs for a few hours.

Request a Rust Contract Audit

Try Recon Pro

Related Posts

Related Glossary Terms

Get your Rust contracts audited