commit aca5bb09434d7b4ed4adca5382f97e14327e7276
parent bd59ccda3e9a7bac818bea83fa1207d056264ed2
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Thu, 24 Sep 2026 22:08:50 +0200
fix(wallet): optimize hybrid UTXOs
Diffstat:
6 files changed, 413 insertions(+), 54 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -5,6 +5,10 @@ from the Git history and Conventional Commit titles by `deployment.sh`.
## [Unreleased]
+### Fixed
+
+- optimize both legacy and hybrid wallet UTXOs
+
## [0.4.43] - 2026-09-24
### Added
diff --git a/src/adapters/http/consolidation.rs b/src/adapters/http/consolidation.rs
@@ -52,11 +52,12 @@ pub(super) async fn submit(
(result, node.drain_outbox())
};
match result {
- Ok(transaction) => {
- // Submission has already happened. Return the signature even if broadcasting fails;
- // the UI must never blindly retry a possibly accepted financial action.
+ Ok(transaction_id) => {
+ // Submission has already happened. Return its identifier even if broadcasting fails;
+ // the UI must never blindly retry a possibly accepted financial action. Keep the
+ // legacy `signature` field name for API compatibility with existing clients.
let broadcast = state.gossip.broadcast(outbox).await;
- Json(json!({ "ok": true, "signature": transaction.signature(),
+ Json(json!({ "ok": true, "signature": transaction_id,
"broadcast_error": broadcast.err().map(|error| error.to_string()) }))
}
Err(error) => Json(json!({ "ok": false, "error": error.to_string() })),
diff --git a/src/app.rs b/src/app.rs
@@ -16,6 +16,8 @@ use crate::domain::{
mod automatic_mining;
mod consolidation;
+#[cfg(test)]
+pub(crate) use consolidation::ConsolidationKind;
mod gossip;
mod helpers;
mod in_memory_network;
diff --git a/src/app/consolidation.rs b/src/app/consolidation.rs
@@ -4,19 +4,35 @@ use anyhow::{Context, Result, bail};
use serde::Serialize;
use super::{NodeCore, helpers::converge_fee_by_byte};
-use crate::domain::{Amount, Ledger, OutPoint, Transaction, minimum_transfer_economic_size_bytes};
+use crate::domain::{
+ Amount, Ledger, OutPoint, Transaction, TransactionV2, TxOutput, hex_encode,
+ minimum_transfer_economic_size_bytes,
+};
const BATCH_INPUTS: usize = 128;
const MAX_BATCHES: usize = 32;
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub(crate) enum ConsolidationKind {
+ Legacy,
+ Hybrid,
+}
+
#[derive(Serialize)]
pub(crate) struct ConsolidationBatch {
+ pub kind: ConsolidationKind,
pub utxos: Vec<OutPoint>,
pub fee: Amount,
pub amount: Amount,
pub bytes: usize,
}
+enum BuiltConsolidation {
+ Legacy(Transaction),
+ Hybrid(TransactionV2),
+}
+
#[derive(Serialize)]
pub(crate) struct ConsolidationPlan {
pub address: String,
@@ -32,30 +48,55 @@ impl NodeCore {
fee_per_byte: Amount,
merge_roots: bool,
) -> Result<ConsolidationPlan> {
- self.wallet.unlocked()?;
+ let wallet = self.wallet.unlocked()?;
let ledger = self.wallet_build_ledger()?;
- let confirmed = ledger.utxos_for_address(self.wallet.address());
+ let mut confirmed = Vec::new();
+ let mut available = BTreeSet::new();
+ collect_consolidation_outputs(
+ &ledger,
+ self.wallet.address(),
+ ConsolidationKind::Legacy,
+ &mut confirmed,
+ &mut available,
+ )?;
+ for address in ledger.wallet_owned_hybrid_encoded_addresses(wallet)? {
+ collect_consolidation_outputs(
+ &ledger,
+ &address,
+ ConsolidationKind::Hybrid,
+ &mut confirmed,
+ &mut available,
+ )?;
+ }
+ let pending_spent = self.wallet_pending_spent_outpoints();
+ available.retain(|point| !pending_spent.contains(point));
let before = confirmed.len();
- let available: BTreeSet<_> = ledger
- .available_utxos_for_address(self.wallet.address())?
- .into_iter()
- .map(|(point, _)| point)
- .collect();
- let mut candidates: Vec<_> = confirmed
+ let mut candidates = confirmed
.into_iter()
- .filter(|(point, _)| available.contains(point))
- .collect();
- 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, Amount)>>::new();
- for (point, output) in candidates {
+ .filter(|(_, point, _)| available.contains(point))
+ .collect::<Vec<_>>();
+ candidates.sort_by_key(|(kind, point, output)| (*kind, output.amount, point.clone()));
+ // Keep the largest output of each protocol available for payments and automatic burns.
+ for kind in [ConsolidationKind::Legacy, ConsolidationKind::Hybrid] {
+ if let Some(index) = candidates
+ .iter()
+ .rposition(|(candidate, _, _)| *candidate == kind)
+ {
+ candidates.remove(index);
+ }
+ }
+ let mut groups =
+ BTreeMap::<(ConsolidationKind, Option<OutPoint>), Vec<(OutPoint, Amount)>>::new();
+ for (kind, point, output) in candidates {
let root = if merge_roots {
None
} else {
ledger.consolidation_root(&point)
};
- groups.entry(root).or_default().push((point, output.amount));
+ groups
+ .entry((kind, root))
+ .or_default()
+ .push((point, output.amount));
}
let mut plan = ConsolidationPlan {
address: self.wallet.address().to_string(),
@@ -64,7 +105,7 @@ impl NodeCore {
fee: 0,
batches: Vec::new(),
};
- for group in groups.values() {
+ for ((kind, _), group) in &groups {
let mut offset = 0;
while offset + 1 < group.len() && plan.batches.len() < MAX_BATCHES {
let mut count = BATCH_INPUTS.min(group.len() - offset);
@@ -88,6 +129,7 @@ impl NodeCore {
}
};
if let Some((_, batch)) = built {
+ debug_assert_eq!(batch.kind, *kind);
plan.after -= batch.utxos.len() - 1;
plan.fee = plan
.fee
@@ -109,22 +151,31 @@ impl NodeCore {
outpoints: &[OutPoint],
fee_per_byte: Amount,
merge_roots: bool,
- ) -> Result<(Transaction, ConsolidationBatch)> {
+ ) -> Result<(BuiltConsolidation, ConsolidationBatch)> {
if !(2..=BATCH_INPUTS).contains(&outpoints.len()) {
bail!("choose between 2 and 128 outputs per batch");
}
- let confirmed: BTreeMap<_, _> = ledger
- .utxos_for_address(self.wallet.address())
- .into_iter()
- .collect();
- let available: BTreeSet<_> = ledger
- .available_utxos_for_address(self.wallet.address())?
- .into_iter()
- .map(|(point, _)| point)
- .collect();
+ let wallet = self.wallet.unlocked()?;
+ let hybrid_addresses = ledger.wallet_owned_hybrid_encoded_addresses(wallet)?;
+ let mut confirmed = BTreeMap::new();
+ let mut available = BTreeSet::new();
+ for address in std::iter::once(self.wallet.address())
+ .chain(hybrid_addresses.iter().map(String::as_str))
+ {
+ confirmed.extend(ledger.utxos_for_address(address));
+ available.extend(
+ ledger
+ .available_utxos_for_address(address)?
+ .into_iter()
+ .map(|(point, _)| point),
+ );
+ }
+ let pending_spent = self.wallet_pending_spent_outpoints();
+ available.retain(|point| !pending_spent.contains(point));
let mut seen = BTreeSet::new();
let root = ledger.consolidation_root(&outpoints[0]);
let mut total: Amount = 0;
+ let mut kind = None;
for point in outpoints {
if !seen.insert(point) || !available.contains(point) {
bail!("outputs changed or are reserved; review a new preview");
@@ -135,32 +186,58 @@ impl NodeCore {
let output = confirmed
.get(point)
.context("output is no longer confirmed in this wallet")?;
+ let output_kind = if output.address == self.wallet.address() {
+ ConsolidationKind::Legacy
+ } else if hybrid_addresses.contains(&output.address) {
+ ConsolidationKind::Hybrid
+ } else {
+ bail!("output is not owned by this wallet");
+ };
+ if kind
+ .replace(output_kind)
+ .is_some_and(|kind| kind != output_kind)
+ {
+ bail!("legacy and hybrid outputs require separate consolidation batches");
+ }
total = total
.checked_add(output.amount)
.context("input total overflows")?;
}
- let (transaction, estimate) = converge_fee_by_byte(fee_per_byte, |fee| {
- let amount = total
- .checked_sub(fee)
- .filter(|amount| *amount > 0)
- .context("outputs do not cover the network fee")?;
- ledger.build_transfer_with_inputs(
- self.wallet.unlocked()?,
- self.wallet.address(),
- amount,
- fee,
- outpoints,
- )
- })?;
+ let kind = kind.context("consolidation batch has no outputs")?;
+ let (transaction, bytes, fee) = match kind {
+ ConsolidationKind::Legacy => {
+ let (transaction, estimate) = converge_fee_by_byte(fee_per_byte, |fee| {
+ let amount = consolidation_amount(total, fee)?;
+ ledger.build_transfer_with_inputs(
+ wallet,
+ self.wallet.address(),
+ amount,
+ fee,
+ outpoints,
+ )
+ })?;
+ (
+ BuiltConsolidation::Legacy(transaction),
+ estimate.bytes,
+ estimate.fee,
+ )
+ }
+ ConsolidationKind::Hybrid => {
+ let (transaction, bytes, fee) =
+ converge_v2_consolidation_fee(ledger, wallet, outpoints, total, fee_per_byte)?;
+ (BuiltConsolidation::Hybrid(transaction), bytes, fee)
+ }
+ };
// Never recommend or accept batches spending over 1% of their value on fees.
- if u128::from(estimate.fee) * 100 > u128::from(total) {
+ if u128::from(fee) * 100 > u128::from(total) {
bail!("batch fee exceeds 1% of its value; use a lower fee or wait");
}
let batch = ConsolidationBatch {
+ kind,
utxos: outpoints.to_vec(),
- fee: estimate.fee,
- amount: total - estimate.fee,
- bytes: estimate.bytes,
+ fee,
+ amount: total - fee,
+ bytes,
};
Ok((transaction, batch))
}
@@ -172,7 +249,7 @@ impl NodeCore {
max_fee: Amount,
merge_roots: bool,
address: &str,
- ) -> Result<Transaction> {
+ ) -> Result<String> {
if address != self.wallet.address() {
bail!("wallet changed; review a new preview");
}
@@ -182,8 +259,75 @@ impl NodeCore {
if batch.fee > max_fee {
bail!("fee exceeds the approved limit; review a new preview");
}
- self.submit_public_transaction(transaction)
+ match transaction {
+ BuiltConsolidation::Legacy(transaction) => {
+ let signature = transaction.signature().to_string();
+ self.submit_public_transaction(transaction)?;
+ Ok(signature)
+ }
+ BuiltConsolidation::Hybrid(transaction) => {
+ let domain = self.ledger.transaction_v2_domain()?;
+ let transaction_id = hex_encode(transaction.transaction_id(&domain)?);
+ self.submit_public_transaction_v2(transaction)?;
+ Ok(transaction_id)
+ }
+ }
+ }
+}
+
+fn collect_consolidation_outputs(
+ ledger: &Ledger,
+ address: &str,
+ kind: ConsolidationKind,
+ confirmed: &mut Vec<(ConsolidationKind, OutPoint, TxOutput)>,
+ available: &mut BTreeSet<OutPoint>,
+) -> Result<()> {
+ confirmed.extend(
+ ledger
+ .utxos_for_address(address)
+ .into_iter()
+ .map(|(point, output)| (kind, point, output)),
+ );
+ available.extend(
+ ledger
+ .available_utxos_for_address(address)?
+ .into_iter()
+ .map(|(point, _)| point),
+ );
+ Ok(())
+}
+
+fn consolidation_amount(total: Amount, fee: Amount) -> Result<Amount> {
+ total
+ .checked_sub(fee)
+ .filter(|amount| *amount > 0)
+ .context("outputs do not cover the network fee")
+}
+
+fn converge_v2_consolidation_fee(
+ ledger: &Ledger,
+ wallet: &crate::domain::Wallet,
+ outpoints: &[OutPoint],
+ total: Amount,
+ fee_per_byte: Amount,
+) -> Result<(TransactionV2, usize, Amount)> {
+ let domain = ledger.transaction_v2_domain()?;
+ let mut fee = 1;
+ for _ in 0..64 {
+ let amount = consolidation_amount(total, fee)?;
+ let transaction =
+ ledger.build_v2_consolidation_with_inputs(wallet, amount, fee, outpoints)?;
+ let bytes = transaction.encoded_size_bytes(&domain)?;
+ let required_fee = fee_per_byte
+ .checked_mul(bytes as Amount)
+ .context("fee per byte times transaction bytes overflows")?
+ .max(1);
+ if fee >= required_fee {
+ return Ok((transaction, bytes, fee));
+ }
+ fee = required_fee;
}
+ bail!("hybrid consolidation fee did not converge")
}
fn can_meet_consolidation_fee_cap(candidates: &[(OutPoint, Amount)], fee_per_byte: Amount) -> bool {
diff --git a/src/domain/consolidation_tests.rs b/src/domain/consolidation_tests.rs
@@ -1,6 +1,6 @@
use std::collections::{BTreeMap, BTreeSet};
-use super::{Ledger, OutPoint, TxOutput, UtxoLineageRoot, Wallet};
+use super::{AddressNetwork, Ledger, OutPoint, TransactionV2, TxOutput, UtxoLineageRoot, Wallet};
use crate::app::NodeCore;
fn fixture(count: usize, value: u64, roots: bool) -> (Wallet, Ledger) {
@@ -49,11 +49,17 @@ fn consolidation_large_wallet_is_bounded_disjoint_and_keeps_a_reserve() {
let tx = node
.consolidate(&batch.utxos, 1, batch.fee, false, wallet.address())
.unwrap();
- let outputs = tx.outputs();
+ assert!(!tx.is_empty());
+ let pending = node
+ .pending_transactions()
+ .into_iter()
+ .find(|transaction| transaction.signature() == tx)
+ .unwrap();
+ let outputs = pending.outputs();
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0].address, wallet.address());
assert_eq!(
- outputs[0].amount + tx.fee(),
+ outputs[0].amount + pending.fee(),
batch.utxos.len() as u64 * 1_000_000
);
}
@@ -70,6 +76,116 @@ fn consolidation_large_wallet_is_bounded_disjoint_and_keeps_a_reserve() {
}
#[test]
+fn consolidation_plan_includes_hybrid_outputs_in_separate_v2_batches() {
+ let (wallet, mut ledger) = fixture(3, 1_000_000, false);
+ let hybrid_address = wallet.hybrid_address(AddressNetwork::Mainnet);
+ for index in 0..3 {
+ ledger.utxos.insert(
+ OutPoint {
+ txid: format!("{:064x}", index + 10),
+ index: 0,
+ },
+ TxOutput {
+ address: hybrid_address.clone(),
+ amount: 2_000_000,
+ },
+ );
+ }
+
+ let node = NodeCore::from_ledger(wallet, ledger, 0);
+ let plan = node.consolidation_plan(1, true).unwrap();
+ assert_eq!(plan.before, 6);
+ assert_eq!(plan.after, 4);
+ assert_eq!(plan.batches.len(), 2);
+ assert_eq!(
+ plan.batches
+ .iter()
+ .filter(|batch| batch.kind == crate::app::ConsolidationKind::Legacy)
+ .count(),
+ 1
+ );
+ assert_eq!(
+ plan.batches
+ .iter()
+ .filter(|batch| batch.kind == crate::app::ConsolidationKind::Hybrid)
+ .count(),
+ 1
+ );
+}
+
+#[test]
+fn selected_hybrid_outputs_build_one_v2_consolidation_output() {
+ let wallet = Wallet::from_seed("hybrid-consolidation-builder");
+ let address = wallet.hybrid_address(AddressNetwork::Mainnet);
+ let points = (0..2)
+ .map(|index| OutPoint {
+ txid: format!("{:064x}", index + 1),
+ index: 0,
+ })
+ .collect::<Vec<_>>();
+ let mut ledger = Ledger::new(BTreeMap::new(), 1);
+ for point in &points {
+ ledger.utxos.insert(
+ point.clone(),
+ TxOutput {
+ address: address.clone(),
+ amount: 1_000_000,
+ },
+ );
+ }
+
+ let transaction = ledger
+ .build_v2_consolidation_with_inputs(&wallet, 1_990_000, 10_000, &points)
+ .unwrap();
+ let TransactionV2::Transfer {
+ inputs,
+ outputs,
+ fee,
+ ..
+ } = transaction
+ else {
+ panic!("expected a transaction-v2 transfer");
+ };
+ assert_eq!(inputs.len(), 2);
+ assert_eq!(outputs.len(), 1);
+ assert_eq!(outputs[0].address, wallet.hybrid_versioned_address());
+ assert_eq!(outputs[0].amount, 1_990_000);
+ assert_eq!(fee, 10_000);
+}
+
+#[test]
+fn consolidation_plan_excludes_outputs_reserved_by_pending_v2() {
+ let wallet = Wallet::from_seed("pending-v2-consolidation");
+ let address = wallet.hybrid_address(AddressNetwork::Mainnet);
+ let points = (0..3)
+ .map(|index| OutPoint {
+ txid: format!("{:064x}", index + 1),
+ index: 0,
+ })
+ .collect::<Vec<_>>();
+ let mut ledger = Ledger::new(BTreeMap::new(), 1);
+ for point in &points {
+ ledger.utxos.insert(
+ point.clone(),
+ TxOutput {
+ address: address.clone(),
+ amount: 1_000_000,
+ },
+ );
+ }
+ let pending = ledger
+ .build_v2_consolidation_with_inputs(&wallet, 1_990_000, 10_000, &points[..2])
+ .unwrap();
+ ledger.pending_v2.push(pending);
+
+ let node = NodeCore::from_ledger(wallet, ledger, 0);
+ let plan = node.consolidation_plan(1, true).unwrap();
+ assert_eq!(plan.before, 3);
+ assert_eq!(plan.after, 3);
+ assert!(plan.batches.is_empty());
+}
+
+#[test]
fn consolidation_requires_consent_for_distinct_mining_roots_and_respects_fee_cap() {
let (wallet, ledger) = fixture(4, 1_000_000, true);
let mut node = NodeCore::from_ledger(wallet.clone(), ledger, 0);
diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs
@@ -440,6 +440,98 @@ impl Ledger {
Ok(transaction)
}
+ /// Builds a transaction-v2 transfer that consolidates exactly the selected hybrid outputs.
+ /// Inputs may belong to different derived addresses owned by the same wallet.
+ pub(crate) fn build_v2_consolidation_with_inputs(
+ &self,
+ wallet: &Wallet,
+ amount: Amount,
+ fee: Amount,
+ outpoints: &[OutPoint],
+ ) -> Result<TransactionV2> {
+ if amount == 0 {
+ bail!("transfer amount must be greater than zero");
+ }
+ let recipient = self
+ .wallet_external_addresses(wallet)?
+ .last()
+ .map(|(_, address)| *address)
+ .context("wallet receive address is unavailable")?;
+
+ let mut available = Vec::new();
+ for owner in self.wallet_owned_hybrid_addresses(wallet)? {
+ let owner_address = encode_versioned_address(owner, self.address_network())?;
+ available.extend(
+ self.available_utxos_for_address(&owner_address)?
+ .into_iter()
+ .map(|(outpoint, output)| (outpoint, output, owner)),
+ );
+ }
+ let available = available
+ .into_iter()
+ .map(|(outpoint, output, owner)| (outpoint, (output, owner)))
+ .collect::<std::collections::BTreeMap<_, _>>();
+
+ let mut seen = std::collections::BTreeSet::new();
+ let mut total = 0_u64;
+ let mut inputs = Vec::with_capacity(outpoints.len());
+ for outpoint in outpoints {
+ if !seen.insert(outpoint) {
+ bail!("outputs changed or are reserved; review a new preview");
+ }
+ let (output, owner) = available
+ .get(outpoint)
+ .context("output is no longer an available hybrid output in this wallet")?;
+ total = total
+ .checked_add(output.amount)
+ .context("transaction v2 input total overflows")?;
+ inputs.push(TransactionV2Input {
+ outpoint_txid: decode_hex_array::<32>(&outpoint.txid)
+ .context("transaction v2 outpoint ID must be a 32-byte hash")?,
+ outpoint_index: outpoint.index,
+ owner: *owner,
+ });
+ }
+ if total
+ != amount
+ .checked_add(fee)
+ .context("amount plus fee overflows")?
+ {
+ bail!("selected hybrid outputs do not match amount plus fee");
+ }
+
+ let domain = self.transaction_v2_domain()?;
+ let mut transaction = TransactionV2::Transfer {
+ inputs,
+ outputs: vec![TransactionV2Output {
+ address: recipient,
+ amount,
+ }],
+ fee,
+ authorizations: Vec::new(),
+ };
+ let payload = transaction.signing_bytes(&domain)?;
+ if let TransactionV2::Transfer {
+ inputs,
+ authorizations,
+ ..
+ } = &mut transaction
+ {
+ *authorizations = inputs
+ .iter()
+ .map(|input| wallet.sign_v2_authorization(input.owner, &payload))
+ .collect::<Result<Vec<_>>>()?;
+ }
+ transaction.verify_authorizations(&domain)?;
+ ensure_v2_transaction_within_block_budget(
+ self,
+ &transaction,
+ &domain,
+ self.launch_profile.max_block_bytes,
+ )?;
+ Ok(transaction)
+ }
+
pub fn build_v2_burn(
&self,
wallet: &Wallet,