iuna

iuna

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

wallet.rs (23079B)


      1 use std::collections::BTreeMap;
      2 
      3 use anyhow::{Context, Result, bail};
      4 
      5 use crate::domain::{
      6     AddressNetwork, Amount, Block, BurnBundle, DEFAULT_TRANSACTION_FEE, Ledger, OutPoint,
      7     PreparedBlock, StratumMineShare, StratumMineTemplate, Transaction, TransactionSubmitOutcome,
      8     TransactionV2, VersionedAddress, Wallet, hex_encode, run_vdf,
      9 };
     10 
     11 use super::{
     12     ExternalMineJob, FeeEstimate, GossipEnvelope, NodeCore, QuantumMigrationPreview,
     13     helpers::converge_fee_by_byte, now_ms,
     14 };
     15 
     16 #[derive(Clone, Debug)]
     17 pub(super) enum NodeWallet {
     18     Unlocked(Wallet),
     19     Locked { address: String },
     20 }
     21 
     22 impl NodeWallet {
     23     pub(super) fn address(&self) -> &str {
     24         match self {
     25             Self::Unlocked(wallet) => wallet.address(),
     26             Self::Locked { address } => address,
     27         }
     28     }
     29 
     30     pub(super) fn unlocked(&self) -> Result<&Wallet> {
     31         match self {
     32             Self::Unlocked(wallet) => Ok(wallet),
     33             Self::Locked { .. } => bail!("wallet is locked"),
     34         }
     35     }
     36 
     37     pub(super) fn is_locked(&self) -> bool {
     38         matches!(self, Self::Locked { .. })
     39     }
     40 }
     41 
     42 impl NodeCore {
     43     pub fn preview_quantum_migration(
     44         &self,
     45         fee_per_byte: Amount,
     46     ) -> Result<QuantumMigrationPreview> {
     47         if fee_per_byte == 0 {
     48             bail!("migration fee per byte must be greater than zero");
     49         }
     50         let ledger = self.wallet_build_ledger()?;
     51         let wallet = self.wallet.unlocked()?;
     52         let domain = ledger.transaction_v2_domain()?;
     53         let initial = ledger.build_v2_migration_batch(wallet, 1)?;
     54         let bytes = initial.encoded_size_bytes(&domain)?;
     55         let fee = fee_per_byte
     56             .checked_mul(bytes as u64)
     57             .context("migration fee overflows")?;
     58         let transaction = ledger.build_v2_migration_batch(wallet, fee)?;
     59         let bytes = transaction.encoded_size_bytes(&domain)?;
     60         let transaction_id = hex_encode(transaction.transaction_id(&domain)?);
     61         let (input_count, amount) = match &transaction {
     62             TransactionV2::Migration {
     63                 inputs, outputs, ..
     64             } => (
     65                 inputs.len(),
     66                 outputs.iter().try_fold(0_u64, |total, output| {
     67                     total
     68                         .checked_add(output.amount)
     69                         .context("migration amount overflows")
     70                 })?,
     71             ),
     72             _ => unreachable!("migration builder returned another transaction kind"),
     73         };
     74         Ok(QuantumMigrationPreview {
     75             address: wallet.hybrid_address(AddressNetwork::from_profile_id(
     76                 &ledger.launch_profile().profile_id,
     77             )),
     78             transaction_id,
     79             input_count,
     80             remaining_legacy_utxos: ledger
     81                 .available_utxos_for_address(wallet.address())?
     82                 .len()
     83                 .saturating_sub(input_count),
     84             bytes,
     85             fee,
     86             amount,
     87         })
     88     }
     89 
     90     pub fn submit_quantum_migration(
     91         &mut self,
     92         fee_per_byte: Amount,
     93         max_fee: Amount,
     94         expected_transaction_id: &str,
     95     ) -> Result<QuantumMigrationPreview> {
     96         let preview = self.preview_quantum_migration(fee_per_byte)?;
     97         if preview.fee > max_fee || preview.transaction_id != expected_transaction_id {
     98             bail!("wallet outputs or migration fee changed; request a new preview");
     99         }
    100         let ledger = self.wallet_build_ledger()?;
    101         let transaction = ledger.build_v2_migration_batch(self.wallet.unlocked()?, preview.fee)?;
    102         self.submit_public_transaction_v2(transaction)?;
    103         Ok(preview)
    104     }
    105 
    106     pub fn estimate_hybrid_transfer_fee(
    107         &self,
    108         recipient: VersionedAddress,
    109         amount: Amount,
    110         fee_per_byte: Amount,
    111     ) -> Result<FeeEstimate> {
    112         self.build_hybrid_transfer_with_fee_rate(recipient, amount, fee_per_byte)
    113             .map(|(_, estimate)| estimate)
    114     }
    115 
    116     pub fn transfer_hybrid_with_fee_rate(
    117         &mut self,
    118         recipient: VersionedAddress,
    119         amount: Amount,
    120         fee_per_byte: Amount,
    121     ) -> Result<String> {
    122         let (transaction, _) =
    123             self.build_hybrid_transfer_with_fee_rate(recipient, amount, fee_per_byte)?;
    124         let domain = self.ledger.transaction_v2_domain()?;
    125         let transaction_id = hex_encode(transaction.transaction_id(&domain)?);
    126         self.submit_public_transaction_v2(transaction)?;
    127         Ok(transaction_id)
    128     }
    129 
    130     fn build_hybrid_transfer_with_fee_rate(
    131         &self,
    132         recipient: VersionedAddress,
    133         amount: Amount,
    134         fee_per_byte: Amount,
    135     ) -> Result<(TransactionV2, FeeEstimate)> {
    136         if fee_per_byte == 0 {
    137             bail!("fee per byte must be greater than zero");
    138         }
    139         let ledger = self.wallet_build_ledger()?;
    140         let domain = ledger.transaction_v2_domain()?;
    141         let wallet = self.wallet.unlocked()?;
    142         let mut fee = 1;
    143         for _ in 0..64 {
    144             let transaction = ledger.build_v2_transfer(wallet, recipient, amount, fee)?;
    145             let bytes = transaction.encoded_size_bytes(&domain)?;
    146             let required_fee = fee_per_byte
    147                 .checked_mul(bytes as Amount)
    148                 .context("fee per byte times transaction bytes overflows")?
    149                 .max(1);
    150             if fee >= required_fee {
    151                 return Ok((transaction, FeeEstimate { bytes, fee }));
    152             }
    153             fee = required_fee;
    154         }
    155         bail!("hybrid transfer fee did not converge")
    156     }
    157 
    158     /// Accept a transaction signed by an external/lightweight wallet.
    159     /// Mining actions are deliberately excluded from the public wallet API.
    160     pub fn submit_external_wallet_transaction(
    161         &mut self,
    162         tx: Transaction,
    163     ) -> Result<TransactionSubmitOutcome> {
    164         if matches!(tx, Transaction::Mine { .. }) {
    165             bail!("the wallet endpoint accepts only transfer and burn transactions");
    166         }
    167         let outcome = self.ledger.submit_transaction_with_outcome(tx.clone())?;
    168         if outcome.added() {
    169             self.outbox.push(GossipEnvelope::Transaction(tx));
    170         }
    171         Ok(outcome)
    172     }
    173 
    174     /// Accept a canonical v2 envelope signed by an external/lightweight wallet.
    175     /// Mining and burn transactions remain node-managed operations.
    176     pub fn submit_external_wallet_transaction_v2(
    177         &mut self,
    178         envelope: &str,
    179     ) -> Result<(String, TransactionSubmitOutcome)> {
    180         let encoded = crate::domain::decode_hex(envelope)?;
    181         let transaction = self.ledger.decode_transaction_v2(&encoded)?;
    182         if !matches!(
    183             transaction,
    184             TransactionV2::Migration { .. } | TransactionV2::Transfer { .. }
    185         ) {
    186             bail!("the wallet endpoint accepts only migration and transfer v2 transactions");
    187         }
    188         let domain = self.ledger.transaction_v2_domain()?;
    189         let transaction_id = hex_encode(transaction.transaction_id(&domain)?);
    190         let outcome = self.ledger.submit_transaction_v2(transaction)?;
    191         if outcome.added() {
    192             self.outbox.push(GossipEnvelope::TransactionV2 {
    193                 envelope: hex_encode(encoded),
    194             });
    195         }
    196         Ok((transaction_id, outcome))
    197     }
    198 
    199     pub fn burn(&mut self, amount: Amount) -> Result<Transaction> {
    200         self.burn_with_fee(amount, DEFAULT_TRANSACTION_FEE)
    201     }
    202 
    203     pub fn burn_with_fee(&mut self, amount: Amount, fee: Amount) -> Result<Transaction> {
    204         let tx = self
    205             .wallet_build_ledger()?
    206             .build_burn(self.wallet.unlocked()?, amount, fee)?;
    207         self.submit_public_transaction(tx)
    208     }
    209 
    210     pub fn burn_with_fee_rate(
    211         &mut self,
    212         amount: Amount,
    213         fee_per_byte: Amount,
    214     ) -> Result<(Transaction, FeeEstimate)> {
    215         let (tx, estimate) = self.build_burn_with_fee_rate(amount, fee_per_byte)?;
    216         self.submit_public_transaction(tx.clone())?;
    217         Ok((tx, estimate))
    218     }
    219 
    220     pub fn estimate_burn_fee(&self, amount: Amount, fee_per_byte: Amount) -> Result<FeeEstimate> {
    221         match self.build_burn_with_fee_rate(amount, fee_per_byte) {
    222             Ok((_, estimate)) => Ok(estimate),
    223             Err(legacy_error) => self
    224                 .build_v2_burn_with_fee_rate_on_ledger(
    225                     &self.wallet_build_ledger()?,
    226                     amount,
    227                     fee_per_byte,
    228                     false,
    229                 )
    230                 .map(|(_, estimate)| estimate)
    231                 .with_context(|| format!("legacy burn unavailable: {legacy_error:#}")),
    232         }
    233     }
    234 
    235     pub fn transfer(&mut self, to: impl Into<String>, amount: Amount) -> Result<Transaction> {
    236         self.transfer_with_fee(to, amount, DEFAULT_TRANSACTION_FEE)
    237     }
    238 
    239     pub fn transfer_with_fee(
    240         &mut self,
    241         to: impl Into<String>,
    242         amount: Amount,
    243         fee: Amount,
    244     ) -> Result<Transaction> {
    245         let tx =
    246             self.wallet_build_ledger()?
    247                 .build_transfer(self.wallet.unlocked()?, to, amount, fee)?;
    248         self.submit_public_transaction(tx)
    249     }
    250 
    251     pub fn transfer_with_fee_spending(
    252         &mut self,
    253         to: impl Into<String>,
    254         amount: Amount,
    255         fee: Amount,
    256         outpoints: &[OutPoint],
    257     ) -> Result<Transaction> {
    258         let tx = self.wallet_build_ledger()?.build_transfer_with_inputs(
    259             self.wallet.unlocked()?,
    260             to,
    261             amount,
    262             fee,
    263             outpoints,
    264         )?;
    265         self.submit_public_transaction(tx)
    266     }
    267 
    268     pub fn transfer_with_fee_rate(
    269         &mut self,
    270         to: impl Into<String>,
    271         amount: Amount,
    272         fee_per_byte: Amount,
    273         outpoints: &[OutPoint],
    274     ) -> Result<(Transaction, FeeEstimate)> {
    275         let (tx, estimate) =
    276             self.build_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)?;
    277         self.submit_public_transaction(tx.clone())?;
    278         Ok((tx, estimate))
    279     }
    280 
    281     pub fn estimate_transfer_fee(
    282         &self,
    283         to: impl Into<String>,
    284         amount: Amount,
    285         fee_per_byte: Amount,
    286         outpoints: &[OutPoint],
    287     ) -> Result<FeeEstimate> {
    288         self.build_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)
    289             .map(|(_, estimate)| estimate)
    290     }
    291 
    292     pub fn mine_pow_reward(&mut self) -> Result<Transaction> {
    293         let (tx, _) = self.build_mine_estimate()?;
    294         self.submit_public_mine_action(tx)
    295     }
    296 
    297     pub fn estimate_mine_fee(&self, _fee_per_byte: Amount) -> Result<FeeEstimate> {
    298         let tx = Transaction::Mine {
    299             recipient: self.reward_address_for_next_block()?,
    300             anchor: self.ledger.tip_hash().to_string(),
    301             salt: 0,
    302             nonce: 0,
    303             difficulty_bits: self.ledger.current_mine_difficulty_bits(),
    304             proof_header: None,
    305             signature: "0".repeat(64),
    306         };
    307         Ok(FeeEstimate {
    308             bytes: tx.economic_size_bytes(),
    309             fee: tx.fee(),
    310         })
    311     }
    312 
    313     pub fn external_mine_job(
    314         &self,
    315         recipient: impl Into<String>,
    316         salt: u64,
    317     ) -> Result<ExternalMineJob> {
    318         let recipient = recipient.into();
    319         let tip = self
    320             .chain()
    321             .last()
    322             .context("cannot build mine job without a chain tip")?;
    323         let difficulty_bits = self.ledger.current_mine_difficulty_bits();
    324         Ok(ExternalMineJob {
    325             template: self.ledger.stratum_mine_template(
    326                 recipient,
    327                 &tip.hash,
    328                 salt,
    329                 difficulty_bits,
    330             )?,
    331         })
    332     }
    333 
    334     pub fn submit_external_mine(
    335         &mut self,
    336         recipient: impl Into<String>,
    337         template: StratumMineTemplate,
    338         share: StratumMineShare,
    339     ) -> Result<Transaction> {
    340         let tx = self.ledger.build_stratum_mine(template, share)?;
    341         let recipient = recipient.into();
    342         if tx.to() != Some(recipient.as_str()) {
    343             bail!("submitted mine recipient does not match worker");
    344         }
    345         self.submit_public_mine_action(tx)
    346     }
    347 
    348     pub(super) fn usable_burn_bundles(&self) -> Vec<BurnBundle> {
    349         let next_height = self.ledger.height().saturating_add(1);
    350         let mut bundles = self
    351             .burn_bundles
    352             .iter()
    353             .filter(|((height, slot, member), _)| {
    354                 *height == next_height
    355                     && !self.equivocated_burn_bundle_slots.contains(&(
    356                         *height,
    357                         *slot,
    358                         member.clone(),
    359                     ))
    360             })
    361             .map(|(_, bundle)| bundle.clone())
    362             .collect::<Vec<_>>();
    363         bundles.sort_by_key(|bundle| bundle.slot);
    364         bundles
    365     }
    366 
    367     pub(super) fn usable_burn_bundles_for_finalizer_rank(
    368         &self,
    369         finalizer_rank: u32,
    370     ) -> Vec<BurnBundle> {
    371         let committee = self
    372             .ledger
    373             .burn_committee_for_next_ticket_block(finalizer_rank)
    374             .into_iter()
    375             .map(|member| (member.slot, member.owner))
    376             .collect::<BTreeMap<_, _>>();
    377         self.usable_burn_bundles()
    378             .into_iter()
    379             .filter(|bundle| {
    380                 committee
    381                     .get(&bundle.slot)
    382                     .is_some_and(|owner| *owner == bundle.member)
    383             })
    384             .collect()
    385     }
    386 
    387     pub(crate) fn burn_bundles_for_request(
    388         &self,
    389         height: u64,
    390         prev_hash: &str,
    391         slots: &[u8],
    392     ) -> Vec<BurnBundle> {
    393         self.usable_burn_bundles()
    394             .into_iter()
    395             .filter(|bundle| bundle.height == height && bundle.prev_hash == prev_hash)
    396             .filter(|bundle| slots.is_empty() || slots.contains(&bundle.slot))
    397             .collect()
    398     }
    399 
    400     pub(super) fn prune_burn_bundles(&mut self) {
    401         let height = self.ledger.height();
    402         self.burn_bundles
    403             .retain(|(bundle_height, _, _), _| *bundle_height > height);
    404         self.equivocated_burn_bundle_slots
    405             .retain(|(bundle_height, _, _)| *bundle_height > height);
    406     }
    407 
    408     pub(super) fn publish_burn_bundle_for_next_block(&mut self) -> Result<()> {
    409         let wallet = match &self.wallet {
    410             NodeWallet::Unlocked(wallet) => wallet,
    411             NodeWallet::Locked { .. } => return Ok(()),
    412         };
    413         let (ledger, _) = self.ledger_with_local_block_anchor();
    414         for bundle in ledger.build_burn_bundles(wallet)? {
    415             let key = (bundle.height, bundle.slot, bundle.member.clone());
    416             if self.equivocated_burn_bundle_slots.contains(&key) {
    417                 continue;
    418             }
    419             if let Some(existing) = self.burn_bundles.get(&key) {
    420                 self.outbox
    421                     .push(GossipEnvelope::BurnBundle(existing.clone()));
    422                 continue;
    423             }
    424             ledger.validate_next_block_burn_bundles(vec![bundle.clone()])?;
    425             self.burn_bundles.insert(key, bundle.clone());
    426             self.outbox.push(GossipEnvelope::BurnBundle(bundle));
    427         }
    428         Ok(())
    429     }
    430 
    431     pub(super) fn submit_public_mine_action(&mut self, tx: Transaction) -> Result<Transaction> {
    432         if !matches!(tx, Transaction::Mine { .. }) {
    433             bail!("only mine actions may be submitted as public mempool transactions");
    434         }
    435         if self
    436             .ledger
    437             .submit_transaction_with_outcome(tx.clone())?
    438             .added()
    439         {
    440             self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
    441         }
    442         Ok(tx)
    443     }
    444 
    445     pub(super) fn submit_public_transaction(&mut self, tx: Transaction) -> Result<Transaction> {
    446         if matches!(tx, Transaction::Mine { .. }) {
    447             return self.submit_public_mine_action(tx);
    448         }
    449         if self
    450             .ledger
    451             .submit_transaction_with_outcome(tx.clone())?
    452             .added()
    453         {
    454             self.outbox.push(GossipEnvelope::Transaction(tx.clone()));
    455         }
    456         Ok(tx)
    457     }
    458 
    459     pub(super) fn build_burn_with_fee_rate(
    460         &self,
    461         amount: Amount,
    462         fee_per_byte: Amount,
    463     ) -> Result<(Transaction, FeeEstimate)> {
    464         let ledger = self.wallet_build_ledger()?;
    465         self.build_burn_with_fee_rate_on_ledger(&ledger, amount, fee_per_byte)
    466     }
    467 
    468     pub(super) fn build_burn_with_fee_rate_on_ledger(
    469         &self,
    470         ledger: &Ledger,
    471         amount: Amount,
    472         fee_per_byte: Amount,
    473     ) -> Result<(Transaction, FeeEstimate)> {
    474         converge_fee_by_byte(fee_per_byte, |fee| {
    475             ledger.build_burn(self.wallet.unlocked()?, amount, fee)
    476         })
    477     }
    478 
    479     pub(super) fn build_v2_burn_with_fee_rate_on_ledger(
    480         &self,
    481         ledger: &Ledger,
    482         amount: Amount,
    483         fee_per_byte: Amount,
    484         for_next_block: bool,
    485     ) -> Result<(TransactionV2, FeeEstimate)> {
    486         if fee_per_byte == 0 {
    487             bail!("fee per byte must be greater than zero");
    488         }
    489         let domain = ledger.transaction_v2_domain()?;
    490         let wallet = self.wallet.unlocked()?;
    491         let mut fee = 1;
    492         for _ in 0..64 {
    493             let transaction = if for_next_block {
    494                 ledger.build_v2_burn_for_next_block(wallet, amount, fee)?
    495             } else {
    496                 ledger.build_v2_burn(wallet, amount, fee)?
    497             };
    498             let bytes = transaction.encoded_size_bytes(&domain)?;
    499             let required_fee = fee_per_byte
    500                 .checked_mul(bytes as Amount)
    501                 .context("fee per byte times transaction bytes overflows")?
    502                 .max(1);
    503             if fee >= required_fee {
    504                 return Ok((transaction, FeeEstimate { bytes, fee }));
    505             }
    506             fee = required_fee;
    507         }
    508         bail!("hybrid burn fee did not converge")
    509     }
    510 
    511     pub(super) fn build_transfer_with_fee_rate(
    512         &self,
    513         to: impl Into<String>,
    514         amount: Amount,
    515         fee_per_byte: Amount,
    516         outpoints: &[OutPoint],
    517     ) -> Result<(Transaction, FeeEstimate)> {
    518         let to = to.into();
    519         let ledger = self.wallet_build_ledger()?;
    520         converge_fee_by_byte(fee_per_byte, |fee| {
    521             if outpoints.is_empty() {
    522                 ledger.build_transfer(self.wallet.unlocked()?, to.clone(), amount, fee)
    523             } else {
    524                 ledger.build_transfer_with_inputs(
    525                     self.wallet.unlocked()?,
    526                     to.clone(),
    527                     amount,
    528                     fee,
    529                     outpoints,
    530                 )
    531             }
    532         })
    533     }
    534 
    535     pub(super) fn build_mine_estimate(&self) -> Result<(Transaction, FeeEstimate)> {
    536         let tx = self
    537             .ledger
    538             .build_mine(self.reward_address_for_next_block()?)?;
    539         Ok((
    540             tx.clone(),
    541             FeeEstimate {
    542                 bytes: tx.economic_size_bytes(),
    543                 fee: tx.fee(),
    544             },
    545         ))
    546     }
    547 
    548     pub(super) fn wallet_build_ledger(&self) -> Result<Ledger> {
    549         let mut ledger = self.ledger.clone();
    550         self.reserve_local_block_anchor_inputs(&mut ledger)?;
    551         Ok(ledger)
    552     }
    553 
    554     pub(super) fn reward_address_for_next_block(&self) -> Result<String> {
    555         if self.ledger.height().saturating_add(1) < crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT {
    556             return Ok(self.wallet.address().to_string());
    557         }
    558         Ok(self.ledger.wallet_reward_address(
    559             self.wallet.unlocked()?,
    560             self.ledger.height().saturating_add(1),
    561         ))
    562     }
    563 
    564     pub(super) fn wallet_anchor_build_ledger(&self) -> Result<Ledger> {
    565         let mut ledger = self.ledger.clone();
    566         self.reserve_local_block_anchor_inputs(&mut ledger)?;
    567         ledger.clear_pending_transactions();
    568         Ok(ledger)
    569     }
    570 
    571     pub(super) fn queue_local_block_anchor(&self, ledger: &mut Ledger) -> Result<()> {
    572         let Some((height, burn)) = &self.local_block_anchor_burn else {
    573             return Ok(());
    574         };
    575         if *height == ledger.height() && !ledger.has_transaction(burn.signature()) {
    576             let _ = ledger.submit_transaction(burn.clone())?;
    577         }
    578         Ok(())
    579     }
    580 
    581     pub(super) fn reserve_local_block_anchor_inputs(&self, ledger: &mut Ledger) -> Result<()> {
    582         let Some((height, burn)) = &self.local_block_anchor_burn else {
    583             return Ok(());
    584         };
    585         if *height == ledger.height() && !ledger.has_transaction(burn.signature()) {
    586             let _ = ledger.reserve_transaction_inputs(burn);
    587         }
    588         Ok(())
    589     }
    590 
    591     pub fn mine_one(&mut self) -> Result<Block> {
    592         self.mine_one_at(now_ms())
    593     }
    594 
    595     pub fn mine_one_at(&mut self, timestamp_ms: u64) -> Result<Block> {
    596         self.publish_burn_bundle_for_next_block()?;
    597         let work = self.prepare_next_block_with_local_anchor(timestamp_ms)?;
    598         let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds());
    599         self.complete_prepared_block_at(work, vdf_output, timestamp_ms)
    600     }
    601 
    602     pub fn complete_prepared_block(
    603         &mut self,
    604         work: PreparedBlock,
    605         vdf_output: String,
    606     ) -> Result<Block> {
    607         self.complete_prepared_block_at(work, vdf_output, now_ms())
    608     }
    609 
    610     pub fn complete_prepared_block_at(
    611         &mut self,
    612         work: PreparedBlock,
    613         vdf_output: String,
    614         timestamp_ms: u64,
    615     ) -> Result<Block> {
    616         let block = work.finish_at(self.wallet.unlocked()?, vdf_output, timestamp_ms);
    617         self.ledger.apply_locally_mined_block(block.clone())?;
    618         self.clear_stale_local_block_anchor();
    619         self.prune_burn_bundles();
    620         self.outbox.push(GossipEnvelope::Block(block.clone()));
    621         Ok(block)
    622     }
    623 
    624     pub fn precheck_prepared_block_without_vdf_at(
    625         &self,
    626         work: &PreparedBlock,
    627         timestamp_ms: u64,
    628     ) -> Result<()> {
    629         let block = work.clone().finish_at(
    630             self.wallet.unlocked()?,
    631             "precheck-vdf-output".to_string(),
    632             timestamp_ms,
    633         );
    634         self.ledger
    635             .block_requires_vdf_verification_at(&block, timestamp_ms)?;
    636         Ok(())
    637     }
    638 }
    639 
    640 #[cfg(test)]
    641 mod quantum_migration_tests {
    642     use std::collections::BTreeMap;
    643 
    644     use crate::{
    645         app::NodeCore,
    646         domain::{Ledger, Wallet},
    647     };
    648 
    649     #[test]
    650     fn migration_preview_reports_the_canonical_hybrid_transaction() {
    651         let wallet = Wallet::from_seed("migration-preview-wallet");
    652         let ledger = Ledger::new(
    653             BTreeMap::from([(wallet.address().to_string(), 1_000_000)]),
    654             1,
    655         );
    656         let node = NodeCore::from_ledger(wallet, ledger, 0);
    657 
    658         let preview = node.preview_quantum_migration(2).unwrap();
    659 
    660         assert_eq!(preview.input_count, 1);
    661         assert_eq!(preview.remaining_legacy_utxos, 0);
    662         assert_eq!(preview.fee, preview.bytes as u64 * 2);
    663         assert_eq!(preview.amount + preview.fee, 1_000_000);
    664         assert_eq!(preview.transaction_id.len(), 64);
    665         assert!(preview.address.starts_with("iuna1p"));
    666     }
    667 }