iuna

iuna

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

consolidation.rs (12277B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 
      3 use anyhow::{Context, Result, bail};
      4 use serde::Serialize;
      5 
      6 use super::{NodeCore, helpers::converge_fee_by_byte};
      7 use crate::domain::{
      8     Amount, Ledger, OutPoint, Transaction, TransactionV2, TxOutput, hex_encode,
      9     minimum_transfer_economic_size_bytes,
     10 };
     11 
     12 const BATCH_INPUTS: usize = 128;
     13 const MAX_BATCHES: usize = 32;
     14 
     15 #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
     16 #[serde(rename_all = "snake_case")]
     17 pub(crate) enum ConsolidationKind {
     18     Legacy,
     19     Hybrid,
     20 }
     21 
     22 #[derive(Serialize)]
     23 pub(crate) struct ConsolidationBatch {
     24     pub kind: ConsolidationKind,
     25     pub utxos: Vec<OutPoint>,
     26     pub fee: Amount,
     27     pub amount: Amount,
     28     pub bytes: usize,
     29 }
     30 
     31 enum BuiltConsolidation {
     32     Legacy(Transaction),
     33     Hybrid(TransactionV2),
     34 }
     35 
     36 #[derive(Serialize)]
     37 pub(crate) struct ConsolidationPlan {
     38     pub address: String,
     39     pub before: usize,
     40     pub after: usize,
     41     pub fee: Amount,
     42     pub batches: Vec<ConsolidationBatch>,
     43 }
     44 
     45 impl NodeCore {
     46     pub(crate) fn consolidation_plan(
     47         &self,
     48         fee_per_byte: Amount,
     49         merge_roots: bool,
     50     ) -> Result<ConsolidationPlan> {
     51         let wallet = self.wallet.unlocked()?;
     52         let ledger = self.wallet_build_ledger()?;
     53         let mut confirmed = Vec::new();
     54         let mut available = BTreeSet::new();
     55         collect_consolidation_outputs(
     56             &ledger,
     57             self.wallet.address(),
     58             ConsolidationKind::Legacy,
     59             &mut confirmed,
     60             &mut available,
     61         )?;
     62         for address in ledger.wallet_owned_hybrid_encoded_addresses(wallet)? {
     63             collect_consolidation_outputs(
     64                 &ledger,
     65                 &address,
     66                 ConsolidationKind::Hybrid,
     67                 &mut confirmed,
     68                 &mut available,
     69             )?;
     70         }
     71         let pending_spent = self.wallet_pending_spent_outpoints();
     72         available.retain(|point| !pending_spent.contains(point));
     73         let before = confirmed.len();
     74         let mut candidates = confirmed
     75             .into_iter()
     76             .filter(|(_, point, _)| available.contains(point))
     77             .collect::<Vec<_>>();
     78         candidates.sort_by_key(|(kind, point, output)| (*kind, output.amount, point.clone()));
     79         // Keep the largest output of each protocol available for payments and automatic burns.
     80         for kind in [ConsolidationKind::Legacy, ConsolidationKind::Hybrid] {
     81             if let Some(index) = candidates
     82                 .iter()
     83                 .rposition(|(candidate, _, _)| *candidate == kind)
     84             {
     85                 candidates.remove(index);
     86             }
     87         }
     88         let mut groups =
     89             BTreeMap::<(ConsolidationKind, Option<OutPoint>), Vec<(OutPoint, Amount)>>::new();
     90         for (kind, point, output) in candidates {
     91             let root = if merge_roots {
     92                 None
     93             } else {
     94                 ledger.consolidation_root(&point)
     95             };
     96             groups
     97                 .entry((kind, root))
     98                 .or_default()
     99                 .push((point, output.amount));
    100         }
    101         let mut plan = ConsolidationPlan {
    102             address: self.wallet.address().to_string(),
    103             before,
    104             after: before,
    105             fee: 0,
    106             batches: Vec::new(),
    107         };
    108         for ((kind, _), group) in &groups {
    109             let mut offset = 0;
    110             while offset + 1 < group.len() && plan.batches.len() < MAX_BATCHES {
    111                 let mut count = BATCH_INPUTS.min(group.len() - offset);
    112                 // The actual builder checks the active network's block-size limit.
    113                 let built = loop {
    114                     if count < 2 {
    115                         break None;
    116                     }
    117                     let candidates = &group[offset..offset + count];
    118                     if !can_meet_consolidation_fee_cap(candidates, fee_per_byte) {
    119                         count /= 2;
    120                         continue;
    121                     }
    122                     let outpoints = candidates
    123                         .iter()
    124                         .map(|(point, _)| point.clone())
    125                         .collect::<Vec<_>>();
    126                     match self.build_consolidation(&ledger, &outpoints, fee_per_byte, merge_roots) {
    127                         Ok(value) => break Some(value),
    128                         Err(_) => count /= 2,
    129                     }
    130                 };
    131                 if let Some((_, batch)) = built {
    132                     debug_assert_eq!(batch.kind, *kind);
    133                     plan.after -= batch.utxos.len() - 1;
    134                     plan.fee = plan
    135                         .fee
    136                         .checked_add(batch.fee)
    137                         .context("total fee overflows")?;
    138                     offset += batch.utxos.len();
    139                     plan.batches.push(batch);
    140                 } else {
    141                     offset += 1;
    142                 }
    143             }
    144         }
    145         Ok(plan)
    146     }
    147 
    148     fn build_consolidation(
    149         &self,
    150         ledger: &Ledger,
    151         outpoints: &[OutPoint],
    152         fee_per_byte: Amount,
    153         merge_roots: bool,
    154     ) -> Result<(BuiltConsolidation, ConsolidationBatch)> {
    155         if !(2..=BATCH_INPUTS).contains(&outpoints.len()) {
    156             bail!("choose between 2 and 128 outputs per batch");
    157         }
    158         let wallet = self.wallet.unlocked()?;
    159         let hybrid_addresses = ledger.wallet_owned_hybrid_encoded_addresses(wallet)?;
    160         let mut confirmed = BTreeMap::new();
    161         let mut available = BTreeSet::new();
    162         for address in std::iter::once(self.wallet.address())
    163             .chain(hybrid_addresses.iter().map(String::as_str))
    164         {
    165             confirmed.extend(ledger.utxos_for_address(address));
    166             available.extend(
    167                 ledger
    168                     .available_utxos_for_address(address)?
    169                     .into_iter()
    170                     .map(|(point, _)| point),
    171             );
    172         }
    173         let pending_spent = self.wallet_pending_spent_outpoints();
    174         available.retain(|point| !pending_spent.contains(point));
    175         let mut seen = BTreeSet::new();
    176         let root = ledger.consolidation_root(&outpoints[0]);
    177         let mut total: Amount = 0;
    178         let mut kind = None;
    179         for point in outpoints {
    180             if !seen.insert(point) || !available.contains(point) {
    181                 bail!("outputs changed or are reserved; review a new preview");
    182             }
    183             if !merge_roots && ledger.consolidation_root(point) != root {
    184                 bail!("merging mining groups requires explicit consent");
    185             }
    186             let output = confirmed
    187                 .get(point)
    188                 .context("output is no longer confirmed in this wallet")?;
    189             let output_kind = if output.address == self.wallet.address() {
    190                 ConsolidationKind::Legacy
    191             } else if hybrid_addresses.contains(&output.address) {
    192                 ConsolidationKind::Hybrid
    193             } else {
    194                 bail!("output is not owned by this wallet");
    195             };
    196             if kind
    197                 .replace(output_kind)
    198                 .is_some_and(|kind| kind != output_kind)
    199             {
    200                 bail!("legacy and hybrid outputs require separate consolidation batches");
    201             }
    202             total = total
    203                 .checked_add(output.amount)
    204                 .context("input total overflows")?;
    205         }
    206         let kind = kind.context("consolidation batch has no outputs")?;
    207         let (transaction, bytes, fee) = match kind {
    208             ConsolidationKind::Legacy => {
    209                 let (transaction, estimate) = converge_fee_by_byte(fee_per_byte, |fee| {
    210                     let amount = consolidation_amount(total, fee)?;
    211                     ledger.build_transfer_with_inputs(
    212                         wallet,
    213                         self.wallet.address(),
    214                         amount,
    215                         fee,
    216                         outpoints,
    217                     )
    218                 })?;
    219                 (
    220                     BuiltConsolidation::Legacy(transaction),
    221                     estimate.bytes,
    222                     estimate.fee,
    223                 )
    224             }
    225             ConsolidationKind::Hybrid => {
    226                 let (transaction, bytes, fee) =
    227                     converge_v2_consolidation_fee(ledger, wallet, outpoints, total, fee_per_byte)?;
    228                 (BuiltConsolidation::Hybrid(transaction), bytes, fee)
    229             }
    230         };
    231         // Never recommend or accept batches spending over 1% of their value on fees.
    232         if u128::from(fee) * 100 > u128::from(total) {
    233             bail!("batch fee exceeds 1% of its value; use a lower fee or wait");
    234         }
    235         let batch = ConsolidationBatch {
    236             kind,
    237             utxos: outpoints.to_vec(),
    238             fee,
    239             amount: total - fee,
    240             bytes,
    241         };
    242         Ok((transaction, batch))
    243     }
    244 
    245     pub(crate) fn consolidate(
    246         &mut self,
    247         outpoints: &[OutPoint],
    248         fee_per_byte: Amount,
    249         max_fee: Amount,
    250         merge_roots: bool,
    251         address: &str,
    252     ) -> Result<String> {
    253         if address != self.wallet.address() {
    254             bail!("wallet changed; review a new preview");
    255         }
    256         let ledger = self.wallet_build_ledger()?;
    257         let (transaction, batch) =
    258             self.build_consolidation(&ledger, outpoints, fee_per_byte, merge_roots)?;
    259         if batch.fee > max_fee {
    260             bail!("fee exceeds the approved limit; review a new preview");
    261         }
    262         match transaction {
    263             BuiltConsolidation::Legacy(transaction) => {
    264                 let signature = transaction.signature().to_string();
    265                 self.submit_public_transaction(transaction)?;
    266                 Ok(signature)
    267             }
    268             BuiltConsolidation::Hybrid(transaction) => {
    269                 let domain = self.ledger.transaction_v2_domain()?;
    270                 let transaction_id = hex_encode(transaction.transaction_id(&domain)?);
    271                 self.submit_public_transaction_v2(transaction)?;
    272                 Ok(transaction_id)
    273             }
    274         }
    275     }
    276 }
    277 
    278 fn collect_consolidation_outputs(
    279     ledger: &Ledger,
    280     address: &str,
    281     kind: ConsolidationKind,
    282     confirmed: &mut Vec<(ConsolidationKind, OutPoint, TxOutput)>,
    283     available: &mut BTreeSet<OutPoint>,
    284 ) -> Result<()> {
    285     confirmed.extend(
    286         ledger
    287             .utxos_for_address(address)
    288             .into_iter()
    289             .map(|(point, output)| (kind, point, output)),
    290     );
    291     available.extend(
    292         ledger
    293             .available_utxos_for_address(address)?
    294             .into_iter()
    295             .map(|(point, _)| point),
    296     );
    297     Ok(())
    298 }
    299 
    300 fn consolidation_amount(total: Amount, fee: Amount) -> Result<Amount> {
    301     total
    302         .checked_sub(fee)
    303         .filter(|amount| *amount > 0)
    304         .context("outputs do not cover the network fee")
    305 }
    306 
    307 fn converge_v2_consolidation_fee(
    308     ledger: &Ledger,
    309     wallet: &crate::domain::Wallet,
    310     outpoints: &[OutPoint],
    311     total: Amount,
    312     fee_per_byte: Amount,
    313 ) -> Result<(TransactionV2, usize, Amount)> {
    314     let domain = ledger.transaction_v2_domain()?;
    315     let mut fee = 1;
    316     for _ in 0..64 {
    317         let amount = consolidation_amount(total, fee)?;
    318         let transaction =
    319             ledger.build_v2_consolidation_with_inputs(wallet, amount, fee, outpoints)?;
    320         let bytes = transaction.encoded_size_bytes(&domain)?;
    321         let required_fee = fee_per_byte
    322             .checked_mul(bytes as Amount)
    323             .context("fee per byte times transaction bytes overflows")?
    324             .max(1);
    325         if fee >= required_fee {
    326             return Ok((transaction, bytes, fee));
    327         }
    328         fee = required_fee;
    329     }
    330     bail!("hybrid consolidation fee did not converge")
    331 }
    332 
    333 fn can_meet_consolidation_fee_cap(candidates: &[(OutPoint, Amount)], fee_per_byte: Amount) -> bool {
    334     let total = candidates
    335         .iter()
    336         .map(|(_, amount)| u128::from(*amount))
    337         .sum::<u128>();
    338     let minimum_fee = (u128::from(fee_per_byte)
    339         * minimum_transfer_economic_size_bytes(candidates.len()) as u128)
    340         .max(1);
    341     minimum_fee * 100 <= total
    342 }