iuna

iuna

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

commit 31971c5fcdbf615c3e06ea6d87876a0eb9ef824a
parent 761bdb43dcca456dffa035736b4819755f0361c8
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Thu, 13 Aug 2026 23:22:45 +0200

Activate unique-owner reveal committees at height 500

Diffstat:
Mdocs/protocol.md | 2+-
Msrc/adapters/http/index_html.rs | 3++-
Msrc/adapters/http/tests.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/adapters/http/ui.rs | 48++++++++++++++++++++++++++++++------------------
Msrc/adapters/ui_data_store.rs | 13+++++++++++--
Msrc/adapters/ui_index.rs | 13+++++++++++--
Msrc/domain.rs | 23+++++++++++++++++++++--
Msrc/domain/ledger_queries.rs | 43++++++++++++++++++++++++++++++++-----------
Msrc/domain/protocol.rs | 1+
Msrc/domain/tests.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 228 insertions(+), 47 deletions(-)

diff --git a/docs/protocol.md b/docs/protocol.md @@ -140,7 +140,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. 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. +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. 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. diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -362,6 +362,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .detail-kv .key { color: #8d989f; } .detail-link { width: fit-content; max-width: 100%; padding: 0; border: 0; background: transparent; color: #d7f2ff; font: inherit; text-align: left; cursor: pointer; } .detail-link code { color: inherit; text-decoration: underline; text-underline-offset: 3px; } + .fee-penalty-value.penalty { color: #ffb1a8; font-weight: 900; } .rank-list { display: grid; gap: 8px; } .rank-row { display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 10px; align-items: start; border: 1px solid #30383d; border-radius: 8px; padding: 10px; background: #15191d; } .rank-number { color: #d7f2ff; font-weight: 700; } @@ -941,7 +942,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="detail-kv"><div class="key">Mode</div><div x-text="selectedBlock.finalizer_mode === 'recovery' ? 'Recovery' : `Rank ${selectedBlock.finalizer_rank ?? 0}`"></div></div> <div class="detail-kv"><div class="key">Reward</div><div>IUNA <span x-text="amountLabel(selectedBlock.reward)"></span></div></div> <div class="detail-kv"><div class="key">Reveal Lists</div><div x-text="blockRevealListRatio(selectedBlock)"></div></div> - <div class="detail-kv"><div class="key">Fee Penalty</div><div>IUNA <span x-text="amountLabel(blockRevealFeePenaltyAmount(selectedBlock))"></span></div></div> + <div class="detail-kv"><div class="key">Fee Penalty</div><div class="fee-penalty-value" :class="{ penalty: blockRevealFeePenaltyAmount(selectedBlock) > 0 }">IUNA <span x-text="amountLabel(blockRevealFeePenaltyAmount(selectedBlock))"></span></div></div> <div class="detail-kv"><div class="key">Burns</div><div x-text="blockBurnCount(selectedBlock)"></div></div> <div class="detail-kv"><div class="key">Transfers</div><div x-text="blockTransferCount(selectedBlock)"></div></div> <div class="detail-kv"><div class="key">Total Burned</div><div>IUNA <span x-text="amountLabel(blockBurned(selectedBlock))"></span></div></div> diff --git a/src/adapters/http/tests.rs b/src/adapters/http/tests.rs @@ -18,9 +18,10 @@ use crate::{ }, app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus}, domain::{ - Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, GenesisBurn, - LaunchProfile, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MaskedBlindedReveal, OutPoint, - RevealBundleSection, RevealBundleSignature, Transaction, TxInput, TxOutput, Wallet, + Amount, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, + GenesisBurn, LaunchProfile, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MaskedBlindedReveal, + OutPoint, RevealBundleSection, RevealBundleSignature, Transaction, TxInput, TxOutput, + Wallet, }, }; @@ -195,6 +196,15 @@ fn block_detail_reconstructs_revealed_items_from_snapshot_blocks() { let mut commit_block = fake_block(7, Vec::new()); commit_block.blinded_transactions = vec![built.transaction.clone()]; let mut reveal_block = fake_block(8, Vec::new()); + reveal_block.transactions = vec![Transaction::Mine { + recipient: bob.address().to_string(), + anchor: "a".repeat(64), + salt: 1, + nonce: 2, + difficulty_bits: 12, + proof_header: None, + signature: "b".repeat(64), + }]; reveal_block.reveal_bundle_section = RevealBundleSection { signatures: vec![RevealBundleSignature { slot: 0, @@ -210,12 +220,42 @@ fn block_detail_reconstructs_revealed_items_from_snapshot_blocks() { allocations, vec![commit_block.clone(), reveal_block.clone()], ); + let mut burn_leader_ranks = BTreeMap::new(); + burn_leader_ranks.insert( + reveal_block.hash.clone(), + vec![ + BurnLeaderRank { + rank: 0, + ticket_id: "ticket-0".to_string(), + owner: alice.address().to_string(), + amount: 1, + eligible_from_height: 8, + eligible_until_height: 8, + }, + BurnLeaderRank { + rank: 1, + ticket_id: "ticket-1".to_string(), + owner: bob.address().to_string(), + amount: 1, + eligible_from_height: 8, + eligible_until_height: 8, + }, + BurnLeaderRank { + rank: 2, + ticket_id: "ticket-2".to_string(), + owner: "carol".to_string(), + amount: 1, + eligible_from_height: 8, + eligible_until_height: 8, + }, + ], + ); let blocks = super::ui_blocks( vec![commit_block, reveal_block], &snapshot, &[], - &BTreeMap::new(), + &burn_leader_ranks, ); assert_eq!(blocks[0].transactions.len(), 1); @@ -224,15 +264,20 @@ fn block_detail_reconstructs_revealed_items_from_snapshot_blocks() { blocks[0].transactions[0].commitment.as_deref(), Some(built.transaction.commitment.as_str()) ); - assert_eq!(blocks[1].transactions.len(), 1); - assert_eq!(blocks[1].transactions[0].kind, "transfer"); - assert!(blocks[1].transactions[0].revealed); - assert_eq!(blocks[1].transactions[0].amount, transfer.amount()); - assert_eq!(blocks[1].transactions[0].to.as_deref(), Some(bob.address())); + assert_eq!(blocks[1].transactions.len(), 2); + assert_eq!(blocks[1].transactions[0].kind, "mine"); + assert_eq!(blocks[1].transactions[1].kind, "transfer"); + assert!(blocks[1].transactions[1].revealed); + assert_eq!(blocks[1].transactions[1].amount, transfer.amount()); + assert_eq!(blocks[1].transactions[1].to.as_deref(), Some(bob.address())); assert_eq!(blocks[1].revealed_transactions.len(), 1); assert_eq!(blocks[1].reveal_fee_penalty.reveal_lists_included, 1); assert_eq!(blocks[1].reveal_fee_penalty.committee_size, 3); - assert_eq!(blocks[1].reveal_fee_penalty.fee_penalty, 1); + assert_eq!(blocks[1].total_fees, transfer.fee() + MINE_FINALIZER_FEE); + assert_eq!( + blocks[1].reveal_fee_penalty.fee_penalty, + ((transfer.fee() + MINE_FINALIZER_FEE) as u128 * 2 / 3) as u64 + ); } #[test] @@ -251,6 +296,11 @@ fn block_detail_markup_uses_blinded_and_revealed_labels() { assert!(super::INDEX_HTML.contains("mempoolSeenTimeLabel(tx)")); assert!(super::INDEX_HTML.contains("<details class=\"tx-section\">")); assert!(super::INDEX_HTML.contains("<summary class=\"tx-section-title\">")); + assert!(super::INDEX_HTML.contains("fee-penalty-value")); + assert!( + super::INDEX_HTML + .contains(":class=\"{ penalty: blockRevealFeePenaltyAmount(selectedBlock) > 0 }\"") + ); assert!(super::INDEX_HTML.contains("Commitment")); assert!(!super::INDEX_HTML.contains("<h3>Revealed</h3>")); assert!(app_js.contains("tx?.revealed ? \"revealed\"")); diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -1,10 +1,9 @@ use std::collections::BTreeMap; use crate::domain::{ - BLINDED_FEE_BPS_DENOMINATOR, BLINDED_REVEAL_FINALIZER_FEE_BPS, BlindedReveal, - BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, MINE_REWARD, OutPoint, - REVEAL_COMMITTEE_SIZE, RevealedBlindedTransaction, Transaction, TxInput, TxOutput, - blinded_reveal_finalizer_fee, reveal_committee_slot_count, + BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, MINE_REWARD, OutPoint, + RevealedBlindedTransaction, Transaction, TxInput, TxOutput, + reveal_committee_slot_count_for_height, }; use crate::adapters::ui_index::build_ui_chain_index; @@ -245,15 +244,28 @@ pub(super) fn ui_block( .unwrap_or_default(); let reveal_lists_included = block.included_reveal_bundle_count(); let committee_size = if ranks.is_empty() { - REVEAL_COMMITTEE_SIZE + reveal_lists_included } else { - reveal_committee_slot_count(ranks.len()) + reveal_committee_slot_count_for_height( + block.height, + ranks.len(), + ranks.iter().map(|rank| rank.owner.as_str()), + ) }; + let public_fees = block + .transactions + .iter() + .fold(0_u64, |total, tx| total.saturating_add(tx.fee())); let revealed_fees = revealed_transactions.iter().fold(0_u64, |total, revealed| { total.saturating_add(revealed.transaction.fee()) }); - let reveal_fee_penalty = - reveal_fee_penalty(revealed_transactions, reveal_lists_included, committee_size); + let total_fees = public_fees.saturating_add(revealed_fees); + let reveal_fee_penalty = reveal_fee_penalty( + total_fees, + !revealed_transactions.is_empty(), + reveal_lists_included, + committee_size, + ); let transaction_bytes = block .transactions .iter() @@ -323,7 +335,7 @@ pub(super) fn ui_block( finalizer_mode: block.finalizer_mode, finalizer_rank: block.finalizer_rank, reward: block.reward, - total_fees: block.reward.saturating_add(revealed_fees), + total_fees, total_bytes, transaction_bytes, transaction_byte_breakdown, @@ -345,18 +357,18 @@ pub(super) fn ui_block( } fn reveal_fee_penalty( - revealed_transactions: &[RevealedBlindedTransaction], + total_fees: u64, + has_reveals: bool, reveal_lists_included: usize, committee_size: usize, ) -> UiRevealFeePenalty { - let fee_penalty = revealed_transactions.iter().fold(0_u64, |total, revealed| { - let fee = revealed.transaction.fee(); - let full_finalizer_share = ((fee as u128 * BLINDED_REVEAL_FINALIZER_FEE_BPS as u128) - / BLINDED_FEE_BPS_DENOMINATOR as u128) as u64; - let paid_finalizer_share = - blinded_reveal_finalizer_fee(fee, reveal_lists_included, committee_size); - total.saturating_add(full_finalizer_share.saturating_sub(paid_finalizer_share)) - }); + let fee_penalty = + if !has_reveals || committee_size == 0 || reveal_lists_included >= committee_size { + 0 + } else { + let missing = committee_size.saturating_sub(reveal_lists_included); + ((total_fees as u128 * missing as u128) / committee_size as u128) as u64 + }; UiRevealFeePenalty { reveal_lists_included, committee_size, diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs @@ -17,7 +17,7 @@ use crate::{ BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, Ledger, MINE_REWARD, OutPoint, REVEAL_COMMITTEE_SIZE, RevealedBlindedTransaction, Transaction, TxInput, TxOutput, blinded_reveal_finalizer_fee, - hex_hash, reveal_committee_slot_count, revealed_blinded_transactions, + hex_hash, reveal_committee_slot_count_for_height, revealed_blinded_transactions, }, }; @@ -1126,7 +1126,16 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> .map(|ranks_by_height| { ranks_by_height .into_iter() - .map(|(height, ranks)| (height, reveal_committee_slot_count(ranks.len()))) + .map(|(height, ranks)| { + ( + height, + reveal_committee_slot_count_for_height( + height, + ranks.len(), + ranks.iter().map(|rank| rank.owner.as_str()), + ), + ) + }) .collect::<BTreeMap<_, _>>() }) .unwrap_or_default(); diff --git a/src/adapters/ui_index.rs b/src/adapters/ui_index.rs @@ -4,7 +4,7 @@ use crate::domain::{ Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, Ledger, MINE_REWARD, OutPoint, REVEAL_COMMITTEE_SIZE, RevealedBlindedTransaction, Transaction, - TxOutput, blinded_reveal_finalizer_fee, hex_hash, reveal_committee_slot_count, + TxOutput, blinded_reveal_finalizer_fee, hex_hash, reveal_committee_slot_count_for_height, revealed_blinded_transactions, }; @@ -178,7 +178,16 @@ fn reveal_bundle_slots_by_height(snapshot: &ChainSnapshot) -> BTreeMap<u64, usiz .map(|ranks_by_height| { ranks_by_height .into_iter() - .map(|(height, ranks)| (height, reveal_committee_slot_count(ranks.len()))) + .map(|(height, ranks)| { + ( + height, + reveal_committee_slot_count_for_height( + height, + ranks.len(), + ranks.iter().map(|rank| rank.owner.as_str()), + ), + ) + }) .collect() }) .unwrap_or_default() diff --git a/src/domain.rs b/src/domain.rs @@ -1,5 +1,6 @@ #[cfg(test)] -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; +use std::collections::BTreeSet; mod blinded; mod block; @@ -76,7 +77,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, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, + REVEAL_COMMITTEE_SIZE, TransactionSubmitOutcome, UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT, + VDF_TARGET_BLOCK_MS, }; use protocol::{ BLINDED_KEY_BYTES, BLINDED_NONCE_BYTES, BLOCK_MEDIAN_TIME_PAST_WINDOW, @@ -128,6 +130,23 @@ pub fn reveal_committee_slot_count(eligible_rank_count: usize) -> usize { eligible_rank_count.min(REVEAL_COMMITTEE_SIZE) } +pub fn reveal_committee_slot_count_for_owners<'a>( + owners: impl IntoIterator<Item = &'a str>, +) -> usize { + reveal_committee_slot_count(owners.into_iter().collect::<BTreeSet<_>>().len()) +} + +pub fn reveal_committee_slot_count_for_height<'a>( + height: u64, + eligible_rank_count: usize, + owners: impl IntoIterator<Item = &'a str>, +) -> usize { + if height < UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT { + return reveal_committee_slot_count(eligible_rank_count); + } + reveal_committee_slot_count_for_owners(owners) +} + pub fn blinded_reveal_finalizer_fee( fee: Amount, included_bundle_count: usize, diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -14,7 +14,8 @@ use super::ticket::{ use super::{ Amount, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, ChainStatus, LaunchProfile, Ledger, OutPoint, RevealCommitteeMember, RevealedBlindedTransaction, - Transaction, TxOutput, reveal_committee_slot_count, + Transaction, TxOutput, UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT, reveal_committee_slot_count, + reveal_committee_slot_count_for_height, }; fn apply_historical_ticket_block( @@ -191,18 +192,38 @@ 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 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; + let selected = if height < UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT { + let mut selected = Vec::new(); + if !ranked.is_empty() { + selected.push(0); } - if !selected.contains(&index) { - selected.push(index); + 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 + } else { + let target_slots = reveal_committee_slot_count_for_height( + height, + ranked.len(), + ranked.iter().map(|ticket| ticket.owner.as_str()), + ); + let mut seen_owners = BTreeSet::new(); + let mut selected = Vec::new(); + for (index, ticket) in ranked.iter().enumerate() { + if selected.len() >= target_slots { + break; + } + if seen_owners.insert(ticket.owner.clone()) { + selected.push(index); + } + } + selected + }; selected .into_iter() .enumerate() diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs @@ -15,6 +15,7 @@ pub const MINE_DIFFICULTY_BITS: u32 = 12; pub const MINE_ACTIONS_PER_ANCHOR_LIMIT: usize = 2; 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 BLINDED_FEE_BPS_DENOMINATOR: u64 = 10_000; pub const BLINDED_COMMITTER_FEE_BPS: u64 = 3_500; diff --git a/src/domain/tests.rs b/src/domain/tests.rs @@ -2086,6 +2086,65 @@ fn reveal_bundle_section_deduplicates_reveals_with_slot_mask() { ); } +fn ledger_with_duplicate_owner_reveal_tickets() -> Ledger { + let alice = Wallet::from_seed("bundle-unique-owner-alice"); + let bob = Wallet::from_seed("bundle-unique-owner-bob"); + let mut ledger = Ledger::new(BTreeMap::new(), 1); + ledger.tickets = vec![ + BurnTicket { + id: "alice-0".to_string(), + owner: alice.address().to_string(), + amount: MICRO_IUNA, + eligible_from_height: 499, + eligible_until_height: 500, + }, + BurnTicket { + id: "alice-1".to_string(), + owner: alice.address().to_string(), + amount: MICRO_IUNA, + eligible_from_height: 499, + eligible_until_height: 500, + }, + BurnTicket { + id: "bob-0".to_string(), + owner: bob.address().to_string(), + amount: MICRO_IUNA, + eligible_from_height: 499, + eligible_until_height: 500, + }, + ]; + ledger +} + +#[test] +fn reveal_committee_allows_duplicate_owners_before_height_500() { + let ledger = ledger_with_duplicate_owner_reveal_tickets(); + + let committee = ledger.reveal_committee_for_height(499); + let owners = committee + .iter() + .map(|member| member.owner.as_str()) + .collect::<BTreeSet<_>>(); + + assert_eq!(committee.len(), 3); + assert_eq!(owners.len(), 2); +} + +#[test] +fn reveal_committee_uses_unique_ticket_owners_from_height_500() { + let ledger = ledger_with_duplicate_owner_reveal_tickets(); + + let committee = ledger.reveal_committee_for_height(500); + let owners = committee + .iter() + .map(|member| member.owner.as_str()) + .collect::<BTreeSet<_>>(); + + assert_eq!(committee.len(), 2); + assert_eq!(owners.len(), 2); + assert_eq!(committee.first().map(|member| member.rank), Some(0)); +} + #[test] fn reveal_bundle_validation_rejects_wrong_signature_and_slot() { let alice = Wallet::from_seed("bundle-invalid-alice");