iuna

iuna

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

ledger_reveal.rs (39212B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 
      3 use anyhow::{Context, Result, bail};
      4 
      5 use super::ledger_ops::verify_address_signature;
      6 use super::reveal::{burn_bundle_slot_mask, burn_committee_mask};
      7 use super::{
      8     Amount, BURN_COMMITTEE_SIZE, Block, BurnBundle, BurnBundlePayload, BurnBundleSection,
      9     BurnBundleSignature, BurnCommitteeMember, FinalizerMode, Ledger, MAX_BURN_BUNDLE_BYTES,
     10     MaskedBurn, MaskedBurnV2, OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, Transaction, Wallet,
     11     hex_encode,
     12 };
     13 
     14 impl Ledger {
     15     pub fn burn_bundle_attestations_required_for_next_block(&self) -> bool {
     16         self.pending.iter().any(|transaction| {
     17             transaction.is_burn() && self.transaction_is_eligible_for_next_block(transaction)
     18         }) || self.pending_v2.iter().any(|transaction| {
     19             transaction.is_burn() && self.transaction_v2_is_eligible_for_next_block(transaction)
     20         })
     21     }
     22 
     23     pub fn explicit_burn_bundle_signatures_required_for_next_block(
     24         &self,
     25         finalizer_mode: FinalizerMode,
     26         finalizer_rank: u32,
     27         _finalizer: &str,
     28     ) -> usize {
     29         let committee_size = match finalizer_mode {
     30             FinalizerMode::Ticket => self
     31                 .burn_committee_for_next_ticket_block(finalizer_rank)
     32                 .len(),
     33             FinalizerMode::Recovery => 1,
     34         };
     35         self.required_explicit_burn_signatures(
     36             self.height() + 1,
     37             finalizer_mode,
     38             finalizer_rank,
     39             committee_size,
     40         )
     41     }
     42 
     43     pub fn build_burn_bundle(&self, wallet: &Wallet) -> Result<Option<BurnBundle>> {
     44         Ok(self.build_burn_bundles(wallet)?.into_iter().next())
     45     }
     46 
     47     pub fn build_burn_bundles(&self, wallet: &Wallet) -> Result<Vec<BurnBundle>> {
     48         let height = self.tip().height + 1;
     49         let prev_hash = self.tip().hash.clone();
     50         let memberships = self.burn_committee_memberships_for_next_block(wallet.address());
     51         if memberships.is_empty() {
     52             return Ok(Vec::new());
     53         }
     54         let burns = self
     55             .valid_pending_transactions()
     56             .into_iter()
     57             .filter(|transaction| {
     58                 transaction.is_burn() && self.transaction_is_eligible_for_next_block(transaction)
     59             })
     60             .collect::<Vec<_>>();
     61         let domain = self.transaction_v2_domain()?;
     62         let burns_v2 = self
     63             .pending_v2
     64             .iter()
     65             .filter(|transaction| {
     66                 transaction.is_burn() && self.transaction_v2_is_eligible_for_next_block(transaction)
     67             })
     68             .map(|transaction| {
     69                 Ok((
     70                     transaction.fee(),
     71                     hex_encode(transaction.transaction_id(&domain)?),
     72                     hex_encode(transaction.encode(&domain)?),
     73                 ))
     74             })
     75             .collect::<Result<Vec<_>>>()?;
     76         let mut candidates = burns
     77             .into_iter()
     78             .map(|burn| (burn.fee(), burn.signature().to_string(), Some(burn), None))
     79             .chain(
     80                 burns_v2
     81                     .into_iter()
     82                     .map(|(fee, id, envelope)| (fee, id, None, Some(envelope))),
     83             )
     84             .collect::<Vec<_>>();
     85         candidates.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
     86 
     87         let mut bundles = Vec::new();
     88         for member in memberships {
     89             let mut selected = Vec::new();
     90             let mut selected_v2 = Vec::new();
     91             for (_, _, legacy_burn, v2_envelope) in &candidates {
     92                 let mut candidate = selected.clone();
     93                 let mut candidate_v2 = selected_v2.clone();
     94                 if let Some(burn) = legacy_burn {
     95                     candidate.push(burn.clone());
     96                 }
     97                 if let Some(envelope) = v2_envelope {
     98                     candidate_v2.push(envelope.clone());
     99                 }
    100                 let bundle = wallet.burn_bundle(BurnBundlePayload {
    101                     height,
    102                     prev_hash: prev_hash.clone(),
    103                     slot: member.slot,
    104                     member: wallet.address().to_string(),
    105                     reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT)
    106                         .then(|| self.wallet_reward_address(wallet, height)),
    107                     burns: candidate.clone(),
    108                     burns_v2: candidate_v2.clone(),
    109                 });
    110                 if bundle.serialized_size_bytes()? <= MAX_BURN_BUNDLE_BYTES {
    111                     selected = candidate;
    112                     selected_v2 = candidate_v2;
    113                 }
    114             }
    115             bundles.push(
    116                 wallet.burn_bundle(BurnBundlePayload {
    117                     height,
    118                     prev_hash: prev_hash.clone(),
    119                     slot: member.slot,
    120                     member: wallet.address().to_string(),
    121                     reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT)
    122                         .then(|| self.wallet_reward_address(wallet, height)),
    123                     burns: selected,
    124                     burns_v2: selected_v2,
    125                 }),
    126             );
    127         }
    128         Ok(bundles)
    129     }
    130 
    131     pub fn validate_next_block_burn_bundles(
    132         &self,
    133         bundles: Vec<BurnBundle>,
    134     ) -> Result<Vec<BurnBundle>> {
    135         let expected_height = self.tip().height + 1;
    136         let expected_prev_hash = self.tip().hash.clone();
    137         self.validate_burn_bundles_for_any_next_ticket_block(
    138             expected_height,
    139             &expected_prev_hash,
    140             bundles,
    141         )
    142     }
    143 
    144     pub(super) fn validate_next_block_burn_bundles_for_finalizer_rank(
    145         &self,
    146         finalizer_rank: u32,
    147         bundles: Vec<BurnBundle>,
    148     ) -> Result<Vec<BurnBundle>> {
    149         let expected_height = self.tip().height + 1;
    150         let expected_prev_hash = self.tip().hash.clone();
    151         let committee = self
    152             .burn_committee_for_next_ticket_block(finalizer_rank)
    153             .into_iter()
    154             .map(|member| (member.slot, member))
    155             .collect::<BTreeMap<_, _>>();
    156         let validated = self.validate_burn_bundles_for_committee(
    157             expected_height,
    158             &expected_prev_hash,
    159             &committee,
    160             bundles,
    161         )?;
    162         let required_signatures = self.required_explicit_burn_signatures(
    163             expected_height,
    164             FinalizerMode::Ticket,
    165             finalizer_rank,
    166             committee.len(),
    167         );
    168         let explicit_signatures = validated.iter().filter(|bundle| bundle.slot != 0).count();
    169         if explicit_signatures < required_signatures {
    170             bail!(
    171                 "not enough burn bundle signatures collected: got {explicit_signatures}, need {required_signatures}"
    172             );
    173         }
    174         Ok(validated)
    175     }
    176 
    177     pub(crate) fn precheck_next_block_burn_bundle(&self, bundle: &BurnBundle) -> Result<()> {
    178         let expected_height = self.tip().height + 1;
    179         let expected_prev_hash = self.tip().hash.clone();
    180         let max_rank = self
    181             .finalizer_rank_count_for_next_block()
    182             .min(BURN_COMMITTEE_SIZE);
    183         let mut first_error = None;
    184         for rank in 0..max_rank {
    185             let committee = self
    186                 .burn_committee_for_next_ticket_block(rank as u32)
    187                 .into_iter()
    188                 .map(|member| (member.slot, member))
    189                 .collect::<BTreeMap<_, _>>();
    190             match self.precheck_burn_bundle_for_block(
    191                 expected_height,
    192                 &expected_prev_hash,
    193                 &committee,
    194                 bundle,
    195             ) {
    196                 Ok(()) => return Ok(()),
    197                 Err(error) => {
    198                     if first_error.is_none() {
    199                         first_error = Some(error);
    200                     }
    201                 }
    202             }
    203         }
    204         Err(first_error.unwrap_or_else(|| anyhow::anyhow!("burn bundle slot is not assigned")))
    205     }
    206 
    207     #[cfg(test)]
    208     pub fn test_burn_bundle(&self, wallet: &Wallet, burns: Vec<Transaction>) -> BurnBundle {
    209         let height = self.tip().height + 1;
    210         let prev_hash = self.tip().hash.clone();
    211         let member = self
    212             .burn_committee_for_next_block()
    213             .into_iter()
    214             .find(|member| member.owner == wallet.address())
    215             .expect("test wallet must be a burn committee member");
    216         wallet.burn_bundle(BurnBundlePayload {
    217             height,
    218             prev_hash,
    219             slot: member.slot,
    220             member: wallet.address().to_string(),
    221             reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT)
    222                 .then(|| self.wallet_reward_address(wallet, height)),
    223             burns,
    224             burns_v2: Vec::new(),
    225         })
    226     }
    227 
    228     pub(super) fn burn_bundle_section_from_bundles(
    229         &self,
    230         bundles: Vec<BurnBundle>,
    231     ) -> Result<BurnBundleSection> {
    232         let signatures = bundles
    233             .iter()
    234             .filter(|bundle| bundle.slot != 0)
    235             .map(|bundle| BurnBundleSignature {
    236                 slot: bundle.slot,
    237                 member: bundle.member.clone(),
    238                 reward_address: bundle.reward_address.clone(),
    239                 signature: bundle.signature.clone(),
    240             })
    241             .collect::<Vec<_>>();
    242         let mut by_signature: BTreeMap<String, MaskedBurn> = BTreeMap::new();
    243         let mut by_v2_id: BTreeMap<String, MaskedBurnV2> = BTreeMap::new();
    244         for bundle in bundles {
    245             let slot_mask = if bundle.slot == 0 {
    246                 0
    247             } else {
    248                 burn_bundle_slot_mask(bundle.slot).unwrap_or(0)
    249             };
    250             for burn in bundle.burns {
    251                 by_signature
    252                     .entry(burn.signature().to_string())
    253                     .and_modify(|masked| masked.bundle_mask |= slot_mask)
    254                     .or_insert(MaskedBurn {
    255                         burn,
    256                         bundle_mask: slot_mask,
    257                     });
    258             }
    259             for envelope in bundle.burns_v2 {
    260                 by_v2_id
    261                     .entry(envelope.clone())
    262                     .and_modify(|masked| masked.bundle_mask |= slot_mask)
    263                     .or_insert(MaskedBurnV2 {
    264                         envelope,
    265                         bundle_mask: slot_mask,
    266                     });
    267             }
    268         }
    269         let mut burns = by_signature.into_values().collect::<Vec<_>>();
    270         burns.sort_by(|left, right| {
    271             right
    272                 .burn
    273                 .fee()
    274                 .cmp(&left.burn.fee())
    275                 .then_with(|| left.burn.signature().cmp(right.burn.signature()))
    276         });
    277         let domain = self.transaction_v2_domain()?;
    278         let mut burns_v2 = by_v2_id
    279             .into_values()
    280             .map(|masked| {
    281                 let burn = super::ledger_v2::decode_canonical_transaction_v2_envelope(
    282                     &masked.envelope,
    283                     &domain,
    284                 )?;
    285                 Ok((
    286                     burn.fee(),
    287                     hex_encode(burn.transaction_id(&domain)?),
    288                     masked,
    289                 ))
    290             })
    291             .collect::<Result<Vec<_>>>()?;
    292         burns_v2.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
    293         Ok(BurnBundleSection {
    294             signatures,
    295             burns,
    296             burns_v2: burns_v2.into_iter().map(|(_, _, masked)| masked).collect(),
    297         })
    298     }
    299 
    300     pub(super) fn validate_burn_bundle_section_for_block(&self, block: &Block) -> Result<()> {
    301         let section = &block.burn_bundle_section;
    302         if section.signatures.len() > BURN_COMMITTEE_SIZE.saturating_sub(1) {
    303             bail!("block has too many burn bundle signatures");
    304         }
    305         if section
    306             .signatures
    307             .windows(2)
    308             .any(|pair| pair[0].slot >= pair[1].slot)
    309         {
    310             bail!("burn bundle signatures are not in slot order");
    311         }
    312         let committee = self
    313             .burn_committee_for_block(block)
    314             .into_iter()
    315             .map(|member| (member.slot, member))
    316             .collect::<BTreeMap<_, _>>();
    317         let mut seen_slots = BTreeSet::new();
    318         let mut seen_members = BTreeSet::new();
    319         let mut included_mask = 0_u8;
    320         for signature in &section.signatures {
    321             if usize::from(signature.slot) >= BURN_COMMITTEE_SIZE {
    322                 bail!("burn bundle slot is invalid");
    323             }
    324             if signature.slot == 0 {
    325                 bail!("finalizer burn attestation is implicit");
    326             }
    327             if !seen_slots.insert(signature.slot) {
    328                 bail!("duplicate burn bundle slot");
    329             }
    330             if !seen_members.insert(signature.member.clone()) {
    331                 bail!("duplicate burn bundle member");
    332             }
    333             let member = committee
    334                 .get(&signature.slot)
    335                 .context("burn bundle slot is not assigned")?;
    336             if signature.member != member.owner {
    337                 bail!("burn bundle member is not assigned to slot");
    338             }
    339             self.validate_reward_address(
    340                 block.height,
    341                 signature.reward_address.as_deref(),
    342                 "committee member",
    343             )?;
    344             included_mask |= burn_bundle_slot_mask(signature.slot)?;
    345         }
    346         let required_signatures = self.required_explicit_burn_signatures(
    347             block.height,
    348             block.finalizer_mode,
    349             block.finalizer_rank,
    350             committee.len(),
    351         );
    352         if section.signatures.len() < required_signatures {
    353             bail!(
    354                 "block has too few burn bundle signatures: got {}, need {required_signatures}",
    355                 section.signatures.len()
    356             );
    357         }
    358 
    359         let mut seen_burns = BTreeSet::new();
    360         let mut previous_key: Option<(Amount, String)> = None;
    361         for masked in &section.burns {
    362             if masked.bundle_mask & !burn_committee_mask() != 0 {
    363                 bail!("masked burn references an invalid burn bundle slot");
    364             }
    365             if masked.bundle_mask & !included_mask != 0 {
    366                 bail!("masked burn references a missing burn bundle signature");
    367             }
    368             if !seen_burns.insert(masked.burn.signature().to_string()) {
    369                 bail!("duplicate burn in burn bundle section");
    370             }
    371             if !masked.burn.is_burn() {
    372                 bail!("burn bundle section contains a non-burn transaction");
    373             }
    374             if matching_burn_by_signature(&masked.burn, &block.transactions).is_none() {
    375                 bail!("attested burn is not included in the block");
    376             }
    377             self.validate_transaction_terms(&masked.burn)?;
    378             let key = (masked.burn.fee(), masked.burn.signature().to_string());
    379             if let Some((previous_fee, previous_signature)) = &previous_key {
    380                 if key.0 > *previous_fee || key.0 == *previous_fee && key.1 < *previous_signature {
    381                     bail!("burn bundle section is not fee ordered");
    382                 }
    383             }
    384             previous_key = Some(key);
    385         }
    386         let domain = self.transaction_v2_domain()?;
    387         let mut seen_burns_v2 = BTreeSet::new();
    388         for masked in &section.burns_v2 {
    389             if masked.bundle_mask & !burn_committee_mask() != 0 {
    390                 bail!("masked transaction v2 burn references an invalid burn bundle slot");
    391             }
    392             if masked.bundle_mask & !included_mask != 0 {
    393                 bail!("masked transaction v2 burn references a missing burn bundle signature");
    394             }
    395             let burn = super::ledger_v2::decode_canonical_transaction_v2_envelope(
    396                 &masked.envelope,
    397                 &domain,
    398             )?;
    399             if !burn.is_burn() {
    400                 bail!("burn bundle section contains a non-burn transaction v2");
    401             }
    402             let id = hex_encode(burn.transaction_id(&domain)?);
    403             if !seen_burns_v2.insert(id) {
    404                 bail!("duplicate transaction v2 burn in burn bundle section");
    405             }
    406             if !block.transactions_v2.contains(&masked.envelope) {
    407                 bail!("attested transaction v2 burn is not included in the block");
    408             }
    409             self.validate_transaction_v2_anchor_for_block(&burn, block.height)?;
    410         }
    411 
    412         for bundle in section.expand(block.height, &block.prev_hash) {
    413             if bundle.serialized_size_bytes()? > MAX_BURN_BUNDLE_BYTES {
    414                 bail!("burn bundle exceeds max size");
    415             }
    416             verify_address_signature(
    417                 &bundle.member,
    418                 &bundle.canonical_payload(),
    419                 &bundle.signature,
    420                 "burn bundle",
    421             )?;
    422         }
    423         Ok(())
    424     }
    425 
    426     fn required_explicit_burn_signatures(
    427         &self,
    428         height: u64,
    429         finalizer_mode: FinalizerMode,
    430         finalizer_rank: u32,
    431         committee_size: usize,
    432     ) -> usize {
    433         if committee_size == 0 {
    434             return 0;
    435         }
    436         match finalizer_mode {
    437             FinalizerMode::Ticket
    438                 if finalizer_rank == 0 && height >= OBJECTIVE_FINALITY_ACTIVATION_HEIGHT =>
    439             {
    440                 objective_finality_quorum(committee_size).saturating_sub(1)
    441             }
    442             FinalizerMode::Ticket if finalizer_rank == 0 => committee_size.min(3).saturating_sub(1),
    443             FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.min(2).saturating_sub(1),
    444             FinalizerMode::Ticket => 0,
    445             FinalizerMode::Recovery => 0,
    446         }
    447     }
    448 
    449     pub(super) fn block_certifies_parent(&self, block: &Block, committee_size: usize) -> bool {
    450         block.height > OBJECTIVE_FINALITY_ACTIVATION_HEIGHT
    451             && block.finalizer_mode == FinalizerMode::Ticket
    452             && block.finalizer_rank == 0
    453             && committee_size > 0
    454             && block.burn_bundle_section.signatures.len() + 1
    455                 >= objective_finality_quorum(committee_size)
    456     }
    457 
    458     fn validate_burn_bundles_for_any_next_ticket_block(
    459         &self,
    460         expected_height: u64,
    461         expected_prev_hash: &str,
    462         bundles: Vec<BurnBundle>,
    463     ) -> Result<Vec<BurnBundle>> {
    464         let max_rank = self
    465             .finalizer_rank_count_for_next_block()
    466             .min(BURN_COMMITTEE_SIZE);
    467         let mut first_error = None;
    468         for rank in 0..max_rank {
    469             let committee = self
    470                 .burn_committee_for_next_ticket_block(rank as u32)
    471                 .into_iter()
    472                 .map(|member| (member.slot, member))
    473                 .collect::<BTreeMap<_, _>>();
    474             match self.validate_burn_bundles_for_committee(
    475                 expected_height,
    476                 expected_prev_hash,
    477                 &committee,
    478                 bundles.clone(),
    479             ) {
    480                 Ok(validated) => return Ok(validated),
    481                 Err(error) => {
    482                     if first_error.is_none() {
    483                         first_error = Some(error);
    484                     }
    485                 }
    486             }
    487         }
    488         Err(first_error.unwrap_or_else(|| anyhow::anyhow!("no ticket committee is available")))
    489     }
    490 
    491     fn validate_burn_bundles_for_committee(
    492         &self,
    493         expected_height: u64,
    494         expected_prev_hash: &str,
    495         committee: &BTreeMap<u8, BurnCommitteeMember>,
    496         mut bundles: Vec<BurnBundle>,
    497     ) -> Result<Vec<BurnBundle>> {
    498         if bundles.len() > BURN_COMMITTEE_SIZE {
    499             bail!("block has too many burn bundles");
    500         }
    501         bundles.sort_by_key(|bundle| bundle.slot);
    502         if bundles.windows(2).any(|pair| pair[0].slot == pair[1].slot) {
    503             bail!("duplicate burn bundle slot");
    504         }
    505         let mut seen_members = BTreeSet::new();
    506         let domain = self.transaction_v2_domain()?;
    507         for bundle in &bundles {
    508             if !seen_members.insert(bundle.member.clone()) {
    509                 bail!("duplicate burn bundle member");
    510             }
    511             self.precheck_burn_bundle_for_block(
    512                 expected_height,
    513                 expected_prev_hash,
    514                 committee,
    515                 bundle,
    516             )?;
    517             for burn in &bundle.burns {
    518                 if matching_burn_by_signature(burn, &self.pending).is_none() {
    519                     bail!("burn bundle references a burn that is not in the mempool");
    520                 }
    521             }
    522             for envelope in &bundle.burns_v2 {
    523                 let burn =
    524                     super::ledger_v2::decode_canonical_transaction_v2_envelope(envelope, &domain)?;
    525                 let id = burn.transaction_id(&domain)?;
    526                 if !self
    527                     .pending_v2
    528                     .iter()
    529                     .any(|pending| pending.transaction_id(&domain).ok() == Some(id))
    530                 {
    531                     bail!(
    532                         "burn bundle references a transaction v2 burn that is not in the mempool"
    533                     );
    534                 }
    535             }
    536         }
    537         Ok(bundles)
    538     }
    539 
    540     fn precheck_burn_bundle_for_block(
    541         &self,
    542         expected_height: u64,
    543         expected_prev_hash: &str,
    544         committee: &BTreeMap<u8, BurnCommitteeMember>,
    545         bundle: &BurnBundle,
    546     ) -> Result<()> {
    547         if bundle.height != expected_height {
    548             bail!("burn bundle height is invalid");
    549         }
    550         if bundle.prev_hash != expected_prev_hash {
    551             return Err(super::ValidationError::BurnBundleParentMismatch.into());
    552         }
    553         if usize::from(bundle.slot) >= BURN_COMMITTEE_SIZE {
    554             bail!("burn bundle slot is invalid");
    555         }
    556         let member = committee
    557             .get(&bundle.slot)
    558             .context("burn bundle slot is not assigned")?;
    559         if bundle.member != member.owner {
    560             bail!("burn bundle member is not assigned to slot");
    561         }
    562         self.validate_reward_address(
    563             expected_height,
    564             bundle.reward_address.as_deref(),
    565             "committee member",
    566         )?;
    567         if bundle.serialized_size_bytes()? > MAX_BURN_BUNDLE_BYTES {
    568             bail!("burn bundle exceeds max size");
    569         }
    570         verify_address_signature(
    571             &bundle.member,
    572             &bundle.canonical_payload(),
    573             &bundle.signature,
    574             "burn bundle",
    575         )?;
    576         let mut seen_bundle_burns = BTreeSet::new();
    577         let mut previous_key: Option<(Amount, String)> = None;
    578         for burn in &bundle.burns {
    579             if !seen_bundle_burns.insert(burn.signature().to_string()) {
    580                 bail!("duplicate burn in burn bundle");
    581             }
    582             if !burn.is_burn() {
    583                 bail!("burn bundle contains a non-burn transaction");
    584             }
    585             self.validate_transaction_terms(burn)?;
    586             self.validate_transaction_anchor_for_block(burn, expected_height)?;
    587             let key = (burn.fee(), burn.signature().to_string());
    588             if let Some((previous_fee, previous_signature)) = &previous_key {
    589                 if key.0 > *previous_fee || key.0 == *previous_fee && key.1 < *previous_signature {
    590                     bail!("burn bundle is not fee ordered");
    591                 }
    592             }
    593             previous_key = Some(key);
    594         }
    595         let domain = self.transaction_v2_domain()?;
    596         let mut seen_bundle_burns_v2 = BTreeSet::new();
    597         let mut previous_v2_key: Option<(Amount, String)> = None;
    598         for envelope in &bundle.burns_v2 {
    599             let burn =
    600                 super::ledger_v2::decode_canonical_transaction_v2_envelope(envelope, &domain)?;
    601             if !burn.is_burn() {
    602                 bail!("burn bundle contains a non-burn transaction v2");
    603             }
    604             let id = hex_encode(burn.transaction_id(&domain)?);
    605             if !seen_bundle_burns_v2.insert(id.clone()) {
    606                 bail!("duplicate transaction v2 burn in burn bundle");
    607             }
    608             self.validate_transaction_v2_anchor_for_block(&burn, expected_height)?;
    609             let key = (burn.fee(), id);
    610             if let Some((previous_fee, previous_id)) = &previous_v2_key {
    611                 if key.0 > *previous_fee || key.0 == *previous_fee && key.1 < *previous_id {
    612                     bail!("transaction v2 burn bundle is not fee ordered");
    613                 }
    614             }
    615             previous_v2_key = Some(key);
    616         }
    617         Ok(())
    618     }
    619 }
    620 
    621 pub(super) fn objective_finality_quorum(committee_size: usize) -> usize {
    622     if committee_size == 0 {
    623         0
    624     } else {
    625         committee_size.saturating_mul(2) / 3 + 1
    626     }
    627 }
    628 
    629 fn matching_burn_by_signature<'a>(
    630     attested: &Transaction,
    631     transactions: &'a [Transaction],
    632 ) -> Option<&'a Transaction> {
    633     transactions.iter().find(|transaction| {
    634         transaction.is_burn()
    635             && transaction.signature() == attested.signature()
    636             && transaction.canonical() == attested.canonical()
    637     })
    638 }
    639 
    640 #[cfg(test)]
    641 mod tests {
    642     use std::collections::BTreeMap;
    643 
    644     use super::*;
    645     use crate::domain::{
    646         GenesisBurn, MICRO_IUNA, OutPoint, TxInput, TxOutput, UtxoLineageRoot, Wallet,
    647     };
    648 
    649     fn ledger() -> Ledger {
    650         Ledger::new(BTreeMap::new(), 1)
    651     }
    652 
    653     fn burn(signature: &str, fee: Amount) -> Transaction {
    654         Transaction::Burn {
    655             inputs: vec![TxInput {
    656                 outpoint: OutPoint {
    657                     txid: format!("{signature:0<64}"),
    658                     index: 0,
    659                 },
    660                 owner: "owner".to_string(),
    661                 signature: signature.to_string(),
    662             }],
    663             change: vec![TxOutput {
    664                 address: "owner".to_string(),
    665                 amount: 1,
    666             }],
    667             amount: 1,
    668             fee,
    669             anchor: None,
    670             signature: signature.to_string(),
    671         }
    672     }
    673 
    674     fn funded_ledger(wallets: &[Wallet]) -> Ledger {
    675         let allocations = wallets
    676             .iter()
    677             .map(|wallet| (wallet.address().to_string(), 10 * MICRO_IUNA))
    678             .collect::<BTreeMap<_, _>>();
    679         let genesis_burns = wallets
    680             .iter()
    681             .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA))
    682             .collect::<Vec<_>>();
    683         Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap()
    684     }
    685 
    686     #[test]
    687     fn burn_quorum_depends_on_rank_and_available_committee() {
    688         let ledger = ledger();
    689 
    690         assert_eq!(
    691             ledger.required_explicit_burn_signatures(999, FinalizerMode::Ticket, 0, 5),
    692             2
    693         );
    694         assert_eq!(
    695             ledger.required_explicit_burn_signatures(999, FinalizerMode::Ticket, 0, 4),
    696             2
    697         );
    698         assert_eq!(
    699             ledger.required_explicit_burn_signatures(999, FinalizerMode::Ticket, 0, 3),
    700             2
    701         );
    702         assert_eq!(
    703             ledger.required_explicit_burn_signatures(999, FinalizerMode::Ticket, 0, 2),
    704             1
    705         );
    706         assert_eq!(
    707             ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 0, 5),
    708             3
    709         );
    710         assert_eq!(
    711             ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 0, 4),
    712             2
    713         );
    714         assert_eq!(
    715             ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 0, 3),
    716             2
    717         );
    718         assert_eq!(
    719             ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 1, 5),
    720             1
    721         );
    722         assert_eq!(
    723             ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 1, 2),
    724             1
    725         );
    726         assert_eq!(
    727             ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 2, 3),
    728             0
    729         );
    730         assert_eq!(
    731             ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Recovery, 0, 3),
    732             0
    733         );
    734         assert_eq!(
    735             ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 0, 1),
    736             0
    737         );
    738     }
    739 
    740     #[test]
    741     fn finalizer_waits_until_the_required_burn_bundle_quorum_is_available() {
    742         let wallets = (0..3)
    743             .map(|index| Wallet::from_seed(&format!("bundle-quorum-wallet-{index}")))
    744             .collect::<Vec<_>>();
    745         let mut ledger = funded_ledger(&wallets);
    746         ledger.launch_profile.burn_lineage_maturity_heights = 0;
    747         for (index, wallet) in wallets.iter().enumerate() {
    748             let outpoint = OutPoint {
    749                 txid: format!("{:064x}", index + 1),
    750                 index: 0,
    751             };
    752             let root = UtxoLineageRoot {
    753                 outpoint: outpoint.clone(),
    754                 height: 0,
    755             };
    756             ledger.lineage_values.insert(root.clone(), 10);
    757             ledger.lineage_owners.insert(
    758                 root,
    759                 BTreeMap::from([(
    760                     wallet.address().to_string(),
    761                     BTreeMap::from([(outpoint, 10)]),
    762                 )]),
    763             );
    764         }
    765         let committee_size = ledger.burn_committee_for_next_ticket_block(0).len();
    766         assert!(
    767             ledger.required_explicit_burn_signatures(
    768                 ledger.height() + 1,
    769                 FinalizerMode::Ticket,
    770                 0,
    771                 committee_size,
    772             ) > 0
    773         );
    774 
    775         let error = ledger
    776             .validate_next_block_burn_bundles_for_finalizer_rank(0, Vec::new())
    777             .unwrap_err();
    778 
    779         assert!(
    780             error
    781                 .to_string()
    782                 .contains("not enough burn bundle signatures collected")
    783         );
    784     }
    785 
    786     #[test]
    787     fn only_post_activation_rank_zero_quorum_certifies_its_parent() {
    788         let ledger = ledger();
    789         let mut block = ledger.tip().clone();
    790         block.height = 1_001;
    791         block.finalizer_mode = FinalizerMode::Ticket;
    792         block.finalizer_rank = 0;
    793         block.burn_bundle_section.signatures = (1..=3)
    794             .map(|slot| BurnBundleSignature {
    795                 slot,
    796                 member: format!("member-{slot}"),
    797                 reward_address: None,
    798                 signature: format!("signature-{slot}"),
    799             })
    800             .collect();
    801 
    802         assert!(ledger.block_certifies_parent(&block, 5));
    803 
    804         block.height = OBJECTIVE_FINALITY_ACTIVATION_HEIGHT;
    805         assert!(!ledger.block_certifies_parent(&block, 5));
    806         block.height += 1;
    807         block.burn_bundle_section.signatures.pop();
    808         assert!(!ledger.block_certifies_parent(&block, 5));
    809         block
    810             .burn_bundle_section
    811             .signatures
    812             .push(BurnBundleSignature {
    813                 slot: 3,
    814                 member: "member-3".to_string(),
    815                 reward_address: None,
    816                 signature: "signature-3".to_string(),
    817             });
    818         block.finalizer_rank = 1;
    819         assert!(!ledger.block_certifies_parent(&block, 5));
    820     }
    821 
    822     #[test]
    823     fn burn_bundle_section_deduplicates_burns_and_tracks_member_masks() {
    824         let ledger = ledger();
    825         let high_fee_burn = burn("a", 10);
    826         let low_fee_burn = burn("b", 1);
    827         let bundles = vec![
    828             BurnBundle {
    829                 height: 1,
    830                 prev_hash: "parent".to_string(),
    831                 slot: 1,
    832                 member: "member-1".to_string(),
    833                 reward_address: None,
    834                 burns: vec![low_fee_burn.clone(), high_fee_burn.clone()],
    835                 burns_v2: Vec::new(),
    836                 signature: "sig-1".to_string(),
    837             },
    838             BurnBundle {
    839                 height: 1,
    840                 prev_hash: "parent".to_string(),
    841                 slot: 2,
    842                 member: "member-2".to_string(),
    843                 reward_address: None,
    844                 burns: vec![high_fee_burn.clone()],
    845                 burns_v2: Vec::new(),
    846                 signature: "sig-2".to_string(),
    847             },
    848         ];
    849 
    850         let section = ledger.burn_bundle_section_from_bundles(bundles).unwrap();
    851 
    852         assert_eq!(section.signatures.len(), 2);
    853         assert_eq!(section.burns.len(), 2);
    854         assert_eq!(section.burns[0].burn.signature(), high_fee_burn.signature());
    855         assert_eq!(
    856             section.burns[0].bundle_mask,
    857             burn_bundle_slot_mask(1).unwrap() | burn_bundle_slot_mask(2).unwrap()
    858         );
    859         assert_eq!(section.burns[1].burn.signature(), low_fee_burn.signature());
    860         assert_eq!(
    861             section.burns[1].bundle_mask,
    862             burn_bundle_slot_mask(1).unwrap()
    863         );
    864 
    865         let expanded = section.expand(1, "parent");
    866         assert_eq!(expanded[0].burns, vec![high_fee_burn.clone(), low_fee_burn]);
    867         assert_eq!(expanded[1].burns, vec![high_fee_burn]);
    868     }
    869 
    870     #[test]
    871     fn burn_bundle_rejects_different_burn_with_same_signature() {
    872         let alice = Wallet::from_seed("bundle-match-alice");
    873         let bob = Wallet::from_seed("bundle-match-bob");
    874         let mut ledger = funded_ledger(&[alice.clone(), bob.clone()]);
    875         let pending_burn = ledger.build_burn(&bob, 1, 1).unwrap();
    876         ledger.submit_transaction(pending_burn.clone()).unwrap();
    877         let member = ledger
    878             .burn_committee_for_next_block()
    879             .into_iter()
    880             .find(|member| member.owner == alice.address())
    881             .expect("alice should be in the burn committee");
    882         let mut attested_burn = pending_burn.clone();
    883         if let Transaction::Burn { amount, .. } = &mut attested_burn {
    884             *amount += 1;
    885         }
    886         let bundle = alice.burn_bundle(BurnBundlePayload {
    887             height: ledger.height() + 1,
    888             prev_hash: ledger.tip_hash().to_string(),
    889             slot: member.slot,
    890             member: alice.address().to_string(),
    891             reward_address: None,
    892             burns: vec![attested_burn],
    893             burns_v2: Vec::new(),
    894         });
    895 
    896         let error = ledger
    897             .validate_next_block_burn_bundles(vec![bundle])
    898             .unwrap_err();
    899 
    900         assert!(
    901             error
    902                 .to_string()
    903                 .contains("burn bundle references a burn that is not in the mempool")
    904         );
    905     }
    906 
    907     #[test]
    908     fn burn_bundle_rejects_a_burn_queued_for_the_following_height() {
    909         let member = Wallet::from_seed("future-burn-bundle-member");
    910         let mut ledger = funded_ledger(std::slice::from_ref(&member));
    911         let tip = ledger.chain.last_mut().unwrap();
    912         tip.height = super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1;
    913         tip.prev_hash = "a".repeat(64);
    914         tip.hash = "b".repeat(64);
    915         let future_burn = ledger.build_burn(&member, 1, 1).unwrap();
    916         let bundle = member.burn_bundle(BurnBundlePayload {
    917             height: ledger.height() + 1,
    918             prev_hash: ledger.tip_hash().to_string(),
    919             slot: 1,
    920             member: member.address().to_string(),
    921             reward_address: None,
    922             burns: vec![future_burn],
    923             burns_v2: Vec::new(),
    924         });
    925         let committee = BTreeMap::from([(
    926             1,
    927             BurnCommitteeMember {
    928                 slot: 1,
    929                 root: "c".repeat(64),
    930                 owner: member.address().to_string(),
    931                 weight: 1,
    932             },
    933         )]);
    934 
    935         let error = ledger
    936             .precheck_burn_bundle_for_block(
    937                 ledger.height() + 1,
    938                 ledger.tip_hash(),
    939                 &committee,
    940                 &bundle,
    941             )
    942             .unwrap_err();
    943 
    944         assert!(error.to_string().contains("block grandparent"));
    945     }
    946 
    947     #[test]
    948     fn invalid_committee_bundle_signature_is_rejected() {
    949         let alice = Wallet::from_seed("bundle-signature-alice");
    950         let bob = Wallet::from_seed("bundle-signature-bob");
    951         let ledger = funded_ledger(&[alice.clone(), bob.clone()]);
    952         let finalizer_address = ledger.expected_leader_for_next_block().unwrap();
    953         let finalizer = [&alice, &bob]
    954             .into_iter()
    955             .find(|wallet| wallet.address() == finalizer_address)
    956             .unwrap();
    957         let mut bundle = ledger.test_burn_bundle(finalizer, Vec::new());
    958         let replacement = if bundle.signature.starts_with('0') {
    959             "1"
    960         } else {
    961             "0"
    962         };
    963         bundle.signature.replace_range(0..1, replacement);
    964 
    965         assert!(
    966             ledger
    967                 .validate_next_block_burn_bundles(vec![bundle])
    968                 .unwrap_err()
    969                 .to_string()
    970                 .contains("signature")
    971         );
    972     }
    973 
    974     #[test]
    975     fn block_bundle_validation_rejects_duplicate_slots() {
    976         let alice = Wallet::from_seed("duplicate-slot-alice");
    977         let bob = Wallet::from_seed("duplicate-slot-bob");
    978         let ledger = funded_ledger(&[alice.clone(), bob.clone()]);
    979         let finalizer_address = ledger.expected_leader_for_next_block().unwrap();
    980         let finalizer = [&alice, &bob]
    981             .into_iter()
    982             .find(|wallet| wallet.address() == finalizer_address)
    983             .unwrap();
    984         let bundle = ledger.test_burn_bundle(finalizer, Vec::new());
    985 
    986         assert!(
    987             ledger
    988                 .validate_next_block_burn_bundles(vec![bundle.clone(), bundle])
    989                 .unwrap_err()
    990                 .to_string()
    991                 .contains("duplicate burn bundle slot")
    992         );
    993     }
    994 
    995     #[test]
    996     fn attested_burn_omitted_from_block_is_rejected_without_consulting_mempool_policy() {
    997         let alice = Wallet::from_seed("required-burn-alice");
    998         let bob = Wallet::from_seed("required-burn-bob");
    999         let mut ledger = funded_ledger(&[alice.clone(), bob.clone()]);
   1000         let finalizer_address = ledger.expected_leader_for_next_block().unwrap();
   1001         let finalizer = [&alice, &bob]
   1002             .into_iter()
   1003             .find(|wallet| wallet.address() == finalizer_address)
   1004             .unwrap();
   1005         let other = [&alice, &bob]
   1006             .into_iter()
   1007             .find(|wallet| wallet.address() != finalizer_address)
   1008             .unwrap();
   1009         let pending_burn = ledger.build_burn(other, 1, 1).unwrap();
   1010         ledger.submit_transaction(pending_burn.clone()).unwrap();
   1011         let anchor = ledger.build_burn(finalizer, 1, 1).unwrap();
   1012         ledger.submit_transaction(anchor).unwrap();
   1013         let mut block = ledger
   1014             .prepare_next_block(finalizer.address(), 1)
   1015             .unwrap()
   1016             .finish(finalizer, "unused-vdf".to_string());
   1017         let bundle = ledger.test_burn_bundle(finalizer, vec![pending_burn.clone()]);
   1018         block.burn_bundle_section = ledger
   1019             .burn_bundle_section_from_bundles(vec![bundle])
   1020             .unwrap();
   1021         block
   1022             .transactions
   1023             .retain(|transaction| transaction.signature() != pending_burn.signature());
   1024 
   1025         assert!(
   1026             ledger
   1027                 .validate_burn_bundle_section_for_block(&block)
   1028                 .unwrap_err()
   1029                 .to_string()
   1030                 .contains("attested burn is not included")
   1031         );
   1032     }
   1033 }