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 }