iuna

iuna

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

commit da0d834384b507da38af0c37e677e45db2a6734b
parent bcbe1f9edfb82ca4caf2c490771134ef176d8b49
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Mon, 17 Aug 2026 12:00:57 +0200

Activate reveal bundle signature thresholds

Diffstat:
Mdocs/protocol.md | 12++++++++++--
Msrc/app/automatic_mining.rs | 8++++++--
Msrc/domain.rs | 3++-
Msrc/domain/ledger_apply.rs | 2++
Msrc/domain/ledger_reveal.rs | 47++++++++++++++++++++++++++++++++++++++++++++---
Msrc/domain/protocol.rs | 1+
Msrc/domain/tests.rs | 267++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/properties.rs | 213++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
8 files changed, 525 insertions(+), 28 deletions(-)

diff --git a/docs/protocol.md b/docs/protocol.md @@ -140,9 +140,9 @@ The visible inputs are signed for the blinded envelope itself and are not repeat Reveal is a later step. A `BlindedReveal` carries only the commitment and decryption key. Reveals are not included as loose block items. They are carried in signed reveal bundles. -For each next block height, nodes compute a reveal committee from the burn leader ranking. Slot `0` is assigned to the rank `0` block finalizer, so the selected finalizer can always sign a reveal list for its own block. Before height `500`, the remaining slots are assigned to the two lowest-ranked eligible tickets. Starting at height `500`, the remaining slots are assigned to the next highest-ranked eligible tickets with owners that are not already in the committee, up to three unique owners total. A committee member can sign one bundle for its slot, height, and parent hash. A bundle is at most `10,000` bytes and lists valid pending reveals ordered by visible fee rate. Empty bundles are not gossiped. +For each next block height, nodes compute a reveal committee from the burn leader ranking. Slot `0` is assigned to the rank `0` block finalizer, so the selected finalizer can always sign a reveal list for its own block. Before height `500`, the remaining slots are assigned to the two lowest-ranked eligible tickets. Starting at height `500`, the remaining slots are assigned to the next highest-ranked eligible tickets with owners that are not already in the committee, up to three unique owners total. A committee member can sign one bundle for its slot, height, and parent hash. A bundle is at most `10,000` bytes and lists valid pending reveals ordered by visible fee rate. Starting at height `1500`, once a blinded envelope is active, committee members also gossip empty bundles when they know no valid reveal for the next height; the empty signature is an attestation that keeps the reveal layer explicit without forcing a reveal to exist. -Automatic nodes wait about `30 seconds` after seeing pending reveals for the next height before signing a reveal bundle or starting the reveal-bound VDF. This gives reveal gossip time to settle and avoids locking in an underfilled bundle from the first partial batch a node received. +Automatic nodes wait about `30 seconds` after seeing pending reveals for the next height before signing a reveal bundle or starting the reveal-bound VDF. Starting at height `1500`, active blinded envelopes also trigger this wait so empty attestations can be collected. This gives reveal gossip time to settle and avoids locking in an underfilled bundle from the first partial batch a node received. A block has an envelope section and one compact reveal-bundle section. The envelope section contains the finalizer's plaintext anchor burn, public mine actions, and blinded transaction envelopes. @@ -156,6 +156,14 @@ Validators reconstruct each signed committee bundle from this compact section be A block may contain at most one bundle per slot. If a node sees two different signed bundles for the same height and slot before block assembly, it treats that slot as locally equivocated and does not use either bundle for that round. +Before height `1500`, reveal-list signatures are optional for chain compatibility. Starting at height `1500`, if there are no active blinded envelopes before a block, no reveal-list threshold is required. If active blinded envelopes exist, ticket blocks must carry enough reveal-list signatures for their finalizer rank: + +- rank `0` needs all available reveal committee signatures (`3-of-3`, `2-of-2`, or `1-of-1`); +- rank `1` needs two signatures when possible (`2-of-3`, `2-of-2`, or `1-of-1`); +- rank `2` and later ticket finalizers need one signature. + +Recovery blocks do not require reveal-list signatures; their job is chain liveness after the ticket path has failed. A valid signed bundle may be empty. Honest committee policy is to sign an empty bundle only when the signer knows no valid reveal for that height, and to include every valid reveal it selects by the canonical fee ordering. The consensus rule checks committee membership, signature validity, ordering, and threshold; it does not depend on a validator's local mempool contents. + The ticket-block VDF seed is bound to the reveal bundle hashes: `seed = hash(parent hash || height || bundle_hash[0] || bundle_hash[1] || bundle_hash[2])` diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -434,14 +434,18 @@ impl NodeCore { will_run_vdf: bool, ) -> Option<u64> { let next_height = self.ledger.height().saturating_add(1); + let needs_reveal_attestations = self + .ledger + .reveal_bundle_attestations_required_for_next_block(); let has_pending_reveals = !self.ledger.pending_blinded_reveals().is_empty(); + let needs_reveal_collection = has_pending_reveals || needs_reveal_attestations; let wallet_is_committee_member = self .ledger .reveal_committee_for_next_block() .iter() .any(|member| member.owner == self.wallet.address()); - if !has_pending_reveals || (!wallet_is_committee_member && !will_run_vdf) { - if !has_pending_reveals { + if !needs_reveal_collection || (!wallet_is_committee_member && !will_run_vdf) { + if !needs_reveal_collection { self.reveal_bundle_collection_started = None; } return None; diff --git a/src/domain.rs b/src/domain.rs @@ -82,7 +82,8 @@ pub use protocol::{ MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MAX_BLOCK_BYTES, MAX_PENDING_TRANSACTIONS, MAX_REVEAL_BUNDLE_BYTES, MAX_VDF_ROUNDS, MICRO_IUNA, MINE_ACTIONS_PER_ANCHOR_LIMIT, MINE_DIFFICULTY_BITS, MINE_FINALIZER_FEE, MINE_REWARD, RECOVERY_BLOCK_DELAY_MS, - REVEAL_COMMITTEE_SIZE, REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, TransactionSubmitOutcome, + REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, REVEAL_COMMITTEE_SIZE, + REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, TransactionSubmitOutcome, UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT, VDF_TARGET_BLOCK_MS, }; use protocol::{ diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -307,6 +307,8 @@ impl Ledger { self.validate_reveal_bundle_section_for_block( block.height, &block.prev_hash, + block.finalizer_mode, + block.finalizer_rank, &block.reveal_bundle_section, )?; validate_block_blinded_items(block, self)?; diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs @@ -5,11 +5,17 @@ use anyhow::{Context, Result, bail}; use super::ledger_ops::verify_address_signature; use super::reveal::{reveal_bundle_slot_mask, reveal_committee_mask}; use super::{ - Amount, Ledger, MAX_REVEAL_BUNDLE_BYTES, MaskedBlindedReveal, REVEAL_COMMITTEE_SIZE, - RevealBundle, RevealBundlePayload, RevealBundleSection, RevealBundleSignature, Wallet, + Amount, FinalizerMode, Ledger, MAX_REVEAL_BUNDLE_BYTES, MaskedBlindedReveal, + REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, REVEAL_COMMITTEE_SIZE, RevealBundle, + RevealBundlePayload, RevealBundleSection, RevealBundleSignature, Wallet, }; impl Ledger { + pub fn reveal_bundle_attestations_required_for_next_block(&self) -> bool { + self.tip().height.saturating_add(1) >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT + && !self.active_blinded.is_empty() + } + pub fn build_reveal_bundle(&self, wallet: &Wallet) -> Result<Option<RevealBundle>> { let height = self.tip().height + 1; let prev_hash = self.tip().hash.clone(); @@ -42,7 +48,7 @@ impl Ledger { selected = candidate; } } - if selected.is_empty() { + if selected.is_empty() && !self.reveal_bundle_attestations_required_for_next_block() { return Ok(None); } Ok(Some(wallet.reveal_bundle(RevealBundlePayload { @@ -104,6 +110,8 @@ impl Ledger { &self, expected_height: u64, expected_prev_hash: &str, + finalizer_mode: FinalizerMode, + finalizer_rank: u32, section: &RevealBundleSection, ) -> Result<()> { if section.signatures.len() > REVEAL_COMMITTEE_SIZE { @@ -142,6 +150,18 @@ impl Ledger { } included_mask |= reveal_bundle_slot_mask(signature.slot)?; } + let required_signatures = self.required_reveal_bundle_signatures( + expected_height, + finalizer_mode, + finalizer_rank, + committee.len(), + ); + if section.signatures.len() < required_signatures { + bail!( + "block has too few reveal bundle signatures: got {}, need {required_signatures}", + section.signatures.len() + ); + } let mut seen_reveals = BTreeSet::new(); let mut previous_key: Option<((u128, Amount), String)> = None; @@ -187,6 +207,27 @@ impl Ledger { Ok(()) } + fn required_reveal_bundle_signatures( + &self, + height: u64, + finalizer_mode: FinalizerMode, + finalizer_rank: u32, + committee_size: usize, + ) -> usize { + if height < REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT + || self.active_blinded.is_empty() + || committee_size == 0 + { + return 0; + } + match finalizer_mode { + FinalizerMode::Ticket if finalizer_rank == 0 => committee_size, + FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.min(2), + FinalizerMode::Ticket => 1, + FinalizerMode::Recovery => 0, + } + } + fn validate_reveal_bundles_for_block( &self, expected_height: u64, diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs @@ -17,6 +17,7 @@ pub const MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS: u64 = 20; pub const REVEAL_COMMITTEE_SIZE: usize = 3; pub const UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT: u64 = 500; pub const MAX_REVEAL_BUNDLE_BYTES: usize = 10_000; +pub const REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT: u64 = 1_500; pub const BLINDED_FEE_BPS_DENOMINATOR: u64 = 10_000; pub const BLINDED_COMMITTER_FEE_BPS: u64 = 3_500; pub const BLINDED_REVEAL_FINALIZER_FEE_BPS: u64 = 3_500; diff --git a/src/domain/tests.rs b/src/domain/tests.rs @@ -204,8 +204,13 @@ fn mine_preverified_as_next_leader( ) -> Block { let leader = ledger.expected_leader_for_next_block().unwrap(); let wallet = wallet_for_address(wallets, &leader); + let reveal_bundles = if ledger.reveal_bundle_attestations_required_for_next_block() { + reveal_bundles_for_next_block(ledger, wallets) + } else { + Vec::new() + }; let prepared = ledger - .prepare_next_block(wallet.address(), timestamp_ms) + .prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, reveal_bundles) .unwrap(); let block = prepared.finish(wallet, "preverified-vdf".to_string()); ledger @@ -222,6 +227,123 @@ fn mine_valid_as_next_leader(ledger: &mut Ledger, wallets: &[Wallet], timestamp_ block } +fn mine_own_burn_only_without_reveal_bundles( + ledger: &mut Ledger, + wallet: &Wallet, + burn_amount: Amount, + timestamp_ms: u64, +) -> anyhow::Result<Block> { + let burn = ledger.build_burn(wallet, burn_amount, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let prepared = ledger.prepare_next_block_with_reveal_bundles( + wallet.address(), + timestamp_ms, + Vec::new(), + )?; + assert!(prepared.reveal_bundle_section.is_empty()); + assert_eq!(prepared.blinded_transactions.len(), 0); + assert_eq!(prepared.transactions.len(), 1); + assert!(prepared.transactions[0].is_burn()); + assert_eq!(prepared.transactions[0].sender(), wallet.address()); + let block = prepared.finish(wallet, "preverified-vdf".to_string()); + ledger.apply_preverified_block_at(block.clone(), u64::MAX)?; + Ok(block) +} + +fn next_rank_wallet<'a>(ledger: &Ledger, wallets: &'a [Wallet], rank: u32) -> &'a Wallet { + wallets + .iter() + .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(rank)) + .unwrap_or_else(|| panic!("missing wallet for next finalizer rank {rank}")) +} + +fn reveal_bundles_for_next_block(ledger: &Ledger, wallets: &[Wallet]) -> Vec<RevealBundle> { + let mut bundles = wallets + .iter() + .filter_map(|wallet| ledger.build_reveal_bundle(wallet).unwrap()) + .collect::<Vec<_>>(); + bundles.sort_by_key(|bundle| bundle.slot); + bundles +} + +fn prepare_active_blinded_burn_for_reveal_thresholds( + seeds: [&str; 4], +) -> (Ledger, Vec<Wallet>, BuiltBlindedTransaction) { + prepare_active_blinded_burn_for_reveal_thresholds_at_next_height( + seeds, + REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, + ) +} + +fn prepare_active_blinded_burn_for_reveal_thresholds_at_next_height( + seeds: [&str; 4], + next_height: u64, +) -> (Ledger, Vec<Wallet>, BuiltBlindedTransaction) { + let attacker = Wallet::from_seed(seeds[0]); + let bob = Wallet::from_seed(seeds[1]); + let carol = Wallet::from_seed(seeds[2]); + let victim = Wallet::from_seed(seeds[3]); + let finalizers = vec![attacker.clone(), bob, carol]; + let mut ledger = ledger_with_finalizers( + &finalizers, + &[(&attacker, 100 * MICRO_IUNA), (&victim, 20 * MICRO_IUNA)], + ); + set_tip_height_for_validation(&mut ledger, next_height.saturating_sub(2)); + install_finalizer_tickets_for_height(&mut ledger, &finalizers, next_height.saturating_sub(1)); + let blinded = ledger + .build_blinded_burn(&victim, 3, MICRO_IUNA, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + let leader = next_rank_wallet(&ledger, &finalizers, 0); + let burn = ledger.build_burn(leader, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let commit_block = ledger.mine_next_block(leader, 1).unwrap(); + ledger.apply_block_at(commit_block, u64::MAX).unwrap(); + install_finalizer_tickets_for_height(&mut ledger, &finalizers, next_height); + ledger + .submit_blinded_reveal(blinded.reveal.clone()) + .unwrap(); + assert_eq!(ledger.height() + 1, next_height); + assert_eq!(ledger.reveal_committee_for_next_block().len(), 3); + (ledger, finalizers, blinded) +} + +fn install_finalizer_tickets_for_height(ledger: &mut Ledger, finalizers: &[Wallet], height: u64) { + ledger.tickets = finalizers + .iter() + .enumerate() + .map(|(index, wallet)| BurnTicket { + id: hex_hash(format!( + "test-reveal-threshold-ticket:{}:{}", + wallet.address(), + height + )), + owner: wallet.address().to_string(), + amount: MICRO_IUNA.saturating_sub(index as u64), + eligible_from_height: height, + eligible_until_height: height, + }) + .collect(); +} + +fn try_mine_rank_with_reveal_bundles( + ledger: &mut Ledger, + wallet: &Wallet, + burn_amount: Amount, + timestamp_ms: u64, + bundles: Vec<RevealBundle>, +) -> anyhow::Result<Block> { + let burn = ledger.build_burn(wallet, burn_amount, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let prepared = + ledger.prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, bundles)?; + let block = prepared.finish(wallet, "preverified-vdf".to_string()); + ledger.apply_preverified_block_at(block.clone(), u64::MAX)?; + Ok(block) +} + fn mine_preverified_as_next_leader_with_reveal_bundles( ledger: &mut Ledger, wallets: &[Wallet], @@ -1769,6 +1891,134 @@ fn blinded_burn_commits_ciphertext_and_reveal_executes_later() { } #[test] +fn reveal_bundle_signature_thresholds_are_inactive_before_activation_height() { + let (mut ledger, finalizers, _) = + prepare_active_blinded_burn_for_reveal_thresholds_at_next_height( + [ + "reveal-threshold-preactivation-attacker", + "reveal-threshold-preactivation-bob", + "reveal-threshold-preactivation-carol", + "reveal-threshold-preactivation-victim", + ], + REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT - 1, + ); + let rank0 = next_rank_wallet(&ledger, &finalizers, 0).clone(); + + let block = mine_own_burn_only_without_reveal_bundles(&mut ledger, &rank0, 1, 2).unwrap(); + + assert_eq!(block.height, REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT - 1); + assert_eq!(block.included_reveal_bundle_count(), 0); +} + +#[test] +fn rank0_finalizer_needs_all_reveal_bundle_signatures_when_blinded_is_active() { + let (ledger, finalizers, blinded) = prepare_active_blinded_burn_for_reveal_thresholds([ + "reveal-threshold-r0-attacker", + "reveal-threshold-r0-bob", + "reveal-threshold-r0-carol", + "reveal-threshold-r0-victim", + ]); + let rank0 = next_rank_wallet(&ledger, &finalizers, 0).clone(); + let bundles = reveal_bundles_for_next_block(&ledger, &finalizers); + assert_eq!(bundles.len(), 3); + assert!(bundles.iter().all(|bundle| { + bundle + .reveals + .iter() + .any(|reveal| reveal.commitment == blinded.transaction.commitment) + })); + + let mut missing_one = ledger.clone(); + let error = + try_mine_rank_with_reveal_bundles(&mut missing_one, &rank0, 1, 2, bundles[..2].to_vec()) + .unwrap_err(); + assert!(format!("{error:#}").contains("got 2, need 3")); + + let mut complete = ledger; + let block = try_mine_rank_with_reveal_bundles(&mut complete, &rank0, 1, 2, bundles).unwrap(); + assert_eq!(block.finalizer_rank, 0); + assert_eq!(block.included_reveal_bundle_count(), 3); + assert_eq!(block.all_blinded_reveals().len(), 1); +} + +#[test] +fn rank1_finalizer_needs_two_reveal_bundle_signatures_when_blinded_is_active() { + let (ledger, finalizers, _) = prepare_active_blinded_burn_for_reveal_thresholds([ + "reveal-threshold-r1-attacker", + "reveal-threshold-r1-bob", + "reveal-threshold-r1-carol", + "reveal-threshold-r1-victim", + ]); + let rank1 = next_rank_wallet(&ledger, &finalizers, 1).clone(); + let bundles = reveal_bundles_for_next_block(&ledger, &finalizers); + assert_eq!(bundles.len(), 3); + + let mut missing_one = ledger.clone(); + let error = try_mine_rank_with_reveal_bundles( + &mut missing_one, + &rank1, + 1, + VDF_TARGET_BLOCK_MS * 2, + bundles[..1].to_vec(), + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("got 1, need 2")); + + let mut enough = ledger; + let block = try_mine_rank_with_reveal_bundles( + &mut enough, + &rank1, + 1, + VDF_TARGET_BLOCK_MS * 2, + bundles[..2].to_vec(), + ) + .unwrap(); + assert_eq!(block.finalizer_rank, 1); + assert_eq!(block.included_reveal_bundle_count(), 2); +} + +#[test] +fn rank2_finalizer_can_publish_one_reveal_bundle_signature_when_blinded_is_active() { + let (ledger, finalizers, _) = prepare_active_blinded_burn_for_reveal_thresholds([ + "reveal-threshold-r2-attacker", + "reveal-threshold-r2-bob", + "reveal-threshold-r2-carol", + "reveal-threshold-r2-victim", + ]); + let rank2 = next_rank_wallet(&ledger, &finalizers, 2).clone(); + let bundles = reveal_bundles_for_next_block(&ledger, &finalizers); + assert_eq!(bundles.len(), 3); + + let mut solo = ledger; + let block = try_mine_rank_with_reveal_bundles( + &mut solo, + &rank2, + 1, + VDF_TARGET_BLOCK_MS * 4, + bundles[..1].to_vec(), + ) + .unwrap(); + assert_eq!(block.finalizer_rank, 2); + assert_eq!(block.included_reveal_bundle_count(), 1); +} + +#[test] +fn active_blinded_envelope_rejects_own_burn_only_block_without_reveal_list_threshold() { + let (mut ledger, finalizers, _) = prepare_active_blinded_burn_for_reveal_thresholds([ + "reveal-threshold-empty-attacker", + "reveal-threshold-empty-bob", + "reveal-threshold-empty-carol", + "reveal-threshold-empty-victim", + ]); + let rank1 = next_rank_wallet(&ledger, &finalizers, 1).clone(); + + let error = + mine_own_burn_only_without_reveal_bundles(&mut ledger, &rank1, 1, VDF_TARGET_BLOCK_MS * 2) + .unwrap_err(); + assert!(format!("{error:#}").contains("got 0, need 2")); +} + +#[test] fn blinded_reveal_finalizer_fees_are_aggregated_into_block_reward() { let alice = Wallet::from_seed("aggregated-finalizer-fee-alice"); let bob = Wallet::from_seed("aggregated-finalizer-fee-bob"); @@ -2426,7 +2676,8 @@ fn blinded_reveal_with_wrong_key_is_rejected_in_block() { let mut prepared = ledger .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) .unwrap(); - let committee_member = ledger.reveal_committee_for_next_block()[0].clone(); + let committee = ledger.reveal_committee_for_next_block(); + let committee_member = committee[0].clone(); let committee_wallet = wallet_for_address(&finalizers, &committee_member.owner); let wrong_reveal = BlindedReveal { commitment: blinded.transaction.commitment, @@ -2439,7 +2690,17 @@ fn blinded_reveal_with_wrong_key_is_rejected_in_block() { member: committee_wallet.address().to_string(), reveals: vec![wrong_reveal], }); - prepared.reveal_bundle_section = ledger.reveal_bundle_section_from_bundles(vec![wrong_bundle]); + let empty_member = committee[1].clone(); + let empty_wallet = wallet_for_address(&finalizers, &empty_member.owner); + let empty_bundle = empty_wallet.reveal_bundle(RevealBundlePayload { + height: prepared.height, + prev_hash: prepared.prev_hash.clone(), + slot: empty_member.slot, + member: empty_wallet.address().to_string(), + reveals: Vec::new(), + }); + prepared.reveal_bundle_section = + ledger.reveal_bundle_section_from_bundles(vec![wrong_bundle, empty_bundle]); prepared.reward = blinded_reveal_finalizer_fee( blinded.transaction.fee, prepared.reveal_bundle_section.signatures.len(), diff --git a/tests/properties.rs b/tests/properties.rs @@ -4,8 +4,9 @@ use iuna::{ app::{GossipEnvelope, InMemoryNetwork, NodeCore}, domain::{ Amount, ChainSnapshot, GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_IUNA, - MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, RECOVERY_BLOCK_DELAY_MS, Transaction, TxInput, - TxOutput, VDF_TARGET_BLOCK_MS, Wallet, hex_hash, revealed_blinded_transactions, verify_vdf, + MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, RECOVERY_BLOCK_DELAY_MS, RevealBundle, + Transaction, TxInput, TxOutput, VDF_TARGET_BLOCK_MS, Wallet, hex_hash, + revealed_blinded_transactions, run_vdf, verify_vdf, }, }; @@ -460,7 +461,14 @@ fn try_finalize_next_block(round: usize, wallets: &[Wallet], ledger: &mut Ledger let _ = ledger.submit_transaction(tx); } - if let Ok(block) = ledger.mine_next_block(wallet, (round + 1) as u64) { + let reveal_bundles = reveal_bundles_for_next_block(ledger, wallets); + if let Ok(work) = ledger.prepare_next_block_with_reveal_bundles( + wallet.address(), + (round + 1) as u64, + reveal_bundles, + ) { + let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); + let block = work.finish(wallet, vdf_output); ledger .apply_block(block) .expect("locally mined block applies"); @@ -474,9 +482,12 @@ fn finalize_with_wallet(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) let _ = ledger .submit_transaction(burn) .expect("finalizer burn enters mempool"); - let block = ledger - .mine_next_block(wallet, timestamp_ms) - .expect("finalizer can mine next block"); + let reveal_bundles = reveal_bundles_for_next_block(ledger, std::slice::from_ref(wallet)); + let work = ledger + .prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, reveal_bundles) + .expect("finalizer can prepare next block"); + let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); + let block = work.finish(wallet, vdf_output); ledger.apply_block(block).expect("finalizer block applies"); } @@ -487,15 +498,32 @@ fn finalize_preverified_with_wallet(ledger: &mut Ledger, wallet: &Wallet, timest ledger .submit_transaction(burn) .expect("finalizer burn enters mempool"); + let reveal_bundles = reveal_bundles_for_next_block(ledger, std::slice::from_ref(wallet)); let work = ledger - .prepare_next_block(wallet.address(), timestamp_ms) + .prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, reveal_bundles) .expect("finalizer can prepare next block"); - let block = work.finish(wallet, "property-vdf".to_string()); + let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); + let block = work.finish(wallet, vdf_output); ledger - .apply_locally_mined_block(block) + .apply_block(block) .expect("locally mined block applies"); } +fn reveal_bundles_for_next_block(ledger: &Ledger, wallets: &[Wallet]) -> Vec<RevealBundle> { + let mut bundles = ledger + .reveal_committee_for_next_block() + .into_iter() + .filter_map(|member| { + wallets + .iter() + .find(|wallet| wallet.address() == member.owner) + .and_then(|wallet| ledger.build_reveal_bundle(wallet).ok().flatten()) + }) + .collect::<Vec<_>>(); + bundles.sort_by_key(|bundle| bundle.slot); + bundles +} + fn next_ticket_slot_timestamp(ledger: &Ledger, offset_ms: u64) -> u64 { ledger .chain() @@ -1711,6 +1739,13 @@ fn mine_expected_leader_with_network_time( .as_deref() .is_some_and(|reason| reason.contains("collecting blinded reveals")) { + collect_reveal_bundles_for_next_block( + network, + node_ids, + wallets, + &node_ids[leader_index], + timestamp_ms, + ); outcome = network .node_mut(&node_ids[leader_index]) .expect("leader node exists") @@ -1730,6 +1765,61 @@ fn drain_all_outboxes(network: &mut InMemoryNetwork, node_ids: &[String]) { } } +fn collect_reveal_bundles_for_next_block( + network: &mut InMemoryNetwork, + node_ids: &[String], + wallets: &[Wallet], + anchor_id: &str, + _timestamp_ms: u64, +) { + let committee = network + .node(anchor_id) + .expect("anchor node exists") + .ledger() + .reveal_committee_for_next_block(); + let committee_node_ids = committee + .into_iter() + .filter_map(|member| { + wallets + .iter() + .position(|wallet| wallet.address() == member.owner) + .map(|index| node_ids[index].clone()) + }) + .collect::<Vec<_>>(); + + let mut bundles = Vec::new(); + for id in &committee_node_ids { + let Some(index) = node_ids.iter().position(|node_id| node_id == id) else { + continue; + }; + if let Some(bundle) = network + .node(id) + .expect("committee node exists") + .ledger() + .build_reveal_bundle(&wallets[index]) + .expect("committee node builds reveal bundle") + { + bundles.push((id.clone(), bundle)); + } + } + + for (from, bundle) in bundles { + for id in node_ids { + if *id == from { + continue; + } + network + .node_mut(id) + .expect("node exists") + .receive(GossipEnvelope::RevealBundle(bundle.clone())) + .expect("node receives reveal bundle"); + } + } + network + .deliver_until_idle() + .expect("reveal bundle gossip succeeds"); +} + fn mine_one_partition_block( network: &mut InMemoryNetwork, node_ids: &[String], @@ -1738,10 +1828,11 @@ fn mine_one_partition_block( round: usize, rng: &mut TestRng, ) { - assert!( - try_mine_one_partition_block(network, node_ids, wallets, partition, round, rng), - "partition could not produce a block" - ); + if let Err(reasons) = + try_mine_one_partition_block(network, node_ids, wallets, partition, round, rng) + { + panic!("partition {partition:?} could not produce a block in round {round}: {reasons:?}"); + } } fn try_mine_one_partition_block( @@ -1751,7 +1842,7 @@ fn try_mine_one_partition_block( partition: &[usize], round: usize, rng: &mut TestRng, -) -> bool { +) -> Result<(), Vec<String>> { let anchor_id = &node_ids[partition[0]]; let anchor_tip_timestamp = network .node(anchor_id) @@ -1771,6 +1862,7 @@ fn try_mine_one_partition_block( .unwrap_or(u32::MAX) }); + let mut reasons = Vec::new(); for index in candidates { let rank_delay = network .node(anchor_id) @@ -1791,6 +1883,22 @@ fn try_mine_one_partition_block( .as_deref() .is_some_and(|reason| reason.contains("collecting blinded reveals")) { + let partition_offline = node_ids + .iter() + .enumerate() + .filter_map(|(node_index, id)| { + (!partition.contains(&node_index)).then_some(id.clone()) + }) + .collect::<BTreeSet<_>>(); + collect_reveal_bundles_for_next_block_with_chaos( + network, + node_ids, + wallets, + anchor_id, + timestamp_ms, + &partition_offline, + rng, + ); outcome = network .node_mut(&node_ids[index]) .expect("partition candidate exists") @@ -1812,11 +1920,18 @@ fn try_mine_one_partition_block( .expect("partition peer imports produced block"); } } - return true; + return Ok(()); } + reasons.push(format!( + "{}: {}", + node_ids[index], + outcome + .skipped_reason + .unwrap_or_else(|| "no block and no skip reason".to_string()) + )); } - false + Err(reasons) } fn sync_until_idle(network: &mut InMemoryNetwork, from: &str, to: &str, limit: usize) { @@ -1831,6 +1946,60 @@ fn sync_until_idle(network: &mut InMemoryNetwork, from: &str, to: &str, limit: u panic!("range sync did not become idle"); } +fn collect_reveal_bundles_for_next_block_with_chaos( + network: &mut InMemoryNetwork, + node_ids: &[String], + wallets: &[Wallet], + anchor_id: &str, + _timestamp_ms: u64, + offline: &BTreeSet<String>, + rng: &mut TestRng, +) { + let committee = network + .node(anchor_id) + .expect("anchor node exists") + .ledger() + .reveal_committee_for_next_block(); + let committee_node_ids = committee + .into_iter() + .filter_map(|member| { + wallets + .iter() + .position(|wallet| wallet.address() == member.owner) + .map(|index| node_ids[index].clone()) + }) + .collect::<Vec<_>>(); + + let mut bundles = Vec::new(); + for id in &committee_node_ids { + if offline.contains(id) { + continue; + } + let Some(index) = node_ids.iter().position(|node_id| node_id == id) else { + continue; + }; + if let Some(bundle) = network + .node(id) + .expect("committee node exists") + .ledger() + .build_reveal_bundle(&wallets[index]) + .expect("committee node builds reveal bundle") + { + bundles.push((id.clone(), bundle)); + } + } + + for (from, bundle) in bundles { + for id in node_ids { + if *id == from || offline.contains(id) { + continue; + } + receive_chaotic_envelope(network, id, GossipEnvelope::RevealBundle(bundle.clone())); + } + } + deliver_chaos_until_idle(network, node_ids, offline, rng); +} + fn deliver_with_chaos( network: &mut InMemoryNetwork, node_ids: &[String], @@ -1978,6 +2147,15 @@ fn in_memory_network_converges_after_generated_offline_and_reordered_delivery() .as_deref() .is_some_and(|reason| reason.contains("collecting blinded reveals")) { + collect_reveal_bundles_for_next_block_with_chaos( + &mut network, + &node_ids, + &wallets, + "n0", + timestamp_ms, + &offline, + &mut rng, + ); outcome = network .node_mut("n0") .expect("finalizer node exists") @@ -1990,7 +2168,8 @@ fn in_memory_network_converges_after_generated_offline_and_reordered_delivery() reason.contains("at least one burn") || reason.contains("required burn") || reason.contains("could not") - || reason.contains("automatic burn failed"), + || reason.contains("automatic burn failed") + || reason.contains("too few reveal bundle signatures"), "unexpected chaotic mining skip reason: {reason}" ); }