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 }