diff --git a/Cargo.toml b/Cargo.toml index 16ef6e1..af287fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ clippy.absolute-paths = "allow" clippy.min-ident-chars = "allow" clippy.indexing-slicing = "allow" clippy.little-endian-bytes = "allow" +clippy.self-named-module-files = "allow" [workspace.lints.rust] unsafe_code = "deny" diff --git a/fuzz_props/src/invariants.rs b/fuzz_props/src/invariants.rs index a233406..b4d92bc 100644 --- a/fuzz_props/src/invariants.rs +++ b/fuzz_props/src/invariants.rs @@ -337,187 +337,3 @@ pub fn assert_invariants(ctx: &InvariantCtx<'_>) { } } } - -// ── Unit tests ──────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use nssa::V03State; - - fn make_empty_state() -> V03State { - V03State::new_with_genesis_accounts(&[], vec![], 0) - } - - fn make_empty_snapshot() -> BalanceSnapshot { - BalanceSnapshot(std::collections::HashMap::new()) - } - - fn make_empty_nonce_snapshot() -> NonceSnapshot { - NonceSnapshot(std::collections::HashMap::new()) - } - - #[test] - fn invariant_state_isolation_on_failure_does_not_panic_on_error() { - let state = make_empty_state(); - let ctx = InvariantCtx { - state_before: &state, - state_after: &state, - execution_succeeded: false, - balances_before: make_empty_snapshot(), - nonces_before: make_empty_nonce_snapshot(), - }; - assert_invariants(&ctx); - } - - #[test] - fn invariant_replay_rejection_does_not_panic() { - let state = make_empty_state(); - let ctx = InvariantCtx { - state_before: &state, - state_after: &state, - execution_succeeded: true, - balances_before: make_empty_snapshot(), - nonces_before: make_empty_nonce_snapshot(), - }; - assert_invariants(&ctx); - } - - #[test] - fn balance_conservation_catches_inflation_on_success() { - // Arrange: one account with balance 100. - let acc_id = nssa::AccountId::new([1_u8; 32]); - let state_before = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); - // Simulate execution that inflated the balance to 200. - let state_after = V03State::new_with_genesis_accounts(&[(acc_id, 200)], vec![], 0); - - let mut balances = std::collections::HashMap::new(); - balances.insert(acc_id, 100_u128); - - let ctx = InvariantCtx { - state_before: &state_before, - state_after: &state_after, - execution_succeeded: true, - balances_before: BalanceSnapshot(balances), - nonces_before: make_empty_nonce_snapshot(), - }; - - let result = std::panic::catch_unwind(|| assert_invariants(&ctx)); - 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([9_u8; 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([3_u8; 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([2_u8; 32]); - // before: nonce 5; after: nonce 6 (should not happen on failure) - let state_before = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); - let state_after = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); - - // We check the nonce snapshot directly; the states both return default nonce (0). - // Fake a discrepancy by inserting nonce=1 in the snapshot while state_after has nonce=0. - let mut nonces = std::collections::HashMap::new(); - // Nonce(1) in snapshot, but state_after will return Nonce(0). - nonces.insert(acc_id, Nonce(1)); - - let mut balances = std::collections::HashMap::new(); - balances.insert(acc_id, 100_u128); - - let ctx = InvariantCtx { - state_before: &state_before, - state_after: &state_after, - execution_succeeded: false, - balances_before: BalanceSnapshot(balances), - nonces_before: NonceSnapshot(nonces), - }; - - let result = std::panic::catch_unwind(|| assert_invariants(&ctx)); - assert!( - result.is_err(), - "expected panic for nonce mutation on failure" - ); - } -} - -// ── ReplayRejection proptest suite ─────────────────────────────────────────── -// -// This suite constitutes the formal, reproducible exercise of the ReplayRejection -// invariant. It generates a realistic initial state and a correctly-signed -// native-transfer transaction, applies it once, and asserts that a second -// application is rejected. -// -// Run with: cargo test -p fuzz_props replay_rejection -#[cfg(test)] -mod replay_proptest { - use crate::generators::{arb_native_transfer_tx, test_accounts}; - use nssa::V03State; - use proptest::prelude::*; - - /// Build a `V03State` from the testnet accounts, assigning each a fixed - /// balance large enough for any reasonable transfer amount. - fn make_test_state() -> V03State { - let accounts = test_accounts(); - let init_accs: Vec<(nssa::AccountId, u128)> = accounts - .iter() - .map(|(id, _)| (*id, 1_000_000_u128)) - .collect(); - V03State::new_with_genesis_accounts(&init_accs, vec![], 0) - } - - proptest! { - /// **ReplayRejection** \u{2014} a transaction accepted in block N must be - /// rejected when replayed in block N+1, because the nonce is consumed - /// on first acceptance. - #[test] - fn replay_rejection_proptest(tx in arb_native_transfer_tx(test_accounts())) { - let mut state = make_test_state(); - - // Stateless gate \u{2014} skip structurally invalid transactions (e.g. those - // whose public key does not match the declared sender). - let Ok(validated_tx) = tx.transaction_stateless_check() else { return Ok(()) }; - - // First application \u{2014} may fail for state-level reasons (e.g. sender - // has insufficient balance, wrong nonce). In that case there is - // nothing to replay. - let first_result = validated_tx.execute_check_on_state(&mut state, 1, 0); - - if let Ok(applied_tx) = first_result { - // Use the shared framework function. assert_replay_rejection uses - // assert!() rather than prop_assert!(); for structured proptest - // inputs the framework-level panic is equivalent. - super::assert_replay_rejection(applied_tx, &mut state, 2, 1); - } - } - } -} diff --git a/fuzz_props/src/lib.rs b/fuzz_props/src/lib.rs index ffba17a..6c45ffd 100644 --- a/fuzz_props/src/lib.rs +++ b/fuzz_props/src/lib.rs @@ -86,33 +86,4 @@ macro_rules! fuzz_entry { } #[cfg(test)] -mod seed_gen { - use std::fs; - use std::path::Path; - - #[test] - fn generate_seeds() { - let tx = common::test_utils::produce_dummy_empty_transaction(); - let bytes = borsh::to_vec(&tx).unwrap(); - - // CARGO_MANIFEST_DIR is lez-fuzzing/fuzz_props/ at compile time. - // Tests inherit the package directory as cwd, so we must use an - // absolute base rather than a bare relative path. - let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("fuzz_props is one level below the workspace root"); - - let targets = [ - "fuzz/corpus/fuzz_transaction_decoding/seed_empty_tx", - "fuzz/corpus/fuzz_stateless_verification/seed_empty_tx", - "fuzz/corpus/fuzz_state_transition/seed_empty_tx", - ]; - for rel in &targets { - let p = workspace_root.join(rel); - if let Some(parent) = p.parent() { - let _ = fs::create_dir_all(parent); - } - let _ = fs::write(&p, &bytes); - } - } -} +mod tests; diff --git a/fuzz_props/src/tests.rs b/fuzz_props/src/tests.rs new file mode 100644 index 0000000..759db83 --- /dev/null +++ b/fuzz_props/src/tests.rs @@ -0,0 +1,3 @@ +mod invariants; +mod replay_proptest; +mod seed_gen; diff --git a/fuzz_props/src/tests/invariants.rs b/fuzz_props/src/tests/invariants.rs new file mode 100644 index 0000000..59c6217 --- /dev/null +++ b/fuzz_props/src/tests/invariants.rs @@ -0,0 +1,119 @@ +use crate::invariants::{ + BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, + assert_nonce_increment_correctness, +}; +use nssa::V03State; +use nssa_core::account::Nonce; + +fn make_empty_state() -> V03State { + V03State::new_with_genesis_accounts(&[], vec![], 0) +} + +fn make_empty_snapshot() -> BalanceSnapshot { + BalanceSnapshot(std::collections::HashMap::new()) +} + +fn make_empty_nonce_snapshot() -> NonceSnapshot { + NonceSnapshot(std::collections::HashMap::new()) +} + +#[test] +fn invariant_state_isolation_on_failure_does_not_panic_on_error() { + let state = make_empty_state(); + let ctx = InvariantCtx { + state_before: &state, + state_after: &state, + execution_succeeded: false, + balances_before: make_empty_snapshot(), + nonces_before: make_empty_nonce_snapshot(), + }; + assert_invariants(&ctx); +} + +#[test] +fn invariant_replay_rejection_does_not_panic() { + let state = make_empty_state(); + let ctx = InvariantCtx { + state_before: &state, + state_after: &state, + execution_succeeded: true, + balances_before: make_empty_snapshot(), + nonces_before: make_empty_nonce_snapshot(), + }; + assert_invariants(&ctx); +} + +#[test] +fn balance_conservation_catches_inflation_on_success() { + let acc_id = nssa::AccountId::new([1_u8; 32]); + let state_before = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); + let state_after = V03State::new_with_genesis_accounts(&[(acc_id, 200)], vec![], 0); + + let mut balances = std::collections::HashMap::new(); + balances.insert(acc_id, 100_u128); + + let ctx = InvariantCtx { + state_before: &state_before, + state_after: &state_after, + execution_succeeded: true, + balances_before: BalanceSnapshot(balances), + nonces_before: make_empty_nonce_snapshot(), + }; + + let result = std::panic::catch_unwind(|| assert_invariants(&ctx)); + assert!(result.is_err(), "expected panic for balance inflation"); +} + +#[test] +fn nonce_increment_correctness_passes_with_no_signers() { + 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() { + let acc_id = nssa::AccountId::new([9_u8; 32]); + let state = make_empty_state(); + assert_nonce_increment_correctness(&[acc_id], &make_empty_nonce_snapshot(), &state); +} + +#[test] +fn nonce_increment_correctness_catches_unchanged_nonce() { + let acc_id = nssa::AccountId::new([3_u8; 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([2_u8; 32]); + let state_before = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); + let state_after = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); + + let mut nonces = std::collections::HashMap::new(); + nonces.insert(acc_id, Nonce(1)); + + let mut balances = std::collections::HashMap::new(); + balances.insert(acc_id, 100_u128); + + let ctx = InvariantCtx { + state_before: &state_before, + state_after: &state_after, + execution_succeeded: false, + balances_before: BalanceSnapshot(balances), + nonces_before: NonceSnapshot(nonces), + }; + + let result = std::panic::catch_unwind(|| assert_invariants(&ctx)); + assert!( + result.is_err(), + "expected panic for nonce mutation on failure" + ); +} diff --git a/fuzz_props/src/tests/replay_proptest.rs b/fuzz_props/src/tests/replay_proptest.rs new file mode 100644 index 0000000..6be98a8 --- /dev/null +++ b/fuzz_props/src/tests/replay_proptest.rs @@ -0,0 +1,33 @@ +// Run with: cargo test -p fuzz_props replay_rejection +use crate::generators::{arb_native_transfer_tx, test_accounts}; +use nssa::V03State; +use proptest::prelude::*; + +fn make_test_state() -> V03State { + let accounts = test_accounts(); + let init_accs: Vec<(nssa::AccountId, u128)> = accounts + .iter() + .map(|(id, _)| (*id, 1_000_000_u128)) + .collect(); + V03State::new_with_genesis_accounts(&init_accs, vec![], 0) +} + +proptest! { + /// **ReplayRejection** \u{2014} a transaction accepted in block N must be + /// rejected when replayed in block N+1, because the nonce is consumed + /// on first acceptance. + #[test] + fn replay_rejection_proptest(tx in arb_native_transfer_tx(test_accounts())) { + let mut state = make_test_state(); + + // Skip structurally invalid transactions (e.g. mismatched public key / sender). + let Ok(validated_tx) = tx.transaction_stateless_check() else { return Ok(()) }; + + // First application may fail for state-level reasons; nothing to replay then. + let first_result = validated_tx.execute_check_on_state(&mut state, 1, 0); + + if let Ok(applied_tx) = first_result { + crate::invariants::assert_replay_rejection(applied_tx, &mut state, 2, 1); + } + } +} diff --git a/fuzz_props/src/tests/seed_gen.rs b/fuzz_props/src/tests/seed_gen.rs new file mode 100644 index 0000000..7548307 --- /dev/null +++ b/fuzz_props/src/tests/seed_gen.rs @@ -0,0 +1,25 @@ +use std::fs; +use std::path::Path; + +#[test] +fn generate_seeds() { + let tx = common::test_utils::produce_dummy_empty_transaction(); + let bytes = borsh::to_vec(&tx).unwrap(); + + let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("fuzz_props is one level below the workspace root"); + + let targets = [ + "fuzz/corpus/fuzz_transaction_decoding/seed_empty_tx", + "fuzz/corpus/fuzz_stateless_verification/seed_empty_tx", + "fuzz/corpus/fuzz_state_transition/seed_empty_tx", + ]; + for rel in &targets { + let p = workspace_root.join(rel); + if let Some(parent) = p.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(&p, &bytes); + } +}