Change FundingInfo::Contribution to expose contributed output scripts
What changed, and why it matters
This commit refactors how a Lightning node reports which transaction outputs a user is contributing during channel funding or splicing. Instead of sharing full output details (including amounts), it now shares only the output scripts (addresses). The stated reason is that amounts can change across RBF (fee-bump) attempts, so scripts are a more stable identifier. The change also fixes filtering logic so duplicate outputs are correctly recognized by their script, preventing a user's own change output from being incorrectly reported as a new contribution in later rounds.
Treat as a normal code-quality and API-clarity patch. Reviewers should verify that downstream consumers of `FundingInfo::Contribution` no longer need output amounts, and that the new script-only deduplication correctly handles all edge cases where two distinct outputs share the same script (e.g., reused addresses). No immediate security response is indicated by the supplied materials.
Security signals we found
Change in public API data type from full transaction outputs to scripts only
Fix to output deduplication logic that previously failed to filter the change output by script
Refactoring of RBF/splicing contribution tracking to use stable identifiers (scripts) rather than amounts
No explicit security claim in commit message or diff
Evidence from the diff
The patch changes FundingInfo::Contribution and related internal helpers from carrying Vec<TxOut> to carrying Vec<ScriptBuf> for outputs. Iterators such as contributed_outputs() now yield &bitcoin::Script rather than &TxOut, and deduplication in into_unique_contributions() compares script_pubkey directly. A bug in the prior deduplication logic is corrected: previously only outputs were filtered against existing scripts, while change_output was not, meaning a repeated change output could be treated as a unique contribution. The new code filters both outputs and change_output by script and returns only scripts. Tests are updated to expect scripts instead of full TxOuts.
Changed components
lightning/src/events/mod.rs - FundingInfo::Contribution public eventlightning/src/ln/channel.rs - PendingFunding, FundingNegotiationContext, SpliceFundingFailedlightning/src/ln/funding.rs - FundingContribution helpers and deduplicationlightning/src/ln/interactivetxs.rs - ConstructedTransaction, InteractiveTxSigningSession, InteractiveTxConstructorlightning/src/ln/splicing_tests.rs - splicing test expectationsInspect captured patch +75 / −45
diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs
index 0d5b8f7..5f4f3cc 100644
--- a/lightning/src/events/mod.rs
+++ b/lightning/src/events/mod.rs
@@ -52,7 +52,7 @@ use bitcoin::hashes::sha256::Hash as Sha256;
use bitcoin::hashes::Hash;
use bitcoin::script::ScriptBuf;
use bitcoin::secp256k1::PublicKey;
-use bitcoin::{OutPoint, Transaction, TxOut};
+use bitcoin::{OutPoint, Transaction};
use core::ops::Deref;
#[allow(unused_imports)]
@@ -82,8 +82,8 @@ pub enum FundingInfo {
Contribution {
/// UTXOs spent as inputs contributed to the funding transaction.
inputs: Vec<OutPoint>,
- /// Outputs contributed to the funding transaction.
- outputs: Vec<TxOut>,
+ /// Output scripts contributed to the funding transaction.
+ outputs: Vec<ScriptBuf>,
},
}
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 18236d7..6967f23 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -3133,7 +3133,7 @@ impl PendingFunding {
self.contributions.iter().flat_map(|c| c.contributed_inputs())
}
- fn contributed_outputs(&self) -> impl Iterator<Item = &TxOut> + '_ {
+ fn contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ {
self.contributions.iter().flat_map(|c| c.contributed_outputs())
}
@@ -3142,7 +3142,7 @@ impl PendingFunding {
self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_inputs())
}
- fn prior_contributed_outputs(&self) -> impl Iterator<Item = &TxOut> + '_ {
+ fn prior_contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ {
let len = self.contributions.len();
self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_outputs())
}
@@ -3191,7 +3191,7 @@ pub(crate) enum QuiescentAction {
pub(super) enum QuiescentError {
DoNothing,
- DiscardFunding { inputs: Vec<bitcoin::OutPoint>, outputs: Vec<bitcoin::TxOut> },
+ DiscardFunding { inputs: Vec<bitcoin::OutPoint>, outputs: Vec<bitcoin::ScriptBuf> },
FailSplice(SpliceFundingFailed, NegotiationFailureReason),
}
@@ -6887,8 +6887,8 @@ impl FundingNegotiationContext {
self.our_funding_inputs.iter().map(|input| input.utxo.outpoint)
}
- fn contributed_outputs(&self) -> impl Iterator<Item = &TxOut> + '_ {
- self.our_funding_outputs.iter()
+ fn contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ {
+ self.our_funding_outputs.iter().map(|output| output.script_pubkey.as_script())
}
}
@@ -7046,7 +7046,7 @@ pub struct SpliceFundingFailed {
/// Outputs contributed to the splice transaction. Excludes outputs already contributed
/// in prior rounds, which may be included in `contribution`.
- contributed_outputs: Vec<bitcoin::TxOut>,
+ contributed_outputs: Vec<ScriptBuf>,
/// The funding contribution from the failed round, if available.
contribution: Option<FundingContribution>,
@@ -11689,7 +11689,7 @@ where
.filter_map(|contribution| {
contribution.into_unique_contributions(
promoted_tx.input.iter().map(|i| i.previous_output),
- promoted_tx.output.iter(),
+ promoted_tx.output.iter().map(|o| o.script_pubkey.as_script()),
)
})
.map(|(inputs, outputs)| FundingInfo::Contribution { inputs, outputs })
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 2f4e89d..93685cc 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -586,8 +586,11 @@ impl FundingContribution {
self.inputs.iter().map(|input| input.utxo.outpoint)
}
- pub(super) fn contributed_outputs(&self) -> impl Iterator<Item = &TxOut> + '_ {
- self.outputs.iter().chain(self.change_output.iter())
+ pub(super) fn contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ {
+ self.outputs
+ .iter()
+ .chain(self.change_output.iter())
+ .map(|output| output.script_pubkey.as_script())
}
/// The value that will be added to the channel after fees. See [`Self::net_value`] for the net
@@ -751,26 +754,41 @@ impl FundingContribution {
(inputs, outputs)
}
- pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec<OutPoint>, Vec<TxOut>) {
- let (inputs, outputs) = self.into_tx_parts();
-
- (inputs.into_iter().map(|input| input.utxo.outpoint).collect(), outputs)
+ pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec<OutPoint>, Vec<ScriptBuf>) {
+ let FundingContribution { inputs, outputs, change_output, .. } = self;
+ let contributed_inputs = inputs.into_iter().map(|input| input.utxo.outpoint).collect();
+ let contributed_outputs = outputs.into_iter().chain(change_output.into_iter());
+ (contributed_inputs, contributed_outputs.map(|output| output.script_pubkey).collect())
}
pub(super) fn into_unique_contributions<'a>(
self, existing_inputs: impl Iterator<Item = OutPoint>,
- existing_outputs: impl Iterator<Item = &'a TxOut>,
- ) -> Option<(Vec<OutPoint>, Vec<TxOut>)> {
- let (mut inputs, mut outputs) = self.into_contributed_inputs_and_outputs();
+ existing_outputs: impl Iterator<Item = &'a bitcoin::Script>,
+ ) -> Option<(Vec<OutPoint>, Vec<ScriptBuf>)> {
+ let FundingContribution { mut inputs, mut outputs, mut change_output, .. } = self;
for existing in existing_inputs {
- inputs.retain(|input| *input != existing);
+ inputs.retain(|input| input.outpoint() != existing);
}
for existing in existing_outputs {
- outputs.retain(|output| output.script_pubkey != existing.script_pubkey);
+ outputs.retain(|output| output.script_pubkey.as_script() != existing);
+ // TODO: Replace with `take_if` once our MSRV is >= 1.80.
+ if change_output
+ .as_ref()
+ .filter(|output| output.script_pubkey.as_script() == existing)
+ .is_some()
+ {
+ change_output.take();
+ }
}
- if inputs.is_empty() && outputs.is_empty() {
+ if inputs.is_empty() && outputs.is_empty() && change_output.as_ref().is_none() {
None
} else {
+ let inputs = inputs.into_iter().map(|input| input.outpoint()).collect();
+ let outputs = outputs
+ .into_iter()
+ .chain(change_output.into_iter())
+ .map(|output| output.script_pubkey)
+ .collect();
Some((inputs, outputs))
}
}
diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs
index 10dae95..16b2806 100644
--- a/lightning/src/ln/interactivetxs.rs
+++ b/lightning/src/ln/interactivetxs.rs
@@ -378,7 +378,7 @@ impl ConstructedTransaction {
.map(|(_, (txin, _))| txin.previous_output)
}
- fn contributed_outputs(&self) -> impl Iterator<Item = &TxOut> + '_ {
+ fn contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ {
self.tx
.output
.iter()
@@ -386,7 +386,7 @@ impl ConstructedTransaction {
.enumerate()
.filter(|(_, (_, output))| output.is_local(self.holder_is_initiator))
.filter(|(index, _)| *index != self.shared_output_index as usize)
- .map(|(_, (txout, _))| txout)
+ .map(|(_, (txout, _))| txout.script_pubkey.as_script())
}
pub fn tx(&self) -> &Transaction {
@@ -879,7 +879,7 @@ impl InteractiveTxSigningSession {
self.unsigned_tx.contributed_inputs()
}
- pub(super) fn contributed_outputs(&self) -> impl Iterator<Item = &TxOut> + '_ {
+ pub(super) fn contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ {
self.unsigned_tx.contributed_outputs()
}
}
@@ -2121,11 +2121,11 @@ impl InteractiveTxConstructor {
.map(|(_, input)| input.tx_in().previous_output)
}
- pub(super) fn contributed_outputs(&self) -> impl Iterator<Item = &TxOut> + '_ {
+ pub(super) fn contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ {
self.outputs_to_contribute
.iter()
.filter(|(_, output)| !output.is_shared())
- .map(|(_, output)| output.tx_out())
+ .map(|(_, output)| output.tx_out().script_pubkey.as_script())
}
pub fn is_initiator(&self) -> bool {
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 1b6879e..2887a5f 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -685,8 +685,8 @@ pub fn splice_channel<'a, 'b, 'c, 'd>(
pub struct SpliceLockedResult {
pub stfu: Option<MessageSendEvent>,
- pub node_a_discarded: Vec<(Vec<bitcoin::OutPoint>, Vec<TxOut>)>,
- pub node_b_discarded: Vec<(Vec<bitcoin::OutPoint>, Vec<TxOut>)>,
+ pub node_a_discarded: Vec<(Vec<bitcoin::OutPoint>, Vec<ScriptBuf>)>,
+ pub node_b_discarded: Vec<(Vec<bitcoin::OutPoint>, Vec<ScriptBuf>)>,
}
pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
@@ -3227,7 +3227,8 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_
assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs);
// The change output was filtered (same script_pubkey as the prior splice's
// change output), but the splice-out output survives (different script_pubkey).
- let expected_outputs: Vec<_> = splice_out_output.into_iter().collect();
+ let expected_outputs: Vec<_> =
+ splice_out_output.into_iter().map(|output| output.script_pubkey).collect();
assert_eq!(*outputs, expected_outputs);
},
other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
@@ -3924,10 +3925,6 @@ fn test_funding_contributed_splice_already_pending() {
// Clear UTXOs and add a LARGER one for the second contribution to ensure
// the change output will be different from the first contribution's change
- //
- // FIXME: Should we actually not consider the change value given DiscardFunding is meant to
- // reclaim the change script pubkey? But that means for other cases we'd need to track which
- // output is for change later in the pipeline.
nodes[0].wallet_source.clear_utxos();
provide_utxo_reserves(&nodes, 1, splice_in_amount * 3);
@@ -3941,6 +3938,13 @@ fn test_funding_contributed_splice_already_pending() {
.build()
.unwrap();
+ // The change script should remain the same.
+ assert_eq!(
+ first_contribution.change_output().map(|output| &output.script_pubkey),
+ second_contribution.change_output().map(|output| &output.script_pubkey),
+ );
+ let change_script = first_contribution.change_output().unwrap().script_pubkey.clone();
+
// First funding_contributed - this sets up the quiescent action
nodes[0].node.funding_contributed(&channel_id, &node_id_1, first_contribution, None).unwrap();
@@ -3950,7 +3954,9 @@ fn test_funding_contributed_splice_already_pending() {
// Second funding_contributed with a different contribution - this should trigger
// DiscardFunding because there's already a pending quiescent action (splice contribution).
// Only inputs/outputs NOT in the existing contribution should be discarded.
- let expected_inputs: Vec<_> = second_contribution.contributed_inputs().collect();
+ let (expected_inputs, mut expected_outputs) =
+ second_contribution.clone().into_contributed_inputs_and_outputs();
+ expected_outputs.retain(|output| *output != change_script);
// Returns Err(APIMisuseError) and emits DiscardFunding for the non-duplicate parts of the second contribution
assert_eq!(
@@ -3960,8 +3966,6 @@ fn test_funding_contributed_splice_already_pending() {
})
);
- // The second contribution has different outputs (second_splice_out differs from first_splice_out),
- // so those outputs should NOT be filtered out - they should appear in DiscardFunding.
let events = nodes[0].node.get_and_clear_pending_events();
assert_eq!(events.len(), 1);
match &events[0] {
@@ -3970,10 +3974,9 @@ fn test_funding_contributed_splice_already_pending() {
if let FundingInfo::Contribution { inputs, outputs } = funding_info {
// The input is different, so it should be in the discard event
assert_eq!(*inputs, expected_inputs);
- // The splice-out output (different script_pubkey) survives filtering;
- // the change output (same script_pubkey as first contribution) is filtered.
- assert_eq!(outputs.len(), 1);
- assert!(outputs.contains(&second_splice_out));
+ // The different output should NOT be filtered out, but the change script should as
+ // it is the same in both contributions.
+ assert_eq!(*outputs, expected_outputs);
} else {
panic!("Expected FundingInfo::Contribution");
}
@@ -4085,6 +4088,13 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) {
.build()
.unwrap();
+ // The change script should remain the same.
+ assert_eq!(
+ first_contribution.change_output().map(|output| &output.script_pubkey),
+ second_contribution.change_output().map(|output| &output.script_pubkey),
+ );
+ let change_script = first_contribution.change_output().unwrap().script_pubkey.clone();
+
// First funding_contributed - sets up the quiescent action and queues STFU
nodes[0]
.node
@@ -4131,7 +4141,9 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) {
// Call funding_contributed with the second contribution. Inputs don't overlap (different
// UTXOs) so they all survive. The splice-out output (different script_pubkey) survives
// while the change output (same script_pubkey as first contribution) is filtered.
- let expected_inputs: Vec<_> = second_contribution.contributed_inputs().collect();
+ let (expected_inputs, mut expected_outputs) =
+ second_contribution.clone().into_contributed_inputs_and_outputs();
+ expected_outputs.retain(|output| *output != change_script);
assert_eq!(
nodes[0].node.funding_contributed(&channel_id, &node_id_1, second_contribution, None),
Err(APIError::APIMisuseError {
@@ -4149,7 +4161,7 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) {
assert_eq!(*inputs, expected_inputs);
// Only the splice-out output survives; the change output is filtered
// (same script_pubkey as first contribution's change).
- assert_eq!(*outputs, vec![splice_out_output]);
+ assert_eq!(*outputs, vec![splice_out_output.script_pubkey]);
} else {
panic!("Expected FundingInfo::Contribution");
}
@@ -4826,7 +4838,7 @@ fn test_splice_rbf_discard_unique_contribution() {
assert_eq!(result.node_a_discarded.len(), 1);
let (ref inputs, ref outputs) = result.node_a_discarded[0];
assert_eq!(*inputs, round_0_inputs);
- assert_eq!(*outputs, vec![splice_out_output]);
+ assert_eq!(*outputs, vec![splice_out_output.script_pubkey]);
// Node 1 (non-contributing acceptor) has no contributions to discard.
assert!(result.node_b_discarded.is_empty());
@@ -6851,7 +6863,7 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() {
assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs);
// The change output was filtered (same script_pubkey as round 0's change output),
// but the splice-out output survives (different script_pubkey).
- assert_eq!(*outputs, vec![splice_out_output.clone()]);
+ assert_eq!(*outputs, vec![splice_out_output.script_pubkey.clone()]);
},
other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
}
Why this scored 28/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.