iuna

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

commit 03d13e99bff807a4706a341bf45c164f8613cbfb
parent b9a7350b3d86a13808087afc1408fbbb93a3b99f
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Mon,  3 Aug 2026 11:59:34 +0200

Add burn claim metrics screen

Diffstat:
Msrc/adapters/chain_store.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/adapters/http.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mwww/assets/iuna-ui.js | 8++++++++
3 files changed, 267 insertions(+), 17 deletions(-)

diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -30,10 +30,13 @@ CREATE TABLE IF NOT EXISTS block_metrics ( transaction_count INTEGER NOT NULL, transfer_count INTEGER NOT NULL, burn_count INTEGER NOT NULL, + burn_claim_count INTEGER NOT NULL DEFAULT 0, mine_count INTEGER NOT NULL, burned_amount INTEGER NOT NULL, total_burned_amount INTEGER NOT NULL, fees_amount INTEGER NOT NULL, + burn_claim_fee_share_count INTEGER NOT NULL DEFAULT 0, + burn_claim_fee_share_amount INTEGER NOT NULL DEFAULT 0, reward_amount INTEGER NOT NULL, vdf_rounds INTEGER NOT NULL, finalizer_rank INTEGER NOT NULL @@ -52,10 +55,13 @@ pub struct BlockMetricRow { pub transaction_count: u64, pub transfer_count: u64, pub burn_count: u64, + pub burn_claim_count: u64, pub mine_count: u64, pub burned_amount: Amount, pub total_burned_amount: Amount, pub fees_amount: Amount, + pub burn_claim_fee_share_count: u64, + pub burn_claim_fee_share_amount: Amount, pub reward_amount: Amount, pub vdf_rounds: u64, pub finalizer_rank: u32, @@ -83,6 +89,7 @@ impl SqliteChainStore { connection .execute_batch(SCHEMA) .context("failed to initialize chain database schema") + .and_then(|_| ensure_block_metric_columns(connection)) })?; Ok(store) } @@ -185,8 +192,9 @@ ON CONFLICT(id) DO UPDATE SET r#" SELECT height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits, circulating_supply, transaction_count, transfer_count, burn_count, - mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount, - vdf_rounds, finalizer_rank + burn_claim_count, mine_count, burned_amount, total_burned_amount, fees_amount, + burn_claim_fee_share_count, burn_claim_fee_share_amount, reward_amount, vdf_rounds, + finalizer_rank FROM block_metrics ORDER BY height ASC "#, @@ -204,13 +212,16 @@ ORDER BY height ASC transaction_count: row.get(6)?, transfer_count: row.get(7)?, burn_count: row.get(8)?, - mine_count: row.get(9)?, - burned_amount: row.get(10)?, - total_burned_amount: row.get(11)?, - fees_amount: row.get(12)?, - reward_amount: row.get(13)?, - vdf_rounds: row.get(14)?, - finalizer_rank: row.get(15)?, + burn_claim_count: row.get(9)?, + mine_count: row.get(10)?, + burned_amount: row.get(11)?, + total_burned_amount: row.get(12)?, + fees_amount: row.get(13)?, + burn_claim_fee_share_count: row.get(14)?, + burn_claim_fee_share_amount: row.get(15)?, + reward_amount: row.get(16)?, + vdf_rounds: row.get(17)?, + finalizer_rank: row.get(18)?, }) }) .context("failed to load block metrics")?; @@ -248,6 +259,32 @@ PRAGMA synchronous = NORMAL; } } +fn ensure_block_metric_columns(connection: &Connection) -> Result<()> { + let mut statement = connection + .prepare("PRAGMA table_info(block_metrics)") + .context("failed to inspect block metrics schema")?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1)) + .context("failed to read block metrics schema")? + .collect::<std::result::Result<std::collections::BTreeSet<_>, _>>() + .context("failed to collect block metrics columns")?; + for (name, definition) in [ + ("burn_claim_count", "INTEGER NOT NULL DEFAULT 0"), + ("burn_claim_fee_share_count", "INTEGER NOT NULL DEFAULT 0"), + ("burn_claim_fee_share_amount", "INTEGER NOT NULL DEFAULT 0"), + ] { + if !columns.contains(name) { + connection + .execute( + &format!("ALTER TABLE block_metrics ADD COLUMN {name} {definition}"), + [], + ) + .with_context(|| format!("failed to add block metrics column {name}"))?; + } + } + Ok(()) +} + fn replace_metrics( transaction: &rusqlite::Transaction<'_>, metrics: &[BlockMetricRow], @@ -259,10 +296,10 @@ fn replace_metrics( r#" INSERT INTO block_metrics ( height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits, - circulating_supply, transaction_count, transfer_count, burn_count, mine_count, - burned_amount, total_burned_amount, fees_amount, reward_amount, vdf_rounds, - finalizer_rank -) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) + circulating_supply, transaction_count, transfer_count, burn_count, burn_claim_count, + mine_count, burned_amount, total_burned_amount, fees_amount, burn_claim_fee_share_count, + burn_claim_fee_share_amount, reward_amount, vdf_rounds, finalizer_rank +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19) "#, params![ metric.height, @@ -274,10 +311,13 @@ INSERT INTO block_metrics ( metric.transaction_count, metric.transfer_count, metric.burn_count, + metric.burn_claim_count, metric.mine_count, metric.burned_amount, metric.total_burned_amount, metric.fees_amount, + metric.burn_claim_fee_share_count, + metric.burn_claim_fee_share_amount, metric.reward_amount, metric.vdf_rounds, metric.finalizer_rank, @@ -314,10 +354,18 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> for block in &snapshot.blocks { let mut transfer_count = 0_u64; let mut burn_count = 0_u64; + let mut burn_claim_count = 0_u64; let mut mine_count = 0_u64; let mut burned_amount = 0_u64; let mut mine_issued_amount = 0_u64; let mut fees_amount = 0_u64; + let burn_claim_fee_share_count = block.fee_shares.len() as u64; + let burn_claim_fee_share_amount = + block.fee_shares.iter().try_fold(0_u64, |total, share| { + total + .checked_add(share.amount) + .context("block metric burn claim fee shares overflow") + })?; for transaction in &block.transactions { fees_amount = fees_amount @@ -338,7 +386,7 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> .and_then(|amount| amount.checked_add(transaction.fee())) .context("block metric mine issuance overflow")?; } - Transaction::BurnClaim { .. } => {} + Transaction::BurnClaim { .. } => burn_claim_count += 1, } } @@ -379,10 +427,13 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> transaction_count: block.transactions.len() as u64, transfer_count, burn_count, + burn_claim_count, mine_count, burned_amount, total_burned_amount, fees_amount, + burn_claim_fee_share_count, + burn_claim_fee_share_amount, reward_amount: block.reward, vdf_rounds: block.vdf_rounds, finalizer_rank: block.finalizer_rank, @@ -418,6 +469,7 @@ fn unix_ms() -> u64 { mod tests { use std::collections::BTreeMap; + use rusqlite::Connection; use tempfile::tempdir; use crate::domain::{BLOCK_REWARD, GenesisBurn, Ledger, Wallet}; @@ -483,8 +535,11 @@ mod tests { assert_eq!(metrics.last().unwrap().height, 1); assert_eq!(metrics.last().unwrap().burn_count, 1); + assert_eq!(metrics.last().unwrap().burn_claim_count, 0); assert_eq!(metrics.last().unwrap().burned_amount, 2); assert_eq!(metrics.last().unwrap().fees_amount, 1); + assert_eq!(metrics.last().unwrap().burn_claim_fee_share_count, 0); + assert_eq!(metrics.last().unwrap().burn_claim_fee_share_amount, 0); assert_eq!(metrics.last().unwrap().circulating_supply, BLOCK_REWARD + 7); store.clear_metrics().unwrap(); @@ -492,6 +547,58 @@ mod tests { } #[test] + fn sqlite_chain_store_migrates_burn_claim_metric_columns() { + let dir = tempdir().unwrap(); + let path = dir.path().join("chain.sqlite3"); + Connection::open(&path) + .unwrap() + .execute_batch( + r#" +CREATE TABLE chain_snapshots ( + id INTEGER PRIMARY KEY CHECK (id = 1), + height INTEGER NOT NULL, + tip_hash TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL +); +CREATE TABLE block_metrics ( + height INTEGER PRIMARY KEY, + block_hash TEXT NOT NULL, + timestamp_ms INTEGER NOT NULL, + block_time_ms INTEGER, + mine_difficulty_bits INTEGER NOT NULL, + circulating_supply INTEGER NOT NULL, + transaction_count INTEGER NOT NULL, + transfer_count INTEGER NOT NULL, + burn_count INTEGER NOT NULL, + mine_count INTEGER NOT NULL, + burned_amount INTEGER NOT NULL, + total_burned_amount INTEGER NOT NULL, + fees_amount INTEGER NOT NULL, + reward_amount INTEGER NOT NULL, + vdf_rounds INTEGER NOT NULL, + finalizer_rank INTEGER NOT NULL +); +INSERT INTO block_metrics ( + height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits, + circulating_supply, transaction_count, transfer_count, burn_count, mine_count, + burned_amount, total_burned_amount, fees_amount, reward_amount, vdf_rounds, + finalizer_rank +) VALUES (1, 'hash', 1000, 500, 12, 100, 2, 0, 1, 0, 1, 1, 2, 2, 99, 0); +"#, + ) + .unwrap(); + + let store = SqliteChainStore::open(&path).unwrap(); + let metrics = store.load_metrics().unwrap(); + + assert_eq!(metrics.len(), 1); + assert_eq!(metrics[0].burn_claim_count, 0); + assert_eq!(metrics[0].burn_claim_fee_share_count, 0); + assert_eq!(metrics[0].burn_claim_fee_share_amount, 0); + } + + #[test] fn sqlite_chain_store_roundtrips_vdf_round_metrics_above_legacy_u32_limit() { let dir = tempdir().unwrap(); let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); @@ -512,10 +619,13 @@ mod tests { transaction_count: 0, transfer_count: 0, burn_count: 0, + burn_claim_count: 0, mine_count: 0, burned_amount: 0, total_burned_amount: 0, fees_amount: 0, + burn_claim_fee_share_count: 0, + burn_claim_fee_share_amount: 0, reward_amount: 0, vdf_rounds, finalizer_rank: 0, diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -276,6 +276,7 @@ struct MetricsResponse { #[serde(rename_all = "camelCase")] struct MetricsChart { id: &'static str, + section: &'static str, title: &'static str, unit: &'static str, value_kind: MetricsValueKind, @@ -1249,6 +1250,7 @@ fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse charts: vec![ metrics_chart( "block-time", + "chain", "Time per block", "s", MetricsValueKind::Seconds, @@ -1261,6 +1263,7 @@ fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse ), metrics_chart( "difficulty", + "chain", "Difficulty", "bits", MetricsValueKind::Number, @@ -1269,6 +1272,7 @@ fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse ), metrics_chart( "supply", + "chain", "IUNA in circulation", "IUNA", MetricsValueKind::Iuna, @@ -1277,6 +1281,7 @@ fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse ), metrics_chart( "transactions", + "chain", "Transactions", "tx", MetricsValueKind::Number, @@ -1285,6 +1290,7 @@ fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse ), metrics_chart( "burn-count", + "chain", "Burn transactions", "burns", MetricsValueKind::Number, @@ -1293,6 +1299,7 @@ fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse ), metrics_chart( "burn-amount", + "chain", "Burn amount", "IUNA", MetricsValueKind::Iuna, @@ -1301,6 +1308,7 @@ fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse ), metrics_chart( "total-burn", + "chain", "Total burn", "IUNA", MetricsValueKind::Iuna, @@ -1309,6 +1317,7 @@ fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse ), metrics_chart( "fees", + "chain", "Fees", "IUNA", MetricsValueKind::Iuna, @@ -1317,6 +1326,7 @@ fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse ), metrics_chart( "mine-actions", + "chain", "Mine actions", "mine", MetricsValueKind::Number, @@ -1325,18 +1335,47 @@ fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse ), metrics_chart( "vdf-rounds", + "chain", "VDF rounds", "rounds", MetricsValueKind::Number, &rows, |row| (row.vdf_rounds > 0).then_some(row.vdf_rounds as f64), ), + metrics_chart( + "burn-claims", + "burnClaims", + "Burn claims", + "claims", + MetricsValueKind::Number, + &rows, + |row| Some(row.burn_claim_count as f64), + ), + metrics_chart( + "burn-claim-payouts", + "burnClaims", + "Claim payouts", + "outputs", + MetricsValueKind::Number, + &rows, + |row| Some(row.burn_claim_fee_share_count as f64), + ), + metrics_chart( + "burn-claim-payout-amount", + "burnClaims", + "Claim payout amount", + "IUNA", + MetricsValueKind::Iuna, + &rows, + |row| Some(micro_iuna_as_iuna(row.burn_claim_fee_share_amount)), + ), ], } } fn metrics_chart( id: &'static str, + section: &'static str, title: &'static str, unit: &'static str, value_kind: MetricsValueKind, @@ -1345,6 +1384,7 @@ fn metrics_chart( ) -> MetricsChart { MetricsChart { id, + section, title, unit, value_kind, @@ -2656,6 +2696,8 @@ const INDEX_HTML: &str = r#"<!doctype html> .metrics-head h2 { margin: 0; } .metrics-range { flex: 0 0 auto; } .metrics-range button { padding: 5px 9px; font-size: 12px; white-space: nowrap; } + .metrics-section-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 4px; border-top: 1px solid #2a3035; } + .metrics-section-head h3 { margin: 0; color: #e8edf0; font-size: 15px; font-weight: 850; } .metrics-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; } .metrics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 430px), 1fr)); gap: 12px; } .metric-chart-card { display: grid; gap: 10px; min-width: 0; border: 1px solid #2a3035; border-radius: 8px; padding: 12px; background: #181b1f; } @@ -2889,7 +2931,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=76"></script> + <script defer src="/assets/iuna-ui.js?v=77"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -3383,7 +3425,53 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="metrics-empty" x-show="metricsCharts().length === 0">No metrics collected yet</div> <div class="metrics-grid"> - <template x-for="chart in metricsCharts()" :key="chart.id"> + <template x-for="chart in metricsChainCharts()" :key="chart.id"> + <article class="metric-chart-card"> + <div class="metric-chart-head"> + <h3 class="metric-chart-title" x-text="chart.title"></h3> + <div class="metric-chart-value" x-text="metricLatestValueLabel(chart)"></div> + </div> + <div class="metric-chart-frame"> + <div class="metric-chart-y-axis"> + <template x-for="tick in metricYAxisTicks(chart)" :key="`${chart.id}-y-${tick}`"> + <span class="metric-chart-axis-label" :style="metricYAxisLabelStyle(chart, tick)" x-text="metricAxisValueLabel(chart, tick)"></span> + </template> + </div> + <div class="metric-chart-plot" @mousemove="setMetricHoverFromPlot(chart, $event)" @mouseleave="clearMetricHover(chart)"> + <svg class="metric-chart-svg" viewBox="0 0 300 148" preserveAspectRatio="none" role="img" :aria-label="chart.title"> + <path class="metric-chart-gridline" :d="metricGridPath(chart)"></path> + <line class="metric-chart-axis" x1="4" y1="8" x2="4" y2="132"></line> + <line class="metric-chart-axis" x1="4" y1="132" x2="296" y2="132"></line> + <polyline class="metric-chart-line" :points="metricChartPoints(chart)"></polyline> + </svg> + <div class="metric-chart-points"> + <template x-for="marker in metricChartPointMarkers(chart)" :key="`${chart.id}-point-${marker.height}`"> + <button class="metric-chart-point-hit" type="button" :class="{ 'is-active': metricHover?.chartId === chart.id && metricHover?.height === marker.height }" :style="metricPointStyle(marker)" :title="marker.label" @focus="setMetricHover(chart, marker)" @blur="clearMetricHover(chart)" :aria-label="marker.label"></button> + </template> + </div> + <template x-if="metricHover?.chartId === chart.id"> + <div class="metric-chart-tooltip" :style="metricTooltipStyle(chart)" x-text="metricTooltipLabel(chart)"></div> + </template> + </div> + <div class="metric-chart-x-axis"> + <template x-for="tick in metricXAxisTicks(chart)" :key="`${chart.id}-x-${tick}`"> + <span class="metric-chart-axis-label" :style="metricXAxisLabelStyle(chart, tick)" x-text="`#${tick}`"></span> + </template> + </div> + </div> + </article> + </template> + </div> + <div class="metrics-section-head"> + <h3>Burn claims</h3> + </div> + <div class="metrics-summary"> + <div class="metric"><div class="label">Claims</div><div class="value" x-text="metricsLatest().burnClaimCount ?? '-'"></div></div> + <div class="metric"><div class="label">Payouts</div><div class="value" x-text="metricsLatest().burnClaimFeeShareCount ?? '-'"></div></div> + <div class="metric"><div class="label">Payout amount</div><div class="value" x-text="metricAmountLabel(metricsLatest().burnClaimFeeShareAmount)"></div></div> + </div> + <div class="metrics-grid"> + <template x-for="chart in metricsBurnClaimCharts()" :key="chart.id"> <article class="metric-chart-card"> <div class="metric-chart-head"> <h3 class="metric-chart-title" x-text="chart.title"></h3> @@ -4728,10 +4816,13 @@ mod tests { transaction_count: 0, transfer_count: 0, burn_count: 0, + burn_claim_count: 0, mine_count: 0, burned_amount: 0, total_burned_amount: 0, fees_amount: 0, + burn_claim_fee_share_count: 0, + burn_claim_fee_share_amount: 0, reward_amount: 0, vdf_rounds, finalizer_rank: 0, @@ -4780,8 +4871,41 @@ mod tests { } #[test] + fn metrics_response_includes_burn_claim_charts() { + let mut row = metric_row(7, Some(600_000), 120); + row.burn_claim_count = 2; + row.burn_claim_fee_share_count = 3; + row.burn_claim_fee_share_amount = 4_500_000; + let response = super::metrics_response(true, vec![row]); + + let burn_claims = response + .charts + .iter() + .find(|chart| chart.id == "burn-claims") + .expect("burn claim count chart should exist"); + assert_eq!(burn_claims.section, "burnClaims"); + assert_eq!(burn_claims.points[0].value, 2.0); + + let payouts = response + .charts + .iter() + .find(|chart| chart.id == "burn-claim-payouts") + .expect("burn claim payout count chart should exist"); + assert_eq!(payouts.section, "burnClaims"); + assert_eq!(payouts.points[0].value, 3.0); + + let payout_amount = response + .charts + .iter() + .find(|chart| chart.id == "burn-claim-payout-amount") + .expect("burn claim payout amount chart should exist"); + assert_eq!(payout_amount.section, "burnClaims"); + assert_eq!(payout_amount.points[0].value, 4.5); + } + + #[test] fn metrics_screen_includes_block_range_filter() { - assert!(super::INDEX_HTML.contains("iuna-ui.js?v=76")); + assert!(super::INDEX_HTML.contains("iuna-ui.js?v=77")); assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\"")); assert!(super::INDEX_HTML.contains("setMetricsRange(100)")); assert!(super::INDEX_HTML.contains("setMetricsRange(1000)")); @@ -4789,6 +4913,14 @@ mod tests { } #[test] + fn metrics_screen_includes_burn_claim_section() { + assert!(super::INDEX_HTML.contains("metricsBurnClaimCharts()")); + assert!(super::INDEX_HTML.contains("Burn claims</h3>")); + assert!(super::INDEX_HTML.contains("burnClaimCount")); + assert!(super::INDEX_HTML.contains("burnClaimFeeShareAmount")); + } + + #[test] fn initial_setup_includes_node_mode_choices() { assert!(super::INDEX_HTML.contains("aria-label=\"Initial node mode\"")); assert!(super::INDEX_HTML.contains("selectSetupNodeMode('wallet')")); diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -1171,6 +1171,14 @@ window.iunaApp = function iunaApp() { return Array.isArray(this.blockchainMetrics?.charts) ? this.blockchainMetrics.charts : []; }, + metricsChainCharts() { + return this.metricsCharts().filter((chart) => chart.section !== "burnClaims"); + }, + + metricsBurnClaimCharts() { + return this.metricsCharts().filter((chart) => chart.section === "burnClaims"); + }, + metricsLatest() { return this.blockchainMetrics?.latest || {}; },