Skip to main content

How to Fuzz Test Programs with Trident

Updated on
Mar 30, 2026

39 min read

Overview

info

Trident is created and maintained by Ackee Blockchain Security. This guide was produced in collaboration between Quicknode and Ackee Blockchain Security.

As of March 2026, Solana currently holds over $6.59B in Total Value Locked (TVL), according to DeFiLlama. As TVL grows and real-world assets move onchain, the attack surface grows with it.

Unit tests verify a handful of edge cases, but they only cover inputs the developer thought to test. Audits catch known vulnerability patterns, but they are expensive and often only done early in the project's lifecycle. Neither approach stress-tests a program with the millions of randomized transactions it will face in production.

Fuzzing fills that gap. It bombards a program with random, valid, invalid, and unexpected inputs to surface bugs that manual testing misses: missing account constraints, incorrect token handling, unexpected state transitions, and more.

Trident is a fuzzing framework built for Solana. Unlike black-box fuzzers that fire instructions in random order, Trident uses manually guided fuzzing where the developer specifies realistic instruction sequences (setup first, permutations in the middle, teardown last) so the fuzzer explores meaningful state paths.

This guide walks through setting up Trident, writing fuzz tests for a token escrow program, catching a real constraint vulnerability, and monitoring results with the built-in metrics dashboard.


TL;DR
  • Trident is a manually guided fuzzer for Solana Anchor programs
  • It catches missing account constraints, incorrect token handling, and unauthorized state changes that unit tests miss
  • This guide fuzzes a token escrow program with an intentional token substitution vulnerability
  • Setup takes ~15 minutes; the fuzzer surfaces the bug in its first run

What You Will Do


  • Install Trident CLI and scaffold fuzz tests in an Anchor project
  • Set up fuzz tests for a token escrow program with an intentional vulnerability
  • Define instruction flows using #[init], #[flow], and #[end] annotations
  • Configure multi-actor fuzzing with SPL Token mints and accounts using Trident's token helpers
  • Monitor results using the Trident metrics dashboard
  • Add invariant checks to verify token correctness after each iteration

What You Will Need

This guide assumes some familiarity with writing and testing Anchor programs. You should also have:


You will also need to install the following:

DependencyVersion
Solana CLI3.0.4
Anchor0.32.1
Rust1.93.1
Node.js24
trident-cli0.12.0

What Is Manually Guided Fuzzing?

Manually guided fuzzing is a security testing technique where the developer defines the structure of instruction sequences: setup first, permutations in the middle, teardown last, while the fuzzer randomizes inputs, amounts, and accounts within that structure.

Traditional "black-box" fuzzers generate completely random instruction sequences and data. This works for simple programs, but Solana programs expect specific instruction orderings. A make_offer instruction must run before take_offer. A black-box fuzzer wastes most of its time on invalid orderings the runtime will reject immediately.

Black-Box FuzzingManually Guided Fuzzing
Instruction orderingFully randomDeveloper-specified structure
Invalid sequence handlingWastes cycles on rejected orderingsSkips invalid orderings by design
Input randomizationRandomRandom within developer-defined ranges
Semantic invariantsNoneDeveloper-encoded assertions
Best forSimple, stateless programsStateful programs with ordered instructions

The fuzzer randomizes within each phase, choosing which permutation instructions to run, in what order, and with what inputs, but respects the overall structure. This means every fuzzing iteration exercises a realistic transaction sequence, increasing the chance of finding real bugs.

Trident integrates directly with Anchor projects. It reads the program's Interface Definition Language (IDL) to understand instruction signatures and account structures, then generates a fuzz test template. The developer fills in the instruction flows, input ranges, and account storage configuration. Trident handles execution, metrics collection, and crash reproduction.

What Bugs Does Trident Catch Automatically?

Trident is fully manually guided. You control the shape of every fuzz iteration: which flows run, in what order, and what values they receive. For each instruction argument you can either supply a fixed value or tell Trident to generate a random one (e.g., trident.random_from_range(0..u64::MAX)). The same applies to accounts. You configure what goes into AddressStorage and Trident picks from it randomly at runtime.

Without any invariants from you, Trident will:


  • Try random instruction sequences drawn from your defined flows to surface crashes and Rust panics caused by unexpected orderings.
  • Try random account substitution using whatever addresses you put in storage like wrong mints, wrong signers, and wrong PDAs, and report any panics or unexpected crashes that result.
  • Try randomized input values for any arguments you configure with a range or randomness helper, catching panics triggered by edge-case numbers or strings.

What Invariants Do You Need to Write Yourself?

Trident will try passing a fake mint automatically through random account substitution, but without an assert!(result.is_error(), ...), it sees the transaction succeed and moves on. It has no way to know that a successful transaction is the bug. You have to encode what correct behavior looks like.

The same applies to all semantic security properties:

Business logic: "after a successful take, the maker must have received exactly token_b_wanted_amount" requires knowing the intended semantics

Avoid copying math from the program into your invariant. If the program has a bug in how it calculates a value, the same bug will appear on both sides of the assertion and the invariant will always pass, even though the program is broken.

Two safer approaches are:


  • Re-implement the math independently in the fuzz test using your own logic, so the two sides can disagree when there is a bug.
  • Use relational invariants instead of exact-value checks. These checks don't rely on the correctness of any program math and are harder to accidentally mirror a bug into.

Authorization invariants: "only the maker can cancel their own offer" requires you to define what unauthorized success looks like

