test: model-based stateful lockstep

This commit is contained in:
Roman 2026-07-03 09:17:33 +02:00
parent eb71679d57
commit 2c943bf503
No known key found for this signature in database
GPG Key ID: 583BDF43C238B83E
2 changed files with 329 additions and 0 deletions

View File

@ -163,3 +163,9 @@ name = "fuzz_transaction_ordering_independence"
path = "fuzz_targets/fuzz_transaction_ordering_independence.rs"
test = false
bench = false
[[bin]]
name = "fuzz_stateful_model_lockstep"
path = "fuzz_targets/fuzz_stateful_model_lockstep.rs"
test = false
bench = false

View File

@ -0,0 +1,323 @@
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
//! Fuzz target: **model-based stateful lockstep** — the Rung-3 lever.
//!
//! Every other target in this repo fuzzes a *single* call, or applies a linear list of
//! transactions and checks aggregate invariants. None of them steps an **independent
//! reference model** in lockstep with the real state machine, asserting agreement after
//! *every* operation. That is the technique this target adds:
//!
//! 1. Decode the fuzz input into a `Vec<Command>` — an *operation grammar* (transfer /
//! advance-block / replay), not raw bytes. Mutating this grammar is "higher-level
//! mutation": the fuzzer explores *schedules* of domain operations.
//! 2. Maintain a tiny, self-contained [`Model`] of `(balance, nonce)` per account that
//! reimplements native-transfer semantics **without** calling into `nssa`.
//! 3. After each command, apply it to both the real [`V03State`] and the model, then assert
//! they agree on (a) whether the transaction was *accepted* and (b) the resulting
//! per-account balances and nonces.
//!
//! A divergence is a real bug in exactly the class single-shot fuzzing cannot reach:
//! acceptance or state that depends on the *history* of operations (a replay that should
//! have been rejected, a nonce that drifts across a sequence, balance that leaks between
//! transactions).
//!
//! # Why the model is a *sound* oracle (no false positives)
//!
//! The model only claims to predict the outcome of native transfers **between two distinct,
//! fuzz-generated public accounts** — the exact shape [`arbitrary_fuzz_state`] produces.
//! For that shape the complete set of dynamic acceptance rules is small and fully modelled:
//!
//! * **Nonce** — the declared nonce must equal the signer's current nonce
//! (`validated_state_diff.rs`: `current_nonce == *nonce`).
//! * **Sufficient balance** — the guest program does `balance.checked_sub(amount)`
//! (`authenticated_transfer/src/main.rs`), so `amount > sender.balance` is rejected.
//! * **No recipient overflow** — `balance.checked_add(amount)`. Under the generator's
//! `u128::MAX / 8` per-account cap this can never fire (total supply ≤ `u128::MAX`, so
//! `recipient + amount ≤ total ≤ u128::MAX`), but the model encodes it for completeness.
//!
//! Everything else that could reject a transaction is held *constant* by construction and so
//! cannot cause the model to diverge from reality:
//!
//! * The signature is always valid — the account id is derived from the signing key
//! (`FuzzAccount`), so the signer is authorized.
//! * Neither party is a reserved system account (faucet / bridge / clock) —
//! [`arbitrary_fuzz_state`] excludes those ids, so the system-account guard never trips.
//! * The recipient is genesis-owned by the authenticated-transfer program, so it is never
//! re-claimed; `program_owner` is invariant.
//! * **Self-transfers are excluded** — the recipient index is remapped to always differ from
//! the sender (see `resolve_recipient`). A `from == to` transfer aliases one account in the
//! diff and its post-balance depends on diff-merge internals we deliberately do not model;
//! excluding it keeps the oracle sound. Self-transfers remain covered by
//! `fuzz_multi_block_state_sequence` / `arb_fuzz_native_transfer`.
//!
//! Because those rules are fully modelled and everything else is constant, the model's
//! accept/reject prediction is *exact* for the transactions this target generates: a
//! disagreement is always a protocol bug, never a modelling gap.
//!
//! # Invariants asserted (after every command)
//!
//! * **ModelAcceptanceAgreement** — the real state machine accepts a transaction iff the
//! reference model does. A real-accept / model-reject split is a double-spend, replay, or
//! token-inflation bug; a real-reject / model-accept split is a spurious rejection
//! (liveness bug).
//! * **ModelStateAgreement** — after applying the command, every fuzz account's balance and
//! nonce in the real state equals the model. Catches drift that acceptance parity alone
//! would miss (e.g. a transfer accepted by both but crediting the wrong amount).
use arbitrary::{Arbitrary, Unstructured};
use common::transaction::LeeTransaction;
use fuzz_props::generators::arbitrary_fuzz_state;
use nssa::AccountId;
use std::collections::HashMap;
/// How the declared nonce for a transfer is chosen. Biased toward `Correct` so the accept
/// path is reached often, with adversarial variants to drive the reject path.
#[derive(Arbitrary, Debug)]
enum NonceKind {
/// The signer's current nonce in the model — should be accepted.
Correct,
/// `current + delta` (delta ≥ 1) — a stale/future nonce, should be rejected.
Off(u8),
/// A fully arbitrary nonce — usually rejected.
Raw(u128),
}
/// How the transfer amount is chosen, relative to the sender's modelled balance.
#[derive(Arbitrary, Debug)]
enum AmountKind {
/// Zero — accepted, a no-op on balances.
Zero,
/// `raw % (balance + 1)` — always within balance, should be accepted.
WithinBalance(u128),
/// Exactly the whole balance — accepted, drains the sender.
All,
/// Strictly greater than the balance — should be rejected (insufficient funds).
Excessive(u128),
/// A fully arbitrary amount — accepted only if it happens to fit.
Raw(u128),
}
/// One step in the generated schedule.
#[derive(Arbitrary, Debug)]
enum Command {
/// Transfer native balance between two distinct fuzz accounts.
Transfer {
from: u8,
to: u8,
nonce: NonceKind,
amount: AmountKind,
},
/// Advance the block id / timestamp without applying a transaction. Exercises schedules
/// where block progression is decoupled from transaction count; must not perturb state.
AdvanceBlock(u8),
/// Re-submit the most recently accepted transaction. The model predicts rejection (its
/// nonce is already consumed); a real acceptance is a replay/double-spend.
ReplayLast,
}
/// Independent reference model: `account_id -> (balance, nonce)` for the fuzz accounts only.
/// Reimplements native-transfer semantics with zero dependence on `nssa`.
struct Model {
accounts: HashMap<AccountId, (u128, u128)>,
}
/// The model's prediction for a transfer.
enum Predict {
/// Rejected — no state change.
Reject,
/// Accepted — `from` debited by `amount`, `to` credited, `from` nonce incremented.
Accept { amount: u128 },
}
impl Model {
fn new(accounts: &[(AccountId, u128)]) -> Self {
Self {
accounts: accounts.iter().map(|&(id, bal)| (id, (bal, 0))).collect(),
}
}
fn balance(&self, id: AccountId) -> u128 {
self.accounts.get(&id).map_or(0, |&(b, _)| b)
}
fn nonce(&self, id: AccountId) -> u128 {
self.accounts.get(&id).map_or(0, |&(_, n)| n)
}
/// Predict whether a transfer `from -> to` of `amount` declaring `declared_nonce` is
/// accepted. `from != to` is a precondition (self-transfers are excluded upstream).
fn predict(&self, from: AccountId, to: AccountId, declared_nonce: u128, amount: u128) -> Predict {
if declared_nonce != self.nonce(from) {
return Predict::Reject; // nonce mismatch
}
if amount > self.balance(from) {
return Predict::Reject; // insufficient balance (checked_sub fails)
}
if self.balance(to).checked_add(amount).is_none() {
return Predict::Reject; // recipient overflow (checked_add fails)
}
Predict::Accept { amount }
}
/// Commit an accepted transfer to the model.
fn apply_transfer(&mut self, from: AccountId, to: AccountId, amount: u128) {
let (from_bal, from_nonce) = self.accounts[&from];
let (to_bal, to_nonce) = self.accounts[&to];
self.accounts.insert(from, (from_bal - amount, from_nonce + 1));
self.accounts.insert(to, (to_bal + amount, to_nonce));
}
}
/// Map a raw `to` index to an account distinct from `from`. With `len >= 2` accounts, picks
/// among the `len - 1` accounts other than `from`, so a self-transfer is impossible.
fn resolve_recipient(raw: u8, from_idx: usize, len: usize) -> usize {
let offset = 1 + (raw as usize) % (len - 1); // 1..=len-1
(from_idx + offset) % len
}
/// Resolve `NonceKind` against the model's current view of the signer.
fn resolve_nonce(kind: &NonceKind, current: u128) -> u128 {
match *kind {
NonceKind::Correct => current,
NonceKind::Off(d) => current.wrapping_add(u128::from(d).max(1)),
NonceKind::Raw(n) => n,
}
}
/// Resolve `AmountKind` against the sender's modelled balance.
fn resolve_amount(kind: &AmountKind, balance: u128) -> u128 {
match *kind {
AmountKind::Zero => 0,
AmountKind::WithinBalance(raw) => raw % balance.saturating_add(1),
AmountKind::All => balance,
AmountKind::Excessive(raw) => balance.saturating_add(1).saturating_add(raw % 1024),
AmountKind::Raw(n) => n,
}
}
/// Assert the real state agrees with the model on every fuzz account.
fn assert_state_agreement(state: &nssa::V03State, model: &Model, step: usize, what: &str) {
for (&id, &(model_bal, model_nonce)) in &model.accounts {
let acc = state.get_account_by_id(id);
assert_eq!(
acc.balance, model_bal,
"INVARIANT VIOLATION [ModelStateAgreement]: balance diverged from reference model \
at step {step} after {what} for account {id:?} real={}, model={model_bal}",
acc.balance,
);
assert_eq!(
acc.nonce.0, model_nonce,
"INVARIANT VIOLATION [ModelStateAgreement]: nonce diverged from reference model \
at step {step} after {what} for account {id:?} real={}, model={model_nonce}",
acc.nonce.0,
);
}
}
fuzz_props::fuzz_entry!(|data: &[u8]| {
let mut u = Unstructured::new(data);
// Need at least two distinct accounts so every transfer is cross-account.
let fuzz_accs = match arbitrary_fuzz_state(&mut u) {
Ok(accs) if accs.len() >= 2 => accs,
_ => return,
};
let init_accs: Vec<(AccountId, u128)> = fuzz_accs.iter().map(|a| (a.account_id, a.balance)).collect();
let mut state = fuzz_props::genesis::genesis_state(&init_accs, vec![]);
let mut model = Model::new(&init_accs);
// Monotonic clock — both the protocol and replay-rejection want strictly increasing ids.
let mut block_id: u64 = 1;
let mut timestamp: u64 = 0;
// The most recently accepted transaction, kept for ReplayLast.
let mut last_applied: Option<LeeTransaction> = None;
// Bounded schedule length keeps individual corpus entries small and fast.
let n_cmds: u8 = u8::arbitrary(&mut u).unwrap_or(0) % 32;
for step in 0..usize::from(n_cmds) {
let Ok(cmd) = Command::arbitrary(&mut u) else { break };
match cmd {
Command::AdvanceBlock(jump) => {
block_id += 1 + u64::from(jump);
timestamp += 1000 * (1 + u64::from(jump));
// No transaction applied — state must be untouched, model unchanged.
assert_state_agreement(&state, &model, step, "AdvanceBlock");
}
Command::ReplayLast => {
let Some(tx) = last_applied.clone() else {
// Nothing to replay yet.
continue;
};
let accepted = tx.execute_check_on_state(&mut state, block_id, timestamp).is_ok();
assert!(
!accepted,
"INVARIANT VIOLATION [ModelAcceptanceAgreement]: the state machine accepted a \
replay of an already-applied transaction at step {step} (block_id={block_id}) \
its nonce was consumed, so the reference model rejects it. This is a \
replay / double-spend.",
);
block_id += 1;
timestamp += 1000;
assert_state_agreement(&state, &model, step, "ReplayLast");
}
Command::Transfer { from, to, nonce, amount } => {
let len = fuzz_accs.len();
let from_idx = (from as usize) % len;
let to_idx = resolve_recipient(to, from_idx, len);
let from_acc = &fuzz_accs[from_idx];
let to_id = fuzz_accs[to_idx].account_id;
let from_id = from_acc.account_id;
let declared_nonce = resolve_nonce(&nonce, model.nonce(from_id));
let amount = resolve_amount(&amount, model.balance(from_id));
let tx = common::test_utils::create_transaction_native_token_transfer(
from_id,
declared_nonce,
to_id,
amount,
&from_acc.private_key,
);
let prediction = model.predict(from_id, to_id, declared_nonce, amount);
let result = tx.execute_check_on_state(&mut state, block_id, timestamp);
let real_accepted = result.is_ok();
match prediction {
Predict::Accept { amount } => {
assert!(
real_accepted,
"INVARIANT VIOLATION [ModelAcceptanceAgreement]: the reference model \
accepts this transfer but the state machine REJECTED it at step {step} \
(from={from_id:?} to={to_id:?} nonce={declared_nonce} amount={amount}) \
spurious rejection / liveness bug.",
);
model.apply_transfer(from_id, to_id, amount);
}
Predict::Reject => {
assert!(
!real_accepted,
"INVARIANT VIOLATION [ModelAcceptanceAgreement]: the state machine \
ACCEPTED a transfer the reference model rejects at step {step} \
(from={from_id:?} to={to_id:?} nonce={declared_nonce} amount={amount}) \
double-spend, bad-nonce acceptance, or token-inflation bug.",
);
}
}
if let Ok(applied) = result {
last_applied = Some(applied);
}
block_id += 1;
timestamp += 1000;
assert_state_agreement(&state, &model, step, "Transfer");
}
}
}
});