fix: improve API around tx invariants

This commit is contained in:
Roman 2026-05-28 11:40:38 +08:00
parent db477a42d0
commit 88e780b865
No known key found for this signature in database
GPG Key ID: 583BDF43C238B83E
7 changed files with 230 additions and 171 deletions

View File

@ -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.
---

View File

@ -478,37 +478,50 @@ automates steps 25 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`):

View File

@ -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 ──────────────────────────────────────────

View File

@ -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),
);
});

View File

@ -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),
);
}
});

View File

@ -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<InvariantViolation> {
// 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<InvariantViolation> {
// 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<E>(
state_before: &V03State,
state_after: &mut V03State,
balances_before: BalanceSnapshot,
nonces_before: NonceSnapshot,
execution_result: Result<NSSATransaction, E>,
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<nssa::AccountId> = 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) {

View File

@ -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,