From 18265815e49644aa681b410547953baaab6c483a Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 12 May 2026 13:18:18 +0800 Subject: [PATCH] fix: move to fuzz-driven state generation --- fuzz/fuzz_targets/fuzz_replay_prevention.rs | 29 ++++++-- .../fuzz_state_diff_computation.rs | 25 +++++-- fuzz/fuzz_targets/fuzz_state_transition.rs | 26 +++++-- .../fuzz_validate_execute_consistency.rs | 36 +++++++--- fuzz_props/src/generators.rs | 71 ++++++++++++++++++- 5 files changed, 161 insertions(+), 26 deletions(-) diff --git a/fuzz/fuzz_targets/fuzz_replay_prevention.rs b/fuzz/fuzz_targets/fuzz_replay_prevention.rs index 547e21d..ed8c4b5 100644 --- a/fuzz/fuzz_targets/fuzz_replay_prevention.rs +++ b/fuzz/fuzz_targets/fuzz_replay_prevention.rs @@ -6,24 +6,41 @@ //! //! `execute_check_on_state` returns the transaction back on success (`Ok(tx)`), //! so we can feed the same struct to the second application without cloning. +//! +//! The initial state is generated from the fuzz input (rather than a fixed +//! testnet genesis) so that nonce-dependent edge cases — e.g. replay prevention +//! at nonce 0, nonce `u128::MAX`, or when the sender has zero balance — are +//! reachable by the fuzzer. -use arbitrary::Unstructured; -use fuzz_props::generators::arbitrary_transaction; +use arbitrary::{Arbitrary, Unstructured}; +use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; use libfuzzer_sys::fuzz_target; use nssa::V03State; -use testnet_initial_state::initial_accounts; fuzz_target!(|data: &[u8]| { let mut u = Unstructured::new(data); - let accs_data = initial_accounts(); - let init_accs: Vec<(nssa::AccountId, u128)> = accs_data + // Generate a fuzz-driven initial state. + let fuzz_accs = match arbitrary_fuzz_state(&mut u) { + Ok(accs) => accs, + Err(_) => return, + }; + let init_accs: Vec<(nssa::AccountId, u128)> = fuzz_accs .iter() .map(|a| (a.account_id, a.balance)) .collect(); let mut state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0); - let Ok(tx) = arbitrary_transaction(&mut u) else { return; }; + // Mix correlated transactions (correctly signed, referencing a fuzz account) + // with random ones. Correlated transactions have a higher chance of being + // accepted on the first application, which is necessary for the replay check + // to fire. + let tx_result = if bool::arbitrary(&mut u).unwrap_or(false) { + arb_fuzz_native_transfer(&mut u, &fuzz_accs) + } else { + arbitrary_transaction(&mut u) + }; + let Ok(tx) = tx_result else { return; }; // Stateless gate: skip structurally malformed transactions. let Ok(tx) = tx.transaction_stateless_check() else { return; }; diff --git a/fuzz/fuzz_targets/fuzz_state_diff_computation.rs b/fuzz/fuzz_targets/fuzz_state_diff_computation.rs index e6b4c5b..dbe06a0 100644 --- a/fuzz/fuzz_targets/fuzz_state_diff_computation.rs +++ b/fuzz/fuzz_targets/fuzz_state_diff_computation.rs @@ -6,22 +6,37 @@ //! //! A diff that modifies an account outside that set would allow a transaction //! to silently corrupt unrelated accounts' balances. +//! +//! The initial state is generated from the fuzz input (rather than a fixed +//! testnet genesis) so that state-dependent diff bugs — those triggered only by +//! specific account shapes such as zero balance or `u128::MAX` — are reachable. +use arbitrary::{Arbitrary, Unstructured}; use fuzz_props::arbitrary_types::ArbPublicTransaction; +use fuzz_props::generators::arbitrary_fuzz_state; use libfuzzer_sys::fuzz_target; use nssa::{V03State, ValidatedStateDiff}; -use testnet_initial_state::initial_accounts; -fuzz_target!(|wrapped: ArbPublicTransaction| { - let pub_tx = wrapped.0; +fuzz_target!(|data: &[u8]| { + let mut u = Unstructured::new(data); - let accs_data = initial_accounts(); - let init_accs: Vec<(nssa::AccountId, u128)> = accs_data + // Generate a fuzz-driven initial state. + let fuzz_accs = match arbitrary_fuzz_state(&mut u) { + Ok(accs) => accs, + Err(_) => return, + }; + let init_accs: Vec<(nssa::AccountId, u128)> = fuzz_accs .iter() .map(|a| (a.account_id, a.balance)) .collect(); let state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0); + // Generate the public transaction from remaining fuzz bytes. + let pub_tx = match ArbPublicTransaction::arbitrary(&mut u) { + Ok(w) => w.0, + Err(_) => return, + }; + // Collect the set of accounts the transaction claims to touch. let affected = pub_tx.affected_public_account_ids(); diff --git a/fuzz/fuzz_targets/fuzz_state_transition.rs b/fuzz/fuzz_targets/fuzz_state_transition.rs index 7cc184f..8ae0825 100644 --- a/fuzz/fuzz_targets/fuzz_state_transition.rs +++ b/fuzz/fuzz_targets/fuzz_state_transition.rs @@ -1,17 +1,22 @@ #![no_main] use arbitrary::{Arbitrary, Unstructured}; -use fuzz_props::generators::arbitrary_transaction; +use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; use libfuzzer_sys::fuzz_target; use nssa::V03State; -use testnet_initial_state::initial_accounts; fuzz_target!(|data: &[u8]| { let mut u = Unstructured::new(data); - // Build genesis account list from testnet initial state - let accs_data = initial_accounts(); - let init_accs: Vec<(nssa::AccountId, u128)> = accs_data + // Generate a fuzz-driven initial state instead of always using the fixed + // testnet genesis. This exposes state-dependent bugs that only manifest + // with specific account shapes (e.g. zero balance, u128::MAX balance, or a + // nonce at the wrap-around boundary). + let fuzz_accs = match arbitrary_fuzz_state(&mut u) { + Ok(accs) => accs, + Err(_) => return, + }; + let init_accs: Vec<(nssa::AccountId, u128)> = fuzz_accs .iter() .map(|a| (a.account_id, a.balance)) .collect(); @@ -22,7 +27,16 @@ fuzz_target!(|data: &[u8]| { // Generate up to 8 transactions and apply them let n_txs: u8 = u8::arbitrary(&mut u).unwrap_or(0) % 8; for i in 0..n_txs { - let Ok(tx) = arbitrary_transaction(&mut u) else { + // Mix correlated transactions (referencing known fuzz accounts and + // correctly signed) with random ones. Correlated transactions give + // the fuzzer a direct path to successful state transitions; random ones + // exercise the rejection and isolation paths. + let tx_result = if bool::arbitrary(&mut u).unwrap_or(false) { + arb_fuzz_native_transfer(&mut u, &fuzz_accs) + } else { + arbitrary_transaction(&mut u) + }; + let Ok(tx) = tx_result else { break; }; diff --git a/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs b/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs index 8710bf0..344caf1 100644 --- a/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs +++ b/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs @@ -18,23 +18,43 @@ //! transaction. This catches double-credit and token-inflation bugs that both //! methods could agree on silently (INVARIANT 2a/2b only check consistency //! between the two methods, not correctness of the arithmetic itself). +//! +//! The initial state is generated from the fuzz input (rather than a fixed +//! testnet genesis) so that state-dependent bugs — those that only manifest +//! with specific account shapes such as zero balance or `u128::MAX` — are +//! reachable by the fuzzer. +use arbitrary::{Arbitrary, Unstructured}; use fuzz_props::arbitrary_types::ArbNSSATransaction; +use fuzz_props::generators::arbitrary_fuzz_state; use libfuzzer_sys::fuzz_target; use nssa::V03State; -use testnet_initial_state::initial_accounts; -fuzz_target!(|wrapped: ArbNSSATransaction| { - let tx = wrapped.0; +fuzz_target!(|data: &[u8]| { + let mut u = Unstructured::new(data); + + // Generate a fuzz-driven initial state. The state shape — account IDs, + // balances, and the private keys needed to sign transactions against it — + // is fully controlled by the fuzzer, exposing state-dependent bugs that + // the fixed testnet genesis would never reach. + let fuzz_accs = match arbitrary_fuzz_state(&mut u) { + Ok(accs) => accs, + Err(_) => return, + }; + let init_accs: Vec<(nssa::AccountId, u128)> = fuzz_accs + .iter() + .map(|a| (a.account_id, a.balance)) + .collect(); + + // Generate the transaction from the remaining fuzz bytes. + let tx = match ArbNSSATransaction::arbitrary(&mut u) { + Ok(w) => w.0, + Err(_) => return, + }; // Stateless gate — skip structurally malformed transactions. let Ok(tx) = tx.transaction_stateless_check() else { return; }; - let accs_data = initial_accounts(); - let init_accs: Vec<(nssa::AccountId, u128)> = accs_data - .iter() - .map(|a| (a.account_id, a.balance)) - .collect(); let state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0); // validate_on_state borrows `tx` and `state` — does NOT mutate state. diff --git a/fuzz_props/src/generators.rs b/fuzz_props/src/generators.rs index 11eb3e9..b7df257 100644 --- a/fuzz_props/src/generators.rs +++ b/fuzz_props/src/generators.rs @@ -2,10 +2,79 @@ use arbitrary::{Arbitrary, Unstructured}; use common::{block::HashableBlockData, transaction::NSSATransaction}; use nssa::{AccountId, PrivateKey}; -use crate::arbitrary_types::ArbNSSATransaction; +use crate::arbitrary_types::{ArbAccountId, ArbNSSATransaction, ArbPrivateKey}; use proptest::prelude::*; use testnet_initial_state::initial_pub_accounts_private_keys; +// ── Fuzz-driven state generation ───────────────────────────────────────────── + +/// An account with an arbitrary identifier, balance, and private key, +/// generated entirely from unstructured fuzzer bytes. +/// +/// Using random account IDs (rather than the fixed `testnet_initial_state` set) +/// exposes state-dependent bugs that only manifest with specific account shapes — +/// for example: zero balance, [`u128::MAX`] balance, or a nonce at the +/// wrap-around boundary. The [`PrivateKey`] field lets downstream generators +/// produce correctly-signed transfers referencing accounts present in this state. +pub struct FuzzAccount { + pub account_id: AccountId, + pub balance: u128, + pub private_key: PrivateKey, +} + +/// Generate 1–8 fuzz-driven accounts with arbitrary IDs, balances, and keys. +/// +/// Call this before generating transactions so the constructed [`nssa::V03State`] +/// has a shape controlled by the fuzzer rather than fixed at compile time. +pub fn arbitrary_fuzz_state(u: &mut Unstructured<'_>) -> arbitrary::Result> { + let n = ((u8::arbitrary(u)? as usize) % 8) + 1; // 1..=8 + (0..n) + .map(|_| { + Ok(FuzzAccount { + account_id: ArbAccountId::arbitrary(u)?.0, + balance: u128::arbitrary(u)?, + private_key: ArbPrivateKey::arbitrary(u)?.0, + }) + }) + .collect() +} + +/// Generate a native-transfer [`NSSATransaction`] between two accounts chosen +/// from `accounts`. +/// +/// Because every account in the slice has a known private key, the resulting +/// transaction is correctly signed and references account IDs that actually +/// exist in the fuzz-generated state — giving the fuzzer a direct path to +/// exercise **successful** state transitions rather than only rejection paths. +/// +/// Self-transfers (`from_idx == to_idx`) are allowed since they are a useful +/// edge case (balance should remain unchanged). +pub fn arb_fuzz_native_transfer( + u: &mut Unstructured<'_>, + accounts: &[FuzzAccount], +) -> arbitrary::Result { + if accounts.is_empty() { + return Err(arbitrary::Error::IncorrectFormat); + } + let from_idx = (u8::arbitrary(u)? as usize) % accounts.len(); + let to_idx = (u8::arbitrary(u)? as usize) % accounts.len(); + let nonce = u128::arbitrary(u)?; + let amount = u128::arbitrary(u)?; + + let from = &accounts[from_idx]; + let to = &accounts[to_idx]; + + Ok( + common::test_utils::create_transaction_native_token_transfer( + from.account_id, + nonce, + to.account_id, + amount, + &from.private_key, + ), + ) +} + // ── Arbitrary (for libFuzzer targets) ──────────────────────────────────────── /// A best-effort attempt to create a structurally plausible `NSSATransaction`