Balance invariants: "total token supply must be conserved across all accounts" requires reading state and asserting relationships

Trident autonomously finds two categories of issues with zero guidance from you:


  • Program panics: Crashes, unexpected reverts, and arithmetic overflows (caught in debug mode). A panic isn't always catastrophic, but it can be: a panic triggered by a specific input sequence is a Denial of Service vulnerability, since an attacker who can reliably crash your program can freeze user funds or block protocol operations.
  • Invariant violations: Any assert! in your fuzz test that fails. These represent the semantic properties you encoded like token balances out of bounds, unauthorized state changes, or whatever your program must never allow.

For semantic security properties such as "this should never succeed" or "only the maker can cancel", you need to know what the properties are and encode them as invariants. That knowledge comes from security audit checklists, threat modeling, or domain expertise, not from the fuzzer itself.

The Escrow Program

This guide uses a token escrow program from the Solana Developers Program Examples repository. The program enables trustless peer-to-peer token swaps: a maker creates an offer by depositing tokens into a PDA-controlled vault, and a taker accepts the offer by sending the requested tokens to the maker and receiving the vault tokens in return.

The program has two instructions:


  • make_offer — The maker specifies two token mints (A and B), deposits token A into a PDA-controlled vault, and records how much token B they want in return
  • take_offer — The taker sends the requested token B to the maker and receives token A from the vault. The offer account and vault are closed after the swap

You will add an intentional vulnerability that the fuzzer will catch:


  • Missing token mint constraint: The TakeOffer account validation will have a missing has_one = token_mint_b constraint on the offer account. This means a taker can substitute a worthless token mint, send fake tokens to the maker, and still receive the real token A from the vault.

Set Up the Anchor Project

Clone the Solana Program Examples repository and navigate to the escrow program:

git clone https://github.com/solana-developers/program-examples.git
cd program-examples/tokens/escrow/anchor

Because Trident reads the IDL to code-generate the fuzz test scaffolding, you must first build the project to generate the IDL:

anchor build

Any time you change an instruction signature or account struct in your Anchor program, you need to re-run anchor build before fuzzing, or your fuzz test structs will be out of sync with what the program actually expects.

Confirm the compiled binary exists:

ls target/deploy/escrow.so

Run the existing Anchor tests to verify the sample escrow project works correctly out of the box. These tests are part of the example repo and will not be modified:

npm install
anchor test

Expected output:

    [DEBUG LOGS ...]
escrow
✔ Puts the tokens Alice offers into the vault when Alice makes an offer
✔ Puts the tokens from the vault into Bob's account, and gives Alice Bob's tokens, when Bob takes an offer

Introduce a Bug

To demonstrate what Trident can catch, you will intentionally introduce a vulnerability before writing the fuzz test. Comment out the has_one = token_mint_b constraint in take_offer.rs at line 58.

Without this check, the program no longer verifies that the token the taker sends matches the mint the maker originally requested. This would allow an attacker to substitute a worthless fake token and drain the vault.

programs/escrow/src/instructions/take_offer.rs
...
#[account(
mut,
close = maker,
has_one = maker,
has_one = token_mint_a,
// has_one = token_mint_b,
seeds = [b"offer", maker.key().as_ref(), offer.id.to_le_bytes().as_ref()],
bump = offer.bump
)]
offer: Account<'info, Offer>,
...

Install Trident CLI

Trident is distributed as a Cargo package, so installation is a single cargo install command. This pulls the latest published version of the CLI from crates.io and makes the trident binary available in your shell.

cargo install trident-cli

Verify the installation:

trident --version

Expected output:

Trident 0.12.0

Initialize Trident Fuzz Tests

trident init reads your compiled Anchor IDL and generates typed Rust scaffolding for your fuzz tests. Trident derives instruction argument types, account structs, and discriminators directly from the IDL so your fuzz tests are always in sync with the program's actual interface.

From the project root, scaffold the fuzz test files:

trident init

This generates a trident-tests/ directory with the following structure:

trident-tests/
├── Cargo.toml # Fuzz test dependencies (separate workspace)
├── Trident.toml # Fuzzer configuration
└── fuzz_0/
├── test_fuzz.rs # Entry point: FuzzTest struct, flows, and main
├── fuzz_accounts.rs # AccountAddresses struct (account address storage)
└── types.rs # Auto-generated instruction types and Offer struct

Trident.toml contains the path to the compiled program binary, metrics and dashboard settings, and coverage configuration. Enable the dashboard in trident-tests/Trident.toml by adding dashboard = true:

trident-tests/Trident.toml
[fuzz.metrics]
enabled = true
dashboard = true

[[fuzz.programs]]
address = "qbuMdeYxYJXBjU6C6qFKjZKjXmrU83eDQomHdrch826"
program = "../target/deploy/escrow.so"

Cargo.toml declares trident-fuzz as a dependency and defines the fuzz_0 binary target. Trident uses a separate workspace to avoid dependency conflicts with your Anchor project.

Since the escrow uses SPL Token accounts, enable the token feature to pull in SPL Token helpers used in the setup and flow methods:

trident-tests/Cargo.toml
...
[dependencies.trident-fuzz]
version = "0.12.0"
features = ["token"]
...

fuzz_0/test_fuzz.rs is the fuzz test entry point. It declares the FuzzTest struct, helper functions, instruction flows, and main. The two companion files, fuzz_accounts.rs and types.rs, are included as Rust modules via mod.

Review Generated Types

