commit 92f5c1e75622b9ae313120d84ce1c76ab471252c
parent c07dea3c0eb7bf75908170e5676ed774a010e406
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Tue, 8 Sep 2026 09:26:38 +0200
Optimize wallet consolidation preview
Diffstat:
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));
}
}