Use FundingTxInput instead of Utxo in CoinSelection
What changed, and why it matters
This commit refactors how the Lightning Dev Kit (LDK) selects coins for on-chain Bitcoin transactions. It changes the internal data structure used during coin selection so that each selected coin now carries the full previous transaction that created it, not just the coin's own details. The main stated reason is to prepare for a future feature called 'splicing.' The commit also adds a new wallet method to look up those previous transactions on demand. There is no direct evidence in the commit that this fixes an active security bug, but it does touch code that handles real money (transaction fees and UTXOs) and changes a public wallet interface, so downstream implementers need to update their code.
Treat as a breaking API refactor with security-adjacent implications. Downstream projects implementing WalletSource or WalletSourceSync must add get_prevtx. Review custom coin-selection implementations to ensure they now provide the previous transaction for each selected UTXO and validate it correctly. Monitor LDK release notes for any follow-up security guidance, but no immediate patch urgency is indicated by the commit itself.
Security signals we found
Refactors coin-selection data structures to include full previous transaction (prevtx) for each selected UTXO
Adds new required wallet interface methods: WalletSource::get_prevtx and WalletSourceSync::get_prevtx
Adds defensive validation in default coin selection that previous transaction txid matches outpoint and output index exists
Changes public API/types (CoinSelection, WalletSource, WalletSourceSync) - downstream implementers must update
Touches fee-calculation and transaction-building code paths used for fee-bumping and HTLC claims
No explicit security bug, CVE, or vulnerability described in commit message or diff
Evidence from the diff
The patch replaces the use of Utxo with FundingTxInput (aliased as ConfirmedUtxo) inside CoinSelection. FundingTxInput bundles a Utxo with its prevtx (the previous transaction containing the spent output). A new WalletSource::get_prevtx(outpoint) async method and its sync counterpart WalletSourceSync::get_prevtx are introduced so the default coin-selection implementation can fetch the previous transaction only for UTXOs that are actually selected. The default implementation now validates that the returned previous transaction’s txid matches the outpoint and that the referenced vout exists. Test utilities and fuzz harnesses are updated to store full previous transactions instead of bare outpoints/amounts. The change is framed as groundwork for reusing CoinSelectionSource for splicing.
Changed components
lightning/src/events/bump_transaction/mod.rslightning/src/events/bump_transaction/sync.rslightning/src/ln/funding.rslightning/src/util/test_utils.rslightning/src/ln/functional_test_utils.rsfuzz/src/full_stack.rsInspect captured patch +129 / −57
diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs
index f7f912c..11eca60 100644
--- a/fuzz/src/full_stack.rs
+++ b/fuzz/src/full_stack.rs
@@ -668,9 +668,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc<dyn Logger>) {
script_pubkey: wallet.get_change_script().unwrap(),
}],
};
- let coinbase_txid = coinbase_tx.compute_txid();
- wallet
- .add_utxo(bitcoin::OutPoint { txid: coinbase_txid, vout: 0 }, Amount::from_sat(1_000_000));
+ wallet.add_utxo(coinbase_tx.clone(), 0);
loop {
match get_slice!(1)[0] {
diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs
index a79e927..8a04f62 100644
--- a/lightning/src/events/bump_transaction/mod.rs
+++ b/lightning/src/events/bump_transaction/mod.rs
@@ -30,6 +30,7 @@ use crate::ln::chan_utils::{
HTLC_TIMEOUT_INPUT_KEYED_ANCHOR_WITNESS_WEIGHT, HTLC_TIMEOUT_INPUT_P2A_ANCHOR_WITNESS_WEIGHT,
P2WSH_TXOUT_WEIGHT, SEGWIT_MARKER_FLAG_WEIGHT, TRUC_CHILD_MAX_WEIGHT, TRUC_MAX_WEIGHT,
};
+use crate::ln::funding::FundingTxInput;
use crate::ln::types::ChannelId;
use crate::prelude::*;
use crate::sign::ecdsa::EcdsaChannelSigner;
@@ -354,13 +355,16 @@ impl Utxo {
}
}
+/// An unspent transaction output with at least one confirmation.
+pub type ConfirmedUtxo = FundingTxInput;
+
/// The result of a successful coin selection attempt for a transaction requiring additional UTXOs
/// to cover its fees.
#[derive(Clone, Debug)]
pub struct CoinSelection {
/// The set of UTXOs (with at least 1 confirmation) to spend and use within a transaction
/// requiring additional fees.
- pub confirmed_utxos: Vec<Utxo>,
+ pub confirmed_utxos: Vec<ConfirmedUtxo>,
/// An additional output tracking whether any change remained after coin selection. This output
/// should always have a value above dust for its given `script_pubkey`. It should not be
/// spent until the transaction it belongs to confirms to ensure mempool descendant limits are
@@ -368,6 +372,16 @@ pub struct CoinSelection {
pub change_output: Option<TxOut>,
}
+impl CoinSelection {
+ fn satisfaction_weight(&self) -> u64 {
+ self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.satisfaction_weight).sum()
+ }
+
+ fn input_amount(&self) -> Amount {
+ self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.output.value).sum()
+ }
+}
+
/// An abstraction over a bitcoin wallet that can perform coin selection over a set of UTXOs and can
/// sign for them. The coin selection method aims to mimic Bitcoin Core's `fundrawtransaction` RPC,
/// which most wallets should be able to satisfy. Otherwise, consider implementing [`WalletSource`],
@@ -438,11 +452,18 @@ pub trait WalletSource {
fn list_confirmed_utxos<'a>(
&'a self,
) -> impl Future<Output = Result<Vec<Utxo>, ()>> + MaybeSend + 'a;
+
+ /// Returns the previous transaction containing the UTXO referenced by the outpoint.
+ fn get_prevtx<'a>(
+ &'a self, outpoint: OutPoint,
+ ) -> impl Future<Output = Result<Transaction, ()>> + MaybeSend + 'a;
+
/// Returns a script to use for change above dust resulting from a successful coin selection
/// attempt.
fn get_change_script<'a>(
&'a self,
) -> impl Future<Output = Result<ScriptBuf, ()>> + MaybeSend + 'a;
+
/// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within
/// the transaction known to the wallet (i.e., any provided via
/// [`WalletSource::list_confirmed_utxos`]).
@@ -628,10 +649,26 @@ where
Some(TxOut { script_pubkey: change_script, value: change_output_amount })
};
- Ok(CoinSelection {
- confirmed_utxos: selected_utxos.into_iter().map(|(utxo, _)| utxo).collect(),
- change_output,
- })
+ let mut confirmed_utxos = Vec::with_capacity(selected_utxos.len());
+ for (utxo, _) in selected_utxos {
+ let prevtx = self.source.get_prevtx(utxo.outpoint).await?;
+ let prevtx_id = prevtx.compute_txid();
+ if prevtx_id != utxo.outpoint.txid
+ || prevtx.output.get(utxo.outpoint.vout as usize).is_none()
+ {
+ log_error!(
+ self.logger,
+ "Tx {} from wallet source doesn't contain output referenced by outpoint: {}",
+ prevtx_id,
+ utxo.outpoint,
+ );
+ return Err(());
+ }
+
+ confirmed_utxos.push(ConfirmedUtxo { utxo, prevtx });
+ }
+
+ Ok(CoinSelection { confirmed_utxos, change_output })
}
}
@@ -740,7 +777,7 @@ where
/// Updates a transaction with the result of a successful coin selection attempt.
fn process_coin_selection(&self, tx: &mut Transaction, coin_selection: &CoinSelection) {
- for utxo in coin_selection.confirmed_utxos.iter() {
+ for ConfirmedUtxo { utxo, .. } in coin_selection.confirmed_utxos.iter() {
tx.input.push(TxIn {
previous_output: utxo.outpoint,
script_sig: ScriptBuf::new(),
@@ -865,12 +902,10 @@ where
output: vec![],
};
- let input_satisfaction_weight: u64 =
- coin_selection.confirmed_utxos.iter().map(|utxo| utxo.satisfaction_weight).sum();
+ let input_satisfaction_weight = coin_selection.satisfaction_weight();
let total_satisfaction_weight =
anchor_input_witness_weight + EMPTY_SCRIPT_SIG_WEIGHT + input_satisfaction_weight;
- let total_input_amount = must_spend_amount
- + coin_selection.confirmed_utxos.iter().map(|utxo| utxo.output.value).sum();
+ let total_input_amount = must_spend_amount + coin_selection.input_amount();
self.process_coin_selection(&mut anchor_tx, &coin_selection);
let anchor_txid = anchor_tx.compute_txid();
@@ -885,10 +920,10 @@ where
let index = idx + 1;
debug_assert_eq!(
anchor_psbt.unsigned_tx.input[index].previous_output,
- utxo.outpoint
+ utxo.outpoint()
);
- if utxo.output.script_pubkey.is_witness_program() {
- anchor_psbt.inputs[index].witness_utxo = Some(utxo.output);
+ if utxo.output().script_pubkey.is_witness_program() {
+ anchor_psbt.inputs[index].witness_utxo = Some(utxo.into_output());
}
}
@@ -1127,13 +1162,11 @@ where
utxo_id = claim_id.step_with_bytes(&broadcasted_htlcs.to_be_bytes());
#[cfg(debug_assertions)]
- let input_satisfaction_weight: u64 =
- coin_selection.confirmed_utxos.iter().map(|utxo| utxo.satisfaction_weight).sum();
+ let input_satisfaction_weight = coin_selection.satisfaction_weight();
#[cfg(debug_assertions)]
let total_satisfaction_weight = must_spend_satisfaction_weight + input_satisfaction_weight;
#[cfg(debug_assertions)]
- let input_value: u64 =
- coin_selection.confirmed_utxos.iter().map(|utxo| utxo.output.value.to_sat()).sum();
+ let input_value = coin_selection.input_amount().to_sat();
#[cfg(debug_assertions)]
let total_input_amount = must_spend_amount + input_value;
@@ -1154,9 +1187,12 @@ where
for (idx, utxo) in coin_selection.confirmed_utxos.into_iter().enumerate() {
// offset to skip the htlc inputs
let index = idx + selected_htlcs.len();
- debug_assert_eq!(htlc_psbt.unsigned_tx.input[index].previous_output, utxo.outpoint);
- if utxo.output.script_pubkey.is_witness_program() {
- htlc_psbt.inputs[index].witness_utxo = Some(utxo.output);
+ debug_assert_eq!(
+ htlc_psbt.unsigned_tx.input[index].previous_output,
+ utxo.outpoint()
+ );
+ if utxo.output().script_pubkey.is_witness_program() {
+ htlc_psbt.inputs[index].witness_utxo = Some(utxo.into_output());
}
}
@@ -1311,10 +1347,9 @@ mod tests {
use crate::util::ser::Readable;
use crate::util::test_utils::{TestBroadcaster, TestLogger};
- use bitcoin::hashes::Hash;
use bitcoin::hex::FromHex;
use bitcoin::{
- Network, ScriptBuf, Transaction, Txid, WitnessProgram, WitnessVersion, XOnlyPublicKey,
+ Network, ScriptBuf, Transaction, WitnessProgram, WitnessVersion, XOnlyPublicKey,
};
struct TestCoinSelectionSource {
@@ -1335,9 +1370,17 @@ mod tests {
Ok(res)
}
fn sign_psbt(&self, psbt: Psbt) -> Result<Transaction, ()> {
+ let prevtx_ids: Vec<_> = self
+ .expected_selects
+ .lock()
+ .unwrap()
+ .iter()
+ .flat_map(|selection| selection.3.confirmed_utxos.iter())
+ .map(|utxo| utxo.prevtx.compute_txid())
+ .collect();
let mut tx = psbt.unsigned_tx;
for input in tx.input.iter_mut() {
- if input.previous_output.txid != Txid::from_byte_array([44; 32]) {
+ if prevtx_ids.contains(&input.previous_output.txid) {
// Channel output, add a realistic size witness to make the assertions happy
input.witness = Witness::from_slice(&[vec![42; 162]]);
}
@@ -1378,6 +1421,13 @@ mod tests {
.weight()
.to_wu();
+ let prevtx = Transaction {
+ version: Version::TWO,
+ lock_time: LockTime::ZERO,
+ input: vec![],
+ output: vec![TxOut { value: Amount::from_sat(200), script_pubkey: ScriptBuf::new() }],
+ };
+
let broadcaster = TestBroadcaster::new(Network::Testnet);
let source = TestCoinSelectionSource {
expected_selects: Mutex::new(vec![
@@ -1392,14 +1442,14 @@ mod tests {
commitment_and_anchor_fee,
868,
CoinSelection {
- confirmed_utxos: vec![Utxo {
- outpoint: OutPoint { txid: Txid::from_byte_array([44; 32]), vout: 0 },
- output: TxOut {
- value: Amount::from_sat(200),
- script_pubkey: ScriptBuf::new(),
+ confirmed_utxos: vec![ConfirmedUtxo {
+ utxo: Utxo {
+ outpoint: OutPoint { txid: prevtx.compute_txid(), vout: 0 },
+ output: prevtx.output[0].clone(),
+ satisfaction_weight: 5, // Just the script_sig and witness lengths
+ sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
},
- satisfaction_weight: 5, // Just the script_sig and witness lengths
- sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
+ prevtx,
}],
change_output: None,
},
diff --git a/lightning/src/events/bump_transaction/sync.rs b/lightning/src/events/bump_transaction/sync.rs
index f4245cd..a521fa9 100644
--- a/lightning/src/events/bump_transaction/sync.rs
+++ b/lightning/src/events/bump_transaction/sync.rs
@@ -21,7 +21,7 @@ use crate::sign::SignerProvider;
use crate::util::async_poll::{dummy_waker, MaybeSend, MaybeSync};
use crate::util::logger::Logger;
-use bitcoin::{Psbt, ScriptBuf, Transaction, TxOut};
+use bitcoin::{OutPoint, Psbt, ScriptBuf, Transaction, TxOut};
use super::BumpTransactionEvent;
use super::{
@@ -37,9 +37,14 @@ use super::{
pub trait WalletSourceSync {
/// Returns all UTXOs, with at least 1 confirmation each, that are available to spend.
fn list_confirmed_utxos(&self) -> Result<Vec<Utxo>, ()>;
+
+ /// Returns the previous transaction containing the UTXO referenced by the outpoint.
+ fn get_prevtx(&self, outpoint: OutPoint) -> Result<Transaction, ()>;
+
/// Returns a script to use for change above dust resulting from a successful coin selection
/// attempt.
fn get_change_script(&self) -> Result<ScriptBuf, ()>;
+
/// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within
/// the transaction known to the wallet (i.e., any provided via
/// [`WalletSource::list_confirmed_utxos`]).
@@ -79,6 +84,13 @@ where
async move { utxos }
}
+ fn get_prevtx<'a>(
+ &'a self, outpoint: OutPoint,
+ ) -> impl Future<Output = Result<Transaction, ()>> + MaybeSend + 'a {
+ let prevtx = self.0.get_prevtx(outpoint);
+ Box::pin(async move { prevtx })
+ }
+
fn get_change_script<'a>(
&'a self,
) -> impl Future<Output = Result<ScriptBuf, ()>> + MaybeSend + 'a {
diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs
index a0246a9..33f78b1 100644
--- a/lightning/src/ln/functional_test_utils.rs
+++ b/lightning/src/ln/functional_test_utils.rs
@@ -397,8 +397,7 @@ fn do_connect_block_without_consistency_checks<'a, 'b, 'c, 'd>(
let wallet_script = node.wallet_source.get_change_script().unwrap();
for (idx, output) in tx.output.iter().enumerate() {
if output.script_pubkey == wallet_script {
- let outpoint = bitcoin::OutPoint { txid: tx.compute_txid(), vout: idx as u32 };
- node.wallet_source.add_utxo(outpoint, output.value);
+ node.wallet_source.add_utxo(tx.clone(), idx as u32);
}
}
}
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 50e0938..9981250 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -103,16 +103,17 @@ impl SpliceContribution {
/// establishment protocol or when splicing.
#[derive(Debug, Clone)]
pub struct FundingTxInput {
- /// The unspent [`TxOut`] that the input spends.
+ /// The unspent [`TxOut`] found in [`prevtx`].
///
/// [`TxOut`]: bitcoin::TxOut
- pub(super) utxo: Utxo,
+ /// [`prevtx`]: Self::prevtx
+ pub(crate) utxo: Utxo,
/// The transaction containing the unspent [`TxOut`] referenced by [`utxo`].
///
/// [`TxOut`]: bitcoin::TxOut
/// [`utxo`]: Self::utxo
- pub(super) prevtx: Transaction,
+ pub(crate) prevtx: Transaction,
}
impl_writeable_tlv_based!(FundingTxInput, {
@@ -237,6 +238,11 @@ impl FundingTxInput {
self.utxo.outpoint
}
+ /// The unspent output.
+ pub fn output(&self) -> &TxOut {
+ &self.utxo.output
+ }
+
/// The sequence number to use in the [`TxIn`].
///
/// [`TxIn`]: bitcoin::TxIn
@@ -251,8 +257,13 @@ impl FundingTxInput {
self.utxo.sequence = sequence;
}
- /// Converts the [`FundingTxInput`] into a [`Utxo`] for coin selection.
+ /// Converts the [`FundingTxInput`] into a [`Utxo`].
pub fn into_utxo(self) -> Utxo {
self.utxo
}
+
+ /// Converts the [`FundingTxInput`] into a [`TxOut`].
+ pub fn into_output(self) -> TxOut {
+ self.utxo.output
+ }
}
diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs
index 1d3137a..02b63a6 100644
--- a/lightning/src/util/test_utils.rs
+++ b/lightning/src/util/test_utils.rs
@@ -22,7 +22,7 @@ use crate::chain::channelmonitor::{
use crate::chain::transaction::OutPoint;
use crate::chain::WatchedOutput;
use crate::events::bump_transaction::sync::WalletSourceSync;
-use crate::events::bump_transaction::Utxo;
+use crate::events::bump_transaction::{ConfirmedUtxo, Utxo};
#[cfg(any(test, feature = "_externalize_tests"))]
use crate::ln::chan_utils::CommitmentTransaction;
use crate::ln::channel_state::ChannelDetails;
@@ -2256,7 +2256,7 @@ impl Drop for TestScorer {
pub struct TestWalletSource {
secret_key: SecretKey,
- utxos: Mutex<Vec<Utxo>>,
+ utxos: Mutex<Vec<ConfirmedUtxo>>,
secp: Secp256k1<bitcoin::secp256k1::All>,
}
@@ -2265,21 +2265,13 @@ impl TestWalletSource {
Self { secret_key, utxos: Mutex::new(Vec::new()), secp: Secp256k1::new() }
}
- pub fn add_utxo(&self, outpoint: bitcoin::OutPoint, value: Amount) -> TxOut {
- let public_key = bitcoin::PublicKey::new(self.secret_key.public_key(&self.secp));
- let utxo = Utxo::new_v0_p2wpkh(outpoint, value, &public_key.wpubkey_hash().unwrap());
- self.utxos.lock().unwrap().push(utxo.clone());
- utxo.output
- }
-
- pub fn add_custom_utxo(&self, utxo: Utxo) -> TxOut {
- let output = utxo.output.clone();
+ pub fn add_utxo(&self, prevtx: Transaction, vout: u32) {
+ let utxo = ConfirmedUtxo::new_p2wpkh(prevtx, vout).unwrap();
self.utxos.lock().unwrap().push(utxo);
- output
}
pub fn remove_utxo(&self, outpoint: bitcoin::OutPoint) {
- self.utxos.lock().unwrap().retain(|utxo| utxo.outpoint != outpoint);
+ self.utxos.lock().unwrap().retain(|utxo| utxo.outpoint() != outpoint);
}
pub fn clear_utxos(&self) {
@@ -2292,12 +2284,12 @@ impl TestWalletSource {
let utxos = self.utxos.lock().unwrap();
for i in 0..tx.input.len() {
if let Some(utxo) =
- utxos.iter().find(|utxo| utxo.outpoint == tx.input[i].previous_output)
+ utxos.iter().find(|utxo| utxo.outpoint() == tx.input[i].previous_output)
{
let sighash = SighashCache::new(&tx).p2wpkh_signature_hash(
i,
- &utxo.output.script_pubkey,
- utxo.output.value,
+ &utxo.output().script_pubkey,
+ utxo.output().value,
EcdsaSighashType::All,
)?;
#[cfg(not(feature = "grind_signatures"))]
@@ -2322,7 +2314,17 @@ impl TestWalletSource {
impl WalletSourceSync for TestWalletSource {
fn list_confirmed_utxos(&self) -> Result<Vec<Utxo>, ()> {
- Ok(self.utxos.lock().unwrap().clone())
+ let utxos = self.utxos.lock().unwrap();
+ Ok(utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.clone()).collect())
+ }
+
+ fn get_prevtx(&self, outpoint: bitcoin::OutPoint) -> Result<Transaction, ()> {
+ let utxos = self.utxos.lock().unwrap();
+ utxos
+ .iter()
+ .find(|confirmed_utxo| confirmed_utxo.utxo.outpoint == outpoint)
+ .map(|ConfirmedUtxo { prevtx, .. }| prevtx.clone())
+ .ok_or(())
}
fn get_change_script(&self) -> Result<ScriptBuf, ()> {
Why this scored 27/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.