iuna

iuna

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

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 }