iuna

iuna

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

commit a7e332d19a52a64bc516d78ddc35a339d17592d6
parent 6d05a879332bd75391e0fdb2a91931b1b4ee090b
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri,  4 Sep 2026 14:49:09 +0200

Add chain storage and proof metrics

Diffstat:
Msrc/adapters/chain_store/compact.rs | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/adapters/http.rs | 2+-
Msrc/adapters/http/api.rs | 25+++++++++++++++++++++----
Msrc/adapters/http/index_html.rs | 18+++++++++++++++++-
Msrc/adapters/http/metrics.rs | 119++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/adapters/http/types.rs | 12++++++++++++
Msrc/adapters/http/ui.rs | 2+-
Msrc/app/ledger_view.rs | 4++++
Msrc/domain/ledger_ops.rs | 38+++++++++++++++++++++++++++++++++++++-
Mwww/assets/iuna-ui.js | 7+++++++
10 files changed, 318 insertions(+), 26 deletions(-)

diff --git a/src/adapters/chain_store/compact.rs b/src/adapters/chain_store/compact.rs @@ -143,17 +143,14 @@ impl DecodeTables { pub(super) fn encode_compact_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<u8>> { let mut writer = CompactWriter::default(); let mut tables = EncodeTables::default(); - writer.bytes(COMPACT_SNAPSHOT_MAGIC); - let version = COMPACT_SNAPSHOT_VERSION; - writer.u8(version); - writer.varint(snapshot.genesis_allocations.len() as u64); - for (address, amount) in &snapshot.genesis_allocations { - writer.address(address, &mut tables)?; - writer.varint(*amount); - } - writer.varint(snapshot.vdf_rounds); - encode_launch_profile(&mut writer, &snapshot.launch_profile); - writer.varint(snapshot.blocks.len() as u64); + encode_compact_snapshot_prefix( + &mut writer, + &mut tables, + &snapshot.genesis_allocations, + snapshot.vdf_rounds, + &snapshot.launch_profile, + snapshot.blocks.len(), + )?; let mut expected_prev_hash = "0".repeat(64); for (height, block) in snapshot.blocks.iter().enumerate() { if block.height != height as u64 { @@ -176,6 +173,71 @@ pub(super) fn encode_compact_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<u8 Ok(writer.into_inner()) } +pub(crate) fn compact_snapshot_fixed_prefix_size( + genesis_allocations: &BTreeMap<String, Amount>, + vdf_rounds: u64, + launch_profile: &LaunchProfile, +) -> Result<usize> { + let mut writer = CompactWriter::default(); + let mut tables = EncodeTables::default(); + encode_compact_snapshot_fixed_prefix( + &mut writer, + &mut tables, + genesis_allocations, + vdf_rounds, + launch_profile, + )?; + Ok(writer.into_inner().len()) +} + +pub(crate) fn compact_varint_size(mut value: usize) -> usize { + let mut bytes = 1; + while value >= 0x80 { + value >>= 7; + bytes += 1; + } + bytes +} + +fn encode_compact_snapshot_prefix( + writer: &mut CompactWriter, + tables: &mut EncodeTables, + genesis_allocations: &BTreeMap<String, Amount>, + vdf_rounds: u64, + launch_profile: &LaunchProfile, + block_count: usize, +) -> Result<()> { + encode_compact_snapshot_fixed_prefix( + writer, + tables, + genesis_allocations, + vdf_rounds, + launch_profile, + )?; + writer.varint(block_count as u64); + Ok(()) +} + +fn encode_compact_snapshot_fixed_prefix( + writer: &mut CompactWriter, + tables: &mut EncodeTables, + genesis_allocations: &BTreeMap<String, Amount>, + vdf_rounds: u64, + launch_profile: &LaunchProfile, +) -> Result<()> { + writer.bytes(COMPACT_SNAPSHOT_MAGIC); + let version = COMPACT_SNAPSHOT_VERSION; + writer.u8(version); + writer.varint(genesis_allocations.len() as u64); + for (address, amount) in genesis_allocations { + writer.address(address, tables)?; + writer.varint(*amount); + } + writer.varint(vdf_rounds); + encode_launch_profile(writer, launch_profile); + Ok(()) +} + pub(super) fn decode_compact_snapshot(bytes: &[u8]) -> Result<ChainSnapshot> { let mut reader = CompactReader::new(bytes); let mut tables = DecodeTables::default(); @@ -994,9 +1056,10 @@ mod tests { COMPACT_SNAPSHOT_MAGIC, COMPACT_SNAPSHOT_VERSION, CompactBlockContext, CompactReader, CompactWriter, DecodeTables, EncodeTables, MAX_COMPACT_BYTE_FIELD, MAX_COMPACT_GENESIS_ALLOCATIONS, MAX_COMPACT_SNAPSHOT_BLOCKS, MAX_COMPACT_VEC_ITEMS, - MIN_SUPPORTED_COMPACT_SNAPSHOT_VERSION, decode_block_body, decode_compact_snapshot, - decode_launch_profile, decode_transaction, encode_block_body, encode_compact_snapshot, - encode_launch_profile, encode_transaction, + MIN_SUPPORTED_COMPACT_SNAPSHOT_VERSION, compact_snapshot_fixed_prefix_size, + compact_varint_size, decode_block_body, decode_compact_snapshot, decode_launch_profile, + decode_transaction, encode_block_body, encode_compact_snapshot, encode_launch_profile, + encode_transaction, }; #[test] @@ -1013,6 +1076,32 @@ mod tests { decode_compact_snapshot(&default_bytes).unwrap(), default_snapshot ); + let default_context = CompactBlockContext::for_chain( + &default_snapshot.genesis_allocations, + &default_snapshot.blocks, + ) + .unwrap(); + let default_block_bytes = default_snapshot + .blocks + .iter() + .map(|block| { + default_context + .stored_block_size_breakdown(&block.hash) + .unwrap() + .total_bytes + }) + .sum::<usize>(); + assert_eq!( + default_bytes.len(), + compact_snapshot_fixed_prefix_size( + &default_snapshot.genesis_allocations, + default_snapshot.vdf_rounds, + &default_snapshot.launch_profile, + ) + .unwrap() + + compact_varint_size(default_snapshot.blocks.len()) + + default_block_bytes + ); let local_ledger = Ledger::new_with_genesis_burns_and_profile( allocations, diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -51,7 +51,7 @@ use auth_routes::{ api_auth_status, require_auth_middleware, }; use index_html::INDEX_HTML; -use metrics::{metrics_response, network_health}; +use metrics::{metrics_response, network_health, top_mine_proofs}; use request_auth::wallet_password_for_request; pub use state::ServeOptions; use state::{AuthClientKey, AuthSession, HttpState}; diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -21,8 +21,8 @@ use super::{ }; use super::{ DATASET_LIMIT, DATASET_PAGE_LIMIT, EXPLORER_LIMIT, EXPLORER_PAGE_LIMIT, HttpState, - add_pending_outputs, metrics_response, network_health, ui_blocks_from_indexes, ui_transaction, - wallet_transaction_row, wallet_transaction_rows, + add_pending_outputs, metrics_response, network_health, top_mine_proofs, ui_blocks_from_indexes, + ui_transaction, wallet_transaction_row, wallet_transaction_rows, }; pub(super) async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> { @@ -360,10 +360,26 @@ pub(super) async fn api_metrics( .and_then(Result::ok) .map(metrics_leaderboards) .unwrap_or_default(); + let (chain_storage_bytes, top_mine_proofs) = { + let node = state.node.lock().await; + ( + node.chain_storage_bytes_by_hash().unwrap_or_default(), + top_mine_proofs(node.chain(), 10), + ) + }; if rows.is_empty() { - return Json(empty_metrics_response(enabled, !ready)); + let mut response = empty_metrics_response(enabled, !ready); + response.top_mine_proofs = top_mine_proofs; + return Json(response); } - Json(metrics_response(enabled, !ready, rows, leaderboards)) + Json(metrics_response( + enabled, + !ready, + rows, + leaderboards, + &chain_storage_bytes, + top_mine_proofs, + )) } fn empty_metrics_response(enabled: bool, preparing: bool) -> MetricsResponse { @@ -373,6 +389,7 @@ fn empty_metrics_response(enabled: bool, preparing: bool) -> MetricsResponse { latest: None, charts: Vec::new(), leaderboards: MetricsLeaderboards::default(), + top_mine_proofs: Vec::new(), } } diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -1070,7 +1070,7 @@ pub(super) const INDEX_HTML: &str = concat!( <span x-text="mineCountLabel(block)"></span> </div> </button> - <button class="block-miner wallet-address-link" type="button" x-text="blockFinalizerLabel(block)" @click="openAddressContact(block.miner)" title="Add or edit contact"></button> + <div class="block-miner" x-text="blockFinalizerLabel(block)"></div> </article> </template> <template x-if="loadingInitialBlocks && blocks.length === 0"> @@ -1310,6 +1310,22 @@ pub(super) const INDEX_HTML: &str = concat!( </div> </article> </template> + <article class="leaderboard-card"> + <h3>Top 10 Mine Proof Bits</h3> + <div class="leaderboard-list"> + <template x-for="(row, index) in topMineProofRows()" :key="`${row.proofHash}-${row.height}`"> + <div class="leaderboard-row"> + <div class="leaderboard-rank" :class="leaderboardRankClass(index)" x-text="leaderboardRankLabel(index)"></div> + <div class="leaderboard-main"> + <code class="tx-value hash wallet-address-link" role="button" tabindex="0" x-text="shortAddressLabel(row.address)" @click="openAddressContact(row.address)" @keydown.enter.prevent="openAddressContact(row.address)" @keydown.space.prevent="openAddressContact(row.address)" title="Add or edit contact"></code> + <div class="muted">Block #<span x-text="row.height"></span> · target <span x-text="row.difficultyBits"></span> bits · <code x-text="short(row.proofHash)"></code></div> + </div> + <div class="leaderboard-amount"><span x-text="row.proofBits"></span> bits</div> + </div> + </template> + <div class="metrics-empty" x-show="topMineProofRows().length === 0">No entries</div> + </div> + </article> </div> </div> </section> diff --git a/src/adapters/http/metrics.rs b/src/adapters/http/metrics.rs @@ -1,15 +1,19 @@ +use std::collections::BTreeMap; + use crate::{ adapters::ui_data_store::BlockMetricRow, app::{PeerDirection, PeerInfo}, - domain::Amount, + domain::{Amount, Block, Transaction}, }; use super::{ PEER_STALE_AFTER_MS, now_ms, types::{ MempoolCounts, MetricsChart, MetricsLeaderboards, MetricsPoint, MetricsResponse, - MetricsValueKind, NetworkHealthLocalState, NetworkHealthResponse, + MetricsValueKind, MineProofLeaderboardEntry, NetworkHealthLocalState, + NetworkHealthResponse, }, + ui::proof_bits, }; pub(super) fn network_health( @@ -25,6 +29,8 @@ pub(super) fn metrics_response( preparing: bool, rows: Vec<BlockMetricRow>, leaderboards: MetricsLeaderboards, + chain_storage_bytes: &BTreeMap<String, u64>, + top_mine_proofs: Vec<MineProofLeaderboardEntry>, ) -> MetricsResponse { let latest = rows.last().cloned(); MetricsResponse { @@ -32,6 +38,7 @@ pub(super) fn metrics_response( preparing, latest, leaderboards, + top_mine_proofs, charts: vec![ metrics_chart( "block-time", @@ -62,6 +69,18 @@ pub(super) fn metrics_response( |row| Some(micro_iuna_as_iuna(row.circulating_supply)), ), metrics_chart( + "chain-storage-bytes", + "Total chain size", + "bytes", + MetricsValueKind::Bytes, + &rows, + |row| { + chain_storage_bytes + .get(&row.block_hash) + .map(|bytes| *bytes as f64) + }, + ), + metrics_chart( "known-wallet-addresses", "Known wallet addresses", "addresses", @@ -129,6 +148,41 @@ pub(super) fn metrics_response( } } +pub(super) fn top_mine_proofs(blocks: &[Block], limit: usize) -> Vec<MineProofLeaderboardEntry> { + let mut proofs = blocks + .iter() + .flat_map(|block| { + block.transactions.iter().filter_map(|transaction| { + let Transaction::Mine { + recipient, + difficulty_bits, + signature, + .. + } = transaction + else { + return None; + }; + Some(MineProofLeaderboardEntry { + height: block.height, + address: recipient.clone(), + proof_bits: proof_bits(signature), + difficulty_bits: *difficulty_bits, + proof_hash: signature.clone(), + }) + }) + }) + .collect::<Vec<_>>(); + proofs.sort_by(|left, right| { + right + .proof_bits + .cmp(&left.proof_bits) + .then_with(|| left.proof_hash.cmp(&right.proof_hash)) + .then_with(|| left.height.cmp(&right.height)) + }); + proofs.truncate(limit); + proofs +} + fn metrics_chart( id: &'static str, title: &'static str, @@ -318,11 +372,16 @@ fn median_peer_clock_offset(peers: &[PeerInfo], now_ms: u64) -> Option<i64> { #[cfg(test)] mod tests { - use crate::adapters::ui_data_store::BlockMetricRow; + use std::collections::BTreeMap; + + use crate::{ + adapters::ui_data_store::BlockMetricRow, + domain::{Block, BurnBundleSection, FinalizerMode, Transaction}, + }; use super::{ MempoolCounts, MetricsLeaderboards, NetworkHealthLocalState, metrics_response, - network_health_at, + network_health_at, top_mine_proofs, }; #[test] @@ -347,16 +406,68 @@ mod tests { finalizer_rank: 0, }; + let chain_storage_bytes = BTreeMap::from([("cached-tip".to_string(), 12_345)]); let response = metrics_response( true, true, vec![row.clone()], MetricsLeaderboards::default(), + &chain_storage_bytes, + Vec::new(), ); assert!(response.preparing); assert_eq!(response.latest, Some(row)); assert!(response.charts.iter().any(|chart| !chart.points.is_empty())); + assert_eq!( + response + .charts + .iter() + .find(|chart| chart.id == "chain-storage-bytes") + .and_then(|chart| chart.points.first()) + .map(|point| point.value), + Some(12_345.0) + ); + } + + #[test] + fn mine_proofs_are_ranked_by_achieved_bits() { + let mine = |recipient: &str, signature: &str| Transaction::Mine { + recipient: recipient.to_string(), + anchor: "anchor".to_string(), + salt: 0, + nonce: 0, + difficulty_bits: 4, + proof_header: None, + signature: signature.to_string(), + }; + let block = Block { + height: 7, + prev_hash: "0".repeat(64), + timestamp_ms: 0, + miner: "finalizer".to_string(), + finalizer_mode: FinalizerMode::Ticket, + finalizer_rank: 0, + reward: 0, + vdf_rounds: 1, + vdf_output: String::new(), + leader_proof: None, + burn_bundle_section: BurnBundleSection::default(), + transactions: vec![ + mine("four-bits", "0fff"), + mine("twelve-bits", "000f"), + mine("eight-bits", "00ff"), + ], + hash: "block-hash".to_string(), + }; + + let proofs = top_mine_proofs(&[block], 2); + + assert_eq!(proofs.len(), 2); + assert_eq!(proofs[0].address, "twelve-bits"); + assert_eq!(proofs[0].proof_bits, 12); + assert_eq!(proofs[1].address, "eight-bits"); + assert_eq!(proofs[1].proof_bits, 8); } #[test] diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -287,6 +287,7 @@ pub(super) struct MetricsResponse { pub(super) latest: Option<BlockMetricRow>, pub(super) charts: Vec<MetricsChart>, pub(super) leaderboards: MetricsLeaderboards, + pub(super) top_mine_proofs: Vec<MineProofLeaderboardEntry>, } #[derive(Clone, Debug, Default, PartialEq, Serialize)] @@ -305,6 +306,16 @@ pub(super) struct LeaderboardEntry { pub(super) count: u64, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct MineProofLeaderboardEntry { + pub(super) height: u64, + pub(super) address: String, + pub(super) proof_bits: u32, + pub(super) difficulty_bits: u32, + pub(super) proof_hash: String, +} + #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct MetricsChart { @@ -328,6 +339,7 @@ pub(super) enum MetricsValueKind { Number, Seconds, Iuna, + Bytes, } #[derive(Debug, Serialize)] diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -426,7 +426,7 @@ fn ui_inputs( .collect() } -fn proof_bits(hex_hash: &str) -> u32 { +pub(super) fn proof_bits(hex_hash: &str) -> u32 { let mut bits = 0_u32; for byte in hex_hash.as_bytes() { let Some(nibble) = hex_nibble(*byte) else { diff --git a/src/app/ledger_view.rs b/src/app/ledger_view.rs @@ -69,6 +69,10 @@ impl NodeCore { self.ledger.storage_size_breakdowns(blocks) } + pub(crate) fn chain_storage_bytes_by_hash(&self) -> Result<BTreeMap<String, u64>> { + self.ledger.chain_storage_bytes_by_hash() + } + pub fn burn_leader_ranks_for_block(&self, height: u64) -> Result<Vec<BurnLeaderRank>> { self.ledger.burn_leader_ranks_for_block(height) } diff --git a/src/domain/ledger_ops.rs b/src/domain/ledger_ops.rs @@ -2,7 +2,10 @@ use std::collections::{BTreeMap, BTreeSet}; use anyhow::{Context, Result, bail}; -use crate::compact::{CompactBlockContext, CompactBlockSizeBreakdown}; +use crate::compact::{ + CompactBlockContext, CompactBlockSizeBreakdown, compact_snapshot_fixed_prefix_size, + compact_varint_size, +}; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use super::hex::hex_hash; @@ -43,6 +46,39 @@ impl Ledger { }) .collect() } + + pub(crate) fn chain_storage_bytes_by_hash(&self) -> Result<BTreeMap<String, u64>> { + let mut cumulative_block_bytes = 0_u64; + let mut sizes = BTreeMap::new(); + let fixed_prefix_bytes = compact_snapshot_fixed_prefix_size( + &self.genesis_allocations, + self.initial_vdf_rounds, + &self.launch_profile, + )?; + for (index, block) in self.chain.iter().enumerate() { + let block_bytes = self + .compact_block_context + .stored_block_size_breakdown(&block.hash) + .with_context(|| { + format!( + "missing compact storage size for block {} at height {}", + block.hash, block.height + ) + })? + .total_bytes; + cumulative_block_bytes = cumulative_block_bytes + .checked_add(u64::try_from(block_bytes).context("block storage size overflows")?) + .context("cumulative block storage size overflows")?; + let prefix_bytes = fixed_prefix_bytes + .checked_add(compact_varint_size(index + 1)) + .context("snapshot prefix size overflows")?; + let chain_bytes = cumulative_block_bytes + .checked_add(u64::try_from(prefix_bytes).context("snapshot prefix size overflows")?) + .context("chain storage size overflows")?; + sizes.insert(block.hash.clone(), chain_bytes); + } + Ok(sizes) + } } pub(super) fn validate_genesis_allocations( diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -2008,6 +2008,11 @@ window.iunaApp = function iunaApp() { return this.blockchainMetrics?.leaderboards || {}; }, + topMineProofRows() { + const rows = this.blockchainMetrics?.topMineProofs; + return Array.isArray(rows) ? rows : []; + }, + leaderboardRows(kind) { const rows = this.metricsLeaderboards()?.[kind]; return Array.isArray(rows) ? rows : []; @@ -2318,6 +2323,7 @@ window.iunaApp = function iunaApp() { const number = Number(value); if (!Number.isFinite(number)) return "-"; if (chart?.valueKind === "seconds") return `${this.compactNumber(number)}s`; + if (chart?.valueKind === "bytes") return `${this.compactNumber(number)} B`; return this.compactNumber(number); }, @@ -2326,6 +2332,7 @@ window.iunaApp = function iunaApp() { if (!Number.isFinite(number)) return "-"; if (chart?.valueKind === "iuna") return `IUNA ${this.compactNumber(number)}`; if (chart?.valueKind === "seconds") return `${this.compactNumber(number)} s`; + if (chart?.valueKind === "bytes") return `${this.compactNumber(number)} bytes`; return `${this.compactNumber(number)}${chart?.unit ? ` ${chart.unit}` : ""}`; },