fix: move to fuzz-driven state generation

This commit is contained in:
Roman 2026-05-12 13:18:18 +08:00
parent 50dab4cfb8
commit 18265815e4
No known key found for this signature in database
GPG Key ID: 583BDF43C238B83E
5 changed files with 161 additions and 26 deletions

View File

@ -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; };

View File

@ -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();

View File

@ -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;
};

View File

@ -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.

View File

@ -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 18 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<Vec<FuzzAccount>> {
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<NSSATransaction> {
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`