Produce FundingInfo::Contribution variants in ChannelMonitor
What changed, and why it matters
This commit improves how the Lightning Dev Kit node keeps track of funds involved in a 'splice' (a way to resize a Lightning channel while it is open). Previously, if the channel closed while a splice was still pending, the wallet only received a generic reference to the old funding transaction. Now it receives the actual inputs and outputs the user contributed, making it easier to recover those funds safely. The change is a feature completeness / robustness improvement rather than a fix for an active exploit.
Treat as a normal code-review item. Verify that the new serialization fields are optional and that downgrades/upgrades across versions do not lose the contribution data. Confirm that the test coverage exercises both the Contribution and OutPoint DiscardFunding paths. No urgent security response is indicated.
Security signals we found
Data-loss / wallet-recovery robustness: richer DiscardFunding events reduce the chance that user funds from a splice contribution become unrecoverable after an unexpected channel close.
Serialization schema change: new optional TLV fields (7 in RenegotiatedFunding, 13 in FundingScope, 41 in ChannelMonitor) preserve backward compatibility.
No input validation changes: the patch relies on the existing FundingContribution logic and does not introduce new parsing of untrusted data.
No privilege boundary or remote-triggerable code path is added.
Evidence from the diff
The patch extends ChannelMonitor to carry and persist the FundingContribution used during splice negotiation. When a pending splice funding is discarded (because the channel closed or a commitment confirmed for the current funding), ChannelMonitor now emits Event::DiscardFunding with FundingInfo::Contribution { inputs, outputs } instead of only FundingInfo::OutPoint. This preserves wallet context across ChannelManager restarts and channel-monitor-only operation. Serialization is updated for both ChannelMonitorUpdateStep::RenegotiatedFunding and FundingScope, with backward-compatible TLV fields. New serialization helpers for Vec
Changed components
lightning/src/chain/channelmonitor.rslightning/src/ln/channel.rslightning/src/ln/funding.rslightning/src/ln/splicing_tests.rslightning/src/util/ser.rsInspect captured patch +98 / −24
diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs
index c3e20ef..42d04e0 100644
--- a/lightning/src/chain/channelmonitor.rs
+++ b/lightning/src/chain/channelmonitor.rs
@@ -44,7 +44,7 @@ use crate::chain::package::{
use crate::chain::transaction::{OutPoint, TransactionData};
use crate::chain::{BlockLocator, WatchedOutput};
use crate::events::bump_transaction::{AnchorDescriptor, BumpTransactionEvent};
-use crate::events::{ClosureReason, Event, EventHandler, ReplayEvent};
+use crate::events::{ClosureReason, Event, EventHandler, FundingInfo, ReplayEvent};
use crate::ln::chan_utils::{
self, ChannelTransactionParameters, CommitmentTransaction, CounterpartyCommitmentSecrets,
HTLCClaim, HTLCOutputInCommitment, HolderCommitmentTransaction,
@@ -55,6 +55,7 @@ use crate::ln::channel_keys::{
RevocationKey,
};
use crate::ln::channelmanager::{HTLCSource, PaymentClaimDetails, SentHTLCId};
+use crate::ln::funding::FundingContribution;
use crate::ln::msgs::DecodeError;
use crate::ln::types::ChannelId;
use crate::sign::{
@@ -688,6 +689,7 @@ pub(crate) enum ChannelMonitorUpdateStep {
channel_parameters: ChannelTransactionParameters,
holder_commitment_tx: HolderCommitmentTransaction,
counterparty_commitment_tx: CommitmentTransaction,
+ funding_contribution: Option<FundingContribution>,
},
RenegotiatedFundingLocked {
funding_txid: Txid,
@@ -773,6 +775,7 @@ impl_writeable_tlv_based_enum_upgradable!(ChannelMonitorUpdateStep,
(1, channel_parameters, (required: ReadableArgs, None)),
(3, holder_commitment_tx, required),
(5, counterparty_commitment_tx, required),
+ (7, funding_contribution, option),
},
(12, RenegotiatedFundingLocked) => {
(1, funding_txid, required),
@@ -1166,6 +1169,9 @@ struct FundingScope {
// transaction for which we have deleted claim information on some watchtowers.
current_holder_commitment_tx: HolderCommitmentTransaction,
prev_holder_commitment_tx: Option<HolderCommitmentTransaction>,
+
+ /// Our funding contribution when we negotiated the corresponding funding transaction.
+ contribution: Option<FundingContribution>,
}
impl FundingScope {
@@ -1185,6 +1191,14 @@ impl FundingScope {
fn channel_type_features(&self) -> &ChannelTypeFeatures {
&self.channel_parameters.channel_type_features
}
+
+ fn contributed_inputs(&self) -> impl Iterator<Item = bitcoin::OutPoint> + '_ {
+ self.contribution.iter().flat_map(|contribution| contribution.contributed_inputs())
+ }
+
+ fn contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ {
+ self.contribution.iter().flat_map(|contribution| contribution.contributed_outputs())
+ }
}
impl_writeable_tlv_based!(FundingScope, {
@@ -1194,6 +1208,7 @@ impl_writeable_tlv_based!(FundingScope, {
(7, current_holder_commitment_tx, required),
(9, prev_holder_commitment_tx, option),
(11, counterparty_claimable_outpoints, required),
+ (13, contribution, option),
});
#[derive(Clone, PartialEq)]
@@ -1756,6 +1771,7 @@ pub(crate) fn write_chanmon_internal<Signer: EcdsaChannelSigner, W: Writer>(
(35, channel_monitor.is_manual_broadcast, required),
(37, channel_monitor.funding_seen_onchain, required),
(39, channel_monitor.best_block.previous_blocks, required),
+ (41, channel_monitor.funding.contribution, option),
});
Ok(())
@@ -1905,6 +1921,8 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitor<Signer> {
current_holder_commitment_tx: initial_holder_commitment_tx,
prev_holder_commitment_tx: None,
+
+ contribution: None,
},
pending_funding: vec![],
@@ -3959,6 +3977,7 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> {
&mut self, logger: &WithContext<L>, channel_parameters: &ChannelTransactionParameters,
alternative_holder_commitment_tx: &HolderCommitmentTransaction,
alternative_counterparty_commitment_tx: &CommitmentTransaction,
+ funding_contribution: &Option<FundingContribution>,
) -> Result<(), ()> {
let alternative_counterparty_commitment_txid =
alternative_counterparty_commitment_tx.trust().txid();
@@ -4025,6 +4044,7 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> {
counterparty_claimable_outpoints,
current_holder_commitment_tx: alternative_holder_commitment_tx.clone(),
prev_holder_commitment_tx: None,
+ contribution: funding_contribution.clone(),
};
let alternative_funding_outpoint = alternative_funding.funding_outpoint();
@@ -4081,6 +4101,29 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> {
Ok(())
}
+ fn queue_discard_funding_event(
+ &mut self, discarded_funding: impl Iterator<Item = FundingScope>,
+ ) {
+ for funding in discarded_funding {
+ if let Some(contribution) = funding.contribution {
+ if let Some((inputs, outputs)) = contribution.into_unique_contributions(
+ self.funding.contributed_inputs(),
+ self.funding.contributed_outputs(),
+ ) {
+ self.pending_events.push(Event::DiscardFunding {
+ channel_id: self.channel_id,
+ funding_info: FundingInfo::Contribution { inputs, outputs },
+ });
+ }
+ } else {
+ self.pending_events.push(Event::DiscardFunding {
+ channel_id: self.channel_id,
+ funding_info: FundingInfo::OutPoint { outpoint: funding.funding_outpoint() },
+ });
+ }
+ }
+ }
+
fn promote_funding(&mut self, new_funding_txid: Txid) -> Result<(), ()> {
let prev_funding_txid = self.funding.funding_txid();
@@ -4111,18 +4154,20 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> {
let no_further_updates_allowed = self.no_further_updates_allowed();
// The swap above places the previous `FundingScope` into `pending_funding`.
- for funding in self.pending_funding.drain(..) {
- let funding_txid = funding.funding_txid();
- self.outputs_to_watch.remove(&funding_txid);
- if no_further_updates_allowed && funding_txid != prev_funding_txid {
- self.pending_events.push(Event::DiscardFunding {
- channel_id: self.channel_id,
- funding_info: crate::events::FundingInfo::OutPoint {
- outpoint: funding.funding_outpoint(),
- },
- });
- }
+ for funding in &self.pending_funding {
+ self.outputs_to_watch.remove(&funding.funding_txid());
}
+ let mut discarded_funding = Vec::new();
+ mem::swap(&mut self.pending_funding, &mut discarded_funding);
+ let discarded_funding = discarded_funding
+ .into_iter()
+ // The previous funding is filtered out since it was already locked, so nothing needs to
+ // be discarded.
+ .filter(|funding| {
+ no_further_updates_allowed && funding.funding_txid() != prev_funding_txid
+ });
+ self.queue_discard_funding_event(discarded_funding);
+
if let Some((alternative_funding_txid, _)) = self.alternative_funding_confirmed.take() {
// In exceedingly rare cases, it's possible there was a reorg that caused a potential funding to
// be locked in that this `ChannelMonitor` has not yet seen. Thus, we avoid a runtime assertion
@@ -4239,11 +4284,13 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> {
},
ChannelMonitorUpdateStep::RenegotiatedFunding {
channel_parameters, holder_commitment_tx, counterparty_commitment_tx,
+ funding_contribution,
} => {
log_trace!(logger, "Updating ChannelMonitor with alternative holder and counterparty commitment transactions for funding txid {}",
channel_parameters.funding_outpoint.unwrap().txid);
if let Err(_) = self.renegotiated_funding(
logger, channel_parameters, holder_commitment_tx, counterparty_commitment_tx,
+ funding_contribution,
) {
ret = Err(());
}
@@ -5810,15 +5857,14 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> {
self.funding_spend_confirmed = Some(entry.txid);
self.confirmed_commitment_tx_counterparty_output = commitment_tx_to_counterparty_output;
if self.alternative_funding_confirmed.is_none() {
- for funding in self.pending_funding.drain(..) {
+ // We saw a confirmed commitment for our currently locked funding, so
+ // discard all pending ones.
+ for funding in &self.pending_funding {
self.outputs_to_watch.remove(&funding.funding_txid());
- self.pending_events.push(Event::DiscardFunding {
- channel_id: self.channel_id,
- funding_info: crate::events::FundingInfo::OutPoint {
- outpoint: funding.funding_outpoint(),
- },
- });
}
+ let mut discarded_funding = Vec::new();
+ mem::swap(&mut self.pending_funding, &mut discarded_funding);
+ self.queue_discard_funding_event(discarded_funding.into_iter());
}
},
OnchainEvent::AlternativeFundingConfirmation {} => {
@@ -6696,6 +6742,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP
let mut is_manual_broadcast = RequiredWrapper(None);
let mut funding_seen_onchain = RequiredWrapper(None);
let mut best_block_previous_blocks = None;
+ let mut current_funding_contribution = None;
read_tlv_fields!(reader, {
(1, funding_spend_confirmed, option),
(3, htlcs_resolved_on_chain, optional_vec),
@@ -6719,6 +6766,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP
(35, is_manual_broadcast, (default_value, false)),
(37, funding_seen_onchain, (default_value, true)),
(39, best_block_previous_blocks, option), // Added and always set in 0.3
+ (41, current_funding_contribution, option),
});
if let Some(previous_blocks) = best_block_previous_blocks {
best_block.previous_blocks = previous_blocks;
@@ -6837,6 +6885,7 @@ impl<'a, 'b, ES: EntropySource, SP: SignerProvider> ReadableArgs<(&'a ES, &'b SP
current_holder_commitment_tx,
prev_holder_commitment_tx,
+ contribution: current_funding_contribution,
},
pending_funding: pending_funding.unwrap_or(vec![]),
is_manual_broadcast: is_manual_broadcast.0.unwrap(),
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 6967f23..e9fde82 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -8400,6 +8400,12 @@ where
);
}
+ let funding_contribution = self
+ .pending_splice
+ .as_ref()
+ .and_then(|pending_splice| pending_splice.contributions.last())
+ .cloned();
+
log_info!(
logger,
"Received splice initial commitment_signed from peer with funding txid {}",
@@ -8413,6 +8419,7 @@ where
channel_parameters: pending_splice_funding.channel_transaction_parameters.clone(),
holder_commitment_tx,
counterparty_commitment_tx,
+ funding_contribution,
}],
channel_id: Some(self.context.channel_id()),
};
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 93685cc..3a0b4fb 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -582,11 +582,11 @@ impl FundingContribution {
self.is_splice
}
- pub(super) fn contributed_inputs(&self) -> impl Iterator<Item = OutPoint> + '_ {
+ pub(crate) fn contributed_inputs(&self) -> impl Iterator<Item = OutPoint> + '_ {
self.inputs.iter().map(|input| input.utxo.outpoint)
}
- pub(super) fn contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ {
+ pub(crate) fn contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ {
self.outputs
.iter()
.chain(self.change_output.iter())
@@ -761,7 +761,7 @@ impl FundingContribution {
(contributed_inputs, contributed_outputs.map(|output| output.script_pubkey).collect())
}
- pub(super) fn into_unique_contributions<'a>(
+ pub(crate) fn into_unique_contributions<'a>(
self, existing_inputs: impl Iterator<Item = OutPoint>,
existing_outputs: impl Iterator<Item = &'a bitcoin::Script>,
) -> Option<(Vec<OutPoint>, Vec<ScriptBuf>)> {
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 2887a5f..9d1342a 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -1842,6 +1842,8 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs:
let splice_in_amount = initial_channel_capacity / 2;
let initiator_contribution =
do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount));
+ let (expected_discarded_inputs, expected_discarded_outputs) =
+ initiator_contribution.clone().into_contributed_inputs_and_outputs();
let (splice_tx, _) =
splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution.clone());
let (preimage2, payment_hash2, ..) = route_payment(&nodes[0], &[&nodes[1]], payment_amount);
@@ -1985,14 +1987,25 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs:
.chain_source
.remove_watched_txn_and_outputs(funding_outpoint, txout.script_pubkey.clone());
- // `SpendableOutputs` events are also included here, but we don't care for them.
let events = nodes[0].chain_monitor.chain_monitor.get_and_clear_pending_events();
assert_eq!(events.len(), if claim_htlcs { 2 } else { 4 }, "{events:?}");
if let Event::DiscardFunding { funding_info, .. } = &events[0] {
- assert_eq!(*funding_info, FundingInfo::OutPoint { outpoint: funding_outpoint });
+ assert_eq!(
+ *funding_info,
+ FundingInfo::Contribution {
+ inputs: expected_discarded_inputs,
+ outputs: expected_discarded_outputs,
+ }
+ );
} else {
panic!();
}
+ assert!(matches!(&events[1], Event::SpendableOutputs { .. }));
+ if !claim_htlcs {
+ assert!(matches!(&events[2], Event::SpendableOutputs { .. }));
+ assert!(matches!(&events[3], Event::SpendableOutputs { .. }));
+ }
+
let events = nodes[1].chain_monitor.chain_monitor.get_and_clear_pending_events();
assert_eq!(events.len(), if claim_htlcs { 2 } else { 1 }, "{events:?}");
if let Event::DiscardFunding { funding_info, .. } = &events[0] {
@@ -2000,6 +2013,9 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs:
} else {
panic!();
}
+ if claim_htlcs {
+ assert!(matches!(&events[1], Event::SpendableOutputs { .. }));
+ }
}
}
diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs
index bd2488b..88c0363 100644
--- a/lightning/src/util/ser.rs
+++ b/lightning/src/util/ser.rs
@@ -1099,6 +1099,8 @@ impl_for_vec!(crate::ln::channelmanager::MonitorUpdateCompletionAction);
impl_for_vec!(crate::ln::channelmanager::PaymentClaimDetails);
impl_for_vec!(crate::ln::msgs::SocketAddress);
impl_for_vec!((A, B), A, B);
+impl_for_vec!(OutPoint);
+impl_for_vec!(ScriptBuf);
impl_for_vec!(SerialId);
impl_for_vec!(TxInMetadata);
impl_for_vec!(TxOutMetadata);
Why this scored 24/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.