diff --git a/fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs b/fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs index 5fc710f..6db12ec 100644 --- a/fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs +++ b/fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs @@ -33,8 +33,10 @@ use std::collections::HashSet; use arbitrary::{Arbitrary, Unstructured}; +use common::transaction::NSSATransaction; use fuzz_props::arbitrary_types::ArbNSSATransaction; use fuzz_props::generators::arbitrary_fuzz_state; +use fuzz_props::invariants::{NonceSnapshot, assert_nonce_increment_correctness}; use libfuzzer_sys::fuzz_target; use nssa::V03State; @@ -72,6 +74,31 @@ fuzz_target!(|data: &[u8]| { return; }; + // ── Extract signer IDs and capture nonce snapshot before apply ──────────── + // Signer IDs are private to ValidatedStateDiff; derive them from the transaction's + // witness set before the diff is consumed by apply_state_diff. + let signer_ids: Vec = match &tx { + NSSATransaction::Public(pub_tx) => pub_tx + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::PrivacyPreserving(pp_tx) => pp_tx + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::ProgramDeployment(_) => vec![], + }; + let nonces_before = NonceSnapshot( + signer_ids + .iter() + .map(|&id| (id, state.get_account_by_id(id).nonce)) + .collect(), + ); + // Capture the IDs declared in the diff before consuming it in apply_state_diff. let diff_account_ids: Vec = diff.public_diff().keys().copied().collect(); @@ -80,6 +107,11 @@ fuzz_target!(|data: &[u8]| { let mut split_state = state.clone(); split_state.apply_state_diff(diff); + // ── Standalone invariant: NonceIncrementCorrectness (split path) ────────── + // Asserts that every signer account's nonce was incremented by exactly one, + // catching bugs in the two-step apply_state_diff nonce-increment logic. + assert_nonce_increment_correctness(&signer_ids, &nonces_before, &split_state); + // ── Direct path: execute_check_on_state ─────────────────────────────────── // This consumes `tx`; it must succeed because validate_on_state already did. let mut exec_state = state.clone(); diff --git a/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs b/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs index 1f6d107..299bab3 100644 --- a/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs +++ b/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs @@ -35,9 +35,11 @@ //! the total; only mint/burn bugs or token-inflation bugs would break it. use arbitrary::{Arbitrary, Unstructured}; +use common::transaction::NSSATransaction; use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; use fuzz_props::invariants::{ - BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, assert_replay_rejection, + BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, assert_nonce_increment_correctness, + assert_replay_rejection, }; use libfuzzer_sys::fuzz_target; use nssa::V03State; @@ -111,13 +113,29 @@ fuzz_target!(|data: &[u8]| { state_after: &state, execution_succeeded, balances_before, - nonces_before, + nonces_before: nonces_before.clone(), }); - // ── ReplayRejection (per-block) ─────────────────────────────────────── - // execute_check_on_state returns the NSSATransaction on Ok; replay it - // immediately in the next block and assert it is rejected (nonce consumed). + // ── NonceIncrementCorrectness + ReplayRejection (per-block) ────────── + // First verify every signer's nonce was incremented by exactly one, then + // replay in the next block to confirm the nonce is permanently consumed. if let Ok(applied_tx) = result { + let signer_ids: Vec = match &applied_tx { + NSSATransaction::Public(t) => t + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::PrivacyPreserving(t) => t + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::ProgramDeployment(_) => vec![], + }; + assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state); assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1); } } diff --git a/fuzz/fuzz_targets/fuzz_replay_prevention.rs b/fuzz/fuzz_targets/fuzz_replay_prevention.rs index d1dd65f..1aacfcb 100644 --- a/fuzz/fuzz_targets/fuzz_replay_prevention.rs +++ b/fuzz/fuzz_targets/fuzz_replay_prevention.rs @@ -23,9 +23,11 @@ //! - **ReplayRejection** — accepted tx rejected on replay use arbitrary::{Arbitrary, Unstructured}; +use common::transaction::NSSATransaction; use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; use fuzz_props::invariants::{ - BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, assert_replay_rejection, + BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, assert_nonce_increment_correctness, + assert_replay_rejection, }; use libfuzzer_sys::fuzz_target; use nssa::V03State; @@ -87,13 +89,29 @@ fuzz_target!(|data: &[u8]| { state_after: &state, execution_succeeded, balances_before, - nonces_before, + nonces_before: nonces_before.clone(), }); - // ── ReplayRejection ─────────────────────────────────────────────────────── - // tx is returned on success; assert that applying it again in the next block - // is rejected (nonce was consumed on first acceptance). + // ── NonceIncrementCorrectness + ReplayRejection ─────────────────────────── + // First verify every signer's nonce was incremented by exactly one, then + // assert that replaying in the next block is rejected (nonce permanently consumed). if let Ok(applied_tx) = result { + let signer_ids: Vec = match &applied_tx { + NSSATransaction::Public(t) => t + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::PrivacyPreserving(t) => t + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::ProgramDeployment(_) => vec![], + }; + assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state); assert_replay_rejection(applied_tx, &mut state, 2, 1); } }); diff --git a/fuzz/fuzz_targets/fuzz_state_transition.rs b/fuzz/fuzz_targets/fuzz_state_transition.rs index a6eb776..8dc3de0 100644 --- a/fuzz/fuzz_targets/fuzz_state_transition.rs +++ b/fuzz/fuzz_targets/fuzz_state_transition.rs @@ -1,9 +1,11 @@ #![no_main] use arbitrary::{Arbitrary, Unstructured}; +use common::transaction::NSSATransaction; use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; use fuzz_props::invariants::{ - BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, assert_replay_rejection, + BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, assert_nonce_increment_correctness, + assert_replay_rejection, }; use libfuzzer_sys::fuzz_target; use nssa::V03State; @@ -85,13 +87,30 @@ fuzz_target!(|data: &[u8]| { state_after: &state, execution_succeeded, balances_before, - nonces_before, + nonces_before: nonces_before.clone(), }); - // ── ReplayRejection ─────────────────────────────────────────────────── - // execute_check_on_state returns the NSSATransaction on Ok; replay it - // immediately in the next block and assert it is rejected. + // ── NonceIncrementCorrectness + ReplayRejection ─────────────────────── + // execute_check_on_state returns the NSSATransaction on Ok. + // First verify every signer's nonce was incremented by exactly one, then + // replay in the next block to confirm the nonce is permanently consumed. if let Ok(applied_tx) = result { + let signer_ids: Vec = match &applied_tx { + NSSATransaction::Public(t) => t + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::PrivacyPreserving(t) => t + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::ProgramDeployment(_) => vec![], + }; + assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state); assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1); } } diff --git a/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs b/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs index 344caf1..d9f1b72 100644 --- a/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs +++ b/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs @@ -25,8 +25,10 @@ //! reachable by the fuzzer. use arbitrary::{Arbitrary, Unstructured}; +use common::transaction::NSSATransaction; use fuzz_props::arbitrary_types::ArbNSSATransaction; use fuzz_props::generators::arbitrary_fuzz_state; +use fuzz_props::invariants::{NonceSnapshot, assert_nonce_increment_correctness}; use libfuzzer_sys::fuzz_target; use nssa::V03State; @@ -57,6 +59,15 @@ fuzz_target!(|data: &[u8]| { let state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0); + // Capture nonces of all known accounts before execution so that + // assert_nonce_increment_correctness can verify the +1 step on success. + let nonces_before = NonceSnapshot( + init_accs + .iter() + .map(|&(id, _)| (id, state.get_account_by_id(id).nonce)) + .collect(), + ); + // validate_on_state borrows `tx` and `state` — does NOT mutate state. let validate_result = tx.validate_on_state(&state, 1, 0); @@ -66,7 +77,7 @@ fuzz_target!(|data: &[u8]| { // INVARIANT 1: both must agree on success vs failure. match (validate_result, execute_result) { - (Ok(diff), Ok(_)) => { + (Ok(diff), Ok(applied_tx)) => { let public_diff = diff.public_diff(); // INVARIANT 2a (forward): every account in the diff matches the post-execute state. @@ -143,6 +154,28 @@ fuzz_target!(|data: &[u8]| { "INVARIANT VIOLATION: total balance of known accounts changed after successful \ transaction (possible double-credit or token-inflation bug)", ); + + // INVARIANT 4 (nonce increment correctness): every signer's nonce must + // have advanced by exactly one. This is orthogonal to the balance and + // consistency checks above: it catches bugs where validate_on_state and + // execute_check_on_state agree (passing INVARIANT 1) but both increment + // the wrong account's nonce, or skip the increment entirely. + let signer_ids: Vec = match &applied_tx { + NSSATransaction::Public(t) => t + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::PrivacyPreserving(t) => t + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::ProgramDeployment(_) => vec![], + }; + assert_nonce_increment_correctness(&signer_ids, &nonces_before, &exec_state); } (Err(_), Err(_)) => { // Both failed — correct. diff --git a/fuzz_props/src/invariants.rs b/fuzz_props/src/invariants.rs index 225cf58..4f97886 100644 --- a/fuzz_props/src/invariants.rs +++ b/fuzz_props/src/invariants.rs @@ -180,6 +180,33 @@ impl ProtocolInvariant for ReplayRejection { } } +/// A successfully applied transaction must increment the nonce of every signer account +/// by exactly one. +/// +/// # Note +/// +/// This invariant **cannot** be exercised through [`InvariantCtx`] alone because +/// `InvariantCtx` does not carry a signer-ID list — that information is private to the +/// `nssa` crate and is consumed by `apply_state_diff` before it returns. The +/// `ProtocolInvariant` impl here is a registry placeholder only; it always returns `None`. +/// +/// Use the standalone [`assert_nonce_increment_correctness`] function instead, passing +/// the signer IDs derived from the transaction's witness set, the [`NonceSnapshot`] +/// captured before execution, and the post-execution state. +pub struct NonceIncrementCorrectness; + +impl ProtocolInvariant for NonceIncrementCorrectness { + fn name(&self) -> &'static str { + "NonceIncrementCorrectness" + } + + fn check(&self, _ctx: &InvariantCtx<'_>) -> Option { + // NonceIncrementCorrectness requires explicit signer_ids not available in InvariantCtx. + // Use `assert_nonce_increment_correctness(signer_ids, nonces_before, state_after)` instead. + None + } +} + // ── Standalone helpers ──────────────────────────────────────────────────────── /// Assert that a successfully-applied transaction is **rejected** when replayed. @@ -220,6 +247,70 @@ pub fn assert_replay_rejection( ); } +/// Assert that every signer account's nonce was incremented by exactly one after a +/// successfully applied transaction. +/// +/// Call this immediately after `apply_state_diff` (or `execute_check_on_state`) succeeds, +/// passing the signer IDs derived from the transaction's witness set, the [`NonceSnapshot`] +/// captured **before** execution, and the post-execution state. +/// +/// For a `NSSATransaction::Public(tx)`, derive signer IDs as: +/// +/// ```rust,ignore +/// let signer_ids: Vec = tx +/// .witness_set() +/// .signatures_and_public_keys() +/// .iter() +/// .map(|(_, pk)| nssa::AccountId::from(pk)) +/// .collect(); +/// ``` +/// +/// For `NSSATransaction::ProgramDeployment`, there are no signers; pass an empty slice. +/// +/// # Why a standalone function? +/// +/// `apply_state_diff` consumes the `ValidatedStateDiff`, whose `signer_account_ids` field +/// is private to the `nssa` crate. The caller must therefore derive signer IDs from the +/// transaction's witness set before consuming the diff, and thread them into this helper. +/// +/// # Example +/// +/// ```rust,ignore +/// let signer_ids = /* derived from tx.witness_set() */; +/// let nonces_before = NonceSnapshot( +/// signer_ids.iter().map(|&id| (id, state.get_account_by_id(id).nonce)).collect(), +/// ); +/// state.apply_state_diff(diff); +/// assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state); +/// ``` +pub fn assert_nonce_increment_correctness( + signer_ids: &[nssa::AccountId], + nonces_before: &NonceSnapshot, + state_after: &V03State, +) { + for &id in signer_ids { + let nonce_before = match nonces_before.0.get(&id) { + Some(n) => *n, + None => continue, // Account not in snapshot (e.g. newly created); skip. + }; + let nonce_after = state_after.get_account_by_id(id).nonce; + let expected = Nonce( + nonce_before + .0 + .checked_add(1) + .expect("nonce overflow — signer nonce at u128::MAX"), + ); + assert_eq!( + nonce_after, expected, + "INVARIANT VIOLATION [NonceIncrementCorrectness]: signer account {:?} nonce \ + not incremented by 1 after successful transaction \ + — before={:?}, expected={:?}, got={:?} \ + (apply_state_diff failed to increment nonce exactly once)", + id, nonce_before, expected, nonce_after, + ); + } +} + // ── Dispatcher ─────────────────────────────────────────────────────────────── /// Run every registered [`ProtocolInvariant`] and panic with a structured message @@ -230,12 +321,14 @@ pub fn assert_replay_rejection( /// - [`BalanceConservation`] — total balance conserved on success /// - [`FailedTxNonceStability`] — nonces unchanged on rejection /// - [`ReplayRejection`] — stub only; use [`assert_replay_rejection`] directly +/// - [`NonceIncrementCorrectness`] — stub only; use [`assert_nonce_increment_correctness`] directly pub fn assert_invariants(ctx: &InvariantCtx<'_>) { let invariants: &[&dyn ProtocolInvariant] = &[ &StateIsolationOnFailure, &BalanceConservation, &FailedTxNonceStability, &ReplayRejection, + &NonceIncrementCorrectness, ]; for inv in invariants { if let Some(violation) = inv.check(ctx) { @@ -316,6 +409,38 @@ mod tests { assert!(result.is_err(), "expected panic for balance inflation"); } + #[test] + fn nonce_increment_correctness_passes_with_no_signers() { + // Empty signer list — no accounts to check; trivially satisfies the invariant. + let state = make_empty_state(); + assert_nonce_increment_correctness(&[], &make_empty_nonce_snapshot(), &state); + } + + #[test] + fn nonce_increment_correctness_passes_when_signer_not_in_snapshot() { + // Signer ID is present in the list but absent from the snapshot — skipped. + let acc_id = nssa::AccountId::new([9u8; 32]); + let state = make_empty_state(); + // Empty snapshot → `continue` branch fires; no assertion is made. + assert_nonce_increment_correctness(&[acc_id], &make_empty_nonce_snapshot(), &state); + } + + #[test] + fn nonce_increment_correctness_catches_unchanged_nonce() { + // Arrange: signer has nonce 5 in the snapshot; the state returns Nonce(0) for the + // same account (genesis default). expected = Nonce(6), actual = Nonce(0) → VIOLATION. + let acc_id = nssa::AccountId::new([3u8; 32]); + let state = V03State::new_with_genesis_accounts(&[], vec![], 0); + + let mut nonces = std::collections::HashMap::new(); + nonces.insert(acc_id, Nonce(5)); + + let result = std::panic::catch_unwind(|| { + assert_nonce_increment_correctness(&[acc_id], &NonceSnapshot(nonces), &state); + }); + assert!(result.is_err(), "expected panic for unchanged nonce"); + } + #[test] fn failed_tx_nonce_stability_catches_nonce_mutation() { let acc_id = nssa::AccountId::new([2u8; 32]);