iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit cbd65e020b6c6bce4e1be355c19117ce640bb912
parent 4b6ed9dd2152dc9ad91cc40be6fc1469dfc498ad
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 19 Aug 2026 19:49:10 +0200

Add mini-validator block precheck oracle

Diffstat:
MPLAN.md | 6++++++
Msrc/domain/adversarial_tests.rs | 358++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 360 insertions(+), 4 deletions(-)

diff --git a/PLAN.md b/PLAN.md @@ -5,6 +5,8 @@ candidate chain is treated as promotable to mainnet. ## 1. Fuzzing Harnesses +Status: initial repository harnesses and deployment smoke runs are in place. + Goal: continuously throw malformed and semi-valid input at every external data boundary and every compact persistence format. @@ -26,6 +28,10 @@ Acceptance: ## 2. Independent Mini-Validator +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. + 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 @@ -3,14 +3,14 @@ use std::collections::{BTreeMap, BTreeSet}; use proptest::prelude::*; use proptest::test_runner::Config; -use super::ledger_ops::block_reward; +use super::ledger_ops::{block_reward, verify_address_signature}; use super::reveal::{BurnBundlePayload, burn_bundle_slot_mask}; use super::ticket::ticket_block_min_timestamp; use super::{ Amount, BURN_LINEAGE_MATURITY_HEIGHTS, Block, BurnBundle, BurnBundleSignature, - BurnCommitteeMember, BurnLeaderRank, ChainSnapshot, GenesisBurn, Ledger, MAX_BLOCK_BYTES, - MICRO_IUNA, MaskedBurn, Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, - run_vdf, + BurnCommitteeMember, BurnLeaderRank, ChainSnapshot, FinalizerMode, GenesisBurn, + LeaderProofPayload, Ledger, MAX_BLOCK_BYTES, MICRO_IUNA, MaskedBurn, Transaction, + TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, }; const NOW_MS: u64 = 10_000_000_000; @@ -478,6 +478,203 @@ fn assert_rejects(mut ledger: Ledger, block: Block, label: &str) { ); } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum MiniBlockVerdict { + Accept, + Height, + Parent, + Hash, + Reward, + VdfRounds, + Timestamp, + FutureTimestamp, + TooManyTransactions, + TooLarge, + MissingBurn, + FeePolicy, + FinalizerTicket, +} + +fn mini_block_verdict(ledger: &Ledger, block: &Block, now_ms: u64) -> MiniBlockVerdict { + let parent = ledger.tip(); + if block.height != parent.height.saturating_add(1) { + return MiniBlockVerdict::Height; + } + if block.prev_hash != parent.hash { + return MiniBlockVerdict::Parent; + } + if block.compute_hash() != block.hash { + return MiniBlockVerdict::Hash; + } + + let expected_reward = block + .transactions + .iter() + .try_fold(0_u64, |total, transaction| { + total.checked_add(transaction.fee()) + }); + if expected_reward != Some(block.reward) { + return MiniBlockVerdict::Reward; + } + + let expected_vdf_rounds = match block.finalizer_mode { + FinalizerMode::Ticket => { + super::ticket::vdf_rounds_for_finalizer_rank(ledger.vdf_rounds, block.finalizer_rank) + } + FinalizerMode::Recovery => { + super::ticket::vdf_rounds_for_finalizer_rank(ledger.vdf_rounds, 0) + } + }; + if expected_vdf_rounds.ok() != Some(block.vdf_rounds) { + return MiniBlockVerdict::VdfRounds; + } + + if block.timestamp_ms <= parent.timestamp_ms { + return MiniBlockVerdict::Timestamp; + } + if block.finalizer_mode == FinalizerMode::Ticket { + let Ok(min_timestamp) = ticket_block_min_timestamp(parent, block.finalizer_rank) else { + return MiniBlockVerdict::Timestamp; + }; + if block.timestamp_ms < min_timestamp { + return MiniBlockVerdict::Timestamp; + } + } + let mut timestamps = ledger + .chain + .iter() + .rev() + .take(super::BLOCK_MEDIAN_TIME_PAST_WINDOW) + .map(|block| block.timestamp_ms) + .collect::<Vec<_>>(); + timestamps.sort_unstable(); + if block.timestamp_ms <= timestamps[timestamps.len() / 2] { + return MiniBlockVerdict::Timestamp; + } + if block.timestamp_ms > now_ms.saturating_add(super::MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS) { + return MiniBlockVerdict::FutureTimestamp; + } + + if block.transactions.len() > ledger.launch_profile.max_block_transactions { + return MiniBlockVerdict::TooManyTransactions; + } + if block + .serialized_size_bytes() + .ok() + .is_none_or(|bytes| bytes > ledger.launch_profile.max_block_bytes) + { + return MiniBlockVerdict::TooLarge; + } + if !block.transactions.iter().any(Transaction::is_burn) { + return MiniBlockVerdict::MissingBurn; + } + if block + .transactions + .iter() + .any(|transaction| transaction.fee() == 0) + { + return MiniBlockVerdict::FeePolicy; + } + + if block.finalizer_mode == FinalizerMode::Ticket { + let Ok(ranks) = ledger.burn_leader_ranks_for_block(block.height) else { + return MiniBlockVerdict::FinalizerTicket; + }; + let Some(selected) = ranks.get(block.finalizer_rank as usize) else { + return MiniBlockVerdict::FinalizerTicket; + }; + let Some(proof) = block.leader_proof.as_ref() else { + return MiniBlockVerdict::FinalizerTicket; + }; + if selected.rank != block.finalizer_rank + || selected.owner != block.miner + || proof.ticket_id != selected.ticket_id + || proof.public_key != block.miner + || selected.eligible_from_height > block.height + || selected.eligible_until_height < block.height + { + return MiniBlockVerdict::FinalizerTicket; + } + let payload = LeaderProofPayload { + height: block.height, + prev_hash: block.prev_hash.clone(), + finalizer_rank: block.finalizer_rank, + vdf_output: block.vdf_output.clone(), + ticket_id: selected.ticket_id.clone(), + ticket_amount: selected.amount, + ticket_owner: selected.owner.clone(), + }; + if verify_address_signature( + &proof.public_key, + &payload.canonical(), + &proof.signature, + "mini-validator leader", + ) + .is_err() + { + return MiniBlockVerdict::FinalizerTicket; + } + } + + MiniBlockVerdict::Accept +} + +fn consensus_block_verdict(mut ledger: Ledger, block: Block, now_ms: u64) -> MiniBlockVerdict { + let error = match ledger.apply_block_at(block, now_ms) { + Ok(()) => return MiniBlockVerdict::Accept, + Err(error) => error.to_string(), + }; + + if error.contains("expected block height") || error.contains("conflicts with local chain") { + MiniBlockVerdict::Height + } else if error.contains("does not extend local tip") { + MiniBlockVerdict::Parent + } else if error.contains("block hash is invalid") { + MiniBlockVerdict::Hash + } else if error.contains("block reward is invalid") { + MiniBlockVerdict::Reward + } else if error.contains("block VDF rounds are invalid") { + MiniBlockVerdict::VdfRounds + } else if error.contains("timestamp must") || error.contains("before finalizer rank") { + MiniBlockVerdict::Timestamp + } else if error.contains("too far in the future") { + MiniBlockVerdict::FutureTimestamp + } else if error.contains("too many transaction") { + MiniBlockVerdict::TooManyTransactions + } else if error.contains("max block size") { + MiniBlockVerdict::TooLarge + } else if error.contains("at least one burn transaction") { + MiniBlockVerdict::MissingBurn + } else if error.contains("fee must be greater than zero") { + MiniBlockVerdict::FeePolicy + } else if error.contains("selected for rank") + || error.contains("selected ticket") + || error.contains("leader proof") + || error.contains("leader ticket") + || error.contains("leader signature") + { + MiniBlockVerdict::FinalizerTicket + } else { + panic!("unclassified consensus error: {error}"); + } +} + +fn assert_mini_validator_agrees( + ledger: &Ledger, + block: Block, + now_ms: u64, + expected: MiniBlockVerdict, +) { + let mini = mini_block_verdict(ledger, &block, now_ms); + assert_eq!(mini, expected, "mini-validator disagreed with test setup"); + + let consensus = consensus_block_verdict(ledger.clone(), block, now_ms); + assert_eq!( + consensus, mini, + "production validator and mini-validator diverged" + ); +} + fn harness_for_percent(seed: u64, burn_percent: u8) -> Harness { Harness::new(seed, burn_percent, 10, AdversaryStrategy::Honest) } @@ -741,6 +938,159 @@ fn attested_burn_is_not_selected_again_as_normal_transaction() { } #[test] +fn independent_mini_validator_matches_consensus_for_block_prechecks() { + let mut harness = harness_for_percent(30, 25); + let block = harness.prepared_ticket_block(0, Vec::new()); + let ledger = harness.ledger.clone(); + let now_ms = NOW_MS.saturating_add(block.timestamp_ms); + + assert_mini_validator_agrees(&ledger, block.clone(), now_ms, MiniBlockVerdict::Accept); + + let mut wrong_height = block.clone(); + wrong_height.height += 1; + rehash(&mut wrong_height); + assert_mini_validator_agrees(&ledger, wrong_height, now_ms, MiniBlockVerdict::Height); + + let mut wrong_parent = block.clone(); + wrong_parent.prev_hash = if ledger.tip_hash() == "0".repeat(64) { + "1".repeat(64) + } else { + "0".repeat(64) + }; + rehash(&mut wrong_parent); + assert_mini_validator_agrees(&ledger, wrong_parent, now_ms, MiniBlockVerdict::Parent); + + let mut wrong_hash = block.clone(); + mutate_signature(&mut wrong_hash.hash); + assert_mini_validator_agrees(&ledger, wrong_hash, now_ms, MiniBlockVerdict::Hash); + + let mut wrong_reward = block.clone(); + wrong_reward.reward += 1; + wrong_reward.hash = wrong_reward.compute_hash(); + assert_mini_validator_agrees(&ledger, wrong_reward, now_ms, MiniBlockVerdict::Reward); + + let mut wrong_vdf_rounds = block.clone(); + wrong_vdf_rounds.vdf_rounds += 1; + wrong_vdf_rounds.hash = wrong_vdf_rounds.compute_hash(); + assert_mini_validator_agrees( + &ledger, + wrong_vdf_rounds, + now_ms, + MiniBlockVerdict::VdfRounds, + ); + + let mut early_timestamp = block.clone(); + early_timestamp.timestamp_ms = ledger.tip().timestamp_ms; + rehash(&mut early_timestamp); + assert_mini_validator_agrees( + &ledger, + early_timestamp, + now_ms, + MiniBlockVerdict::Timestamp, + ); + + let mut future_timestamp = block.clone(); + future_timestamp.timestamp_ms = NOW_MS + super::MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS + 1; + rehash(&mut future_timestamp); + assert_mini_validator_agrees( + &ledger, + future_timestamp, + NOW_MS, + MiniBlockVerdict::FutureTimestamp, + ); + + let mut count_limited_ledger = ledger.clone(); + count_limited_ledger.launch_profile.max_block_transactions = + block.transactions.len().saturating_sub(1); + assert_mini_validator_agrees( + &count_limited_ledger, + block.clone(), + now_ms, + MiniBlockVerdict::TooManyTransactions, + ); + + let mut size_limited_ledger = ledger.clone(); + size_limited_ledger.launch_profile.max_block_bytes = + block.serialized_size_bytes().unwrap().saturating_sub(1); + assert_mini_validator_agrees( + &size_limited_ledger, + block.clone(), + now_ms, + MiniBlockVerdict::TooLarge, + ); + + let mut missing_burn = block.clone(); + missing_burn + .transactions + .retain(|transaction| !transaction.is_burn()); + rehash(&mut missing_burn); + assert_mini_validator_agrees(&ledger, missing_burn, now_ms, MiniBlockVerdict::MissingBurn); + + let mut zero_fee = block.clone(); + match zero_fee + .transactions + .iter_mut() + .find(|transaction| !matches!(transaction, Transaction::Mine { .. })) + .expect("prepared block includes a burn anchor") + { + Transaction::Transfer { fee, .. } | Transaction::Burn { fee, .. } => *fee = 0, + Transaction::Mine { .. } => unreachable!("mine transactions are filtered out"), + } + rehash(&mut zero_fee); + assert_mini_validator_agrees(&ledger, zero_fee, now_ms, MiniBlockVerdict::FeePolicy); + + let mut wrong_finalizer = block.clone(); + wrong_finalizer.miner = harness.honest[0].address().to_string(); + rehash(&mut wrong_finalizer); + assert_mini_validator_agrees( + &ledger, + wrong_finalizer, + now_ms, + MiniBlockVerdict::FinalizerTicket, + ); + + let mut missing_proof = block.clone(); + missing_proof.leader_proof = None; + rehash(&mut missing_proof); + assert_mini_validator_agrees( + &ledger, + missing_proof, + now_ms, + MiniBlockVerdict::FinalizerTicket, + ); + + let mut wrong_proof_signature = block.clone(); + mutate_signature( + &mut wrong_proof_signature + .leader_proof + .as_mut() + .expect("ticket block carries a leader proof") + .signature, + ); + rehash(&mut wrong_proof_signature); + assert_mini_validator_agrees( + &ledger, + wrong_proof_signature, + now_ms, + MiniBlockVerdict::FinalizerTicket, + ); + + let mut wrong_ticket_id = block; + wrong_ticket_id + .leader_proof + .as_mut() + .expect("ticket block carries a leader proof") + .ticket_id = "0".repeat(64); + rehash(&mut wrong_ticket_id); + assert_mini_validator_agrees( + &ledger, + wrong_ticket_id, + now_ms, + MiniBlockVerdict::FinalizerTicket, + ); +} + +#[test] fn attested_burn_block_validates_independent_of_local_mempool() { let mut harness = harness_for_percent(22, 25); let leader = harness.next_rank(0);