iuna

iuna

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

api.rs (19086B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 
      3 use anyhow::{Context, Result};
      4 use axum::{
      5     Json,
      6     extract::{Query, State},
      7 };
      8 
      9 #[cfg(test)]
     10 use crate::domain::Ledger;
     11 use crate::{
     12     adapters::p2p::P2pMetrics,
     13     app::{NodeStatus, PeerInfo},
     14     domain::{BlindedTransaction, OutPoint, Transaction, TxOutput},
     15 };
     16 
     17 use super::types::{LeaderboardEntry, MetricsLeaderboards};
     18 use super::{
     19     BlocksQuery, ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse,
     20     NetworkHealthLocalState, NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction,
     21     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, cached_chain_view, cached_ui_blocks_for_tip, metrics_response,
     27     network_health, ui_blinded_reveal, ui_blinded_transaction, ui_blocks_from_indexes,
     28     ui_pending_revealed_transaction, ui_transaction, wallet_transaction_row,
     29     wallet_transaction_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) = {
     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         (node.chain_tip_hash(), blocks)
     53     };
     54     if let Some(blocks) = cached_ui_blocks_for_tip(&state, Some(tip_hash.as_str()), blocks).await {
     55         return Json(blocks);
     56     }
     57 
     58     let (snapshot, blocks) = {
     59         let node = state.node.lock().await;
     60         let snapshot = node.chain_snapshot();
     61         let blocks = match query.before_height {
     62             Some(before_height) => node.blocks_before(before_height, limit),
     63             None => node.recent_blocks(limit),
     64         };
     65         (snapshot, blocks)
     66     };
     67     let view = cached_chain_view(&state, &snapshot)
     68         .await
     69         .unwrap_or_default();
     70     Json(ui_blocks_from_indexes(
     71         blocks,
     72         &view.outputs,
     73         &view.revealed_by_height,
     74         &view.burn_leader_ranks_by_hash,
     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     })
     84 }
     85 
     86 pub(super) async fn api_mempool(
     87     State(state): State<HttpState>,
     88     Query(query): Query<PageQuery>,
     89 ) -> Json<Page<UiTransaction>> {
     90     let ui_data_ready = ensure_ui_data_current(&state).await.is_ok();
     91     let (pending, pending_blinded, pending_reveals, pending_revealed) = {
     92         let node = state.node.lock().await;
     93         let pending = node.pending_transactions();
     94         let pending_blinded = node.pending_blinded_transactions();
     95         let pending_reveals = node.pending_blinded_reveals();
     96         let pending_revealed = node
     97             .pending_revealed_blinded_transactions()
     98             .into_iter()
     99             .map(|revealed| (revealed.commitment.clone(), revealed))
    100             .collect::<BTreeMap<_, _>>();
    101         (pending, pending_blinded, pending_reveals, pending_revealed)
    102     };
    103     let mut required_outputs = BTreeSet::new();
    104     collect_transaction_input_outpoints(pending.iter(), &mut required_outputs);
    105     collect_blinded_input_outpoints(pending_blinded.iter(), &mut required_outputs);
    106     collect_transaction_input_outpoints(
    107         pending_revealed
    108             .values()
    109             .map(|revealed| &revealed.transaction),
    110         &mut required_outputs,
    111     );
    112     let mut outputs = if ui_data_ready {
    113         load_outputs_for_outpoints(&state, required_outputs)
    114             .await
    115             .unwrap_or_default()
    116     } else {
    117         BTreeMap::new()
    118     };
    119     add_pending_outputs(&mut outputs, &pending);
    120     let mut items = pending
    121         .iter()
    122         .map(|tx| ui_transaction(tx, &outputs))
    123         .collect::<Vec<_>>();
    124     items.extend(
    125         pending_blinded
    126             .iter()
    127             .map(|transaction| ui_blinded_transaction(transaction, &outputs)),
    128     );
    129     items.extend(pending_reveals.iter().map(|reveal| {
    130         pending_revealed
    131             .get(&reveal.commitment)
    132             .map(|revealed| ui_pending_revealed_transaction(revealed, &outputs))
    133             .unwrap_or_else(|| ui_blinded_reveal(reveal))
    134     }));
    135     items.reverse();
    136     Json(page_items(items, query))
    137 }
    138 
    139 pub(super) async fn api_wallet_transactions(
    140     State(state): State<HttpState>,
    141     Query(query): Query<WalletTransactionsQuery>,
    142 ) -> Json<Page<WalletTransactionRow>> {
    143     let page_query = query.page();
    144     let offset = page_query.offset.unwrap_or(0);
    145     let limit = page_query
    146         .limit
    147         .unwrap_or(DATASET_PAGE_LIMIT)
    148         .clamp(1, DATASET_LIMIT);
    149     let filters = WalletTransactionFilters::from_query(query);
    150     if ensure_ui_data_current(&state).await.is_err() {
    151         return Json(Page {
    152             items: Vec::new(),
    153             offset,
    154             limit,
    155             total: 0,
    156             has_more: false,
    157             next_offset: None,
    158         });
    159     }
    160     let (wallet, pending, owned_blinded) = {
    161         let node = state.node.lock().await;
    162         (
    163             node.wallet_address().to_string(),
    164             node.pending_transactions(),
    165             node.owned_blinded_payloads(),
    166         )
    167     };
    168     let mut pending_required_outputs = BTreeSet::new();
    169     collect_transaction_input_outpoints(pending.iter(), &mut pending_required_outputs);
    170     collect_transaction_input_outpoints(owned_blinded.iter(), &mut pending_required_outputs);
    171     let mut pending_outputs = load_outputs_for_outpoints(&state, pending_required_outputs)
    172         .await
    173         .unwrap_or_default();
    174     add_pending_outputs(&mut pending_outputs, &pending);
    175     let pending_rows = wallet_transaction_rows(
    176         &wallet,
    177         pending.clone(),
    178         owned_blinded.clone(),
    179         &[],
    180         &BTreeMap::new(),
    181         &pending_outputs,
    182         filters,
    183     );
    184     let pending_total = pending_rows.len();
    185     let mut items = pending_rows
    186         .into_iter()
    187         .skip(offset.min(pending_total))
    188         .take(limit)
    189         .collect::<Vec<_>>();
    190 
    191     let confirmed_offset = offset.saturating_sub(pending_total);
    192     let remaining_limit = limit.saturating_sub(items.len());
    193     let kinds = wallet_transaction_filter_kinds(filters);
    194     let store = state.ui_data_store.clone();
    195     let wallet_for_query = wallet.clone();
    196     let (confirmed_rows, confirmed_total) = if remaining_limit == 0 {
    197         (Vec::new(), 0)
    198     } else {
    199         tokio::task::spawn_blocking(move || {
    200             store.load_wallet_transactions(
    201                 &wallet_for_query,
    202                 &kinds,
    203                 confirmed_offset,
    204                 remaining_limit,
    205             )
    206         })
    207         .await
    208         .ok()
    209         .and_then(Result::ok)
    210         .unwrap_or_default()
    211     };
    212     let mut confirmed_required_outputs = BTreeSet::new();
    213     collect_transaction_input_outpoints(
    214         confirmed_rows.iter().map(|row| &row.transaction),
    215         &mut confirmed_required_outputs,
    216     );
    217     let confirmed_outputs = load_outputs_for_outpoints(&state, confirmed_required_outputs)
    218         .await
    219         .unwrap_or_default();
    220     items.extend(confirmed_rows.into_iter().filter_map(|row| {
    221         wallet_transaction_row(
    222             &wallet,
    223             &row.transaction,
    224             &confirmed_outputs,
    225             &WalletTransactionContext {
    226                 status: "confirmed",
    227                 block_height: Some(row.block_height),
    228                 timestamp_ms: Some(row.timestamp_ms),
    229                 block_finalizer: Some(row.block_finalizer),
    230                 blinded: row.blinded,
    231             },
    232         )
    233     }));
    234     let total = pending_total + confirmed_total;
    235     let next_offset = offset + items.len();
    236     Json(Page {
    237         items,
    238         offset: offset.min(total),
    239         limit,
    240         total,
    241         has_more: next_offset < total,
    242         next_offset: (next_offset < total).then_some(next_offset),
    243     })
    244 }
    245 
    246 async fn load_outputs_for_outpoints(
    247     state: &HttpState,
    248     outpoints: BTreeSet<OutPoint>,
    249 ) -> Result<BTreeMap<OutPoint, TxOutput>> {
    250     if outpoints.is_empty() {
    251         return Ok(BTreeMap::new());
    252     }
    253     let store = state.ui_data_store.clone();
    254     tokio::task::spawn_blocking(move || store.load_outputs(&outpoints))
    255         .await
    256         .unwrap_or_else(|_| Ok(BTreeMap::new()))
    257 }
    258 
    259 async fn ensure_ui_data_current(state: &HttpState) -> Result<()> {
    260     let Some(tip_hash) = current_real_chain_tip(state).await else {
    261         return Ok(());
    262     };
    263     if ui_data_matches_tip(state, tip_hash).await? {
    264         return Ok(());
    265     }
    266 
    267     let _refresh_guard = state.ui_data_refresh.lock().await;
    268     let Some(tip_hash) = current_real_chain_tip(state).await else {
    269         return Ok(());
    270     };
    271     if ui_data_matches_tip(state, tip_hash).await? {
    272         return Ok(());
    273     }
    274 
    275     let (snapshot, tip_hash) = {
    276         let node = state.node.lock().await;
    277         if !node.has_real_chain() {
    278             return Ok(());
    279         }
    280         (node.chain_snapshot(), node.chain_tip_hash())
    281     };
    282     let keep_metrics = state.ui_config.lock().await.keep_track_of_metrics;
    283     let chain_store = state.chain_store.clone();
    284     let ui_data_store = state.ui_data_store.clone();
    285     tokio::task::spawn_blocking(move || {
    286         chain_store
    287             .save(&snapshot)
    288             .context("failed to persist chain before UI data catch-up")?;
    289         ui_data_store
    290             .project_snapshot(&snapshot, keep_metrics)
    291             .context("failed to project UI data catch-up")?;
    292         Ok::<(), anyhow::Error>(())
    293     })
    294     .await
    295     .context("UI data catch-up worker failed")??;
    296 
    297     ui_data_matches_tip(state, tip_hash)
    298         .await?
    299         .then_some(())
    300         .context("UI data catch-up completed but projection tip does not match the chain tip")
    301 }
    302 
    303 async fn current_real_chain_tip(state: &HttpState) -> Option<String> {
    304     let node = state.node.lock().await;
    305     node.has_real_chain().then(|| node.chain_tip_hash())
    306 }
    307 
    308 async fn ui_data_matches_tip(state: &HttpState, tip_hash: String) -> Result<bool> {
    309     let store = state.ui_data_store.clone();
    310     tokio::task::spawn_blocking(move || store.is_projected_to(&tip_hash))
    311         .await
    312         .context("UI data projection metadata worker failed")?
    313 }
    314 
    315 fn collect_transaction_input_outpoints<'a>(
    316     transactions: impl IntoIterator<Item = &'a Transaction>,
    317     outpoints: &mut BTreeSet<OutPoint>,
    318 ) {
    319     for transaction in transactions {
    320         match transaction {
    321             Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => {
    322                 outpoints.extend(inputs.iter().map(|input| input.outpoint.clone()));
    323             }
    324             Transaction::Mine { .. } => {}
    325         }
    326     }
    327 }
    328 
    329 fn collect_blinded_input_outpoints<'a>(
    330     transactions: impl IntoIterator<Item = &'a BlindedTransaction>,
    331     outpoints: &mut BTreeSet<OutPoint>,
    332 ) {
    333     for transaction in transactions {
    334         outpoints.extend(
    335             transaction
    336                 .inputs
    337                 .iter()
    338                 .map(|input| input.outpoint.clone()),
    339         );
    340     }
    341 }
    342 
    343 pub(super) async fn api_wallet_utxos(
    344     State(state): State<HttpState>,
    345     Query(query): Query<PageQuery>,
    346 ) -> Json<Page<WalletUtxoRow>> {
    347     if ensure_ui_data_current(&state).await.is_err() {
    348         return Json(page_items(Vec::new(), query));
    349     }
    350     let (wallet, pending_spent) = {
    351         let node = state.node.lock().await;
    352         (
    353             node.wallet_address().to_string(),
    354             node.wallet_pending_spent_outpoints(),
    355         )
    356     };
    357     let store = state.ui_data_store.clone();
    358     let utxos = tokio::task::spawn_blocking(move || store.load_wallet_utxos(&wallet))
    359         .await
    360         .ok()
    361         .and_then(Result::ok)
    362         .unwrap_or_default();
    363     Json(page_items(
    364         wallet_utxo_rows_from_ui_data(utxos, &pending_spent),
    365         query,
    366     ))
    367 }
    368 
    369 pub(super) async fn api_wallet_selectable_utxos(
    370     State(state): State<HttpState>,
    371 ) -> Json<Vec<WalletUtxoRow>> {
    372     if ensure_ui_data_current(&state).await.is_err() {
    373         return Json(Vec::new());
    374     }
    375     let (wallet, pending_spent) = {
    376         let node = state.node.lock().await;
    377         (
    378             node.wallet_address().to_string(),
    379             node.wallet_pending_spent_outpoints(),
    380         )
    381     };
    382     let store = state.ui_data_store.clone();
    383     let utxos = tokio::task::spawn_blocking(move || store.load_wallet_utxos(&wallet))
    384         .await
    385         .ok()
    386         .and_then(Result::ok)
    387         .unwrap_or_default();
    388     Json(
    389         wallet_utxo_rows_from_ui_data(utxos, &pending_spent)
    390             .into_iter()
    391             .filter(|utxo| utxo.spendable)
    392             .collect(),
    393     )
    394 }
    395 
    396 pub(super) fn page_items<T>(items: Vec<T>, query: PageQuery) -> Page<T> {
    397     let total = items.len();
    398     let offset = query.offset.unwrap_or(0).min(total);
    399     let limit = query
    400         .limit
    401         .unwrap_or(DATASET_PAGE_LIMIT)
    402         .clamp(1, DATASET_LIMIT);
    403     let page_items = items
    404         .into_iter()
    405         .skip(offset)
    406         .take(limit)
    407         .collect::<Vec<_>>();
    408     let next_offset = offset + page_items.len();
    409     Page {
    410         items: page_items,
    411         offset,
    412         limit,
    413         total,
    414         has_more: next_offset < total,
    415         next_offset: (next_offset < total).then_some(next_offset),
    416     }
    417 }
    418 
    419 fn wallet_transaction_filter_kinds(filters: WalletTransactionFilters) -> Vec<&'static str> {
    420     let mut kinds = Vec::new();
    421     if filters.transfer {
    422         kinds.push("transfer");
    423     }
    424     if filters.mine {
    425         kinds.push("mine");
    426     }
    427     if filters.burn {
    428         kinds.push("burn");
    429     }
    430     kinds
    431 }
    432 
    433 #[cfg(test)]
    434 pub(super) fn wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> {
    435     let spendable_outpoints = ledger
    436         .available_utxos_for_address(wallet)
    437         .unwrap_or_default()
    438         .into_iter()
    439         .map(|(outpoint, _)| outpoint)
    440         .collect::<BTreeSet<_>>();
    441     let mut utxos = ledger
    442         .utxos_for_address(wallet)
    443         .into_iter()
    444         .map(|(outpoint, output)| {
    445             let spendable = spendable_outpoints.contains(&outpoint);
    446             WalletUtxoRow {
    447                 outpoint,
    448                 address: output.address,
    449                 amount: output.amount,
    450                 spendable,
    451             }
    452         })
    453         .collect::<Vec<_>>();
    454     utxos.sort_by(|left, right| {
    455         right
    456             .amount
    457             .cmp(&left.amount)
    458             .then_with(|| left.outpoint.txid.cmp(&right.outpoint.txid))
    459             .then_with(|| left.outpoint.index.cmp(&right.outpoint.index))
    460     });
    461     utxos
    462 }
    463 
    464 fn wallet_utxo_rows_from_ui_data(
    465     utxos: Vec<(crate::domain::OutPoint, crate::domain::TxOutput)>,
    466     pending_spent: &BTreeSet<crate::domain::OutPoint>,
    467 ) -> Vec<WalletUtxoRow> {
    468     utxos
    469         .into_iter()
    470         .map(|(outpoint, output)| {
    471             let spendable = !pending_spent.contains(&outpoint);
    472             WalletUtxoRow {
    473                 outpoint,
    474                 address: output.address,
    475                 amount: output.amount,
    476                 spendable,
    477             }
    478         })
    479         .collect()
    480 }
    481 
    482 #[cfg(test)]
    483 pub(super) fn selectable_wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> {
    484     wallet_utxo_rows(ledger, wallet)
    485         .into_iter()
    486         .filter(|utxo| utxo.spendable)
    487         .collect()
    488 }
    489 
    490 pub(super) async fn api_peers(
    491     State(state): State<HttpState>,
    492     Query(query): Query<PageQuery>,
    493 ) -> Json<Page<PeerInfo>> {
    494     Json(page_items(state.peers.lock().await.list(), query))
    495 }
    496 
    497 pub(super) async fn api_p2p_metrics(State(state): State<HttpState>) -> Json<P2pMetrics> {
    498     Json(state.gossip.metrics())
    499 }
    500 
    501 pub(super) async fn api_metrics(
    502     State(state): State<HttpState>,
    503     Query(query): Query<MetricsQuery>,
    504 ) -> Json<MetricsResponse> {
    505     let enabled = state.ui_config.lock().await.keep_track_of_metrics;
    506     if !enabled {
    507         return Json(empty_metrics_response(enabled));
    508     }
    509     if ensure_ui_data_current(&state).await.is_err() {
    510         return Json(empty_metrics_response(enabled));
    511     }
    512     let store = state.ui_data_store.clone();
    513     let rows = tokio::task::spawn_blocking(move || match query.limit {
    514         Some(limit) => store.load_recent_metrics(limit.clamp(1, DATASET_LIMIT)),
    515         None => store.load_metrics(),
    516     })
    517     .await
    518     .ok()
    519     .and_then(Result::ok)
    520     .unwrap_or_default();
    521     let store = state.ui_data_store.clone();
    522     let leaderboards = tokio::task::spawn_blocking(move || store.load_leaderboards(10))
    523         .await
    524         .ok()
    525         .and_then(Result::ok)
    526         .map(metrics_leaderboards)
    527         .unwrap_or_default();
    528     Json(metrics_response(enabled, rows, leaderboards))
    529 }
    530 
    531 fn empty_metrics_response(enabled: bool) -> MetricsResponse {
    532     MetricsResponse {
    533         enabled,
    534         latest: None,
    535         charts: Vec::new(),
    536         leaderboards: MetricsLeaderboards::default(),
    537     }
    538 }
    539 
    540 fn metrics_leaderboards(
    541     leaderboards: crate::adapters::ui_data_store::UiLeaderboards,
    542 ) -> MetricsLeaderboards {
    543     MetricsLeaderboards {
    544         balances: leaderboards
    545             .balances
    546             .into_iter()
    547             .map(metrics_leaderboard_entry)
    548             .collect(),
    549         miners: leaderboards
    550             .miners
    551             .into_iter()
    552             .map(metrics_leaderboard_entry)
    553             .collect(),
    554         burners: leaderboards
    555             .burners
    556             .into_iter()
    557             .map(metrics_leaderboard_entry)
    558             .collect(),
    559     }
    560 }
    561 
    562 fn metrics_leaderboard_entry(
    563     entry: crate::adapters::ui_data_store::UiLeaderboardEntry,
    564 ) -> LeaderboardEntry {
    565     LeaderboardEntry {
    566         address: entry.address,
    567         amount: entry.amount,
    568         count: entry.count,
    569     }
    570 }
    571 
    572 pub(super) async fn api_network_health(
    573     State(state): State<HttpState>,
    574 ) -> Json<NetworkHealthResponse> {
    575     let (local, mempool) = {
    576         let node = state.node.lock().await;
    577         let mempool = MempoolCounts {
    578             plain_transactions: node.pending_transactions().len(),
    579             blinded_transactions: node.pending_blinded_transactions().len(),
    580             blinded_reveals: node.pending_blinded_reveals().len(),
    581         };
    582         (
    583             NetworkHealthLocalState {
    584                 height: node.chain_height(),
    585                 pending_transactions: mempool.total(),
    586             },
    587             mempool,
    588         )
    589     };
    590     let peers = state.peers.lock().await.list();
    591     Json(network_health(local, &peers, mempool))
    592 }