Uh oh!
There was an error while loading. Please reload this page.
Exit quiescence when splice_init and tx_init_rbf are rejected - #4495
Conversation
👋 Thanks for assigning @wpaulino as a reviewer! |
splice_init and tx_init_rbf are rejecteda5d670c to
57aad34Compare8cdc480 to
959b553CompareCodecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@## main #4495 +/- ##
==========================================
+ Coverage 87.08% 87.14% +0.06%
==========================================
Files 161 161 Lines 109255 109245 -10 Branches 109255 109245 -10 ==========================================
+ Hits 95147 95205 +58 + Misses 11627 11569 -58 + Partials 2481 2471 -10
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
959b553 to
69a1a1cCompare| fn quiescent_negotiation_err(&mut self, err: ChannelError) -> InteractiveTxMsgError { | ||
| let exited_quiescence = | ||
| if matches!(err, ChannelError::Abort(_)) { self.exit_quiescence() } else { false }; | ||
| InteractiveTxMsgError { err, splice_funding_failed: None, exited_quiescence } | ||
| } |
There was a problem hiding this comment.
In splice_init, resolve_queued_contribution (which can return Abort(FeeRateTooHigh)) is called beforevalidate_splice_init checks is_quiescent(). If a misbehaving peer sends splice_init during the STFU handshake (before quiescence is established), and the holder has a queued contribution with a low max_feerate, the Abort error would flow into quiescent_negotiation_err → exit_quiescence(), which has debug_assert!(!is_local_stfu_sent()) / debug_assert!(!is_remote_stfu_sent()).
Those asserts would fire because the STFU flags are still set (handshake incomplete). In release builds, exit_quiescence would harmlessly return false, but in debug/test builds this would panic.
Same issue applies to tx_init_rbf at line 12762 (and line 12780).
Consider guarding with an is_quiescent() check before calling exit_quiescence:
| fn quiescent_negotiation_err(&mutself,err:ChannelError) -> InteractiveTxMsgError{ | |
| let exited_quiescence = | |
| ifmatches!(err,ChannelError::Abort(_)){self.exit_quiescence()}else{false}; | |
| InteractiveTxMsgError{ err,splice_funding_failed:None, exited_quiescence } | |
| } | |
| fn quiescent_negotiation_err(&mutself,err:ChannelError) -> InteractiveTxMsgError{ | |
| let exited_quiescence = ifmatches!(err,ChannelError::Abort(_)) | |
| &&self.context.channel_state.is_quiescent() | |
| { | |
| self.exit_quiescence() | |
| }else{ | |
| false | |
| }; | |
| InteractiveTxMsgError{ err,splice_funding_failed:None, exited_quiescence } | |
| } |
There was a problem hiding this comment.
Fixed, though I took the opportunity to have validate_splice_init and validate_ tx_init_rbf no longer call validate_splice_contributions and FundingScope::for_splice. Rather they are called at those call sites. This let's us check if we are quiescent before checking fee rates when calling resolve_queued_contribution.
After a thorough review of the entire diff, including cross-referencing the existing codebase for correctness of validation ordering, error handling flows, and quiescence state transitions, I found no new issues beyond what was covered in my prior review comments. Prior comment corrections:
No issues found. |
There was a problem hiding this comment.
Note that we'll now abort an in-progress RBF if the counterparty misbehaves by sending us tx_init_rbf. The spec isn't really clear on what to do here. There's a similar case in validate_splice_init where we'll disconnect if we already have a pending splice (even if negotiation is still in progress). The spec does indicate this should be done. So maybe both of these are fine?
d275551 to
3c55d3cComparejkczyz
commented
Apr 2, 2026
Rebased |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
3c55d3c to
e4b1c6bCompareldk-reviews-bot
commented
Apr 6, 2026
🔔 1st Reminder Hey @wpaulino! This PR has been waiting for your review. |
| debug_assert!(!err.exited_quiescence || matches!(err.err, ChannelError::Abort(_))); | ||
| MsgHandleErrInternal::from_chan_no_close(err.err, channel_id) | ||
| .with_exited_quiescence(err.exited_quiescence) |
There was a problem hiding this comment.
This does feel a bit like a leaky abstraction - can we move exited_quiescence into the ChannelError and just call it needs_holding_cell_release? Even moreso because the variable doesn't make sense if we disconnect, close, or send an error.
There was a problem hiding this comment.
Ok, I think we can infer it from tx_abort. At least I managed to get Claude to convince me that. Also, renamed the variables to needs_holding_cell_release and include similar inference elsewhere.
Let me know if this is looks good. I can make it a fixup if you prefer. May need a careful look, though.
ldk-reviews-bot
commented
Apr 8, 2026
🔔 2nd Reminder Hey @wpaulino! This PR has been waiting for your review. |
Uh oh!
There was an error while loading. Please reload this page.
b861c68 to
bdc17dcCompareldk-reviews-bot
commented
Apr 11, 2026
🔔 3rd Reminder Hey @wpaulino! This PR has been waiting for your review. |
ldk-reviews-bot
commented
Apr 13, 2026
🔔 4th Reminder Hey @wpaulino! This PR has been waiting for your review. |
ldk-reviews-bot
commented
Apr 15, 2026
🔔 5th Reminder Hey @TheBlueMatt@wpaulino! This PR has been waiting for your review. |
| let their_funding_contribution = match msg.funding_output_contribution { | ||
| Some(value) => SignedAmount::from_sat(value), | ||
| None => SignedAmount::ZERO, |
There was a problem hiding this comment.
I'm a bit confused why this field is even optional in the spec, and why we would allow the counterparty to initiate an RBF when they're not contributing anything. I guess we could have a queued splice with pending counterparty-initiated splice, but the feerate is too high for us, so we help the counterparty confirm their splice, such that we can try ours once it becomes locked?
There was a problem hiding this comment.
Yeah, or we just changed our mind and want to back out. Similarly for dual funding.
ldk-reviews-bot
commented
Apr 16, 2026
🔔 1st Reminder Hey @TheBlueMatt! This PR has been waiting for your review. |
ldk-reviews-bot
commented
Apr 20, 2026
🔔 2nd Reminder Hey @TheBlueMatt! This PR has been waiting for your review. |
ldk-reviews-bot
commented
Apr 22, 2026
🔔 3rd Reminder Hey @TheBlueMatt! This PR has been waiting for your review. |
When tx_init_rbf is rejected with ChannelError::Abort (e.g., insufficient RBF feerate, negotiation in progress, feerate too high), the error is converted to a tx_abort message but quiescence is never exited and holding cells are never freed. This leaves the channel stuck in a quiescent state. Fix this by intercepting ChannelError::Abort before try_channel_entry! in internal_tx_init_rbf, calling exit_quiescence on the channel, and returning the error with exited_quiescence set so that handle_error frees holding cells. Also make exit_quiescence available in non-test builds by removing its cfg gate. Update tests to use the proper RBF initiation flow (with tampered feerates) so that handle_tx_abort correctly echoes the abort and exits quiescence, rather than manually crafting tx_init_rbf messages that leave node 0 without proper negotiation state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The same bug fixed in the prior commit for tx_init_rbf also exists in internal_splice_init: when splice_init triggers FeeRateTooHigh in resolve_queued_contribution, the ChannelError::Abort goes through try_channel_entry! without exiting quiescence. Apply the same fix: intercept ChannelError::Abort before try_channel_entry!, call exit_quiescence, and return the error with exited_quiescence set. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
bdc17dc to
528b80fCompare| let their_funding_contribution = match msg.funding_output_contribution { | ||
| Some(value) => SignedAmount::from_sat(value), | ||
| None => SignedAmount::ZERO, |
There was a problem hiding this comment.
Yeah, or we just changed our mind and want to back out. Similarly for dual funding.
Uh oh!
There was an error while loading. Please reload this page.
The prior two commits manually intercepted ChannelError::Abort in the channelmanager handlers for splice_init and tx_init_rbf to exit quiescence before returning, since the channel methods didn't signal this themselves. The interactive TX message handlers already solved this by returning InteractiveTxMsgError which bundles exited_quiescence into the error type. Apply the same pattern: change splice_init and tx_init_rbf to return InteractiveTxMsgError, adding a quiescent_negotiation_err helper on FundedChannel that exits quiescence for Abort errors and passes through other variants unchanged. Extract handle_interactive_tx_msg_err in channelmanager to deduplicate the error handling across internal_tx_msg, internal_splice_init, internal_tx_init_rbf, and internal_tx_complete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `exited_quiescence` field on `MsgHandleErrInternal` and `InteractiveTxMsgError` is a leaky abstraction -- the channelmanager error handling shouldn't know about quiescence, only whether the holding cell needs to be released. Infer this from the presence of a `tx_abort` instead, since exiting quiescence via an error always produces one. Remove `exited_quiescence` from `InteractiveTxMsgError`, `MsgHandleErrInternal`, and the return type of `Channel::tx_abort`, along with the `with_exited_quiescence` builder. For unfunded v2 channels, `tx_abort` may be present without quiescence having been exited, but the holding cell release is a no-op since an unfunded channel won't have any HTLCs. Similarly, the unreachable `debug_assert!(false)` branch in `fail_interactive_tx_negotiation` for funded channels produces a `tx_abort` without exiting quiescence, but the holding cell release is a no-op since the channel is still quiescent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Several code paths exit quiescence by calling `clear_quiescent()` directly without also clearing the disconnect timer via `mark_response_received()`. This causes the timer to fire after the splice completes or is aborted, spuriously disconnecting the peer. Replace `clear_quiescent()` with `exit_quiescence()` in `on_tx_signatures_exchange`, `reset_pending_splice_state`, and `peer_connected_get_handshake`, which clears both the quiescent state and the disconnect timer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
528b80f to
d2f422bCompareldk-reviews-bot
commented
Apr 25, 2026
🔔 4th Reminder Hey @TheBlueMatt! This PR has been waiting for your review. |
ldk-reviews-bot
commented
Apr 27, 2026
🔔 5th Reminder Hey @TheBlueMatt! This PR has been waiting for your review. |
Uh oh!
There was an error while loading. Please reload this page.
Summary
tx_init_rbforsplice_initis rejected withAbort(e.g., insufficient RBF feerate, negotiation in progress), which left the channel stuck in a quiescent stateInteractiveTxMsgError, reusing the same pattern already used by the interactive TX message handlers, with a sharedhandle_interactive_tx_msg_errhelper in channelmanagerTest plan
test_splice_rbf_insufficient_feerateupdated to verify quiescence is properly exited aftertx_aborttest_splice_feerate_too_highupdated to verify quiescence is properly exited aftersplice_initrejection🤖 Generated with Claude Code
Based on #4494.