When you run trident init, Trident reads the Anchor IDL and auto-generates trident-tests/fuzz_0/types.rs. Do not edit this file manually (unless you are on Anchor 0.29 or earlier — see note below). If the program IDL changes, regenerate it with:

trident fuzz refresh fuzz_0

types.rs defines everything needed to build and submit program instructions without importing the program crate directly:


  • escrow::program_id(): The program's deployed address
  • MakeOfferInstructionAccounts / TakeOfferInstructionAccounts: Pubkey structs for each required account
  • MakeOfferInstructionData / TakeOfferInstructionData: Borsh-serializable instruction parameter structs
  • MakeOfferInstruction / TakeOfferInstruction: Builders that assemble a serialized Instruction with the correct 8-byte discriminator and account metadata (signer/writable flags pre-set)
  • Offer: A Borsh-deserializable mirror of the on-chain Offer struct for reading account state mid-iteration

The Associated Token Program and System Program addresses are hard-coded inside each instruction's accounts() builder so you never need to pass them manually.


Anchor 0.29

Anchor 0.29 and earlier IDLs do not include the program ID or instruction discriminators. If you are working with an older program, types.rs will be generated with placeholder values and you will need to fill in the program ID and discriminators manually before running the fuzzer.

Configure Account Address Storage

fuzz_accounts.rs defines AccountAddresses, a struct of AddressStorage fields, one per named account in the program. trident init scaffolds it from the IDL and you add custom fields (like fake_mint) by hand and remove the ones you don't use.

trident-tests/fuzz_0/fuzz_accounts.rs
use trident_fuzz::fuzzing::*;

/// Storage for all account addresses used in fuzz testing.
#[derive(Default)]
pub struct AccountAddresses {
pub maker: AddressStorage,
pub token_mint_a: AddressStorage,
pub token_mint_b: AddressStorage,
pub offer: AddressStorage,
pub vault: AddressStorage,
pub associated_token_program: AddressStorage,
pub token_program: AddressStorage,
pub system_program: AddressStorage,
pub taker: AddressStorage,
pub fake_mint: AddressStorage,
}

By grouping all address storage in one struct, every flow method can share state without threading individual variables through each method.

Define the FuzzTest Struct

test_fuzz.rs opens with module imports and defines the FuzzTest struct. The struct holds two fields:


  • trident: The Trident client that executes transactions, provides randomness, and exposes token helpers
  • fuzz_accounts: The AccountAddresses instance from fuzz_accounts.rs that pools all participant and token addresses

The #[derive(FuzzTestMethods)] macro generates the test execution loop (iteration control, flow selection, timing). The #[flow_executor] attribute on the impl block marks it as the source of #[init], #[flow], and #[end] methods.

In the sections that follow, you will write:


  • Helper functions to reduce repeated code
  • A setup helper to initialize mints and fund accounts
  • A make_offer flow that acts as the maker
  • A take_offer flow that acts as the taker with an optional fake mint substitution

Write Helper Functions

Add three helper functions at the top of test_fuzz.rs to reduce repetition in the setup method.

trident-tests/fuzz_0/test_fuzz.rs
// use/mod statements 

// initializes a new token mint with the given authority and 6 decimal places
fn setup_mint(trident: &mut Trident, payer: &Pubkey, mint: &Pubkey, authority: &Pubkey) {
let ixs = trident.initialize_mint(payer, mint, 6, authority, None);
trident.process_transaction(&ixs, None);
}

// creates an associated token account for a given owner and mint without funding it
fn setup_ata(trident: &mut Trident, payer: &Pubkey, mint: &Pubkey, owner: &Pubkey) {
let ix = trident.initialize_associated_token_account(payer, mint, owner);
trident.process_transaction(&[ix], None);
}

// creates the ATA and mints tokens into it in two transactions
fn setup_funded_ata(
trident: &mut Trident,
payer: &Pubkey,
mint: &Pubkey,
owner: &Pubkey,
authority: &Pubkey,
amount: u64,
token_program: &Pubkey,
) {
let ix = trident.initialize_associated_token_account(payer, mint, owner);
trident.process_transaction(&[ix], None);
let ata = trident.get_associated_token_address(mint, owner, token_program);
let ix = trident.mint_to(&ata, mint, authority, amount);
trident.process_transaction(&[ix], None);
}

#[derive(FuzzTestMethods)]
...

Configure the #[init] Method

A fuzz test target may define a single #[init] method that Trident runs at the start of every fuzzing iteration. trident init scaffolds an empty #[init] handler. Replace that stub with the following so each iteration gets fresh keypairs, mints, token accounts, and stored program IDs.

