From 9eb5b7436aea9d150a135353fe15427947eb724f Mon Sep 17 00:00:00 2001 From: bc1cindy Date: Thu, 9 Jul 2026 08:52:39 -0300 Subject: [PATCH 1/2] spend: make coin selection deterministic Coin selection (both the BnB algorithm and the descending-value-per-weight fallback) depends on the order in which candidate coins are supplied. Callers build that list from DatabaseConnection::coins, which returns a HashMap, so the order is randomized per run. When several coins are equivalent for selection (equal value per weight unit), which coins get selected and the resulting input order are non-deterministic. Sort candidates by outpoint before selecting, so ties break deterministically and a given set of coins always yields the same spend. This keeps spend and recovery reproducible and testable. --- liana/src/spend.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/liana/src/spend.rs b/liana/src/spend.rs index 1416e0c919..06db1904f8 100644 --- a/liana/src/spend.rs +++ b/liana/src/spend.rs @@ -277,6 +277,15 @@ fn select_coins_for_spend( max_sat_weight: u64, must_have_change: bool, ) -> Result { + // Callers pass `candidate_coins` from a HashMap, so sort by outpoint to keep + // selection deterministic. + let sorted_candidates = { + let mut v = candidate_coins.to_vec(); + v.sort_unstable_by_key(|c| c.outpoint); + v + }; + let candidate_coins: &[CandidateCoin] = &sorted_candidates; + let out_value_nochange = base_tx.output.iter().map(|o| o.value.to_sat()).sum(); let out_weight_nochange = { let mut total: u64 = 0; @@ -927,4 +936,61 @@ mod tests { LockTime::from_height(1).unwrap() // subtract 90 ); } + + #[test] + fn test_coin_selection_is_deterministic() { + use bitcoin::hashes::Hash; + use bitcoin::{ + absolute::LockTime, transaction::Version, Amount, OutPoint, ScriptBuf, Transaction, + TxOut, Txid, + }; + + // Equal-value coins, so selection has ties, with distinct outpoints. + let candidates: Vec = (0..6u32) + .map(|vout| CandidateCoin { + outpoint: OutPoint { + txid: Txid::from_byte_array([0u8; 32]), + vout, + }, + amount: Amount::from_sat(100_000), + deriv_index: bip32::ChildNumber::from_normal_idx(0).unwrap(), + is_change: false, + must_select: false, + sequence: None, + ancestor_info: None, + }) + .collect(); + + let base_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![TxOut { + value: Amount::from_sat(250_000), + script_pubkey: ScriptBuf::new(), + }], + }; + let change_txo = TxOut { + value: Amount::MAX, + script_pubkey: ScriptBuf::new(), + }; + let select = |cands: &[CandidateCoin]| { + select_coins_for_spend( + cands, + base_tx.clone(), + change_txo.clone(), + 2.0, + None, + 100, + false, + ) + .expect("enough funds") + .selected + }; + + // The same coins in a different input order must yield the same selection. + let mut reversed = candidates.clone(); + reversed.reverse(); + assert_eq!(select(&candidates), select(&reversed)); + } } From e38006941a9d696d0d01961ea44dd113a70e5530 Mon Sep 17 00:00:00 2001 From: bc1cindy Date: Thu, 9 Jul 2026 09:19:47 -0300 Subject: [PATCH 2/2] spend: extract canonical coin_to_psbt_input helper Building a coin's transaction input and PSBT input (its sequence, witness_utxo, and whether a non_witness_utxo is included for non-Taproot) is inlined in create_spend. A payjoin receiver being added separately reimplements the same construction and diverges from it: it hardcodes the sequence and always sets a non_witness_utxo, even for Taproot, making a payjoin input distinguishable from an ordinary spend input. Extract the construction into a shared coin_to_psbt_input helper so spend, recovery and payjoin build inputs identically. This is a pure refactor of the spend path here; the payjoin receiver will reuse the helper once rebased on top. --- liana/src/spend.rs | 54 +++++++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/liana/src/spend.rs b/liana/src/spend.rs index 06db1904f8..ad82bed6b9 100644 --- a/liana/src/spend.rs +++ b/liana/src/spend.rs @@ -491,6 +491,33 @@ fn derived_desc( desc.derive(coin.deriv_index, secp) } +/// Build the canonical `(TxIn, PsbtIn)` for spending a coin, so every caller +/// constructs inputs the same way. +pub fn coin_to_psbt_input( + outpoint: bitcoin::OutPoint, + amount: bitcoin::Amount, + sequence: bitcoin::Sequence, + coin_desc: &descriptors::DerivedSinglePathLianaDesc, + is_taproot: bool, + prev_tx: impl FnOnce() -> Option, +) -> (bitcoin::TxIn, PsbtIn) { + let txin = bitcoin::TxIn { + previous_output: outpoint, + sequence, + ..Default::default() + }; + let mut psbt_in = PsbtIn::default(); + coin_desc.update_psbt_in(&mut psbt_in); + psbt_in.witness_utxo = Some(bitcoin::TxOut { + value: amount, + script_pubkey: coin_desc.script_pubkey(), + }); + if !is_taproot { + psbt_in.non_witness_utxo = prev_tx(); + } + (txin, psbt_in) +} + /// Get value to use for transaction nLockTime in order to /// discourage fee sniping. /// @@ -775,27 +802,20 @@ pub fn create_spend( // Iterate through selected coins and add necessary information to the PSBT inputs. let mut psbt_ins = Vec::with_capacity(selected.len()); for cand in &selected { + // TODO: once we move to Taproot, anti-fee-sniping using nSequence let sequence = cand .sequence .unwrap_or(bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME); - tx.input.push(bitcoin::TxIn { - previous_output: cand.outpoint, - sequence, - // TODO: once we move to Taproot, anti-fee-sniping using nSequence - ..bitcoin::TxIn::default() - }); - - // Populate the PSBT input with the information needed by signers. - let mut psbt_in = PsbtIn::default(); let coin_desc = derived_desc(secp, main_descriptor, cand); - coin_desc.update_psbt_in(&mut psbt_in); - psbt_in.witness_utxo = Some(bitcoin::TxOut { - value: cand.amount, - script_pubkey: coin_desc.script_pubkey(), - }); - if !main_descriptor.is_taproot() { - psbt_in.non_witness_utxo = tx_getter.get_tx(&cand.outpoint.txid); - } + let (txin, psbt_in) = coin_to_psbt_input( + cand.outpoint, + cand.amount, + sequence, + &coin_desc, + main_descriptor.is_taproot(), + || tx_getter.get_tx(&cand.outpoint.txid), + ); + tx.input.push(txin); psbt_ins.push(psbt_in); }