ui.rs (21468B)
1 use std::collections::BTreeMap; 2 3 use crate::domain::{ 4 BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, MINE_REWARD, OutPoint, 5 RevealedBlindedTransaction, Transaction, TxInput, TxOutput, 6 }; 7 8 use crate::adapters::ui_index::build_ui_chain_index; 9 10 use super::{ 11 HttpState, UiChainView, 12 types::{ 13 UiBlock, UiByteBreakdown, UiRevealBundle, UiTransaction, UiTxInput, 14 WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow, 15 }, 16 }; 17 18 pub(super) fn wallet_transaction_rows( 19 wallet: &str, 20 pending: Vec<Transaction>, 21 owned_blinded: Vec<Transaction>, 22 chain: &[Block], 23 revealed_by_height: &BTreeMap<u64, Vec<RevealedBlindedTransaction>>, 24 outputs: &BTreeMap<OutPoint, TxOutput>, 25 filters: WalletTransactionFilters, 26 ) -> Vec<WalletTransactionRow> { 27 let mut rows = Vec::new(); 28 let pending_context = WalletTransactionContext { 29 status: "pending", 30 block_height: None, 31 timestamp_ms: None, 32 block_finalizer: None, 33 blinded: false, 34 }; 35 36 for (index, tx) in pending.iter().enumerate() { 37 if !filters.allows(tx) { 38 continue; 39 } 40 if let Some(row) = wallet_transaction_row(wallet, tx, outputs, &pending_context) { 41 rows.push((u128::MAX - index as u128, row)); 42 } 43 } 44 45 let pending_blind_context = WalletTransactionContext { 46 blinded: true, 47 ..pending_context 48 }; 49 for (index, tx) in owned_blinded.iter().enumerate() { 50 if !filters.allows(tx) { 51 continue; 52 } 53 if let Some(row) = wallet_transaction_row(wallet, tx, outputs, &pending_blind_context) { 54 rows.push((u128::MAX - 10_000 - index as u128, row)); 55 } 56 } 57 58 for block in chain { 59 for (index, tx) in block.transactions.iter().rev().enumerate() { 60 if !filters.allows(tx) { 61 continue; 62 } 63 if let Some(row) = wallet_transaction_row( 64 wallet, 65 tx, 66 outputs, 67 &WalletTransactionContext { 68 status: "confirmed", 69 block_height: Some(block.height), 70 timestamp_ms: Some(block.timestamp_ms), 71 block_finalizer: Some(block.miner.clone()), 72 blinded: false, 73 }, 74 ) { 75 rows.push((block.height as u128 * 10_000 + index as u128, row)); 76 } 77 } 78 if let Some(revealed_transactions) = revealed_by_height.get(&block.height) { 79 for (index, revealed) in revealed_transactions.iter().rev().enumerate() { 80 let tx = &revealed.transaction; 81 if !filters.allows(tx) { 82 continue; 83 } 84 if let Some(row) = wallet_transaction_row( 85 wallet, 86 tx, 87 outputs, 88 &WalletTransactionContext { 89 status: "confirmed", 90 block_height: Some(block.height), 91 timestamp_ms: Some(block.timestamp_ms), 92 block_finalizer: Some(block.miner.clone()), 93 blinded: false, 94 }, 95 ) { 96 rows.push((block.height as u128 * 10_000 + 5_000 + index as u128, row)); 97 } 98 } 99 } 100 } 101 102 rows.sort_by(|left, right| right.0.cmp(&left.0)); 103 rows.into_iter().map(|(_, row)| row).collect() 104 } 105 106 pub(super) fn wallet_transaction_row( 107 wallet: &str, 108 tx: &Transaction, 109 outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, 110 context: &WalletTransactionContext, 111 ) -> Option<WalletTransactionRow> { 112 match tx { 113 Transaction::Transfer { 114 inputs, 115 outputs, 116 fee, 117 signature, 118 } if tx.sender() == wallet || tx.to() == Some(wallet) => Some(WalletTransactionRow { 119 kind: "transfer", 120 from: tx.sender().to_string(), 121 to: tx.to().map(str::to_string), 122 amount: tx.amount(), 123 fee: *fee, 124 inputs: ui_inputs(inputs, outputs_by_outpoint), 125 outputs: outputs.clone(), 126 change: Vec::new(), 127 signature: signature.clone(), 128 status: context.status, 129 block_height: context.block_height, 130 timestamp_ms: context.timestamp_ms, 131 block_finalizer: context.block_finalizer.clone(), 132 direction: if tx.to() == Some(wallet) { 133 "received" 134 } else { 135 "sent" 136 }, 137 blinded: context.blinded, 138 difficulty_bits: None, 139 proof_bits: None, 140 proof_hash: None, 141 }), 142 Transaction::Burn { 143 inputs, 144 change, 145 amount, 146 fee, 147 signature, 148 } if tx.sender() == wallet => Some(WalletTransactionRow { 149 kind: "burn", 150 from: tx.sender().to_string(), 151 to: None, 152 amount: *amount, 153 fee: *fee, 154 inputs: ui_inputs(inputs, outputs_by_outpoint), 155 outputs: Vec::new(), 156 change: change.clone(), 157 signature: signature.clone(), 158 status: context.status, 159 block_height: context.block_height, 160 timestamp_ms: context.timestamp_ms, 161 block_finalizer: context.block_finalizer.clone(), 162 direction: "burned", 163 blinded: context.blinded, 164 difficulty_bits: None, 165 proof_bits: None, 166 proof_hash: None, 167 }), 168 Transaction::Mine { 169 recipient, 170 difficulty_bits, 171 signature, 172 .. 173 } if recipient == wallet => Some(WalletTransactionRow { 174 kind: "mine", 175 from: "pow".to_string(), 176 to: Some(recipient.clone()), 177 amount: MINE_REWARD, 178 fee: tx.fee(), 179 inputs: Vec::new(), 180 outputs: vec![TxOutput { 181 address: recipient.clone(), 182 amount: MINE_REWARD, 183 }], 184 change: Vec::new(), 185 signature: signature.clone(), 186 status: context.status, 187 block_height: context.block_height, 188 timestamp_ms: context.timestamp_ms, 189 block_finalizer: context.block_finalizer.clone(), 190 direction: "received", 191 blinded: context.blinded, 192 difficulty_bits: Some(*difficulty_bits), 193 proof_bits: Some(proof_bits(signature)), 194 proof_hash: Some(signature.clone()), 195 }), 196 _ => None, 197 } 198 } 199 200 #[cfg(test)] 201 pub(super) fn revealed_transactions_by_height( 202 snapshot: &ChainSnapshot, 203 ) -> BTreeMap<u64, Vec<RevealedBlindedTransaction>> { 204 crate::adapters::ui_index::revealed_transactions_by_height(snapshot) 205 } 206 207 #[cfg(test)] 208 pub(super) fn ui_blocks( 209 blocks: Vec<Block>, 210 snapshot: &ChainSnapshot, 211 pending: &[Transaction], 212 burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, 213 ) -> Vec<UiBlock> { 214 let outputs = known_output_index(snapshot, pending); 215 let revealed = revealed_transactions_by_height(snapshot); 216 ui_blocks_from_indexes(blocks, &outputs, &revealed, burn_leader_ranks) 217 } 218 219 pub(super) fn ui_blocks_from_indexes( 220 blocks: Vec<Block>, 221 outputs: &BTreeMap<OutPoint, TxOutput>, 222 revealed: &BTreeMap<u64, Vec<RevealedBlindedTransaction>>, 223 burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, 224 ) -> Vec<UiBlock> { 225 blocks 226 .into_iter() 227 .map(|block| { 228 let revealed_transactions = revealed.get(&block.height).cloned().unwrap_or_default(); 229 ui_block(block, outputs, burn_leader_ranks, &revealed_transactions) 230 }) 231 .collect() 232 } 233 234 pub(super) fn ui_block( 235 block: Block, 236 outputs: &BTreeMap<OutPoint, TxOutput>, 237 burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, 238 revealed_transactions: &[RevealedBlindedTransaction], 239 ) -> UiBlock { 240 let ranks = burn_leader_ranks 241 .get(&block.hash) 242 .cloned() 243 .unwrap_or_default(); 244 let revealed_fees = revealed_transactions.iter().fold(0_u64, |total, revealed| { 245 total.saturating_add(revealed.transaction.fee()) 246 }); 247 let transaction_bytes = block 248 .transactions 249 .iter() 250 .map(|tx| tx.serialized_size_bytes().unwrap_or_default()) 251 .sum::<usize>(); 252 let transaction_byte_breakdown = transaction_byte_breakdown(&block.transactions); 253 let blinded_transaction_bytes = block 254 .blinded_transactions 255 .iter() 256 .map(|tx| tx.serialized_size_bytes().unwrap_or_default()) 257 .sum::<usize>(); 258 let mut transactions = block 259 .transactions 260 .iter() 261 .map(|tx| ui_transaction(tx, outputs)) 262 .collect::<Vec<_>>(); 263 transactions.extend( 264 block 265 .blinded_transactions 266 .iter() 267 .map(|transaction| ui_blinded_transaction(transaction, outputs)), 268 ); 269 transactions.extend( 270 revealed_transactions 271 .iter() 272 .map(|revealed| ui_revealed_transaction(&revealed.transaction, outputs)), 273 ); 274 let revealed_by_commitment = revealed_transactions 275 .iter() 276 .map(|revealed| (revealed.commitment.clone(), revealed.transaction.clone())) 277 .collect::<BTreeMap<_, _>>(); 278 let reveal_bundles: Vec<UiRevealBundle> = block 279 .reveal_bundle_section 280 .expand(block.height, &block.prev_hash) 281 .into_iter() 282 .map(|bundle| UiRevealBundle { 283 slot: bundle.slot, 284 member: bundle.member.clone(), 285 hash: bundle.bundle_hash(), 286 byte_size: bundle.serialized_size_bytes().unwrap_or_default(), 287 reveals: bundle 288 .reveals 289 .iter() 290 .map(|reveal| { 291 revealed_by_commitment 292 .get(&reveal.commitment) 293 .map(|tx| ui_revealed_transaction(tx, outputs)) 294 .unwrap_or_else(|| ui_blinded_reveal(reveal)) 295 }) 296 .collect(), 297 }) 298 .collect(); 299 let reveal_bundle_bytes = reveal_bundles 300 .iter() 301 .map(|bundle: &UiRevealBundle| bundle.byte_size) 302 .sum::<usize>(); 303 let total_bytes = block.serialized_size_bytes().unwrap_or_else(|_| { 304 transaction_bytes 305 .saturating_add(blinded_transaction_bytes) 306 .saturating_add(reveal_bundle_bytes) 307 }); 308 UiBlock { 309 height: block.height, 310 prev_hash: block.prev_hash, 311 timestamp_ms: block.timestamp_ms, 312 miner: block.miner, 313 finalizer_mode: block.finalizer_mode, 314 finalizer_rank: block.finalizer_rank, 315 reward: block.reward, 316 total_fees: block.reward.saturating_add(revealed_fees), 317 total_bytes, 318 transaction_bytes, 319 transaction_byte_breakdown, 320 blinded_transaction_bytes, 321 reveal_bundle_bytes, 322 vdf_rounds: block.vdf_rounds, 323 vdf_output: block.vdf_output, 324 leader_proof: block.leader_proof, 325 burn_leader_ranks: ranks, 326 transactions, 327 revealed_transactions: revealed_transactions 328 .iter() 329 .map(|revealed| ui_revealed_transaction(&revealed.transaction, outputs)) 330 .collect(), 331 reveal_bundles, 332 hash: block.hash, 333 } 334 } 335 336 fn ui_revealed_transaction( 337 transaction: &Transaction, 338 outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, 339 ) -> UiTransaction { 340 let mut row = ui_transaction(transaction, outputs_by_outpoint); 341 row.revealed = true; 342 row 343 } 344 345 fn transaction_byte_breakdown(transactions: &[Transaction]) -> Vec<UiByteBreakdown> { 346 let mut transfer_bytes = 0_usize; 347 let mut burn_bytes = 0_usize; 348 let mut mine_bytes = 0_usize; 349 for transaction in transactions { 350 let bytes = transaction.serialized_size_bytes().unwrap_or_default(); 351 match transaction { 352 Transaction::Transfer { .. } => transfer_bytes = transfer_bytes.saturating_add(bytes), 353 Transaction::Burn { .. } => burn_bytes = burn_bytes.saturating_add(bytes), 354 Transaction::Mine { .. } => mine_bytes = mine_bytes.saturating_add(bytes), 355 } 356 } 357 [ 358 ("transfer", transfer_bytes), 359 ("burn", burn_bytes), 360 ("mine", mine_bytes), 361 ] 362 .into_iter() 363 .filter_map(|(label, bytes)| (bytes > 0).then_some(UiByteBreakdown { label, bytes })) 364 .collect() 365 } 366 367 pub(super) fn ui_pending_revealed_transaction( 368 revealed: &RevealedBlindedTransaction, 369 outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, 370 ) -> UiTransaction { 371 let mut row = ui_revealed_transaction(&revealed.transaction, outputs_by_outpoint); 372 row.commitment = Some(revealed.commitment.clone()); 373 row 374 } 375 376 pub(super) fn ui_transaction( 377 transaction: &Transaction, 378 outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, 379 ) -> UiTransaction { 380 match transaction { 381 Transaction::Transfer { 382 inputs, 383 outputs, 384 fee, 385 signature, 386 } => UiTransaction { 387 kind: "transfer", 388 from: transaction.sender().to_string(), 389 to: transaction.to().map(str::to_string), 390 amount: transaction.amount(), 391 fee: *fee, 392 inputs: ui_inputs(inputs, outputs_by_outpoint), 393 outputs: outputs.clone(), 394 change: Vec::new(), 395 signature: signature.clone(), 396 difficulty_bits: None, 397 proof_bits: None, 398 proof_hash: None, 399 commitment: None, 400 encrypted_size: None, 401 expires_at_height: None, 402 revealed: false, 403 }, 404 Transaction::Burn { 405 inputs, 406 change, 407 amount, 408 fee, 409 signature, 410 } => UiTransaction { 411 kind: "burn", 412 from: transaction.sender().to_string(), 413 to: None, 414 amount: *amount, 415 fee: *fee, 416 inputs: ui_inputs(inputs, outputs_by_outpoint), 417 outputs: Vec::new(), 418 change: change.clone(), 419 signature: signature.clone(), 420 difficulty_bits: None, 421 proof_bits: None, 422 proof_hash: None, 423 commitment: None, 424 encrypted_size: None, 425 expires_at_height: None, 426 revealed: false, 427 }, 428 Transaction::Mine { 429 recipient, 430 difficulty_bits, 431 signature, 432 .. 433 } => UiTransaction { 434 kind: "mine", 435 from: "pow".to_string(), 436 to: Some(recipient.clone()), 437 amount: MINE_REWARD, 438 fee: transaction.fee(), 439 inputs: Vec::new(), 440 outputs: vec![TxOutput { 441 address: recipient.clone(), 442 amount: MINE_REWARD, 443 }], 444 change: Vec::new(), 445 signature: signature.clone(), 446 difficulty_bits: Some(*difficulty_bits), 447 proof_bits: Some(proof_bits(signature)), 448 proof_hash: Some(signature.clone()), 449 commitment: None, 450 encrypted_size: None, 451 expires_at_height: None, 452 revealed: false, 453 }, 454 } 455 } 456 457 pub(super) fn ui_blinded_transaction( 458 transaction: &BlindedTransaction, 459 outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, 460 ) -> UiTransaction { 461 UiTransaction { 462 kind: "blinded", 463 from: transaction 464 .inputs 465 .first() 466 .map(|input| input.owner.clone()) 467 .unwrap_or_else(|| "encrypted".to_string()), 468 to: None, 469 amount: 0, 470 fee: transaction.fee, 471 inputs: ui_inputs(&transaction.inputs, outputs_by_outpoint), 472 outputs: Vec::new(), 473 change: Vec::new(), 474 signature: transaction.commitment.clone(), 475 difficulty_bits: None, 476 proof_bits: None, 477 proof_hash: None, 478 commitment: Some(transaction.commitment.clone()), 479 encrypted_size: Some(transaction.encrypted_size), 480 expires_at_height: Some(transaction.expires_at_height), 481 revealed: false, 482 } 483 } 484 485 pub(super) fn ui_blinded_reveal(reveal: &BlindedReveal) -> UiTransaction { 486 UiTransaction { 487 kind: "reveal", 488 from: "encrypted".to_string(), 489 to: None, 490 amount: 0, 491 fee: 0, 492 inputs: Vec::new(), 493 outputs: Vec::new(), 494 change: Vec::new(), 495 signature: reveal.commitment.clone(), 496 difficulty_bits: None, 497 proof_bits: None, 498 proof_hash: None, 499 commitment: Some(reveal.commitment.clone()), 500 encrypted_size: None, 501 expires_at_height: None, 502 revealed: false, 503 } 504 } 505 506 fn ui_inputs( 507 inputs: &[TxInput], 508 outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, 509 ) -> Vec<UiTxInput> { 510 inputs 511 .iter() 512 .map(|input| { 513 let spent_output = outputs_by_outpoint.get(&input.outpoint); 514 UiTxInput { 515 outpoint: input.outpoint.clone(), 516 owner: input.owner.clone(), 517 signature: input.signature.clone(), 518 amount: spent_output.map(|output| output.amount), 519 address: spent_output.map(|output| output.address.clone()), 520 } 521 }) 522 .collect() 523 } 524 525 fn proof_bits(hex_hash: &str) -> u32 { 526 let mut bits = 0_u32; 527 for byte in hex_hash.as_bytes() { 528 let Some(nibble) = hex_nibble(*byte) else { 529 break; 530 }; 531 if nibble == 0 { 532 bits += 4; 533 continue; 534 } 535 bits += nibble.leading_zeros() - 4; 536 break; 537 } 538 bits 539 } 540 541 fn hex_nibble(byte: u8) -> Option<u8> { 542 match byte { 543 b'0'..=b'9' => Some(byte - b'0'), 544 b'a'..=b'f' => Some(byte - b'a' + 10), 545 b'A'..=b'F' => Some(byte - b'A' + 10), 546 _ => None, 547 } 548 } 549 550 #[cfg(test)] 551 pub(super) fn known_output_index( 552 snapshot: &ChainSnapshot, 553 pending: &[Transaction], 554 ) -> BTreeMap<OutPoint, TxOutput> { 555 let mut outputs = build_ui_chain_index(snapshot).outputs; 556 add_pending_outputs(&mut outputs, pending); 557 outputs 558 } 559 560 pub(super) async fn cached_chain_view( 561 state: &HttpState, 562 snapshot: &ChainSnapshot, 563 ) -> anyhow::Result<UiChainView> { 564 let tip_hash = snapshot.blocks.last().map(|block| block.hash.clone()); 565 { 566 let cache = state.ui_cache.lock().await; 567 if cache.tip_hash == tip_hash { 568 return Ok(ui_chain_view_from_cache(&cache)); 569 } 570 } 571 572 let (computed_tip_hash, view) = tokio::task::spawn_blocking({ 573 let snapshot = snapshot.clone(); 574 move || build_chain_view(&snapshot) 575 }) 576 .await?; 577 578 let mut cache = state.ui_cache.lock().await; 579 if cache.tip_hash == tip_hash { 580 return Ok(ui_chain_view_from_cache(&cache)); 581 } 582 583 cache.tip_hash = computed_tip_hash; 584 cache.outputs = view.outputs.clone(); 585 cache.revealed_by_height = view.revealed_by_height.clone(); 586 cache.burn_leader_ranks_by_hash = view.burn_leader_ranks_by_hash.clone(); 587 Ok(UiChainView { 588 outputs: view.outputs, 589 revealed_by_height: view.revealed_by_height, 590 burn_leader_ranks_by_hash: view.burn_leader_ranks_by_hash, 591 }) 592 } 593 594 pub(super) async fn cached_ui_blocks_for_tip( 595 state: &HttpState, 596 tip_hash: Option<&str>, 597 blocks: Vec<Block>, 598 ) -> Option<Vec<UiBlock>> { 599 let cache = state.ui_cache.lock().await; 600 (cache.tip_hash.as_deref() == tip_hash).then(|| { 601 ui_blocks_from_indexes( 602 blocks, 603 &cache.outputs, 604 &cache.revealed_by_height, 605 &cache.burn_leader_ranks_by_hash, 606 ) 607 }) 608 } 609 610 fn ui_chain_view_from_cache(cache: &super::UiChainCache) -> UiChainView { 611 UiChainView { 612 outputs: cache.outputs.clone(), 613 revealed_by_height: cache.revealed_by_height.clone(), 614 burn_leader_ranks_by_hash: cache.burn_leader_ranks_by_hash.clone(), 615 } 616 } 617 618 fn build_chain_view(snapshot: &ChainSnapshot) -> (Option<String>, UiChainView) { 619 let index = build_ui_chain_index(snapshot); 620 ( 621 index.tip_hash.clone(), 622 UiChainView { 623 outputs: index.outputs, 624 revealed_by_height: index.revealed_by_height, 625 burn_leader_ranks_by_hash: index.burn_leader_ranks_by_hash, 626 }, 627 ) 628 } 629 630 pub(super) fn add_pending_outputs( 631 outputs: &mut BTreeMap<OutPoint, TxOutput>, 632 pending: &[Transaction], 633 ) { 634 for transaction in pending { 635 index_transaction_outputs(outputs, transaction); 636 } 637 } 638 639 fn index_transaction_outputs( 640 outputs: &mut BTreeMap<OutPoint, TxOutput>, 641 transaction: &Transaction, 642 ) { 643 let created_outputs = match transaction { 644 Transaction::Transfer { outputs, .. } => outputs.clone(), 645 Transaction::Burn { change, .. } => change.clone(), 646 Transaction::Mine { recipient, .. } => vec![TxOutput { 647 address: recipient.clone(), 648 amount: MINE_REWARD, 649 }], 650 }; 651 for (index, output) in created_outputs.iter().enumerate() { 652 outputs.insert( 653 OutPoint { 654 txid: transaction.signature().to_string(), 655 index: index as u32, 656 }, 657 output.clone(), 658 ); 659 } 660 }