trident-tests/fuzz_0/test_fuzz.rs
#[flow_executor]
impl FuzzTest {
fn new() -> Self {
Self {
trident: Trident::default(),
fuzz_accounts: AccountAddresses::default(),
}
}

#[init]
fn start(&mut self) {
self.fuzz_accounts.token_program.insert_with_address(pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"));
self.fuzz_accounts.associated_token_program.insert_with_address(pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"));
self.fuzz_accounts.system_program.insert_with_address(pubkey!("11111111111111111111111111111111"));

let maker = self.fuzz_accounts.maker.insert(&mut self.trident, None);
let taker = self.fuzz_accounts.taker.insert(&mut self.trident, None);
self.trident.airdrop(&maker, 10_000_000_000);
self.trident.airdrop(&taker, 10_000_000_000);

let mint_a = self.fuzz_accounts.token_mint_a.insert(&mut self.trident, None);
let mint_b = self.fuzz_accounts.token_mint_b.insert(&mut self.trident, None);
let fake_mint = self.fuzz_accounts.fake_mint.insert(&mut self.trident, None);

setup_mint(&mut self.trident, &maker, &mint_a, &maker);
setup_mint(&mut self.trident, &taker, &mint_b, &maker);
setup_mint(&mut self.trident, &taker, &fake_mint, &taker);

let token_program = self.fuzz_accounts.token_program.get(&mut self.trident).unwrap();
setup_funded_ata(&mut self.trident, &maker, &mint_a, &maker, &maker, 1_000_000_000, &token_program);
setup_funded_ata(&mut self.trident, &taker, &mint_b, &taker, &maker, 1_000_000_000, &token_program);
setup_funded_ata(&mut self.trident, &taker, &fake_mint, &taker, &taker, 1_000_000_000, &token_program);

// Pre-create the ATAs that take_offer's init_if_needed would otherwise
// fund at the taker's expense, which would cause the taker's SOL balance
// to decrease even after the vault-close rent is returned.
setup_ata(&mut self.trident, &taker, &mint_a, &taker); // taker receives token A
setup_ata(&mut self.trident, &maker, &mint_b, &maker); // maker receives token B

// Reset offer/vault so take_offer cannot run against a stale PDA from a
// previous iteration (which would have a different token_mint_b).
self.fuzz_accounts.offer = AddressStorage::default();
self.fuzz_accounts.vault = AddressStorage::default();
}

// ADD TEST FLOWS HERE
}

Configure Flow Methods

Methods marked with #[flow] define the instructions the fuzzer randomly selects and executes during each iteration.

Make Offer

After the start method, add the first flow for make offer:

trident-tests/fuzz_0/test_fuzz.rs
 // Flow: make_offer
//
// Simulates the maker creating an escrow offer. Each call generates a fresh
// random offer ID, offered amount, and wanted amount, so the fuzzer exercises
// a wide range of numeric inputs and PDA addresses across iterations.
//
// After a successful transaction the flow verifies:
// - The exact number of tokens were moved from the maker into the vault (no
// extra tokens created or lost in transit).
// - Every field written to the on-chain Offer account matches the values
// passed into the instruction (guards against silent data corruption in
// save_offer or Borsh serialisation).
// - The vault token account is owned by the offer PDA, not by the maker,
// ensuring the maker cannot withdraw the tokens unilaterally.
// - If wanted_amount is zero the program still accepts the offer, which is
// a latent policy issue — a warning is printed so it is visible in fuzz
// output without hard-failing (the program has no explicit guard for this).
#[flow]
fn make_offer(&mut self) {
let Some(maker) = self.fuzz_accounts.maker.get(&mut self.trident) else { return; };
let Some(mint_a) = self.fuzz_accounts.token_mint_a.get(&mut self.trident) else { return; };
let Some(mint_b) = self.fuzz_accounts.token_mint_b.get(&mut self.trident) else { return; };
let Some(token_program) = self.fuzz_accounts.token_program.get(&mut self.trident) else { return; };

// Randomise all offer parameters so the fuzzer explores the full input space.
// wanted_amount starts at 0 (not 1) to exercise the zero-amount edge case.
let id: u64 = self.trident.random_from_range(0..u64::MAX);
let offered_amount: u64 = self.trident.random_from_range(1u64..100_000u64);
let wanted_amount: u64 = self.trident.random_from_range(0u64..100_000u64);

// Derive the offer PDA and vault address the same way the program does,
// so the instruction accounts are always consistent with the on-chain seeds.
let id_bytes = id.to_le_bytes();
let (offer_pda, _) = self.trident.find_program_address(
&[b"offer", maker.as_ref(), &id_bytes],
&escrow::program_id(),
);

let maker_ata_a =
self.trident.get_associated_token_address(&mint_a, &maker, &token_program);
let vault =
self.trident.get_associated_token_address(&mint_a, &offer_pda, &token_program);

let accounts = escrow::MakeOfferInstructionAccounts::new(
maker,
mint_a,
mint_b,
maker_ata_a,
offer_pda,
vault,
token_program,
);
let data = escrow::MakeOfferInstructionData::new(id, offered_amount, wanted_amount);
let ix = escrow::MakeOfferInstruction::data(data)
.accounts(accounts)
.instruction();

// Snapshot the maker's token A balance before the transaction so we can
// verify the exact amount that leaves their account.
let maker_ata_a_before = self.trident
.get_token_account(maker_ata_a)
.map(|a| a.account.amount)
.unwrap_or(0);

let result = self.trident.process_transaction(&[ix], Some("make_offer"));

if result.is_success() {
let vault_balance = self.trident
.get_token_account(vault)
.map(|a| a.account.amount)
.unwrap_or(0);
let maker_ata_a_after = self.trident
.get_token_account(maker_ata_a)
.map(|a| a.account.amount)
.unwrap_or(0);

// Check that the vault received exactly offered_amount tokens and that
// the same number left the maker's account — no tokens created or lost.
assert_eq!(vault_balance, offered_amount,
"make_offer: vault balance {vault_balance} != offered_amount {offered_amount}");
assert_eq!(maker_ata_a_after, maker_ata_a_before - offered_amount,
"make_offer: maker_ata_a drained incorrectly");

// Every field written to the offer PDA must round-trip correctly.
// A bug in save_offer (e.g. wrong field order, off-by-one in Borsh layout)
// would silently store incorrect data that only shows up at take time.
let offer_state = self.trident
.get_account_with_type::<crate::types::Offer>(&offer_pda, 8)
.expect("offer PDA not readable after make_offer");
assert_eq!(offer_state.maker, maker, "offer.maker mismatch");
assert_eq!(offer_state.token_mint_a, mint_a, "offer.token_mint_a mismatch");
assert_eq!(offer_state.token_mint_b, mint_b, "offer.token_mint_b mismatch");
assert_eq!(offer_state.token_b_wanted_amount, wanted_amount, "offer.wanted_amount mismatch");
assert_eq!(offer_state.id, id, "offer.id mismatch");

// The vault token account must be owned by the offer PDA, not
// the maker. If the maker were the authority they could drain the vault at
// any time, bypassing the escrow entirely.
let vault_acct = self.trident.get_token_account(vault).unwrap();
assert_eq!(vault_acct.account.owner, offer_pda,
"make_offer: vault authority is not the offer PDA");
assert_eq!(vault_acct.account.mint, mint_a,
"make_offer: vault has wrong mint");

self.fuzz_accounts.offer.insert_with_address(offer_pda);
self.fuzz_accounts.vault.insert_with_address(vault);
}

// The program has no explicit wanted_amount > 0 guard. If the
// fuzzer generates wanted_amount=0 and the transaction succeeds, the taker
// could drain the vault without transferring anything in return.
if wanted_amount == 0 && result.is_success() {
eprintln!("WARNING: make_offer succeeded with wanted_amount=0 — verify this is intentional");
}

self.trident.record_histogram("offered_amount", offered_amount as f64);
}

Take Offer

Next, add the flow for take offer:

trident-tests/fuzz_0/test_fuzz.rs
// Flow: take_offer
//
// Simulates a taker accepting an existing escrow offer. The flow is only
// attempted when a live offer PDA exists in the pool (created by make_offer).
//
// Token substitution attack: on each call the fuzzer randomly chooses whether
// to pass the correct token_mint_b or a fake mint. Passing a wrong mint must
// always be rejected — if it succeeded, the taker could drain the vault while
// paying with worthless tokens.
//
// After a successful swap the flow verifies:
// - The taker received exactly the vault balance in token A (all escrowed
// tokens transferred, nothing left behind or double-spent).
// - The taker paid exactly wanted_amount in token B (no under-payment).
// - The maker received exactly wanted_amount in token B (no over-payment or
// tokens redirected to a third party).
// - The vault token account was closed (Anchor `close` directive executed).
// - The offer PDA account was closed (account data and lamports wiped).
// - Both the maker and taker received their rent-exempt SOL back from the
// closed accounts (maker gets offer PDA rent, taker gets vault rent).
#[flow]
fn take_offer(&mut self) {
let Some(taker) = self.fuzz_accounts.taker.get(&mut self.trident) else { return; };
let Some(maker) = self.fuzz_accounts.maker.get(&mut self.trident) else { return; };
let Some(mint_a) = self.fuzz_accounts.token_mint_a.get(&mut self.trident) else { return; };
let Some(offer_pda) = self.fuzz_accounts.offer.get(&mut self.trident) else { return; };
let Some(token_program) = self.fuzz_accounts.token_program.get(&mut self.trident) else { return; };

// Derive the vault address from the offer PDA rather than reading it from
// storage — this avoids any pool-sync issues and is how the program does it.
let vault =
self.trident.get_associated_token_address(&mint_a, &offer_pda, &token_program);

// Read wanted_amount from the live on-chain state, not from a local variable,
// so the assertion reflects what the program actually stored.
let Some(offer_state) = self.trident
.get_account_with_type::<crate::types::Offer>(&offer_pda, 8) else { return; };
let wanted_amount = offer_state.token_b_wanted_amount;

// Token substitution attack: randomly supply the correct mint or an unrelated
// fake mint. The program must reject any mint that does not match the one
// recorded in the offer PDA.
let use_fake_mint = self.trident.random_bool();
let mint_b = if use_fake_mint {
let Some(m) = self.fuzz_accounts.fake_mint.get(&mut self.trident) else { return; };
m
} else {
let Some(m) = self.fuzz_accounts.token_mint_b.get(&mut self.trident) else { return; };
m
};

let taker_ata_a =
self.trident.get_associated_token_address(&mint_a, &taker, &token_program);
let taker_ata_b =
self.trident.get_associated_token_address(&mint_b, &taker, &token_program);
let maker_ata_b =
self.trident.get_associated_token_address(&mint_b, &maker, &token_program);

// Snapshot SOL balances before the transaction so we can verify that both
// parties receive their rent-exempt lamports back when the accounts close.
let maker_sol_before = self.trident.get_account(&maker).lamports();
let taker_sol_before = self.trident.get_account(&taker).lamports();

// Snapshot all token balances before the transaction so post-transaction
// deltas can be checked exactly.
let vault_before = self.trident
.get_token_account(vault)
.map(|a| a.account.amount)
.unwrap_or(0);
let taker_ata_a_before = self.trident
.get_token_account(taker_ata_a)
.map(|a| a.account.amount)
.unwrap_or(0);
let taker_ata_b_before = self.trident
.get_token_account(taker_ata_b)
.map(|a| a.account.amount)
.unwrap_or(0);
let maker_ata_b_before = self.trident
.get_token_account(maker_ata_b)
.map(|a| a.account.amount)
.unwrap_or(0);

let accounts = escrow::TakeOfferInstructionAccounts::new(
taker,
maker,
mint_a,
mint_b,
taker_ata_a,
taker_ata_b,
maker_ata_b,
offer_pda,
vault,
token_program,
);
let data = escrow::TakeOfferInstructionData::new();
let ix = escrow::TakeOfferInstruction::data(data)
.accounts(accounts)
.instruction();

let result = self.trident.process_transaction(&[ix], Some("take_offer"));

// Token substitution check: any attempt to swap with a mint that does not
// match the one stored in the offer PDA must be rejected by the program.
if use_fake_mint {
if result.is_success() {
eprintln!("VULNERABILITY: take_offer accepted a fake token mint (token substitution attack succeeded)");
self.trident.record_histogram("fake_mint_accepted", 1.0);
} else {
self.trident.record_histogram("fake_mint_accepted", 0.0);
}
}

// All post-success invariants are only meaningful when the correct mint was
// used. On success the offer PDA is closed on-chain, so we also reset the
// pool to prevent future iterations from hitting the dead account.
if !use_fake_mint && result.is_success() {
let taker_ata_a_after = self.trident
.get_token_account(taker_ata_a)
.map(|a| a.account.amount)
.unwrap_or(0);
let taker_ata_b_after = self.trident
.get_token_account(taker_ata_b)
.map(|a| a.account.amount)
.unwrap_or(0);
let maker_ata_b_after = self.trident
.get_token_account(maker_ata_b)
.map(|a| a.account.amount)
.unwrap_or(0);

// Verify the complete token swap: taker gets all of the vault (token A),
// taker pays exactly wanted_amount (token B), maker receives that same
// amount. Any deviation indicates tokens were created, destroyed, or
// redirected.
assert_eq!(taker_ata_a_after, taker_ata_a_before + vault_before,
"take_offer: taker did not receive correct token A amount");
assert_eq!(taker_ata_b_after, taker_ata_b_before - wanted_amount,
"take_offer: taker did not pay correct token B amount");
assert_eq!(maker_ata_b_after, maker_ata_b_before + wanted_amount,
"take_offer: maker did not receive correct token B amount");

// The vault token account must be fully closed. If it is
// still open, tokens could be stranded or the account reused unexpectedly.
assert!(self.trident.get_token_account(vault).is_err(),
"take_offer: vault was not closed after successful swap");

// The offer PDA itself must be closed and its data wiped.
// An open PDA could be replayed or its storage reinterpreted by another
// instruction.
assert!(self.trident
.get_account_with_type::<crate::types::Offer>(&offer_pda, 8)
.is_none(),
"take_offer: offer PDA still exists after successful swap");

// Closing the offer PDA returns rent to the maker, and
// closing the vault returns rent to the taker. Neither party should end
// up with less SOL than before (rent recovered > tx fees in a local env).
let maker_sol_after = self.trident.get_account(&maker).lamports();
let taker_sol_after = self.trident.get_account(&taker).lamports();
assert!(maker_sol_after > maker_sol_before,
"take_offer: maker did not receive offer PDA rent");
assert!(taker_sol_after > taker_sol_before,
"take_offer: taker did not receive vault rent");

self.fuzz_accounts.offer = AddressStorage::default();
}

self.trident.record_histogram(
"take_offer_result",
if result.is_success() { 1.0 } else { 0.0 },
);
}

Add the End Method and Main

The #[end] method runs after each iteration completes. In this fuzz test, invariant checking happens inline inside each flow rather than in teardown. The #[end] block is available for iteration-level cleanup or global invariants:

trident-tests/fuzz_0/test_fuzz.rs
    #[end]
fn end(&mut self) {
// Perform any cleanup here, this method will be executed
// at the end of each iteration
}
}

fn main() {
FuzzTest::fuzz(1000, 100);
}

FuzzTest::fuzz(1000, 100) runs 1,000 iterations with up to 100 flows per iteration — approximately 100,000 total transactions.

The fake mint check is handled inline in take_offer using eprintln! rather than assert!, so a successful fake-mint swap prints a VULNERABILITY line to the console and records a metric without stopping the fuzzer. This lets the run complete and show the full picture in the results table.

Common patterns for using end:


