2025-11-03·14 min read

Solana smart contract fuzzing: Trident, Honggfuzz, and property testing

Solana Smart Contract Fuzzing: Trident, Honggfuzz, and Property Testing

Author: kn0t | Guide

Solana fuzzing is where EVM fuzzing was three years ago. The tools exist, but they're less mature, less documented, and have sharper edges. If you're coming from the EVM world where Echidna and Medusa just work — buckle up. Here's the practical guide to getting fuzzing campaigns running on Solana programs.

The Solana fuzzing toolbox

Three main approaches exist for testing Solana programs:

  1. Trident, Ackee's fuzzing framework, purpose-built for Anchor programs
  2. honggfuzz-rs, Google's general-purpose fuzzer wrapped for Rust
  3. cargo-fuzz / libFuzzer, LLVM's fuzzer through Rust's official interface

Each has tradeoffs. Trident understands Anchor's account model. Honggfuzz and cargo-fuzz don't, but they're more mature fuzzers under the hood.

Trident: Anchor-aware fuzzing

Trident is the closest thing Solana has to Echidna. It auto-generates fuzzing harnesses from your Anchor IDL and understands accounts, signers, and program interactions.

Setup

# Install Trident
cargo install trident-cli

# Initialize in your Anchor project
trident init

# This generates:
# trident-tests/
#   ├── fuzz_tests/
#   │   └── fuzz_0/
#   │       ├── fuzz_instructions.rs
#   │       └── test_fuzz.rs
#   └── Cargo.toml

After trident init, you get a skeleton harness. The real work is filling in the instruction definitions and account setup.

Writing a Trident fuzz test

Let's say you're testing a token swap program. Here's how you'd set up the fuzzing harness:

use trident_client::fuzzing::*;

// Define the instructions the fuzzer can call
#[derive(Arbitrary, Debug)]
pub struct InitializePoolData {
    pub fee_bps: u16,
    pub initial_price: u64,
}

#[derive(Arbitrary, Debug)]
pub struct SwapData {
    pub amount_in: u64,
    pub minimum_amount_out: u64,
    pub swap_direction: bool, // true = A->B, false = B->A
}

#[derive(Arbitrary, Debug)]
pub struct AddLiquidityData {
    pub amount_a: u64,
    pub amount_b: u64,
    pub min_lp_tokens: u64,
}

impl FuzzInstruction for SwapData {
    fn get_accounts(&self, accounts: &mut AccountsStorage)
        -> Result<Vec<AccountMeta>, FuzzingError>
    {
        let pool = accounts.get_or_create("pool")?;
        let user = accounts.get_or_create("user")?;
        let token_a_vault = accounts.get_or_create("token_a_vault")?;
        let token_b_vault = accounts.get_or_create("token_b_vault")?;
        let user_token_a = accounts.get_or_create("user_token_a")?;
        let user_token_b = accounts.get_or_create("user_token_b")?;

        Ok(vec![
            AccountMeta::new(pool, false),
            AccountMeta::new_readonly(user, true), // signer
            AccountMeta::new(token_a_vault, false),
            AccountMeta::new(token_b_vault, false),
            AccountMeta::new(user_token_a, false),
            AccountMeta::new(user_token_b, false),
        ])
    }
}

Trident's Arbitrary derive macro generates random values for each field. The fuzzer then builds transactions from these random instructions and fires them at your program.

The account problem

Here's where Solana fuzzing gets tricky. Every instruction needs a specific set of accounts with the right owners and data layout, plus correct signing authority. If any account is wrong, the instruction just fails with a runtime error. The fuzzer learns nothing.

Good Trident harnesses spend most of their code on account setup:

// This is the tedious but critical part
fn setup_pool_accounts(
    accounts: &mut AccountsStorage,
) -> Result<(), FuzzingError> {
    // Create token mints
    let mint_a = accounts.create_mint("mint_a", 6)?; // 6 decimals like USDC
    let mint_b = accounts.create_mint("mint_b", 9)?; // 9 decimals like SOL

    // Create pool account with correct PDA
    let (pool_pda, bump) = Pubkey::find_program_address(
        &[b"pool", mint_a.as_ref(), mint_b.as_ref()],
        &program_id(),
    );
    accounts.register_pda("pool", pool_pda, bump)?;

    // Create vault token accounts owned by the pool PDA
    let vault_a = accounts.create_token_account(
        "token_a_vault",
        &mint_a,
        &pool_pda
    )?;
    let vault_b = accounts.create_token_account(
        "token_b_vault",
        &mint_b,
        &pool_pda
    )?;

    // Fund initial liquidity so swaps can actually execute
    accounts.mint_to("mint_a", &vault_a, 1_000_000_000)?;
    accounts.mint_to("mint_b", &vault_b, 1_000_000_000)?;

    Ok(())
}

