iuna

iuna

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

commit 92f5c1e75622b9ae313120d84ce1c76ab471252c
parent c07dea3c0eb7bf75908170e5676ed774a010e406
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue,  8 Sep 2026 09:26:38 +0200

Optimize wallet consolidation preview

Diffstat:
Msrc/app/consolidation.rs | 35+++++++++++++++++++++++++----------
Msrc/domain.rs | 1+
Msrc/domain/consolidation_tests.rs | 10++++++++++
Msrc/domain/validation.rs | 20++++++++++++++++++--
4 files changed, 54 insertions(+), 12 deletions(-)

diff --git a/src/app/consolidation.rs b/src/app/consolidation.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result, bail}; use serde::Serialize; use super::{NodeCore, helpers::converge_fee_by_byte}; -use crate::domain::{Amount, Ledger, OutPoint, Transaction}; +use crate::domain::{Amount, Ledger, OutPoint, Transaction, minimum_transfer_economic_size_bytes}; const BATCH_INPUTS: usize = 128; const MAX_BATCHES: usize = 32; @@ -48,14 +48,14 @@ impl NodeCore { candidates.sort_by_key(|(point, output)| (output.amount, point.clone())); // Leave the largest spendable output for payments and automatic burns. candidates.pop(); - let mut groups = BTreeMap::<Option<OutPoint>, Vec<OutPoint>>::new(); - for (point, _) in candidates { + let mut groups = BTreeMap::<Option<OutPoint>, Vec<(OutPoint, Amount)>>::new(); + for (point, output) in candidates { let root = if merge_roots { None } else { ledger.consolidation_root(&point) }; - groups.entry(root).or_default().push(point); + groups.entry(root).or_default().push((point, output.amount)); } let mut plan = ConsolidationPlan { address: self.wallet.address().to_string(), @@ -73,12 +73,16 @@ impl NodeCore { if count < 2 { break None; } - match self.build_consolidation( - &ledger, - &group[offset..offset + count], - fee_per_byte, - merge_roots, - ) { + let candidates = &group[offset..offset + count]; + if !can_meet_consolidation_fee_cap(candidates, fee_per_byte) { + count /= 2; + continue; + } + let outpoints = candidates + .iter() + .map(|(point, _)| point.clone()) + .collect::<Vec<_>>(); + match self.build_consolidation(&ledger, &outpoints, fee_per_byte, merge_roots) { Ok(value) => break Some(value), Err(_) => count /= 2, } @@ -181,3 +185,14 @@ impl NodeCore { self.submit_public_transaction(transaction) } } + +fn can_meet_consolidation_fee_cap(candidates: &[(OutPoint, Amount)], fee_per_byte: Amount) -> bool { + let total = candidates + .iter() + .map(|(_, amount)| u128::from(*amount)) + .sum::<u128>(); + let minimum_fee = (u128::from(fee_per_byte) + * minimum_transfer_economic_size_bytes(candidates.len()) as u128) + .max(1); + minimum_fee * 100 <= total +} diff --git a/src/domain.rs b/src/domain.rs @@ -88,6 +88,7 @@ pub use transaction::{ MineSearchOutcome, OutPoint, TRANSACTION_SIGNING_FORMAT_VERSION, Transaction, TxInput, TxOutput, }; use transaction::{TransactionSigningDomain, mine_signing_bytes}; +pub(crate) use validation::minimum_transfer_economic_size_bytes; pub use validation::validate_address; use validation::{ canonical_transaction_size_bytes, validate_hash, validate_protocol_id, validate_signature, diff --git a/src/domain/consolidation_tests.rs b/src/domain/consolidation_tests.rs @@ -120,6 +120,16 @@ fn consolidation_skips_uneconomic_and_locked_wallets() { } #[test] +fn consolidation_large_uneconomic_wallet_skips_dust_without_building_transactions() { + let (wallet, ledger) = fixture(2000, 10, false); + let node = NodeCore::from_ledger(wallet, ledger, 0); + let plan = node.consolidation_plan(1, false).unwrap(); + assert_eq!(plan.before, 2000); + assert_eq!(plan.after, 2000); + assert!(plan.batches.is_empty()); +} + +#[test] fn consolidation_adapts_to_the_network_block_limit() { let (wallet, mut ledger) = fixture(150, 1_000_000, false); ledger.launch_profile.max_block_bytes = 3000; diff --git a/src/domain/validation.rs b/src/domain/validation.rs @@ -107,6 +107,19 @@ pub(super) fn canonical_transaction_size_bytes(transaction: &Transaction) -> usi } } +pub(crate) fn minimum_transfer_economic_size_bytes(input_count: usize) -> usize { + // A transfer always contains its tag, collection lengths, one output, fee, + // and signature. Each input contains at least a 32-byte transaction id, + // one byte for its output index, and a 32-byte owner address. + 1 + compact_len(input_count as u128) + + input_count * (HASH_BYTES + 1 + PUBLIC_KEY_BYTES) + + compact_len(1) + + PUBLIC_KEY_BYTES + + 1 + + 1 + + SIGNATURE_BYTES +} + fn compact_inputs_size_bytes(inputs: &[TxInput]) -> usize { inputs .iter() @@ -166,8 +179,9 @@ pub(super) fn compact_len(mut value: u128) -> usize { #[cfg(test)] mod tests { use super::{ - canonical_transaction_size_bytes, compact_len, validate_address, validate_hash, - validate_protocol_id, validate_signature, validate_stratum_header, + canonical_transaction_size_bytes, compact_len, minimum_transfer_economic_size_bytes, + validate_address, validate_hash, validate_protocol_id, validate_signature, + validate_stratum_header, }; use crate::domain::{OutPoint, Transaction, TxInput, TxOutput}; @@ -243,5 +257,7 @@ mod tests { + 64 // signature bytes ); assert!(tx.serialized_size_bytes().unwrap() > canonical_transaction_size_bytes(&tx)); + assert_eq!(minimum_transfer_economic_size_bytes(1), 166); + assert!(minimum_transfer_economic_size_bytes(1) <= canonical_transaction_size_bytes(&tx)); } }