  • Verify global invariants that require reading multiple accounts (e.g., total supply equals sum of all balances)
  • Assert conditions that apply regardless of which flows ran (e.g., the maker's token A balance never increases without a corresponding offer)
  • Add accounts to regression snapshots with self.trident.add_to_regression(&pubkey, "label"). Trident records the on-chain contents of those accounts at the end of the iteration. This is useful for comparing behavior across program versions: run the fuzzer against version A and version B using the same seed, then use trident compare to diff the snapshots and verify that a refactor didn't change observable account state.

Run Fuzz Tests

Navigate into the trident-tests/ directory and run the fuzz test you just created:

cd trident-tests
trident fuzz run fuzz_0

Trident compiles the fuzz test binary in this separate workspace, then iterates through the test flows. On a typical machine, this completes in seconds.

Expected output:

...
VULNERABILITY: take_offer accepted a fake token mint (token substitution attack succeeded)
Overall: [00:00:02] [####] 100000/100000 (100%) [00:00:00] Parallel fuzzing completed!
+-------------+---------------+------------+-----------+----------------------+
| Instruction | Invoked Total | Ix Success | Ix Failed | Instruction Panicked |
+-------------+---------------+------------+-----------+----------------------+
| make_offer | 49460 | 49460 | 0 | 0 |
+-------------+---------------+------------+-----------+----------------------+
| take_offer | 24404 | 24404 | 0 | 0 |
+-------------+---------------+------------+-----------+----------------------+
MASTER SEED used: "96c563af7b3ddf3dac6cfd30f6ec8273ebee7f849c3b2129248e3a576150a873"

The results table shows make_offer invoked ~50,000 times and take_offer ~25,000 times out of 100,000 total flow executions. This 2:1 ratio is expected.

The fuzzer randomly selects between the two flows on every call, so each is chosen roughly 50,000 times, but take_offer has an early-return guard that skips execution when no offer exists in the pool. Since start() resets the pool at the start of each iteration, and the pool is also cleared after every successful swap, there are many windows where take_offer is selected but has nothing to act on.

Those early returns are not counted as invocations, which is why the number is roughly half of make_offer.

The VULNERABILITY line printed to the console is the key finding: with has_one = token_mint_b commented out in the program, take_offer accepted a fake token mint on every attempt.

Monitor Results with the Metrics Dashboard

You already enabled the metrics dashboard in trident-tests/Trident.toml earlier in this guide ([fuzz.metrics] with dashboard = true).

In a separate terminal window, launch the Trident dashboard server to visualize fuzzing results:

trident server

Open http://localhost:8000 in a browser. The dashboard displays:

&quot;Trident fuzzing dashboard showing total transactions, success rate, and per-instruction transaction statistics&quot;

At the top, the dashboard shows summary counters for the entire session. The Transaction Statistics section breaks those numbers down per instruction.

Every transaction succeeded at the Solana level — no program errors, no panics. This is exactly what a token substitution vulnerability looks like in the results: the exploit doesn't crash the program, it silently accepts invalid inputs. The finding surfaces in the console output (the VULNERABILITY line) and in the Custom Metrics histograms below.

The Custom Metrics section shows three histograms from the fuzz run:

&quot;custom metrics dashboard showing Fake Mint accepted, offered amount, and take offer result histograms&quot;

MetricCountRangeAvgMedianShannon Entropy
fake_mint_accepted12,4131.00 – 1.001.001.000.0000
offered_amount49,8212.00 – 99,999.0049,763.2149,696.0015.1491
take_offer_result24,5891.00 – 1.001.001.000.0000

fake_mint_accepted and take_offer_result both have a range of 1.00 – 1.00 and Shannon Entropy of 0.0000. Every value is 1 (success) — the fake mint was accepted and take_offer succeeded on every single attempt. Zero entropy means zero variation: the exploit is completely reliable, not a flaky edge case.

offered_amount shows the opposite pattern. The range spans 2 to 99,999 with high entropy (15.15), confirming the fuzzer explored a wide, well-distributed spread of token amounts across all make_offer calls.

Reproducing and Debugging a Failure

When an invariant failure or panic occurs, Trident prints the crash seed that triggered it. You can replay that exact sequence with full program logs to investigate:

trident fuzz debug fuzz_0 <crash_seed>

This re-runs the failing iteration with logging enabled so you can see exactly which accounts were passed, what state was on-chain, and where the invariant fired.

When the fuzzing session ends, Trident also prints a Master Seed for the entire session. It lets you re-run the full session to reproduce the same sequence of random iterations, whereas a crash seed reproduces a single failing input. If you want to share or archive a run, save the Master Seed.

Compare Regression Snapshots

After fixing a bug and re-running the fuzzer, compare account state across runs:

trident compare snapshot_before.json snapshot_after.json

This shows differences in account balances, data fields, and any new errors introduced by the fix.

Fix Vulnerabilities and Verify

The fuzzer surfaced the missing constraint. Open programs/escrow/src/instructions/take_offer.rs and add the missing has_one = token_mint_b, constraint back:

programs/escrow/src/instructions/take_offer.rs
    #[account(
mut,
close = maker,
has_one = maker,
has_one = token_mint_a,
has_one = token_mint_b,
seeds = [b"offer", maker.key().as_ref(), offer.id.to_le_bytes().as_ref()],
bump = offer.bump,
)]
pub offer: Account<'info, Offer>,

Rebuild the Anchor program from the root folder:

cd ..
anchor build

From the trident-tests folder, re-run the fuzz test:

cd trident-tests
trident fuzz run fuzz_0

You should see output similar to:

+-------------+---------------+------------+-----------+----------------------+
| Instruction | Invoked Total | Ix Success | Ix Failed | Instruction Panicked |
+-------------+---------------+------------+-----------+----------------------+
| make_offer | 49648 | 49648 | 0 | 0 |
+-------------+---------------+------------+-----------+----------------------+
| take_offer | 32867 | 16341 | 16526 | 0 |
+-------------+---------------+------------+-----------+----------------------+

The ~50% failure rate on take_offer is expected, and is actually the sign the fix is working. The fuzzer picks randomly from 3 mints (token A, token B, and the fake mint), so a large portion of take_offer calls will supply the wrong mint. With has_one = token_mint_b now in place, those calls are correctly rejected by Anchor's constraint validation instead of silently succeeding. The invariant failures dropped to zero because the exploit path no longer exists.

You've now seen the full Trident workflow end-to-end: scaffold a fuzz test, define flows, introduce an invariant, catch a real vulnerability, and verify the fix. Fuzz testing surfaces bugs that are easy to miss in hand-written unit tests, especially authorization and constraint issues that only appear with unexpected input combinations.

Advanced Techniques

The fuzz tests in this guide cover the core Trident workflow, but Trident offers additional capabilities that weren't needed for the escrow example. The sections below highlight a few of the more powerful features you can reach for as your programs grow in complexity.

Time Manipulation

Test time-dependent logic by warping the clock:

// Forward time by one hour
self.trident.forward_in_time(3600);

// Jump to a specific Unix timestamp
self.trident.warp_to_timestamp(1_700_000_000);

// Advance to a specific slot or epoch
self.trident.warp_to_slot(500);
self.trident.warp_to_epoch(10);

Token Extensions Support

The escrow example uses TokenInterface which works with both SPL Token and Token Extensions (Token-2022). Trident also supports Token Extensions directly:

// Token-2022 helpers
self.trident.initialize_mint_2022(mint_pubkey, authority, decimals, &[]);
self.trident.mint_to_2022(mint_pubkey, token_account, authority, amount);
self.trident.initialize_associated_token_account_2022(wallet, mint);

This lets you test that your program handles both token standards correctly, which can be a common source of bugs when programs assume all tokens use the original SPL Token program.

Custom Account State

Inject custom account data to simulate specific scenarios:

self.trident.set_account_custom(pubkey, account_data);

This is useful for dumping accounts from devnet or mainnet into the local fuzzing environment to test against production-like state.

Multiple Actors

Expand the fuzz test by storing multiple makers and takers to test permission boundaries more thoroughly:

#[init]
fn setup(&mut self) {
// Store multiple makers and takers
for _ in 0..5 {
self.fuzz_accounts.maker.insert(&mut self.trident, None);
self.fuzz_accounts.taker.insert(&mut self.trident, None);
}
// Fund all of them, create mints, etc.
// ...
}

#[flow]
fn take_offer(&mut self) {
// Fuzzer picks a random taker — tests that any taker can accept any open offer
let Some(taker) = self.fuzz_accounts.taker.get(&mut self.trident) else { return; };
let Some(offer_pda) = self.fuzz_accounts.offer.get(&mut self.trident) else { return; };
// ...
}

With multiple actors, the fuzzer explores scenarios like one maker's offer being taken by different takers, or the same taker attempting to take multiple offers. This increases the chance of finding permission-related bugs.

Enable Code Coverage (Optional)

Ackee Blockchain publishes a Solana VS Code extension that visualizes Trident code coverage inline.

info

The VS Code extension requires VS Code 1.96+ and a Rust nightly toolchain. It also provides static security analysis with real-time detectors for common Solana vulnerabilities like unsafe math, missing signer checks, and improper sysvar access.

Set up live coverage:


