From 88e780b865138c4bf112c94fa923266065c7ad8c Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 28 May 2026 11:40:38 +0800 Subject: [PATCH] fix: improve API around tx invariants --- current_vs_alternative_approach.md | 2 +- docs/fuzzing.md | 69 ++++--- .../fuzz_multi_block_state_sequence.rs | 50 ++--- fuzz/fuzz_targets/fuzz_replay_prevention.rs | 47 ++--- fuzz/fuzz_targets/fuzz_state_transition.rs | 42 ++-- fuzz_props/src/invariants.rs | 189 +++++++++++++----- fuzz_props/src/tests/invariants.rs | 2 +- 7 files changed, 230 insertions(+), 171 deletions(-) diff --git a/current_vs_alternative_approach.md b/current_vs_alternative_approach.md index 34af3ac..8498316 100644 --- a/current_vs_alternative_approach.md +++ b/current_vs_alternative_approach.md @@ -111,7 +111,7 @@ The extension noted in [`docs/fuzzing.md`](docs/fuzzing.md:356) is: | Execution time | Slow (recompile per mutation) | Continuous | | Output | Surviving mutants = assertion gaps | Crash artifacts | -**Decision-maker view**: `cargo-mutants` would **audit the invariant assertions themselves** — revealing if [`assert_invariants()`](fuzz_props/src/invariants.rs:325) has gaps. Three invariants are fully implemented: [`StateIsolationOnFailure`](fuzz_props/src/invariants.rs:60), [`BalanceConservation`](fuzz_props/src/invariants.rs:94), and [`FailedTxNonceStability`](fuzz_props/src/invariants.rs:130). Two are registry stubs: [`ReplayRejection`](fuzz_props/src/invariants.rs:169) and [`NonceIncrementCorrectness`](fuzz_props/src/invariants.rs:196) — each enforced via dedicated standalone helpers (`assert_replay_rejection`, `assert_nonce_increment_correctness`). This is a **complementary quality gate**, not a fuzzing replacement. Low cost (~1 day), highly useful before an external security audit. +**Decision-maker view**: `cargo-mutants` would **audit the invariant assertions themselves** — revealing if [`assert_invariants()`](fuzz_props/src/invariants.rs) has gaps. Three invariants are fully implemented and registered in `assert_invariants()`: [`StateIsolationOnFailure`](fuzz_props/src/invariants.rs:60), [`BalanceConservation`](fuzz_props/src/invariants.rs:94), and [`FailedTxNonceStability`](fuzz_props/src/invariants.rs:130). Two additional invariants — [`ReplayRejection`](fuzz_props/src/invariants.rs:167) and [`NonceIncrementCorrectness`](fuzz_props/src/invariants.rs:194) — are enforced exclusively via standalone helpers (`assert_replay_rejection`, `assert_nonce_increment_correctness`) and are **not** in the `assert_invariants()` registry; this is intentional because they require data consumed before `InvariantCtx` is built. This is a **complementary quality gate**, not a fuzzing replacement. Low cost (~1 day), highly useful before an external security audit. --- diff --git a/docs/fuzzing.md b/docs/fuzzing.md index bb870ca..c65bf7f 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -478,37 +478,50 @@ automates steps 2–5 and uploads the report as a workflow artifact. ## Invariant Framework -Shared invariants live in `fuzz_props/src/invariants.rs`. Each invariant implements -`ProtocolInvariant` and is automatically run by `assert_invariants()`. +Shared invariants live in `fuzz_props/src/invariants.rs`. There are two layers: -Concrete invariants currently registered in `assert_invariants()`: +### Primary API — `assert_tx_execution_invariants()` -| Invariant | Description | Implementation status | -|-----------|-------------|----------------------| -| `StateIsolationOnFailure` | Per-account balance must not change for any tracked account when a transaction is rejected | ✅ Fully implemented | -| `BalanceConservation` | Total balance of all known accounts must be conserved when a transaction succeeds | ✅ Fully implemented | -| `FailedTxNonceStability` | Every account's nonce must remain unchanged when a transaction is rejected | ✅ Fully implemented | -| `ReplayRejection` | An accepted transaction must be rejected when replayed | ⚠️ Registry stub — always returns `None` from `InvariantCtx`; use `assert_replay_rejection()` directly (see note below) | -| `NonceIncrementCorrectness` | Every signer account's nonce must be incremented by exactly one after a successful transaction | ⚠️ Registry stub — always returns `None` from `InvariantCtx`; use `assert_nonce_increment_correctness()` directly (see note below) | +For every fuzz target that calls `execute_check_on_state`, use the single unified entry +point. It enforces the five state-transition invariants in one call, routing by outcome: -> **Note on stub invariants:** `ReplayRejection` and `NonceIncrementCorrectness` cannot be -> fully exercised through `InvariantCtx` alone. Each requires information that is consumed -> before `InvariantCtx` is built: -> -> - **`ReplayRejection`**: `execute_check_on_state` returns the `NSSATransaction` on `Ok`, -> consuming `self`. Replaying it requires re-applying the returned transaction to the -> post-execution state — not possible via a shared `&InvariantCtx`. Use the standalone -> `assert_replay_rejection(applied_tx, state, next_block_id, next_timestamp)` helper -> immediately after each successful execution. The proptest suite `replay_rejection_proptest` -> in `fuzz_props/src/invariants.rs` provides reproducible structured coverage of this -> invariant. -> -> - **`NonceIncrementCorrectness`**: `apply_state_diff` consumes the `ValidatedStateDiff` -> whose signer-account list is private to the `nssa` crate. The caller must derive signer -> IDs from the transaction's witness set before consuming the diff, then call the standalone -> `assert_nonce_increment_correctness(signer_ids, nonces_before, state_after)` helper. -> The `signer_account_ids()` helper in `fuzz_props::generators` extracts signer `AccountId`s -> from an `NSSATransaction`'s witness set. +| Invariant | Active when | +|-----------|-------------| +| `StateIsolationOnFailure` | `execution_result` is `Err` | +| `FailedTxNonceStability` | `execution_result` is `Err` | +| `BalanceConservation` | `execution_result` is `Ok` | +| `NonceIncrementCorrectness` | `execution_result` is `Ok` | +| `ReplayRejection` | `execution_result` is `Ok` | + +```rust +let state_snapshot = state.clone(); +let result = tx.execute_check_on_state(&mut state, block_id, timestamp); + +assert_tx_execution_invariants( + &state_snapshot, + &mut state, + balances_before, + nonces_before, + result, + (block_id + 1, timestamp + 1), +); +``` + +One call. No standalone helpers to remember. + +### Registry API — `assert_invariants()` + `ProtocolInvariant` + +Each invariant is a zero-size struct implementing `ProtocolInvariant`; `assert_invariants()` +runs the registry and panics on the first violation. This lower-level API is used +internally by `assert_tx_execution_invariants` and is also available for targets where no +transaction is available for replay (e.g. pure state-serialization targets). + +```rust +// Only use assert_invariants() directly for non-execution contexts. +// For execute_check_on_state call sites, prefer assert_tx_execution_invariants(). +assert_invariants(&InvariantCtx { state_before, state_after, execution_succeeded, + balances_before, nonces_before }); +``` Additional invariants enforced **inline** in specific targets (not via `ProtocolInvariant`): diff --git a/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs b/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs index bb084de..c1974f8 100644 --- a/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs +++ b/fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs @@ -17,15 +17,14 @@ //! //! # Invariants //! -//! The following per-transaction invariants are checked via the shared framework -//! ([`fuzz_props::invariants::assert_invariants`]) on every iteration: +//! The following per-transaction invariants are checked via +//! [`fuzz_props::invariants::assert_tx_execution_invariants`] on every iteration: //! //! - **StateIsolationOnFailure** — balances unchanged on rejection. -//! - **BalanceConservation** — total balance conserved on success. //! - **FailedTxNonceStability** — nonces unchanged on rejection. -//! -//! In addition, [`assert_replay_rejection`] is called on every successful -//! transaction (per-block replay check). +//! - **BalanceConservation** — total balance conserved on success. +//! - **NonceIncrementCorrectness** — signer nonces each increment by exactly one on success. +//! - **ReplayRejection** — every successful transaction rejected on replay (per-block). //! //! The following multi-block aggregate invariant is checked **after** the loop: //! @@ -35,11 +34,8 @@ //! the total; only mint/burn bugs or token-inflation bugs would break it. use arbitrary::{Arbitrary, Unstructured}; -use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction, signer_account_ids}; -use fuzz_props::invariants::{ - BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, assert_nonce_increment_correctness, - assert_replay_rejection, -}; +use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; +use fuzz_props::invariants::{BalanceSnapshot, NonceSnapshot, assert_tx_execution_invariants}; use nssa::V03State; fuzz_props::fuzz_entry!(|data: &[u8]| { @@ -99,29 +95,19 @@ fuzz_props::fuzz_entry!(|data: &[u8]| { let state_snapshot = state.clone(); let result = tx.execute_check_on_state(&mut state, block_id, timestamp); - let execution_succeeded = result.is_ok(); - // ── Shared invariant checks ─────────────────────────────────────────── - // Asserts per-transaction: - // • StateIsolationOnFailure — balances unchanged on rejection - // • BalanceConservation — total balance conserved on success - // • FailedTxNonceStability — nonces unchanged on rejection - assert_invariants(&InvariantCtx { - state_before: &state_snapshot, - state_after: &state, - execution_succeeded, + // ── All five protocol invariants ────────────────────────────────────── + // A single call enforces every invariant — no standalone helpers needed: + // On rejection: StateIsolationOnFailure + FailedTxNonceStability + // On success: BalanceConservation + NonceIncrementCorrectness + ReplayRejection + assert_tx_execution_invariants( + &state_snapshot, + &mut state, balances_before, - nonces_before: nonces_before.clone(), - }); - - // ── 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 ids = signer_account_ids(&applied_tx); - assert_nonce_increment_correctness(&ids, &nonces_before, &state); - assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1); - } + nonces_before, + result, + (block_id + 1, timestamp + 1), + ); } // ── LongRangeBalanceConservation ────────────────────────────────────────── diff --git a/fuzz/fuzz_targets/fuzz_replay_prevention.rs b/fuzz/fuzz_targets/fuzz_replay_prevention.rs index 1bc2d63..bdeb1b8 100644 --- a/fuzz/fuzz_targets/fuzz_replay_prevention.rs +++ b/fuzz/fuzz_targets/fuzz_replay_prevention.rs @@ -14,22 +14,17 @@ //! //! # Invariants checked //! -//! The shared framework ([`assert_invariants`]) enforces per-transaction: +//! [`assert_tx_execution_invariants`] enforces all five invariants per transaction +//! in one call: //! - **StateIsolationOnFailure** — balances unchanged on rejection //! - **BalanceConservation** — total balance conserved on success //! - **FailedTxNonceStability** — nonces unchanged on rejection -//! -//! The dedicated [`assert_replay_rejection`] function enforces: +//! - **NonceIncrementCorrectness** — signer nonces each increment by exactly one on success //! - **ReplayRejection** — accepted tx rejected on replay use arbitrary::{Arbitrary, Unstructured}; -use fuzz_props::generators::{ - arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction, signer_account_ids, -}; -use fuzz_props::invariants::{ - BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, - assert_nonce_increment_correctness, assert_replay_rejection, -}; +use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; +use fuzz_props::invariants::{BalanceSnapshot, NonceSnapshot, assert_tx_execution_invariants}; use nssa::V03State; fuzz_props::fuzz_entry!(|data: &[u8]| { @@ -77,27 +72,17 @@ fuzz_props::fuzz_entry!(|data: &[u8]| { // First application — may legitimately fail for state-level reasons. let result = tx.execute_check_on_state(&mut state, 1, 0); - let execution_succeeded = result.is_ok(); - // ── Shared invariant checks ─────────────────────────────────────────────── - // Asserts: - // • StateIsolationOnFailure — balances unchanged on rejection - // • BalanceConservation — total balance conserved on success - // • FailedTxNonceStability — nonces unchanged on rejection - assert_invariants(&InvariantCtx { - state_before: &state_snapshot, - state_after: &state, - execution_succeeded, + // ── All five protocol invariants ────────────────────────────────────────── + // A single call enforces every invariant — no standalone helpers needed: + // On rejection: StateIsolationOnFailure + FailedTxNonceStability + // On success: BalanceConservation + NonceIncrementCorrectness + ReplayRejection + assert_tx_execution_invariants( + &state_snapshot, + &mut state, balances_before, - nonces_before: nonces_before.clone(), - }); - - // ── 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 = signer_account_ids(&applied_tx); - assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state); - assert_replay_rejection(applied_tx, &mut state, 2, 1); - } + nonces_before, + result, + (2, 1), + ); }); diff --git a/fuzz/fuzz_targets/fuzz_state_transition.rs b/fuzz/fuzz_targets/fuzz_state_transition.rs index 4915237..b892436 100644 --- a/fuzz/fuzz_targets/fuzz_state_transition.rs +++ b/fuzz/fuzz_targets/fuzz_state_transition.rs @@ -1,13 +1,8 @@ #![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] use arbitrary::{Arbitrary, Unstructured}; -use fuzz_props::generators::{ - arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction, signer_account_ids, -}; -use fuzz_props::invariants::{ - BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants, - assert_nonce_increment_correctness, assert_replay_rejection, -}; +use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction}; +use fuzz_props::invariants::{BalanceSnapshot, NonceSnapshot, assert_tx_execution_invariants}; use nssa::V03State; fuzz_props::fuzz_entry!(|data: &[u8]| { @@ -75,29 +70,18 @@ fuzz_props::fuzz_entry!(|data: &[u8]| { // Snapshot state before execution for isolation checks. let state_snapshot = state.clone(); let result = tx.execute_check_on_state(&mut state, block_id, timestamp); - let execution_succeeded = result.is_ok(); - // ── Shared invariant checks ─────────────────────────────────────────── - // Asserts: - // • StateIsolationOnFailure — balances unchanged on rejection - // • BalanceConservation — total balance conserved on success - // • FailedTxNonceStability — nonces unchanged on rejection - assert_invariants(&InvariantCtx { - state_before: &state_snapshot, - state_after: &state, - execution_succeeded, + // ── All five protocol invariants ────────────────────────────────────── + // A single call enforces every invariant — no standalone helpers needed: + // On rejection: StateIsolationOnFailure + FailedTxNonceStability + // On success: BalanceConservation + NonceIncrementCorrectness + ReplayRejection + assert_tx_execution_invariants( + &state_snapshot, + &mut state, balances_before, - nonces_before: nonces_before.clone(), - }); - - // ── 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 = signer_account_ids(&applied_tx); - assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state); - assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1); - } + nonces_before, + result, + (block_id + 1, timestamp + 1), + ); } }); diff --git a/fuzz_props/src/invariants.rs b/fuzz_props/src/invariants.rs index b4d92bc..13c6a41 100644 --- a/fuzz_props/src/invariants.rs +++ b/fuzz_props/src/invariants.rs @@ -155,56 +155,47 @@ impl ProtocolInvariant for FailedTxNonceStability { /// A successfully accepted transaction must be rejected when replayed. /// -/// # Note +/// # Enforcement /// -/// This invariant **cannot** be exercised through [`InvariantCtx`] alone because -/// the replay check requires re-applying the `NSSATransaction` that was consumed -/// by `execute_check_on_state`. The `ProtocolInvariant` impl here is a registry -/// placeholder only; it always returns `None`. +/// This invariant **cannot** be enforced through [`InvariantCtx`] because the replay +/// check requires re-applying the `NSSATransaction` that `execute_check_on_state` +/// consumes and returns on `Ok`. It is therefore **not registered** in +/// [`assert_invariants`]; calling `assert_invariants` alone does **not** cover +/// `ReplayRejection`. /// -/// Use the standalone [`assert_replay_rejection`] function instead, which accepts -/// the `NSSATransaction` returned on success and performs the replay inline. +/// Every fuzz target that performs state transitions **must** call the standalone +/// [`assert_replay_rejection`] function after each successful execution: +/// +/// ```rust,ignore +/// if let Ok(applied_tx) = result { +/// assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1); +/// } +/// ``` pub struct ReplayRejection; -impl ProtocolInvariant for ReplayRejection { - fn name(&self) -> &'static str { - "ReplayRejection" - } - - fn check(&self, _ctx: &InvariantCtx<'_>) -> Option { - // ReplayRejection cannot be fully exercised through InvariantCtx alone. - // Use `assert_replay_rejection(applied_tx, state, next_block_id, next_ts)` instead. - None - } -} - /// A successfully applied transaction must increment the nonce of every signer account /// by exactly one. /// -/// # Note +/// # Enforcement /// -/// 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`. +/// This invariant **cannot** be enforced through [`InvariantCtx`] because signer +/// account IDs are private to the `nssa` crate and are consumed by `apply_state_diff` +/// before the caller can observe them. It is therefore **not registered** in +/// [`assert_invariants`]; calling `assert_invariants` alone does **not** cover +/// `NonceIncrementCorrectness`. /// -/// 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. +/// Every fuzz target that performs state transitions **must** call the standalone +/// [`assert_nonce_increment_correctness`] function after each successful execution, +/// passing signer IDs derived from the transaction's witness set: +/// +/// ```rust,ignore +/// if let Ok(applied_tx) = result { +/// let signer_ids = signer_account_ids(&applied_tx); +/// assert_nonce_increment_correctness(&signer_ids, &nonces_before, &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. @@ -308,24 +299,124 @@ pub fn assert_nonce_increment_correctness( } } -// ── Dispatcher ─────────────────────────────────────────────────────────────── +// ── Dispatchers ─────────────────────────────────────────────────────────────── -/// Run every registered [`ProtocolInvariant`] and panic with a structured message -/// on the first violation. +/// Assert the five state-transition invariants for a single `execute_check_on_state` call. +/// +/// Covers the invariants that are defined over one transaction execution attempt — +/// both the failure-isolation properties and the success-outcome correctness properties. +/// All are enforced from a single call; no standalone helpers are needed: +/// +/// | Invariant | Active when | +/// |-----------|-------------| +/// | [`StateIsolationOnFailure`] | `execution_result` is `Err` | +/// | [`FailedTxNonceStability`] | `execution_result` is `Err` | +/// | [`BalanceConservation`] | `execution_result` is `Ok` | +/// | [`NonceIncrementCorrectness`] | `execution_result` is `Ok` | +/// | [`ReplayRejection`] | `execution_result` is `Ok` | +/// +/// # Parameters +/// +/// * `state_before` — clone of the state captured **before** `execute_check_on_state`. +/// * `state_after` — live state **after** execution (mutably borrowed for the replay attempt). +/// * `balances_before` — per-account balance snapshot captured before execution. +/// * `nonces_before` — per-account nonce snapshot captured before execution. +/// * `execution_result` — the `Result` returned by `execute_check_on_state`. +/// * `replay_context` — `(next_block_id, next_timestamp)` used for the mandatory replay attempt. +/// +/// # Usage +/// +/// ```rust,ignore +/// let state_snapshot = state.clone(); +/// let balances_before = BalanceSnapshot( +/// accounts.iter().map(|&(id, _)| (id, state.get_account_by_id(id).balance)).collect(), +/// ); +/// let nonces_before = NonceSnapshot( +/// accounts.iter().map(|&(id, _)| (id, state.get_account_by_id(id).nonce)).collect(), +/// ); +/// let result = tx.execute_check_on_state(&mut state, block_id, timestamp); +/// +/// assert_tx_execution_invariants( +/// &state_snapshot, +/// &mut state, +/// balances_before, +/// nonces_before, +/// result, +/// (block_id + 1, timestamp + 1), +/// ); +/// ``` +pub fn assert_tx_execution_invariants( + state_before: &V03State, + state_after: &mut V03State, + balances_before: BalanceSnapshot, + nonces_before: NonceSnapshot, + execution_result: Result, + replay_context: (u64, u64), +) { + let execution_succeeded = execution_result.is_ok(); + // Clone nonces_before before it is moved into InvariantCtx so the clone + // remains available for assert_nonce_increment_correctness on the success path. + let nonces_for_nonce_check = nonces_before.clone(); + + // ── Three InvariantCtx-based invariants ─────────────────────────────────── + // The shared reborrow of state_after ends when assert_invariants returns (NLL), + // after which state_after is available mutably again for the replay attempt. + assert_invariants(&InvariantCtx { + state_before, + state_after: &*state_after, + execution_succeeded, + balances_before, + nonces_before, + }); + + // ── Two success-only invariants ─────────────────────────────────────────── + if let Ok(applied_tx) = execution_result { + // Derive signer IDs from the witness set. ProgramDeployment has no signers. + let signer_ids: Vec = match &applied_tx { + NSSATransaction::Public(pt) => pt + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::PrivacyPreserving(pt) => pt + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| nssa::AccountId::from(pk)) + .collect(), + NSSATransaction::ProgramDeployment(_) => vec![], + }; + assert_nonce_increment_correctness(&signer_ids, &nonces_for_nonce_check, state_after); + let (next_block_id, next_timestamp) = replay_context; + assert_replay_rejection(applied_tx, state_after, next_block_id, next_timestamp); + } +} + +/// Run the three [`InvariantCtx`]-based invariants and panic on the first violation. /// /// Invariants checked: -/// - [`StateIsolationOnFailure`] — balances unchanged on 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 +/// +/// | Invariant | Condition | +/// |-----------|-----------| +/// | [`StateIsolationOnFailure`] | balances unchanged on rejection | +/// | [`BalanceConservation`] | total balance conserved on success | +/// | [`FailedTxNonceStability`] | nonces unchanged on rejection | +/// +/// # Prefer [`assert_tx_execution_invariants`] for `execute_check_on_state` call sites +/// +/// [`ReplayRejection`] and [`NonceIncrementCorrectness`] are not checked here — they +/// require data unavailable inside [`InvariantCtx`]. Use [`assert_tx_execution_invariants`] +/// instead for any target that calls `execute_check_on_state`; it enforces all five +/// invariants in one call. +/// +/// Reserve `assert_invariants` for contexts where no transaction is available for +/// replay (e.g. pure state-serialization or encoding targets). 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) { diff --git a/fuzz_props/src/tests/invariants.rs b/fuzz_props/src/tests/invariants.rs index 59c6217..178b934 100644 --- a/fuzz_props/src/tests/invariants.rs +++ b/fuzz_props/src/tests/invariants.rs @@ -31,7 +31,7 @@ fn invariant_state_isolation_on_failure_does_not_panic_on_error() { } #[test] -fn invariant_replay_rejection_does_not_panic() { +fn assert_invariants_does_not_panic_on_success_with_empty_state() { let state = make_empty_state(); let ctx = InvariantCtx { state_before: &state,