iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 87d0d3f641de9d911c5d4fd413ac062fbf8125aa
parent 53ad2392b94be3b9d1cfc784abdd731499f7e433
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed,  5 Aug 2026 16:07:06 +0200

Prepare v0.2.23

Diffstat:
MCargo.lock | 2+-
MCargo.toml | 2+-
Mdocs/protocol.md | 4++--
Msrc/adapters/chain_store.rs | 22+++++++++++++---------
Msrc/adapters/http.rs | 37++++++++++++++++++++++++++++---------
Msrc/app.rs | 117++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/domain.rs | 140++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
7 files changed, 282 insertions(+), 42 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "iuna" -version = "0.2.22" +version = "0.2.23" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iuna" -version = "0.2.22" +version = "0.2.23" edition = "2024" license = "Apache-2.0" diff --git a/docs/protocol.md b/docs/protocol.md @@ -134,7 +134,7 @@ 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. The last three ranked eligible tickets form the three reveal-bundle slots. 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. The remaining slots are assigned to the two lowest-ranked eligible tickets. 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. A block has an envelope section and one compact reveal-bundle section. The envelope section contains the finalizer's plaintext burn, public mine actions, and blinded transaction envelopes. @@ -156,7 +156,7 @@ If a slot has no included bundle, it contributes a fixed default hash for that s When a valid bundled reveal executes, nodes decrypt the earlier payload, check the commitment and payload hash, and decode the transfer or burn. The decrypted transaction inputs must match the visible inputs locked by the envelope, and the transaction executes against that locked value. If the reveal bitmask says multiple committee bundles contained the same reveal, the reveal is still executed only once. If the decrypted transaction is a burn, it creates burn tickets at the reveal height, not the earlier envelope-commit height. -Fees are paid without inflating the reveal block reward. The decrypted transaction must pay the same fee declared by the blinded envelope. `35%` goes to the envelope committer, `35%` goes to the reveal-block finalizer, and `10%` goes to each included signed reveal-list maker. Missing reveal-list shares and rounding dust are burned instead of redistributed. +Fees are paid without inflating the reveal block reward. The decrypted transaction must pay the same fee declared by the blinded envelope. `35%` goes to the envelope committer. Up to `35%` goes to the reveal-block finalizer, scaled by the included signed reveal lists divided by the available committee slots for that height. With three eligible slots, one included list pays one third of that share; with two eligible slots, one included list pays half; with one eligible slot, one included list pays the full share. `10%` goes to each included signed reveal-list maker. Missing reveal-list shares, the missing reveal-finalizer share, and rounding dust are burned instead of redistributed. Expiry is exclusive: a blinded envelope with expiry height `H` can be included only in blocks below height `H`, and revealed only while the current chain height is below `H`. The expiry height must be within `20` blocks of the node's current chain height when the envelope is accepted or selected. If an envelope expires unrevealed, its declared fee is burned and any remaining locked value returns as deterministic change to the owner of the first visible input. Expired local envelopes and reveals are dropped from local selection. diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -11,10 +11,10 @@ use serde::Serialize; use crate::domain::{ Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, - BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BLINDED_REVEAL_FINALIZER_FEE_BPS, BlindedReveal, - BlindedTransaction, Block, ChainSnapshot, FinalizerMode, LaunchProfile, LeaderProof, Ledger, - MINE_REWARD, MaskedBlindedReveal, OutPoint, RevealBundleSection, RevealBundleSignature, - Transaction, TxInput, TxOutput, revealed_blinded_transactions, + BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, + FinalizerMode, LaunchProfile, LeaderProof, Ledger, MINE_REWARD, MaskedBlindedReveal, OutPoint, + REVEAL_COMMITTEE_SIZE, RevealBundleSection, RevealBundleSignature, Transaction, TxInput, + TxOutput, blinded_reveal_finalizer_fee, revealed_blinded_transactions, }; const SCHEMA: &str = r#" @@ -944,11 +944,15 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> .context("block metric fees overflow")?; let committer_fee = blinded_fee_share(transaction.fee(), BLINDED_COMMITTER_FEE_BPS); let included_reveal_bundle_count = block.included_reveal_bundle_count(); - let reveal_finalizer_fee = if included_reveal_bundle_count == 0 { - 0 - } else { - blinded_fee_share(transaction.fee(), BLINDED_REVEAL_FINALIZER_FEE_BPS) - }; + let available_reveal_bundle_slots = ledger + .burn_leader_ranks_for_block(block.height) + .map(|ranks| ranks.len()) + .unwrap_or(REVEAL_COMMITTEE_SIZE); + let reveal_finalizer_fee = blinded_reveal_finalizer_fee( + transaction.fee(), + included_reveal_bundle_count, + available_reveal_bundle_slots, + ); let reveal_bundle_signer_fees = blinded_fee_share(transaction.fee(), BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS) .saturating_mul(included_reveal_bundle_count as u64); diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -34,10 +34,10 @@ use crate::{ }, domain::{ Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, - BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BLINDED_REVEAL_FINALIZER_FEE_BPS, BlindedReveal, - BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, Ledger, MINE_FINALIZER_FEE, - MINE_REWARD, OutPoint, RevealedBlindedTransaction, Transaction, TxInput, TxOutput, Wallet, - hex_hash, revealed_blinded_transactions, + BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, BlindedTransaction, Block, + BurnLeaderRank, ChainSnapshot, Ledger, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, + REVEAL_COMMITTEE_SIZE, RevealedBlindedTransaction, Transaction, TxInput, TxOutput, Wallet, + blinded_reveal_finalizer_fee, hex_hash, revealed_blinded_transactions, }, }; @@ -2089,6 +2089,22 @@ fn known_output_index( .iter() .map(|block| (block.height, block)) .collect::<BTreeMap<_, _>>(); + let reveal_bundle_slots_by_height = Ledger::from_persisted_snapshot(snapshot.clone()) + .ok() + .map(|ledger| { + snapshot + .blocks + .iter() + .map(|block| { + let slots = ledger + .burn_leader_ranks_for_block(block.height) + .map(|ranks| ranks.len()) + .unwrap_or(REVEAL_COMMITTEE_SIZE); + (block.height, slots) + }) + .collect::<BTreeMap<_, _>>() + }) + .unwrap_or_default(); let blinded_by_commitment = snapshot .blocks .iter() @@ -2129,11 +2145,14 @@ fn known_output_index( ); } if let Some(block) = blocks_by_height.get(&revealed.height) { - let reveal_finalizer_fee = if block.included_reveal_bundle_count() == 0 { - 0 - } else { - blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS) - }; + let reveal_finalizer_fee = blinded_reveal_finalizer_fee( + fee, + block.included_reveal_bundle_count(), + reveal_bundle_slots_by_height + .get(&revealed.height) + .copied() + .unwrap_or(REVEAL_COMMITTEE_SIZE), + ); if reveal_finalizer_fee > 0 { outputs.insert( blinded_executor_fee_outpoint(&revealed.commitment), diff --git a/src/app.rs b/src/app.rs @@ -15,9 +15,9 @@ use tokio::sync::Mutex; use crate::domain::{ Amount, BlindedReveal, BlindedTransaction, Block, BuiltBlindedTransaction, BurnLeaderRank, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, Ledger, - MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MINE_FINALIZER_FEE, OutPoint, OwnedBlindedTransaction, - PreparedBlock, RevealBundle, StratumMineShare, StratumMineTemplate, Transaction, - TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, + OwnedBlindedTransaction, PreparedBlock, RevealBundle, StratumMineShare, StratumMineTemplate, + Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -618,7 +618,7 @@ impl NodeCore { NodeStatus { app_version: env!("CARGO_PKG_VERSION").to_string(), wallet_address: self.wallet.address().to_string(), - wallet_balance: self.ledger.balance_of(self.wallet.address()), + wallet_balance: self.wallet_projected_balance(), wallet_locked: self.wallet.is_locked(), launch_profile: LaunchProfileStatus { profile_id: launch_profile.profile_id.clone(), @@ -653,6 +653,35 @@ impl NodeCore { } } + fn wallet_projected_balance(&self) -> Amount { + let address = self.wallet.address(); + let mut balance = self.ledger.balance_of(address); + let confirmed_outputs = self + .ledger + .utxos_for_address(address) + .into_iter() + .map(|(outpoint, output)| (outpoint, output.amount)) + .collect::<BTreeMap<_, _>>(); + + for (commitment, payload) in &self.owned_blinded_payloads { + if !self.ledger.has_unrevealed_blinded_transaction(commitment) { + continue; + } + let output_total = transaction_output_total_for_address(payload, address); + if self.ledger.has_active_blinded_transaction(commitment) { + balance = balance.saturating_add(output_total); + } else { + let input_total = + transaction_input_total_from_outputs(payload, address, &confirmed_outputs); + balance = balance + .saturating_sub(input_total) + .saturating_add(output_total); + } + } + + balance + } + pub fn set_burn_per_block(&mut self, amount: Amount) -> Result<Option<Transaction>> { self.set_automatic_burn(amount, self.burn_fee) } @@ -2152,6 +2181,34 @@ fn converge_fee_by_byte( )) } +fn transaction_output_total_for_address(transaction: &Transaction, address: &str) -> Amount { + match transaction { + Transaction::Transfer { outputs, .. } => outputs, + Transaction::Burn { change, .. } => change, + Transaction::Mine { recipient, .. } if recipient == address => return MINE_REWARD, + Transaction::Mine { .. } => return 0, + } + .iter() + .filter(|output| output.address == address) + .fold(0_u64, |total, output| total.saturating_add(output.amount)) +} + +fn transaction_input_total_from_outputs( + transaction: &Transaction, + address: &str, + outputs: &BTreeMap<OutPoint, Amount>, +) -> Amount { + let inputs = match transaction { + Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs, + Transaction::Mine { .. } => return 0, + }; + inputs + .iter() + .filter(|input| input.owner == address) + .filter_map(|input| outputs.get(&input.outpoint)) + .fold(0_u64, |total, amount| total.saturating_add(*amount)) +} + #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -2611,6 +2668,58 @@ mod tests { } #[test] + fn status_wallet_balance_includes_owned_blinded_change_before_and_after_commit() { + let alice = Wallet::from_seed("owned-blinded-balance-alice"); + let bob = Wallet::from_seed("owned-blinded-balance-bob"); + let carol = Wallet::from_seed("owned-blinded-balance-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let starting_balance = 2 * MICRO_IUNA; + let burn_amount = MICRO_IUNA / 10; + let fee = MICRO_IUNA / 10; + let expected_balance = starting_balance - burn_amount - fee; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(carol.address().to_string(), starting_balance); + let ledger = Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let mut wallet_node = NodeCore::from_ledger(carol.clone(), ledger.clone(), 0); + let mut finalizer_ledger = ledger; + + let blinded = wallet_node + .blinded_burn_with_fee(burn_amount, fee, wallet_node.chain_height() + 4) + .unwrap(); + + assert_eq!(wallet_node.status().wallet_balance, expected_balance); + + finalizer_ledger + .submit_blinded_transaction(blinded.clone()) + .unwrap(); + let leader = finalizer_ledger.expected_leader_for_next_block().unwrap(); + let finalizer = finalizers + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap(); + let burn = finalizer_ledger.build_burn(finalizer, 1, 0).unwrap(); + finalizer_ledger.submit_transaction(burn).unwrap(); + let commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap(); + + wallet_node + .receive(GossipEnvelope::Block(commit_block)) + .unwrap(); + + assert_eq!(wallet_node.ledger().balance_of(carol.address()), 0); + assert_eq!(wallet_node.status().wallet_balance, expected_balance); + } + + #[test] fn owned_blinded_transaction_restore_requeues_pending_commit() { let alice = Wallet::from_seed("owned-blinded-restore-pending-alice"); let bob = Wallet::from_seed("owned-blinded-restore-pending-bob"); diff --git a/src/domain.rs b/src/domain.rs @@ -33,6 +33,26 @@ 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; pub const BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS: u64 = 1_000; + +pub fn reveal_committee_slot_count(eligible_rank_count: usize) -> usize { + eligible_rank_count.min(REVEAL_COMMITTEE_SIZE) +} + +pub fn blinded_reveal_finalizer_fee( + fee: Amount, + included_bundle_count: usize, + available_bundle_slots: usize, +) -> Amount { + if included_bundle_count == 0 { + return 0; + } + let available_bundle_slots = available_bundle_slots.clamp(1, REVEAL_COMMITTEE_SIZE); + let included_bundle_count = included_bundle_count.min(available_bundle_slots); + let full_share = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS); + ((full_share as u128 * included_bundle_count as u128) / available_bundle_slots as u128) + as Amount +} + const MINE_RETARGET_WINDOW_BLOCKS: u64 = 10; const MINE_TARGET_ACTIONS_PER_BLOCK: u64 = 1; const MINE_MAX_RETARGET_STEP_BITS: u32 = 2; @@ -2045,13 +2065,23 @@ impl Ledger { pub fn reveal_committee_for_height(&self, height: u64) -> Vec<RevealCommitteeMember> { let ranked = ranked_tickets_for_height(self.tip(), height, &self.tickets); - let committee_start = ranked.len().saturating_sub(REVEAL_COMMITTEE_SIZE); - ranked + let mut selected = Vec::new(); + if !ranked.is_empty() { + selected.push(0); + } + for index in (0..ranked.len()).rev() { + if selected.len() >= reveal_committee_slot_count(ranked.len()) { + break; + } + if !selected.contains(&index) { + selected.push(index); + } + } + selected .into_iter() .enumerate() - .skip(committee_start) - .enumerate() - .filter_map(|(slot, (rank, ticket))| { + .filter_map(|(slot, rank)| { + let ticket = ranked.get(rank)?.clone(); Some(RevealCommitteeMember { slot: u8::try_from(slot).ok()?, rank: u32::try_from(rank).ok()?, @@ -3042,6 +3072,7 @@ impl Ledger { bail!("block VDF output is invalid"); } + let reveal_bundle_slot_count = self.reveal_committee_for_height(block.height).len(); let mut utxos = self.utxos.clone(); let mut signatures = BTreeSet::new(); let mut revealed_transactions = Vec::new(); @@ -3070,6 +3101,7 @@ impl Ledger { &block.miner, &tx, &block.reveal_bundle_section.signatures, + reveal_bundle_slot_count, )?; revealed_transactions.push(tx); } @@ -4924,17 +4956,15 @@ fn credit_blinded_fee_outputs( reveal_executor: &str, transaction: &Transaction, reveal_bundle_signatures: &[RevealBundleSignature], + available_bundle_slots: usize, ) -> Result<()> { let fee = transaction.fee(); if fee == 0 { return Ok(()); } let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); - let reveal_finalizer_fee = if reveal_bundle_signatures.is_empty() { - 0 - } else { - blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS) - }; + let reveal_finalizer_fee = + blinded_reveal_finalizer_fee(fee, reveal_bundle_signatures.len(), available_bundle_slots); let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); let mut outputs = Vec::new(); if committer_fee > 0 { @@ -6026,8 +6056,15 @@ mod tests { }; let mut utxos = BTreeMap::new(); - credit_blinded_fee_outputs(&mut utxos, &active, executor.address(), &transaction, &[]) - .unwrap(); + credit_blinded_fee_outputs( + &mut utxos, + &active, + executor.address(), + &transaction, + &[], + 3, + ) + .unwrap(); assert!(!utxos.contains_key(&blinded_committer_fee_outpoint(&commitment))); assert!(!utxos.contains_key(&blinded_executor_fee_outpoint(&commitment))); @@ -6061,8 +6098,15 @@ mod tests { }; let mut utxos = BTreeMap::new(); - credit_blinded_fee_outputs(&mut utxos, &active, executor.address(), &transaction, &[]) - .unwrap(); + credit_blinded_fee_outputs( + &mut utxos, + &active, + executor.address(), + &transaction, + &[], + 3, + ) + .unwrap(); assert_eq!( utxos.get(&blinded_committer_fee_outpoint(&commitment)), @@ -6122,6 +6166,7 @@ mod tests { executor.address(), &transaction, &signatures, + 3, ) .unwrap(); @@ -6136,7 +6181,7 @@ mod tests { utxos.get(&blinded_executor_fee_outpoint(&commitment)), Some(&TxOutput { address: executor.address().to_string(), - amount: 35, + amount: 23, }) ); assert_eq!( @@ -6156,6 +6201,20 @@ mod tests { } #[test] + fn blinded_reveal_finalizer_fee_scales_by_available_reveal_bundle_slots() { + let fee = 300_000; + let full_share = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS); + + assert_eq!(blinded_reveal_finalizer_fee(fee, 0, 3), 0); + assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 3), full_share / 3); + assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 2), full_share / 2); + assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 1), full_share); + assert_eq!(blinded_reveal_finalizer_fee(fee, 2, 3), full_share * 2 / 3); + assert_eq!(blinded_reveal_finalizer_fee(fee, 3, 3), full_share); + assert_eq!(blinded_reveal_finalizer_fee(fee, 4, 3), full_share); + } + + #[test] fn transfer_rejects_invalid_recipient_address() { let alice = Wallet::from_seed("invalid-transfer-recipient-alice"); let ledger = ledger_with_wallet_utxos(&alice, &[10]); @@ -6936,7 +6995,14 @@ mod tests { total + transaction.amount() + transaction.fee() }); let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); - let reveal_finalizer_fee = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS); + let reveal_finalizer_fee = blinded_reveal_finalizer_fee( + fee, + reveal_block.included_reveal_bundle_count(), + ledger + .burn_leader_ranks_for_block(reveal_block.height) + .unwrap() + .len(), + ); let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); let commitment = &commit_block.blinded_transactions[0].commitment; assert_eq!( @@ -7173,6 +7239,48 @@ mod tests { } #[test] + fn reveal_committee_includes_next_block_finalizer_as_slot_zero() { + let alice = Wallet::from_seed("bundle-finalizer-slot-alice"); + let bob = Wallet::from_seed("bundle-finalizer-slot-bob"); + let carol = Wallet::from_seed("bundle-finalizer-slot-carol"); + let dave = Wallet::from_seed("bundle-finalizer-slot-dave"); + let erin = Wallet::from_seed("bundle-finalizer-slot-erin"); + let finalizers = [alice.clone(), bob.clone(), carol.clone(), dave.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&erin, 10 * MICRO_IUNA)]); + let blinded = ledger + .build_blinded_burn(&erin, 3, 7, ledger.height() + 4) + .unwrap(); + let commitment = blinded.transaction.commitment.clone(); + ledger + .submit_blinded_transaction(blinded.transaction) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + ledger.submit_blinded_reveal(blinded.reveal).unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + + let leader = ledger.expected_leader_for_next_block().unwrap(); + let committee = ledger.reveal_committee_for_next_block(); + let leader_wallet = wallet_for_address(&finalizers, &leader); + let bundle = ledger.build_reveal_bundle(leader_wallet).unwrap().unwrap(); + + assert_eq!(committee.first().map(|member| member.slot), Some(0)); + assert_eq!(committee.first().map(|member| member.rank), Some(0)); + assert_eq!( + committee.first().map(|member| member.owner.as_str()), + Some(leader.as_str()) + ); + assert_eq!(bundle.slot, 0); + assert_eq!(bundle.member, leader); + assert!( + bundle + .reveals + .iter() + .any(|reveal| reveal.commitment == commitment) + ); + } + + #[test] fn reveal_bundle_section_deduplicates_reveals_with_slot_mask() { let alice = Wallet::from_seed("bundle-compact-alice"); let bob = Wallet::from_seed("bundle-compact-bob");