commit d217aad58fb548266e356a694c68c3d9c0e00fd3
parent cbd65e020b6c6bce4e1be355c19117ce640bb912
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 19 Aug 2026 19:52:29 +0200
Add mini-validator supply oracle
Diffstat:
2 files changed, 125 insertions(+), 33 deletions(-)
diff --git a/PLAN.md b/PLAN.md
@@ -30,7 +30,7 @@ Acceptance:
Status: started with a test-only block precheck oracle for height, parent hash,
block hash, reward, VDF rounds, timestamps, block limits, burn presence, fee
-policy, and ticket finalizer proof selection.
+policy, ticket finalizer proof selection, and snapshot supply accounting.
Goal: compare the production validator against a small, deliberately separate
oracle for consensus-critical facts.
diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs
@@ -495,6 +495,12 @@ enum MiniBlockVerdict {
FinalizerTicket,
}
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum MiniSupplyVerdict {
+ Balanced,
+ Mismatch,
+}
+
fn mini_block_verdict(ledger: &Ledger, block: &Block, now_ms: u64) -> MiniBlockVerdict {
let parent = ledger.tip();
if block.height != parent.height.saturating_add(1) {
@@ -675,6 +681,40 @@ fn assert_mini_validator_agrees(
);
}
+fn mini_expected_supply(snapshot: &ChainSnapshot) -> Option<Amount> {
+ let mut supply = snapshot
+ .genesis_allocations
+ .values()
+ .try_fold(0_u64, |total, amount| total.checked_add(*amount))?;
+
+ for block in &snapshot.blocks {
+ supply = supply.checked_add(block.reward)?;
+ for transaction in &block.transactions {
+ match transaction {
+ Transaction::Transfer { fee, .. } => {
+ supply = supply.checked_sub(*fee)?;
+ }
+ Transaction::Burn { amount, fee, .. } => {
+ supply = supply.checked_sub(amount.checked_add(*fee)?)?;
+ }
+ Transaction::Mine { .. } => {
+ supply = supply.checked_add(transaction.amount())?;
+ }
+ }
+ }
+ }
+
+ Some(supply)
+}
+
+fn mini_supply_verdict(ledger: &Ledger, snapshot: &ChainSnapshot) -> MiniSupplyVerdict {
+ if mini_expected_supply(snapshot) == Some(live_supply(ledger)) {
+ MiniSupplyVerdict::Balanced
+ } else {
+ MiniSupplyVerdict::Mismatch
+ }
+}
+
fn harness_for_percent(seed: u64, burn_percent: u8) -> Harness {
Harness::new(seed, burn_percent, 10, AdversaryStrategy::Honest)
}
@@ -699,38 +739,7 @@ fn live_supply(ledger: &Ledger) -> Amount {
}
fn expected_supply(snapshot: &ChainSnapshot) -> Amount {
- let mut supply = snapshot
- .genesis_allocations
- .values()
- .try_fold(0_u64, |total, amount| total.checked_add(*amount))
- .expect("test genesis supply should not overflow");
-
- for block in &snapshot.blocks {
- supply = supply
- .checked_add(block.reward)
- .expect("test reward supply should not overflow");
- for transaction in &block.transactions {
- match transaction {
- Transaction::Transfer { fee, .. } => {
- supply = supply
- .checked_sub(*fee)
- .expect("transfer fee should be backed by supply");
- }
- Transaction::Burn { amount, fee, .. } => {
- supply = supply
- .checked_sub(amount.checked_add(*fee).expect("burn debit overflow"))
- .expect("burn should be backed by supply");
- }
- Transaction::Mine { .. } => {
- supply = supply
- .checked_add(transaction.amount())
- .expect("mine reward supply should not overflow");
- }
- }
- }
- }
-
- supply
+ mini_expected_supply(snapshot).expect("test snapshot supply accounting should not overflow")
}
fn assert_supply_invariant(ledger: &Ledger) {
@@ -1641,6 +1650,89 @@ fn supply_invariant_holds_for_mixed_burns_fees_and_pow_mine_actions() {
}
#[test]
+fn mini_supply_oracle_detects_accounting_mutations() {
+ let mut harness = harness_for_percent(31, 25);
+ let leader = harness.next_rank(0);
+ let finalizer = harness.wallet(&leader.owner).clone();
+ let actors = harness
+ .honest
+ .iter()
+ .filter(|wallet| wallet.address() != finalizer.address())
+ .cloned()
+ .collect::<Vec<_>>();
+ assert!(actors.len() >= 4, "test fixture needs non-finalizer actors");
+
+ let transfer = harness
+ .ledger
+ .build_transfer(&actors[0], actors[1].address(), 11, 2)
+ .unwrap();
+ harness.ledger.submit_transaction(transfer).unwrap();
+ let burn = harness.ledger.build_burn(&actors[2], 7, 3).unwrap();
+ harness.ledger.submit_transaction(burn).unwrap();
+ let mine = harness.ledger.build_mine(actors[3].address()).unwrap();
+ harness.ledger.submit_transaction(mine).unwrap();
+ harness.submit_anchor_burn(&finalizer);
+
+ let block = harness.finish_ticket_block_from_pending(0, Vec::new());
+ harness
+ .ledger
+ .apply_block_at(block, NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS))
+ .unwrap();
+ let snapshot = harness.ledger.snapshot();
+ assert_eq!(
+ mini_supply_verdict(&harness.ledger, &snapshot),
+ MiniSupplyVerdict::Balanced
+ );
+
+ let mut reward_inflation = snapshot.clone();
+ reward_inflation
+ .blocks
+ .last_mut()
+ .expect("snapshot has a mined block")
+ .reward += 1;
+ assert_eq!(
+ mini_supply_verdict(&harness.ledger, &reward_inflation),
+ MiniSupplyVerdict::Mismatch
+ );
+
+ let mut fee_erasure = snapshot.clone();
+ let fee = fee_erasure
+ .blocks
+ .last_mut()
+ .expect("snapshot has a mined block")
+ .transactions
+ .iter_mut()
+ .find_map(|transaction| match transaction {
+ Transaction::Transfer { fee, .. } if *fee > 0 => Some(fee),
+ _ => None,
+ })
+ .expect("mixed block has a fee-paying transfer");
+ *fee = 0;
+ assert_eq!(
+ mini_supply_verdict(&harness.ledger, &fee_erasure),
+ MiniSupplyVerdict::Mismatch
+ );
+
+ let mut burn_erasure = snapshot;
+ let amount = burn_erasure
+ .blocks
+ .last_mut()
+ .expect("snapshot has a mined block")
+ .transactions
+ .iter_mut()
+ .find_map(|transaction| match transaction {
+ Transaction::Burn { amount, .. } if *amount > 0 => Some(amount),
+ _ => None,
+ })
+ .expect("mixed block has a positive burn");
+ *amount = 0;
+ assert_eq!(
+ mini_supply_verdict(&harness.ledger, &burn_erasure),
+ MiniSupplyVerdict::Mismatch
+ );
+}
+
+#[test]
fn supply_invariant_tracks_reorg_to_better_fork() {
let base = harness_for_percent(28, 25);
let snapshot = base.ledger.snapshot();