iuna

iuna

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

ledger_v2.rs (42781B)


      1 use std::collections::BTreeMap;
      2 
      3 use anyhow::{Context, Result, bail};
      4 
      5 use super::ledger_ops::{compact_block_context, ensure_transaction_v2_fits_empty_block};
      6 use super::{
      7     AddressNetwork, AddressVersion, Amount, Ledger, LegacyTransactionId, LineageOwnerValues,
      8     MAX_PENDING_POOL_BYTES, OutPoint, Transaction, TransactionSubmitOutcome, TransactionV2,
      9     TransactionV2Domain, TransactionV2Input, TransactionV2LegacyInput, TransactionV2Output,
     10     TxOutput, UtxoLineageRoot, attach_existing_output_lineage, decode_hex, decode_hex_array,
     11     encode_versioned_address, ensure_transaction_v2_active, hex_encode, newest_lineage_root,
     12     remove_spent_output_lineage,
     13 };
     14 
     15 impl Ledger {
     16     pub fn transaction_v2_domain(&self) -> Result<TransactionV2Domain> {
     17         TransactionV2Domain::new(
     18             self.launch_profile.profile_id.clone(),
     19             decode_hex_array::<32>(self.genesis_hash())
     20                 .context("ledger genesis hash is not a 32-byte hexadecimal value")?,
     21         )
     22     }
     23 
     24     /// Decodes a v2 envelope and rejects a chain ID or genesis hash chosen by the sender.
     25     pub fn decode_transaction_v2(&self, encoded: &[u8]) -> Result<TransactionV2> {
     26         let (domain, transaction) = TransactionV2::decode(encoded)?;
     27         if domain != self.transaction_v2_domain()? {
     28             bail!("transaction v2 belongs to a different chain domain");
     29         }
     30         Ok(transaction)
     31     }
     32 
     33     /// Validates the v2 rules against confirmed state at an explicit candidate block height.
     34     /// Mempool and block code can share this method without consulting wall-clock or local config.
     35     pub fn validate_transaction_v2_at_height(
     36         &self,
     37         transaction: &TransactionV2,
     38         height: u64,
     39     ) -> Result<()> {
     40         self.validate_transaction_v2_anchor_for_block(transaction, height)?;
     41         self.validated_v2_utxos_at_height(transaction, height)?;
     42         Ok(())
     43     }
     44 
     45     pub fn submit_transaction_v2(
     46         &mut self,
     47         transaction: TransactionV2,
     48     ) -> Result<TransactionSubmitOutcome> {
     49         self.submit_transaction_v2_at_height(transaction, self.height().saturating_add(1))
     50     }
     51 
     52     fn submit_transaction_v2_at_height(
     53         &mut self,
     54         transaction: TransactionV2,
     55         height: u64,
     56     ) -> Result<TransactionSubmitOutcome> {
     57         ensure_transaction_v2_active(height)?;
     58         self.validate_transaction_v2_anchor_for_pending(&transaction)?;
     59         let domain = self.transaction_v2_domain()?;
     60         let transaction_id = transaction.transaction_id(&domain)?;
     61         if self
     62             .pending_v2
     63             .iter()
     64             .any(|pending| pending.transaction_id(&domain).ok() == Some(transaction_id))
     65         {
     66             return Ok(TransactionSubmitOutcome::AlreadyKnown);
     67         }
     68         if transaction.fee() == 0 {
     69             bail!("public transaction v2 fee must be greater than zero");
     70         }
     71         let encoded = transaction.encode(&domain)?;
     72         let transaction_bytes = encoded.len();
     73         let envelope = hex_encode(&encoded);
     74         ensure_transaction_v2_fits_empty_block(
     75             compact_block_context(self),
     76             &envelope,
     77             self.launch_profile.max_block_bytes,
     78         )?;
     79         if self.pending.len().saturating_add(self.pending_v2.len())
     80             >= self.launch_profile.max_pending_transactions
     81         {
     82             bail!("mempool is full");
     83         }
     84         if self
     85             .pending_bytes
     86             .saturating_add(self.pending_v2_bytes)
     87             .checked_add(transaction_bytes)
     88             .is_none_or(|bytes| bytes > MAX_PENDING_POOL_BYTES)
     89         {
     90             bail!("mempool byte limit exceeded");
     91         }
     92 
     93         let mut utxos = self.utxos_after_spendable_pending()?;
     94         for pending in &self.pending_v2 {
     95             apply_prevalidated_transaction_v2_to_utxos(
     96                 pending,
     97                 &domain,
     98                 AddressNetwork::from_profile_id(&self.launch_profile.profile_id),
     99                 &mut utxos,
    100             )?;
    101         }
    102         apply_transaction_v2_to_utxos(
    103             &transaction,
    104             &domain,
    105             AddressNetwork::from_profile_id(&self.launch_profile.profile_id),
    106             &mut utxos,
    107         )?;
    108         self.pending_v2.push(transaction);
    109         self.pending_v2_bytes = self.pending_v2_bytes.saturating_add(transaction_bytes);
    110         Ok(TransactionSubmitOutcome::Added)
    111     }
    112 
    113     pub(super) fn transaction_conflicts_with_pending_v2(&self, transaction: &Transaction) -> bool {
    114         transaction.inputs().iter().any(|legacy_input| {
    115             self.pending_v2.iter().any(|pending| {
    116                 let TransactionV2::Migration { inputs, .. } = pending else {
    117                     return false;
    118                 };
    119                 inputs.iter().any(|v2_input| {
    120                     v2_input.outpoint_index == legacy_input.outpoint.index
    121                         && legacy_transaction_id_hex(&v2_input.outpoint_id)
    122                             == legacy_input.outpoint.txid
    123                 })
    124             })
    125         })
    126     }
    127 
    128     pub(super) fn revalidate_pending_v2(&mut self) -> Result<()> {
    129         self.revalidate_pending_v2_at_height(self.height().saturating_add(1))
    130     }
    131 
    132     fn revalidate_pending_v2_at_height(&mut self, height: u64) -> Result<()> {
    133         if ensure_transaction_v2_active(height).is_err() {
    134             self.pending_v2.clear();
    135             self.pending_v2_bytes = 0;
    136             return Ok(());
    137         }
    138         let domain = self.transaction_v2_domain()?;
    139         let network = AddressNetwork::from_profile_id(&self.launch_profile.profile_id);
    140         let mut utxos = self.utxos_after_spendable_pending()?;
    141         let pending = std::mem::take(&mut self.pending_v2);
    142         self.pending_v2_bytes = 0;
    143         for transaction in pending {
    144             let bytes = transaction.encoded_size_bytes(&domain)?;
    145             let mut candidate_utxos = utxos.clone();
    146             if self
    147                 .validate_transaction_v2_anchor_for_pending(&transaction)
    148                 .and_then(|()| {
    149                     apply_prevalidated_transaction_v2_to_utxos(
    150                         &transaction,
    151                         &domain,
    152                         network,
    153                         &mut candidate_utxos,
    154                     )
    155                 })
    156                 .is_ok()
    157             {
    158                 utxos = candidate_utxos;
    159                 self.pending_v2.push(transaction);
    160                 self.pending_v2_bytes = self.pending_v2_bytes.saturating_add(bytes);
    161             }
    162         }
    163         Ok(())
    164     }
    165 
    166     pub(super) fn validated_v2_utxos_at_height(
    167         &self,
    168         transaction: &TransactionV2,
    169         height: u64,
    170     ) -> Result<BTreeMap<OutPoint, TxOutput>> {
    171         ensure_transaction_v2_active(height)?;
    172         let domain = self.transaction_v2_domain()?;
    173         let mut utxos = self.utxos.clone();
    174         apply_transaction_v2_to_utxos(
    175             transaction,
    176             &domain,
    177             AddressNetwork::from_profile_id(&self.launch_profile.profile_id),
    178             &mut utxos,
    179         )?;
    180         Ok(utxos)
    181     }
    182 
    183     pub(super) fn validate_transaction_v2_anchor_for_block(
    184         &self,
    185         transaction: &TransactionV2,
    186         height: u64,
    187     ) -> Result<()> {
    188         if !transaction.is_burn() {
    189             return Ok(());
    190         }
    191         let anchor = transaction
    192             .burn_anchor()
    193             .context("transaction v2 burn is missing its chain anchor")?;
    194         let expected = decode_hex_array::<32>(&self.tip().prev_hash)
    195             .context("block grandparent hash is invalid")?;
    196         if height >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT && anchor != expected {
    197             bail!("transaction v2 burn anchor does not match the block grandparent");
    198         }
    199         Ok(())
    200     }
    201 
    202     pub(super) fn validate_transaction_v2_anchor_for_pending(
    203         &self,
    204         transaction: &TransactionV2,
    205     ) -> Result<()> {
    206         if !transaction.is_burn() {
    207             return Ok(());
    208         }
    209         let anchor = transaction
    210             .burn_anchor()
    211             .context("transaction v2 burn is missing its chain anchor")?;
    212         let tip = decode_hex_array::<32>(self.tip_hash()).context("tip hash is invalid")?;
    213         let parent =
    214             decode_hex_array::<32>(&self.tip().prev_hash).context("tip parent hash is invalid")?;
    215         if anchor != tip && anchor != parent {
    216             return Err(super::ValidationError::BurnAnchorOutsidePendingWindow.into());
    217         }
    218         Ok(())
    219     }
    220 
    221     pub(crate) fn transaction_v2_is_eligible_for_next_block(
    222         &self,
    223         transaction: &TransactionV2,
    224     ) -> bool {
    225         self.validate_transaction_v2_anchor_for_block(transaction, self.height().saturating_add(1))
    226             .is_ok()
    227     }
    228 }
    229 
    230 #[allow(clippy::too_many_arguments)]
    231 pub(super) fn apply_transaction_v2_with_lineage(
    232     transaction: &TransactionV2,
    233     domain: &TransactionV2Domain,
    234     network: AddressNetwork,
    235     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    236     utxo_lineage: &mut BTreeMap<OutPoint, UtxoLineageRoot>,
    237     lineage_values: &mut BTreeMap<UtxoLineageRoot, Amount>,
    238     lineage_owners: &mut LineageOwnerValues,
    239     verify_authorizations: bool,
    240 ) -> Result<()> {
    241     let spent = transaction_v2_outpoints(transaction);
    242     let spent_outputs = spent
    243         .iter()
    244         .map(|outpoint| {
    245             utxos
    246                 .get(outpoint)
    247                 .cloned()
    248                 .map(|output| (outpoint.clone(), output))
    249                 .with_context(|| format!("transaction v2 input {} is not spendable", outpoint.id()))
    250         })
    251         .collect::<Result<Vec<_>>>()?;
    252 
    253     apply_transaction_v2_to_utxos_with_policy(
    254         transaction,
    255         domain,
    256         network,
    257         utxos,
    258         verify_authorizations,
    259     )?;
    260 
    261     let mut inherited_root = None;
    262     for (outpoint, output) in &spent_outputs {
    263         let root = remove_spent_output_lineage(
    264             outpoint,
    265             output,
    266             utxo_lineage,
    267             lineage_values,
    268             lineage_owners,
    269         )?;
    270         inherited_root = newest_lineage_root(inherited_root, root);
    271     }
    272     if let Some(root) = inherited_root {
    273         let transaction_id = hex_encode(transaction.transaction_id(domain)?);
    274         for (index, output) in transaction_v2_outputs(transaction).iter().enumerate() {
    275             let outpoint = OutPoint {
    276                 txid: transaction_id.clone(),
    277                 index: u32::try_from(index).context("transaction v2 output index exceeds u32")?,
    278             };
    279             let internal = utxos
    280                 .get(&outpoint)
    281                 .context("transaction v2 output is missing after application")?;
    282             attach_existing_output_lineage(
    283                 outpoint,
    284                 internal,
    285                 root.clone(),
    286                 utxo_lineage,
    287                 lineage_values,
    288                 lineage_owners,
    289             )?;
    290             debug_assert_eq!(internal.amount, output.amount);
    291         }
    292     }
    293     Ok(())
    294 }
    295 
    296 pub(super) fn decode_canonical_transaction_v2_envelope(
    297     envelope: &str,
    298     expected_domain: &TransactionV2Domain,
    299 ) -> Result<TransactionV2> {
    300     let bytes = decode_hex(envelope).context("transaction v2 envelope is not hexadecimal")?;
    301     if hex_encode(&bytes) != envelope {
    302         bail!("transaction v2 envelope is not canonical lowercase hexadecimal");
    303     }
    304     let (domain, transaction) = TransactionV2::decode(&bytes)?;
    305     if &domain != expected_domain {
    306         bail!("transaction v2 belongs to a different chain domain");
    307     }
    308     if transaction.encode(expected_domain)? != bytes {
    309         bail!("transaction v2 envelope is not canonically encoded");
    310     }
    311     Ok(transaction)
    312 }
    313 
    314 fn transaction_v2_outpoints(transaction: &TransactionV2) -> Vec<OutPoint> {
    315     match transaction {
    316         TransactionV2::Migration { inputs, .. } => inputs
    317             .iter()
    318             .map(|input| OutPoint {
    319                 txid: legacy_transaction_id_hex(&input.outpoint_id),
    320                 index: input.outpoint_index,
    321             })
    322             .collect(),
    323         TransactionV2::Transfer { inputs, .. } | TransactionV2::Burn { inputs, .. } => inputs
    324             .iter()
    325             .map(|input| OutPoint {
    326                 txid: hex_encode(input.outpoint_txid),
    327                 index: input.outpoint_index,
    328             })
    329             .collect(),
    330         TransactionV2::Mine { .. } => Vec::new(),
    331     }
    332 }
    333 
    334 fn transaction_v2_outputs(transaction: &TransactionV2) -> &[TransactionV2Output] {
    335     match transaction {
    336         TransactionV2::Migration { outputs, .. } | TransactionV2::Transfer { outputs, .. } => {
    337             outputs
    338         }
    339         TransactionV2::Burn { change, .. } => change,
    340         TransactionV2::Mine { .. } => &[],
    341     }
    342 }
    343 
    344 pub(super) fn apply_transaction_v2_to_utxos(
    345     transaction: &TransactionV2,
    346     domain: &TransactionV2Domain,
    347     network: AddressNetwork,
    348     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    349 ) -> Result<()> {
    350     apply_transaction_v2_to_utxos_with_policy(transaction, domain, network, utxos, true)
    351 }
    352 
    353 pub(super) fn apply_prevalidated_transaction_v2_to_utxos(
    354     transaction: &TransactionV2,
    355     domain: &TransactionV2Domain,
    356     network: AddressNetwork,
    357     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    358 ) -> Result<()> {
    359     apply_transaction_v2_to_utxos_with_policy(transaction, domain, network, utxos, false)
    360 }
    361 
    362 fn apply_transaction_v2_to_utxos_with_policy(
    363     transaction: &TransactionV2,
    364     domain: &TransactionV2Domain,
    365     network: AddressNetwork,
    366     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    367     verify_authorizations: bool,
    368 ) -> Result<()> {
    369     let (spent, outputs, fee, burned) = match transaction {
    370         TransactionV2::Migration {
    371             inputs,
    372             outputs,
    373             fee,
    374             ..
    375         } => (
    376             spend_legacy_inputs(inputs, utxos)?,
    377             outputs.as_slice(),
    378             *fee,
    379             0,
    380         ),
    381         TransactionV2::Transfer {
    382             inputs,
    383             outputs,
    384             fee,
    385             ..
    386         } => (
    387             spend_v2_inputs(inputs, network, utxos)?,
    388             outputs.as_slice(),
    389             *fee,
    390             0,
    391         ),
    392         TransactionV2::Burn {
    393             inputs,
    394             change,
    395             amount,
    396             fee,
    397             ..
    398         } => {
    399             transaction.burn_legacy_owner()?;
    400             (
    401                 spend_v2_inputs(inputs, network, utxos)?,
    402                 change.as_slice(),
    403                 *fee,
    404                 *amount,
    405             )
    406         }
    407         TransactionV2::Mine { .. } => {
    408             bail!("transaction v2 mining is not integrated into live proof consensus")
    409         }
    410     };
    411 
    412     let credited = sum_outputs(outputs)?;
    413     let required = credited
    414         .checked_add(fee)
    415         .context("transaction v2 output value plus fee overflows")?
    416         .checked_add(burned)
    417         .context("transaction v2 output value plus burn overflows")?;
    418     if spent != required {
    419         bail!("transaction v2 input value does not equal outputs plus fee");
    420     }
    421 
    422     // Verify expensive signatures only for an untrusted candidate and only after cheap state and
    423     // conservation checks. Pending entries are immutable and were verified on admission.
    424     if verify_authorizations {
    425         transaction.verify_authorizations(domain)?;
    426     }
    427     let transaction_id = hex_encode(transaction.transaction_id(domain)?);
    428     for (index, output) in outputs.iter().enumerate() {
    429         let index = u32::try_from(index).context("transaction v2 output index exceeds u32")?;
    430         let outpoint = OutPoint {
    431             txid: transaction_id.clone(),
    432             index,
    433         };
    434         if utxos
    435             .insert(
    436                 outpoint,
    437                 TxOutput {
    438                     address: internal_address(output.address, network)?,
    439                     amount: output.amount,
    440                 },
    441             )
    442             .is_some()
    443         {
    444             bail!("transaction v2 recreates an existing outpoint");
    445         }
    446     }
    447     Ok(())
    448 }
    449 
    450 fn spend_legacy_inputs(
    451     inputs: &[TransactionV2LegacyInput],
    452     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    453 ) -> Result<u64> {
    454     let mut spent = 0_u64;
    455     for input in inputs {
    456         let outpoint = OutPoint {
    457             txid: legacy_transaction_id_hex(&input.outpoint_id),
    458             index: input.outpoint_index,
    459         };
    460         let output = utxos
    461             .remove(&outpoint)
    462             .with_context(|| format!("transaction v2 input {} is not spendable", outpoint.id()))?;
    463         let expected_owner = internal_address(input.owner, AddressNetwork::Mainnet)?;
    464         if output.address != expected_owner {
    465             bail!("transaction v2 legacy input owner does not match the referenced output");
    466         }
    467         spent = spent
    468             .checked_add(output.amount)
    469             .context("transaction v2 input value overflows")?;
    470     }
    471     Ok(spent)
    472 }
    473 
    474 fn legacy_transaction_id_hex(transaction_id: &LegacyTransactionId) -> String {
    475     match transaction_id {
    476         LegacyTransactionId::Hash(value) => hex_encode(value),
    477         LegacyTransactionId::Signature(value) => hex_encode(value),
    478     }
    479 }
    480 
    481 fn spend_v2_inputs(
    482     inputs: &[TransactionV2Input],
    483     network: AddressNetwork,
    484     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    485 ) -> Result<u64> {
    486     let mut spent = 0_u64;
    487     for input in inputs {
    488         let outpoint = OutPoint {
    489             txid: hex_encode(input.outpoint_txid),
    490             index: input.outpoint_index,
    491         };
    492         let output = utxos
    493             .remove(&outpoint)
    494             .with_context(|| format!("transaction v2 input {} is not spendable", outpoint.id()))?;
    495         if output.address != internal_address(input.owner, network)? {
    496             bail!("transaction v2 input owner does not match the referenced output");
    497         }
    498         spent = spent
    499             .checked_add(output.amount)
    500             .context("transaction v2 input value overflows")?;
    501     }
    502     Ok(spent)
    503 }
    504 
    505 fn sum_outputs(outputs: &[TransactionV2Output]) -> Result<u64> {
    506     outputs.iter().try_fold(0_u64, |total, output| {
    507         total
    508             .checked_add(output.amount)
    509             .context("transaction v2 output value overflows")
    510     })
    511 }
    512 
    513 fn internal_address(address: super::VersionedAddress, network: AddressNetwork) -> Result<String> {
    514     match address.version {
    515         AddressVersion::Ed25519PublicKey => Ok(hex_encode(address.payload)),
    516         AddressVersion::HybridKeyCommitment => encode_versioned_address(address, network),
    517     }
    518 }
    519 
    520 #[cfg(test)]
    521 mod tests {
    522     use std::collections::BTreeMap;
    523 
    524     use super::*;
    525     use crate::domain::{
    526         GenesisBurn, LaunchProfile, SignatureScheme, TRANSACTION_V2_ACTIVATION_HEIGHT,
    527         TransactionV2Output, Wallet,
    528     };
    529 
    530     fn set_next_height(ledger: &mut Ledger, next_height: u64) {
    531         ledger.chain.last_mut().unwrap().height = next_height.saturating_sub(1);
    532         for ticket in &mut ledger.tickets {
    533             ticket.eligible_from_height = next_height;
    534             ticket.eligible_until_height = next_height;
    535         }
    536     }
    537 
    538     fn post_activation_height() -> u64 {
    539         TRANSACTION_V2_ACTIVATION_HEIGHT.unwrap().saturating_add(1)
    540     }
    541 
    542     #[test]
    543     fn migration_validation_switches_at_3000_and_creates_a_hybrid_utxo() {
    544         let wallet = Wallet::from_seed("v2-ledger-migration-wallet");
    545         let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
    546         let transaction = ledger.build_v2_migration(&wallet, 3).unwrap();
    547 
    548         assert!(
    549             ledger
    550                 .validate_transaction_v2_at_height(&transaction, 2_999)
    551                 .is_err()
    552         );
    553         ledger
    554             .validate_transaction_v2_at_height(&transaction, 3_000)
    555             .unwrap();
    556 
    557         let domain = ledger.transaction_v2_domain().unwrap();
    558         let transaction_id = hex_encode(transaction.transaction_id(&domain).unwrap());
    559         let utxos = ledger
    560             .validated_v2_utxos_at_height(&transaction, 3_000)
    561             .unwrap();
    562         assert_eq!(
    563             utxos.get(&OutPoint {
    564                 txid: transaction_id,
    565                 index: 0,
    566             }),
    567             Some(&TxOutput {
    568                 address: wallet.hybrid_address(AddressNetwork::Mainnet),
    569                 amount: 97,
    570             })
    571         );
    572         assert_eq!(utxos.len(), 1);
    573     }
    574 
    575     #[test]
    576     fn hybrid_output_requires_both_signatures_when_spent() {
    577         let wallet = Wallet::from_seed("v2-ledger-hybrid-spend-wallet");
    578         let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
    579         let migration = ledger.build_v2_migration(&wallet, 3).unwrap();
    580         let domain = ledger.transaction_v2_domain().unwrap();
    581         let migration_id = migration.transaction_id(&domain).unwrap();
    582         let migrated_utxos = ledger
    583             .validated_v2_utxos_at_height(&migration, 3_000)
    584             .unwrap();
    585         let mut migrated_ledger = ledger.clone();
    586         migrated_ledger.utxos = migrated_utxos;
    587 
    588         let mut transfer = TransactionV2::Transfer {
    589             inputs: vec![TransactionV2Input {
    590                 outpoint_txid: migration_id,
    591                 outpoint_index: 0,
    592                 owner: wallet.hybrid_versioned_address(),
    593             }],
    594             outputs: vec![TransactionV2Output {
    595                 address: wallet.hybrid_versioned_address(),
    596                 amount: 96,
    597             }],
    598             fee: 1,
    599             authorizations: Vec::new(),
    600         };
    601         let payload = transfer.signing_bytes(&domain).unwrap();
    602         let authorization = wallet
    603             .sign_v2_authorization(wallet.hybrid_versioned_address(), &payload)
    604             .unwrap();
    605         assert_eq!(
    606             authorization.scheme(),
    607             SignatureScheme::HybridEd25519MlDsa44
    608         );
    609         if let TransactionV2::Transfer { authorizations, .. } = &mut transfer {
    610             authorizations.push(authorization);
    611         }
    612         migrated_ledger
    613             .validate_transaction_v2_at_height(&transfer, 3_001)
    614             .unwrap();
    615 
    616         if let TransactionV2::Transfer { authorizations, .. } = &mut transfer {
    617             let public_key = authorizations[0].public_key().clone();
    618             let classical_signature = authorizations[0].signature().as_bytes()[..64].to_vec();
    619             authorizations[0] = super::super::V2SpendingAuthorization::new(
    620                 super::super::ProtocolPublicKey::new(
    621                     SignatureScheme::Ed25519,
    622                     public_key.as_bytes()[..32].to_vec(),
    623                 )
    624                 .unwrap(),
    625                 super::super::ProtocolSignature::new(SignatureScheme::Ed25519, classical_signature)
    626                     .unwrap(),
    627             )
    628             .unwrap();
    629         }
    630         assert!(
    631             migrated_ledger
    632                 .validate_transaction_v2_at_height(&transfer, 3_001)
    633                 .is_err()
    634         );
    635     }
    636 
    637     #[test]
    638     fn hybrid_wallet_can_build_select_and_create_a_ticket_from_a_v2_burn() {
    639         let wallet = Wallet::from_seed("v2-burn-wallet");
    640         let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
    641         let migration = ledger.build_v2_migration(&wallet, 3).unwrap();
    642         ledger.utxos = ledger
    643             .validated_v2_utxos_at_height(&migration, 3_000)
    644             .unwrap();
    645         let split = ledger
    646             .build_v2_transfer(&wallet, wallet.hybrid_versioned_address(), 40, 1)
    647             .unwrap();
    648         ledger.utxos = ledger.validated_v2_utxos_at_height(&split, 3_000).unwrap();
    649         set_next_height(&mut ledger, post_activation_height());
    650 
    651         let burn = ledger.build_v2_burn_for_next_block(&wallet, 9, 5).unwrap();
    652         assert_eq!(
    653             burn.burn_legacy_owner().unwrap().as_deref(),
    654             Some(wallet.address())
    655         );
    656         assert_eq!(burn.amount(), 9);
    657         ledger.submit_transaction_v2(burn.clone()).unwrap();
    658         let mut low_fee_build_ledger = ledger.clone();
    659         low_fee_build_ledger
    660             .utxos
    661             .retain(|_, output| output.amount == 40);
    662         let low_fee_burn = low_fee_build_ledger
    663             .build_v2_burn_for_next_block(&wallet, 7, 1)
    664             .unwrap();
    665         ledger.submit_transaction_v2(low_fee_burn.clone()).unwrap();
    666 
    667         let bundles = ledger.build_burn_bundles(&wallet).unwrap();
    668         assert!(!bundles.is_empty());
    669         let domain = ledger.transaction_v2_domain().unwrap();
    670         let expected_burns = vec![
    671             hex_encode(burn.encode(&domain).unwrap()),
    672             hex_encode(low_fee_burn.encode(&domain).unwrap()),
    673         ];
    674         assert!(
    675             bundles
    676                 .iter()
    677                 .all(|bundle| bundle.burns_v2 == vec![expected_burns[0].clone()])
    678         );
    679         let recovery = ledger
    680             .prepare_recovery_block_with_required_burn_and_burn_bundles(
    681                 wallet.address(),
    682                 None,
    683                 ledger.recovery_block_min_timestamp(),
    684                 Vec::new(),
    685                 None,
    686             )
    687             .unwrap();
    688         assert_eq!(recovery.transactions_v2.len(), 2);
    689         let section = ledger.burn_bundle_section_from_bundles(bundles).unwrap();
    690         assert_eq!(section.burns_v2.len(), 1);
    691         let sorted_section = ledger
    692             .burn_bundle_section_from_bundles(vec![super::super::BurnBundle {
    693                 height: ledger.height() + 1,
    694                 prev_hash: ledger.tip_hash().to_string(),
    695                 slot: 1,
    696                 member: wallet.address().to_string(),
    697                 reward_address: None,
    698                 burns: Vec::new(),
    699                 burns_v2: vec![expected_burns[1].clone(), expected_burns[0].clone()],
    700                 signature: "test-signature".to_string(),
    701             }])
    702             .unwrap();
    703         assert_eq!(
    704             sorted_section.expand(ledger.height() + 1, ledger.tip_hash())[0].burns_v2,
    705             expected_burns
    706         );
    707 
    708         let selection = ledger
    709             .select_block_transactions_with_required_burn_owner(
    710                 Some(wallet.address()),
    711                 None,
    712                 super::super::FinalizerMode::Ticket,
    713                 &section,
    714             )
    715             .unwrap();
    716         assert!(selection.transactions.is_empty());
    717         assert_eq!(selection.transactions_v2.len(), 2);
    718 
    719         let mut block = ledger.tip().clone();
    720         block.height = post_activation_height();
    721         block.transactions.clear();
    722         block.transactions_v2 = selection.transactions_v2;
    723         let tickets =
    724             super::super::ticket::tickets_created_by_block(&block, ledger.launch_profile())
    725                 .unwrap();
    726         assert_eq!(tickets.len(), 2);
    727         assert_eq!(tickets[0].owner, wallet.address());
    728         assert_eq!(tickets[0].amount, 9);
    729         assert_eq!(tickets[1].owner, wallet.address());
    730         assert_eq!(tickets[1].amount, 7);
    731     }
    732 
    733     #[test]
    734     fn decoder_rejects_an_attacker_selected_chain_domain() {
    735         let wallet = Wallet::from_seed("v2-ledger-domain-wallet");
    736         let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
    737         let transaction = ledger.build_v2_migration(&wallet, 1).unwrap();
    738         let foreign = TransactionV2Domain::new("foreign-chain", [0x44; 32]).unwrap();
    739         let encoded = transaction.encode(&foreign).unwrap();
    740 
    741         assert!(ledger.decode_transaction_v2(&encoded).is_err());
    742     }
    743 
    744     #[test]
    745     fn v2_mempool_enforces_activation_deduplication_and_legacy_conflicts() {
    746         let wallet = Wallet::from_seed("v2-ledger-mempool-wallet");
    747         let recipient = Wallet::from_seed("v2-ledger-mempool-recipient");
    748         let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
    749         let transaction = ledger.build_v2_migration(&wallet, 3).unwrap();
    750         let conflicting_legacy = ledger
    751             .build_transfer(&wallet, recipient.address(), 90, 1)
    752             .unwrap();
    753 
    754         assert!(
    755             ledger
    756                 .submit_transaction_v2_at_height(transaction.clone(), 2_999)
    757                 .is_err()
    758         );
    759         assert!(ledger.pending_v2().is_empty());
    760         assert_eq!(
    761             ledger
    762                 .submit_transaction_v2_at_height(transaction.clone(), 3_000)
    763                 .unwrap(),
    764             TransactionSubmitOutcome::Added
    765         );
    766         assert_eq!(ledger.pending_v2().len(), 1);
    767         assert!(ledger.pending_v2_bytes > 0);
    768         assert_eq!(ledger.status().pending_transactions, 1);
    769         assert_eq!(
    770             ledger
    771                 .submit_transaction_v2_at_height(transaction, 3_000)
    772                 .unwrap(),
    773             TransactionSubmitOutcome::AlreadyKnown
    774         );
    775         assert_eq!(
    776             ledger
    777                 .submit_transaction_with_outcome(conflicting_legacy)
    778                 .unwrap(),
    779             TransactionSubmitOutcome::ConflictsWithPending
    780         );
    781     }
    782 
    783     #[test]
    784     fn v2_mempool_accepts_a_transfer_spending_a_pending_migration() {
    785         let wallet = Wallet::from_seed("v2-ledger-dependent-mempool-wallet");
    786         let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
    787         let migration = ledger.build_v2_migration(&wallet, 3).unwrap();
    788         let domain = ledger.transaction_v2_domain().unwrap();
    789         let migration_id = migration.transaction_id(&domain).unwrap();
    790         ledger
    791             .submit_transaction_v2_at_height(migration, 3_000)
    792             .unwrap();
    793 
    794         let mut transfer = TransactionV2::Transfer {
    795             inputs: vec![TransactionV2Input {
    796                 outpoint_txid: migration_id,
    797                 outpoint_index: 0,
    798                 owner: wallet.hybrid_versioned_address(),
    799             }],
    800             outputs: vec![TransactionV2Output {
    801                 address: wallet.hybrid_versioned_address(),
    802                 amount: 96,
    803             }],
    804             fee: 1,
    805             authorizations: Vec::new(),
    806         };
    807         let payload = transfer.signing_bytes(&domain).unwrap();
    808         let authorization = wallet
    809             .sign_v2_authorization(wallet.hybrid_versioned_address(), &payload)
    810             .unwrap();
    811         if let TransactionV2::Transfer { authorizations, .. } = &mut transfer {
    812             authorizations.push(authorization);
    813         }
    814 
    815         assert_eq!(
    816             ledger
    817                 .submit_transaction_v2_at_height(transfer, 3_000)
    818                 .unwrap(),
    819             TransactionSubmitOutcome::Added
    820         );
    821         assert_eq!(ledger.pending_v2().len(), 2);
    822     }
    823 
    824     #[test]
    825     fn v2_mempool_revalidation_drops_a_migration_spent_by_new_chain_state() {
    826         let wallet = Wallet::from_seed("v2-ledger-revalidation-wallet");
    827         let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
    828         let migration = ledger.build_v2_migration(&wallet, 3).unwrap();
    829         ledger
    830             .submit_transaction_v2_at_height(migration, 3_000)
    831             .unwrap();
    832         assert_eq!(ledger.pending_v2().len(), 1);
    833 
    834         ledger.utxos.clear();
    835         ledger.revalidate_pending_v2_at_height(3_000).unwrap();
    836         assert!(ledger.pending_v2().is_empty());
    837         assert_eq!(ledger.pending_v2_bytes, 0);
    838     }
    839 
    840     #[test]
    841     fn invalid_v2_candidate_does_not_mutate_the_mempool() {
    842         let wallet = Wallet::from_seed("v2-ledger-invalid-candidate-wallet");
    843         let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
    844         let mut migration = ledger.build_v2_migration(&wallet, 3).unwrap();
    845         if let TransactionV2::Migration { authorizations, .. } = &mut migration {
    846             let public_key = authorizations[0].public_key().clone();
    847             let mut signature = authorizations[0].signature().as_bytes().to_vec();
    848             signature[0] ^= 1;
    849             authorizations[0] = super::super::V2SpendingAuthorization::new(
    850                 public_key,
    851                 super::super::ProtocolSignature::new(SignatureScheme::Ed25519, signature).unwrap(),
    852             )
    853             .unwrap();
    854         }
    855 
    856         assert!(
    857             ledger
    858                 .submit_transaction_v2_at_height(migration, 3_000)
    859                 .is_err()
    860         );
    861         assert!(ledger.pending_v2().is_empty());
    862         assert_eq!(ledger.pending_v2_bytes, 0);
    863     }
    864 
    865     #[test]
    866     fn v2_mempool_rejects_an_envelope_that_cannot_fit_with_block_overhead() {
    867         let wallet = Wallet::from_seed("v2-empty-block-budget-wallet");
    868         let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
    869         let migration = ledger.build_v2_migration(&wallet, 3).unwrap();
    870         let domain = ledger.transaction_v2_domain().unwrap();
    871         ledger.launch_profile.max_block_bytes = migration.encoded_size_bytes(&domain).unwrap();
    872 
    873         assert!(
    874             ledger
    875                 .submit_transaction_v2_at_height(migration, 3_000)
    876                 .unwrap_err()
    877                 .to_string()
    878                 .contains("exceeds max block size")
    879         );
    880         assert!(ledger.pending_v2().is_empty());
    881     }
    882 
    883     #[test]
    884     fn block_selection_includes_v2_transactions_at_activation() {
    885         let wallet = Wallet::from_seed("v2-block-selection-wallet");
    886         let legacy_sender = Wallet::from_seed("v2-block-selection-legacy-sender");
    887         let mut ledger = Ledger::new(
    888             BTreeMap::from([
    889                 (wallet.address().to_string(), 100),
    890                 (legacy_sender.address().to_string(), 100),
    891             ]),
    892             1,
    893         );
    894         ledger.chain[0].height = 2_999;
    895         let migration = ledger.build_v2_migration(&wallet, 50).unwrap();
    896         let legacy = ledger
    897             .build_transfer(&legacy_sender, wallet.address(), 50, 1)
    898             .unwrap();
    899         ledger.submit_transaction(legacy).unwrap();
    900         ledger.submit_transaction_v2(migration.clone()).unwrap();
    901         ledger.launch_profile.max_block_transactions = 1;
    902 
    903         let selection = ledger
    904             .select_block_transactions_with_required_burn_owner(
    905                 None,
    906                 None,
    907                 super::super::FinalizerMode::Ticket,
    908                 &super::super::BurnBundleSection::default(),
    909             )
    910             .unwrap();
    911 
    912         assert!(selection.transactions.is_empty());
    913         assert_eq!(selection.transactions_v2.len(), 1);
    914         let encoded = decode_hex(&selection.transactions_v2[0]).unwrap();
    915         assert_eq!(ledger.decode_transaction_v2(&encoded).unwrap(), migration);
    916     }
    917 
    918     #[test]
    919     fn block_application_preserves_legacy_output_lineage_through_migration() {
    920         let wallet = Wallet::from_seed("v2-block-lineage-wallet");
    921         let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
    922         let migration = ledger.build_v2_migration(&wallet, 3).unwrap();
    923         let spent = ledger.utxos.keys().next().unwrap().clone();
    924         let root = UtxoLineageRoot {
    925             outpoint: spent.clone(),
    926             height: 1,
    927         };
    928         ledger.utxo_lineage.insert(spent.clone(), root.clone());
    929         ledger.lineage_values.insert(root.clone(), 100);
    930         ledger.lineage_owners.insert(
    931             root.clone(),
    932             BTreeMap::from([(wallet.address().to_string(), BTreeMap::from([(spent, 100)]))]),
    933         );
    934         let domain = ledger.transaction_v2_domain().unwrap();
    935 
    936         apply_transaction_v2_with_lineage(
    937             &migration,
    938             &domain,
    939             AddressNetwork::Mainnet,
    940             &mut ledger.utxos,
    941             &mut ledger.utxo_lineage,
    942             &mut ledger.lineage_values,
    943             &mut ledger.lineage_owners,
    944             true,
    945         )
    946         .unwrap();
    947 
    948         assert_eq!(
    949             ledger.utxo_lineage.values().collect::<Vec<_>>(),
    950             vec![&root]
    951         );
    952         assert_eq!(ledger.lineage_values.get(&root), Some(&97));
    953         assert_eq!(
    954             ledger
    955                 .lineage_owners
    956                 .get(&root)
    957                 .and_then(|owners| owners.get(&wallet.hybrid_address(AddressNetwork::Mainnet)))
    958                 .map(|outputs| outputs.values().copied().sum::<u64>()),
    959             Some(97)
    960         );
    961     }
    962 
    963     #[test]
    964     fn height_3000_block_selects_applies_and_rewards_a_v2_migration() {
    965         let finalizer = Wallet::from_seed("v2-height-3000-finalizer");
    966         let migrator = Wallet::from_seed("v2-height-3000-migrator");
    967         let mut ledger = Ledger::new_with_genesis_burns_and_profile(
    968             BTreeMap::from([
    969                 (finalizer.address().to_string(), 100),
    970                 (migrator.address().to_string(), 100),
    971             ]),
    972             vec![GenesisBurn::new(finalizer.address(), 10)],
    973             1,
    974             LaunchProfile::local_testnet(),
    975         )
    976         .unwrap();
    977         ledger.chain[0].height = 2_999;
    978         for ticket in &mut ledger.tickets {
    979             ticket.eligible_from_height = 3_000;
    980             ticket.eligible_until_height = 3_000;
    981         }
    982         let anchor = ledger.build_burn_for_next_block(&finalizer, 1, 1).unwrap();
    983         ledger.submit_transaction(anchor).unwrap();
    984         let migration = ledger.build_v2_migration(&migrator, 3).unwrap();
    985         ledger.submit_transaction_v2(migration.clone()).unwrap();
    986         let timestamp_ms = ledger.tip().timestamp_ms.saturating_add(1);
    987 
    988         let prepared = ledger
    989             .prepare_next_block(finalizer.address(), timestamp_ms)
    990             .unwrap();
    991         assert_eq!(prepared.transactions_v2.len(), 1);
    992         let block = prepared.finish(&finalizer, "preverified-vdf".to_string());
    993         assert_eq!(block.reward, 4);
    994         ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
    995 
    996         assert_eq!(ledger.height(), 3_000);
    997         assert_eq!(
    998             ledger.balance_of(&migrator.hybrid_address(AddressNetwork::Testnet)),
    999             97
   1000         );
   1001         assert!(ledger.pending_v2().is_empty());
   1002     }
   1003 
   1004     #[test]
   1005     fn normal_post_activation_block_flow_shares_the_transaction_count_budget() {
   1006         let finalizer = Wallet::from_seed("v2-post-activation-count-finalizer");
   1007         let recipient = Wallet::from_seed("v2-post-activation-count-recipient");
   1008         let migrators = (0..4)
   1009             .map(|index| Wallet::from_seed(&format!("v2-post-activation-count-{index}")))
   1010             .collect::<Vec<_>>();
   1011         let legacy_senders = (0..2)
   1012             .map(|index| Wallet::from_seed(&format!("legacy-post-activation-count-{index}")))
   1013             .collect::<Vec<_>>();
   1014         let mut allocations = BTreeMap::from([
   1015             (finalizer.address().to_string(), 100),
   1016             (recipient.address().to_string(), 100),
   1017         ]);
   1018         for wallet in migrators.iter().chain(&legacy_senders) {
   1019             allocations.insert(wallet.address().to_string(), 100);
   1020         }
   1021         let mut ledger = Ledger::new_with_genesis_burns_and_profile(
   1022             allocations,
   1023             vec![GenesisBurn::new(finalizer.address(), 10)],
   1024             1,
   1025             LaunchProfile::local_testnet(),
   1026         )
   1027         .unwrap();
   1028         let next_height = post_activation_height();
   1029         set_next_height(&mut ledger, next_height);
   1030         ledger.launch_profile.max_block_transactions = 4;
   1031 
   1032         let anchor = ledger.build_burn_for_next_block(&finalizer, 1, 1).unwrap();
   1033         ledger.submit_transaction(anchor.clone()).unwrap();
   1034         for (index, wallet) in migrators.iter().enumerate() {
   1035             let migration = ledger
   1036                 .build_v2_migration(wallet, 10 + index as u64)
   1037                 .unwrap();
   1038             ledger.submit_transaction_v2(migration).unwrap();
   1039         }
   1040         for wallet in &legacy_senders {
   1041             let transfer = ledger
   1042                 .build_transfer(wallet, recipient.address(), 10, 1)
   1043                 .unwrap();
   1044             ledger.submit_transaction(transfer).unwrap();
   1045         }
   1046 
   1047         let prepared = ledger
   1048             .prepare_next_block(
   1049                 finalizer.address(),
   1050                 ledger.tip().timestamp_ms.saturating_add(1),
   1051             )
   1052             .unwrap();
   1053         assert_eq!(
   1054             prepared.transactions.len() + prepared.transactions_v2.len(),
   1055             ledger.launch_profile.max_block_transactions
   1056         );
   1057         assert!(
   1058             prepared
   1059                 .transactions
   1060                 .iter()
   1061                 .any(|transaction| transaction.signature() == anchor.signature())
   1062         );
   1063         assert!(!prepared.transactions_v2.is_empty());
   1064         let block = prepared.finish(&finalizer, "preverified-vdf".to_string());
   1065         ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
   1066 
   1067         assert_eq!(ledger.height(), next_height);
   1068         assert!(
   1069             ledger.pending().len() + ledger.pending_v2().len() > 0,
   1070             "transactions over the shared block limit must remain pending"
   1071         );
   1072     }
   1073 
   1074     #[test]
   1075     fn normal_post_activation_v2_block_enforces_the_exact_byte_boundary() {
   1076         let finalizer = Wallet::from_seed("v2-post-activation-bytes-finalizer");
   1077         let migrator = Wallet::from_seed("v2-post-activation-bytes-migrator");
   1078         let mut ledger = Ledger::new_with_genesis_burns_and_profile(
   1079             BTreeMap::from([
   1080                 (finalizer.address().to_string(), 100),
   1081                 (migrator.address().to_string(), 100),
   1082             ]),
   1083             vec![GenesisBurn::new(finalizer.address(), 10)],
   1084             1,
   1085             LaunchProfile::local_testnet(),
   1086         )
   1087         .unwrap();
   1088         let next_height = post_activation_height();
   1089         set_next_height(&mut ledger, next_height);
   1090         let anchor = ledger.build_burn_for_next_block(&finalizer, 1, 1).unwrap();
   1091         ledger.submit_transaction(anchor).unwrap();
   1092         let migration = ledger.build_v2_migration(&migrator, 3).unwrap();
   1093         ledger.submit_transaction_v2(migration).unwrap();
   1094         let prepared = ledger
   1095             .prepare_next_block(
   1096                 finalizer.address(),
   1097                 ledger.tip().timestamp_ms.saturating_add(1),
   1098             )
   1099             .unwrap();
   1100         assert_eq!(prepared.transactions_v2.len(), 1);
   1101         let block = prepared.finish(&finalizer, "preverified-vdf".to_string());
   1102         let block_bytes = ledger.consensus_block_size_bytes(&block).unwrap();
   1103 
   1104         let mut exact = ledger.clone();
   1105         exact.launch_profile.max_block_bytes = block_bytes;
   1106         exact
   1107             .apply_preverified_block_at(block.clone(), u64::MAX)
   1108             .expect("post-activation v2 block at the byte limit should validate");
   1109 
   1110         let mut one_byte_over = ledger;
   1111         one_byte_over.launch_profile.max_block_bytes = block_bytes.saturating_sub(1);
   1112         assert!(
   1113             one_byte_over
   1114                 .apply_preverified_block_at(block, u64::MAX)
   1115                 .is_err(),
   1116             "post-activation v2 block one byte over the limit validated"
   1117         );
   1118     }
   1119 }