Skip to content

Commit 082bc58

Browse files
jkczyzclaude
andcommitted
Re-validate contribution at quiescence time
Outbound HTLCs can be sent between funding_contributed and quiescence, reducing the holder's balance. Re-validate the contribution when quiescence is achieved and balances are stable. On failure, emit SpliceFailed + DiscardFunding events and disconnect the peer so both sides cleanly exit quiescence. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e66168c commit 082bc58

File tree

4 files changed

+282
-80
lines changed

4 files changed

+282
-80
lines changed

lightning/src/ln/channel.rs

Lines changed: 59 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6796,24 +6796,30 @@ where
67966796
shutdown_result
67976797
}
67986798

6799+
/// Builds a [`SpliceFundingFailed`] from a contribution, filtering out inputs/outputs
6800+
/// that are still committed to a prior splice round.
6801+
fn splice_funding_failed_for(&self, contribution: FundingContribution) -> SpliceFundingFailed {
6802+
let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs();
6803+
if let Some(ref pending_splice) = self.pending_splice {
6804+
for input in pending_splice.contributed_inputs() {
6805+
inputs.retain(|i| *i != input);
6806+
}
6807+
for output in pending_splice.contributed_outputs() {
6808+
outputs.retain(|o| o.script_pubkey != output.script_pubkey);
6809+
}
6810+
}
6811+
SpliceFundingFailed {
6812+
funding_txo: None,
6813+
channel_type: None,
6814+
contributed_inputs: inputs,
6815+
contributed_outputs: outputs,
6816+
}
6817+
}
6818+
67996819
fn quiescent_action_into_error(&self, action: QuiescentAction) -> QuiescentError {
68006820
match action {
68016821
QuiescentAction::Splice { contribution, .. } => {
6802-
let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs();
6803-
if let Some(ref pending_splice) = self.pending_splice {
6804-
for input in pending_splice.contributed_inputs() {
6805-
inputs.retain(|i| *i != input);
6806-
}
6807-
for output in pending_splice.contributed_outputs() {
6808-
outputs.retain(|o| o.script_pubkey != output.script_pubkey);
6809-
}
6810-
}
6811-
QuiescentError::FailSplice(SpliceFundingFailed {
6812-
funding_txo: None,
6813-
channel_type: None,
6814-
contributed_inputs: inputs,
6815-
contributed_outputs: outputs,
6816-
})
6822+
QuiescentError::FailSplice(self.splice_funding_failed_for(contribution))
68176823
},
68186824
#[cfg(any(test, fuzzing, feature = "_test_utils"))]
68196825
QuiescentAction::DoNothing => QuiescentError::DoNothing,
@@ -13692,27 +13698,27 @@ where
1369213698
#[rustfmt::skip]
1369313699
pub fn stfu<L: Logger>(
1369413700
&mut self, msg: &msgs::Stfu, logger: &L
13695-
) -> Result<Option<StfuResponse>, ChannelError> {
13701+
) -> Result<Option<StfuResponse>, (ChannelError, QuiescentError)> {
1369613702
if self.context.channel_state.is_quiescent() {
13697-
return Err(ChannelError::Warn("Channel is already quiescent".to_owned()));
13703+
return Err((ChannelError::Warn("Channel is already quiescent".to_owned()), QuiescentError::DoNothing));
1369813704
}
1369913705
if self.context.channel_state.is_remote_stfu_sent() {
13700-
return Err(ChannelError::Warn(
13706+
return Err((ChannelError::Warn(
1370113707
"Peer sent `stfu` when they already sent it and we've yet to become quiescent".to_owned()
13702-
));
13708+
), QuiescentError::DoNothing));
1370313709
}
1370413710

1370513711
if !self.context.is_live() {
13706-
return Err(ChannelError::Warn(
13712+
return Err((ChannelError::Warn(
1370713713
"Peer sent `stfu` when we were not in a live state".to_owned()
13708-
));
13714+
), QuiescentError::DoNothing));
1370913715
}
1371013716

1371113717
if !self.context.channel_state.is_local_stfu_sent() {
1371213718
if !msg.initiator {
13713-
return Err(ChannelError::WarnAndDisconnect(
13719+
return Err((ChannelError::WarnAndDisconnect(
1371413720
"Peer sent unexpected `stfu` without signaling as initiator".to_owned()
13715-
));
13721+
), QuiescentError::DoNothing));
1371613722
}
1371713723

1371813724
// We don't check `is_waiting_on_peer_pending_channel_update` prior to setting the flag
@@ -13742,9 +13748,9 @@ where
1374213748
// have a monitor update pending if we've processed a message from the counterparty, but
1374313749
// we don't consider this when becoming quiescent since the states are not mutually
1374413750
// exclusive.
13745-
return Err(ChannelError::WarnAndDisconnect(
13751+
return Err((ChannelError::WarnAndDisconnect(
1374613752
"Received counterparty stfu while having pending counterparty updates".to_owned()
13747-
));
13753+
), QuiescentError::DoNothing));
1374813754
}
1374913755

1375013756
self.context.channel_state.clear_local_stfu_sent();
@@ -13757,14 +13763,40 @@ where
1375713763
);
1375813764

1375913765
if is_holder_quiescence_initiator {
13766+
// Re-validate the contribution before consuming it. Outbound HTLCs may
13767+
// have been sent between funding_contributed and quiescence, reducing
13768+
// the holder's balance. If invalid, disconnect — peer_disconnected will
13769+
// abandon the quiescent action and emit SpliceFailed + DiscardFunding.
1376013770
match self.quiescent_action.take() {
1376113771
None => {
1376213772
debug_assert!(false);
13763-
return Err(ChannelError::WarnAndDisconnect(
13773+
return Err((ChannelError::WarnAndDisconnect(
1376413774
"Internal Error: Didn't have anything to do after reaching quiescence".to_owned()
13765-
));
13775+
), QuiescentError::DoNothing));
1376613776
},
1376713777
Some(QuiescentAction::Splice { contribution, locktime }) => {
13778+
// Re-validate the contribution now that we're quiescent and
13779+
// balances are stable. Outbound HTLCs may have been sent between
13780+
// funding_contributed and quiescence, reducing the holder's
13781+
// balance. If invalid, disconnect and return the contribution so
13782+
// the user can reclaim their inputs.
13783+
if let Err(e) = contribution.validate().and_then(|()| {
13784+
let our_funding_contribution = contribution.net_value();
13785+
self.validate_splice_contributions(
13786+
our_funding_contribution,
13787+
SignedAmount::ZERO,
13788+
)
13789+
}) {
13790+
let failed = self.splice_funding_failed_for(contribution);
13791+
return Err((
13792+
ChannelError::WarnAndDisconnect(format!(
13793+
"Channel {} contribution no longer valid at quiescence: {}",
13794+
self.context.channel_id(),
13795+
e,
13796+
)),
13797+
QuiescentError::FailSplice(failed),
13798+
));
13799+
}
1376813800
let prior_contribution = contribution.clone();
1376913801
let prev_funding_input = self.funding.to_splice_funding_input();
1377013802
let our_funding_contribution = contribution.net_value();

lightning/src/ln/channelmanager.rs

Lines changed: 77 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -6487,6 +6487,53 @@ impl<
64876487
result
64886488
}
64896489

6490+
/// Emits events for a [`QuiescentError`], if applicable.
6491+
fn handle_quiescent_error(
6492+
&self, channel_id: ChannelId, counterparty_node_id: PublicKey, user_channel_id: u128,
6493+
error: QuiescentError,
6494+
) {
6495+
match error {
6496+
QuiescentError::DoNothing => {},
6497+
QuiescentError::DiscardFunding { inputs, outputs } => {
6498+
self.pending_events.lock().unwrap().push_back((
6499+
events::Event::DiscardFunding {
6500+
channel_id,
6501+
funding_info: FundingInfo::Contribution { inputs, outputs },
6502+
},
6503+
None,
6504+
));
6505+
},
6506+
QuiescentError::FailSplice(SpliceFundingFailed {
6507+
funding_txo,
6508+
channel_type,
6509+
contributed_inputs,
6510+
contributed_outputs,
6511+
}) => {
6512+
let pending_events = &mut self.pending_events.lock().unwrap();
6513+
pending_events.push_back((
6514+
events::Event::SpliceFailed {
6515+
channel_id,
6516+
counterparty_node_id,
6517+
user_channel_id,
6518+
abandoned_funding_txo: funding_txo,
6519+
channel_type,
6520+
},
6521+
None,
6522+
));
6523+
pending_events.push_back((
6524+
events::Event::DiscardFunding {
6525+
channel_id,
6526+
funding_info: FundingInfo::Contribution {
6527+
inputs: contributed_inputs,
6528+
outputs: contributed_outputs,
6529+
},
6530+
},
6531+
None,
6532+
));
6533+
},
6534+
}
6535+
}
6536+
64906537
/// Adds or removes funds from the given channel as specified by a [`FundingContribution`].
64916538
///
64926539
/// Used after [`ChannelManager::splice_channel`] by constructing a [`FundingContribution`]
@@ -6593,62 +6640,29 @@ impl<
65936640
);
65946641
}
65956642
},
6596-
Err(QuiescentError::DoNothing) => {
6597-
result = Err(APIError::APIMisuseError {
6598-
err: format!(
6599-
"Duplicate funding contribution for channel {}",
6600-
channel_id
6601-
),
6602-
});
6603-
},
6604-
Err(QuiescentError::DiscardFunding { inputs, outputs }) => {
6605-
self.pending_events.lock().unwrap().push_back((
6606-
events::Event::DiscardFunding {
6607-
channel_id: *channel_id,
6608-
funding_info: FundingInfo::Contribution { inputs, outputs },
6609-
},
6610-
None,
6611-
));
6643+
Err(e) => {
66126644
result = Err(APIError::APIMisuseError {
6613-
err: format!(
6614-
"Channel {} already has a pending funding contribution",
6615-
channel_id
6616-
),
6617-
});
6618-
},
6619-
Err(QuiescentError::FailSplice(SpliceFundingFailed {
6620-
funding_txo,
6621-
channel_type,
6622-
contributed_inputs,
6623-
contributed_outputs,
6624-
})) => {
6625-
let pending_events = &mut self.pending_events.lock().unwrap();
6626-
pending_events.push_back((
6627-
events::Event::SpliceFailed {
6628-
channel_id: *channel_id,
6629-
counterparty_node_id: *counterparty_node_id,
6630-
user_channel_id: channel.context().get_user_id(),
6631-
abandoned_funding_txo: funding_txo,
6632-
channel_type,
6633-
},
6634-
None,
6635-
));
6636-
pending_events.push_back((
6637-
events::Event::DiscardFunding {
6638-
channel_id: *channel_id,
6639-
funding_info: FundingInfo::Contribution {
6640-
inputs: contributed_inputs,
6641-
outputs: contributed_outputs,
6642-
},
6645+
err: match &e {
6646+
QuiescentError::DoNothing => format!(
6647+
"Duplicate funding contribution for channel {}",
6648+
channel_id,
6649+
),
6650+
QuiescentError::DiscardFunding { .. } => format!(
6651+
"Channel {} already has a pending funding contribution",
6652+
channel_id,
6653+
),
6654+
QuiescentError::FailSplice(_) => format!(
6655+
"Channel {} cannot accept funding contribution",
6656+
channel_id,
6657+
),
66436658
},
6644-
None,
6645-
));
6646-
result = Err(APIError::APIMisuseError {
6647-
err: format!(
6648-
"Channel {} cannot accept funding contribution",
6649-
channel_id
6650-
),
66516659
});
6660+
self.handle_quiescent_error(
6661+
*channel_id,
6662+
*counterparty_node_id,
6663+
channel.context().get_user_id(),
6664+
e,
6665+
);
66526666
},
66536667
}
66546668

@@ -12793,6 +12807,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
1279312807
);
1279412808

1279512809
let res = chan.stfu(&msg, &&logger);
12810+
let (res, quiescent_error) = match res {
12811+
Ok(resp) => (Ok(resp), QuiescentError::DoNothing),
12812+
Err((chan_err, quiescent_err)) => (Err(chan_err), quiescent_err),
12813+
};
12814+
self.handle_quiescent_error(
12815+
chan_entry.get().context().channel_id(),
12816+
*counterparty_node_id,
12817+
chan_entry.get().context().get_user_id(),
12818+
quiescent_error,
12819+
);
1279612820
let resp = try_channel_entry!(self, peer_state, res, chan_entry);
1279712821
match resp {
1279812822
None => Ok(false),

lightning/src/ln/funding.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,16 @@ pub(super) struct PriorContribution {
176176
contribution: FundingContribution,
177177
/// The holder's balance, used for feerate adjustment. `None` when the balance computation
178178
/// fails, in which case adjustment is skipped and coin selection is re-run.
179+
///
180+
/// This value is captured at [`ChannelManager::splice_channel`] time and may become stale
181+
/// if balances change before the contribution is used. Staleness is acceptable here because
182+
/// this is only used as an optimization to determine if the prior contribution can be
183+
/// reused with adjusted fees — the contribution is re-validated at
184+
/// [`ChannelManager::funding_contributed`] time and again at quiescence time against the
185+
/// current balances.
186+
///
187+
/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
188+
/// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed
179189
holder_balance: Option<Amount>,
180190
}
181191

0 commit comments

Comments
 (0)