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:
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");