iuna

iuna

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

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:
MCHANGELOG.md | 4++++
Msrc/adapters/http/consolidation.rs | 9+++++----
Msrc/app.rs | 2++
Msrc/app/consolidation.rs | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/domain/consolidation_tests.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/domain/ledger_builders.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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,