ui.rs (55811B)
1 use std::collections::BTreeMap; 2 3 use anyhow::Result; 4 5 use crate::compact::CompactBlockSizeBreakdown; 6 use crate::domain::{ 7 AddressNetwork, Amount, Block, BurnLeaderRank, FinalizerMode, LegacyTransactionId, MINE_REWARD, 8 OutPoint, Transaction, TransactionV2, TransactionV2Domain, TxInput, TxOutput, decode_hex, 9 encode_versioned_address, hex_encode, 10 }; 11 12 use super::types::{ 13 UiBlock, UiBurnBundle, UiBurnBundleQuorum, UiByteBreakdown, UiRewardFeeInput, UiRewardOutput, 14 UiTransaction, UiTxInput, WalletTransactionContext, WalletTransactionFilters, 15 WalletTransactionRow, 16 }; 17 18 pub(crate) fn wallet_transaction_rows( 19 wallet_addresses: &[String], 20 pending: Vec<Transaction>, 21 chain: &[Block], 22 outputs: &BTreeMap<OutPoint, TxOutput>, 23 filters: WalletTransactionFilters, 24 ) -> Vec<WalletTransactionRow> { 25 let mut rows = Vec::new(); 26 let pending_context = WalletTransactionContext { 27 status: "pending", 28 block_height: None, 29 timestamp_ms: None, 30 block_finalizer: None, 31 }; 32 33 for (index, tx) in pending.iter().enumerate() { 34 if !filters.allows(tx) { 35 continue; 36 } 37 if let Some(row) = 38 wallet_transaction_row_for_addresses(wallet_addresses, tx, outputs, &pending_context) 39 { 40 rows.push((u128::MAX - index as u128, row)); 41 } 42 } 43 44 for block in chain { 45 for (index, tx) in block.transactions.iter().rev().enumerate() { 46 if !filters.allows(tx) { 47 continue; 48 } 49 if let Some(row) = wallet_transaction_row_for_addresses( 50 wallet_addresses, 51 tx, 52 outputs, 53 &WalletTransactionContext { 54 status: "confirmed", 55 block_height: Some(block.height), 56 timestamp_ms: Some(block.timestamp_ms), 57 block_finalizer: Some(block.miner.clone()), 58 }, 59 ) { 60 rows.push((block.height as u128 * 10_000 + index as u128, row)); 61 } 62 } 63 } 64 65 rows.sort_by(|left, right| right.0.cmp(&left.0)); 66 rows.into_iter().map(|(_, row)| row).collect() 67 } 68 69 pub(crate) fn wallet_transaction_v2_rows( 70 wallet_addresses: &[String], 71 pending: &[TransactionV2], 72 outputs: &BTreeMap<OutPoint, TxOutput>, 73 filters: WalletTransactionFilters, 74 domain: &TransactionV2Domain, 75 network: AddressNetwork, 76 ) -> Vec<WalletTransactionRow> { 77 let context = WalletTransactionContext { 78 status: "pending", 79 block_height: None, 80 timestamp_ms: None, 81 block_finalizer: None, 82 }; 83 pending 84 .iter() 85 .rev() 86 .filter(|transaction| filters.allows_v2(transaction)) 87 .filter_map(|transaction| { 88 wallet_transaction_v2_row( 89 wallet_addresses, 90 transaction, 91 outputs, 92 domain, 93 network, 94 &context, 95 ) 96 .ok() 97 .flatten() 98 }) 99 .collect() 100 } 101 102 pub(crate) fn wallet_transaction_v2_row( 103 wallet_addresses: &[String], 104 transaction: &TransactionV2, 105 outputs: &BTreeMap<OutPoint, TxOutput>, 106 domain: &TransactionV2Domain, 107 network: AddressNetwork, 108 context: &WalletTransactionContext, 109 ) -> Result<Option<WalletTransactionRow>> { 110 let presented = ui_transaction_v2(transaction, outputs, domain, network)?; 111 let is_wallet_address = 112 |candidate: &str| wallet_addresses.iter().any(|address| address == candidate); 113 let sent = is_wallet_address(&presented.from); 114 let received = presented 115 .outputs 116 .iter() 117 .chain(presented.change.iter()) 118 .any(|output| is_wallet_address(&output.address)); 119 if !sent && !received { 120 return Ok(None); 121 } 122 let amount = match transaction { 123 TransactionV2::Migration { .. } | TransactionV2::Burn { .. } => presented.amount, 124 _ if sent => presented 125 .outputs 126 .iter() 127 .filter(|output| !is_wallet_address(&output.address)) 128 .fold(0_u64, |total, output| total.saturating_add(output.amount)), 129 _ => presented 130 .outputs 131 .iter() 132 .chain(presented.change.iter()) 133 .filter(|output| is_wallet_address(&output.address)) 134 .fold(0_u64, |total, output| total.saturating_add(output.amount)), 135 }; 136 let direction = match transaction { 137 TransactionV2::Migration { .. } => "migrated", 138 TransactionV2::Burn { .. } => "burned", 139 _ if sent => "sent", 140 _ => "received", 141 }; 142 Ok(Some(WalletTransactionRow { 143 kind: presented.kind, 144 from: presented.from, 145 to: presented.to, 146 amount, 147 fee: presented.fee, 148 inputs: presented.inputs, 149 outputs: presented.outputs, 150 change: presented.change, 151 signature: presented.signature, 152 status: context.status, 153 block_height: context.block_height, 154 timestamp_ms: context.timestamp_ms, 155 block_finalizer: context.block_finalizer.clone(), 156 direction, 157 difficulty_bits: presented.difficulty_bits, 158 proof_bits: presented.proof_bits, 159 proof_hash: presented.proof_hash, 160 reward_total: None, 161 reward_fee_inputs: Vec::new(), 162 reward_outputs: Vec::new(), 163 })) 164 } 165 166 #[cfg(test)] 167 pub(super) fn wallet_transaction_row( 168 wallet: &str, 169 tx: &Transaction, 170 outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, 171 context: &WalletTransactionContext, 172 ) -> Option<WalletTransactionRow> { 173 wallet_transaction_row_for_addresses( 174 std::slice::from_ref(&wallet.to_string()), 175 tx, 176 outputs_by_outpoint, 177 context, 178 ) 179 } 180 181 pub(crate) fn wallet_transaction_row_for_addresses( 182 wallet_addresses: &[String], 183 tx: &Transaction, 184 outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, 185 context: &WalletTransactionContext, 186 ) -> Option<WalletTransactionRow> { 187 let owns = |address: &str| wallet_addresses.iter().any(|wallet| wallet == address); 188 match tx { 189 Transaction::Transfer { 190 inputs, 191 outputs, 192 fee, 193 signature, 194 } if owns(tx.sender()) || outputs.iter().any(|output| owns(&output.address)) => { 195 let sent = owns(tx.sender()); 196 let amount = if sent { 197 tx.amount() 198 } else { 199 outputs 200 .iter() 201 .filter(|output| owns(&output.address)) 202 .fold(0_u64, |total, output| total.saturating_add(output.amount)) 203 }; 204 Some(WalletTransactionRow { 205 kind: "transfer", 206 from: tx.sender().to_string(), 207 to: tx.to().map(str::to_string), 208 amount, 209 fee: *fee, 210 inputs: ui_inputs(inputs, outputs_by_outpoint), 211 outputs: outputs.clone(), 212 change: Vec::new(), 213 signature: signature.clone(), 214 status: context.status, 215 block_height: context.block_height, 216 timestamp_ms: context.timestamp_ms, 217 block_finalizer: context.block_finalizer.clone(), 218 direction: if sent { "sent" } else { "received" }, 219 difficulty_bits: None, 220 proof_bits: None, 221 proof_hash: None, 222 reward_total: None, 223 reward_fee_inputs: Vec::new(), 224 reward_outputs: Vec::new(), 225 }) 226 } 227 Transaction::Burn { 228 inputs, 229 change, 230 amount, 231 fee, 232 signature, 233 .. 234 } if owns(tx.sender()) => Some(WalletTransactionRow { 235 kind: "burn", 236 from: tx.sender().to_string(), 237 to: None, 238 amount: *amount, 239 fee: *fee, 240 inputs: ui_inputs(inputs, outputs_by_outpoint), 241 outputs: Vec::new(), 242 change: change.clone(), 243 signature: signature.clone(), 244 status: context.status, 245 block_height: context.block_height, 246 timestamp_ms: context.timestamp_ms, 247 block_finalizer: context.block_finalizer.clone(), 248 direction: "burned", 249 difficulty_bits: None, 250 proof_bits: None, 251 proof_hash: None, 252 reward_total: None, 253 reward_fee_inputs: Vec::new(), 254 reward_outputs: Vec::new(), 255 }), 256 Transaction::Mine { 257 recipient, 258 difficulty_bits, 259 signature, 260 .. 261 } if owns(recipient) => Some(WalletTransactionRow { 262 kind: "mine", 263 from: "pow".to_string(), 264 to: Some(recipient.clone()), 265 amount: MINE_REWARD, 266 fee: tx.fee(), 267 inputs: Vec::new(), 268 outputs: vec![TxOutput { 269 address: recipient.clone(), 270 amount: MINE_REWARD, 271 }], 272 change: Vec::new(), 273 signature: signature.clone(), 274 status: context.status, 275 block_height: context.block_height, 276 timestamp_ms: context.timestamp_ms, 277 block_finalizer: context.block_finalizer.clone(), 278 direction: "received", 279 difficulty_bits: Some(*difficulty_bits), 280 proof_bits: Some(proof_bits(signature)), 281 proof_hash: Some(signature.clone()), 282 reward_total: None, 283 reward_fee_inputs: Vec::new(), 284 reward_outputs: Vec::new(), 285 }), 286 _ => None, 287 } 288 } 289 290 pub(super) fn populate_wallet_reward_flow(row: &mut WalletTransactionRow, block: &Block) { 291 row.reward_total = Some(block.reward); 292 row.reward_fee_inputs = block 293 .transactions 294 .iter() 295 .filter(|transaction| transaction.fee() > 0) 296 .map(|transaction| UiRewardFeeInput { 297 transaction_kind: transaction_kind(transaction), 298 amount: transaction.fee(), 299 owner: match transaction { 300 Transaction::Mine { .. } => "pow".to_string(), 301 _ => transaction.sender().to_string(), 302 }, 303 signature: transaction.signature().to_string(), 304 }) 305 .collect(); 306 307 let committee_slots = block 308 .burn_bundle_section 309 .signatures 310 .iter() 311 .filter(|signature| signature.member != block.miner) 312 .map(|signature| (signature.member.as_str(), signature.slot)) 313 .collect::<BTreeMap<_, _>>(); 314 315 row.reward_outputs = row 316 .outputs 317 .iter() 318 .cloned() 319 .enumerate() 320 .map(|(index, output)| UiRewardOutput { 321 label: if index == 0 { 322 "Finalizer reward".to_string() 323 } else if let Some(slot) = committee_slots.get(output.address.as_str()) { 324 format!("Committee reward (slot {slot})") 325 } else { 326 format!("Committee reward {index}") 327 }, 328 amount: output.amount, 329 address: output.address, 330 }) 331 .collect(); 332 } 333 334 pub(crate) fn mark_wallet_reward_row(row: &mut WalletTransactionRow, block: Option<&Block>) { 335 row.kind = "reward"; 336 row.from = "fees".to_string(); 337 row.direction = "reward"; 338 if let Some(block) = block { 339 populate_wallet_reward_flow(row, block); 340 } 341 } 342 343 fn transaction_kind(transaction: &Transaction) -> &'static str { 344 match transaction { 345 Transaction::Transfer { .. } => "transfer", 346 Transaction::Burn { .. } => "burn", 347 Transaction::Mine { .. } => "mine", 348 } 349 } 350 351 pub(super) fn ui_blocks_from_indexes( 352 blocks: Vec<Block>, 353 outputs: &BTreeMap<OutPoint, TxOutput>, 354 burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, 355 storage_size_breakdowns: &BTreeMap<String, CompactBlockSizeBreakdown>, 356 transaction_v2_domain: Option<&TransactionV2Domain>, 357 network: AddressNetwork, 358 ) -> Vec<UiBlock> { 359 blocks 360 .into_iter() 361 .map(|block| { 362 let storage_size = storage_size_breakdowns.get(&block.hash); 363 ui_block_with_v2( 364 block, 365 outputs, 366 burn_leader_ranks, 367 storage_size, 368 transaction_v2_domain, 369 network, 370 ) 371 }) 372 .collect() 373 } 374 375 #[cfg(test)] 376 pub(super) fn ui_block( 377 block: Block, 378 outputs: &BTreeMap<OutPoint, TxOutput>, 379 burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, 380 storage_size: Option<&CompactBlockSizeBreakdown>, 381 ) -> UiBlock { 382 ui_block_with_v2( 383 block, 384 outputs, 385 burn_leader_ranks, 386 storage_size, 387 None, 388 AddressNetwork::Mainnet, 389 ) 390 } 391 392 fn ui_block_with_v2( 393 block: Block, 394 outputs: &BTreeMap<OutPoint, TxOutput>, 395 burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, 396 storage_size: Option<&CompactBlockSizeBreakdown>, 397 transaction_v2_domain: Option<&TransactionV2Domain>, 398 network: AddressNetwork, 399 ) -> UiBlock { 400 let ranks = burn_leader_ranks 401 .get(&block.hash) 402 .cloned() 403 .unwrap_or_default(); 404 let (burn_bundles_included, burn_bundles_required) = burn_bundle_wallet_quorum(&block); 405 let public_fees = block 406 .transactions 407 .iter() 408 .fold(0_u64, |total, tx| total.saturating_add(tx.fee())); 409 let transactions_v2 = transaction_v2_domain 410 .map(|domain| decode_block_transactions_v2(&block, domain)) 411 .unwrap_or_default(); 412 let total_fees = transactions_v2 413 .iter() 414 .fold(public_fees, |total, tx| total.saturating_add(tx.fee())); 415 let transaction_bytes = storage_size 416 .map(|size| size.transaction_bytes) 417 .unwrap_or_else(|| { 418 let legacy_bytes = block 419 .transactions 420 .iter() 421 .map(|tx| tx.serialized_size_bytes().unwrap_or_default()) 422 .sum::<usize>(); 423 let v2_bytes = transaction_v2_domain.map_or(0, |domain| { 424 transactions_v2.iter().fold(0_usize, |total, transaction| { 425 total.saturating_add(transaction.encoded_size_bytes(domain).unwrap_or_default()) 426 }) 427 }); 428 legacy_bytes.saturating_add(v2_bytes) 429 }); 430 let transaction_byte_breakdown = transaction_byte_breakdown( 431 &block.transactions, 432 &transactions_v2, 433 storage_size, 434 transaction_v2_domain, 435 ); 436 let header_and_proof_bytes = storage_size 437 .map(|size| size.header_and_proof_bytes) 438 .unwrap_or_default(); 439 let mut transactions = block 440 .transactions 441 .iter() 442 .map(|tx| ui_transaction(tx, outputs)) 443 .collect::<Vec<_>>(); 444 if let Some(domain) = transaction_v2_domain { 445 transactions.extend(transactions_v2.iter().filter_map(|transaction| { 446 ui_transaction_v2(transaction, outputs, domain, network).ok() 447 })); 448 } 449 let burn_bundles: Vec<UiBurnBundle> = block 450 .burn_bundle_section 451 .expand(block.height, &block.prev_hash) 452 .into_iter() 453 .map(|bundle| { 454 let mut burns = bundle 455 .burns 456 .iter() 457 .map(|burn| ui_transaction(burn, outputs)) 458 .collect::<Vec<_>>(); 459 if let Some(domain) = transaction_v2_domain { 460 burns.extend(bundle.burns_v2.iter().filter_map(|envelope| { 461 let encoded = crate::domain::decode_hex(envelope).ok()?; 462 let (decoded_domain, transaction) = TransactionV2::decode(&encoded).ok()?; 463 (decoded_domain == *domain) 464 .then(|| ui_transaction_v2(&transaction, outputs, domain, network).ok()) 465 .flatten() 466 })); 467 } 468 UiBurnBundle { 469 slot: bundle.slot, 470 member: bundle.member.clone(), 471 hash: bundle.bundle_hash(), 472 byte_size: bundle.serialized_size_bytes().unwrap_or_default(), 473 burns, 474 } 475 }) 476 .collect(); 477 let burn_bundle_bytes = storage_size 478 .map(|size| size.burn_bundle_bytes) 479 .unwrap_or_else(|| { 480 burn_bundles 481 .iter() 482 .map(|bundle: &UiBurnBundle| bundle.byte_size) 483 .sum::<usize>() 484 }); 485 let total_bytes = storage_size 486 .map(|size| size.total_bytes) 487 .unwrap_or_else(|| { 488 block 489 .json_size_bytes() 490 .unwrap_or_else(|_| transaction_bytes.saturating_add(burn_bundle_bytes)) 491 }); 492 UiBlock { 493 height: block.height, 494 prev_hash: block.prev_hash, 495 timestamp_ms: block.timestamp_ms, 496 miner: block.miner, 497 finalizer_mode: block.finalizer_mode, 498 finalizer_rank: block.finalizer_rank, 499 reward: block.reward, 500 total_fees, 501 lost_iuna: block_lost_iuna(&block.transactions, &transactions_v2, block.reward), 502 total_bytes, 503 header_and_proof_bytes, 504 transaction_bytes, 505 transaction_byte_breakdown, 506 burn_bundle_bytes, 507 burn_bundle_quorum: UiBurnBundleQuorum { 508 burn_bundles_included, 509 committee_size: burn_bundles_required, 510 }, 511 vdf_rounds: block.vdf_rounds, 512 vdf_output: block.vdf_output, 513 leader_proof: block.leader_proof, 514 burn_leader_ranks: ranks, 515 transactions, 516 burn_bundles, 517 hash: block.hash, 518 } 519 } 520 521 fn decode_block_transactions_v2( 522 block: &Block, 523 expected_domain: &TransactionV2Domain, 524 ) -> Vec<TransactionV2> { 525 block 526 .transactions_v2 527 .iter() 528 .filter_map(|envelope| { 529 let bytes = decode_hex(envelope).ok()?; 530 let (domain, transaction) = TransactionV2::decode(&bytes).ok()?; 531 (domain == *expected_domain).then_some(transaction) 532 }) 533 .collect() 534 } 535 536 fn burn_bundle_wallet_quorum(block: &Block) -> (usize, usize) { 537 if block.finalizer_mode != FinalizerMode::Ticket { 538 return (0, 0); 539 } 540 541 // The block records the attestations actually included in its rank-dependent 542 // quorum. This UI summary does not reconstruct the historical committee from 543 // unrelated burn-ticket rank owners. 544 let committee_size = block.burn_bundle_section.signatures.len().saturating_add(1); 545 (committee_size, committee_size) 546 } 547 548 fn block_lost_iuna( 549 transactions: &[Transaction], 550 transactions_v2: &[TransactionV2], 551 reward: Amount, 552 ) -> Amount { 553 let mut burned = 0_u64; 554 let mut existing_supply_fees = 0_u64; 555 let mut minted_finalizer_fees = 0_u64; 556 for transaction in transactions { 557 match transaction { 558 Transaction::Burn { amount, fee, .. } => { 559 burned = burned.saturating_add(*amount); 560 existing_supply_fees = existing_supply_fees.saturating_add(*fee); 561 } 562 Transaction::Transfer { fee, .. } => { 563 existing_supply_fees = existing_supply_fees.saturating_add(*fee); 564 } 565 Transaction::Mine { .. } => { 566 minted_finalizer_fees = minted_finalizer_fees.saturating_add(transaction.fee()); 567 } 568 } 569 } 570 for transaction in transactions_v2 { 571 if let TransactionV2::Burn { amount, .. } = transaction { 572 burned = burned.saturating_add(*amount); 573 } 574 existing_supply_fees = existing_supply_fees.saturating_add(transaction.fee()); 575 } 576 let returned_fees = reward 577 .saturating_sub(minted_finalizer_fees) 578 .min(existing_supply_fees); 579 burned.saturating_add(existing_supply_fees.saturating_sub(returned_fees)) 580 } 581 582 fn transaction_byte_breakdown( 583 transactions: &[Transaction], 584 transactions_v2: &[TransactionV2], 585 storage_size: Option<&CompactBlockSizeBreakdown>, 586 transaction_v2_domain: Option<&TransactionV2Domain>, 587 ) -> Vec<UiByteBreakdown> { 588 if let Some(storage_size) = storage_size { 589 let mut rows = byte_breakdown_rows( 590 storage_size.transfer_bytes, 591 storage_size.burn_bytes, 592 storage_size.mine_bytes, 593 ); 594 let legacy_bytes = storage_size 595 .transfer_bytes 596 .saturating_add(storage_size.burn_bytes) 597 .saturating_add(storage_size.mine_bytes); 598 let v2_bytes = storage_size.transaction_bytes.saturating_sub(legacy_bytes); 599 if v2_bytes > 0 { 600 rows.push(UiByteBreakdown { 601 label: "transaction v2", 602 bytes: v2_bytes, 603 }); 604 } 605 return rows; 606 } 607 let mut transfer_bytes = 0_usize; 608 let mut burn_bytes = 0_usize; 609 let mut mine_bytes = 0_usize; 610 for transaction in transactions { 611 let bytes = transaction.serialized_size_bytes().unwrap_or_default(); 612 match transaction { 613 Transaction::Transfer { .. } => transfer_bytes = transfer_bytes.saturating_add(bytes), 614 Transaction::Burn { .. } => burn_bytes = burn_bytes.saturating_add(bytes), 615 Transaction::Mine { .. } => mine_bytes = mine_bytes.saturating_add(bytes), 616 } 617 } 618 let mut rows = byte_breakdown_rows(transfer_bytes, burn_bytes, mine_bytes); 619 let transaction_v2_bytes = transaction_v2_domain.map_or(0, |domain| { 620 transactions_v2.iter().fold(0_usize, |total, transaction| { 621 total.saturating_add(transaction.encoded_size_bytes(domain).unwrap_or_default()) 622 }) 623 }); 624 if transaction_v2_bytes > 0 { 625 rows.push(UiByteBreakdown { 626 label: "transaction v2", 627 bytes: transaction_v2_bytes, 628 }); 629 } 630 rows 631 } 632 633 fn byte_breakdown_rows( 634 transfer_bytes: usize, 635 burn_bytes: usize, 636 mine_bytes: usize, 637 ) -> Vec<UiByteBreakdown> { 638 [ 639 ("transfer", transfer_bytes), 640 ("burn", burn_bytes), 641 ("mine", mine_bytes), 642 ] 643 .into_iter() 644 .filter_map(|(label, bytes)| (bytes > 0).then_some(UiByteBreakdown { label, bytes })) 645 .collect() 646 } 647 648 pub(super) fn ui_transaction( 649 transaction: &Transaction, 650 outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, 651 ) -> UiTransaction { 652 match transaction { 653 Transaction::Transfer { 654 inputs, 655 outputs, 656 fee, 657 signature, 658 } => UiTransaction { 659 kind: "transfer", 660 from: transaction.sender().to_string(), 661 to: transaction.to().map(str::to_string), 662 amount: transaction.amount(), 663 fee: *fee, 664 inputs: ui_inputs(inputs, outputs_by_outpoint), 665 outputs: outputs.clone(), 666 change: Vec::new(), 667 signature: signature.clone(), 668 difficulty_bits: None, 669 proof_bits: None, 670 proof_hash: None, 671 }, 672 Transaction::Burn { 673 inputs, 674 change, 675 amount, 676 fee, 677 signature, 678 .. 679 } => UiTransaction { 680 kind: "burn", 681 from: transaction.sender().to_string(), 682 to: None, 683 amount: *amount, 684 fee: *fee, 685 inputs: ui_inputs(inputs, outputs_by_outpoint), 686 outputs: Vec::new(), 687 change: change.clone(), 688 signature: signature.clone(), 689 difficulty_bits: None, 690 proof_bits: None, 691 proof_hash: None, 692 }, 693 Transaction::Mine { 694 recipient, 695 difficulty_bits, 696 signature, 697 .. 698 } => UiTransaction { 699 kind: "mine", 700 from: "pow".to_string(), 701 to: Some(recipient.clone()), 702 amount: MINE_REWARD, 703 fee: transaction.fee(), 704 inputs: Vec::new(), 705 outputs: vec![TxOutput { 706 address: recipient.clone(), 707 amount: MINE_REWARD, 708 }], 709 change: Vec::new(), 710 signature: signature.clone(), 711 difficulty_bits: Some(*difficulty_bits), 712 proof_bits: Some(proof_bits(signature)), 713 proof_hash: Some(signature.clone()), 714 }, 715 } 716 } 717 718 pub(super) fn ui_transaction_v2( 719 transaction: &TransactionV2, 720 outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, 721 domain: &TransactionV2Domain, 722 network: AddressNetwork, 723 ) -> Result<UiTransaction> { 724 let transaction_id = hex_encode(transaction.transaction_id(domain)?); 725 match transaction { 726 TransactionV2::Migration { 727 inputs, 728 outputs, 729 fee, 730 authorizations, 731 } => Ok(UiTransaction { 732 kind: "migration", 733 from: inputs 734 .first() 735 .map(|input| encode_versioned_address(input.owner, network)) 736 .transpose()? 737 .unwrap_or_default(), 738 to: first_v2_output_address(outputs, network)?, 739 amount: outputs 740 .iter() 741 .fold(0_u64, |total, output| total.saturating_add(output.amount)), 742 fee: *fee, 743 inputs: inputs 744 .iter() 745 .enumerate() 746 .map(|(index, input)| { 747 let txid = match &input.outpoint_id { 748 LegacyTransactionId::Hash(value) => hex_encode(value), 749 LegacyTransactionId::Signature(value) => hex_encode(value), 750 }; 751 v2_ui_input( 752 OutPoint { 753 txid, 754 index: input.outpoint_index, 755 }, 756 input.owner, 757 authorizations.get(index), 758 outputs_by_outpoint, 759 network, 760 ) 761 }) 762 .collect::<Result<Vec<_>>>()?, 763 outputs: v2_ui_outputs(outputs, network)?, 764 change: Vec::new(), 765 signature: transaction_id, 766 difficulty_bits: None, 767 proof_bits: None, 768 proof_hash: None, 769 }), 770 TransactionV2::Transfer { 771 inputs, 772 outputs, 773 fee, 774 authorizations, 775 } => Ok(UiTransaction { 776 kind: "transfer", 777 from: inputs 778 .first() 779 .map(|input| encode_versioned_address(input.owner, network)) 780 .transpose()? 781 .unwrap_or_default(), 782 to: first_v2_output_address(outputs, network)?, 783 amount: outputs.first().map(|output| output.amount).unwrap_or(0), 784 fee: *fee, 785 inputs: inputs 786 .iter() 787 .enumerate() 788 .map(|(index, input)| { 789 v2_ui_input( 790 OutPoint { 791 txid: hex_encode(input.outpoint_txid), 792 index: input.outpoint_index, 793 }, 794 input.owner, 795 authorizations.get(index), 796 outputs_by_outpoint, 797 network, 798 ) 799 }) 800 .collect::<Result<Vec<_>>>()?, 801 outputs: v2_ui_outputs(outputs, network)?, 802 change: Vec::new(), 803 signature: transaction_id, 804 difficulty_bits: None, 805 proof_bits: None, 806 proof_hash: None, 807 }), 808 TransactionV2::Burn { 809 inputs, 810 change, 811 amount, 812 fee, 813 authorizations, 814 .. 815 } => Ok(UiTransaction { 816 kind: "burn", 817 from: inputs 818 .first() 819 .map(|input| encode_versioned_address(input.owner, network)) 820 .transpose()? 821 .unwrap_or_default(), 822 to: None, 823 amount: *amount, 824 fee: *fee, 825 inputs: inputs 826 .iter() 827 .enumerate() 828 .map(|(index, input)| { 829 v2_ui_input( 830 OutPoint { 831 txid: hex_encode(input.outpoint_txid), 832 index: input.outpoint_index, 833 }, 834 input.owner, 835 authorizations.get(index), 836 outputs_by_outpoint, 837 network, 838 ) 839 }) 840 .collect::<Result<Vec<_>>>()?, 841 outputs: Vec::new(), 842 change: v2_ui_outputs(change, network)?, 843 signature: transaction_id, 844 difficulty_bits: None, 845 proof_bits: None, 846 proof_hash: None, 847 }), 848 TransactionV2::Mine { 849 recipient, 850 difficulty_bits, 851 proof_hash, 852 .. 853 } => { 854 let recipient = encode_versioned_address(*recipient, network)?; 855 let proof_hash = hex_encode(proof_hash); 856 Ok(UiTransaction { 857 kind: "mine", 858 from: "pow".to_string(), 859 to: Some(recipient.clone()), 860 amount: MINE_REWARD, 861 fee: 0, 862 inputs: Vec::new(), 863 outputs: vec![TxOutput { 864 address: recipient, 865 amount: MINE_REWARD, 866 }], 867 change: Vec::new(), 868 signature: transaction_id, 869 difficulty_bits: Some(*difficulty_bits), 870 proof_bits: Some(proof_bits(&proof_hash)), 871 proof_hash: Some(proof_hash), 872 }) 873 } 874 } 875 } 876 877 pub(crate) fn transaction_v2_input_outpoints(transaction: &TransactionV2) -> Vec<OutPoint> { 878 match transaction { 879 TransactionV2::Migration { inputs, .. } => inputs 880 .iter() 881 .map(|input| OutPoint { 882 txid: match &input.outpoint_id { 883 LegacyTransactionId::Hash(value) => hex_encode(value), 884 LegacyTransactionId::Signature(value) => hex_encode(value), 885 }, 886 index: input.outpoint_index, 887 }) 888 .collect(), 889 TransactionV2::Transfer { inputs, .. } | TransactionV2::Burn { inputs, .. } => inputs 890 .iter() 891 .map(|input| OutPoint { 892 txid: hex_encode(input.outpoint_txid), 893 index: input.outpoint_index, 894 }) 895 .collect(), 896 TransactionV2::Mine { .. } => Vec::new(), 897 } 898 } 899 900 pub(crate) fn add_pending_v2_outputs( 901 outputs: &mut BTreeMap<OutPoint, TxOutput>, 902 pending: &[TransactionV2], 903 domain: &TransactionV2Domain, 904 network: AddressNetwork, 905 ) -> Result<()> { 906 for transaction in pending { 907 let txid = hex_encode(transaction.transaction_id(domain)?); 908 let transaction_outputs = match transaction { 909 TransactionV2::Migration { outputs, .. } | TransactionV2::Transfer { outputs, .. } => { 910 outputs.as_slice() 911 } 912 TransactionV2::Burn { change, .. } => change.as_slice(), 913 TransactionV2::Mine { .. } => &[], 914 }; 915 for (index, output) in v2_ui_outputs(transaction_outputs, network)? 916 .into_iter() 917 .enumerate() 918 { 919 outputs.insert( 920 OutPoint { 921 txid: txid.clone(), 922 index: u32::try_from(index)?, 923 }, 924 output, 925 ); 926 } 927 } 928 Ok(()) 929 } 930 931 fn first_v2_output_address( 932 outputs: &[crate::domain::TransactionV2Output], 933 network: AddressNetwork, 934 ) -> Result<Option<String>> { 935 outputs 936 .first() 937 .map(|output| encode_versioned_address(output.address, network)) 938 .transpose() 939 } 940 941 fn v2_ui_input( 942 outpoint: OutPoint, 943 owner: crate::domain::VersionedAddress, 944 authorization: Option<&crate::domain::V2SpendingAuthorization>, 945 outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, 946 network: AddressNetwork, 947 ) -> Result<UiTxInput> { 948 let spent_output = outputs_by_outpoint.get(&outpoint); 949 Ok(UiTxInput { 950 outpoint, 951 owner: encode_versioned_address(owner, network)?, 952 signature: authorization 953 .map(|authorization| hex_encode(authorization.signature().as_bytes())) 954 .unwrap_or_default(), 955 amount: spent_output.map(|output| output.amount), 956 address: spent_output.map(|output| output.address.clone()), 957 }) 958 } 959 960 fn v2_ui_outputs( 961 outputs: &[crate::domain::TransactionV2Output], 962 network: AddressNetwork, 963 ) -> Result<Vec<TxOutput>> { 964 outputs 965 .iter() 966 .map(|output| { 967 Ok(TxOutput { 968 address: encode_versioned_address(output.address, network)?, 969 amount: output.amount, 970 }) 971 }) 972 .collect() 973 } 974 975 fn ui_inputs( 976 inputs: &[TxInput], 977 outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, 978 ) -> Vec<UiTxInput> { 979 inputs 980 .iter() 981 .map(|input| { 982 let spent_output = outputs_by_outpoint.get(&input.outpoint); 983 UiTxInput { 984 outpoint: input.outpoint.clone(), 985 owner: input.owner.clone(), 986 signature: input.signature.clone(), 987 amount: spent_output.map(|output| output.amount), 988 address: spent_output.map(|output| output.address.clone()), 989 } 990 }) 991 .collect() 992 } 993 994 pub(super) fn proof_bits(hex_hash: &str) -> u32 { 995 let mut bits = 0_u32; 996 for byte in hex_hash.as_bytes() { 997 let Some(nibble) = hex_nibble(*byte) else { 998 break; 999 }; 1000 if nibble == 0 { 1001 bits += 4; 1002 continue; 1003 } 1004 bits += nibble.leading_zeros() - 4; 1005 break; 1006 } 1007 bits 1008 } 1009 1010 fn hex_nibble(byte: u8) -> Option<u8> { 1011 match byte { 1012 b'0'..=b'9' => Some(byte - b'0'), 1013 b'a'..=b'f' => Some(byte - b'a' + 10), 1014 b'A'..=b'F' => Some(byte - b'A' + 10), 1015 _ => None, 1016 } 1017 } 1018 1019 pub(crate) fn add_pending_outputs( 1020 outputs: &mut BTreeMap<OutPoint, TxOutput>, 1021 pending: &[Transaction], 1022 ) { 1023 for transaction in pending { 1024 index_transaction_outputs(outputs, transaction); 1025 } 1026 } 1027 1028 fn index_transaction_outputs( 1029 outputs: &mut BTreeMap<OutPoint, TxOutput>, 1030 transaction: &Transaction, 1031 ) { 1032 let created_outputs = match transaction { 1033 Transaction::Transfer { outputs, .. } => outputs.clone(), 1034 Transaction::Burn { change, .. } => change.clone(), 1035 Transaction::Mine { recipient, .. } => vec![TxOutput { 1036 address: recipient.clone(), 1037 amount: MINE_REWARD, 1038 }], 1039 }; 1040 for (index, output) in created_outputs.iter().enumerate() { 1041 outputs.insert( 1042 OutPoint { 1043 txid: transaction.signature().to_string(), 1044 index: index as u32, 1045 }, 1046 output.clone(), 1047 ); 1048 } 1049 } 1050 1051 #[cfg(test)] 1052 mod tests { 1053 use std::collections::BTreeMap; 1054 1055 use crate::compact::CompactBlockSizeBreakdown; 1056 use crate::domain::{ 1057 AddressNetwork, Amount, Block, BurnBundleSection, BurnBundleSignature, BurnLeaderRank, 1058 FinalizerMode, Ledger, MaskedBurn, OutPoint, Transaction, TransactionV2, 1059 TransactionV2Input, TransactionV2Output, TxInput, TxOutput, Wallet, 1060 encode_versioned_address, hex_encode, 1061 }; 1062 1063 use super::{ 1064 block_lost_iuna, mark_wallet_reward_row, ui_block, ui_blocks_from_indexes, 1065 ui_transaction_v2, wallet_transaction_row, wallet_transaction_v2_rows, 1066 }; 1067 use crate::adapters::http::types::{WalletTransactionContext, WalletTransactionFilters}; 1068 1069 fn burn(signature: &str) -> Transaction { 1070 Transaction::Burn { 1071 inputs: vec![TxInput { 1072 outpoint: OutPoint { 1073 txid: format!("{signature:0<64}"), 1074 index: 0, 1075 }, 1076 owner: "owner".to_string(), 1077 signature: signature.to_string(), 1078 }], 1079 change: vec![TxOutput { 1080 address: "owner".to_string(), 1081 amount: 1, 1082 }], 1083 amount: 1, 1084 fee: 1, 1085 anchor: None, 1086 signature: signature.to_string(), 1087 } 1088 } 1089 1090 fn transfer(signature: &str, fee: Amount) -> Transaction { 1091 Transaction::Transfer { 1092 inputs: vec![TxInput { 1093 outpoint: OutPoint { 1094 txid: format!("{signature:0<64}"), 1095 index: 0, 1096 }, 1097 owner: "owner".to_string(), 1098 signature: signature.to_string(), 1099 }], 1100 outputs: vec![TxOutput { 1101 address: "recipient".to_string(), 1102 amount: 1, 1103 }], 1104 fee, 1105 signature: signature.to_string(), 1106 } 1107 } 1108 1109 #[test] 1110 fn pending_v2_migration_is_presented_in_chain_and_wallet_views() { 1111 let wallet = Wallet::from_seed("pending-v2-ui-wallet"); 1112 let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100_000)]), 1); 1113 let transaction = ledger.build_v2_migration_batch(&wallet, 100).unwrap(); 1114 let domain = ledger.transaction_v2_domain().unwrap(); 1115 let outputs = ledger.all_utxos().into_iter().collect::<BTreeMap<_, _>>(); 1116 let network = AddressNetwork::Mainnet; 1117 1118 let chain_row = ui_transaction_v2(&transaction, &outputs, &domain, network).unwrap(); 1119 assert_eq!(chain_row.kind, "migration"); 1120 assert_eq!(chain_row.signature.len(), 64); 1121 assert_eq!(chain_row.inputs.len(), 1); 1122 assert_eq!(chain_row.outputs.len(), 1); 1123 1124 let wallet_addresses = vec![ 1125 wallet.address().to_string(), 1126 encode_versioned_address(wallet.legacy_versioned_address(), network).unwrap(), 1127 wallet.hybrid_address(network), 1128 ]; 1129 let rows = wallet_transaction_v2_rows( 1130 &wallet_addresses, 1131 &[transaction], 1132 &outputs, 1133 WalletTransactionFilters::default(), 1134 &domain, 1135 network, 1136 ); 1137 assert_eq!(rows.len(), 1); 1138 assert_eq!(rows[0].kind, "migration"); 1139 assert_eq!(rows[0].direction, "migrated"); 1140 assert_eq!(rows[0].status, "pending"); 1141 assert_eq!(rows[0].amount, 99_900); 1142 } 1143 1144 #[test] 1145 fn pending_v2_burn_shows_the_burned_amount_in_the_wallet_view() { 1146 let wallet = Wallet::from_seed("pending-v2-burn-ui-wallet"); 1147 let ledger = Ledger::new(BTreeMap::new(), 1); 1148 let domain = ledger.transaction_v2_domain().unwrap(); 1149 let network = AddressNetwork::Mainnet; 1150 let owner = wallet.hybrid_versioned_address(); 1151 let input_id = [0x42; 32]; 1152 let mut transaction = TransactionV2::Burn { 1153 inputs: vec![TransactionV2Input { 1154 outpoint_txid: input_id, 1155 outpoint_index: 0, 1156 owner, 1157 }], 1158 change: vec![TransactionV2Output { 1159 address: owner, 1160 amount: 2, 1161 }], 1162 amount: 7, 1163 fee: 1, 1164 anchor: Some([0x24; 32]), 1165 authorizations: Vec::new(), 1166 }; 1167 let payload = transaction.signing_bytes(&domain).unwrap(); 1168 let authorization = wallet.sign_v2_authorization(owner, &payload).unwrap(); 1169 let TransactionV2::Burn { authorizations, .. } = &mut transaction else { 1170 unreachable!(); 1171 }; 1172 authorizations.push(authorization); 1173 let outputs = BTreeMap::from([( 1174 OutPoint { 1175 txid: hex_encode(input_id), 1176 index: 0, 1177 }, 1178 TxOutput { 1179 address: wallet.hybrid_address(network), 1180 amount: 10, 1181 }, 1182 )]); 1183 let wallet_addresses = vec![wallet.hybrid_address(network)]; 1184 1185 let rows = wallet_transaction_v2_rows( 1186 &wallet_addresses, 1187 &[transaction], 1188 &outputs, 1189 WalletTransactionFilters::default(), 1190 &domain, 1191 network, 1192 ); 1193 1194 assert_eq!(rows.len(), 1); 1195 assert_eq!(rows[0].kind, "burn"); 1196 assert_eq!(rows[0].direction, "burned"); 1197 assert_eq!(rows[0].status, "pending"); 1198 assert_eq!(rows[0].amount, 7); 1199 assert_eq!(rows[0].fee, 1); 1200 } 1201 1202 #[test] 1203 fn confirmed_v2_migration_is_presented_in_block_transactions() { 1204 let wallet = Wallet::from_seed("confirmed-v2-chain-ui-wallet"); 1205 let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100_000)]), 1); 1206 let transaction = ledger.build_v2_migration_batch(&wallet, 100).unwrap(); 1207 let domain = ledger.transaction_v2_domain().unwrap(); 1208 let mut block = ledger.chain().last().unwrap().clone(); 1209 block.transactions_v2 = vec![hex_encode(transaction.encode(&domain).unwrap())]; 1210 let blocks = ui_blocks_from_indexes( 1211 vec![block], 1212 &ledger.all_utxos().into_iter().collect(), 1213 &BTreeMap::new(), 1214 &BTreeMap::new(), 1215 Some(&domain), 1216 AddressNetwork::Mainnet, 1217 ); 1218 1219 assert_eq!(blocks.len(), 1); 1220 assert_eq!(blocks[0].transactions.len(), 1); 1221 assert_eq!(blocks[0].transactions[0].kind, "migration"); 1222 assert_eq!(blocks[0].transactions[0].fee, 100); 1223 assert_eq!(blocks[0].transactions[0].signature.len(), 64); 1224 assert_eq!(blocks[0].total_fees, 100); 1225 } 1226 1227 #[test] 1228 fn wallet_reward_flow_contains_every_fee_and_payout() { 1229 let reward_projection = Transaction::Transfer { 1230 inputs: Vec::new(), 1231 outputs: vec![ 1232 TxOutput { 1233 address: "finalizer".to_string(), 1234 amount: 3, 1235 }, 1236 TxOutput { 1237 address: "committee".to_string(), 1238 amount: 2, 1239 }, 1240 ], 1241 fee: 0, 1242 signature: "reward:block:0".to_string(), 1243 }; 1244 let mut row = wallet_transaction_row( 1245 "finalizer", 1246 &reward_projection, 1247 &BTreeMap::new(), 1248 &WalletTransactionContext { 1249 status: "confirmed", 1250 block_height: Some(1), 1251 timestamp_ms: Some(1_000), 1252 block_finalizer: Some("finalizer".to_string()), 1253 }, 1254 ) 1255 .unwrap(); 1256 let committee_row = wallet_transaction_row( 1257 "committee", 1258 &reward_projection, 1259 &BTreeMap::new(), 1260 &WalletTransactionContext { 1261 status: "confirmed", 1262 block_height: Some(1), 1263 timestamp_ms: Some(1_000), 1264 block_finalizer: Some("finalizer".to_string()), 1265 }, 1266 ) 1267 .unwrap(); 1268 assert_eq!(committee_row.amount, 2); 1269 assert_eq!(committee_row.direction, "received"); 1270 1271 let block = Block { 1272 height: 1, 1273 prev_hash: "parent".to_string(), 1274 timestamp_ms: 1_000, 1275 miner: "finalizer".to_string(), 1276 reward_address: None, 1277 reward_address_signature: None, 1278 finalizer_mode: FinalizerMode::Ticket, 1279 finalizer_rank: 0, 1280 reward: 5, 1281 vdf_rounds: 1, 1282 vdf_output: "vdf".to_string(), 1283 leader_proof: None, 1284 burn_bundle_section: BurnBundleSection { 1285 signatures: vec![BurnBundleSignature { 1286 slot: 2, 1287 member: "committee".to_string(), 1288 reward_address: None, 1289 signature: "attestation".to_string(), 1290 }], 1291 burns: Vec::new(), 1292 burns_v2: Vec::new(), 1293 }, 1294 transactions: vec![transfer("transfer", 2), burn("burn")], 1295 transactions_v2: Vec::new(), 1296 hash: "block".to_string(), 1297 }; 1298 1299 mark_wallet_reward_row(&mut row, Some(&block)); 1300 1301 assert_eq!(row.kind, "reward"); 1302 assert_eq!(row.from, "fees"); 1303 assert_eq!(row.direction, "reward"); 1304 assert_eq!(row.reward_fee_inputs.len(), 2); 1305 assert_eq!(row.reward_total, Some(5)); 1306 assert_eq!(row.reward_fee_inputs[0].transaction_kind, "transfer"); 1307 assert_eq!(row.reward_fee_inputs[0].amount, 2); 1308 assert_eq!(row.reward_fee_inputs[1].transaction_kind, "burn"); 1309 assert_eq!(row.reward_fee_inputs[1].amount, 1); 1310 assert_eq!(row.reward_outputs.len(), 2); 1311 assert_eq!(row.reward_outputs[0].label, "Finalizer reward"); 1312 assert_eq!(row.reward_outputs[0].address, "finalizer"); 1313 assert_eq!(row.reward_outputs[0].amount, 3); 1314 assert_eq!(row.reward_outputs[1].label, "Committee reward (slot 2)"); 1315 assert_eq!(row.reward_outputs[1].address, "committee"); 1316 assert_eq!(row.reward_outputs[1].amount, 2); 1317 } 1318 1319 #[test] 1320 fn block_lost_iuna_counts_burns_without_rewarded_fees() { 1321 let burn = Transaction::Burn { 1322 inputs: Vec::new(), 1323 change: Vec::new(), 1324 amount: 7, 1325 fee: 3, 1326 anchor: None, 1327 signature: "burn".to_string(), 1328 }; 1329 1330 assert_eq!(block_lost_iuna(&[burn], &[], 3), 7); 1331 } 1332 1333 #[test] 1334 fn block_lost_iuna_counts_unreturned_existing_supply_fees() { 1335 let transfer = transfer("transfer-a", 5); 1336 1337 assert_eq!(block_lost_iuna(&[transfer], &[], 2), 3); 1338 } 1339 1340 #[test] 1341 fn block_lost_iuna_ignores_minted_mine_finalizer_fees() { 1342 let mine = Transaction::Mine { 1343 recipient: "miner".to_string(), 1344 anchor: "anchor".to_string(), 1345 salt: 0, 1346 nonce: 0, 1347 difficulty_bits: 1, 1348 proof_header: None, 1349 signature: "mine".to_string(), 1350 }; 1351 1352 assert_eq!(block_lost_iuna(&[mine], &[], 1), 0); 1353 } 1354 1355 #[test] 1356 fn block_lost_iuna_does_not_count_mine_fees_as_returned_existing_supply() { 1357 let transfer = transfer("transfer-a", 5); 1358 let mine = Transaction::Mine { 1359 recipient: "miner".to_string(), 1360 anchor: "anchor".to_string(), 1361 salt: 0, 1362 nonce: 0, 1363 difficulty_bits: 1, 1364 proof_header: None, 1365 signature: "mine".to_string(), 1366 }; 1367 let reward = mine.fee().saturating_add(2); 1368 1369 assert_eq!(block_lost_iuna(&[transfer, mine], &[], reward), 3); 1370 } 1371 1372 #[test] 1373 fn ui_block_exposes_burn_bundles() { 1374 let burn = burn("burn-a"); 1375 let block = Block { 1376 height: 1, 1377 prev_hash: "parent".to_string(), 1378 timestamp_ms: 1, 1379 miner: "finalizer".to_string(), 1380 reward_address: None, 1381 reward_address_signature: None, 1382 finalizer_mode: FinalizerMode::Ticket, 1383 finalizer_rank: 0, 1384 reward: 1, 1385 vdf_rounds: 1, 1386 vdf_output: "vdf".to_string(), 1387 leader_proof: None, 1388 burn_bundle_section: BurnBundleSection { 1389 signatures: vec![crate::domain::BurnBundleSignature { 1390 slot: 1, 1391 member: "member-1".to_string(), 1392 reward_address: None, 1393 signature: "sig-1".to_string(), 1394 }], 1395 burns: vec![MaskedBurn { 1396 burn: burn.clone(), 1397 bundle_mask: 1 << 1, 1398 }], 1399 burns_v2: Vec::new(), 1400 }, 1401 transactions: vec![burn], 1402 transactions_v2: Vec::new(), 1403 hash: "hash".to_string(), 1404 }; 1405 1406 let storage_size = CompactBlockSizeBreakdown { 1407 total_bytes: 120, 1408 header_and_proof_bytes: 10, 1409 transaction_bytes: 80, 1410 transfer_bytes: 0, 1411 burn_bytes: 80, 1412 mine_bytes: 0, 1413 burn_bundle_bytes: 30, 1414 }; 1415 let ui = ui_block( 1416 block, 1417 &BTreeMap::new(), 1418 &BTreeMap::new(), 1419 Some(&storage_size), 1420 ); 1421 1422 assert_eq!(ui.burn_bundle_quorum.burn_bundles_included, 2); 1423 assert_eq!(ui.burn_bundle_quorum.committee_size, 2); 1424 assert_eq!(ui.lost_iuna, 1); 1425 assert_eq!(ui.burn_bundles.len(), 1); 1426 assert_eq!(ui.burn_bundles[0].slot, 1); 1427 assert_eq!(ui.burn_bundles[0].burns.len(), 1); 1428 assert_eq!(ui.total_bytes, 120); 1429 assert_eq!(ui.header_and_proof_bytes, 10); 1430 assert_eq!(ui.transaction_bytes, 80); 1431 assert_eq!(ui.burn_bundle_bytes, 30); 1432 } 1433 1434 #[test] 1435 fn ui_block_counts_implicit_finalizer_attestation_in_wallet_quorum() { 1436 let block = Block { 1437 height: 1, 1438 prev_hash: "parent".to_string(), 1439 timestamp_ms: 1, 1440 miner: "finalizer".to_string(), 1441 reward_address: None, 1442 reward_address_signature: None, 1443 finalizer_mode: FinalizerMode::Ticket, 1444 finalizer_rank: 0, 1445 reward: 1, 1446 vdf_rounds: 1, 1447 vdf_output: "vdf".to_string(), 1448 leader_proof: None, 1449 burn_bundle_section: BurnBundleSection::default(), 1450 transactions: vec![burn("burn-a")], 1451 transactions_v2: Vec::new(), 1452 hash: "hash".to_string(), 1453 }; 1454 let ranks = BTreeMap::from([( 1455 "hash".to_string(), 1456 vec![BurnLeaderRank { 1457 rank: 0, 1458 ticket_id: "ticket".to_string(), 1459 owner: "finalizer".to_string(), 1460 amount: 1, 1461 eligible_from_height: 1, 1462 eligible_until_height: 1, 1463 }], 1464 )]); 1465 1466 let ui = ui_block(block, &BTreeMap::new(), &ranks, None); 1467 1468 assert_eq!(ui.burn_bundle_quorum.burn_bundles_included, 1); 1469 assert_eq!(ui.burn_bundle_quorum.committee_size, 1); 1470 } 1471 1472 #[test] 1473 fn ui_block_burn_bundle_quorum_uses_included_lineage_attestations() { 1474 let block = Block { 1475 height: 1, 1476 prev_hash: "parent".to_string(), 1477 timestamp_ms: 1, 1478 miner: "finalizer".to_string(), 1479 reward_address: None, 1480 reward_address_signature: None, 1481 finalizer_mode: FinalizerMode::Ticket, 1482 finalizer_rank: 0, 1483 reward: 1, 1484 vdf_rounds: 1, 1485 vdf_output: "vdf".to_string(), 1486 leader_proof: None, 1487 burn_bundle_section: BurnBundleSection { 1488 signatures: vec![crate::domain::BurnBundleSignature { 1489 slot: 1, 1490 member: "member-1".to_string(), 1491 reward_address: None, 1492 signature: "sig-1".to_string(), 1493 }], 1494 burns: Vec::new(), 1495 burns_v2: Vec::new(), 1496 }, 1497 transactions: vec![burn("burn-a")], 1498 transactions_v2: Vec::new(), 1499 hash: "hash".to_string(), 1500 }; 1501 let ranks = BTreeMap::from([( 1502 "hash".to_string(), 1503 vec![ 1504 BurnLeaderRank { 1505 rank: 0, 1506 ticket_id: "ticket-0".to_string(), 1507 owner: "finalizer".to_string(), 1508 amount: 1, 1509 eligible_from_height: 1, 1510 eligible_until_height: 1, 1511 }, 1512 BurnLeaderRank { 1513 rank: 1, 1514 ticket_id: "ticket-1".to_string(), 1515 owner: "member-1".to_string(), 1516 amount: 1, 1517 eligible_from_height: 1, 1518 eligible_until_height: 1, 1519 }, 1520 BurnLeaderRank { 1521 rank: 2, 1522 ticket_id: "ticket-2".to_string(), 1523 owner: "finalizer".to_string(), 1524 amount: 1, 1525 eligible_from_height: 1, 1526 eligible_until_height: 1, 1527 }, 1528 BurnLeaderRank { 1529 rank: 3, 1530 ticket_id: "ticket-3".to_string(), 1531 owner: "member-1".to_string(), 1532 amount: 1, 1533 eligible_from_height: 1, 1534 eligible_until_height: 1, 1535 }, 1536 BurnLeaderRank { 1537 rank: 4, 1538 ticket_id: "ticket-4".to_string(), 1539 owner: "finalizer".to_string(), 1540 amount: 1, 1541 eligible_from_height: 1, 1542 eligible_until_height: 1, 1543 }, 1544 ], 1545 )]); 1546 1547 let ui = ui_block(block, &BTreeMap::new(), &ranks, None); 1548 1549 assert_eq!(ui.burn_bundle_quorum.burn_bundles_included, 2); 1550 assert_eq!(ui.burn_bundle_quorum.committee_size, 2); 1551 } 1552 1553 #[test] 1554 fn ui_block_burn_bundle_quorum_ignores_unrelated_ticket_rank_window() { 1555 let block = Block { 1556 height: 1, 1557 prev_hash: "parent".to_string(), 1558 timestamp_ms: 1, 1559 miner: "fallback".to_string(), 1560 reward_address: None, 1561 reward_address_signature: None, 1562 finalizer_mode: FinalizerMode::Ticket, 1563 finalizer_rank: 1, 1564 reward: 1, 1565 vdf_rounds: 1, 1566 vdf_output: "vdf".to_string(), 1567 leader_proof: None, 1568 burn_bundle_section: BurnBundleSection { 1569 signatures: vec![crate::domain::BurnBundleSignature { 1570 slot: 1, 1571 member: "member-1".to_string(), 1572 reward_address: None, 1573 signature: "sig-1".to_string(), 1574 }], 1575 burns: Vec::new(), 1576 burns_v2: Vec::new(), 1577 }, 1578 transactions: vec![burn("burn-a")], 1579 transactions_v2: Vec::new(), 1580 hash: "hash".to_string(), 1581 }; 1582 let ranks = BTreeMap::from([( 1583 "hash".to_string(), 1584 vec![ 1585 BurnLeaderRank { 1586 rank: 0, 1587 ticket_id: "ticket-0".to_string(), 1588 owner: "missed-primary".to_string(), 1589 amount: 1, 1590 eligible_from_height: 1, 1591 eligible_until_height: 1, 1592 }, 1593 BurnLeaderRank { 1594 rank: 1, 1595 ticket_id: "ticket-1".to_string(), 1596 owner: "fallback".to_string(), 1597 amount: 1, 1598 eligible_from_height: 1, 1599 eligible_until_height: 1, 1600 }, 1601 BurnLeaderRank { 1602 rank: 2, 1603 ticket_id: "ticket-2".to_string(), 1604 owner: "member-1".to_string(), 1605 amount: 1, 1606 eligible_from_height: 1, 1607 eligible_until_height: 1, 1608 }, 1609 BurnLeaderRank { 1610 rank: 3, 1611 ticket_id: "ticket-3".to_string(), 1612 owner: "member-2".to_string(), 1613 amount: 1, 1614 eligible_from_height: 1, 1615 eligible_until_height: 1, 1616 }, 1617 ], 1618 )]); 1619 1620 let ui = ui_block(block, &BTreeMap::new(), &ranks, None); 1621 1622 assert_eq!(ui.burn_bundle_quorum.burn_bundles_included, 2); 1623 assert_eq!(ui.burn_bundle_quorum.committee_size, 2); 1624 } 1625 }