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:
- Trident, Ackee's fuzzing framework, purpose-built for Anchor programs
- honggfuzz-rs, Google's general-purpose fuzzer wrapped for Rust
- 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:
| Aspect | EVM | Solana |
|---|---|---|
| Purpose-built fuzzers | Echidna, Medusa, Foundry | Trident |
| Maturity | 5+ years | ~2 years |
| Property testing support | First-class | Manual setup |
| Coverage tooling | Good | Limited |
| Corpus management | Automatic | Basic |
| Community resources | Extensive | Growing |
| Multi-contract testing | Well supported | Painful |
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:
trident initin your Anchor project- Define your instruction set in the generated harness
- Invest time in proper account setup. This is where campaigns succeed or fail
- Run for at least 4 hours on your first campaign
- 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:
- Write manual serialization/deserialization for instruction data
- Set up
ProgramTestenvironments for realistic state - 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
Fuzzing ZK circuits: testing Noir and Circom with property-based approaches
ZK circuits need testing too. Under-constrained circuits, missing range checks, and witness mismatch...
Halmos symbolic execution for smart contracts: setup, limitations, and when it beats fuzzing
Fuzzers sample randomly. Symbolic execution explores every path. Halmos brings symbolic execution to...