iuna

iuna

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

metrics.rs (19518B)


      1 use std::collections::BTreeMap;
      2 
      3 use crate::{
      4     adapters::ui_data_store::BlockMetricRow,
      5     app::{PeerDirection, PeerInfo},
      6     domain::{Amount, Block, Transaction},
      7 };
      8 
      9 use super::{
     10     PEER_STALE_AFTER_MS, now_ms,
     11     types::{
     12         MempoolCounts, MetricsChart, MetricsLeaderboards, MetricsPoint, MetricsResponse,
     13         MetricsValueKind, MineProofLeaderboardEntry, NetworkHealthLocalState,
     14         NetworkHealthResponse,
     15     },
     16     ui::proof_bits,
     17 };
     18 
     19 pub(super) fn network_health(
     20     local: NetworkHealthLocalState,
     21     peers: &[PeerInfo],
     22     mempool: MempoolCounts,
     23 ) -> NetworkHealthResponse {
     24     network_health_at(local, peers, mempool, now_ms())
     25 }
     26 
     27 pub(super) fn metrics_response(
     28     enabled: bool,
     29     preparing: bool,
     30     rows: Vec<BlockMetricRow>,
     31     leaderboards: MetricsLeaderboards,
     32     chain_storage_bytes: &BTreeMap<String, u64>,
     33     top_mine_proofs: Vec<MineProofLeaderboardEntry>,
     34 ) -> MetricsResponse {
     35     let latest = rows.last().cloned();
     36     MetricsResponse {
     37         enabled,
     38         preparing,
     39         latest,
     40         leaderboards,
     41         top_mine_proofs,
     42         charts: vec![
     43             metrics_chart(
     44                 "block-time",
     45                 "Block interval",
     46                 "s",
     47                 MetricsValueKind::Seconds,
     48                 &rows,
     49                 |row| {
     50                     row.block_time_ms
     51                         .filter(|_| row.height > 1)
     52                         .map(|ms| ms as f64 / 1_000.0)
     53                 },
     54             ),
     55             metrics_chart(
     56                 "difficulty",
     57                 "Mining difficulty",
     58                 "bits",
     59                 MetricsValueKind::Number,
     60                 &rows,
     61                 |row| Some(row.mine_difficulty_bits as f64),
     62             ),
     63             metrics_chart(
     64                 "supply",
     65                 "Circulating supply",
     66                 "IUNA",
     67                 MetricsValueKind::Iuna,
     68                 &rows,
     69                 |row| Some(micro_iuna_as_iuna(row.circulating_supply)),
     70             ),
     71             metrics_chart(
     72                 "chain-storage-bytes",
     73                 "Compact chain size",
     74                 "bytes",
     75                 MetricsValueKind::Bytes,
     76                 &rows,
     77                 |row| {
     78                     chain_storage_bytes
     79                         .get(&row.block_hash)
     80                         .map(|bytes| *bytes as f64)
     81                 },
     82             ),
     83             metrics_chart(
     84                 "total-utxos",
     85                 "UTXO set size",
     86                 "UTXOs",
     87                 MetricsValueKind::Number,
     88                 &rows,
     89                 |row| Some(row.utxo_count as f64),
     90             ),
     91             metrics_chart(
     92                 "transactions",
     93                 "Transactions per block",
     94                 "tx",
     95                 MetricsValueKind::Number,
     96                 &rows,
     97                 |row| Some(row.transaction_count as f64),
     98             ),
     99             metrics_chart(
    100                 "burn-count",
    101                 "Burns per block",
    102                 "burns",
    103                 MetricsValueKind::Number,
    104                 &rows,
    105                 |row| Some(row.burn_count as f64),
    106             ),
    107             metrics_chart(
    108                 "burn-amount",
    109                 "Burned per block",
    110                 "IUNA",
    111                 MetricsValueKind::Iuna,
    112                 &rows,
    113                 |row| Some(micro_iuna_as_iuna(row.burned_amount)),
    114             ),
    115             metrics_chart(
    116                 "total-burn",
    117                 "Cumulative burned",
    118                 "IUNA",
    119                 MetricsValueKind::Iuna,
    120                 &rows,
    121                 |row| Some(micro_iuna_as_iuna(row.total_burned_amount)),
    122             ),
    123             metrics_chart(
    124                 "fees",
    125                 "Fees per block",
    126                 "IUNA",
    127                 MetricsValueKind::Iuna,
    128                 &rows,
    129                 |row| Some(micro_iuna_as_iuna(row.fees_amount)),
    130             ),
    131             metrics_chart(
    132                 "mine-actions",
    133                 "Mine actions per block",
    134                 "mine",
    135                 MetricsValueKind::Number,
    136                 &rows,
    137                 |row| Some(row.mine_count as f64),
    138             ),
    139             metrics_chart(
    140                 "vdf-rounds",
    141                 "VDF work per block",
    142                 "rounds",
    143                 MetricsValueKind::Number,
    144                 &rows,
    145                 |row| (row.vdf_rounds > 0).then_some(row.vdf_rounds as f64),
    146             ),
    147         ],
    148     }
    149 }
    150 
    151 pub(super) fn top_mine_proofs(blocks: &[Block], limit: usize) -> Vec<MineProofLeaderboardEntry> {
    152     let mut proofs = blocks
    153         .iter()
    154         .flat_map(|block| {
    155             block.transactions.iter().filter_map(|transaction| {
    156                 let Transaction::Mine {
    157                     recipient,
    158                     difficulty_bits,
    159                     signature,
    160                     ..
    161                 } = transaction
    162                 else {
    163                     return None;
    164                 };
    165                 Some(MineProofLeaderboardEntry {
    166                     height: block.height,
    167                     address: recipient.clone(),
    168                     proof_bits: proof_bits(signature),
    169                     difficulty_bits: *difficulty_bits,
    170                     proof_hash: signature.clone(),
    171                 })
    172             })
    173         })
    174         .collect::<Vec<_>>();
    175     proofs.sort_by(|left, right| {
    176         right
    177             .proof_bits
    178             .cmp(&left.proof_bits)
    179             .then_with(|| left.height.cmp(&right.height))
    180             .then_with(|| left.proof_hash.cmp(&right.proof_hash))
    181     });
    182     proofs.truncate(limit);
    183     proofs
    184 }
    185 
    186 fn metrics_chart(
    187     id: &'static str,
    188     title: &'static str,
    189     unit: &'static str,
    190     value_kind: MetricsValueKind,
    191     rows: &[BlockMetricRow],
    192     value: impl Fn(&BlockMetricRow) -> Option<f64>,
    193 ) -> MetricsChart {
    194     MetricsChart {
    195         id,
    196         title,
    197         unit,
    198         value_kind,
    199         points: rows
    200             .iter()
    201             .filter_map(|row| {
    202                 value(row).map(|value| MetricsPoint {
    203                     height: row.height,
    204                     value,
    205                 })
    206             })
    207             .collect(),
    208     }
    209 }
    210 
    211 fn micro_iuna_as_iuna(amount: Amount) -> f64 {
    212     amount as f64 / 1_000_000.0
    213 }
    214 
    215 pub(super) fn network_health_at(
    216     local: NetworkHealthLocalState,
    217     peers: &[PeerInfo],
    218     mempool: MempoolCounts,
    219     now_ms: u64,
    220 ) -> NetworkHealthResponse {
    221     let local_height = local.height;
    222     let last_block_age_ms = local
    223         .tip_timestamp_ms
    224         .map(|tip_timestamp_ms| now_ms.saturating_sub(tip_timestamp_ms));
    225     let remote_best_height = peers.iter().filter_map(|peer| peer.last_known_height).max();
    226     let best_known_height = remote_best_height
    227         .unwrap_or(local_height)
    228         .max(local.sync_target_height.unwrap_or(local_height))
    229         .max(local_height);
    230     let healthy_heights = peers
    231         .iter()
    232         .filter(|peer| peer.last_error.is_none())
    233         .filter_map(|peer| peer.last_known_height)
    234         .collect::<Vec<_>>();
    235     let shared_height = healthy_heights
    236         .iter()
    237         .copied()
    238         .min()
    239         .unwrap_or(local_height)
    240         .min(local_height);
    241     let outbound_peers = peers
    242         .iter()
    243         .filter(|peer| peer.direction != PeerDirection::Inbound)
    244         .count();
    245     let inbound_peers = peers
    246         .iter()
    247         .filter(|peer| peer.direction == PeerDirection::Inbound)
    248         .count();
    249     let healthy_peers = peers
    250         .iter()
    251         .filter(|peer| peer.last_error.is_none() && peer.last_known_height.is_some())
    252         .count();
    253     let failed_peers = peers
    254         .iter()
    255         .filter(|peer| peer.last_error.is_some())
    256         .count();
    257     let stale_peers = peers
    258         .iter()
    259         .filter(|peer| {
    260             peer.last_success_ms.is_some_and(|last_success| {
    261                 now_ms.saturating_sub(last_success) > PEER_STALE_AFTER_MS
    262             })
    263         })
    264         .count();
    265     let banned_peers = peers
    266         .iter()
    267         .filter(|peer| peer.is_banned_at(now_ms))
    268         .count();
    269     let network_time_offset_ms = median_peer_clock_offset(peers, now_ms);
    270     let bad_clock_peers = peers
    271         .iter()
    272         .filter(|peer| {
    273             peer.last_clock_observed_ms.is_some_and(|observed_ms| {
    274                 now_ms.saturating_sub(observed_ms) <= PEER_STALE_AFTER_MS
    275             })
    276         })
    277         .filter(|peer| peer.last_clock_offset_accepted == Some(false))
    278         .count();
    279     let lag_blocks = best_known_height.saturating_sub(local_height);
    280     let last_error = peers.iter().rev().find_map(|peer| {
    281         peer.last_error
    282             .as_ref()
    283             .map(|error| format!("{}: {error}", peer.address))
    284     });
    285     let rejected_chain_payloads = local
    286         .rejected_blocks
    287         .saturating_add(local.rejected_block_batches)
    288         .saturating_add(local.rejected_snapshots);
    289     let actively_syncing = local
    290         .sync_target_height
    291         .is_some_and(|target_height| target_height > local_height);
    292 
    293     let state = if actively_syncing {
    294         "syncing"
    295     } else if peers.is_empty() {
    296         "isolated"
    297     } else if banned_peers > 0 && healthy_peers == 0 {
    298         "banned"
    299     } else if lag_blocks > 0 {
    300         "syncing"
    301     } else if failed_peers > 0 && healthy_peers == 0 {
    302         "peer errors"
    303     } else if stale_peers > 0 && healthy_peers == stale_peers {
    304         "stale"
    305     } else if remote_best_height.is_some_and(|height| local_height > height) {
    306         "ahead of peers"
    307     } else {
    308         "healthy"
    309     }
    310     .to_string();
    311 
    312     NetworkHealthResponse {
    313         ok: !peers.is_empty() && lag_blocks == 0 && healthy_peers > stale_peers,
    314         state,
    315         local_height,
    316         local_tip_hash: local.tip_hash,
    317         finalized_height: local.finalized_height,
    318         finalized_hash: local.finalized_hash,
    319         last_block_age_ms,
    320         best_known_height,
    321         sync_start_height: local.sync_start_height,
    322         sync_validated_height: local.sync_validated_height,
    323         sync_target_height: local.sync_target_height,
    324         shared_height,
    325         lag_blocks,
    326         outbound_peers,
    327         inbound_peers,
    328         healthy_peers,
    329         failed_peers,
    330         stale_peers,
    331         banned_peers,
    332         pending_transactions: local.pending_transactions,
    333         pending_plain_transactions: mempool.plain_transactions,
    334         pending_v2_transactions: mempool.v2_transactions,
    335         last_finalizer_mode: local.last_finalizer_mode,
    336         last_finalizer_rank: local.last_finalizer_rank,
    337         last_block_finalizer: local.last_block_finalizer,
    338         current_leader: local.current_leader,
    339         wallet_is_current_leader: local.wallet_is_current_leader,
    340         last_auto_finalization_status: local.last_auto_finalization_status,
    341         vdf_rounds: local.vdf_rounds,
    342         vdf_target_block_ms: local.vdf_target_block_ms,
    343         rejected_blocks: local.rejected_blocks,
    344         rejected_block_batches: local.rejected_block_batches,
    345         rejected_snapshots: local.rejected_snapshots,
    346         rejected_chain_payloads,
    347         last_chain_payload_error: local.last_chain_payload_error,
    348         network_time_offset_ms,
    349         bad_clock_peers,
    350         last_error,
    351     }
    352 }
    353 
    354 fn median_peer_clock_offset(peers: &[PeerInfo], now_ms: u64) -> Option<i64> {
    355     let mut offsets = peers
    356         .iter()
    357         .filter(|peer| peer.last_error.is_none())
    358         .filter(|peer| !peer.is_banned_at(now_ms))
    359         .filter(|peer| peer.last_clock_offset_accepted == Some(true))
    360         .filter(|peer| {
    361             peer.last_clock_observed_ms.is_some_and(|observed_ms| {
    362                 now_ms.saturating_sub(observed_ms) <= PEER_STALE_AFTER_MS
    363             })
    364         })
    365         .filter_map(|peer| peer.last_clock_offset_ms)
    366         .collect::<Vec<_>>();
    367     if offsets.is_empty() {
    368         return None;
    369     }
    370     offsets.sort_unstable();
    371     Some(offsets[offsets.len() / 2])
    372 }
    373 
    374 #[cfg(test)]
    375 mod tests {
    376     use std::collections::BTreeMap;
    377 
    378     use crate::{
    379         adapters::ui_data_store::BlockMetricRow,
    380         domain::{Block, BurnBundleSection, FinalizerMode, Transaction},
    381     };
    382 
    383     use super::{
    384         MempoolCounts, MetricsLeaderboards, NetworkHealthLocalState, metrics_response,
    385         network_health_at, top_mine_proofs,
    386     };
    387 
    388     #[test]
    389     fn stale_metrics_remain_visible_while_the_latest_tip_is_preparing() {
    390         let row = BlockMetricRow {
    391             height: 7,
    392             block_hash: "cached-tip".to_string(),
    393             timestamp_ms: 1_000,
    394             block_time_ms: Some(100),
    395             mine_difficulty_bits: 10,
    396             circulating_supply: 100,
    397             known_wallet_addresses: 2,
    398             utxo_count: 4,
    399             transaction_count: 1,
    400             transfer_count: 0,
    401             burn_count: 1,
    402             mine_count: 0,
    403             burned_amount: 1,
    404             total_burned_amount: 3,
    405             fees_amount: 1,
    406             reward_amount: 1,
    407             vdf_rounds: 10,
    408             finalizer_rank: 0,
    409         };
    410 
    411         let chain_storage_bytes = BTreeMap::from([("cached-tip".to_string(), 12_345)]);
    412         let response = metrics_response(
    413             true,
    414             true,
    415             vec![row.clone()],
    416             MetricsLeaderboards::default(),
    417             &chain_storage_bytes,
    418             Vec::new(),
    419         );
    420 
    421         assert!(response.preparing);
    422         assert_eq!(response.latest, Some(row));
    423         assert!(response.charts.iter().any(|chart| !chart.points.is_empty()));
    424         assert_eq!(
    425             response
    426                 .charts
    427                 .iter()
    428                 .find(|chart| chart.id == "chain-storage-bytes")
    429                 .and_then(|chart| chart.points.first())
    430                 .map(|point| point.value),
    431             Some(12_345.0)
    432         );
    433         assert_eq!(
    434             response
    435                 .charts
    436                 .iter()
    437                 .find(|chart| chart.id == "total-utxos")
    438                 .and_then(|chart| chart.points.first())
    439                 .map(|point| point.value),
    440             Some(4.0)
    441         );
    442         assert_eq!(
    443             response
    444                 .charts
    445                 .iter()
    446                 .map(|chart| chart.id)
    447                 .collect::<Vec<_>>(),
    448             vec![
    449                 "block-time",
    450                 "difficulty",
    451                 "supply",
    452                 "chain-storage-bytes",
    453                 "total-utxos",
    454                 "transactions",
    455                 "burn-count",
    456                 "burn-amount",
    457                 "total-burn",
    458                 "fees",
    459                 "mine-actions",
    460                 "vdf-rounds",
    461             ]
    462         );
    463     }
    464 
    465     #[test]
    466     fn mine_proofs_are_ranked_by_achieved_bits() {
    467         let mine = |recipient: &str, signature: &str| Transaction::Mine {
    468             recipient: recipient.to_string(),
    469             anchor: "anchor".to_string(),
    470             salt: 0,
    471             nonce: 0,
    472             difficulty_bits: 4,
    473             proof_header: None,
    474             signature: signature.to_string(),
    475         };
    476         let block = |height, transactions| Block {
    477             height,
    478             prev_hash: "0".repeat(64),
    479             timestamp_ms: 0,
    480             miner: "finalizer".to_string(),
    481             reward_address: None,
    482             reward_address_signature: None,
    483             finalizer_mode: FinalizerMode::Ticket,
    484             finalizer_rank: 0,
    485             reward: 0,
    486             vdf_rounds: 1,
    487             vdf_output: String::new(),
    488             leader_proof: None,
    489             burn_bundle_section: BurnBundleSection::default(),
    490             transactions,
    491             transactions_v2: Vec::new(),
    492             hash: format!("block-{height}"),
    493         };
    494 
    495         let proofs = top_mine_proofs(
    496             &[
    497                 block(
    498                     7,
    499                     vec![mine("four-bits", "0fff"), mine("late-twelve-bits", "000f")],
    500                 ),
    501                 block(
    502                     3,
    503                     vec![
    504                         mine("early-twelve-bits", "000a"),
    505                         mine("eight-bits", "00ff"),
    506                     ],
    507                 ),
    508             ],
    509             3,
    510         );
    511 
    512         assert_eq!(proofs.len(), 3);
    513         assert_eq!(proofs[0].address, "early-twelve-bits");
    514         assert_eq!(proofs[0].proof_bits, 12);
    515         assert_eq!(proofs[0].height, 3);
    516         assert_eq!(proofs[1].address, "late-twelve-bits");
    517         assert_eq!(proofs[1].proof_bits, 12);
    518         assert_eq!(proofs[1].height, 7);
    519         assert_eq!(proofs[2].address, "eight-bits");
    520         assert_eq!(proofs[2].proof_bits, 8);
    521     }
    522 
    523     #[test]
    524     fn network_health_exposes_operator_chain_and_rejection_context() {
    525         let local = NetworkHealthLocalState {
    526             height: 42,
    527             tip_hash: "tip-hash".to_string(),
    528             finalized_height: Some(40),
    529             finalized_hash: Some("finalized-hash".to_string()),
    530             tip_timestamp_ms: Some(1_000),
    531             sync_start_height: Some(42),
    532             sync_validated_height: Some(47),
    533             sync_target_height: Some(60),
    534             pending_transactions: 3,
    535             last_finalizer_mode: Some("ticket".to_string()),
    536             last_finalizer_rank: Some(1),
    537             last_block_finalizer: Some("last-finalizer".to_string()),
    538             current_leader: Some("next-leader".to_string()),
    539             wallet_is_current_leader: true,
    540             last_auto_finalization_status: Some("waiting for VDF".to_string()),
    541             vdf_rounds: 67_000_000,
    542             vdf_target_block_ms: 120_000,
    543             rejected_blocks: 2,
    544             rejected_block_batches: 3,
    545             rejected_snapshots: 5,
    546             last_chain_payload_error: Some("snapshot: invalid block".to_string()),
    547         };
    548 
    549         let health = network_health_at(
    550             local,
    551             &[],
    552             MempoolCounts {
    553                 plain_transactions: 3,
    554                 v2_transactions: 0,
    555             },
    556             2_500,
    557         );
    558 
    559         assert_eq!(health.local_height, 42);
    560         assert_eq!(health.best_known_height, 60);
    561         assert_eq!(health.sync_start_height, Some(42));
    562         assert_eq!(health.sync_validated_height, Some(47));
    563         assert_eq!(health.sync_target_height, Some(60));
    564         assert_eq!(health.state, "syncing");
    565         assert_eq!(health.local_tip_hash, "tip-hash");
    566         assert_eq!(health.finalized_height, Some(40));
    567         assert_eq!(health.finalized_hash.as_deref(), Some("finalized-hash"));
    568         assert_eq!(health.last_block_age_ms, Some(1_500));
    569         assert_eq!(health.last_finalizer_mode.as_deref(), Some("ticket"));
    570         assert_eq!(health.last_finalizer_rank, Some(1));
    571         assert_eq!(
    572             health.last_block_finalizer.as_deref(),
    573             Some("last-finalizer")
    574         );
    575         assert_eq!(health.current_leader.as_deref(), Some("next-leader"));
    576         assert!(health.wallet_is_current_leader);
    577         assert_eq!(
    578             health.last_auto_finalization_status.as_deref(),
    579             Some("waiting for VDF")
    580         );
    581         assert_eq!(health.vdf_rounds, 67_000_000);
    582         assert_eq!(health.vdf_target_block_ms, 120_000);
    583         assert_eq!(health.rejected_blocks, 2);
    584         assert_eq!(health.rejected_block_batches, 3);
    585         assert_eq!(health.rejected_snapshots, 5);
    586         assert_eq!(health.rejected_chain_payloads, 10);
    587         assert_eq!(
    588             health.last_chain_payload_error.as_deref(),
    589             Some("snapshot: invalid block")
    590         );
    591     }
    592 }