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:
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}` : ""}`;
},