iuna

iuna

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

commit 91adcf249843f87f767e575b3ea9ee68bb797ba2
parent 2ad29083b5ddb7034e9df96779daeb7db8bf53f3
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Thu, 13 Aug 2026 11:35:07 +0200

Show reveal metrics and finalizer status

Diffstat:
Msrc/adapters/http/index_html.rs | 6++++++
Msrc/adapters/http/tests.rs | 3+++
Msrc/adapters/http/types.rs | 8++++++++
Msrc/adapters/http/ui.rs | 37++++++++++++++++++++++++++++++++++---
Msrc/app.rs | 1+
Msrc/app/automatic_mining.rs | 22++++++++++++++++++++++
Msrc/app/automatic_mining/tests.rs | 21+++++++++++++++++++++
Msrc/app/node_lifecycle.rs | 2++
Msrc/app/status.rs | 5+++++
Msrc/app/types.rs | 1+
Msrc/main.rs | 8++++++++
Mwww/assets/iuna-ui.js | 28++++++++++++++++++++++++++++
12 files changed, 139 insertions(+), 3 deletions(-)

diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -644,6 +644,10 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="mine-stat-value" x-text="pobStatusLabel()"></div> </div> <div class="mine-stat"> + <div class="mine-stat-label">PoB Detail</div> + <div class="mine-stat-value" x-text="pobDetailLabel()" :title="pobDetailLabel()"></div> + </div> + <div class="mine-stat"> <div class="mine-stat-label">PoW State</div> <div class="mine-stat-value" x-text="powStatusShortLabel()"></div> </div> @@ -936,6 +940,8 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> </div> <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">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 @@ -230,6 +230,9 @@ fn block_detail_reconstructs_revealed_items_from_snapshot_blocks() { 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].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); } #[test] diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -346,6 +346,7 @@ pub(super) struct UiBlock { pub(super) transaction_byte_breakdown: Vec<UiByteBreakdown>, pub(super) blinded_transaction_bytes: usize, pub(super) reveal_bundle_bytes: usize, + pub(super) reveal_fee_penalty: UiRevealFeePenalty, pub(super) vdf_rounds: u64, pub(super) vdf_output: String, pub(super) leader_proof: Option<crate::domain::LeaderProof>, @@ -357,6 +358,13 @@ pub(super) struct UiBlock { } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(super) struct UiRevealFeePenalty { + pub(super) reveal_lists_included: usize, + pub(super) committee_size: usize, + pub(super) fee_penalty: Amount, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub(super) struct UiByteBreakdown { pub(super) label: &'static str, pub(super) bytes: usize, diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -1,8 +1,10 @@ use std::collections::BTreeMap; use crate::domain::{ - BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, MINE_REWARD, OutPoint, - RevealedBlindedTransaction, Transaction, TxInput, TxOutput, + 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, }; use crate::adapters::ui_index::build_ui_chain_index; @@ -10,7 +12,7 @@ use crate::adapters::ui_index::build_ui_chain_index; use super::{ HttpState, UiChainView, types::{ - UiBlock, UiByteBreakdown, UiRevealBundle, UiTransaction, UiTxInput, + UiBlock, UiByteBreakdown, UiRevealBundle, UiRevealFeePenalty, UiTransaction, UiTxInput, WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow, }, }; @@ -241,9 +243,17 @@ pub(super) fn ui_block( .get(&block.hash) .cloned() .unwrap_or_default(); + let reveal_lists_included = block.included_reveal_bundle_count(); + let committee_size = if ranks.is_empty() { + REVEAL_COMMITTEE_SIZE + } else { + reveal_committee_slot_count(ranks.len()) + }; 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 transaction_bytes = block .transactions .iter() @@ -319,6 +329,7 @@ pub(super) fn ui_block( transaction_byte_breakdown, blinded_transaction_bytes, reveal_bundle_bytes, + reveal_fee_penalty, vdf_rounds: block.vdf_rounds, vdf_output: block.vdf_output, leader_proof: block.leader_proof, @@ -333,6 +344,26 @@ pub(super) fn ui_block( } } +fn reveal_fee_penalty( + revealed_transactions: &[RevealedBlindedTransaction], + 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)) + }); + UiRevealFeePenalty { + reveal_lists_included, + committee_size, + fee_penalty, + } +} + fn ui_revealed_transaction( transaction: &Transaction, outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, diff --git a/src/app.rs b/src/app.rs @@ -110,6 +110,7 @@ pub struct NodeCore { recovery_vdf_top_rank_percent: u8, last_auto_burn_height: Option<u64>, last_auto_anchor_burn_height: Option<u64>, + last_auto_finalization_status: Option<String>, last_auto_pow_mine_anchor: Option<String>, last_auto_pow_mine_status: Option<String>, auto_pow_mine_cursor: Option<AutoPowMineCursor>, diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -158,11 +158,13 @@ impl NodeCore { if self.wallet.is_locked() { plan.skipped_reason = Some("wallet is locked".to_string()); + self.last_auto_finalization_status = plan.skipped_reason.clone(); return plan; } if !self.automatic_mining_enabled { plan.skipped_reason = Some("automatic mining is off".to_string()); + self.last_auto_finalization_status = plan.skipped_reason.clone(); return plan; } @@ -170,6 +172,7 @@ impl NodeCore { Ok(tx) => plan.burned = tx, Err(error) => { plan.skipped_reason = Some(format!("automatic burn failed: {error:#}")); + self.last_auto_finalization_status = plan.skipped_reason.clone(); return plan; } } @@ -188,10 +191,12 @@ impl NodeCore { "collecting blinded reveals for next block ({:.1}s remaining)", wait_ms as f64 / 1000.0 )); + self.last_auto_finalization_status = plan.skipped_reason.clone(); return plan; } if let Err(error) = self.publish_reveal_bundle_for_next_block() { plan.skipped_reason = Some(format!("{error:#}")); + self.last_auto_finalization_status = plan.skipped_reason.clone(); return plan; } @@ -201,16 +206,23 @@ impl NodeCore { "wallet finalizer rank {rank} is outside the top {}% VDF threshold", self.recovery_vdf_top_rank_percent )); + self.last_auto_finalization_status = plan.skipped_reason.clone(); return plan; } } else { if self.should_prepare_recovery_vdf(timestamp_ms) { match self.prepare_recovery_block_with_local_anchor(timestamp_ms) { Ok(work) => { + self.last_auto_finalization_status = Some(format!( + "running recovery VDF for candidate block {} ({} rounds)", + work.height(), + work.vdf_rounds() + )); plan.work = Some(work); } Err(error) => { plan.skipped_reason = Some(format!("{error:#}")); + self.last_auto_finalization_status = plan.skipped_reason.clone(); } } } else { @@ -218,16 +230,23 @@ impl NodeCore { plan.skipped_reason = selected_leader.map(|leader| { format!("wallet is waiting for selected finalizer {leader} to finish the VDF") }); + self.last_auto_finalization_status = plan.skipped_reason.clone(); } return plan; } match self.prepare_next_block_with_local_anchor(timestamp_ms) { Ok(work) => { + self.last_auto_finalization_status = Some(format!( + "running VDF for candidate block {} ({} rounds)", + work.height(), + work.vdf_rounds() + )); plan.work = Some(work); } Err(error) => { plan.skipped_reason = Some(format!("{error:#}")); + self.last_auto_finalization_status = plan.skipped_reason.clone(); } } @@ -384,6 +403,9 @@ impl NodeCore { } fn wallet_rank_runs_vdf(&self, rank: u32) -> bool { + if rank == 0 { + return true; + } let rank_count = self.ledger.finalizer_rank_count_for_next_block(); let allowed = allowed_recovery_vdf_rank_count(rank_count, self.recovery_vdf_top_rank_percent); diff --git a/src/app/automatic_mining/tests.rs b/src/app/automatic_mining/tests.rs @@ -330,6 +330,27 @@ fn automatic_finalization_respects_zero_recovery_vdf_threshold() { } #[test] +fn selected_finalizer_runs_vdf_with_zero_recovery_vdf_threshold() { + let alice = Wallet::from_seed("automatic-selected-zero-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + let ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) + .unwrap(); + assert_eq!( + ledger.expected_leader_for_next_block().as_deref(), + Some(alice.address()) + ); + let mut node = NodeCore::from_ledger(alice.clone(), ledger, 1); + node.set_recovery_vdf_top_rank_percent(0); + + let plan = node.prepare_automatic_finalization(1); + + assert!(plan.work.is_some()); + assert!(plan.skipped_reason.is_none()); +} + +#[test] fn automatic_non_leader_burn_is_queued_as_blinded() { let alice = Wallet::from_seed("auto-blinded-burn-alice"); let bob = Wallet::from_seed("auto-blinded-burn-bob"); diff --git a/src/app/node_lifecycle.rs b/src/app/node_lifecycle.rs @@ -98,6 +98,7 @@ impl NodeCore { recovery_vdf_top_rank_percent: recovery_vdf_top_rank_percent.min(100), last_auto_burn_height: None, last_auto_anchor_burn_height: None, + last_auto_finalization_status: None, last_auto_pow_mine_anchor: None, last_auto_pow_mine_status: None, auto_pow_mine_cursor: None, @@ -151,6 +152,7 @@ impl NodeCore { pub(super) fn reset_automatic_mining_progress(&mut self) { self.last_auto_burn_height = None; self.last_auto_anchor_burn_height = None; + self.last_auto_finalization_status = None; self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; self.auto_pow_mine_cursor = None; diff --git a/src/app/status.rs b/src/app/status.rs @@ -42,6 +42,7 @@ impl NodeCore { burn_per_block: self.burn_per_block, automatic_burn_fee: self.burn_fee, automatic_pow_mine_fee: MINE_FINALIZER_FEE, + last_auto_finalization_status: self.last_auto_finalization_status.clone(), last_auto_pow_mine_anchor: self.last_auto_pow_mine_anchor.clone(), last_auto_pow_mine_status: if self.pow_mining_enabled && !self.has_real_chain() { Some("waiting for a real chain before PoW mining can start".to_string()) @@ -164,6 +165,10 @@ impl NodeCore { self.pow_mining_workers } + pub fn record_automatic_finalization_status(&mut self, message: impl Into<String>) { + self.last_auto_finalization_status = Some(message.into()); + } + pub fn set_recovery_vdf_top_rank_percent(&mut self, percent: u8) { self.recovery_vdf_top_rank_percent = percent.min(100); } diff --git a/src/app/types.rs b/src/app/types.rs @@ -141,6 +141,7 @@ pub struct MiningStatus { pub burn_per_block: Amount, pub automatic_burn_fee: Amount, pub automatic_pow_mine_fee: Amount, + pub last_auto_finalization_status: Option<String>, pub last_auto_pow_mine_anchor: Option<String>, pub last_auto_pow_mine_status: Option<String>, pub vdf_rounds: u64, diff --git a/src/main.rs b/src/main.rs @@ -505,6 +505,14 @@ async fn run_automatic_finalizer(node: SharedNode, gossip: p2p::GossipNetwork, d let (finalized, outbox) = { let mut node = node.lock().await; let finalized = node.complete_prepared_block_at(work, vdf_output, publish_timestamp_ms); + match &finalized { + Ok(block) => node.record_automatic_finalization_status(format!( + "finalized block {} ({})", + block.height, block.hash + )), + Err(error) => node + .record_automatic_finalization_status(format!("skipped after VDF: {error:#}")), + } let outbox = node.drain_outbox(); (finalized, outbox) }; diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -1655,6 +1655,10 @@ window.iunaApp = function iunaApp() { return `${this.powMiningWorkers} worker${this.powMiningWorkers === 1 ? "" : "s"}`; }, + pobDetailLabel() { + return this.status.mining?.last_auto_finalization_status || "Waiting for next automatic finalization tick"; + }, + autoPowStatusLabel() { if (!this.powMiningEnabled) return "PoW mining is off"; const status = @@ -1724,6 +1728,14 @@ window.iunaApp = function iunaApp() { : "Automatic burn preparation is off.", mining.automatic ? "active" : "warning" ); + const finalizationStatus = mining.last_auto_finalization_status || ""; + this.noteMiningStateChange( + "pob-status", + finalizationStatus, + "PoB status", + finalizationStatus || "Waiting for next automatic finalization tick.", + mining.automatic ? "active" : "info" + ); this.noteMiningStateChange( "pow-workers", String(mining.pow_mining_workers ?? this.powMiningWorkers), @@ -2602,6 +2614,22 @@ window.iunaApp = function iunaApp() { ); }, + blockRevealFeePenalty(block) { + return block?.revealFeePenalty ?? block?.reveal_fee_penalty ?? {}; + }, + + blockRevealListRatio(block) { + const penalty = this.blockRevealFeePenalty(block); + const included = Number(penalty.revealListsIncluded ?? penalty.reveal_lists_included ?? 0); + const committeeSize = Number(penalty.committeeSize ?? penalty.committee_size ?? 0); + return `${included}/${committeeSize}`; + }, + + blockRevealFeePenaltyAmount(block) { + const penalty = this.blockRevealFeePenalty(block); + return Number(penalty.feePenalty ?? penalty.fee_penalty ?? 0); + }, + blockByteBreakdown(block) { const transactionRows = this.blockTransactionByteBreakdown(block); return [