  1. Install the extension from the VS Code marketplace
  2. Update Trident.toml to enable JSON coverage output:
trident-tests/Trident.toml
[fuzz.coverage]
format = "json"
loopcount = 100
  1. Run the fuzzer — the extension auto-activates when trident-tests/ exists and updates coverage in real time
  2. Or run Solana: Show Code Coverage from the VS Code command palette after fuzzing to load a saved report

Coverage data shows which lines of your program were executed and how many times, highlighting untested code paths that need additional flows or input ranges.

Frequently Asked Questions

Does Trident work with non-Anchor programs?

No. Trident requires Anchor and derives instruction types from the Anchor IDL. Programs must be built with Anchor 0.29.0 or later. If your program uses a different framework (like Pinocchio or vanilla Solana SDK), it is not compatible with Trident at this time.

How does manually guided fuzzing differ from coverage-guided fuzzing?

In manually guided fuzzing, you define the instruction flows, account storage, and input ranges. Trident randomly selects flows and draws values from the ranges you configured on every iteration. You are fully in control — if you want a u64 argument to vary, you tell Trident to use random_from_range(0..u64::MAX); if you want it fixed, you hard-code the value. Coverage-guided fuzzers (like AFL or HonggFuzz) work differently: they automatically mutate inputs to maximize code path coverage, but they have no awareness of instruction ordering constraints and only sometimes produce different inputs across runs. Trident dropped AFL++ in favor of pure manual guidance because Solana programs have strict ordering requirements that coverage-guided fuzzers handle poorly.

Can I fuzz test with real on-chain state?

Yes. Trident supports loading accounts from devnet, testnet, or mainnet into the local TridentSVM environment using set_account_custom(). This lets you test against production-like conditions without connecting to a live cluster. Fork testing (live on-chain state during fuzzing) is currently in development.

Can I integrate Trident into CI/CD?

Yes. The Trident repository includes GitHub Actions examples. Add trident fuzz run <target> as a CI step with a fixed iteration count. The fuzzer exits with a non-zero code on panics or invariant failures, so it integrates naturally with CI pipelines.

What types of bugs does Trident typically catch?

Common findings include missing account constraints (like the token substitution bug in this guide), arithmetic overflows and underflows, missing signer or authority checks, invalid account state transitions, incorrect PDA derivations, and DoS vectors caused by panicking code paths. The invariant check system also catches logical errors like incorrect token handling or unauthorized state changes.

Wrapping Up

You've installed Trident, scaffolded fuzz tests for a token escrow program, defined multi-actor instruction flows with randomized inputs and token mints, caught a real token substitution vulnerability caused by a missing has_one constraint, monitored the results through the metrics dashboard, and verified the fix by re-running the fuzzer.

These are the same techniques professional auditors use, and Trident was built by the security team at Ackee Blockchain.

Fuzzing is iterative. Each run surfaces new edge cases, and invariant checks provide ongoing confidence as your program evolves. Make it a standard part of your development workflow and your programs will be significantly more resilient for it.

Resources


We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide