commit e2cb59a969ca9dd366ec36f9c8c4a2c7744d4484
parent 765cce8ff8996d96fc8cd3b7584c23b4c4ccae15
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sun, 2 Aug 2026 20:09:04 +0200
Show burn leader ranks in block detail
Diffstat:
4 files changed, 186 insertions(+), 9 deletions(-)
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -33,8 +33,8 @@ use crate::{
FeeEstimate, NodeStatus, PeerDirection, PeerInfo, SharedNode, SharedPeerBook, StratumStatus,
},
domain::{
- Amount, Block, Ledger, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, Transaction, TxInput,
- TxOutput, hex_hash,
+ Amount, Block, BurnLeaderRank, Ledger, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint,
+ Transaction, TxInput, TxOutput, hex_hash,
},
};
@@ -359,6 +359,7 @@ struct UiBlock {
vdf_rounds: u64,
vdf_output: String,
leader_proof: Option<crate::domain::LeaderProof>,
+ burn_leader_ranks: Vec<BurnLeaderRank>,
transactions: Vec<UiTransaction>,
hash: String,
}
@@ -718,11 +719,22 @@ async fn api_blocks(
Some(before_height) => node.blocks_before(before_height, limit),
None => node.recent_blocks(limit),
};
+ let burn_leader_ranks = blocks
+ .iter()
+ .map(|block| {
+ (
+ block.hash.clone(),
+ node.burn_leader_ranks_for_block(block.height)
+ .unwrap_or_default(),
+ )
+ })
+ .collect::<BTreeMap<_, _>>();
Json(ui_blocks(
blocks,
&snapshot.genesis_allocations,
&snapshot.blocks,
&pending,
+ &burn_leader_ranks,
))
}
@@ -1654,15 +1666,24 @@ fn ui_blocks(
genesis_allocations: &BTreeMap<String, Amount>,
chain: &[Block],
pending: &[Transaction],
+ burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>,
) -> Vec<UiBlock> {
let outputs = known_output_index(genesis_allocations, chain, pending);
blocks
.into_iter()
- .map(|block| ui_block(block, &outputs))
+ .map(|block| ui_block(block, &outputs, burn_leader_ranks))
.collect()
}
-fn ui_block(block: Block, outputs: &BTreeMap<OutPoint, TxOutput>) -> UiBlock {
+fn ui_block(
+ block: Block,
+ outputs: &BTreeMap<OutPoint, TxOutput>,
+ burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>,
+) -> UiBlock {
+ let ranks = burn_leader_ranks
+ .get(&block.hash)
+ .cloned()
+ .unwrap_or_default();
UiBlock {
height: block.height,
prev_hash: block.prev_hash,
@@ -1675,6 +1696,7 @@ fn ui_block(block: Block, outputs: &BTreeMap<OutPoint, TxOutput>) -> UiBlock {
vdf_rounds: block.vdf_rounds,
vdf_output: block.vdf_output,
leader_proof: block.leader_proof,
+ burn_leader_ranks: ranks,
transactions: block
.transactions
.iter()
@@ -2781,6 +2803,12 @@ const INDEX_HTML: &str = r#"<!doctype html>
.detail-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 12px; }
.detail-kv { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 8px; font-size: 13px; margin: 7px 0; }
.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; }
+ .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; }
+ .rank-details { display: grid; gap: 6px; min-width: 0; }
.tx-list { display: grid; gap: 8px; }
.tx-card, .mempool-item { position: relative; display: grid; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 12px; background: #111316; cursor: pointer; text-align: left; }
.tx-card .pill, .mempool-item .pill { position: absolute; top: 10px; right: 10px; }
@@ -2835,7 +2863,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/iuna-ui.js?v=71"></script>
+ <script defer src="/assets/iuna-ui.js?v=72"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak>
@@ -3250,7 +3278,12 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="detail-kv"><div class="key">Height</div><div x-text="selectedBlock.height"></div></div>
<div class="detail-kv"><div class="key">Hash</div><code x-text="selectedBlock.hash"></code></div>
<div class="detail-kv"><div class="key">Previous</div><code x-text="short(selectedBlock.prev_hash)"></code></div>
- <div class="detail-kv"><div class="key">Finalizer</div><code x-text="short(selectedBlock.miner)"></code></div>
+ <div class="detail-kv">
+ <div class="key">Finalizer</div>
+ <button class="detail-link" type="button" @click="openBurnLeaderRanksModal(selectedBlock)" title="Burn leader ranks">
+ <code x-text="short(selectedBlock.miner)"></code>
+ </button>
+ </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">Burns</div><div x-text="blockBurnCount(selectedBlock)"></div></div>
@@ -3496,6 +3529,31 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
</section>
</div>
+ <div class="setup-overlay transaction-overlay" x-show="selectedBurnLeaderBlock" x-transition.opacity @click.self="closeBurnLeaderRanksModal()" role="dialog" aria-modal="true" aria-labelledby="burn-ranks-title">
+ <section class="tx-modal">
+ <div class="tx-modal-head">
+ <div class="tx-modal-title">
+ <h2 id="burn-ranks-title" x-text="burnLeaderRanksTitle(selectedBurnLeaderBlock)"></h2>
+ <div class="tx-field"><span class="tx-label">Finalizer</span><code class="tx-value hash" x-text="selectedBurnLeaderBlock ? selectedBurnLeaderBlock.miner : '-'"></code></div>
+ </div>
+ <button type="button" @click="closeBurnLeaderRanksModal">Close</button>
+ </div>
+ <div class="rank-list">
+ <template x-for="rank in burnLeaderRanks(selectedBurnLeaderBlock)" :key="`${selectedBurnLeaderBlock.hash}-${rank.rank}-${rank.ticketId}`">
+ <div class="rank-row">
+ <div class="rank-number" x-text="burnLeaderRankLabel(rank)"></div>
+ <div class="rank-details">
+ <div class="tx-field"><span class="tx-label">Owner</span><code class="tx-value hash" x-text="rank.owner"></code></div>
+ <div class="tx-field"><span class="tx-label">Burn</span><span class="tx-value money">IUNA <span x-text="amountLabel(rank.amount)"></span></span></div>
+ <div class="tx-field"><span class="tx-label">Ticket</span><code class="tx-value hash" x-text="short(rank.ticketId)"></code></div>
+ <div class="tx-field"><span class="tx-label">Eligible</span><span class="tx-value number" x-text="burnLeaderEligibilityLabel(rank)"></span></div>
+ </div>
+ </div>
+ </template>
+ <div class="tx-modal-empty" x-show="burnLeaderRanks(selectedBurnLeaderBlock).length === 0">No burn leader ranks</div>
+ </div>
+ </section>
+ </div>
<div class="setup-overlay transaction-overlay" x-show="selectedTransaction" x-transition.opacity @click.self="closeTransactionModal()" role="dialog" aria-modal="true" aria-labelledby="tx-modal-title">
<section class="tx-modal">
<div class="tx-modal-head">
@@ -4684,7 +4742,7 @@ mod tests {
#[test]
fn metrics_screen_includes_block_range_filter() {
- assert!(super::INDEX_HTML.contains("iuna-ui.js?v=71"));
+ assert!(super::INDEX_HTML.contains("iuna-ui.js?v=72"));
assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\""));
assert!(super::INDEX_HTML.contains("setMetricsRange(100)"));
assert!(super::INDEX_HTML.contains("setMetricsRange(1000)"));
@@ -4702,6 +4760,16 @@ mod tests {
assert!(!super::INDEX_HTML.contains("Needs burns"));
}
+ #[test]
+ fn block_detail_finalizer_opens_burn_leader_ranks_modal() {
+ let app_js = include_str!("../../www/assets/iuna-ui.js");
+ assert!(super::INDEX_HTML.contains("openBurnLeaderRanksModal(selectedBlock)"));
+ assert!(super::INDEX_HTML.contains("id=\"burn-ranks-title\""));
+ assert!(super::INDEX_HTML.contains("burnLeaderRanks(selectedBurnLeaderBlock)"));
+ assert!(app_js.contains("selectedBurnLeaderBlock"));
+ assert!(app_js.contains("burnLeaderRankLabel(rank)"));
+ }
+
async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> HttpState {
config_store::save(&config_path, &config).unwrap();
let wallet_path = config_path.with_file_name("wallet.json");
diff --git a/src/app.rs b/src/app.rs
@@ -13,8 +13,8 @@ use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use crate::domain::{
- Amount, BURN_CLAIM_SEEN_WINDOW_BLOCKS, Block, BurnSeen, ChainSnapshot, ChainStatus,
- DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, Ledger, MAX_PENDING_TRANSACTIONS,
+ Amount, BURN_CLAIM_SEEN_WINDOW_BLOCKS, Block, BurnLeaderRank, BurnSeen, ChainSnapshot,
+ ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, Ledger, MAX_PENDING_TRANSACTIONS,
MINE_FINALIZER_FEE, OutPoint, PreparedBlock, StratumMineShare, StratumMineTemplate,
Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, hex_hash, run_vdf,
};
@@ -396,6 +396,10 @@ impl NodeCore {
self.ledger.blocks_before(before_height, limit)
}
+ pub fn burn_leader_ranks_for_block(&self, height: u64) -> Result<Vec<BurnLeaderRank>> {
+ self.ledger.burn_leader_ranks_for_block(height)
+ }
+
pub fn pending_transactions(&self) -> Vec<Transaction> {
self.ledger.pending().to_vec()
}
diff --git a/src/domain.rs b/src/domain.rs
@@ -1060,6 +1060,17 @@ struct BurnTicket {
eligible_until_height: u64,
}
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BurnLeaderRank {
+ pub rank: u32,
+ pub ticket_id: String,
+ pub owner: String,
+ pub amount: Amount,
+ pub eligible_from_height: u64,
+ pub eligible_until_height: u64,
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
struct BurnClaimState {
burn: Transaction,
@@ -1638,6 +1649,43 @@ impl Ledger {
&self.chain
}
+ pub fn burn_leader_ranks_for_block(&self, height: u64) -> Result<Vec<BurnLeaderRank>> {
+ if height == 0 {
+ return Ok(Vec::new());
+ }
+ let parent_index = height.checked_sub(1).context("block height underflows")? as usize;
+ let parent = self
+ .chain
+ .get(parent_index)
+ .with_context(|| format!("missing parent block for height {height}"))?;
+ let mut tickets = genesis_tickets(
+ &self.genesis_allocations,
+ &self.chain[0],
+ &self.launch_profile,
+ )?;
+ for block in self
+ .chain
+ .iter()
+ .skip(1)
+ .take_while(|block| block.height < height)
+ {
+ apply_finalizer_ticket_effects(block, &mut tickets)?;
+ tickets.extend(tickets_created_by_block(block, &self.launch_profile)?);
+ }
+ Ok(ranked_tickets_for_height(parent, height, &tickets)
+ .into_iter()
+ .enumerate()
+ .map(|(rank, ticket)| BurnLeaderRank {
+ rank: rank as u32,
+ ticket_id: ticket.id,
+ owner: ticket.owner,
+ amount: ticket.amount,
+ eligible_from_height: ticket.eligible_from_height,
+ eligible_until_height: ticket.eligible_until_height,
+ })
+ .collect())
+ }
+
pub fn genesis_hash(&self) -> &str {
&self.chain[0].hash
}
@@ -4763,6 +4811,33 @@ mod tests {
}
#[test]
+ fn burn_leader_ranks_for_block_reconstructs_historical_ticket_order() {
+ let alice = Wallet::from_seed("burn-rank-alice");
+ let bob = Wallet::from_seed("burn-rank-bob");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA);
+ allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA);
+ let ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![
+ GenesisBurn::new(alice.address(), MICRO_IUNA),
+ GenesisBurn::new(bob.address(), MICRO_IUNA),
+ ],
+ 1,
+ )
+ .unwrap();
+
+ let ranks = ledger.burn_leader_ranks_for_block(1).unwrap();
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+
+ assert_eq!(ranks.len(), 2);
+ assert_eq!(ranks[0].rank, 0);
+ assert_eq!(ranks[0].owner, leader);
+ assert!(ranks.iter().all(|rank| rank.amount == MICRO_IUNA));
+ assert_eq!(ledger.burn_leader_ranks_for_block(0).unwrap(), Vec::new());
+ }
+
+ #[test]
fn mine_recipient_is_bound_to_proof_hash() {
let alice = Wallet::from_seed("mine-proof-alice");
let bob = Wallet::from_seed("mine-proof-bob");
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -5,6 +5,7 @@ window.iunaApp = function iunaApp() {
blocks: [],
selectedBlock: null,
selectedTransaction: null,
+ selectedBurnLeaderBlock: null,
loadingOlder: false,
hasMoreBlocks: true,
walletTxs: [],
@@ -809,6 +810,14 @@ window.iunaApp = function iunaApp() {
this.selectedBlock = block;
},
+ openBurnLeaderRanksModal(block) {
+ this.selectedBurnLeaderBlock = block;
+ },
+
+ closeBurnLeaderRanksModal() {
+ this.selectedBurnLeaderBlock = null;
+ },
+
openTransactionModal(tx, context = {}) {
this.selectedTransaction = { tx, context };
},
@@ -837,6 +846,7 @@ window.iunaApp = function iunaApp() {
this.closeTransactionModal();
this.closeWalletUtxosModal();
this.closePowDifficultyInfo();
+ this.closeBurnLeaderRanksModal();
},
async loadOlderBlocks() {
@@ -1689,6 +1699,26 @@ window.iunaApp = function iunaApp() {
return block.finalizer_mode === "recovery" ? `${owner} ยท Recovery` : owner;
},
+ burnLeaderRanks(block) {
+ return Array.isArray(block?.burnLeaderRanks) ? block.burnLeaderRanks : [];
+ },
+
+ burnLeaderRanksTitle(block) {
+ if (!block) return "Burn Leader Ranks";
+ return `Block ${block.height} Burn Leader Ranks`;
+ },
+
+ burnLeaderRankLabel(rank) {
+ const value = Number(rank?.rank ?? 0);
+ return `#${value + 1}`;
+ },
+
+ burnLeaderEligibilityLabel(rank) {
+ const from = rank?.eligibleFromHeight ?? "-";
+ const until = rank?.eligibleUntilHeight ?? "-";
+ return `${from}-${until}`;
+ },
+
walletTransactions() {
return this.walletTxs;
},