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