iuna

iuna

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

api.rs (24693B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 use std::net::SocketAddr;
      3 
      4 use anyhow::Result;
      5 use axum::{
      6     Json,
      7     extract::{Query, State},
      8 };
      9 
     10 use crate::{
     11     adapters::p2p::P2pMetrics,
     12     app::{NodeStatus, PeerInfo},
     13     domain::{AddressNetwork, OutPoint, Transaction, TransactionV2, TxOutput, decode_hex},
     14     ip_geolocation::IpGeolocation,
     15 };
     16 
     17 use super::types::{LeaderboardEntry, MetricsLeaderboards};
     18 use super::{
     19     BlocksQuery, ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse,
     20     NetworkHealthLocalState, NetworkHealthResponse, Page, PageQuery, PeerPresentation, UiBlock,
     21     UiTransaction, WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow,
     22     WalletTransactionsQuery, WalletUtxoRow,
     23 };
     24 use super::{
     25     DATASET_LIMIT, DATASET_PAGE_LIMIT, EXPLORER_LIMIT, EXPLORER_PAGE_LIMIT, HttpState,
     26     add_pending_outputs, add_pending_v2_outputs, metrics_response, network_health,
     27     populate_wallet_reward_flow, top_mine_proofs, transaction_v2_input_outpoints,
     28     ui_blocks_from_indexes, ui_transaction, ui_transaction_v2, wallet_transaction_rows,
     29     wallet_transaction_v2_row, wallet_transaction_v2_rows,
     30 };
     31 
     32 pub(super) async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> {
     33     let mut status = state.node.lock().await.status();
     34     status.stratum = state.stratum.clone();
     35     Json(status)
     36 }
     37 
     38 pub(super) async fn api_blocks(
     39     State(state): State<HttpState>,
     40     Query(query): Query<BlocksQuery>,
     41 ) -> Json<Vec<UiBlock>> {
     42     let limit = query
     43         .limit
     44         .unwrap_or(EXPLORER_PAGE_LIMIT)
     45         .min(EXPLORER_LIMIT);
     46     let (tip_hash, blocks, storage_size_breakdowns, transaction_v2_domain, network) = {
     47         let node = state.node.lock().await;
     48         let blocks = match query.before_height {
     49             Some(before_height) => node.blocks_before(before_height, limit),
     50             None => node.recent_blocks(limit),
     51         };
     52         let storage_size_breakdowns = node.block_storage_size_breakdowns(&blocks);
     53         (
     54             node.chain_tip_hash(),
     55             blocks,
     56             storage_size_breakdowns,
     57             node.ledger().transaction_v2_domain().ok(),
     58             AddressNetwork::from_profile_id(&node.ledger().launch_profile().profile_id),
     59         )
     60     };
     61     let store = state.ui_data_store.clone();
     62     let view = tokio::task::spawn_blocking(move || store.load_ui_chain_index(&tip_hash))
     63         .await
     64         .ok()
     65         .and_then(Result::ok)
     66         .flatten()
     67         .unwrap_or_default();
     68     Json(ui_blocks_from_indexes(
     69         blocks,
     70         &view.outputs,
     71         &view.burn_leader_ranks_by_hash,
     72         &storage_size_breakdowns,
     73         transaction_v2_domain.as_ref(),
     74         network,
     75     ))
     76 }
     77 
     78 pub(super) async fn api_config(State(state): State<HttpState>) -> Json<ConfigResponse> {
     79     Json(ConfigResponse {
     80         config: state.ui_config.lock().await.clone(),
     81         p2p_inbound_runtime_active: state.gossip.accepts_inbound().await,
     82         p2p_runtime_bind_addr: state.gossip.listen_addr().to_string(),
     83         stratum_runtime_enabled: state.stratum.enabled,
     84         stratum_runtime_listen_addr: state.stratum.listen_addr.clone(),
     85         wallet_endpoint_runtime_enabled: state.wallet_endpoint_addr.is_some(),
     86         wallet_endpoint_runtime_listen_addr: state
     87             .wallet_endpoint_addr
     88             .map(|addr| addr.to_string()),
     89     })
     90 }
     91 
     92 pub(super) async fn api_mempool(
     93     State(state): State<HttpState>,
     94     Query(query): Query<PageQuery>,
     95 ) -> Json<Page<UiTransaction>> {
     96     let (pending, pending_v2, domain, network, confirmed_outputs) = {
     97         let node = state.node.lock().await;
     98         let pending_v2 = node.pending_transactions_v2();
     99         let confirmed_outputs = pending_v2
    100             .iter()
    101             .flat_map(transaction_v2_input_outpoints)
    102             .filter_map(|outpoint| {
    103                 node.ledger()
    104                     .output_for_outpoint(&outpoint)
    105                     .map(|output| (outpoint, output))
    106             })
    107             .collect::<BTreeMap<_, _>>();
    108         (
    109             node.pending_transactions(),
    110             pending_v2,
    111             node.ledger().transaction_v2_domain().ok(),
    112             AddressNetwork::from_profile_id(&node.ledger().launch_profile().profile_id),
    113             confirmed_outputs,
    114         )
    115     };
    116     let mut required_outputs = BTreeSet::new();
    117     collect_transaction_input_outpoints(pending.iter(), &mut required_outputs);
    118     let mut outputs = load_outputs_for_outpoints(&state, required_outputs)
    119         .await
    120         .unwrap_or_default();
    121     add_pending_outputs(&mut outputs, &pending);
    122     let mut items = pending
    123         .iter()
    124         .map(|tx| ui_transaction(tx, &outputs))
    125         .collect::<Vec<_>>();
    126     let mut v2_outputs = confirmed_outputs;
    127     if let Some(domain) = domain.as_ref() {
    128         let _ = add_pending_v2_outputs(&mut v2_outputs, &pending_v2, domain, network);
    129         items.extend(pending_v2.iter().filter_map(|transaction| {
    130             ui_transaction_v2(transaction, &v2_outputs, domain, network).ok()
    131         }));
    132     }
    133     items.reverse();
    134     Json(page_items(items, query))
    135 }
    136 
    137 pub(super) async fn api_wallet_transactions(
    138     State(state): State<HttpState>,
    139     Query(query): Query<WalletTransactionsQuery>,
    140 ) -> Json<Page<WalletTransactionRow>> {
    141     let page_query = query.page();
    142     let offset = page_query.offset.unwrap_or(0);
    143     let limit = page_query
    144         .limit
    145         .unwrap_or(DATASET_PAGE_LIMIT)
    146         .clamp(1, DATASET_LIMIT);
    147     let filters = WalletTransactionFilters::from_query(query);
    148     let (wallet_addresses, pending, pending_v2, domain, network, v2_outputs) = {
    149         let node = state.node.lock().await;
    150         let status = node.status();
    151         let mut wallet_addresses = node
    152             .wallet_owned_addresses()
    153             .unwrap_or_else(|_| vec![node.wallet_address().to_string()]);
    154         wallet_addresses.push(status.wallet_receive_address);
    155         wallet_addresses.sort();
    156         wallet_addresses.dedup();
    157         let pending_v2 = node.pending_transactions_v2();
    158         let v2_outputs = pending_v2
    159             .iter()
    160             .flat_map(transaction_v2_input_outpoints)
    161             .filter_map(|outpoint| {
    162                 node.ledger()
    163                     .output_for_outpoint(&outpoint)
    164                     .map(|output| (outpoint, output))
    165             })
    166             .collect::<BTreeMap<_, _>>();
    167         (
    168             wallet_addresses,
    169             node.pending_transactions(),
    170             pending_v2,
    171             node.ledger().transaction_v2_domain().ok(),
    172             AddressNetwork::from_profile_id(&node.ledger().launch_profile().profile_id),
    173             v2_outputs,
    174         )
    175     };
    176     let mut pending_required_outputs = BTreeSet::new();
    177     collect_transaction_input_outpoints(pending.iter(), &mut pending_required_outputs);
    178     let mut pending_outputs = load_outputs_for_outpoints(&state, pending_required_outputs)
    179         .await
    180         .unwrap_or_default();
    181     add_pending_outputs(&mut pending_outputs, &pending);
    182     let mut pending_rows = wallet_transaction_rows(
    183         &wallet_addresses,
    184         pending.clone(),
    185         &[],
    186         &pending_outputs,
    187         filters,
    188     );
    189     if let Some(domain) = domain.as_ref() {
    190         let mut v2_outputs = v2_outputs;
    191         let _ = add_pending_v2_outputs(&mut v2_outputs, &pending_v2, domain, network);
    192         let mut v2_rows = wallet_transaction_v2_rows(
    193             &wallet_addresses,
    194             &pending_v2,
    195             &v2_outputs,
    196             filters,
    197             domain,
    198             network,
    199         );
    200         v2_rows.append(&mut pending_rows);
    201         pending_rows = v2_rows;
    202     }
    203     let pending_total = pending_rows.len();
    204     let mut items = pending_rows
    205         .into_iter()
    206         .skip(offset.min(pending_total))
    207         .take(limit)
    208         .collect::<Vec<_>>();
    209 
    210     let confirmed_offset = offset.saturating_sub(pending_total);
    211     let remaining_limit = limit.saturating_sub(items.len());
    212     let kinds = wallet_transaction_filter_kinds(filters);
    213     let store = state.ui_data_store.clone();
    214     let wallet_addresses_for_query = wallet_addresses.clone();
    215     let confirmed_fetch_limit = confirmed_offset.saturating_add(remaining_limit);
    216     let ((confirmed_rows, confirmed_legacy_total), (confirmed_v2_rows, confirmed_v2_total)) =
    217         tokio::task::spawn_blocking(move || -> Result<_> {
    218             Ok((
    219                 store.load_wallet_transactions_for_addresses(
    220                     &wallet_addresses_for_query,
    221                     &kinds,
    222                     0,
    223                     confirmed_fetch_limit,
    224                 )?,
    225                 store.load_wallet_transactions_v2(
    226                     &wallet_addresses_for_query,
    227                     &kinds,
    228                     0,
    229                     confirmed_fetch_limit,
    230                 )?,
    231             ))
    232         })
    233         .await
    234         .ok()
    235         .and_then(Result::ok)
    236         .unwrap_or_default();
    237     let decoded_confirmed_v2 = domain.as_ref().map_or_else(Vec::new, |expected_domain| {
    238         confirmed_v2_rows
    239             .into_iter()
    240             .filter_map(|row| {
    241                 let bytes = decode_hex(&row.envelope).ok()?;
    242                 let (decoded_domain, transaction) = TransactionV2::decode(&bytes).ok()?;
    243                 (decoded_domain == *expected_domain).then_some((row, transaction))
    244             })
    245             .collect::<Vec<_>>()
    246     });
    247     let mut confirmed_required_outputs = BTreeSet::new();
    248     collect_transaction_input_outpoints(
    249         confirmed_rows.iter().map(|row| &row.transaction),
    250         &mut confirmed_required_outputs,
    251     );
    252     for (_, transaction) in &decoded_confirmed_v2 {
    253         confirmed_required_outputs.extend(transaction_v2_input_outpoints(transaction));
    254     }
    255     let confirmed_outputs = load_outputs_for_outpoints(&state, confirmed_required_outputs)
    256         .await
    257         .unwrap_or_default();
    258     let reward_blocks = {
    259         let node = state.node.lock().await;
    260         confirmed_rows
    261             .iter()
    262             .filter(|row| row.kind == "reward")
    263             .filter_map(|row| {
    264                 let block = node.chain().get(row.block_height as usize)?.clone();
    265                 Some((row.block_height, block))
    266             })
    267             .collect::<BTreeMap<_, _>>()
    268     };
    269     let mut confirmed_items = confirmed_rows
    270         .into_iter()
    271         .filter_map(|row| {
    272             let sort_key = row.sort_key;
    273             let is_reward = row.kind == "reward";
    274             let mut item = super::ui::wallet_transaction_row_for_addresses(
    275                 &wallet_addresses,
    276                 &row.transaction,
    277                 &confirmed_outputs,
    278                 &WalletTransactionContext {
    279                     status: "confirmed",
    280                     block_height: Some(row.block_height),
    281                     timestamp_ms: Some(row.timestamp_ms),
    282                     block_finalizer: Some(row.block_finalizer),
    283                 },
    284             )?;
    285             if is_reward {
    286                 item.kind = "reward";
    287                 item.from = "fees".to_string();
    288                 item.direction = "reward";
    289                 if let Some(block) = reward_blocks.get(&row.block_height) {
    290                     populate_wallet_reward_flow(&mut item, block);
    291                 }
    292             }
    293             Some((sort_key, item))
    294         })
    295         .collect::<Vec<_>>();
    296     if let Some(domain) = domain.as_ref() {
    297         confirmed_items.extend(decoded_confirmed_v2.into_iter().filter_map(
    298             |(row, transaction)| {
    299                 let sort_key = row.sort_key;
    300                 let context = WalletTransactionContext {
    301                     status: "confirmed",
    302                     block_height: Some(row.block_height),
    303                     timestamp_ms: Some(row.timestamp_ms),
    304                     block_finalizer: Some(row.block_finalizer),
    305                 };
    306                 wallet_transaction_v2_row(
    307                     &wallet_addresses,
    308                     &transaction,
    309                     &confirmed_outputs,
    310                     domain,
    311                     network,
    312                     &context,
    313                 )
    314                 .ok()
    315                 .flatten()
    316                 .map(|item| (sort_key, item))
    317             },
    318         ));
    319     }
    320     confirmed_items.sort_by(|left, right| right.0.cmp(&left.0));
    321     items.extend(
    322         confirmed_items
    323             .into_iter()
    324             .skip(confirmed_offset)
    325             .take(remaining_limit)
    326             .map(|(_, item)| item),
    327     );
    328     let confirmed_total = confirmed_legacy_total.saturating_add(confirmed_v2_total);
    329     let total = pending_total + confirmed_total;
    330     let next_offset = offset + items.len();
    331     Json(Page {
    332         items,
    333         offset: offset.min(total),
    334         limit,
    335         total,
    336         has_more: next_offset < total,
    337         next_offset: (next_offset < total).then_some(next_offset),
    338     })
    339 }
    340 
    341 async fn load_outputs_for_outpoints(
    342     state: &HttpState,
    343     outpoints: BTreeSet<OutPoint>,
    344 ) -> Result<BTreeMap<OutPoint, TxOutput>> {
    345     if outpoints.is_empty() {
    346         return Ok(BTreeMap::new());
    347     }
    348     let store = state.ui_data_store.clone();
    349     tokio::task::spawn_blocking(move || store.load_outputs(&outpoints))
    350         .await
    351         .unwrap_or_else(|_| Ok(BTreeMap::new()))
    352 }
    353 
    354 async fn current_real_chain_tip(state: &HttpState) -> Option<String> {
    355     let node = state.node.lock().await;
    356     node.has_real_chain().then(|| node.chain_tip_hash())
    357 }
    358 
    359 fn collect_transaction_input_outpoints<'a>(
    360     transactions: impl IntoIterator<Item = &'a Transaction>,
    361     outpoints: &mut BTreeSet<OutPoint>,
    362 ) {
    363     for transaction in transactions {
    364         match transaction {
    365             Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => {
    366                 outpoints.extend(inputs.iter().map(|input| input.outpoint.clone()));
    367             }
    368             Transaction::Mine { .. } => {}
    369         }
    370     }
    371 }
    372 
    373 pub(super) async fn api_wallet_utxos(
    374     State(state): State<HttpState>,
    375     Query(query): Query<PageQuery>,
    376 ) -> Json<Page<WalletUtxoRow>> {
    377     let (wallet_addresses, pending_spent) = {
    378         let node = state.node.lock().await;
    379         (
    380             node.wallet_owned_addresses()
    381                 .unwrap_or_else(|_| vec![node.wallet_address().to_string()]),
    382             node.wallet_pending_spent_outpoints(),
    383         )
    384     };
    385     let store = state.ui_data_store.clone();
    386     let utxos = tokio::task::spawn_blocking(move || -> Result<Vec<_>> {
    387         let mut utxos = Vec::new();
    388         for address in wallet_addresses {
    389             utxos.extend(store.load_wallet_utxos(&address)?);
    390         }
    391         utxos.sort_by(|(left_point, left), (right_point, right)| {
    392             right
    393                 .amount
    394                 .cmp(&left.amount)
    395                 .then_with(|| left_point.cmp(right_point))
    396         });
    397         Ok(utxos)
    398     })
    399     .await
    400     .ok()
    401     .and_then(Result::ok)
    402     .unwrap_or_default();
    403     Json(page_items(
    404         wallet_utxo_rows_from_ui_data(utxos, &pending_spent),
    405         query,
    406     ))
    407 }
    408 
    409 pub(super) async fn api_wallet_selectable_utxos(
    410     State(state): State<HttpState>,
    411 ) -> Json<Vec<WalletUtxoRow>> {
    412     let (wallet, pending_spent) = {
    413         let node = state.node.lock().await;
    414         (
    415             node.wallet_address().to_string(),
    416             node.wallet_pending_spent_outpoints(),
    417         )
    418     };
    419     let store = state.ui_data_store.clone();
    420     let utxos = tokio::task::spawn_blocking(move || store.load_wallet_utxos(&wallet))
    421         .await
    422         .ok()
    423         .and_then(Result::ok)
    424         .unwrap_or_default();
    425     Json(
    426         wallet_utxo_rows_from_ui_data(utxos, &pending_spent)
    427             .into_iter()
    428             .filter(|utxo| utxo.spendable)
    429             .collect(),
    430     )
    431 }
    432 
    433 pub(super) fn page_items<T>(items: Vec<T>, query: PageQuery) -> Page<T> {
    434     let total = items.len();
    435     let offset = query.offset.unwrap_or(0).min(total);
    436     let limit = query
    437         .limit
    438         .unwrap_or(DATASET_PAGE_LIMIT)
    439         .clamp(1, DATASET_LIMIT);
    440     let page_items = items
    441         .into_iter()
    442         .skip(offset)
    443         .take(limit)
    444         .collect::<Vec<_>>();
    445     let next_offset = offset + page_items.len();
    446     Page {
    447         items: page_items,
    448         offset,
    449         limit,
    450         total,
    451         has_more: next_offset < total,
    452         next_offset: (next_offset < total).then_some(next_offset),
    453     }
    454 }
    455 
    456 fn wallet_transaction_filter_kinds(filters: WalletTransactionFilters) -> Vec<&'static str> {
    457     let mut kinds = Vec::new();
    458     if filters.transfer {
    459         kinds.push("transfer");
    460     }
    461     if filters.mine {
    462         kinds.push("mine");
    463     }
    464     if filters.burn {
    465         kinds.push("burn");
    466     }
    467     if filters.reward {
    468         kinds.push("reward");
    469     }
    470     kinds
    471 }
    472 
    473 fn wallet_utxo_rows_from_ui_data(
    474     utxos: Vec<(crate::domain::OutPoint, crate::domain::TxOutput)>,
    475     pending_spent: &BTreeSet<crate::domain::OutPoint>,
    476 ) -> Vec<WalletUtxoRow> {
    477     utxos
    478         .into_iter()
    479         .map(|(outpoint, output)| {
    480             let spendable = !pending_spent.contains(&outpoint);
    481             WalletUtxoRow {
    482                 outpoint,
    483                 address: output.address,
    484                 amount: output.amount,
    485                 spendable,
    486             }
    487         })
    488         .collect()
    489 }
    490 
    491 pub(super) async fn api_peers(
    492     State(state): State<HttpState>,
    493     Query(query): Query<PageQuery>,
    494 ) -> Json<Page<PeerPresentation>> {
    495     let peers = peer_presentations(state.peers.lock().await.list(), IpGeolocation::bundled());
    496     Json(page_items(peers, query))
    497 }
    498 
    499 fn peer_presentations(peers: Vec<PeerInfo>, geolocation: &IpGeolocation) -> Vec<PeerPresentation> {
    500     peers
    501         .into_iter()
    502         .map(|peer| {
    503             let country_code = peer
    504                 .address
    505                 .parse::<SocketAddr>()
    506                 .ok()
    507                 .and_then(|address| geolocation.country_for_ip(address.ip()));
    508             PeerPresentation { peer, country_code }
    509         })
    510         .collect()
    511 }
    512 
    513 pub(super) async fn api_p2p_metrics(State(state): State<HttpState>) -> Json<P2pMetrics> {
    514     Json(state.gossip.metrics())
    515 }
    516 
    517 pub(super) async fn api_metrics(
    518     State(state): State<HttpState>,
    519     Query(query): Query<MetricsQuery>,
    520 ) -> Json<MetricsResponse> {
    521     let enabled = state.ui_config.lock().await.keep_track_of_metrics;
    522     if !enabled {
    523         return Json(empty_metrics_response(enabled, false));
    524     }
    525     let Some(tip_hash) = current_real_chain_tip(&state).await else {
    526         return Json(empty_metrics_response(enabled, false));
    527     };
    528     let store = state.ui_data_store.clone();
    529     let ready = tokio::task::spawn_blocking(move || store.metrics_are_projected_to(&tip_hash))
    530         .await
    531         .ok()
    532         .and_then(Result::ok)
    533         .unwrap_or(false);
    534     let store = state.ui_data_store.clone();
    535     let rows = tokio::task::spawn_blocking(move || match query.limit {
    536         Some(limit) => store.load_recent_metrics(limit.clamp(1, DATASET_LIMIT)),
    537         None => store.load_metrics(),
    538     })
    539     .await
    540     .ok()
    541     .and_then(Result::ok)
    542     .unwrap_or_default();
    543     let store = state.ui_data_store.clone();
    544     let leaderboards = tokio::task::spawn_blocking(move || store.load_leaderboards(10))
    545         .await
    546         .ok()
    547         .and_then(Result::ok)
    548         .map(metrics_leaderboards)
    549         .unwrap_or_default();
    550     let (chain_storage_bytes, top_mine_proofs) = {
    551         let node = state.node.lock().await;
    552         (
    553             node.chain_storage_bytes_by_hash().unwrap_or_default(),
    554             top_mine_proofs(node.chain(), 10),
    555         )
    556     };
    557     if rows.is_empty() {
    558         let mut response = empty_metrics_response(enabled, !ready);
    559         response.top_mine_proofs = top_mine_proofs;
    560         return Json(response);
    561     }
    562     Json(metrics_response(
    563         enabled,
    564         !ready,
    565         rows,
    566         leaderboards,
    567         &chain_storage_bytes,
    568         top_mine_proofs,
    569     ))
    570 }
    571 
    572 fn empty_metrics_response(enabled: bool, preparing: bool) -> MetricsResponse {
    573     MetricsResponse {
    574         enabled,
    575         preparing,
    576         latest: None,
    577         charts: Vec::new(),
    578         leaderboards: MetricsLeaderboards::default(),
    579         top_mine_proofs: Vec::new(),
    580     }
    581 }
    582 
    583 fn metrics_leaderboards(
    584     leaderboards: crate::adapters::ui_data_store::UiLeaderboards,
    585 ) -> MetricsLeaderboards {
    586     MetricsLeaderboards {
    587         balances: leaderboards
    588             .balances
    589             .into_iter()
    590             .map(metrics_leaderboard_entry)
    591             .collect(),
    592         miners: leaderboards
    593             .miners
    594             .into_iter()
    595             .map(metrics_leaderboard_entry)
    596             .collect(),
    597         burners: leaderboards
    598             .burners
    599             .into_iter()
    600             .map(metrics_leaderboard_entry)
    601             .collect(),
    602     }
    603 }
    604 
    605 fn metrics_leaderboard_entry(
    606     entry: crate::adapters::ui_data_store::UiLeaderboardEntry,
    607 ) -> LeaderboardEntry {
    608     LeaderboardEntry {
    609         address: entry.address,
    610         amount: entry.amount,
    611         count: entry.count,
    612     }
    613 }
    614 
    615 pub(super) async fn api_network_health(
    616     State(state): State<HttpState>,
    617 ) -> Json<NetworkHealthResponse> {
    618     let sync_progress = state.gossip.sync_progress();
    619     let (local, mempool) = {
    620         let node = state.node.lock().await;
    621         let status = node.status();
    622         let tip = node.chain().last().cloned();
    623         let p2p_metrics = state.gossip.metrics();
    624         let tip_timestamp_ms = tip
    625             .as_ref()
    626             .filter(|block| block.height > 0)
    627             .map(|block| block.timestamp_ms);
    628         let mempool = MempoolCounts {
    629             plain_transactions: node.pending_transactions().len(),
    630             v2_transactions: node.pending_transactions_v2().len(),
    631         };
    632         (
    633             NetworkHealthLocalState {
    634                 height: status.chain.height,
    635                 tip_hash: status.chain.tip_hash,
    636                 finalized_height: status.chain.finalized_height,
    637                 finalized_hash: status.chain.finalized_hash,
    638                 tip_timestamp_ms,
    639                 sync_start_height: sync_progress.map(|progress| progress.start_height),
    640                 sync_validated_height: sync_progress.map(|progress| progress.validated_height),
    641                 sync_target_height: sync_progress.map(|progress| progress.target_height),
    642                 pending_transactions: mempool.total(),
    643                 last_finalizer_mode: tip.as_ref().map(|block| match block.finalizer_mode {
    644                     crate::domain::FinalizerMode::Ticket => "ticket".to_string(),
    645                     crate::domain::FinalizerMode::Recovery => "recovery".to_string(),
    646                 }),
    647                 last_finalizer_rank: tip.as_ref().map(|block| block.finalizer_rank),
    648                 last_block_finalizer: tip.as_ref().map(|block| block.miner.clone()),
    649                 current_leader: status.mining.current_leader,
    650                 wallet_is_current_leader: status.mining.wallet_is_current_leader,
    651                 last_auto_finalization_status: status.mining.last_auto_finalization_status,
    652                 vdf_rounds: status.mining.vdf_rounds,
    653                 vdf_target_block_ms: status.mining.vdf_target_block_ms,
    654                 rejected_blocks: p2p_metrics.rejected_blocks,
    655                 rejected_block_batches: p2p_metrics.rejected_block_batches,
    656                 rejected_snapshots: p2p_metrics.rejected_snapshots,
    657                 last_chain_payload_error: p2p_metrics.last_chain_payload_error,
    658             },
    659             mempool,
    660         )
    661     };
    662     let peers = state.peers.lock().await.list();
    663     Json(network_health(local, &peers, mempool))
    664 }
    665 
    666 #[cfg(test)]
    667 mod tests {
    668     use super::peer_presentations;
    669     use crate::{app::PeerBook, ip_geolocation::IpGeolocation};
    670 
    671     #[test]
    672     fn presents_multiple_peer_countries_without_guessing_hostnames() {
    673         let peers = PeerBook::from_addresses(vec![
    674             "8.8.8.8:9444".to_string(),
    675             "[2001:4860:4860::8888]:9444".to_string(),
    676             "seed.example:9444".to_string(),
    677         ])
    678         .list();
    679         let geolocation = IpGeolocation::from_entries(&[
    680             ("8.8.8.0".parse().unwrap(), 24, "NL"),
    681             ("2001:4860::".parse().unwrap(), 32, "US"),
    682         ]);
    683 
    684         let presented = peer_presentations(peers, &geolocation);
    685         assert_eq!(presented[0].country_code.unwrap().as_str(), "NL");
    686         assert_eq!(presented[1].country_code.unwrap().as_str(), "US");
    687         assert_eq!(presented[2].country_code, None);
    688 
    689         let json = serde_json::to_value(presented).unwrap();
    690         assert_eq!(json[0]["country_code"], "NL");
    691         assert_eq!(json[1]["country_code"], "US");
    692         assert!(json[2].get("country_code").is_none());
    693     }
    694 }