mirror of
https://github.com/logos-blockchain/lez-fuzzing.git
synced 2026-06-07 11:39:30 +00:00
458 lines
19 KiB
Rust
458 lines
19 KiB
Rust
use common::transaction::NSSATransaction;
|
|
use nssa::V03State;
|
|
use nssa_core::account::Nonce;
|
|
|
|
/// Snapshot of public account balances used for conservation checks.
|
|
#[derive(Clone, Debug)]
|
|
pub struct BalanceSnapshot(pub std::collections::HashMap<nssa::AccountId, u128>);
|
|
|
|
impl BalanceSnapshot {
|
|
/// Sum of all recorded account balances.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if the sum overflows `u128`. This indicates a token-inflation bug — i.e.
|
|
/// the protocol somehow created tokens past `u128::MAX` — and would have been silently
|
|
/// masked by `saturating_add`. The generator caps each account balance at
|
|
/// `u128::MAX / 8` so eight accounts never overflow; any overflow here is therefore
|
|
/// a genuine protocol violation, not a fuzzer artefact.
|
|
#[must_use]
|
|
pub fn total(&self) -> u128 {
|
|
self.0
|
|
.values()
|
|
.copied()
|
|
.try_fold(0_u128, u128::checked_add)
|
|
.expect(
|
|
"INVARIANT VIOLATION [BalanceOverflow]: sum of account balances exceeded u128::MAX \
|
|
\u{2014} token-inflation bug that saturating_add would have silently masked",
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
/// `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>;
|
|
}
|
|
|
|
// ── 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 {
|
|
fn name(&self) -> &'static str {
|
|
"StateIsolationOnFailure"
|
|
}
|
|
|
|
fn check(&self, ctx: &InvariantCtx<'_>) -> Option<InvariantViolation> {
|
|
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 {acc_id:?} had \
|
|
{expected_balance} before, {actual_balance} after",
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
/// 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"
|
|
}
|
|
|
|
// Overflow in the balance sum IS the violation; using `?` here would silently return
|
|
// `None` and skip the check, which is worse than the inflation bug it was meant to catch.
|
|
#[expect(
|
|
clippy::unwrap_in_result,
|
|
reason = "overflow panic is the intended signal"
|
|
)]
|
|
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)
|
|
.try_fold(0_u128, u128::checked_add)
|
|
.expect(
|
|
"INVARIANT VIOLATION [BalanceOverflow]: sum of post-execution account balances \
|
|
exceeded u128::MAX \u{2014} token-inflation bug that saturating_add would \
|
|
have silently masked",
|
|
);
|
|
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 {acc_id:?} nonce was \
|
|
{expected_nonce:?} before, {actual_nonce:?} after \
|
|
(griefing attack \u{2014} victim nonce permanently burned on failed tx)",
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
/// A successfully accepted transaction must be rejected when replayed.
|
|
///
|
|
/// # Enforcement
|
|
///
|
|
/// 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`.
|
|
///
|
|
/// 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;
|
|
|
|
/// A successfully applied transaction must increment the nonce of every signer account
|
|
/// by exactly one.
|
|
///
|
|
/// # Enforcement
|
|
///
|
|
/// 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`.
|
|
///
|
|
/// 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;
|
|
|
|
// ── 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 \u{2014} \
|
|
nonce replay not prevented (replay block_id={next_block_id}, \
|
|
replay timestamp={next_timestamp})",
|
|
);
|
|
}
|
|
|
|
/// Assert that every signer account's nonce was incremented by exactly one after a
|
|
/// successfully applied transaction.
|
|
///
|
|
/// Call this immediately after `apply_state_diff` (or `execute_check_on_state`) succeeds,
|
|
/// passing the signer IDs derived from the transaction's witness set, the [`NonceSnapshot`]
|
|
/// captured **before** execution, and the post-execution state.
|
|
///
|
|
/// For a `NSSATransaction::Public(tx)`, derive signer IDs as:
|
|
///
|
|
/// ```rust,ignore
|
|
/// let signer_ids: Vec<nssa::AccountId> = tx
|
|
/// .witness_set()
|
|
/// .signatures_and_public_keys()
|
|
/// .iter()
|
|
/// .map(|(_, pk)| nssa::AccountId::from(pk))
|
|
/// .collect();
|
|
/// ```
|
|
///
|
|
/// For `NSSATransaction::ProgramDeployment`, there are no signers; pass an empty slice.
|
|
///
|
|
/// # Why a standalone function?
|
|
///
|
|
/// `apply_state_diff` consumes the `ValidatedStateDiff`, whose `signer_account_ids` field
|
|
/// is private to the `nssa` crate. The caller must therefore derive signer IDs from the
|
|
/// transaction's witness set before consuming the diff, and thread them into this helper.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust,ignore
|
|
/// let signer_ids = /* derived from tx.witness_set() */;
|
|
/// let nonces_before = NonceSnapshot(
|
|
/// signer_ids.iter().map(|&id| (id, state.get_account_by_id(id).nonce)).collect(),
|
|
/// );
|
|
/// state.apply_state_diff(diff);
|
|
/// assert_nonce_increment_correctness(&signer_ids, &nonces_before, &state);
|
|
/// ```
|
|
pub fn assert_nonce_increment_correctness(
|
|
signer_ids: &[nssa::AccountId],
|
|
nonces_before: &NonceSnapshot,
|
|
state_after: &V03State,
|
|
) {
|
|
for &id in signer_ids {
|
|
let nonce_before = match nonces_before.0.get(&id) {
|
|
Some(n) => *n,
|
|
None => continue, // Account not in snapshot (e.g. newly created); skip.
|
|
};
|
|
let nonce_after = state_after.get_account_by_id(id).nonce;
|
|
let expected = Nonce(
|
|
nonce_before
|
|
.0
|
|
.checked_add(1)
|
|
.expect("nonce overflow \u{2014} signer nonce at u128::MAX"),
|
|
);
|
|
assert_eq!(
|
|
nonce_after, expected,
|
|
"INVARIANT VIOLATION [NonceIncrementCorrectness]: signer account {id:?} nonce \
|
|
not incremented by 1 after successful transaction \
|
|
\u{2014} before={nonce_before:?}, expected={expected:?}, got={nonce_after:?} \
|
|
(apply_state_diff failed to increment nonce exactly once)",
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Dispatchers ───────────────────────────────────────────────────────────────
|
|
|
|
/// 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:
|
|
///
|
|
/// | 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,
|
|
];
|
|
for inv in invariants {
|
|
if let Some(violation) = inv.check(ctx) {
|
|
panic!(
|
|
"INVARIANT VIOLATION [{inv}]: {msg}",
|
|
inv = violation.invariant,
|
|
msg = violation.message,
|
|
);
|
|
}
|
|
}
|
|
}
|