iuna

iuna

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

ledger_builders.rs (58556B)


      1 use super::hex::{decode_hex, decode_hex_array, hex_encode};
      2 use super::ledger_ops::{compact_block_context, ensure_transaction_v2_fits_empty_block};
      3 use super::mining::mine_signature;
      4 use super::stratum::{
      5     hash_meets_difficulty, stratum_mine_header_bytes, stratum_mine_signature, stratum_mine_template,
      6 };
      7 use super::transaction::{UnsignedTxInput, UnsignedUtxoTransaction};
      8 use super::validation::validate_address;
      9 use super::{
     10     AddressNetwork, Amount, HYBRID_REWARD_ACTIVATION_HEIGHT, HybridAddressBranch, Ledger,
     11     LegacyTransactionId, MineSearchOutcome, OutPoint, SignatureScheme, StratumMineShare,
     12     StratumMineTemplate, Transaction, TransactionV2, TransactionV2Domain, TransactionV2Input,
     13     TransactionV2LegacyInput, TransactionV2Output, TxOutput, V2SpendingAuthorization,
     14     VersionedAddress, Wallet, decode_versioned_address, encode_versioned_address,
     15 };
     16 use anyhow::{Context, Result, bail};
     17 
     18 pub const HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT: u32 = 20;
     19 const MAX_DISCOVERED_EXTERNAL_ADDRESSES: u32 = 10_000;
     20 
     21 impl Ledger {
     22     pub fn wallet_receive_address(&self, wallet: &Wallet) -> Result<String> {
     23         let (_, address) = self
     24             .wallet_external_addresses(wallet)?
     25             .last()
     26             .copied()
     27             .context(
     28                 "wallet external address discovery did not return a current receive address",
     29             )?;
     30         encode_versioned_address(address, self.address_network())
     31     }
     32 
     33     pub fn wallet_reward_address(&self, wallet: &Wallet, _height: u64) -> String {
     34         let (_, address) = self
     35             .wallet_reward_addresses(wallet)
     36             .expect("valid chain has discoverable wallet reward addresses")
     37             .last()
     38             .copied()
     39             .expect("wallet reward discovery always returns a current address");
     40         encode_versioned_address(address, self.address_network())
     41             .expect("wallet reward address has a valid fixed-size commitment")
     42     }
     43 
     44     pub fn wallet_owned_hybrid_addresses(&self, wallet: &Wallet) -> Result<Vec<VersionedAddress>> {
     45         let mut addresses = self
     46             .wallet_external_addresses(wallet)?
     47             .into_iter()
     48             .map(|(_, address)| address)
     49             .collect::<Vec<_>>();
     50         if self.height() >= HYBRID_REWARD_ACTIVATION_HEIGHT {
     51             for (_, address) in self.wallet_reward_addresses(wallet)? {
     52                 if !addresses.contains(&address) {
     53                     addresses.push(address);
     54                 }
     55             }
     56         }
     57         Ok(addresses.into_iter().collect())
     58     }
     59 
     60     pub fn wallet_owned_hybrid_encoded_addresses(&self, wallet: &Wallet) -> Result<Vec<String>> {
     61         self.wallet_owned_hybrid_addresses(wallet)?
     62             .into_iter()
     63             .map(|address| encode_versioned_address(address, self.address_network()))
     64             .collect()
     65     }
     66 
     67     fn address_network(&self) -> AddressNetwork {
     68         AddressNetwork::from_profile_id(&self.launch_profile.profile_id)
     69     }
     70 
     71     fn wallet_external_addresses(&self, wallet: &Wallet) -> Result<Vec<(u32, VersionedAddress)>> {
     72         self.recover_historical_hybrid_address_cursors(wallet)?;
     73         let tip_changed = !wallet.external_discovery_tip_matches(self.tip_hash());
     74         let used = if tip_changed {
     75             self.used_hybrid_addresses()?
     76         } else {
     77             self.pending_hybrid_addresses()
     78         };
     79         let start = wallet.external_address_cursor();
     80         let mut index = start;
     81         let mut highest_used = None;
     82         let mut unused_run = 0_u32;
     83         while index < MAX_DISCOVERED_EXTERNAL_ADDRESSES {
     84             let address = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, index);
     85             if used.contains(&address) {
     86                 highest_used = Some(index);
     87                 unused_run = 0;
     88             } else {
     89                 unused_run = unused_run.saturating_add(1);
     90                 if unused_run >= HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT {
     91                     let current = highest_used
     92                         .and_then(|used| used.checked_add(1))
     93                         .unwrap_or(start);
     94                     wallet.advance_external_address_cursor(current);
     95                     if tip_changed {
     96                         wallet.mark_external_discovery_tip(self.tip_hash());
     97                     }
     98                     return Ok((0..=current)
     99                         .map(|index| {
    100                             (
    101                                 index,
    102                                 wallet.hybrid_versioned_address_at(
    103                                     HybridAddressBranch::External,
    104                                     index,
    105                                 ),
    106                             )
    107                         })
    108                         .collect());
    109                 }
    110             }
    111             index = index.saturating_add(1);
    112         }
    113         bail!(
    114             "wallet external address discovery exceeded {MAX_DISCOVERED_EXTERNAL_ADDRESSES} addresses"
    115         )
    116     }
    117 
    118     fn wallet_reward_addresses(&self, wallet: &Wallet) -> Result<Vec<(u32, VersionedAddress)>> {
    119         self.recover_historical_hybrid_address_cursors(wallet)?;
    120         let tip_changed = !wallet.reward_discovery_tip_matches(self.tip_hash());
    121         let spent = if tip_changed {
    122             self.spent_hybrid_addresses()?
    123         } else {
    124             self.pending_spent_hybrid_addresses()
    125         };
    126         let start = wallet.reward_address_cursor();
    127         let mut index = start;
    128         let mut highest_spent = None;
    129         let mut unspent_run = 0_u32;
    130         while index < MAX_DISCOVERED_EXTERNAL_ADDRESSES {
    131             let address = wallet.hybrid_versioned_address_at(HybridAddressBranch::Reward, index);
    132             if spent.contains(&address) {
    133                 highest_spent = Some(index);
    134                 unspent_run = 0;
    135             } else {
    136                 unspent_run = unspent_run.saturating_add(1);
    137                 if unspent_run >= HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT {
    138                     let current = highest_spent
    139                         .and_then(|spent| spent.checked_add(1))
    140                         .unwrap_or(start);
    141                     wallet.advance_reward_address_cursor(current);
    142                     if tip_changed {
    143                         wallet.mark_reward_discovery_tip(self.tip_hash());
    144                     }
    145                     return Ok((0..=current)
    146                         .map(|index| {
    147                             (
    148                                 index,
    149                                 wallet.hybrid_versioned_address_at(
    150                                     HybridAddressBranch::Reward,
    151                                     index,
    152                                 ),
    153                             )
    154                         })
    155                         .collect());
    156                 }
    157             }
    158             index = index.saturating_add(1);
    159         }
    160         bail!(
    161             "wallet reward address discovery exceeded {MAX_DISCOVERED_EXTERNAL_ADDRESSES} addresses"
    162         )
    163     }
    164 
    165     /// Restores cursors across gaps left by addresses issued to pending transactions that never
    166     /// confirmed. Hybrid authorizations retain the wallet's Ed25519 public key, so confirmed
    167     /// wallet-authored transactions provide an unambiguous recovery target without persisting
    168     /// secret child-key material.
    169     fn recover_historical_hybrid_address_cursors(&self, wallet: &Wallet) -> Result<()> {
    170         if wallet.historical_address_recovery_complete() {
    171             return Ok(());
    172         }
    173 
    174         let mut targets = self.wallet_authored_hybrid_addresses(wallet)?;
    175         let mut highest_external = None;
    176         let mut highest_reward = None;
    177         for index in 0..MAX_DISCOVERED_EXTERNAL_ADDRESSES {
    178             let external = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, index);
    179             if targets.contains(&external) {
    180                 targets.retain(|target| *target != external);
    181                 highest_external = Some(index);
    182             }
    183             let reward = wallet.hybrid_versioned_address_at(HybridAddressBranch::Reward, index);
    184             if targets.contains(&reward) {
    185                 targets.retain(|target| *target != reward);
    186                 highest_reward = Some(index);
    187             }
    188             if targets.is_empty() {
    189                 break;
    190             }
    191         }
    192 
    193         if let Some(index) = highest_external.and_then(|index| index.checked_add(1)) {
    194             wallet.advance_external_address_cursor(index);
    195         }
    196         if let Some(index) = highest_reward.and_then(|index| index.checked_add(1)) {
    197             wallet.advance_reward_address_cursor(index);
    198         }
    199         wallet.mark_historical_address_recovery_complete();
    200         Ok(())
    201     }
    202 
    203     fn wallet_authored_hybrid_addresses(&self, wallet: &Wallet) -> Result<Vec<VersionedAddress>> {
    204         let legacy_public_key = wallet.legacy_versioned_address().payload;
    205         let domain = self.transaction_v2_domain()?;
    206         let mut owned = Vec::new();
    207         for block in self.chain() {
    208             for envelope in &block.transactions_v2 {
    209                 let bytes = decode_hex(envelope).context("invalid confirmed transaction-v2 hex")?;
    210                 let (decoded_domain, transaction) = TransactionV2::decode(&bytes)?;
    211                 if decoded_domain != domain {
    212                     continue;
    213                 }
    214                 match &transaction {
    215                     TransactionV2::Migration {
    216                         outputs,
    217                         authorizations,
    218                         ..
    219                     } => {
    220                         if authorizations.iter().any(|authorization| {
    221                             authorization.scheme() == SignatureScheme::Ed25519
    222                                 && authorization.public_key().as_bytes() == legacy_public_key
    223                         }) {
    224                             for output in outputs {
    225                                 push_unique_address(&mut owned, output.address);
    226                             }
    227                         }
    228                     }
    229                     TransactionV2::Transfer {
    230                         inputs,
    231                         outputs,
    232                         authorizations,
    233                         ..
    234                     } => {
    235                         let authored = collect_wallet_hybrid_input_addresses(
    236                             inputs,
    237                             authorizations,
    238                             &legacy_public_key,
    239                             &mut owned,
    240                         );
    241                         if authored {
    242                             // Native and browser builders place the external recipient first and
    243                             // deterministic wallet change, when present, in subsequent outputs.
    244                             for output in outputs.iter().skip(1) {
    245                                 push_unique_address(&mut owned, output.address);
    246                             }
    247                         }
    248                     }
    249                     TransactionV2::Burn {
    250                         inputs,
    251                         change,
    252                         authorizations,
    253                         ..
    254                     } => {
    255                         if collect_wallet_hybrid_input_addresses(
    256                             inputs,
    257                             authorizations,
    258                             &legacy_public_key,
    259                             &mut owned,
    260                         ) {
    261                             for output in change {
    262                                 push_unique_address(&mut owned, output.address);
    263                             }
    264                         }
    265                     }
    266                     TransactionV2::Mine { .. } => {}
    267                 }
    268             }
    269         }
    270         Ok(owned)
    271     }
    272 
    273     fn used_hybrid_addresses(&self) -> Result<Vec<VersionedAddress>> {
    274         let mut used = self.pending_hybrid_addresses();
    275         let network = self.address_network();
    276         let domain = self.transaction_v2_domain()?;
    277         for block in self.chain() {
    278             if let Some(address) = block.reward_address.as_deref() {
    279                 if let Ok(address) = decode_versioned_address(address, network) {
    280                     if address.version == super::AddressVersion::HybridKeyCommitment {
    281                         push_unique_address(&mut used, address);
    282                     }
    283                 }
    284             }
    285             for transaction in &block.transactions {
    286                 collect_legacy_hybrid_outputs(transaction, network, &mut used);
    287             }
    288             for envelope in &block.transactions_v2 {
    289                 let bytes = decode_hex(envelope).context("invalid confirmed transaction-v2 hex")?;
    290                 let (decoded_domain, transaction) = TransactionV2::decode(&bytes)?;
    291                 if decoded_domain == domain {
    292                     collect_v2_output_addresses(&transaction, &mut used);
    293                 }
    294             }
    295         }
    296         Ok(used)
    297     }
    298 
    299     /// Returns every hybrid address that has appeared as a transaction or reward output,
    300     /// including unconfirmed outputs. External wallets use this to recover their deterministic
    301     /// receive cursor without exposing their seed or public-key material to the node.
    302     pub fn used_hybrid_encoded_addresses(&self) -> Result<Vec<String>> {
    303         let network = self.address_network();
    304         self.used_hybrid_addresses()?
    305             .into_iter()
    306             .map(|address| encode_versioned_address(address, network))
    307             .collect()
    308     }
    309 
    310     fn pending_hybrid_addresses(&self) -> Vec<VersionedAddress> {
    311         let mut used = Vec::new();
    312         let network = self.address_network();
    313         for transaction in &self.pending {
    314             collect_legacy_hybrid_outputs(transaction, network, &mut used);
    315         }
    316         for transaction in &self.pending_v2 {
    317             collect_v2_output_addresses(transaction, &mut used);
    318         }
    319         used
    320     }
    321 
    322     fn spent_hybrid_addresses(&self) -> Result<Vec<VersionedAddress>> {
    323         let mut spent = self.pending_spent_hybrid_addresses();
    324         let domain = self.transaction_v2_domain()?;
    325         for block in self.chain() {
    326             for envelope in &block.transactions_v2 {
    327                 let bytes = decode_hex(envelope).context("invalid confirmed transaction-v2 hex")?;
    328                 let (decoded_domain, transaction) = TransactionV2::decode(&bytes)?;
    329                 if decoded_domain == domain {
    330                     collect_v2_input_addresses(&transaction, &mut spent);
    331                 }
    332             }
    333         }
    334         Ok(spent)
    335     }
    336 
    337     fn pending_spent_hybrid_addresses(&self) -> Vec<VersionedAddress> {
    338         let mut spent = Vec::new();
    339         for transaction in &self.pending_v2 {
    340             collect_v2_input_addresses(transaction, &mut spent);
    341         }
    342         spent
    343     }
    344 
    345     /// Builds one consolidation transaction from every currently spendable legacy wallet output
    346     /// into the wallet's hybrid address. Submission remains subject to the height-3000 gate.
    347     pub fn build_v2_migration(&self, wallet: &Wallet, fee: Amount) -> Result<TransactionV2> {
    348         let available = self.available_utxos_for_address(wallet.address())?;
    349         if available.is_empty() {
    350             bail!("no legacy outputs are available for migration");
    351         }
    352         self.build_v2_migration_from_available(wallet, fee, &available, true)
    353     }
    354 
    355     /// Builds the largest deterministic prefix of legacy outputs that fits one block.
    356     pub fn build_v2_migration_batch(&self, wallet: &Wallet, fee: Amount) -> Result<TransactionV2> {
    357         let available = self.available_utxos_for_address(wallet.address())?;
    358         if available.is_empty() {
    359             bail!("no legacy outputs are available for migration");
    360         }
    361         let mut input_count = available.len().min(1_000);
    362         loop {
    363             let transaction = self.build_v2_migration_from_available(
    364                 wallet,
    365                 fee,
    366                 &available[..input_count],
    367                 false,
    368             )?;
    369             let bytes = transaction.encoded_size_bytes(&self.transaction_v2_domain()?)?;
    370             if ensure_v2_transaction_within_block_budget(
    371                 self,
    372                 &transaction,
    373                 &self.transaction_v2_domain()?,
    374                 self.launch_profile.max_block_bytes,
    375             )
    376             .is_ok()
    377             {
    378                 return Ok(transaction);
    379             }
    380             if input_count == 1 {
    381                 bail!(
    382                     "one-input migration requires {bytes} bytes and exceeds the {}-byte block budget",
    383                     self.launch_profile.max_block_bytes
    384                 );
    385             }
    386             let proportional = ((input_count as u128)
    387                 .saturating_mul(self.launch_profile.max_block_bytes as u128)
    388                 / bytes as u128) as usize;
    389             input_count = proportional.clamp(1, input_count - 1);
    390         }
    391     }
    392 
    393     fn build_v2_migration_from_available(
    394         &self,
    395         wallet: &Wallet,
    396         fee: Amount,
    397         available: &[(OutPoint, TxOutput)],
    398         enforce_block_budget: bool,
    399     ) -> Result<TransactionV2> {
    400         let mut total = 0_u64;
    401         let mut inputs = Vec::with_capacity(available.len());
    402         for (outpoint, output) in available {
    403             total = total
    404                 .checked_add(output.amount)
    405                 .context("migration input total overflows")?;
    406             inputs.push(TransactionV2LegacyInput {
    407                 outpoint_id: legacy_transaction_id(&outpoint.txid)?,
    408                 outpoint_index: outpoint.index,
    409                 owner: wallet.legacy_versioned_address(),
    410             });
    411         }
    412         let migrated_amount = total
    413             .checked_sub(fee)
    414             .context("migration fee exceeds available value")?;
    415         if migrated_amount == 0 {
    416             bail!("migration output must be greater than zero");
    417         }
    418 
    419         let domain = TransactionV2Domain::new(
    420             self.launch_profile.profile_id.clone(),
    421             decode_hex_array::<32>(self.genesis_hash())
    422                 .context("ledger genesis hash is not a 32-byte hexadecimal value")?,
    423         )?;
    424         let mut transaction = TransactionV2::Migration {
    425             inputs,
    426             outputs: vec![TransactionV2Output {
    427                 address: wallet.hybrid_versioned_address(),
    428                 amount: migrated_amount,
    429             }],
    430             fee,
    431             authorizations: Vec::new(),
    432         };
    433         let payload = transaction.signing_bytes(&domain)?;
    434         let authorization =
    435             wallet.sign_v2_authorization(wallet.legacy_versioned_address(), &payload)?;
    436         if let TransactionV2::Migration {
    437             inputs,
    438             authorizations,
    439             ..
    440         } = &mut transaction
    441         {
    442             authorizations.resize(inputs.len(), authorization);
    443         }
    444         transaction.verify_authorizations(&domain)?;
    445         if enforce_block_budget {
    446             ensure_v2_transaction_within_block_budget(
    447                 self,
    448                 &transaction,
    449                 &domain,
    450                 self.launch_profile.max_block_bytes,
    451             )?;
    452         }
    453         Ok(transaction)
    454     }
    455 
    456     pub fn build_v2_transfer(
    457         &self,
    458         wallet: &Wallet,
    459         recipient: VersionedAddress,
    460         amount: Amount,
    461         fee: Amount,
    462     ) -> Result<TransactionV2> {
    463         if recipient.version != super::AddressVersion::HybridKeyCommitment {
    464             bail!("transaction v2 recipient must use address v1");
    465         }
    466         if amount == 0 {
    467             bail!("transfer amount must be greater than zero");
    468         }
    469         let required = amount
    470             .checked_add(fee)
    471             .context("transfer amount plus fee overflows")?;
    472         let mut available = Vec::new();
    473         for owner in self.wallet_owned_hybrid_addresses(wallet)? {
    474             let owner_address = encode_versioned_address(owner, self.address_network())?;
    475             available.extend(
    476                 self.available_utxos_for_address(&owner_address)?
    477                     .into_iter()
    478                     .map(|(outpoint, output)| (outpoint, output, owner)),
    479             );
    480         }
    481         available.sort_by(|(left_point, left, _), (right_point, right, _)| {
    482             right
    483                 .amount
    484                 .cmp(&left.amount)
    485                 .then_with(|| left_point.cmp(right_point))
    486         });
    487 
    488         let mut total = 0_u64;
    489         let mut inputs = Vec::new();
    490         for (outpoint, output, owner) in available {
    491             total = total
    492                 .checked_add(output.amount)
    493                 .context("transaction v2 input total overflows")?;
    494             inputs.push(TransactionV2Input {
    495                 outpoint_txid: decode_hex_array::<32>(&outpoint.txid)
    496                     .context("transaction v2 outpoint ID must be a 32-byte hash")?,
    497                 outpoint_index: outpoint.index,
    498                 owner,
    499             });
    500             if total >= required {
    501                 break;
    502             }
    503         }
    504         if total < required {
    505             bail!("insufficient hybrid funds");
    506         }
    507 
    508         let mut outputs = vec![TransactionV2Output {
    509             address: recipient,
    510             amount,
    511         }];
    512         let change = total - required;
    513         if change > 0 {
    514             let change_address = self
    515                 .wallet_external_addresses(wallet)?
    516                 .last()
    517                 .map(|(_, address)| *address)
    518                 .context("wallet change address is unavailable")?;
    519             outputs.push(TransactionV2Output {
    520                 address: change_address,
    521                 amount: change,
    522             });
    523         }
    524         let domain = self.transaction_v2_domain()?;
    525         let mut transaction = TransactionV2::Transfer {
    526             inputs,
    527             outputs,
    528             fee,
    529             authorizations: Vec::new(),
    530         };
    531         let payload = transaction.signing_bytes(&domain)?;
    532         if let TransactionV2::Transfer {
    533             inputs,
    534             authorizations,
    535             ..
    536         } = &mut transaction
    537         {
    538             *authorizations = inputs
    539                 .iter()
    540                 .map(|input| wallet.sign_v2_authorization(input.owner, &payload))
    541                 .collect::<Result<Vec<_>>>()?;
    542         }
    543         transaction.verify_authorizations(&domain)?;
    544         ensure_v2_transaction_within_block_budget(
    545             self,
    546             &transaction,
    547             &domain,
    548             self.launch_profile.max_block_bytes,
    549         )?;
    550         Ok(transaction)
    551     }
    552 
    553     /// Builds a transaction-v2 transfer that consolidates exactly the selected hybrid outputs.
    554     /// Inputs may belong to different derived addresses owned by the same wallet.
    555     pub(crate) fn build_v2_consolidation_with_inputs(
    556         &self,
    557         wallet: &Wallet,
    558         amount: Amount,
    559         fee: Amount,
    560         outpoints: &[OutPoint],
    561     ) -> Result<TransactionV2> {
    562         if amount == 0 {
    563             bail!("transfer amount must be greater than zero");
    564         }
    565         let recipient = self
    566             .wallet_external_addresses(wallet)?
    567             .last()
    568             .map(|(_, address)| *address)
    569             .context("wallet receive address is unavailable")?;
    570 
    571         let mut available = Vec::new();
    572         for owner in self.wallet_owned_hybrid_addresses(wallet)? {
    573             let owner_address = encode_versioned_address(owner, self.address_network())?;
    574             available.extend(
    575                 self.available_utxos_for_address(&owner_address)?
    576                     .into_iter()
    577                     .map(|(outpoint, output)| (outpoint, output, owner)),
    578             );
    579         }
    580         let available = available
    581             .into_iter()
    582             .map(|(outpoint, output, owner)| (outpoint, (output, owner)))
    583             .collect::<std::collections::BTreeMap<_, _>>();
    584 
    585         let mut seen = std::collections::BTreeSet::new();
    586         let mut total = 0_u64;
    587         let mut inputs = Vec::with_capacity(outpoints.len());
    588         for outpoint in outpoints {
    589             if !seen.insert(outpoint) {
    590                 bail!("outputs changed or are reserved; review a new preview");
    591             }
    592             let (output, owner) = available
    593                 .get(outpoint)
    594                 .context("output is no longer an available hybrid output in this wallet")?;
    595             total = total
    596                 .checked_add(output.amount)
    597                 .context("transaction v2 input total overflows")?;
    598             inputs.push(TransactionV2Input {
    599                 outpoint_txid: decode_hex_array::<32>(&outpoint.txid)
    600                     .context("transaction v2 outpoint ID must be a 32-byte hash")?,
    601                 outpoint_index: outpoint.index,
    602                 owner: *owner,
    603             });
    604         }
    605         if total
    606             != amount
    607                 .checked_add(fee)
    608                 .context("amount plus fee overflows")?
    609         {
    610             bail!("selected hybrid outputs do not match amount plus fee");
    611         }
    612 
    613         let domain = self.transaction_v2_domain()?;
    614         let mut transaction = TransactionV2::Transfer {
    615             inputs,
    616             outputs: vec![TransactionV2Output {
    617                 address: recipient,
    618                 amount,
    619             }],
    620             fee,
    621             authorizations: Vec::new(),
    622         };
    623         let payload = transaction.signing_bytes(&domain)?;
    624         if let TransactionV2::Transfer {
    625             inputs,
    626             authorizations,
    627             ..
    628         } = &mut transaction
    629         {
    630             *authorizations = inputs
    631                 .iter()
    632                 .map(|input| wallet.sign_v2_authorization(input.owner, &payload))
    633                 .collect::<Result<Vec<_>>>()?;
    634         }
    635         transaction.verify_authorizations(&domain)?;
    636         ensure_v2_transaction_within_block_budget(
    637             self,
    638             &transaction,
    639             &domain,
    640             self.launch_profile.max_block_bytes,
    641         )?;
    642         Ok(transaction)
    643     }
    644 
    645     pub fn build_v2_burn(
    646         &self,
    647         wallet: &Wallet,
    648         amount: Amount,
    649         fee: Amount,
    650     ) -> Result<TransactionV2> {
    651         self.build_v2_burn_with_anchor(wallet, amount, fee, self.tip_hash())
    652     }
    653 
    654     pub(crate) fn build_v2_burn_for_next_block(
    655         &self,
    656         wallet: &Wallet,
    657         amount: Amount,
    658         fee: Amount,
    659     ) -> Result<TransactionV2> {
    660         self.build_v2_burn_with_anchor(wallet, amount, fee, &self.tip().prev_hash)
    661     }
    662 
    663     fn build_v2_burn_with_anchor(
    664         &self,
    665         wallet: &Wallet,
    666         amount: Amount,
    667         fee: Amount,
    668         anchor: &str,
    669     ) -> Result<TransactionV2> {
    670         if amount == 0 {
    671             bail!("burn amount must be greater than zero");
    672         }
    673         let required = amount
    674             .checked_add(fee)
    675             .context("burn amount plus fee overflows")?;
    676         let mut selected = None;
    677         for owner in self.wallet_owned_hybrid_addresses(wallet)? {
    678             let owner_address = encode_versioned_address(owner, self.address_network())?;
    679             let mut available = self.available_utxos_for_address(&owner_address)?;
    680             available.sort_by(|(left_point, left), (right_point, right)| {
    681                 right
    682                     .amount
    683                     .cmp(&left.amount)
    684                     .then_with(|| left_point.cmp(right_point))
    685             });
    686             let total = available.iter().try_fold(0_u64, |total, (_, output)| {
    687                 total
    688                     .checked_add(output.amount)
    689                     .context("transaction v2 input total overflows")
    690             })?;
    691             if total >= required {
    692                 selected = Some((owner, available));
    693                 break;
    694             }
    695         }
    696         let Some((owner, available)) = selected else {
    697             bail!("insufficient hybrid funds in one address for burn");
    698         };
    699         let mut total = 0_u64;
    700         let mut inputs = Vec::new();
    701         for (outpoint, output) in available {
    702             total = total
    703                 .checked_add(output.amount)
    704                 .context("transaction v2 input total overflows")?;
    705             inputs.push(TransactionV2Input {
    706                 outpoint_txid: decode_hex_array::<32>(&outpoint.txid)
    707                     .context("transaction v2 outpoint ID must be a 32-byte hash")?,
    708                 outpoint_index: outpoint.index,
    709                 owner,
    710             });
    711             if total >= required {
    712                 break;
    713             }
    714         }
    715         if total < required {
    716             bail!("insufficient hybrid funds");
    717         }
    718         let change_amount = total - required;
    719         let change_address = self
    720             .wallet_external_addresses(wallet)?
    721             .last()
    722             .map(|(_, address)| *address)
    723             .context("wallet change address is unavailable")?;
    724         let change = (change_amount > 0)
    725             .then_some(TransactionV2Output {
    726                 address: change_address,
    727                 amount: change_amount,
    728             })
    729             .into_iter()
    730             .collect();
    731         let domain = self.transaction_v2_domain()?;
    732         let mut transaction = TransactionV2::Burn {
    733             inputs,
    734             change,
    735             amount,
    736             fee,
    737             anchor: Some(
    738                 decode_hex_array::<32>(anchor).context("transaction v2 burn anchor is invalid")?,
    739             ),
    740             authorizations: Vec::new(),
    741         };
    742         let payload = transaction.signing_bytes(&domain)?;
    743         let authorization = wallet.sign_v2_authorization(owner, &payload)?;
    744         if let TransactionV2::Burn {
    745             inputs,
    746             authorizations,
    747             ..
    748         } = &mut transaction
    749         {
    750             authorizations.resize(inputs.len(), authorization);
    751         }
    752         transaction.verify_authorizations(&domain)?;
    753         ensure_v2_transaction_within_block_budget(
    754             self,
    755             &transaction,
    756             &domain,
    757             self.launch_profile.max_block_bytes,
    758         )?;
    759         Ok(transaction)
    760     }
    761 
    762     pub fn build_transfer(
    763         &self,
    764         wallet: &Wallet,
    765         to: impl Into<String>,
    766         amount: Amount,
    767         fee: Amount,
    768     ) -> Result<Transaction> {
    769         let to = to.into();
    770         validate_address(&to, "transfer recipient")?;
    771         let required = amount
    772             .checked_add(fee)
    773             .context("transfer amount plus fee overflows")?;
    774         let mut available = self.available_utxos_for_address(wallet.address())?;
    775         // Prefer the smallest sufficient single output. Otherwise minimize input count.
    776         if let Some((point, _)) = available
    777             .iter()
    778             .filter(|(_, output)| output.amount >= required)
    779             .min_by_key(|(point, output)| (output.amount, point))
    780         {
    781             return self.build_transfer_with_inputs(
    782                 wallet,
    783                 to,
    784                 amount,
    785                 fee,
    786                 std::slice::from_ref(point),
    787             );
    788         }
    789         available.sort_by(|(left_point, left), (right_point, right)| {
    790             right
    791                 .amount
    792                 .cmp(&left.amount)
    793                 .then_with(|| left_point.cmp(right_point))
    794         });
    795         let mut total = 0_u64;
    796         let mut points = Vec::new();
    797         for (point, output) in available {
    798             total = total
    799                 .checked_add(output.amount)
    800                 .context("selected input total overflows")?;
    801             points.push(point);
    802             if total >= required {
    803                 break;
    804             }
    805         }
    806         if total < required {
    807             bail!("insufficient funds for {}", wallet.address());
    808         }
    809         self.build_transfer_with_inputs(wallet, to, amount, fee, &points)
    810     }
    811 
    812     pub fn build_transfer_with_inputs(
    813         &self,
    814         wallet: &Wallet,
    815         to: impl Into<String>,
    816         amount: Amount,
    817         fee: Amount,
    818         outpoints: &[OutPoint],
    819     ) -> Result<Transaction> {
    820         let to = to.into();
    821         validate_address(&to, "transfer recipient")?;
    822         let required = amount
    823             .checked_add(fee)
    824             .context("transfer amount plus fee overflows")?;
    825         let (inputs, input_total) =
    826             self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?;
    827         let mut outputs = vec![TxOutput {
    828             address: to,
    829             amount,
    830         }];
    831         let change = input_total
    832             .checked_sub(required)
    833             .context("selected inputs do not cover transfer")?;
    834         if change > 0 {
    835             outputs.push(TxOutput {
    836                 address: wallet.address().to_string(),
    837                 amount: change,
    838             });
    839         }
    840         let transaction = UnsignedUtxoTransaction::Transfer {
    841             inputs,
    842             outputs,
    843             fee,
    844         }
    845         .sign(wallet, &self.transaction_signing_domain())?;
    846         self.validate_new_transaction(&transaction)?;
    847         Ok(transaction)
    848     }
    849 
    850     pub fn build_burn(&self, wallet: &Wallet, amount: Amount, fee: Amount) -> Result<Transaction> {
    851         let required = amount
    852             .checked_add(fee)
    853             .context("burn amount plus fee overflows")?;
    854         let (inputs, input_total) = self.select_inputs(wallet.address(), required)?;
    855         self.build_burn_from_inputs(
    856             wallet,
    857             amount,
    858             fee,
    859             inputs,
    860             input_total,
    861             self.public_burn_anchor(),
    862         )
    863     }
    864 
    865     pub fn build_burn_with_inputs(
    866         &self,
    867         wallet: &Wallet,
    868         amount: Amount,
    869         fee: Amount,
    870         outpoints: &[OutPoint],
    871     ) -> Result<Transaction> {
    872         let required = amount
    873             .checked_add(fee)
    874             .context("burn amount plus fee overflows")?;
    875         let (inputs, input_total) =
    876             self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?;
    877         self.build_burn_from_inputs(
    878             wallet,
    879             amount,
    880             fee,
    881             inputs,
    882             input_total,
    883             self.public_burn_anchor(),
    884         )
    885     }
    886 
    887     #[cfg(test)]
    888     pub(crate) fn build_burn_for_next_block(
    889         &self,
    890         wallet: &Wallet,
    891         amount: Amount,
    892         fee: Amount,
    893     ) -> Result<Transaction> {
    894         let required = amount
    895             .checked_add(fee)
    896             .context("burn amount plus fee overflows")?;
    897         let (inputs, input_total) = self.select_inputs(wallet.address(), required)?;
    898         self.build_burn_from_inputs(
    899             wallet,
    900             amount,
    901             fee,
    902             inputs,
    903             input_total,
    904             self.next_block_burn_anchor(),
    905         )
    906     }
    907 
    908     pub(crate) fn build_burn_for_next_block_with_inputs(
    909         &self,
    910         wallet: &Wallet,
    911         amount: Amount,
    912         fee: Amount,
    913         outpoints: &[OutPoint],
    914     ) -> Result<Transaction> {
    915         let required = amount
    916             .checked_add(fee)
    917             .context("burn amount plus fee overflows")?;
    918         let (inputs, input_total) =
    919             self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?;
    920         self.build_burn_from_inputs(
    921             wallet,
    922             amount,
    923             fee,
    924             inputs,
    925             input_total,
    926             self.next_block_burn_anchor(),
    927         )
    928     }
    929 
    930     fn build_burn_from_inputs(
    931         &self,
    932         wallet: &Wallet,
    933         amount: Amount,
    934         fee: Amount,
    935         inputs: Vec<UnsignedTxInput>,
    936         input_total: Amount,
    937         anchor: Option<String>,
    938     ) -> Result<Transaction> {
    939         let signing_height = if anchor.as_deref() == Some(self.tip_hash()) {
    940             self.height().saturating_add(2)
    941         } else {
    942             self.height().saturating_add(1)
    943         };
    944         let required = amount
    945             .checked_add(fee)
    946             .context("burn amount plus fee overflows")?;
    947         let change_amount = input_total
    948             .checked_sub(required)
    949             .context("selected inputs do not cover burn")?;
    950         let change = if change_amount > 0 {
    951             vec![TxOutput {
    952                 address: wallet.address().to_string(),
    953                 amount: change_amount,
    954             }]
    955         } else {
    956             Vec::new()
    957         };
    958         let transaction = UnsignedUtxoTransaction::Burn {
    959             inputs,
    960             change,
    961             amount,
    962             fee,
    963             anchor,
    964         }
    965         .sign(wallet, &self.transaction_signing_domain_at(signing_height))?;
    966         self.validate_new_transaction(&transaction)?;
    967         Ok(transaction)
    968     }
    969 
    970     fn public_burn_anchor(&self) -> Option<String> {
    971         // Public burns enter a one-block queue: a burn signed at tip H is
    972         // eligible in the block after H's direct child.
    973         (self.height().saturating_add(2) >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT)
    974             .then(|| self.tip().hash.clone())
    975     }
    976 
    977     fn next_block_burn_anchor(&self) -> Option<String> {
    978         // A finalizer learns its role only after the parent exists, so its
    979         // mandatory local burn is signed directly against that parent's parent.
    980         (self.height().saturating_add(1) >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT)
    981             .then(|| self.tip().prev_hash.clone())
    982     }
    983 
    984     pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> {
    985         let recipient = recipient.into();
    986         self.validate_mine_reward_address(&recipient)?;
    987         let anchor = self.tip().hash.clone();
    988         let salt = 1;
    989         let difficulty_bits = self.current_mine_difficulty_bits();
    990         let signing_domain = self.transaction_signing_domain();
    991         for nonce in 0..u64::MAX {
    992             let signature = mine_signature(
    993                 &signing_domain,
    994                 &recipient,
    995                 &anchor,
    996                 salt,
    997                 nonce,
    998                 difficulty_bits,
    999             )?;
   1000             if !hash_meets_difficulty(&signature, difficulty_bits) {
   1001                 continue;
   1002             }
   1003             let transaction = Transaction::Mine {
   1004                 recipient: recipient.clone(),
   1005                 anchor: anchor.clone(),
   1006                 salt,
   1007                 nonce,
   1008                 difficulty_bits,
   1009                 proof_header: None,
   1010                 signature,
   1011             };
   1012             if self.has_transaction(transaction.signature()) {
   1013                 continue;
   1014             }
   1015             self.validate_new_transaction(&transaction)?;
   1016             return Ok(transaction);
   1017         }
   1018         bail!("could not find valid mine proof");
   1019     }
   1020 
   1021     pub fn search_mine(
   1022         &self,
   1023         recipient: impl Into<String>,
   1024         salt: u64,
   1025         start_nonce: u64,
   1026         max_attempts: u64,
   1027     ) -> Result<MineSearchOutcome> {
   1028         let recipient = recipient.into();
   1029         self.validate_mine_reward_address(&recipient)?;
   1030         let anchor = self.tip().hash.clone();
   1031         let difficulty_bits = self.current_mine_difficulty_bits();
   1032         let signing_domain = self.transaction_signing_domain();
   1033         let mut attempts = 0_u64;
   1034         let mut nonce = start_nonce;
   1035         while attempts < max_attempts {
   1036             let signature = mine_signature(
   1037                 &signing_domain,
   1038                 &recipient,
   1039                 &anchor,
   1040                 salt,
   1041                 nonce,
   1042                 difficulty_bits,
   1043             )?;
   1044             attempts = attempts.saturating_add(1);
   1045             let next_nonce = nonce.checked_add(1).unwrap_or(0);
   1046             if hash_meets_difficulty(&signature, difficulty_bits) {
   1047                 let transaction = Transaction::Mine {
   1048                     recipient: recipient.clone(),
   1049                     anchor: anchor.clone(),
   1050                     salt,
   1051                     nonce,
   1052                     difficulty_bits,
   1053                     proof_header: None,
   1054                     signature,
   1055                 };
   1056                 if !self.has_transaction(transaction.signature()) {
   1057                     self.validate_new_transaction(&transaction)?;
   1058                     return Ok(MineSearchOutcome {
   1059                         transaction: Some(transaction),
   1060                         next_nonce,
   1061                         attempts,
   1062                     });
   1063                 }
   1064             }
   1065             nonce = next_nonce;
   1066         }
   1067         Ok(MineSearchOutcome {
   1068             transaction: None,
   1069             next_nonce: nonce,
   1070             attempts,
   1071         })
   1072     }
   1073 
   1074     pub fn stratum_mine_template(
   1075         &self,
   1076         recipient: impl Into<String>,
   1077         anchor: impl AsRef<str>,
   1078         salt: u64,
   1079         difficulty_bits: u32,
   1080     ) -> Result<StratumMineTemplate> {
   1081         let recipient = recipient.into();
   1082         self.validate_mine_reward_address(&recipient)?;
   1083         stratum_mine_template(
   1084             &self.transaction_signing_domain(),
   1085             recipient,
   1086             anchor.as_ref(),
   1087             salt,
   1088             difficulty_bits,
   1089         )
   1090     }
   1091 
   1092     fn validate_mine_reward_address(&self, recipient: &str) -> Result<()> {
   1093         let height = self.height().saturating_add(1);
   1094         if height < super::HYBRID_REWARD_ACTIVATION_HEIGHT {
   1095             return validate_address(recipient, "mine recipient");
   1096         }
   1097         let address = super::decode_versioned_address(
   1098             recipient,
   1099             AddressNetwork::from_profile_id(&self.launch_profile.profile_id),
   1100         )?;
   1101         if address.version != super::AddressVersion::HybridKeyCommitment {
   1102             bail!("mine reward must use a hybrid address at height {height}");
   1103         }
   1104         Ok(())
   1105     }
   1106 
   1107     pub fn build_stratum_mine(
   1108         &self,
   1109         template: StratumMineTemplate,
   1110         share: StratumMineShare,
   1111     ) -> Result<Transaction> {
   1112         let nonce = super::stratum::pack_stratum_nonce(share.extranonce2, share.header_nonce);
   1113         let header = stratum_mine_header_bytes(
   1114             &self.transaction_signing_domain(),
   1115             &template.recipient,
   1116             &template.anchor,
   1117             template.salt,
   1118             nonce,
   1119             template.difficulty_bits,
   1120         )?;
   1121         let transaction = Transaction::Mine {
   1122             recipient: template.recipient,
   1123             anchor: template.anchor,
   1124             salt: template.salt,
   1125             nonce,
   1126             difficulty_bits: template.difficulty_bits,
   1127             proof_header: Some(hex_encode(header)),
   1128             signature: stratum_mine_signature(&header),
   1129         };
   1130         self.validate_new_transaction(&transaction)?;
   1131         Ok(transaction)
   1132     }
   1133 }
   1134 
   1135 fn collect_legacy_hybrid_outputs(
   1136     transaction: &Transaction,
   1137     network: AddressNetwork,
   1138     used: &mut Vec<VersionedAddress>,
   1139 ) {
   1140     for output in transaction.outputs() {
   1141         if let Ok(address) = decode_versioned_address(&output.address, network) {
   1142             if address.version == super::AddressVersion::HybridKeyCommitment {
   1143                 push_unique_address(used, address);
   1144             }
   1145         }
   1146     }
   1147 }
   1148 
   1149 fn collect_v2_output_addresses(transaction: &TransactionV2, used: &mut Vec<VersionedAddress>) {
   1150     let outputs = match transaction {
   1151         TransactionV2::Migration { outputs, .. } | TransactionV2::Transfer { outputs, .. } => {
   1152             outputs.as_slice()
   1153         }
   1154         TransactionV2::Burn { change, .. } => change.as_slice(),
   1155         TransactionV2::Mine { recipient, .. } => {
   1156             push_unique_address(used, *recipient);
   1157             return;
   1158         }
   1159     };
   1160     for output in outputs {
   1161         push_unique_address(used, output.address);
   1162     }
   1163 }
   1164 
   1165 fn collect_v2_input_addresses(transaction: &TransactionV2, spent: &mut Vec<VersionedAddress>) {
   1166     let inputs = match transaction {
   1167         TransactionV2::Transfer { inputs, .. } | TransactionV2::Burn { inputs, .. } => {
   1168             inputs.as_slice()
   1169         }
   1170         TransactionV2::Migration { .. } | TransactionV2::Mine { .. } => return,
   1171     };
   1172     for input in inputs {
   1173         push_unique_address(spent, input.owner);
   1174     }
   1175 }
   1176 
   1177 fn collect_wallet_hybrid_input_addresses(
   1178     inputs: &[TransactionV2Input],
   1179     authorizations: &[V2SpendingAuthorization],
   1180     legacy_public_key: &[u8; 32],
   1181     owned: &mut Vec<VersionedAddress>,
   1182 ) -> bool {
   1183     let mut authored = false;
   1184     for (input, authorization) in inputs.iter().zip(authorizations) {
   1185         let public_key = authorization.public_key().as_bytes();
   1186         if authorization.scheme() == SignatureScheme::HybridEd25519MlDsa44
   1187             && public_key.get(..legacy_public_key.len()) == Some(legacy_public_key)
   1188         {
   1189             push_unique_address(owned, input.owner);
   1190             authored = true;
   1191         }
   1192     }
   1193     authored
   1194 }
   1195 
   1196 fn push_unique_address(addresses: &mut Vec<VersionedAddress>, address: VersionedAddress) {
   1197     if !addresses.contains(&address) {
   1198         addresses.push(address);
   1199     }
   1200 }
   1201 
   1202 fn legacy_transaction_id(txid: &str) -> Result<LegacyTransactionId> {
   1203     let bytes = decode_hex(txid).context("legacy outpoint ID is not hexadecimal")?;
   1204     match bytes.len() {
   1205         32 => Ok(LegacyTransactionId::Hash(
   1206             bytes.try_into().expect("checked legacy hash length"),
   1207         )),
   1208         64 => Ok(LegacyTransactionId::Signature(
   1209             bytes.try_into().expect("checked legacy signature length"),
   1210         )),
   1211         length => bail!("legacy outpoint ID must contain 32 or 64 bytes, got {length}"),
   1212     }
   1213 }
   1214 
   1215 fn ensure_v2_transaction_within_block_budget(
   1216     ledger: &Ledger,
   1217     transaction: &TransactionV2,
   1218     domain: &TransactionV2Domain,
   1219     max_block_bytes: usize,
   1220 ) -> Result<()> {
   1221     let transaction_bytes = transaction.encode(domain)?.len();
   1222     if transaction_bytes > max_block_bytes {
   1223         bail!(
   1224             "transaction v2 requires {transaction_bytes} bytes and exceeds the {max_block_bytes}-byte block budget"
   1225         );
   1226     }
   1227     ensure_transaction_v2_fits_empty_block(
   1228         compact_block_context(ledger),
   1229         &hex_encode(transaction.encode(domain)?),
   1230         max_block_bytes,
   1231     )?;
   1232     Ok(())
   1233 }
   1234 
   1235 #[cfg(test)]
   1236 mod v2_migration_tests {
   1237     use std::collections::BTreeMap;
   1238 
   1239     use super::*;
   1240 
   1241     #[test]
   1242     fn mine_reward_destination_switches_to_hybrid_at_3750() {
   1243         let wallet = Wallet::from_seed("hybrid-mine-reward-wallet");
   1244         let mut ledger = Ledger::new(BTreeMap::new(), 1);
   1245         ledger.chain.last_mut().unwrap().height = super::super::HYBRID_REWARD_ACTIVATION_HEIGHT - 2;
   1246         assert!(
   1247             ledger
   1248                 .validate_mine_reward_address(wallet.address())
   1249                 .is_ok()
   1250         );
   1251 
   1252         ledger.chain.last_mut().unwrap().height = super::super::HYBRID_REWARD_ACTIVATION_HEIGHT - 1;
   1253         assert!(
   1254             ledger
   1255                 .validate_mine_reward_address(wallet.address())
   1256                 .is_err()
   1257         );
   1258         assert!(
   1259             ledger
   1260                 .validate_mine_reward_address(&wallet.hybrid_address(AddressNetwork::Mainnet))
   1261                 .is_ok()
   1262         );
   1263     }
   1264 
   1265     #[test]
   1266     fn receive_and_change_advance_after_the_current_external_address_is_used() {
   1267         let wallet = Wallet::from_seed("rotating-external-wallet");
   1268         let recipient = Wallet::from_seed("rotating-external-recipient");
   1269         let mut ledger = Ledger::new(BTreeMap::new(), 1);
   1270         let owner = wallet.hybrid_versioned_address();
   1271         let initial_receive = ledger.wallet_receive_address(&wallet).unwrap();
   1272         assert_eq!(
   1273             initial_receive,
   1274             wallet.hybrid_address(AddressNetwork::Mainnet)
   1275         );
   1276 
   1277         ledger.pending_v2.push(TransactionV2::Migration {
   1278             inputs: Vec::new(),
   1279             outputs: vec![TransactionV2Output {
   1280                 address: owner,
   1281                 amount: 100,
   1282             }],
   1283             fee: 0,
   1284             authorizations: Vec::new(),
   1285         });
   1286         let rotated_receive = ledger.wallet_receive_address(&wallet).unwrap();
   1287         assert_eq!(
   1288             rotated_receive,
   1289             wallet.hybrid_address_at(HybridAddressBranch::External, 1, AddressNetwork::Mainnet)
   1290         );
   1291         let restored = Wallet::from_seed("rotating-external-wallet");
   1292         assert_eq!(
   1293             ledger.wallet_receive_address(&restored).unwrap(),
   1294             rotated_receive
   1295         );
   1296 
   1297         ledger.utxos.insert(
   1298             OutPoint {
   1299                 txid: "42".repeat(32),
   1300                 index: 0,
   1301             },
   1302             TxOutput {
   1303                 address: initial_receive,
   1304                 amount: 100,
   1305             },
   1306         );
   1307         let transaction = ledger
   1308             .build_v2_transfer(&wallet, recipient.hybrid_versioned_address(), 40, 2)
   1309             .unwrap();
   1310         let TransactionV2::Transfer {
   1311             outputs,
   1312             authorizations,
   1313             ..
   1314         } = transaction
   1315         else {
   1316             panic!("builder returned a non-transfer transaction");
   1317         };
   1318         assert_eq!(
   1319             outputs[1].address,
   1320             wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 1,)
   1321         );
   1322         assert_eq!(authorizations[0].committed_address().unwrap(), owner);
   1323     }
   1324 
   1325     #[test]
   1326     fn reward_address_rotates_after_its_hybrid_key_is_revealed_by_a_spend() {
   1327         let wallet = Wallet::from_seed("rotating-reward-wallet");
   1328         let mut ledger = Ledger::new(BTreeMap::new(), 1);
   1329         let activation = super::super::HYBRID_REWARD_ACTIVATION_HEIGHT;
   1330 
   1331         let first = ledger.wallet_reward_address(&wallet, activation);
   1332         assert_eq!(
   1333             first,
   1334             ledger.wallet_reward_address(&wallet, activation + 1_000)
   1335         );
   1336         let first_owner = wallet.hybrid_versioned_address_at(HybridAddressBranch::Reward, 0);
   1337         ledger.pending_v2.push(TransactionV2::Burn {
   1338             inputs: vec![TransactionV2Input {
   1339                 outpoint_txid: [0x42; 32],
   1340                 outpoint_index: 0,
   1341                 owner: first_owner,
   1342             }],
   1343             change: Vec::new(),
   1344             amount: 1,
   1345             fee: 1,
   1346             anchor: Some([0x24; 32]),
   1347             authorizations: Vec::new(),
   1348         });
   1349         let second = ledger.wallet_reward_address(&wallet, activation + 1);
   1350         assert_ne!(first, second);
   1351         let restored = Wallet::from_seed("rotating-reward-wallet");
   1352         assert_eq!(
   1353             ledger.wallet_reward_address(&restored, activation + 1),
   1354             second
   1355         );
   1356 
   1357         ledger.chain.last_mut().unwrap().height = activation;
   1358         let owned = ledger
   1359             .wallet_owned_hybrid_encoded_addresses(&wallet)
   1360             .unwrap();
   1361         assert!(owned.contains(&first));
   1362         assert!(owned.contains(&second));
   1363     }
   1364 
   1365     #[test]
   1366     fn seed_recovery_discovers_used_external_addresses_across_a_gap() {
   1367         let wallet = Wallet::from_seed("external-gap-recovery-wallet");
   1368         let mut ledger = Ledger::new(BTreeMap::new(), 1);
   1369         let used_after_gap = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 2);
   1370         ledger.pending_v2.push(TransactionV2::Transfer {
   1371             inputs: Vec::new(),
   1372             outputs: vec![TransactionV2Output {
   1373                 address: used_after_gap,
   1374                 amount: 10,
   1375             }],
   1376             fee: 0,
   1377             authorizations: Vec::new(),
   1378         });
   1379 
   1380         let restored = Wallet::from_seed("external-gap-recovery-wallet");
   1381         assert_eq!(
   1382             ledger.wallet_receive_address(&restored).unwrap(),
   1383             restored.hybrid_address_at(HybridAddressBranch::External, 3, AddressNetwork::Mainnet,)
   1384         );
   1385         assert!(
   1386             ledger
   1387                 .wallet_owned_hybrid_addresses(&restored)
   1388                 .unwrap()
   1389                 .contains(&used_after_gap)
   1390         );
   1391     }
   1392 
   1393     #[test]
   1394     fn seed_recovery_crosses_abandoned_pending_address_gap_after_restart() {
   1395         let seed = "abandoned-pending-gap-recovery-wallet";
   1396         let signing_wallet = Wallet::from_seed(seed);
   1397         let mut ledger = Ledger::new(BTreeMap::new(), 1);
   1398         let source_index = HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT + 7;
   1399         let source =
   1400             signing_wallet.hybrid_versioned_address_at(HybridAddressBranch::External, source_index);
   1401         let change = signing_wallet
   1402             .hybrid_versioned_address_at(HybridAddressBranch::External, source_index + 1);
   1403         let mut burn = TransactionV2::Burn {
   1404             inputs: vec![TransactionV2Input {
   1405                 outpoint_txid: [0x42; 32],
   1406                 outpoint_index: 0,
   1407                 owner: source,
   1408             }],
   1409             change: vec![TransactionV2Output {
   1410                 address: change,
   1411                 amount: 98,
   1412             }],
   1413             amount: 1,
   1414             fee: 1,
   1415             anchor: None,
   1416             authorizations: Vec::new(),
   1417         };
   1418         let domain = ledger.transaction_v2_domain().unwrap();
   1419         let payload = burn.signing_bytes(&domain).unwrap();
   1420         let authorization = signing_wallet
   1421             .sign_v2_authorization(source, &payload)
   1422             .unwrap();
   1423         let TransactionV2::Burn { authorizations, .. } = &mut burn else {
   1424             unreachable!();
   1425         };
   1426         authorizations.push(authorization);
   1427         ledger
   1428             .chain
   1429             .last_mut()
   1430             .unwrap()
   1431             .transactions_v2
   1432             .push(hex_encode(burn.encode(&domain).unwrap()));
   1433 
   1434         let restored = Wallet::from_seed(seed);
   1435         let owned = ledger.wallet_owned_hybrid_addresses(&restored).unwrap();
   1436         assert!(owned.contains(&source));
   1437         assert!(owned.contains(&change));
   1438         assert_eq!(
   1439             ledger.wallet_receive_address(&restored).unwrap(),
   1440             restored.hybrid_address_at(
   1441                 HybridAddressBranch::External,
   1442                 source_index + 2,
   1443                 AddressNetwork::Mainnet,
   1444             )
   1445         );
   1446     }
   1447 
   1448     #[test]
   1449     fn migration_builder_consolidates_legacy_value_into_one_hybrid_output() {
   1450         let wallet = Wallet::from_seed("v2-migration-builder-wallet");
   1451         let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
   1452 
   1453         let transaction = ledger.build_v2_migration(&wallet, 3).unwrap();
   1454         let TransactionV2::Migration {
   1455             inputs,
   1456             outputs,
   1457             fee,
   1458             authorizations,
   1459         } = &transaction
   1460         else {
   1461             panic!("builder returned a non-migration transaction");
   1462         };
   1463         assert_eq!(inputs.len(), 1);
   1464         assert!(matches!(
   1465             inputs[0].outpoint_id,
   1466             LegacyTransactionId::Hash(_)
   1467         ));
   1468         assert_eq!(outputs.len(), 1);
   1469         assert_eq!(outputs[0].address, wallet.hybrid_versioned_address());
   1470         assert_eq!(outputs[0].amount, 97);
   1471         assert_eq!(*fee, 3);
   1472         assert_eq!(authorizations.len(), inputs.len());
   1473 
   1474         let domain = TransactionV2Domain::new(
   1475             ledger.launch_profile.profile_id.clone(),
   1476             decode_hex_array::<32>(ledger.genesis_hash()).unwrap(),
   1477         )
   1478         .unwrap();
   1479         transaction.verify_authorizations(&domain).unwrap();
   1480         let encoded = transaction.encode(&domain).unwrap();
   1481         assert_eq!(
   1482             TransactionV2::decode(&encoded).unwrap(),
   1483             (domain, transaction)
   1484         );
   1485     }
   1486 
   1487     #[test]
   1488     fn migration_builder_rejects_a_transaction_larger_than_the_block_budget() {
   1489         let wallet = Wallet::from_seed("v2-oversized-migration-wallet");
   1490         let profile = crate::domain::LaunchProfile {
   1491             max_block_bytes: 1,
   1492             ..crate::domain::LaunchProfile::default()
   1493         };
   1494         let ledger = Ledger::new_with_genesis_burns_and_profile(
   1495             BTreeMap::from([(wallet.address().to_string(), 100)]),
   1496             Vec::new(),
   1497             1,
   1498             profile,
   1499         )
   1500         .unwrap();
   1501 
   1502         let error = ledger.build_v2_migration(&wallet, 1).unwrap_err();
   1503         assert!(
   1504             error
   1505                 .to_string()
   1506                 .contains("exceeds the 1-byte block budget")
   1507         );
   1508     }
   1509 
   1510     #[test]
   1511     fn hybrid_transfer_builder_spends_migrated_value_and_returns_hybrid_change() {
   1512         let wallet = Wallet::from_seed("v2-transfer-builder-wallet");
   1513         let recipient = Wallet::from_seed("v2-transfer-builder-recipient");
   1514         let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
   1515         let migration = ledger.build_v2_migration(&wallet, 3).unwrap();
   1516         let mut migrated = ledger.clone();
   1517         migrated.utxos = ledger
   1518             .validated_v2_utxos_at_height(&migration, 3_000)
   1519             .unwrap();
   1520 
   1521         let transfer = migrated
   1522             .build_v2_transfer(&wallet, recipient.hybrid_versioned_address(), 40, 2)
   1523             .unwrap();
   1524         let TransactionV2::Transfer {
   1525             inputs,
   1526             outputs,
   1527             fee,
   1528             authorizations,
   1529         } = &transfer
   1530         else {
   1531             panic!("builder returned a non-transfer transaction");
   1532         };
   1533 
   1534         assert_eq!(inputs.len(), 1);
   1535         assert_eq!(*fee, 2);
   1536         assert_eq!(outputs.len(), 2);
   1537         assert_eq!(outputs[0].address, recipient.hybrid_versioned_address());
   1538         assert_eq!(outputs[0].amount, 40);
   1539         assert_eq!(outputs[1].address, wallet.hybrid_versioned_address());
   1540         assert_eq!(outputs[1].amount, 55);
   1541         assert_eq!(authorizations.len(), inputs.len());
   1542         migrated
   1543             .validate_transaction_v2_at_height(&transfer, 3_001)
   1544             .unwrap();
   1545     }
   1546 
   1547     #[test]
   1548     fn migration_batch_fits_the_real_empty_block_budget() {
   1549         let wallet = Wallet::from_seed("v2-migration-batch-wallet");
   1550         let profile = crate::domain::LaunchProfile {
   1551             max_block_bytes: 2_000,
   1552             ..crate::domain::LaunchProfile::default()
   1553         };
   1554         let mut ledger =
   1555             Ledger::new_with_genesis_burns_and_profile(BTreeMap::new(), Vec::new(), 1, profile)
   1556                 .unwrap();
   1557         ledger.utxos = (1_u64..=50)
   1558             .map(|index| {
   1559                 (
   1560                     OutPoint {
   1561                         txid: format!("{index:064x}"),
   1562                         index: 0,
   1563                     },
   1564                     TxOutput {
   1565                         address: wallet.address().to_string(),
   1566                         amount: 10_000,
   1567                     },
   1568                 )
   1569             })
   1570             .collect();
   1571         ledger.chain.last_mut().unwrap().height = 2_999;
   1572 
   1573         let transaction = ledger.build_v2_migration_batch(&wallet, 1).unwrap();
   1574         let TransactionV2::Migration { inputs, .. } = &transaction else {
   1575             panic!("builder returned a non-migration transaction");
   1576         };
   1577         assert!(!inputs.is_empty());
   1578         assert!(inputs.len() < 50);
   1579         ledger.submit_transaction_v2(transaction).unwrap();
   1580     }
   1581 }