lez-fuzzing/fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs

223 lines
9.6 KiB
Rust
Raw Normal View History

2026-05-19 11:31:48 +08:00
#![no_main]
//! Fuzz target: sequencer vs replayer differential state-root equivalence.
//!
//! Feeds the same block of transactions through two independent state-transition
//! pipelines and asserts that the resulting state is bit-for-bit identical:
//!
//! - **Sequencer path** — mirrors `SequencerCore::build_block_from_mempool`:
//! for each user transaction call `validate_on_state` and, on success, call
//! `apply_state_diff`; skip rejected transactions. Append the mandatory clock
//! invocation last via `transition_from_public_transaction`.
//!
//! - **Replayer path** — mirrors `IndexerStore::put_block`:
//! for each transaction that the sequencer accepted, call
//! `transaction_stateless_check` followed by `execute_check_on_state`.
//! Apply the clock invocation last via `transition_from_public_transaction`.
//!
//! Both pipelines start from the **same** fuzz-generated initial state and
//! process the **same** set of accepted transactions with the same block context
//! (block_id, timestamp). Any difference in the resulting account states is a
//! consensus-breaking bug: a replaying node would derive a different state root
//! from the sequencer, which would invalidate all subsequent blocks.
//!
//! # Invariants
//!
//! 1. **SequencerReplayerEquivalence** — for every known account (genesis
//! accounts declared in any accepted transaction's diff), the sequencer state
//! and the replayer state must agree on balance, nonce, data, and
//! program_owner after applying the full block.
//!
//! 2. **ReplayerAcceptsAllSequencerTxs** — every transaction accepted by the
//! sequencer (`validate_on_state` returned `Ok`) must also be accepted by the
//! replayer (`execute_check_on_state` returned `Ok`). A replayer rejection of
//! a sequencer-accepted transaction is a validity-rule divergence bug.
//!
//! 3. **ClockConsistency** — the mandatory clock invocation appended at the end
//! of every block must succeed on both paths and leave both states identical.
use std::collections::HashSet;
use arbitrary::{Arbitrary, Unstructured};
use common::transaction::{NSSATransaction, clock_invocation};
use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction};
use libfuzzer_sys::fuzz_target;
use nssa::V03State;
fuzz_target!(|data: &[u8]| {
let mut u = Unstructured::new(data);
// ── Initial state ─────────────────────────────────────────────────────────
// Generate a fuzz-driven initial state so that state-dependent bugs
// (e.g. zero balance, u128::MAX nonce) are reachable by the fuzzer.
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();
// Fixed block context — both pipelines use identical block_id and timestamp
// so the only variable is the code path (sequencer vs replayer).
let block_id: u64 = 2; // block 1 is genesis; this is the first "real" block
let timestamp: u64 = 1_000;
// Shared base state — cloned once for each pipeline.
let base_state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0);
// Track all account IDs touched by accepted transactions so we can compare
// them across both pipelines after the full block is applied.
let mut touched_ids: HashSet<nssa::AccountId> =
init_accs.iter().map(|&(id, _)| id).collect();
// ── Phase 1: Sequencer path ───────────────────────────────────────────────
// Mirrors `SequencerCore::build_block_from_mempool`:
// for each mempool transaction: try validate_on_state; on success apply_state_diff.
let mut seq_state = base_state.clone();
// Accepted transaction list — populated here, consumed by the replayer phase
// so that both pipelines process exactly the same set of transactions.
let mut accepted_txs: Vec<NSSATransaction> = Vec::new();
let n_txs: u8 = u8::arbitrary(&mut u).unwrap_or(0) % 8;
for _ in 0..n_txs {
// Mix correctly-signed fuzz transfers (likely to succeed) with
// random structured transactions (likely to fail — stress the skip path).
let tx_raw = if bool::arbitrary(&mut u).unwrap_or(false) {
match arb_fuzz_native_transfer(&mut u, &fuzz_accs) {
Ok(tx) => tx,
Err(_) => break,
}
} else {
match arbitrary_transaction(&mut u) {
Ok(tx) => tx,
Err(_) => break,
}
};
// Stateless gate — both sequencer and replayer reject malformed transactions
// before they ever touch state.
let Ok(tx) = tx_raw.transaction_stateless_check() else {
continue;
};
// Sequencer: validate_on_state borrows `tx` (does not consume it).
let Ok(diff) = tx.validate_on_state(&seq_state, block_id, timestamp) else {
// Sequencer skips failed transactions; they do not appear in the block.
continue;
};
// Record the account IDs declared by this diff so they are included in
// the invariant check after both pipelines finish.
for acc_id in diff.public_diff().keys().copied() {
touched_ids.insert(acc_id);
}
// Sequencer: apply_state_diff consumes the diff and mutates seq_state.
seq_state.apply_state_diff(diff);
// Save the accepted transaction for the replayer phase.
accepted_txs.push(tx);
}
// Sequencer: append the mandatory clock invocation as the last transaction
// in the block. If the clock fails here (e.g. corrupted initial state),
// the block cannot be produced — abort without a panic.
let clock_tx = clock_invocation(timestamp);
if seq_state
.transition_from_public_transaction(&clock_tx, block_id, timestamp)
.is_err()
{
return;
}
// ── Phase 2: Replayer path ────────────────────────────────────────────────
// Mirrors `IndexerStore::put_block`:
// for each transaction in the block: stateless_check → execute_check_on_state.
let mut rep_state = base_state.clone();
for tx in &accepted_txs {
// Replayer: stateless check. This must succeed because the sequencer
// already passed the same check above (deterministic, no state involved).
let Ok(checked_tx) = tx.clone().transaction_stateless_check() else {
// INVARIANT 2: sequencer accepted this tx but stateless check fails
// on the replayer. Stateless validity is deterministic — this is a bug.
panic!(
"INVARIANT VIOLATION [ReplayerAcceptsAllSequencerTxs]: \
transaction_stateless_check failed on the replayer for a \
sequencer-accepted transaction (stateless check is deterministic)"
);
};
// Replayer: execute_check_on_state must succeed for every transaction
// the sequencer accepted (INVARIANT 2).
checked_tx
.execute_check_on_state(&mut rep_state, block_id, timestamp)
.unwrap_or_else(|e| {
panic!(
"INVARIANT VIOLATION [ReplayerAcceptsAllSequencerTxs]: \
execute_check_on_state rejected a sequencer-accepted \
transaction on the replayer: {e:?}"
)
});
}
// Replayer: apply the same clock invocation (INVARIANT 3).
rep_state
.transition_from_public_transaction(&clock_tx, block_id, timestamp)
.unwrap_or_else(|e| {
panic!(
"INVARIANT VIOLATION [ClockConsistency]: \
clock invocation succeeded on the sequencer state but failed \
on the replayer state: {e:?}"
)
});
// ── Invariant 1: SequencerReplayerEquivalence ─────────────────────────────
// Compare every known account (genesis diff-declared) across both states.
// Any mismatch means the two pipelines derived a different state root —
// a consensus-breaking bug.
for acc_id in &touched_ids {
let seq_acc = seq_state.get_account_by_id(*acc_id);
let rep_acc = rep_state.get_account_by_id(*acc_id);
assert_eq!(
seq_acc.balance,
rep_acc.balance,
"INVARIANT VIOLATION [SequencerReplayerEquivalence]: balance diverges \
for account {:?} sequencer={} replayer={}",
acc_id,
seq_acc.balance,
rep_acc.balance,
);
assert_eq!(
seq_acc.nonce,
rep_acc.nonce,
"INVARIANT VIOLATION [SequencerReplayerEquivalence]: nonce diverges \
for account {:?} sequencer={:?} replayer={:?}",
acc_id,
seq_acc.nonce,
rep_acc.nonce,
);
assert_eq!(
seq_acc.data,
rep_acc.data,
"INVARIANT VIOLATION [SequencerReplayerEquivalence]: data field \
diverges for account {:?}",
acc_id,
);
assert_eq!(
seq_acc.program_owner,
rep_acc.program_owner,
"INVARIANT VIOLATION [SequencerReplayerEquivalence]: program_owner \
diverges for account {:?}",
acc_id,
);
}
});