Include change output weight in estimate_transaction_fee
What changed, and why it matters
This commit fixes a fee-estimation bug in rust-lightning's channel funding and splicing code. When building a transaction that creates a change output, the code previously forgot to include the change output's size/weight when estimating the required transaction fee. That made the fee estimate slightly too low. The patch adds the missing weight and adds a test showing that a contribution with barely enough money would now be correctly rejected, whereas before it might have been accepted and later failed to broadcast or confirm reliably.
Treat as a routine correctness/defensive fix. Review callers of `estimate_transaction_fee` to ensure the new `change_output` argument is supplied everywhere. Consider whether any production wallet integrations could have produced under-funded splice/funding transactions and monitor for related failures. No emergency response is indicated by the diff alone.
Security signals we found
Underestimation of transaction fees in funding/splicing transaction construction
Potential acceptance of under-funded contributions leading to invalid or non-broadcastable transactions
Added defensive validation test (`test_validate_accounts_for_change_output_weight`)
No explicit CVE, advisory, or security disclosure referenced in commit
Evidence from the diff
The change modifies estimate_transaction_fee in lightning/src/ln/funding.rs to accept an optional change_output: Option<&TxOut> and includes its weight in the output-weight sum. FundingContribution::estimated_fee now accounts for the change output. FundingContribution::validate() is updated to rely on this conservative estimate. A new TightBudgetWallet mock in splicing_tests.rs demonstrates that validate() rejects a splice-in contribution whose inputs cover fees only when the change output weight is ignored. The commit also removes a temporary Vec<TxOut> allocation in compute_feerate_adjustment.
Changed components
lightning/src/ln/funding.rslightning/src/ln/splicing_tests.rsFundingContribution::estimate_transaction_feeFundingContribution::validatecompute_feerate_adjustmentInspect captured patch +129 / −17
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 1d1762a..18c05a5 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -116,7 +116,7 @@ macro_rules! build_funding_contribution {
// The caller creating a FundingContribution is always the initiator for fee estimation
// purposes — this is conservative, overestimating rather than underestimating fees if
// the node ends up as the acceptor.
- let estimated_fee = estimate_transaction_fee(&inputs, &outputs, true, is_splice, feerate);
+ let estimated_fee = estimate_transaction_fee(&inputs, &outputs, change_output.as_ref(), true, is_splice, feerate);
debug_assert!(estimated_fee <= Amount::MAX_MONEY);
let contribution = FundingContribution {
@@ -208,8 +208,8 @@ impl FundingTemplate {
}
fn estimate_transaction_fee(
- inputs: &[FundingTxInput], outputs: &[TxOut], is_initiator: bool, is_splice: bool,
- feerate: FeeRate,
+ inputs: &[FundingTxInput], outputs: &[TxOut], change_output: Option<&TxOut>,
+ is_initiator: bool, is_splice: bool, feerate: FeeRate,
) -> Amount {
let input_weight: u64 = inputs
.iter()
@@ -218,6 +218,7 @@ fn estimate_transaction_fee(
let output_weight: u64 = outputs
.iter()
+ .chain(change_output.into_iter())
.map(|txout| txout.weight().to_wu())
.fold(0, |total_weight, output_weight| total_weight.saturating_add(output_weight));
@@ -303,6 +304,14 @@ impl FundingContribution {
self.outputs.iter().chain(self.change_output.iter())
}
+ /// Returns the change output included in this contribution, if any.
+ ///
+ /// When coin selection provides more value than needed for the funding contribution and fees,
+ /// the surplus is returned to the wallet via this change output.
+ pub fn change_output(&self) -> Option<&TxOut> {
+ self.change_output.as_ref()
+ }
+
pub(super) fn into_tx_parts(self) -> (Vec<FundingTxInput>, Vec<TxOut>) {
let FundingContribution { inputs, mut outputs, change_output, .. } = self;
@@ -372,11 +381,11 @@ impl FundingContribution {
.ok_or("Sum of input values is greater than the total bitcoin supply")?;
}
- // If the inputs are enough to cover intended contribution amount, with fees even when
- // there is a change output, we are fine.
- // If the inputs are less, but enough to cover intended contribution amount, with
- // (lower) fees with no change, we are also fine (change will not be generated).
- // So it's enough to check considering the lower, no-change fees.
+ // If the inputs are enough to cover intended contribution amount plus fees (which
+ // include the change output weight when present), we are fine.
+ // If the inputs are less, but enough to cover intended contribution amount with
+ // (lower) fees without change, we are also fine (change will not be generated).
+ // Since estimated_fee includes change weight, this check is conservative.
//
// Note: dust limit is not relevant in this check.
@@ -442,45 +451,71 @@ mod tests {
// 2 inputs, initiator, 2000 sat/kw feerate
assert_eq!(
- estimate_transaction_fee(&two_inputs, &[], true, false, FeeRate::from_sat_per_kwu(2000)),
+ estimate_transaction_fee(&two_inputs, &[], None, true, false, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 1512 } else { 1516 }),
);
// higher feerate
assert_eq!(
- estimate_transaction_fee(&two_inputs, &[], true, false, FeeRate::from_sat_per_kwu(3000)),
+ estimate_transaction_fee(&two_inputs, &[], None, true, false, FeeRate::from_sat_per_kwu(3000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 2268 } else { 2274 }),
);
// only 1 input
assert_eq!(
- estimate_transaction_fee(&one_input, &[], true, false, FeeRate::from_sat_per_kwu(2000)),
+ estimate_transaction_fee(&one_input, &[], None, true, false, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 970 } else { 972 }),
);
// 0 inputs
assert_eq!(
- estimate_transaction_fee(&[], &[], true, false, FeeRate::from_sat_per_kwu(2000)),
+ estimate_transaction_fee(&[], &[], None, true, false, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(428),
);
// not initiator
assert_eq!(
- estimate_transaction_fee(&[], &[], false, false, FeeRate::from_sat_per_kwu(2000)),
+ estimate_transaction_fee(&[], &[], None, false, false, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(0),
);
// splice initiator
assert_eq!(
- estimate_transaction_fee(&one_input, &[], true, true, FeeRate::from_sat_per_kwu(2000)),
+ estimate_transaction_fee(&one_input, &[], None, true, true, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }),
);
// splice acceptor
assert_eq!(
- estimate_transaction_fee(&one_input, &[], false, true, FeeRate::from_sat_per_kwu(2000)),
+ estimate_transaction_fee(&one_input, &[], None, false, true, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 542 } else { 544 }),
);
+
+ // splice initiator, 1 input, 1 output
+ let outputs = [funding_output_sats(500)];
+ assert_eq!(
+ estimate_transaction_fee(&one_input, &outputs, None, true, true, FeeRate::from_sat_per_kwu(2000)),
+ Amount::from_sat(if cfg!(feature = "grind_signatures") { 1984 } else { 1988 }),
+ );
+
+ // splice acceptor, 1 input, 1 output
+ assert_eq!(
+ estimate_transaction_fee(&one_input, &outputs, None, false, true, FeeRate::from_sat_per_kwu(2000)),
+ Amount::from_sat(if cfg!(feature = "grind_signatures") { 790 } else { 792 }),
+ );
+
+ // splice initiator, 1 input, 1 output, 1 change via change_output parameter
+ let change = funding_output_sats(1_000);
+ assert_eq!(
+ estimate_transaction_fee(&one_input, &outputs, Some(&change), true, true, FeeRate::from_sat_per_kwu(2000)),
+ Amount::from_sat(if cfg!(feature = "grind_signatures") { 2232 } else { 2236 }),
+ );
+
+ // splice acceptor, 1 input, 1 output, 1 change via change_output parameter
+ assert_eq!(
+ estimate_transaction_fee(&one_input, &outputs, Some(&change), false, true, FeeRate::from_sat_per_kwu(2000)),
+ Amount::from_sat(if cfg!(feature = "grind_signatures") { 1038 } else { 1040 }),
+ );
}
#[rustfmt::skip]
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 66ff2e8..9bcc473 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -29,15 +29,18 @@ use crate::types::features::ChannelTypeFeatures;
use crate::util::config::UserConfig;
use crate::util::errors::APIError;
use crate::util::ser::Writeable;
-use crate::util::wallet_utils::{WalletSourceSync, WalletSync};
+use crate::util::wallet_utils::{
+ CoinSelection, CoinSelectionSourceSync, ConfirmedUtxo, Input, WalletSourceSync, WalletSync,
+};
use crate::sync::Arc;
use bitcoin::hashes::Hash;
use bitcoin::secp256k1::ecdsa::Signature;
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
+use bitcoin::transaction::Version;
use bitcoin::{
- Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash,
+ Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, WPubkeyHash,
};
#[test]
@@ -116,6 +119,80 @@ fn test_v1_splice_in_negative_insufficient_inputs() {
assert!(funding_template.splice_in_sync(splice_in_value, &wallet).is_err());
}
+/// A mock wallet that returns a pre-configured [`CoinSelection`] with a single input and change
+/// output. Used to test edge cases where the input value is tight relative to the fee estimate.
+#[cfg(test)]
+struct TightBudgetWallet {
+ utxo_value: Amount,
+ change_value: Amount,
+}
+
+#[cfg(test)]
+impl CoinSelectionSourceSync for TightBudgetWallet {
+ fn select_confirmed_utxos(
+ &self, _claim_id: Option<crate::chain::ClaimId>, _must_spend: Vec<Input>,
+ _must_pay_to: &[TxOut], _target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64,
+ ) -> Result<CoinSelection, ()> {
+ let prevout = TxOut {
+ value: self.utxo_value,
+ script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()),
+ };
+ let prevtx = Transaction {
+ input: vec![],
+ output: vec![prevout],
+ version: Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ };
+ let utxo = ConfirmedUtxo::new_p2wpkh(prevtx, 0).unwrap();
+
+ let change_output = TxOut {
+ value: self.change_value,
+ script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()),
+ };
+
+ Ok(CoinSelection { confirmed_utxos: vec![utxo], change_output: Some(change_output) })
+ }
+
+ fn sign_psbt(&self, _psbt: Psbt) -> Result<Transaction, ()> {
+ unreachable!("should not reach signing")
+ }
+}
+
+#[test]
+fn test_validate_accounts_for_change_output_weight() {
+ // Demonstrates that estimated_fee includes the change output's weight when building a
+ // FundingContribution. A mock wallet returns a single input whose value is between
+ // estimated_fee_without_change (1736/1740 sats) and estimated_fee_with_change (1984/1988
+ // sats) above value_added. The validate() check correctly catches that the inputs are
+ // insufficient when the change output weight is included. Without accounting for the change
+ // output weight, the check would incorrectly pass.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+
+ let feerate = FeeRate::from_sat_per_kwu(2000);
+ let funding_template = nodes[0]
+ .node
+ .splice_channel(&channel_id, &nodes[1].node.get_our_node_id(), feerate)
+ .unwrap();
+
+ // Input value = value_added + 1800: above 1736/1740 (fee without change), below 1984/1988
+ // (fee with change).
+ let value_added = Amount::from_sat(20_000);
+ let wallet = TightBudgetWallet {
+ utxo_value: value_added + Amount::from_sat(1800),
+ change_value: Amount::from_sat(1000),
+ };
+ let contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap();
+
+ assert!(contribution.change_output().is_some());
+ assert!(contribution.validate().is_err());
+}
+
pub fn negotiate_splice_tx<'a, 'b, 'c, 'd>(
initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId,
funding_contribution: FundingContribution,
Why this scored 37/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.