test(fee-core): exercise the reserve-vs-actual release path

The golden-vector harness always set gas_limit == cycles, so
`reserve - (f_base + tip)` was structurally always 0 and the
reserve/actual split fee_reserve exists for (SPECS.md:132) was never
exercised. Give the usage-example test independent gas_limit=60_000 /
cycles=50_000 (per SPECS.md Annex A "Usage"), add intermediate release
assertions, and add a focused unit test asserting a public fee_reserve
value directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
erhant
2026-08-11 15:50:52 +03:00
co-authored by Claude Fable 5
parent ebfdebc171
commit fa28682b37
2 changed files with 140 additions and 43 deletions
+35
View File
@@ -121,6 +121,11 @@ const fn wadd(a: u128, b: u128) -> u128 {
#[cfg(test)]
mod tests {
#![allow(
clippy::arithmetic_side_effects,
reason = "test code, plain arithmetic is clearer"
)]
use super::*;
use crate::state::FeeState;
@@ -161,4 +166,34 @@ mod tests {
// fee_reserve equals the actual fee for private txs (constants only).
assert_eq!(fee_reserve(&private, &state), private_fee);
}
/// SPECS.md Annex A "Usage" example: `fee_reserve` prices `gas_limit`
/// (60,000), not the metered `cycles` (50,000) that
/// `fee_actual_base` prices — the two differ whenever a transaction
/// doesn't use its full gas limit, and the difference is the amount
/// released back to the payer after settlement (SPECS §Overview
/// "Reserve, execute, settle").
#[test]
fn public_fee_reserve_prices_gas_limit_not_metered_cycles() {
let state = FeeState::genesis().unwrap();
let tx = FeeTxView::Public {
payer: PAYER,
gas_limit: 60_000,
data_bytes: 200,
tip: 100,
max_fee: u128::MAX,
};
let reserve = fee_reserve(&tx, &state);
assert_eq!(reserve, 60_000 * 8 + 200 * 8 + 100);
let actual_base = fee_actual_base(50_000, &tx, &state);
assert_eq!(actual_base, 401_600);
// gas_limit (60_000) != cycles (50_000), so the reserve must exceed
// the actual fee, and by exactly the unused gas priced at
// base_fee_exec.
assert!(reserve > actual_base + 100);
assert_eq!(reserve - (actual_base + 100), (60_000 - 50_000) * 8);
}
}
+105 -43
View File
@@ -65,18 +65,45 @@ impl Lcg {
}
}
const fn pub_tx(cycles: u64, data: u64, tip: u64, payer: PayerId) -> FeeTxView {
FeeTxView::Public {
payer,
gas_limit: cycles,
data_bytes: data,
tip,
max_fee: 1_000_000_000_000_000_000_000_000_000_u128,
/// A `FeeTxView` paired with its scripted metered-cycle count.
///
/// `cycles` is TEST SCAFFOLD ONLY (mirroring SPECS.md Annex A/B's
/// `Transaction.mock_cycles`): it is not a `FeeTxView` field — `fee_core`
/// doesn't execute anything, so a real caller gets `cycles` from the
/// execution outcome, not from the transaction. Keeping it independent of
/// `gas_limit` here (rather than always setting `cycles == gas_limit`) is
/// what lets a test express `cycles < gas_limit`, exercising the
/// reserve-vs-actual release path (SPECS.md:132).
#[derive(Debug, Clone, Copy)]
struct ScenarioTx {
view: FeeTxView,
cycles: u64,
}
/// Builds a public tx with independent `gas_limit` and `cycles`. The driver
/// scenario/fuzz below always call this with `gas_limit == cycles`,
/// matching Annex B's own `pub_tx(cycles, data, tip, payer)` helper (which
/// sets `mock_cycles == gas_limit`); the usage-example test below is the
/// one place that gives them different values, per SPECS.md Annex A
/// "Usage" (`gas_limit=60_000`, `cycles=50_000`).
const fn pub_tx(gas_limit: u64, cycles: u64, data: u64, tip: u64, payer: PayerId) -> ScenarioTx {
ScenarioTx {
view: FeeTxView::Public {
payer,
gas_limit,
data_bytes: data,
tip,
max_fee: 1_000_000_000_000_000_000_000_000_000_u128,
},
cycles,
}
}
const fn prv_tx(payer: PayerId) -> FeeTxView {
FeeTxView::Private { payer }
const fn prv_tx(payer: PayerId) -> ScenarioTx {
ScenarioTx {
view: FeeTxView::Private { payer },
cycles: PRIVATE_VERIFY_GAS,
}
}
fn tip_of(tx: &FeeTxView) -> u128 {
@@ -86,17 +113,6 @@ fn tip_of(tx: &FeeTxView) -> u128 {
}
}
/// The mock executor: in this harness cycles always equal `gas_limit` for
/// public transactions (the scenario below never scripts a lower cycle
/// count), and `PRIVATE_VERIFY_GAS` for private ones — matching Annex B's
/// `tx_exec_gas`/`pub_tx` (which always sets `mock_cycles == gas_limit`).
const fn mock_cycles(tx: &FeeTxView) -> u64 {
match tx {
FeeTxView::Public { gas_limit, .. } => *gas_limit,
FeeTxView::Private { .. } => PRIVATE_VERIFY_GAS,
}
}
/// SPECS.md §Block transition, composed from `fee_core`'s public API.
///
/// Panics (via `unwrap`/`expect`) on any rejection or fault, matching the
@@ -105,9 +121,10 @@ const fn mock_cycles(tx: &FeeTxView) -> u64 {
fn block_transition(
state: &mut FeeState,
balances: &mut HashMap<PayerId, u128>,
txs: &[FeeTxView],
txs: &[ScenarioTx],
) -> Receipt {
validate_static_block(txs, state).expect("scenario only ever builds valid blocks");
let views: Vec<FeeTxView> = txs.iter().map(|tx| tx.view).collect();
validate_static_block(&views, state).expect("scenario only ever builds valid blocks");
let mut revenue_base: u128 = 0;
let mut revenue_tip: u128 = 0;
@@ -115,22 +132,23 @@ fn block_transition(
let mut gas_used_stor: u64 = 0;
for tx in txs {
let reserve = fee_reserve(tx, state);
let bal = balances.entry(tx.payer()).or_insert(0);
let view = &tx.view;
let reserve = fee_reserve(view, state);
let bal = balances.entry(view.payer()).or_insert(0);
assert!(
*bal >= reserve,
"fee debit failed: payer cannot cover reserve"
);
*bal -= reserve;
let cycles = mock_cycles(tx);
let cycles = tx.cycles;
gas_used_exec = accumulate_gas_used(gas_used_exec, cycles, MAX_GAS_EXEC)
.expect("block exceeds MAX_GAS_EXEC");
gas_used_stor += gas_stor(tx);
gas_used_stor += gas_stor(view);
let f_base = fee_actual_base(cycles, tx, state);
let tip = tip_of(tx);
*balances.get_mut(&tx.payer()).unwrap() += reserve - (f_base + tip);
let f_base = fee_actual_base(cycles, view, state);
let tip = tip_of(view);
*balances.get_mut(&view.payer()).unwrap() += reserve - (f_base + tip);
revenue_base += f_base;
revenue_tip += tip;
}
@@ -167,8 +185,12 @@ mod tests {
use super::*;
/// SPECS.md Annex A "Usage" example, printed in the spec: from genesis,
/// one block with a 60,000-gas_limit/200-byte/100-tip public tx
/// (50,000 cycles) plus one private tx.
/// one block with a public tx signed at `gas_limit=60_000`,
/// `data_bytes=200`, `tip=100` but metered at only 50,000 cycles (SPECS
/// §Fee assessment: `fee_reserve` prices `gas_limit`, `fee_actual_base`
/// prices the metered `cycles` — they differ here on purpose, so the
/// unused part of the reservation gets released back to the payer),
/// plus one private tx.
///
/// `print(r)` in the spec prints
/// `BlockReceipt(height=1, revenue_base=5472216, revenue_tip=100, payout=109444)`,
@@ -176,9 +198,34 @@ mod tests {
#[test]
fn usage_example_matches_spec_printed_output() {
let mut state = FeeState::genesis().unwrap();
let mut balances = HashMap::from([(PAYER, 1_000_000_000_000_u128), (PRODUCER, 0)]);
let payer_initial_balance = 1_000_000_000_000_u128;
let mut balances = HashMap::from([(PAYER, payer_initial_balance), (PRODUCER, 0)]);
let txs = [pub_tx(50_000, 200, 100, PAYER), prv_tx(PAYER)];
let public = pub_tx(60_000, 50_000, 200, 100, PAYER);
let private = prv_tx(PAYER);
let public_reserve = fee_reserve(&public.view, &state);
// Genesis fees are 8/8: reserve prices gas_limit=60_000 (+ 200 data
// bytes + the 100 tip); the actual fee below prices the metered
// 50,000 cycles instead. They must differ for this test to exercise
// anything (Important review finding round 1): the released
// reservation is `reserve - actual`, and if `cycles == gas_limit`
// that release is always zero.
assert_eq!(public_reserve, 60_000 * 8 + 200 * 8 + 100);
let public_actual_base = fee_actual_base(50_000, &public.view, &state);
assert_eq!(public_actual_base, 401_600);
let public_released = public_reserve - (public_actual_base + 100);
assert_eq!(public_released, (60_000 - 50_000) * 8);
// The private tx has no reservation to release: fee_reserve and
// fee_actual_base are both computed from the same protocol
// constants, so they're always equal (SPECS §Fee assessment).
let private_reserve = fee_reserve(&private.view, &state);
let private_actual_base = fee_actual_base(private.cycles, &private.view, &state);
assert_eq!(private_reserve, private_actual_base);
assert_eq!(private_actual_base, 5_070_616);
let txs = [public, private];
let receipt = block_transition(&mut state, &mut balances, &txs);
assert_eq!(
@@ -189,6 +236,14 @@ mod tests {
payout: 109_444,
}
);
// Payer's net change = -(public fee_total) - (private fee_total);
// the released part of the public reservation isn't lost, it just
// never left the payer in the first place.
let public_fee_total = public_actual_base + 100;
assert_eq!(
balances[&PAYER],
payer_initial_balance - public_fee_total - private_actual_base
);
assert_eq!(balances[&PAYER], 999_994_527_684);
assert_eq!(balances[&PRODUCER], 109_544);
}
@@ -211,15 +266,19 @@ mod tests {
let mut state = FeeState::genesis().unwrap();
let mut balances = HashMap::from([(PAYER, 10_u128.pow(30)), (PRODUCER, 10_u128.pow(30))]);
let scenario: [Vec<FeeTxView>; 8] = [
// Annex B's own `pub_tx(cycles, data, tip, payer)` helper always
// sets `gas_limit == mock_cycles == cycles`; the scenario below
// does the same by passing the same value twice (`pub_tx`'s
// `gas_limit` and `cycles` parameters).
let scenario: [Vec<ScenarioTx>; 8] = [
vec![],
vec![pub_tx(1_000_000, 40_000, 500, PAYER)],
vec![pub_tx(5_000_000, 100_000, 0, PAYER)],
vec![pub_tx(9_000_000, 900_000, 2_000, PAYER)],
vec![pub_tx(10_000_000, 1_000_000, 0, PAYER)],
vec![pub_tx(1_000_000, 1_000_000, 40_000, 500, PAYER)],
vec![pub_tx(5_000_000, 5_000_000, 100_000, 0, PAYER)],
vec![pub_tx(9_000_000, 9_000_000, 900_000, 2_000, PAYER)],
vec![pub_tx(10_000_000, 10_000_000, 1_000_000, 0, PAYER)],
vec![prv_tx(PAYER), prv_tx(PAYER), prv_tx(PAYER), prv_tx(PAYER)],
vec![
pub_tx(2_500_000, 10_000, 1_000, PAYER),
pub_tx(2_500_000, 2_500_000, 10_000, 1_000, PAYER),
prv_tx(PAYER),
prv_tx(PAYER),
],
@@ -263,19 +322,22 @@ mod tests {
// running state; it does not reset to genesis between them).
let mut rng = Lcg(42);
for _ in 0..10_000 {
let mut txs: Vec<FeeTxView> = Vec::new();
let mut txs: Vec<ScenarioTx> = Vec::new();
for _ in 0..rng.below(4) {
txs.push(prv_tx(PAYER));
}
let mut exec_left = MAX_GAS_EXEC - txs.iter().map(mock_cycles).sum::<u64>();
let mut stor_left = MAX_GAS_STOR - txs.iter().map(gas_stor).sum::<u64>();
let mut exec_left = MAX_GAS_EXEC - txs.iter().map(|tx| tx.cycles).sum::<u64>();
let mut stor_left = MAX_GAS_STOR - txs.iter().map(|tx| gas_stor(&tx.view)).sum::<u64>();
for _ in 0..rng.below(12) {
let c = 40_000 + rng.below(610_000);
let d = 1 + rng.below(5_000);
let t = rng.below(10_000);
let payer = if rng.below(4) == 0 { PRODUCER } else { PAYER };
if c <= exec_left && d <= stor_left {
txs.push(pub_tx(c, d, t, payer));
// Annex B's driver `pub_tx(cycles, data, tip, payer)`
// always sets `gas_limit == cycles` (unlike the
// usage-example test above): pass `c` for both.
txs.push(pub_tx(c, c, d, t, payer));
exec_left -= c;
stor_left -= d;
}