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:
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 [