mirror of
https://github.com/logos-blockchain/lez-fuzzing.git
synced 2026-06-07 03:29:26 +00:00
fix: move reusable invariants into shared module
This commit is contained in:
parent
173430a8b5
commit
720cce4efc
@ -17,23 +17,28 @@
|
||||
//!
|
||||
//! # Invariants
|
||||
//!
|
||||
//! The following per-transaction invariants are checked via the shared framework
|
||||
//! ([`fuzz_props::invariants::assert_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).
|
||||
//!
|
||||
//! The following multi-block aggregate invariant is checked **after** the loop:
|
||||
//!
|
||||
//! 1. **LongRangeBalanceConservation** — the total balance of the original genesis
|
||||
//! accounts is the same at the end of all N blocks as at the beginning. Failed
|
||||
//! transactions and successful transfers between genesis accounts both preserve
|
||||
//! the total; only mint/burn bugs or token-inflation bugs would break it.
|
||||
//!
|
||||
//! 2. **FailedTxNonceStability** — when `execute_check_on_state` returns `Err` for
|
||||
//! a transaction, every genesis account nonce must be identical to what it was
|
||||
//! before that transaction was attempted. Nonce mutations on failed txs would
|
||||
//! allow a griefing attack that permanently burns the victim's account.
|
||||
//!
|
||||
//! 3. **PerBlockReplayRejection** — every transaction that succeeded in block B is
|
||||
//! rejected when replayed in block B+1. This is the same per-tx invariant as
|
||||
//! in `fuzz_state_transition` but exercised cumulatively across a longer
|
||||
//! sequence so that interactions between successive nonce increments are tested.
|
||||
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
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,
|
||||
};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use nssa::V03State;
|
||||
|
||||
@ -52,7 +57,7 @@ fuzz_target!(|data: &[u8]| {
|
||||
|
||||
let mut state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0);
|
||||
|
||||
// Record starting balances for the long-range conservation check (invariant 1).
|
||||
// Record starting balances for the long-range conservation check.
|
||||
let starting_total: u128 = init_accs
|
||||
.iter()
|
||||
.map(|&(id, _)| state.get_account_by_id(id).balance)
|
||||
@ -78,57 +83,51 @@ fuzz_target!(|data: &[u8]| {
|
||||
let block_id: u64 = 1 + u64::from(i);
|
||||
let timestamp: u64 = u64::from(i) * 1000;
|
||||
|
||||
// Snapshot nonces before this transaction for the nonce-stability check.
|
||||
let nonces_before: Vec<nssa_core::account::Nonce> = init_accs
|
||||
.iter()
|
||||
.map(|&(id, _)| state.get_account_by_id(id).nonce)
|
||||
.collect();
|
||||
// Build per-transaction snapshots for the shared invariant framework.
|
||||
let balances_before = BalanceSnapshot(
|
||||
init_accs
|
||||
.iter()
|
||||
.map(|&(id, _)| (id, state.get_account_by_id(id).balance))
|
||||
.collect(),
|
||||
);
|
||||
let nonces_before = NonceSnapshot(
|
||||
init_accs
|
||||
.iter()
|
||||
.map(|&(id, _)| (id, state.get_account_by_id(id).nonce))
|
||||
.collect(),
|
||||
);
|
||||
let state_snapshot = state.clone();
|
||||
|
||||
let result = tx.execute_check_on_state(&mut state, block_id, timestamp);
|
||||
let execution_succeeded = result.is_ok();
|
||||
|
||||
match result {
|
||||
Err(_) => {
|
||||
// ── Invariant 2: FailedTxNonceStability ──────────────────────
|
||||
for (k, &(acc_id, _)) in init_accs.iter().enumerate() {
|
||||
let nonce_after = state.get_account_by_id(acc_id).nonce;
|
||||
assert_eq!(
|
||||
nonces_before[k],
|
||||
nonce_after,
|
||||
"INVARIANT VIOLATION [FailedTxNonceStability]: \
|
||||
nonce changed for account {:?} after a REJECTED transaction \
|
||||
in block {} (nonce before={:?}, nonce after={:?})",
|
||||
acc_id,
|
||||
block_id,
|
||||
nonces_before[k],
|
||||
nonce_after,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(applied_tx) => {
|
||||
// ── Invariant 3: PerBlockReplayRejection ─────────────────────
|
||||
let replay_result = applied_tx.execute_check_on_state(
|
||||
&mut state,
|
||||
block_id + 1,
|
||||
timestamp + 1,
|
||||
);
|
||||
assert!(
|
||||
replay_result.is_err(),
|
||||
"INVARIANT VIOLATION [PerBlockReplayRejection]: \
|
||||
a transaction accepted in block {} was accepted again in block {} \
|
||||
— nonce was not consumed",
|
||||
block_id,
|
||||
block_id + 1,
|
||||
);
|
||||
}
|
||||
// ── 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,
|
||||
balances_before,
|
||||
nonces_before,
|
||||
});
|
||||
|
||||
// ── 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).
|
||||
if let Ok(applied_tx) = result {
|
||||
assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant 1: LongRangeBalanceConservation ─────────────────────────────
|
||||
// ── LongRangeBalanceConservation ──────────────────────────────────────────
|
||||
// After all N blocks, the total balance of genesis accounts must equal the
|
||||
// starting total. Successful transfers between genesis accounts cancel out;
|
||||
// failed transactions must not mutate balances (covered by
|
||||
// fuzz_state_transition's StateIsolationOnFailure, but we also verify the
|
||||
// cumulative result here to catch interactions).
|
||||
// failed transactions must not mutate balances (covered per-tx by
|
||||
// StateIsolationOnFailure above, verified cumulatively here to catch
|
||||
// interactions across the full sequence).
|
||||
let ending_total: u128 = init_accs
|
||||
.iter()
|
||||
.map(|&(id, _)| state.get_account_by_id(id).balance)
|
||||
|
||||
@ -11,9 +11,22 @@
|
||||
//! testnet genesis) so that nonce-dependent edge cases — e.g. replay prevention
|
||||
//! at nonce 0, nonce `u128::MAX`, or when the sender has zero balance — are
|
||||
//! reachable by the fuzzer.
|
||||
//!
|
||||
//! # Invariants checked
|
||||
//!
|
||||
//! The shared framework ([`assert_invariants`]) enforces per-transaction:
|
||||
//! - **StateIsolationOnFailure** — balances unchanged on rejection
|
||||
//! - **BalanceConservation** — total balance conserved on success
|
||||
//! - **FailedTxNonceStability** — nonces unchanged on rejection
|
||||
//!
|
||||
//! The dedicated [`assert_replay_rejection`] function enforces:
|
||||
//! - **ReplayRejection** — accepted tx rejected on replay
|
||||
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
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,
|
||||
};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use nssa::V03State;
|
||||
|
||||
@ -45,15 +58,42 @@ fuzz_target!(|data: &[u8]| {
|
||||
// Stateless gate: skip structurally malformed transactions.
|
||||
let Ok(tx) = tx.transaction_stateless_check() else { return; };
|
||||
|
||||
// Build snapshots before execution.
|
||||
let balances_before = BalanceSnapshot(
|
||||
init_accs
|
||||
.iter()
|
||||
.map(|&(id, _)| (id, state.get_account_by_id(id).balance))
|
||||
.collect(),
|
||||
);
|
||||
let nonces_before = NonceSnapshot(
|
||||
init_accs
|
||||
.iter()
|
||||
.map(|&(id, _)| (id, state.get_account_by_id(id).nonce))
|
||||
.collect(),
|
||||
);
|
||||
let state_snapshot = state.clone();
|
||||
|
||||
// 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();
|
||||
|
||||
if let Ok(tx) = result {
|
||||
// tx is returned on success; try applying the identical transaction again.
|
||||
let result2 = tx.execute_check_on_state(&mut state, 2, 1);
|
||||
assert!(
|
||||
result2.is_err(),
|
||||
"INVARIANT VIOLATION: transaction accepted a second time — nonce replay not prevented"
|
||||
);
|
||||
// ── 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,
|
||||
balances_before,
|
||||
nonces_before,
|
||||
});
|
||||
|
||||
// ── ReplayRejection ───────────────────────────────────────────────────────
|
||||
// tx is returned on success; assert that applying it again in the next block
|
||||
// is rejected (nonce was consumed on first acceptance).
|
||||
if let Ok(applied_tx) = result {
|
||||
assert_replay_rejection(applied_tx, &mut state, 2, 1);
|
||||
}
|
||||
});
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
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,
|
||||
};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use nssa::V03State;
|
||||
|
||||
@ -45,8 +48,20 @@ fuzz_target!(|data: &[u8]| {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Clone state before to detect state leakage on failure
|
||||
let state_snapshot = state.clone();
|
||||
// Build snapshots from the live state before this transaction so that the
|
||||
// shared invariant framework can check isolation and conservation.
|
||||
let balances_before = BalanceSnapshot(
|
||||
init_accs
|
||||
.iter()
|
||||
.map(|&(id, _)| (id, state.get_account_by_id(id).balance))
|
||||
.collect(),
|
||||
);
|
||||
let nonces_before = NonceSnapshot(
|
||||
init_accs
|
||||
.iter()
|
||||
.map(|&(id, _)| (id, state.get_account_by_id(id).nonce))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
// Advance block_id and timestamp each iteration so the state machine
|
||||
// sees a realistic monotonically-increasing context. Using the same
|
||||
@ -54,61 +69,30 @@ fuzz_target!(|data: &[u8]| {
|
||||
// when the block context changes across a multi-transaction sequence.
|
||||
let block_id: u64 = 1 + u64::from(i);
|
||||
let timestamp: u64 = u64::from(i);
|
||||
|
||||
// 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();
|
||||
|
||||
match result {
|
||||
Err(_) => {
|
||||
// INVARIANT: StateIsolationOnFailure — a rejected tx must leave
|
||||
// public account balances unchanged.
|
||||
for &(acc_id, _) in &init_accs {
|
||||
let bal_before = state_snapshot.get_account_by_id(acc_id).balance;
|
||||
let bal_after = state.get_account_by_id(acc_id).balance;
|
||||
assert_eq!(
|
||||
bal_before, bal_after,
|
||||
"INVARIANT VIOLATION [StateIsolationOnFailure]: balance changed \
|
||||
despite tx rejection for account {:?}",
|
||||
acc_id
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(applied_tx) => {
|
||||
// INVARIANT: BalanceConservation — total balance of known accounts
|
||||
// must be conserved on success. Catches double-credit and
|
||||
// token-inflation bugs — two transfer paths that each credit the
|
||||
// recipient without debiting the sender would inflate the total, but
|
||||
// neither the rejection check nor any other per-account check catches
|
||||
// it unless we compare the aggregate.
|
||||
let total_before: u128 = init_accs
|
||||
.iter()
|
||||
.map(|&(acc_id, _)| state_snapshot.get_account_by_id(acc_id).balance)
|
||||
.fold(0u128, u128::saturating_add);
|
||||
let total_after: u128 = init_accs
|
||||
.iter()
|
||||
.map(|&(acc_id, _)| state.get_account_by_id(acc_id).balance)
|
||||
.fold(0u128, u128::saturating_add);
|
||||
assert_eq!(
|
||||
total_before,
|
||||
total_after,
|
||||
"INVARIANT VIOLATION [BalanceConservation]: total balance of genesis \
|
||||
accounts changed after successful transaction (double-credit / \
|
||||
token-inflation bug)",
|
||||
);
|
||||
// ── 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,
|
||||
balances_before,
|
||||
nonces_before,
|
||||
});
|
||||
|
||||
// INVARIANT: ReplayRejection — the nonce is consumed on first
|
||||
// acceptance; replaying the identical transaction in the very next
|
||||
// block must be rejected.
|
||||
// `execute_check_on_state` returns the `ValidatedTransaction` on
|
||||
// success, so we can feed it back without re-validating.
|
||||
let replay_result =
|
||||
applied_tx.execute_check_on_state(&mut state, block_id + 1, timestamp + 1);
|
||||
assert!(
|
||||
replay_result.is_err(),
|
||||
"INVARIANT VIOLATION [ReplayRejection]: transaction accepted twice — \
|
||||
nonce replay not prevented (first block_id={block_id}, replay \
|
||||
block_id={})",
|
||||
block_id + 1,
|
||||
);
|
||||
}
|
||||
// ── ReplayRejection ───────────────────────────────────────────────────
|
||||
// execute_check_on_state returns the NSSATransaction on Ok; replay it
|
||||
// immediately in the next block and assert it is rejected.
|
||||
if let Ok(applied_tx) = result {
|
||||
assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
use common::transaction::NSSATransaction;
|
||||
use nssa::{V03State, error::NssaError};
|
||||
use nssa::V03State;
|
||||
use nssa_core::account::Nonce;
|
||||
|
||||
/// Snapshot of public account balances used for conservation checks.
|
||||
#[derive(Clone, Debug)]
|
||||
@ -12,21 +13,39 @@ impl BalanceSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared context threaded through every invariant check.
|
||||
/// Snapshot of account nonces captured before a transaction is applied.
|
||||
///
|
||||
/// Mirrors [`BalanceSnapshot`]: one entry per account that should remain
|
||||
/// stable on a failed transaction (`FailedTxNonceStability` invariant).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NonceSnapshot(pub std::collections::HashMap<nssa::AccountId, Nonce>);
|
||||
|
||||
/// Shared context threaded through every per-transaction invariant check.
|
||||
///
|
||||
/// Build this **after** calling `execute_check_on_state` so that `state_after`
|
||||
/// reflects the post-execution state and `execution_succeeded` matches the
|
||||
/// actual outcome.
|
||||
pub struct InvariantCtx<'a> {
|
||||
/// State snapshot captured **before** applying the transaction.
|
||||
pub state_before: &'a V03State,
|
||||
/// Live state **after** applying (or attempting) the transaction.
|
||||
pub state_after: &'a V03State,
|
||||
pub tx: &'a NSSATransaction,
|
||||
pub result: &'a Result<(), NssaError>,
|
||||
/// `true` when `execute_check_on_state` returned `Ok`, `false` on `Err`.
|
||||
pub execution_succeeded: bool,
|
||||
/// Per-account balances captured before the transaction.
|
||||
pub balances_before: BalanceSnapshot,
|
||||
/// Per-account nonces captured before the transaction.
|
||||
pub nonces_before: NonceSnapshot,
|
||||
}
|
||||
|
||||
/// A named invariant violation with an actionable diagnostic message.
|
||||
#[derive(Debug)]
|
||||
pub struct InvariantViolation {
|
||||
pub invariant: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// A protocol rule that can be checked against a single transaction's context.
|
||||
pub trait ProtocolInvariant {
|
||||
fn name(&self) -> &'static str;
|
||||
fn check(&self, ctx: &InvariantCtx<'_>) -> Option<InvariantViolation>;
|
||||
@ -35,6 +54,9 @@ pub trait ProtocolInvariant {
|
||||
// ── Concrete invariants ───────────────────────────────────────────────────────
|
||||
|
||||
/// Sum of all public account balances must never change when a transaction is rejected.
|
||||
///
|
||||
/// A balance mutation on failure means the protocol leaks state on error paths —
|
||||
/// e.g., a debit that is not rolled back after a later validation failure.
|
||||
pub struct StateIsolationOnFailure;
|
||||
|
||||
impl ProtocolInvariant for StateIsolationOnFailure {
|
||||
@ -43,14 +65,15 @@ impl ProtocolInvariant for StateIsolationOnFailure {
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &InvariantCtx<'_>) -> Option<InvariantViolation> {
|
||||
if ctx.result.is_err() {
|
||||
if !ctx.execution_succeeded {
|
||||
for (acc_id, &expected_balance) in &ctx.balances_before.0 {
|
||||
let actual_balance = ctx.state_after.get_account_by_id(*acc_id).balance;
|
||||
if actual_balance != expected_balance {
|
||||
return Some(InvariantViolation {
|
||||
invariant: self.name(),
|
||||
message: format!(
|
||||
"balance changed despite tx rejection: account {:?} had {expected_balance} before, {actual_balance} after",
|
||||
"balance changed despite tx rejection: account {:?} had \
|
||||
{expected_balance} before, {actual_balance} after",
|
||||
acc_id,
|
||||
),
|
||||
});
|
||||
@ -61,7 +84,88 @@ impl ProtocolInvariant for StateIsolationOnFailure {
|
||||
}
|
||||
}
|
||||
|
||||
/// Total balance of all known accounts must be conserved when a transaction succeeds.
|
||||
///
|
||||
/// Catches double-credit and token-inflation bugs: a transfer path that credits the
|
||||
/// recipient without debiting the sender would inflate the sum of all known balances.
|
||||
/// The check uses the same account set captured in [`BalanceSnapshot`] so that new
|
||||
/// accounts silently created by execution are NOT included (see known limitation
|
||||
/// in `fuzz_validate_execute_consistency`).
|
||||
pub struct BalanceConservation;
|
||||
|
||||
impl ProtocolInvariant for BalanceConservation {
|
||||
fn name(&self) -> &'static str {
|
||||
"BalanceConservation"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &InvariantCtx<'_>) -> Option<InvariantViolation> {
|
||||
if ctx.execution_succeeded {
|
||||
let total_before = ctx.balances_before.total();
|
||||
let total_after: u128 = ctx
|
||||
.balances_before
|
||||
.0
|
||||
.keys()
|
||||
.map(|&id| ctx.state_after.get_account_by_id(id).balance)
|
||||
.fold(0u128, u128::saturating_add);
|
||||
if total_before != total_after {
|
||||
return Some(InvariantViolation {
|
||||
invariant: self.name(),
|
||||
message: format!(
|
||||
"total balance of known accounts changed after successful transaction: \
|
||||
before={total_before}, after={total_after} \
|
||||
(possible double-credit or token-inflation bug)",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// When a transaction is rejected, every account's nonce must remain unchanged.
|
||||
///
|
||||
/// A nonce mutation on a failed transaction constitutes a griefing attack: an
|
||||
/// adversary could force arbitrary transactions to fail and permanently burn the
|
||||
/// victim's nonce, rendering their account unusable.
|
||||
pub struct FailedTxNonceStability;
|
||||
|
||||
impl ProtocolInvariant for FailedTxNonceStability {
|
||||
fn name(&self) -> &'static str {
|
||||
"FailedTxNonceStability"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &InvariantCtx<'_>) -> Option<InvariantViolation> {
|
||||
if !ctx.execution_succeeded {
|
||||
for (&acc_id, expected_nonce) in &ctx.nonces_before.0 {
|
||||
let actual_nonce = ctx.state_after.get_account_by_id(acc_id).nonce;
|
||||
if actual_nonce != *expected_nonce {
|
||||
return Some(InvariantViolation {
|
||||
invariant: self.name(),
|
||||
message: format!(
|
||||
"nonce changed despite tx rejection: account {:?} nonce was \
|
||||
{:?} before, {:?} after \
|
||||
(griefing attack — victim nonce permanently burned on failed tx)",
|
||||
acc_id, expected_nonce, actual_nonce,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A successfully accepted transaction must be rejected when replayed.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// 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`.
|
||||
///
|
||||
/// Use the standalone [`assert_replay_rejection`] function instead, which accepts
|
||||
/// the `NSSATransaction` returned on success and performs the replay inline.
|
||||
pub struct ReplayRejection;
|
||||
|
||||
impl ProtocolInvariant for ReplayRejection {
|
||||
@ -70,25 +174,69 @@ impl ProtocolInvariant for ReplayRejection {
|
||||
}
|
||||
|
||||
fn check(&self, _ctx: &InvariantCtx<'_>) -> Option<InvariantViolation> {
|
||||
// ReplayRejection cannot be fully exercised through InvariantCtx alone,
|
||||
// because the check requires *re-applying* the same ValidatedTransaction
|
||||
// to the post-execution state. InvariantCtx holds `tx: &NSSATransaction`,
|
||||
// and `transaction_stateless_check()` consumes `self`, so re-validation
|
||||
// from a shared reference is not possible.
|
||||
//
|
||||
// The invariant is enforced in two complementary ways instead:
|
||||
// 1. `fuzz_state_transition.rs` — captures the `ValidatedTransaction`
|
||||
// returned on `Ok` by `execute_check_on_state` and immediately
|
||||
// re-applies it at block_id+1; asserts the replay is rejected.
|
||||
// 2. The proptest suite in this module (`replay_rejection_proptest`)
|
||||
// exercises the same property with structured, reproducible inputs.
|
||||
// ReplayRejection cannot be fully exercised through InvariantCtx alone.
|
||||
// Use `assert_replay_rejection(applied_tx, state, next_block_id, next_ts)` instead.
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Run every registered invariant and panic with a structured message on first violation.
|
||||
// ── Standalone helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Assert that a successfully-applied transaction is **rejected** when replayed.
|
||||
///
|
||||
/// Call this immediately after `execute_check_on_state` returns `Ok(applied_tx)`,
|
||||
/// passing `applied_tx` as the first argument. The transaction is re-applied to
|
||||
/// `state` at `next_block_id` / `next_timestamp`; if it is accepted a second time
|
||||
/// the function panics with a structured `INVARIANT VIOLATION [ReplayRejection]`
|
||||
/// message.
|
||||
///
|
||||
/// # Why a standalone function?
|
||||
///
|
||||
/// `execute_check_on_state` consumes the `NSSATransaction` and returns it on `Ok`,
|
||||
/// so the transaction is not available as a shared reference inside [`InvariantCtx`].
|
||||
/// This function accepts ownership of the returned transaction and performs the
|
||||
/// replay in-place.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let result = tx.execute_check_on_state(&mut state, block_id, timestamp);
|
||||
/// if let Ok(applied_tx) = result {
|
||||
/// assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn assert_replay_rejection(
|
||||
applied_tx: NSSATransaction,
|
||||
state: &mut V03State,
|
||||
next_block_id: u64,
|
||||
next_timestamp: u64,
|
||||
) {
|
||||
let replay = applied_tx.execute_check_on_state(state, next_block_id, next_timestamp);
|
||||
assert!(
|
||||
replay.is_err(),
|
||||
"INVARIANT VIOLATION [ReplayRejection]: transaction accepted a second time — \
|
||||
nonce replay not prevented (replay block_id={next_block_id}, \
|
||||
replay timestamp={next_timestamp})",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dispatcher ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Run every registered [`ProtocolInvariant`] and panic with a structured message
|
||||
/// 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
|
||||
pub fn assert_invariants(ctx: &InvariantCtx<'_>) {
|
||||
let invariants: &[&dyn ProtocolInvariant] = &[&StateIsolationOnFailure, &ReplayRejection];
|
||||
let invariants: &[&dyn ProtocolInvariant] = &[
|
||||
&StateIsolationOnFailure,
|
||||
&BalanceConservation,
|
||||
&FailedTxNonceStability,
|
||||
&ReplayRejection,
|
||||
];
|
||||
for inv in invariants {
|
||||
if let Some(violation) = inv.check(ctx) {
|
||||
panic!(
|
||||
@ -100,13 +248,14 @@ 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(&[], &[])
|
||||
V03State::new_with_genesis_accounts(&[], vec![], 0)
|
||||
}
|
||||
|
||||
@ -114,36 +263,89 @@ mod tests {
|
||||
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 tx = common::test_utils::produce_dummy_empty_transaction();
|
||||
let result: Result<(), NssaError> = Err(NssaError::InvalidInput("test".to_owned()));
|
||||
let ctx = InvariantCtx {
|
||||
state_before: &state,
|
||||
state_after: &state,
|
||||
tx: &tx,
|
||||
result: &result,
|
||||
execution_succeeded: false,
|
||||
balances_before: make_empty_snapshot(),
|
||||
nonces_before: make_empty_nonce_snapshot(),
|
||||
};
|
||||
// Should not panic — invariant check is a placeholder
|
||||
assert_invariants(&ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invariant_replay_rejection_does_not_panic() {
|
||||
let state = make_empty_state();
|
||||
let tx = common::test_utils::produce_dummy_empty_transaction();
|
||||
let result: Result<(), NssaError> = Ok(());
|
||||
let ctx = InvariantCtx {
|
||||
state_before: &state,
|
||||
state_after: &state,
|
||||
tx: &tx,
|
||||
result: &result,
|
||||
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([1u8; 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, 100u128);
|
||||
|
||||
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 failed_tx_nonce_stability_catches_nonce_mutation() {
|
||||
let acc_id = nssa::AccountId::new([2u8; 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, 100u128);
|
||||
|
||||
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 ───────────────────────────────────────────
|
||||
@ -192,14 +394,10 @@ mod replay_proptest {
|
||||
let first_result = validated_tx.execute_check_on_state(&mut state, 1, 0);
|
||||
|
||||
if let Ok(validated_tx) = first_result {
|
||||
// The same ValidatedTransaction is returned on Ok; replay it
|
||||
// immediately in the next block.
|
||||
let second_result = validated_tx.execute_check_on_state(&mut state, 2, 1);
|
||||
prop_assert!(
|
||||
second_result.is_err(),
|
||||
"ReplayRejection violated: transaction accepted a second time (nonce \
|
||||
replay not prevented)"
|
||||
);
|
||||
// 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(validated_tx, &mut state, 2, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user