Without proper account setup, the fuzzer just hits AccountNotFound or ConstraintOwner errors on every attempt. It's like running an EVM fuzzer without deploying any contracts first.

Honggfuzz-rs: raw power, manual wiring

Honggfuzz is Google's coverage-guided fuzzer. It doesn't know anything about Solana or Anchor — it just sees bytes. You're responsible for deserializing those bytes into meaningful program inputs.

Setup

# Install honggfuzz
cargo install honggfuzz

# In your Cargo.toml
[dependencies]
honggfuzz = "0.5"
solana-program-test = "1.17"
solana-sdk = "1.17"

Writing a Honggfuzz harness

use honggfuzz::fuzz;
use solana_program_test::*;
use solana_sdk::{
    signature::Keypair,
    signer::Signer,
    transaction::Transaction,
};

fn main() {
    // Set up program test environment once
    loop {
        fuzz!(|data: &[u8]| {
            if data.len() < 16 { return; }

            // Parse fuzzer bytes into instruction data
            let amount_in = u64::from_le_bytes(
                data[0..8].try_into().unwrap()
            );
            let min_out = u64::from_le_bytes(
                data[8..16].try_into().unwrap()
            );

            // Build and send transaction
            let rt = tokio::runtime::Runtime::new().unwrap();
            rt.block_on(async {
                let mut ctx = setup_test_context().await;

                let ix = create_swap_instruction(
                    amount_in,
                    min_out,
                    &ctx.payer.pubkey(),
                );

                let tx = Transaction::new_signed_with_payer(
                    &[ix],
                    Some(&ctx.payer.pubkey()),
                    &[&ctx.payer],
                    ctx.last_blockhash,
                );

                // We don't care if it fails -- just looking for panics
                // and unexpected behavior
                let _ = ctx.banks_client
                    .process_transaction(tx)
                    .await;
            });
        });
    }
}

The big advantage of honggfuzz: it's fast. Really fast. It doesn't need to understand your program's semantics. It just sprays inputs and tracks coverage at the machine code level.

The big disadvantage: you lose all structure. The fuzzer doesn't know that amount_in and min_out are related, or that certain account combinations are more interesting than others.

Property testing with Proptest

For unit-level property testing, proptest is excellent. It's not a fuzzer in the traditional sense. It generates random inputs based on your type definitions and runs property assertions.

use proptest::prelude::*;

// Define strategies for generating test data
fn valid_swap_amount() -> impl Strategy<Value = u64> {
    1u64..=1_000_000_000_000u64 // 1 lamport to 1000 tokens
}

fn valid_fee_bps() -> impl Strategy<Value = u16> {
    1u16..=10000u16 // 0.01% to 100%
}

proptest! {
    #[test]
    fn swap_preserves_k_invariant(
        amount_in in valid_swap_amount(),
        fee_bps in valid_fee_bps(),
        reserve_a in 1_000_000u64..1_000_000_000_000u64,
        reserve_b in 1_000_000u64..1_000_000_000_000u64,
    ) {
        let k_before = (reserve_a as u128) * (reserve_b as u128);

        // Simulate swap math
        let fee = (amount_in as u128 * fee_bps as u128) / 10000;
        let amount_after_fee = amount_in as u128 - fee;
        let new_reserve_a = reserve_a as u128 + amount_after_fee;
        let amount_out = reserve_b as u128
            - (k_before / new_reserve_a);
        let new_reserve_b = reserve_b as u128 - amount_out;

        let k_after = new_reserve_a * new_reserve_b;

        // k should never decrease (fees make it grow)
        prop_assert!(
            k_after >= k_before,
            "k decreased: before={}, after={}, amount_in={}, fee_bps={}",
            k_before, k_after, amount_in, fee_bps
        );
    }

    #[test]
    fn add_liquidity_proportional(
        amount_a in 1_000u64..1_000_000_000u64,
        reserve_a in 1_000_000u64..1_000_000_000u64,
        reserve_b in 1_000_000u64..1_000_000_000u64,
        total_lp in 1_000_000u64..1_000_000_000u64,
    ) {
        // Adding liquidity should give proportional LP tokens
        let expected_ratio = amount_a as f64 / reserve_a as f64;
        let lp_minted = (total_lp as f64 * expected_ratio) as u64;

        // LP tokens should be > 0 for any non-zero deposit
        prop_assert!(lp_minted > 0 || amount_a < reserve_a / total_lp);
    }
}

