iuna

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

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:
Msrc/adapters/http.rs | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/app.rs | 8++++++--
Msrc/domain.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwww/assets/iuna-ui.js | 30++++++++++++++++++++++++++++++
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; },