Proptest shines for testing pure math functions (swap calculations, fee computations, price conversions). You don't need the full Solana runtime for these.

CPI testing: the blind spot

Cross-program invocations are where most Solana bugs live. A program calls another program, trusting the return data. But what if the called program is malicious? What if it re-enters?

Testing CPIs is hard because you need to simulate the full call stack:

#[cfg(test)]
mod cpi_tests {
    use super::*;
    use solana_program_test::*;

    #[tokio::test]
    async fn test_cpi_reentrancy_guard() {
        let mut test = ProgramTest::new(
            "token_swap",
            program_id(),
            processor!(process_instruction),
        );

        // Deploy a malicious "token" program that re-enters
        test.add_program(
            "malicious_token",
            malicious_program_id(),
            processor!(malicious_process_instruction),
        );

        let (mut banks, payer, blockhash) = test.start().await;

        // Set up accounts pointing to malicious token program
        // Then try to swap -- should fail, not re-enter
        let ix = create_swap_instruction_with_token_program(
            1000,
            0,
            &payer.pubkey(),
            &malicious_program_id(), // Using malicious token program
        );

        let tx = Transaction::new_signed_with_payer(
            &[ix],
            Some(&payer.pubkey()),
            &[&payer],
            blockhash,
        );

        // This should fail cleanly, not panic or allow reentrancy
        let result = banks.process_transaction(tx).await;
        assert!(result.is_err());
    }
}

Solana vs EVM fuzzing maturity

Let's be honest about where things stand:

AspectEVMSolana
Purpose-built fuzzersEchidna, Medusa, FoundryTrident
Maturity5+ years~2 years
Property testing supportFirst-classManual setup
Coverage toolingGoodLimited
Corpus managementAutomaticBasic
Community resourcesExtensiveGrowing
Multi-contract testingWell supportedPainful

The EVM ecosystem has years of head start. Tools like Echidna and Medusa handle stateful fuzzing with multi-contract interactions out of the box. On Solana, you're still wiring a lot of this yourself.

That said, Solana's type system (via Anchor's account constraints) prevents an entire class of bugs that plague EVM contracts. You can't accidentally call the wrong contract, and account ownership checks are enforced structurally. The bugs that remain are subtler: math errors, missing signer checks in non-Anchor programs, and logic flaws in CPI chains.

Practical recommendations

For Anchor programs

Start with Trident. It has the lowest barrier to entry and understands your program structure. Here's the workflow:

  1. trident init in your Anchor project
  2. Define your instruction set in the generated harness
  3. Invest time in proper account setup. This is where campaigns succeed or fail
  4. Run for at least 4 hours on your first campaign
  5. Check coverage reports to find unreached code

For native Solana programs

Use cargo-fuzz or honggfuzz-rs. Native programs don't have Anchor's IDL, so Trident can't auto-generate harnesses. You'll need to:

  1. Write manual serialization/deserialization for instruction data
  2. Set up ProgramTest environments for realistic state
  3. Focus on the instruction parsing layer. That's where native programs have the most bugs

For math-heavy logic

Use proptest. Don't try to fuzz your AMM curve math through the full Solana runtime. Extract the pure functions and test them with property-based strategies:

// Extract pure math for property testing
pub fn calculate_swap_output(
    amount_in: u64,
    reserve_in: u64,
    reserve_out: u64,
    fee_bps: u16,
) -> Result<u64, SwapError> {
    // This function has no Solana dependencies -- proptest it directly
    let fee = (amount_in as u128)
        .checked_mul(fee_bps as u128)
        .ok_or(SwapError::Overflow)?
        / 10000;
    let net_in = (amount_in as u128) - fee;
    let new_reserve_in = (reserve_in as u128) + net_in;
    let new_reserve_out = (reserve_in as u128)
        .checked_mul(reserve_out as u128)
        .ok_or(SwapError::Overflow)?
        / new_reserve_in;
    let output = (reserve_out as u128) - new_reserve_out;

    Ok(output as u64)
}

What's next for Solana fuzzing

The tooling is improving fast. Trident adds features every release. The property-based testing ecosystem in Rust is mature and directly applicable. And as more auditors move into the Solana space, expect better coverage tools, corpus management, and integration with CI/CD pipelines.

The fundamentals of fuzzing apply universally. If you understand coverage-guided feedback loops, seed selection, and invariant design, you can fuzz Solana programs effectively today. The tools are rougher, but the techniques transfer directly.

Get a Solana Security Review

Try Recon Pro

Related Posts

Related Glossary Terms

Get your Solana program audited