Skip to content

Splice channel state rework - #4061

Merged
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework
Sep 16, 2025
Merged

Splice channel state rework#4061
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework

Conversation

@wpaulino

Copy link
Copy Markdown
Contributor

This was pulled out of #4054 to ease review. It depends on #4060 to not bother with cfg(splicing) anymore.

@ldk-reviews-bot

ldk-reviews-bot commented Sep 5, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

Comment threadlightning/src/ln/channel.rs Outdated
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation has a potential logic issue. The FIXME comment correctly identifies that is_interactive_signing() is being used as a replacement for the removed OUR_TX_SIGNATURES_READY flag, but these represent different states:

  • is_interactive_signing() is true during the entire interactive signing process
  • OUR_TX_SIGNATURES_READY was specifically true only after our signatures were ready to send

This semantic difference could cause funding transactions to be incorrectly marked as non-broadcastable when they should be broadcastable. Consider either:

  1. Restoring the OUR_TX_SIGNATURES_READY flag for this specific use case, or
  2. Accessing the signing session directly to check if holder signatures are ready via something like signing_session.holder_tx_signatures().is_some()

This would more accurately represent the original intent of the code.

Suggested change
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),
// We need to check if our signatures are ready to send, which means the transaction
// is ready to be broadcast.
ChannelState::FundingNegotiated(flags) => {
ifletSome(signing_session) = &self.funding_tx_signing_session{
signing_session.holder_tx_signatures().is_none()
}else{
!flags.is_interactive_signing()
}
},

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @valentinewallace! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@wpaulino
wpaulino removed the request for review from valentinewallaceSeptember 8, 2025 20:42
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 9272b39 to 23a1c29CompareSeptember 8, 2025 21:49
@codecov

codecovBot commented Sep 8, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 20.20725% with 154 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.84%. Comparing base (5ae19b4) to head (7597140).
⚠️ Report is 8 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs20.10%146 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs0.00%3 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4061 +/- ##
==========================================
- Coverage 88.33% 87.84% -0.49% 
==========================================
Files 177 176 -1 Lines 131896 131744 -152 Branches 131896 131744 -152 ==========================================
- Hits 116512 115733 -779 - Misses 12728 13376 +648 + Partials 2656 2635 -21 
FlagCoverage Δ
fuzzing21.60% <6.21%> (-0.01%)⬇️
tests87.68% <20.20%> (-0.50%)⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@wpaulinowpaulino self-assigned this Sep 9, 2025
@wpaulinowpaulino added this to the 0.2 milestone Sep 9, 2025
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 23a1c29 to 0cbeb51CompareSeptember 9, 2025 19:59
Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines 8606 to 8623
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures", self.context.channel_id);
return Err(APIError::APIMisuseError { err });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code contains a pattern where a debug assertion claims a signing session must always be present during interactive signing, but then includes error handling for when it's None. This creates a logical contradiction - either:

  1. The assertion is correct and the error handling is unnecessary, or
  2. The error handling is necessary and the assertion is incorrect

This pattern appears multiple times throughout the code. Consider either:

  • Removing the error handling if the assertion truly holds
  • Adjusting the assertion to reflect the actual conditions where a signing session might be absent

This would make the code's intent clearer and avoid potential confusion about the expected state during interactive signing.

Suggested change
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });
// A signing session should typically be present during interactive signing,
// but handle the case where it's not
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will have to finish looking later

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
pub fn tx_signatures(&mut self, msg: &msgs::TxSignatures) -> Result<(Option<msgs::TxSignatures>, Option<Transaction>), ChannelError> {
if !self.context.channel_state.is_interactive_signing()
|| self.context.channel_state.is_their_tx_signatures_sent()
&& !self.has_pending_splice_awaiting_signatures()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why &&?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are the only two states when we should be processing an incoming tx_signatures.

Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines +4276 to +4277
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move this out of ChannelContext then?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either that, or we track the session in the context, which lets us get rid of the interactive signing flag. I'm leaning towards the latter.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or we track the session in the context, which lets us get rid of the interactive signing flag

Pushed an update that ended up doing this.

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
self.channel_state,
ChannelState::ChannelReady(f) if f.is_set(ChannelReadyFlags::QUIESCENT)
);
debug_assert!(self.channel_state.is_interactive_signing() || is_quiescent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For v2-channel establishment, is_quiescent isn't applicable, IIUC. But for splicing, wouldn't checking is_interactive_signing be sufficient?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interactive signing flag is only relevant to dual funding due to it being a flag on ChannelState::FundingNegotiated.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 0cbeb51 to 8ca96e3CompareSeptember 12, 2025 00:12

@jkczyzjkczyz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM other some minor fixes

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8ca96e3 to e60f8d7CompareSeptember 12, 2025 22:16
jkczyz
jkczyz previously approved these changes Sep 12, 2025

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few additional things, though none of them blocking this PR per se:

  • is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and its not finishing quickly.
  • We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.
  • Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).
  • I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Oh also splice_initial_commitment_signed shouldn't be public.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 78da77b to 8a574a5CompareSeptember 15, 2025 21:54
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and it's not finishing quickly.

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.

We only track the action up until we're quiescent, and at that point we're already sending the first message (if we're the initiator) for the action we want to take. When we're not the initiator, the action isn't relevant or we're not allowed to propose something anyway, so we should just accept whatever the counterparty sends. We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).

validate_splice_init already checks for self.pending_splice.is_some().

I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

It's removed in #4054.

The tests have become a bit painful to maintain, and they don't even
fully test the production code paths, so we opt to just remove them for
now. In the future, we plan to unify the dual funding code paths with
splicing.
Since the `InteractiveTxSigningSession` already tracks everything we
need, we can remove the duplicate interactive signing state tracking
within `ChannelState`.
This commit addresses an overlap of state between
`InteractiveTxSigningSession` and `ChannelState::FundingNegotiated`. The
signing session already tracks whether both holder and counterparty
`tx_signatures` have been produced, so tracking the state duplicatively
at the `ChannelState` level is unnecessary.
Comment on lines +8547 to +8601
fn on_tx_signatures_exchange(&mut self, funding_tx: Transaction) {
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());

if let Some(pending_splice) = self.pending_splice.as_mut() {
if let Some(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
} else {
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
return Err(APIError::APIMisuseError { err });
}
self.context.channel_state.clear_quiescent();
} else {
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State management bug: The on_tx_signatures_exchange function modifies channel state and moves funding to pending_funding for splices, but doesn't handle the case where pending_splice.funding_negotiation.take() returns None. The else branch only has debug_assert!(false) which is a no-op in release builds, leaving the channel in an inconsistent state when the funding negotiation is unexpectedly missing.

Suggested change
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
returnErr(APIError::APIMisuseError{ err });
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
// This should never happen - if we're in on_tx_signatures_exchange for a splice,
// we must have a pending funding negotiation in the AwaitingSignatures state.
panic!("Missing or invalid funding negotiation state in splice during signature exchange");
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unnecessary, we always check we're in the AwaitingSignatures state prior to calling this function.

Comment on lines +13083 to +13161
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_) if self.context.interactive_tx_signing_session.is_none()
));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The debug_assert condition is too restrictive. It only allows unsetting funding info when interactive_tx_signing_session.is_none(), but the function should also be callable in AwaitingChannelReady state as indicated by the original logic. This overly restrictive condition will cause assertion failures in valid scenarios where funding info needs to be unset after the signing session has completed.

Suggested change
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none()
));
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none() |
ChannelState::AwaitingChannelReady
));

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutboundV1Channel is not expected to be in AwaitingChannelReady.

Once a channel open has become locked (i.e., we've entered
`ChannelState::ChannelReady`), the channel is intended to remain within
this state for the rest of its lifetime until shutdown. Previously, we
had assumed a channel being spliced would go through the `ChannelState`
lifecycle again starting from `NegotiatingFunding` but skipping
`AwaitingChannelReady`. This inconsistency departs from what we strive
to achieve with `ChannelState` and also makes the state of a channel
harder to reason about.
This commit ensures a channel undergoing a splice remains in
`ChannelReady`, clearing the quiescent flag once the negotiation is
complete. Dual funding is unaffected by this change as the channel is
being opened and we want to maintain the same `ChannelState` lifecycle.
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8a574a5 to 7597140CompareSeptember 15, 2025 22:15
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

Had to rebase due to a small conflict.

Comment on lines +8661 to +8663
if let Some(funding_tx) = funding_tx_opt.clone() {
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The code calls self.on_tx_signatures_exchange(funding_tx) only when funding_tx_opt is Some, and asserts that tx_signatures_opt.is_some() in this case. However, this assumes that having a funding transaction always means we have tx_signatures to send, which may not be true in all signing scenarios. This could cause assertion failures or incorrect state transitions.

Suggested change
ifletSome(funding_tx) = funding_tx_opt.clone(){
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);
ifletSome(funding_tx) = funding_tx_opt.clone(){
if tx_signatures_opt.is_some(){
self.on_tx_signatures_exchange(funding_tx);
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Within the context of funding_transaction_signed, if the funding transaction is ready, it implies that the counterparty already sent their signatures first so we must always respond with ours.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

No strong opinion, honestly. Just seemed like something to highlight that we should be at least somewhat less strict.

We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Right, that was my point :)

@TheBlueMatt
TheBlueMatt merged commit f3c22a7 into lightningdevkit:mainSep 16, 2025
25 checks passed
@wpaulino
wpaulino deleted the splice-channel-state-rework branch September 16, 2025 16:18
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@wpaulino@ldk-reviews-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Splice channel state rework by wpaulino · Pull Request #4061 · lightningdevkit/rust-lightning · GitHub
Skip to content

Splice channel state rework - #4061

Merged
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework
Sep 16, 2025
Merged

Splice channel state rework#4061
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework

Conversation

@wpaulino

Copy link
Copy Markdown
Contributor

This was pulled out of #4054 to ease review. It depends on #4060 to not bother with cfg(splicing) anymore.

@ldk-reviews-bot

ldk-reviews-bot commented Sep 5, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

Comment threadlightning/src/ln/channel.rs Outdated
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation has a potential logic issue. The FIXME comment correctly identifies that is_interactive_signing() is being used as a replacement for the removed OUR_TX_SIGNATURES_READY flag, but these represent different states:

  • is_interactive_signing() is true during the entire interactive signing process
  • OUR_TX_SIGNATURES_READY was specifically true only after our signatures were ready to send

This semantic difference could cause funding transactions to be incorrectly marked as non-broadcastable when they should be broadcastable. Consider either:

  1. Restoring the OUR_TX_SIGNATURES_READY flag for this specific use case, or
  2. Accessing the signing session directly to check if holder signatures are ready via something like signing_session.holder_tx_signatures().is_some()

This would more accurately represent the original intent of the code.

Suggested change
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),
// We need to check if our signatures are ready to send, which means the transaction
// is ready to be broadcast.
ChannelState::FundingNegotiated(flags) => {
ifletSome(signing_session) = &self.funding_tx_signing_session{
signing_session.holder_tx_signatures().is_none()
}else{
!flags.is_interactive_signing()
}
},

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @valentinewallace! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@wpaulino
wpaulino removed the request for review from valentinewallaceSeptember 8, 2025 20:42
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 9272b39 to 23a1c29CompareSeptember 8, 2025 21:49
@codecov

codecovBot commented Sep 8, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 20.20725% with 154 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.84%. Comparing base (5ae19b4) to head (7597140).
⚠️ Report is 8 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs20.10%146 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs0.00%3 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4061 +/- ##
==========================================
- Coverage 88.33% 87.84% -0.49% 
==========================================
Files 177 176 -1 Lines 131896 131744 -152 Branches 131896 131744 -152 ==========================================
- Hits 116512 115733 -779 - Misses 12728 13376 +648 + Partials 2656 2635 -21 
FlagCoverage Δ
fuzzing21.60% <6.21%> (-0.01%)⬇️
tests87.68% <20.20%> (-0.50%)⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@wpaulinowpaulino self-assigned this Sep 9, 2025
@wpaulinowpaulino added this to the 0.2 milestone Sep 9, 2025
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 23a1c29 to 0cbeb51CompareSeptember 9, 2025 19:59
Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines 8606 to 8623
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures", self.context.channel_id);
return Err(APIError::APIMisuseError { err });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code contains a pattern where a debug assertion claims a signing session must always be present during interactive signing, but then includes error handling for when it's None. This creates a logical contradiction - either:

  1. The assertion is correct and the error handling is unnecessary, or
  2. The error handling is necessary and the assertion is incorrect

This pattern appears multiple times throughout the code. Consider either:

  • Removing the error handling if the assertion truly holds
  • Adjusting the assertion to reflect the actual conditions where a signing session might be absent

This would make the code's intent clearer and avoid potential confusion about the expected state during interactive signing.

Suggested change
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });
// A signing session should typically be present during interactive signing,
// but handle the case where it's not
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will have to finish looking later

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
pub fn tx_signatures(&mut self, msg: &msgs::TxSignatures) -> Result<(Option<msgs::TxSignatures>, Option<Transaction>), ChannelError> {
if !self.context.channel_state.is_interactive_signing()
|| self.context.channel_state.is_their_tx_signatures_sent()
&& !self.has_pending_splice_awaiting_signatures()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why &&?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are the only two states when we should be processing an incoming tx_signatures.

Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines +4276 to +4277
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move this out of ChannelContext then?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either that, or we track the session in the context, which lets us get rid of the interactive signing flag. I'm leaning towards the latter.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or we track the session in the context, which lets us get rid of the interactive signing flag

Pushed an update that ended up doing this.

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
self.channel_state,
ChannelState::ChannelReady(f) if f.is_set(ChannelReadyFlags::QUIESCENT)
);
debug_assert!(self.channel_state.is_interactive_signing() || is_quiescent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For v2-channel establishment, is_quiescent isn't applicable, IIUC. But for splicing, wouldn't checking is_interactive_signing be sufficient?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interactive signing flag is only relevant to dual funding due to it being a flag on ChannelState::FundingNegotiated.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 0cbeb51 to 8ca96e3CompareSeptember 12, 2025 00:12

@jkczyzjkczyz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM other some minor fixes

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8ca96e3 to e60f8d7CompareSeptember 12, 2025 22:16
jkczyz
jkczyz previously approved these changes Sep 12, 2025

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few additional things, though none of them blocking this PR per se:

  • is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and its not finishing quickly.
  • We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.
  • Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).
  • I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Oh also splice_initial_commitment_signed shouldn't be public.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 78da77b to 8a574a5CompareSeptember 15, 2025 21:54
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and it's not finishing quickly.

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.

We only track the action up until we're quiescent, and at that point we're already sending the first message (if we're the initiator) for the action we want to take. When we're not the initiator, the action isn't relevant or we're not allowed to propose something anyway, so we should just accept whatever the counterparty sends. We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).

validate_splice_init already checks for self.pending_splice.is_some().

I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

It's removed in #4054.

The tests have become a bit painful to maintain, and they don't even
fully test the production code paths, so we opt to just remove them for
now. In the future, we plan to unify the dual funding code paths with
splicing.
Since the `InteractiveTxSigningSession` already tracks everything we
need, we can remove the duplicate interactive signing state tracking
within `ChannelState`.
This commit addresses an overlap of state between
`InteractiveTxSigningSession` and `ChannelState::FundingNegotiated`. The
signing session already tracks whether both holder and counterparty
`tx_signatures` have been produced, so tracking the state duplicatively
at the `ChannelState` level is unnecessary.
Comment on lines +8547 to +8601
fn on_tx_signatures_exchange(&mut self, funding_tx: Transaction) {
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());

if let Some(pending_splice) = self.pending_splice.as_mut() {
if let Some(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
} else {
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
return Err(APIError::APIMisuseError { err });
}
self.context.channel_state.clear_quiescent();
} else {
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State management bug: The on_tx_signatures_exchange function modifies channel state and moves funding to pending_funding for splices, but doesn't handle the case where pending_splice.funding_negotiation.take() returns None. The else branch only has debug_assert!(false) which is a no-op in release builds, leaving the channel in an inconsistent state when the funding negotiation is unexpectedly missing.

Suggested change
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
returnErr(APIError::APIMisuseError{ err });
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
// This should never happen - if we're in on_tx_signatures_exchange for a splice,
// we must have a pending funding negotiation in the AwaitingSignatures state.
panic!("Missing or invalid funding negotiation state in splice during signature exchange");
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unnecessary, we always check we're in the AwaitingSignatures state prior to calling this function.

Comment on lines +13083 to +13161
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_) if self.context.interactive_tx_signing_session.is_none()
));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The debug_assert condition is too restrictive. It only allows unsetting funding info when interactive_tx_signing_session.is_none(), but the function should also be callable in AwaitingChannelReady state as indicated by the original logic. This overly restrictive condition will cause assertion failures in valid scenarios where funding info needs to be unset after the signing session has completed.

Suggested change
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none()
));
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none() |
ChannelState::AwaitingChannelReady
));

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutboundV1Channel is not expected to be in AwaitingChannelReady.

Once a channel open has become locked (i.e., we've entered
`ChannelState::ChannelReady`), the channel is intended to remain within
this state for the rest of its lifetime until shutdown. Previously, we
had assumed a channel being spliced would go through the `ChannelState`
lifecycle again starting from `NegotiatingFunding` but skipping
`AwaitingChannelReady`. This inconsistency departs from what we strive
to achieve with `ChannelState` and also makes the state of a channel
harder to reason about.
This commit ensures a channel undergoing a splice remains in
`ChannelReady`, clearing the quiescent flag once the negotiation is
complete. Dual funding is unaffected by this change as the channel is
being opened and we want to maintain the same `ChannelState` lifecycle.
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8a574a5 to 7597140CompareSeptember 15, 2025 22:15
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

Had to rebase due to a small conflict.

Comment on lines +8661 to +8663
if let Some(funding_tx) = funding_tx_opt.clone() {
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The code calls self.on_tx_signatures_exchange(funding_tx) only when funding_tx_opt is Some, and asserts that tx_signatures_opt.is_some() in this case. However, this assumes that having a funding transaction always means we have tx_signatures to send, which may not be true in all signing scenarios. This could cause assertion failures or incorrect state transitions.

Suggested change
ifletSome(funding_tx) = funding_tx_opt.clone(){
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);
ifletSome(funding_tx) = funding_tx_opt.clone(){
if tx_signatures_opt.is_some(){
self.on_tx_signatures_exchange(funding_tx);
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Within the context of funding_transaction_signed, if the funding transaction is ready, it implies that the counterparty already sent their signatures first so we must always respond with ours.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

No strong opinion, honestly. Just seemed like something to highlight that we should be at least somewhat less strict.

We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Right, that was my point :)

@TheBlueMatt
TheBlueMatt merged commit f3c22a7 into lightningdevkit:mainSep 16, 2025
25 checks passed
@wpaulino
wpaulino deleted the splice-channel-state-rework branch September 16, 2025 16:18
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@wpaulino@ldk-reviews-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Splice channel state rework by wpaulino · Pull Request #4061 · lightningdevkit/rust-lightning · GitHub
Skip to content

Splice channel state rework - #4061

Merged
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework
Sep 16, 2025
Merged

Splice channel state rework#4061
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework

Conversation

@wpaulino

Copy link
Copy Markdown
Contributor

This was pulled out of #4054 to ease review. It depends on #4060 to not bother with cfg(splicing) anymore.

@ldk-reviews-bot

ldk-reviews-bot commented Sep 5, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

Comment threadlightning/src/ln/channel.rs Outdated
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation has a potential logic issue. The FIXME comment correctly identifies that is_interactive_signing() is being used as a replacement for the removed OUR_TX_SIGNATURES_READY flag, but these represent different states:

  • is_interactive_signing() is true during the entire interactive signing process
  • OUR_TX_SIGNATURES_READY was specifically true only after our signatures were ready to send

This semantic difference could cause funding transactions to be incorrectly marked as non-broadcastable when they should be broadcastable. Consider either:

  1. Restoring the OUR_TX_SIGNATURES_READY flag for this specific use case, or
  2. Accessing the signing session directly to check if holder signatures are ready via something like signing_session.holder_tx_signatures().is_some()

This would more accurately represent the original intent of the code.

Suggested change
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),
// We need to check if our signatures are ready to send, which means the transaction
// is ready to be broadcast.
ChannelState::FundingNegotiated(flags) => {
ifletSome(signing_session) = &self.funding_tx_signing_session{
signing_session.holder_tx_signatures().is_none()
}else{
!flags.is_interactive_signing()
}
},

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @valentinewallace! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@wpaulino
wpaulino removed the request for review from valentinewallaceSeptember 8, 2025 20:42
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 9272b39 to 23a1c29CompareSeptember 8, 2025 21:49
@codecov

codecovBot commented Sep 8, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 20.20725% with 154 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.84%. Comparing base (5ae19b4) to head (7597140).
⚠️ Report is 8 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs20.10%146 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs0.00%3 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4061 +/- ##
==========================================
- Coverage 88.33% 87.84% -0.49% 
==========================================
Files 177 176 -1 Lines 131896 131744 -152 Branches 131896 131744 -152 ==========================================
- Hits 116512 115733 -779 - Misses 12728 13376 +648 + Partials 2656 2635 -21 
FlagCoverage Δ
fuzzing21.60% <6.21%> (-0.01%)⬇️
tests87.68% <20.20%> (-0.50%)⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@wpaulinowpaulino self-assigned this Sep 9, 2025
@wpaulinowpaulino added this to the 0.2 milestone Sep 9, 2025
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 23a1c29 to 0cbeb51CompareSeptember 9, 2025 19:59
Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines 8606 to 8623
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures", self.context.channel_id);
return Err(APIError::APIMisuseError { err });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code contains a pattern where a debug assertion claims a signing session must always be present during interactive signing, but then includes error handling for when it's None. This creates a logical contradiction - either:

  1. The assertion is correct and the error handling is unnecessary, or
  2. The error handling is necessary and the assertion is incorrect

This pattern appears multiple times throughout the code. Consider either:

  • Removing the error handling if the assertion truly holds
  • Adjusting the assertion to reflect the actual conditions where a signing session might be absent

This would make the code's intent clearer and avoid potential confusion about the expected state during interactive signing.

Suggested change
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });
// A signing session should typically be present during interactive signing,
// but handle the case where it's not
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will have to finish looking later

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
pub fn tx_signatures(&mut self, msg: &msgs::TxSignatures) -> Result<(Option<msgs::TxSignatures>, Option<Transaction>), ChannelError> {
if !self.context.channel_state.is_interactive_signing()
|| self.context.channel_state.is_their_tx_signatures_sent()
&& !self.has_pending_splice_awaiting_signatures()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why &&?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are the only two states when we should be processing an incoming tx_signatures.

Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines +4276 to +4277
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move this out of ChannelContext then?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either that, or we track the session in the context, which lets us get rid of the interactive signing flag. I'm leaning towards the latter.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or we track the session in the context, which lets us get rid of the interactive signing flag

Pushed an update that ended up doing this.

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
self.channel_state,
ChannelState::ChannelReady(f) if f.is_set(ChannelReadyFlags::QUIESCENT)
);
debug_assert!(self.channel_state.is_interactive_signing() || is_quiescent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For v2-channel establishment, is_quiescent isn't applicable, IIUC. But for splicing, wouldn't checking is_interactive_signing be sufficient?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interactive signing flag is only relevant to dual funding due to it being a flag on ChannelState::FundingNegotiated.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 0cbeb51 to 8ca96e3CompareSeptember 12, 2025 00:12

@jkczyzjkczyz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM other some minor fixes

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8ca96e3 to e60f8d7CompareSeptember 12, 2025 22:16
jkczyz
jkczyz previously approved these changes Sep 12, 2025

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few additional things, though none of them blocking this PR per se:

  • is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and its not finishing quickly.
  • We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.
  • Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).
  • I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Oh also splice_initial_commitment_signed shouldn't be public.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 78da77b to 8a574a5CompareSeptember 15, 2025 21:54
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and it's not finishing quickly.

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.

We only track the action up until we're quiescent, and at that point we're already sending the first message (if we're the initiator) for the action we want to take. When we're not the initiator, the action isn't relevant or we're not allowed to propose something anyway, so we should just accept whatever the counterparty sends. We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).

validate_splice_init already checks for self.pending_splice.is_some().

I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

It's removed in #4054.

The tests have become a bit painful to maintain, and they don't even
fully test the production code paths, so we opt to just remove them for
now. In the future, we plan to unify the dual funding code paths with
splicing.
Since the `InteractiveTxSigningSession` already tracks everything we
need, we can remove the duplicate interactive signing state tracking
within `ChannelState`.
This commit addresses an overlap of state between
`InteractiveTxSigningSession` and `ChannelState::FundingNegotiated`. The
signing session already tracks whether both holder and counterparty
`tx_signatures` have been produced, so tracking the state duplicatively
at the `ChannelState` level is unnecessary.
Comment on lines +8547 to +8601
fn on_tx_signatures_exchange(&mut self, funding_tx: Transaction) {
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());

if let Some(pending_splice) = self.pending_splice.as_mut() {
if let Some(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
} else {
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
return Err(APIError::APIMisuseError { err });
}
self.context.channel_state.clear_quiescent();
} else {
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State management bug: The on_tx_signatures_exchange function modifies channel state and moves funding to pending_funding for splices, but doesn't handle the case where pending_splice.funding_negotiation.take() returns None. The else branch only has debug_assert!(false) which is a no-op in release builds, leaving the channel in an inconsistent state when the funding negotiation is unexpectedly missing.

Suggested change
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
returnErr(APIError::APIMisuseError{ err });
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
// This should never happen - if we're in on_tx_signatures_exchange for a splice,
// we must have a pending funding negotiation in the AwaitingSignatures state.
panic!("Missing or invalid funding negotiation state in splice during signature exchange");
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unnecessary, we always check we're in the AwaitingSignatures state prior to calling this function.

Comment on lines +13083 to +13161
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_) if self.context.interactive_tx_signing_session.is_none()
));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The debug_assert condition is too restrictive. It only allows unsetting funding info when interactive_tx_signing_session.is_none(), but the function should also be callable in AwaitingChannelReady state as indicated by the original logic. This overly restrictive condition will cause assertion failures in valid scenarios where funding info needs to be unset after the signing session has completed.

Suggested change
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none()
));
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none() |
ChannelState::AwaitingChannelReady
));

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutboundV1Channel is not expected to be in AwaitingChannelReady.

Once a channel open has become locked (i.e., we've entered
`ChannelState::ChannelReady`), the channel is intended to remain within
this state for the rest of its lifetime until shutdown. Previously, we
had assumed a channel being spliced would go through the `ChannelState`
lifecycle again starting from `NegotiatingFunding` but skipping
`AwaitingChannelReady`. This inconsistency departs from what we strive
to achieve with `ChannelState` and also makes the state of a channel
harder to reason about.
This commit ensures a channel undergoing a splice remains in
`ChannelReady`, clearing the quiescent flag once the negotiation is
complete. Dual funding is unaffected by this change as the channel is
being opened and we want to maintain the same `ChannelState` lifecycle.
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8a574a5 to 7597140CompareSeptember 15, 2025 22:15
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

Had to rebase due to a small conflict.

Comment on lines +8661 to +8663
if let Some(funding_tx) = funding_tx_opt.clone() {
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The code calls self.on_tx_signatures_exchange(funding_tx) only when funding_tx_opt is Some, and asserts that tx_signatures_opt.is_some() in this case. However, this assumes that having a funding transaction always means we have tx_signatures to send, which may not be true in all signing scenarios. This could cause assertion failures or incorrect state transitions.

Suggested change
ifletSome(funding_tx) = funding_tx_opt.clone(){
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);
ifletSome(funding_tx) = funding_tx_opt.clone(){
if tx_signatures_opt.is_some(){
self.on_tx_signatures_exchange(funding_tx);
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Within the context of funding_transaction_signed, if the funding transaction is ready, it implies that the counterparty already sent their signatures first so we must always respond with ours.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

No strong opinion, honestly. Just seemed like something to highlight that we should be at least somewhat less strict.

We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Right, that was my point :)

@TheBlueMatt
TheBlueMatt merged commit f3c22a7 into lightningdevkit:mainSep 16, 2025
25 checks passed
@wpaulino
wpaulino deleted the splice-channel-state-rework branch September 16, 2025 16:18
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@wpaulino@ldk-reviews-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Splice channel state rework by wpaulino · Pull Request #4061 · lightningdevkit/rust-lightning · GitHub
Skip to content

Splice channel state rework - #4061

Merged
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework
Sep 16, 2025
Merged

Splice channel state rework#4061
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework

Conversation

@wpaulino

Copy link
Copy Markdown
Contributor

This was pulled out of #4054 to ease review. It depends on #4060 to not bother with cfg(splicing) anymore.

@ldk-reviews-bot

ldk-reviews-bot commented Sep 5, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

Comment threadlightning/src/ln/channel.rs Outdated
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation has a potential logic issue. The FIXME comment correctly identifies that is_interactive_signing() is being used as a replacement for the removed OUR_TX_SIGNATURES_READY flag, but these represent different states:

  • is_interactive_signing() is true during the entire interactive signing process
  • OUR_TX_SIGNATURES_READY was specifically true only after our signatures were ready to send

This semantic difference could cause funding transactions to be incorrectly marked as non-broadcastable when they should be broadcastable. Consider either:

  1. Restoring the OUR_TX_SIGNATURES_READY flag for this specific use case, or
  2. Accessing the signing session directly to check if holder signatures are ready via something like signing_session.holder_tx_signatures().is_some()

This would more accurately represent the original intent of the code.

Suggested change
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),
// We need to check if our signatures are ready to send, which means the transaction
// is ready to be broadcast.
ChannelState::FundingNegotiated(flags) => {
ifletSome(signing_session) = &self.funding_tx_signing_session{
signing_session.holder_tx_signatures().is_none()
}else{
!flags.is_interactive_signing()
}
},

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @valentinewallace! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@wpaulino
wpaulino removed the request for review from valentinewallaceSeptember 8, 2025 20:42
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 9272b39 to 23a1c29CompareSeptember 8, 2025 21:49
@codecov

codecovBot commented Sep 8, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 20.20725% with 154 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.84%. Comparing base (5ae19b4) to head (7597140).
⚠️ Report is 8 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs20.10%146 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs0.00%3 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4061 +/- ##
==========================================
- Coverage 88.33% 87.84% -0.49% 
==========================================
Files 177 176 -1 Lines 131896 131744 -152 Branches 131896 131744 -152 ==========================================
- Hits 116512 115733 -779 - Misses 12728 13376 +648 + Partials 2656 2635 -21 
FlagCoverage Δ
fuzzing21.60% <6.21%> (-0.01%)⬇️
tests87.68% <20.20%> (-0.50%)⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@wpaulinowpaulino self-assigned this Sep 9, 2025
@wpaulinowpaulino added this to the 0.2 milestone Sep 9, 2025
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 23a1c29 to 0cbeb51CompareSeptember 9, 2025 19:59
Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines 8606 to 8623
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures", self.context.channel_id);
return Err(APIError::APIMisuseError { err });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code contains a pattern where a debug assertion claims a signing session must always be present during interactive signing, but then includes error handling for when it's None. This creates a logical contradiction - either:

  1. The assertion is correct and the error handling is unnecessary, or
  2. The error handling is necessary and the assertion is incorrect

This pattern appears multiple times throughout the code. Consider either:

  • Removing the error handling if the assertion truly holds
  • Adjusting the assertion to reflect the actual conditions where a signing session might be absent

This would make the code's intent clearer and avoid potential confusion about the expected state during interactive signing.

Suggested change
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });
// A signing session should typically be present during interactive signing,
// but handle the case where it's not
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will have to finish looking later

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
pub fn tx_signatures(&mut self, msg: &msgs::TxSignatures) -> Result<(Option<msgs::TxSignatures>, Option<Transaction>), ChannelError> {
if !self.context.channel_state.is_interactive_signing()
|| self.context.channel_state.is_their_tx_signatures_sent()
&& !self.has_pending_splice_awaiting_signatures()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why &&?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are the only two states when we should be processing an incoming tx_signatures.

Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines +4276 to +4277
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move this out of ChannelContext then?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either that, or we track the session in the context, which lets us get rid of the interactive signing flag. I'm leaning towards the latter.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or we track the session in the context, which lets us get rid of the interactive signing flag

Pushed an update that ended up doing this.

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
self.channel_state,
ChannelState::ChannelReady(f) if f.is_set(ChannelReadyFlags::QUIESCENT)
);
debug_assert!(self.channel_state.is_interactive_signing() || is_quiescent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For v2-channel establishment, is_quiescent isn't applicable, IIUC. But for splicing, wouldn't checking is_interactive_signing be sufficient?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interactive signing flag is only relevant to dual funding due to it being a flag on ChannelState::FundingNegotiated.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 0cbeb51 to 8ca96e3CompareSeptember 12, 2025 00:12

@jkczyzjkczyz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM other some minor fixes

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8ca96e3 to e60f8d7CompareSeptember 12, 2025 22:16
jkczyz
jkczyz previously approved these changes Sep 12, 2025

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few additional things, though none of them blocking this PR per se:

  • is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and its not finishing quickly.
  • We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.
  • Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).
  • I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Oh also splice_initial_commitment_signed shouldn't be public.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 78da77b to 8a574a5CompareSeptember 15, 2025 21:54
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and it's not finishing quickly.

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.

We only track the action up until we're quiescent, and at that point we're already sending the first message (if we're the initiator) for the action we want to take. When we're not the initiator, the action isn't relevant or we're not allowed to propose something anyway, so we should just accept whatever the counterparty sends. We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).

validate_splice_init already checks for self.pending_splice.is_some().

I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

It's removed in #4054.

The tests have become a bit painful to maintain, and they don't even
fully test the production code paths, so we opt to just remove them for
now. In the future, we plan to unify the dual funding code paths with
splicing.
Since the `InteractiveTxSigningSession` already tracks everything we
need, we can remove the duplicate interactive signing state tracking
within `ChannelState`.
This commit addresses an overlap of state between
`InteractiveTxSigningSession` and `ChannelState::FundingNegotiated`. The
signing session already tracks whether both holder and counterparty
`tx_signatures` have been produced, so tracking the state duplicatively
at the `ChannelState` level is unnecessary.
Comment on lines +8547 to +8601
fn on_tx_signatures_exchange(&mut self, funding_tx: Transaction) {
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());

if let Some(pending_splice) = self.pending_splice.as_mut() {
if let Some(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
} else {
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
return Err(APIError::APIMisuseError { err });
}
self.context.channel_state.clear_quiescent();
} else {
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State management bug: The on_tx_signatures_exchange function modifies channel state and moves funding to pending_funding for splices, but doesn't handle the case where pending_splice.funding_negotiation.take() returns None. The else branch only has debug_assert!(false) which is a no-op in release builds, leaving the channel in an inconsistent state when the funding negotiation is unexpectedly missing.

Suggested change
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
returnErr(APIError::APIMisuseError{ err });
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
// This should never happen - if we're in on_tx_signatures_exchange for a splice,
// we must have a pending funding negotiation in the AwaitingSignatures state.
panic!("Missing or invalid funding negotiation state in splice during signature exchange");
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unnecessary, we always check we're in the AwaitingSignatures state prior to calling this function.

Comment on lines +13083 to +13161
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_) if self.context.interactive_tx_signing_session.is_none()
));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The debug_assert condition is too restrictive. It only allows unsetting funding info when interactive_tx_signing_session.is_none(), but the function should also be callable in AwaitingChannelReady state as indicated by the original logic. This overly restrictive condition will cause assertion failures in valid scenarios where funding info needs to be unset after the signing session has completed.

Suggested change
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none()
));
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none() |
ChannelState::AwaitingChannelReady
));

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutboundV1Channel is not expected to be in AwaitingChannelReady.

Once a channel open has become locked (i.e., we've entered
`ChannelState::ChannelReady`), the channel is intended to remain within
this state for the rest of its lifetime until shutdown. Previously, we
had assumed a channel being spliced would go through the `ChannelState`
lifecycle again starting from `NegotiatingFunding` but skipping
`AwaitingChannelReady`. This inconsistency departs from what we strive
to achieve with `ChannelState` and also makes the state of a channel
harder to reason about.
This commit ensures a channel undergoing a splice remains in
`ChannelReady`, clearing the quiescent flag once the negotiation is
complete. Dual funding is unaffected by this change as the channel is
being opened and we want to maintain the same `ChannelState` lifecycle.
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8a574a5 to 7597140CompareSeptember 15, 2025 22:15
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

Had to rebase due to a small conflict.

Comment on lines +8661 to +8663
if let Some(funding_tx) = funding_tx_opt.clone() {
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The code calls self.on_tx_signatures_exchange(funding_tx) only when funding_tx_opt is Some, and asserts that tx_signatures_opt.is_some() in this case. However, this assumes that having a funding transaction always means we have tx_signatures to send, which may not be true in all signing scenarios. This could cause assertion failures or incorrect state transitions.

Suggested change
ifletSome(funding_tx) = funding_tx_opt.clone(){
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);
ifletSome(funding_tx) = funding_tx_opt.clone(){
if tx_signatures_opt.is_some(){
self.on_tx_signatures_exchange(funding_tx);
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Within the context of funding_transaction_signed, if the funding transaction is ready, it implies that the counterparty already sent their signatures first so we must always respond with ours.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

No strong opinion, honestly. Just seemed like something to highlight that we should be at least somewhat less strict.

We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Right, that was my point :)

@TheBlueMatt
TheBlueMatt merged commit f3c22a7 into lightningdevkit:mainSep 16, 2025
25 checks passed
@wpaulino
wpaulino deleted the splice-channel-state-rework branch September 16, 2025 16:18
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@wpaulino@ldk-reviews-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Splice channel state rework by wpaulino · Pull Request #4061 · lightningdevkit/rust-lightning · GitHub
Skip to content

Splice channel state rework - #4061

Merged
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework
Sep 16, 2025
Merged

Splice channel state rework#4061
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework

Conversation

@wpaulino

Copy link
Copy Markdown
Contributor

This was pulled out of #4054 to ease review. It depends on #4060 to not bother with cfg(splicing) anymore.

@ldk-reviews-bot

ldk-reviews-bot commented Sep 5, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

Comment threadlightning/src/ln/channel.rs Outdated
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation has a potential logic issue. The FIXME comment correctly identifies that is_interactive_signing() is being used as a replacement for the removed OUR_TX_SIGNATURES_READY flag, but these represent different states:

  • is_interactive_signing() is true during the entire interactive signing process
  • OUR_TX_SIGNATURES_READY was specifically true only after our signatures were ready to send

This semantic difference could cause funding transactions to be incorrectly marked as non-broadcastable when they should be broadcastable. Consider either:

  1. Restoring the OUR_TX_SIGNATURES_READY flag for this specific use case, or
  2. Accessing the signing session directly to check if holder signatures are ready via something like signing_session.holder_tx_signatures().is_some()

This would more accurately represent the original intent of the code.

Suggested change
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),
// We need to check if our signatures are ready to send, which means the transaction
// is ready to be broadcast.
ChannelState::FundingNegotiated(flags) => {
ifletSome(signing_session) = &self.funding_tx_signing_session{
signing_session.holder_tx_signatures().is_none()
}else{
!flags.is_interactive_signing()
}
},

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @valentinewallace! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@wpaulino
wpaulino removed the request for review from valentinewallaceSeptember 8, 2025 20:42
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 9272b39 to 23a1c29CompareSeptember 8, 2025 21:49
@codecov

codecovBot commented Sep 8, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 20.20725% with 154 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.84%. Comparing base (5ae19b4) to head (7597140).
⚠️ Report is 8 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs20.10%146 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs0.00%3 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4061 +/- ##
==========================================
- Coverage 88.33% 87.84% -0.49% 
==========================================
Files 177 176 -1 Lines 131896 131744 -152 Branches 131896 131744 -152 ==========================================
- Hits 116512 115733 -779 - Misses 12728 13376 +648 + Partials 2656 2635 -21 
FlagCoverage Δ
fuzzing21.60% <6.21%> (-0.01%)⬇️
tests87.68% <20.20%> (-0.50%)⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@wpaulinowpaulino self-assigned this Sep 9, 2025
@wpaulinowpaulino added this to the 0.2 milestone Sep 9, 2025
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 23a1c29 to 0cbeb51CompareSeptember 9, 2025 19:59
Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines 8606 to 8623
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures", self.context.channel_id);
return Err(APIError::APIMisuseError { err });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code contains a pattern where a debug assertion claims a signing session must always be present during interactive signing, but then includes error handling for when it's None. This creates a logical contradiction - either:

  1. The assertion is correct and the error handling is unnecessary, or
  2. The error handling is necessary and the assertion is incorrect

This pattern appears multiple times throughout the code. Consider either:

  • Removing the error handling if the assertion truly holds
  • Adjusting the assertion to reflect the actual conditions where a signing session might be absent

This would make the code's intent clearer and avoid potential confusion about the expected state during interactive signing.

Suggested change
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });
// A signing session should typically be present during interactive signing,
// but handle the case where it's not
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will have to finish looking later

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
pub fn tx_signatures(&mut self, msg: &msgs::TxSignatures) -> Result<(Option<msgs::TxSignatures>, Option<Transaction>), ChannelError> {
if !self.context.channel_state.is_interactive_signing()
|| self.context.channel_state.is_their_tx_signatures_sent()
&& !self.has_pending_splice_awaiting_signatures()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why &&?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are the only two states when we should be processing an incoming tx_signatures.

Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines +4276 to +4277
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move this out of ChannelContext then?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either that, or we track the session in the context, which lets us get rid of the interactive signing flag. I'm leaning towards the latter.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or we track the session in the context, which lets us get rid of the interactive signing flag

Pushed an update that ended up doing this.

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
self.channel_state,
ChannelState::ChannelReady(f) if f.is_set(ChannelReadyFlags::QUIESCENT)
);
debug_assert!(self.channel_state.is_interactive_signing() || is_quiescent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For v2-channel establishment, is_quiescent isn't applicable, IIUC. But for splicing, wouldn't checking is_interactive_signing be sufficient?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interactive signing flag is only relevant to dual funding due to it being a flag on ChannelState::FundingNegotiated.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 0cbeb51 to 8ca96e3CompareSeptember 12, 2025 00:12

@jkczyzjkczyz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM other some minor fixes

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8ca96e3 to e60f8d7CompareSeptember 12, 2025 22:16
jkczyz
jkczyz previously approved these changes Sep 12, 2025

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few additional things, though none of them blocking this PR per se:

  • is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and its not finishing quickly.
  • We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.
  • Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).
  • I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Oh also splice_initial_commitment_signed shouldn't be public.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 78da77b to 8a574a5CompareSeptember 15, 2025 21:54
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and it's not finishing quickly.

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.

We only track the action up until we're quiescent, and at that point we're already sending the first message (if we're the initiator) for the action we want to take. When we're not the initiator, the action isn't relevant or we're not allowed to propose something anyway, so we should just accept whatever the counterparty sends. We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).

validate_splice_init already checks for self.pending_splice.is_some().

I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

It's removed in #4054.

The tests have become a bit painful to maintain, and they don't even
fully test the production code paths, so we opt to just remove them for
now. In the future, we plan to unify the dual funding code paths with
splicing.
Since the `InteractiveTxSigningSession` already tracks everything we
need, we can remove the duplicate interactive signing state tracking
within `ChannelState`.
This commit addresses an overlap of state between
`InteractiveTxSigningSession` and `ChannelState::FundingNegotiated`. The
signing session already tracks whether both holder and counterparty
`tx_signatures` have been produced, so tracking the state duplicatively
at the `ChannelState` level is unnecessary.
Comment on lines +8547 to +8601
fn on_tx_signatures_exchange(&mut self, funding_tx: Transaction) {
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());

if let Some(pending_splice) = self.pending_splice.as_mut() {
if let Some(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
} else {
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
return Err(APIError::APIMisuseError { err });
}
self.context.channel_state.clear_quiescent();
} else {
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State management bug: The on_tx_signatures_exchange function modifies channel state and moves funding to pending_funding for splices, but doesn't handle the case where pending_splice.funding_negotiation.take() returns None. The else branch only has debug_assert!(false) which is a no-op in release builds, leaving the channel in an inconsistent state when the funding negotiation is unexpectedly missing.

Suggested change
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
returnErr(APIError::APIMisuseError{ err });
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
// This should never happen - if we're in on_tx_signatures_exchange for a splice,
// we must have a pending funding negotiation in the AwaitingSignatures state.
panic!("Missing or invalid funding negotiation state in splice during signature exchange");
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unnecessary, we always check we're in the AwaitingSignatures state prior to calling this function.

Comment on lines +13083 to +13161
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_) if self.context.interactive_tx_signing_session.is_none()
));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The debug_assert condition is too restrictive. It only allows unsetting funding info when interactive_tx_signing_session.is_none(), but the function should also be callable in AwaitingChannelReady state as indicated by the original logic. This overly restrictive condition will cause assertion failures in valid scenarios where funding info needs to be unset after the signing session has completed.

Suggested change
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none()
));
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none() |
ChannelState::AwaitingChannelReady
));

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutboundV1Channel is not expected to be in AwaitingChannelReady.

Once a channel open has become locked (i.e., we've entered
`ChannelState::ChannelReady`), the channel is intended to remain within
this state for the rest of its lifetime until shutdown. Previously, we
had assumed a channel being spliced would go through the `ChannelState`
lifecycle again starting from `NegotiatingFunding` but skipping
`AwaitingChannelReady`. This inconsistency departs from what we strive
to achieve with `ChannelState` and also makes the state of a channel
harder to reason about.
This commit ensures a channel undergoing a splice remains in
`ChannelReady`, clearing the quiescent flag once the negotiation is
complete. Dual funding is unaffected by this change as the channel is
being opened and we want to maintain the same `ChannelState` lifecycle.
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8a574a5 to 7597140CompareSeptember 15, 2025 22:15
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

Had to rebase due to a small conflict.

Comment on lines +8661 to +8663
if let Some(funding_tx) = funding_tx_opt.clone() {
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The code calls self.on_tx_signatures_exchange(funding_tx) only when funding_tx_opt is Some, and asserts that tx_signatures_opt.is_some() in this case. However, this assumes that having a funding transaction always means we have tx_signatures to send, which may not be true in all signing scenarios. This could cause assertion failures or incorrect state transitions.

Suggested change
ifletSome(funding_tx) = funding_tx_opt.clone(){
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);
ifletSome(funding_tx) = funding_tx_opt.clone(){
if tx_signatures_opt.is_some(){
self.on_tx_signatures_exchange(funding_tx);
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Within the context of funding_transaction_signed, if the funding transaction is ready, it implies that the counterparty already sent their signatures first so we must always respond with ours.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

No strong opinion, honestly. Just seemed like something to highlight that we should be at least somewhat less strict.

We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Right, that was my point :)

@TheBlueMatt
TheBlueMatt merged commit f3c22a7 into lightningdevkit:mainSep 16, 2025
25 checks passed
@wpaulino
wpaulino deleted the splice-channel-state-rework branch September 16, 2025 16:18
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@wpaulino@ldk-reviews-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Splice channel state rework by wpaulino · Pull Request #4061 · lightningdevkit/rust-lightning · GitHub
Skip to content

Splice channel state rework - #4061

Merged
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework
Sep 16, 2025
Merged

Splice channel state rework#4061
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework

Conversation

@wpaulino

Copy link
Copy Markdown
Contributor

This was pulled out of #4054 to ease review. It depends on #4060 to not bother with cfg(splicing) anymore.

@ldk-reviews-bot

ldk-reviews-bot commented Sep 5, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

Comment threadlightning/src/ln/channel.rs Outdated
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation has a potential logic issue. The FIXME comment correctly identifies that is_interactive_signing() is being used as a replacement for the removed OUR_TX_SIGNATURES_READY flag, but these represent different states:

  • is_interactive_signing() is true during the entire interactive signing process
  • OUR_TX_SIGNATURES_READY was specifically true only after our signatures were ready to send

This semantic difference could cause funding transactions to be incorrectly marked as non-broadcastable when they should be broadcastable. Consider either:

  1. Restoring the OUR_TX_SIGNATURES_READY flag for this specific use case, or
  2. Accessing the signing session directly to check if holder signatures are ready via something like signing_session.holder_tx_signatures().is_some()

This would more accurately represent the original intent of the code.

Suggested change
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),
// We need to check if our signatures are ready to send, which means the transaction
// is ready to be broadcast.
ChannelState::FundingNegotiated(flags) => {
ifletSome(signing_session) = &self.funding_tx_signing_session{
signing_session.holder_tx_signatures().is_none()
}else{
!flags.is_interactive_signing()
}
},

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @valentinewallace! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@wpaulino
wpaulino removed the request for review from valentinewallaceSeptember 8, 2025 20:42
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 9272b39 to 23a1c29CompareSeptember 8, 2025 21:49
@codecov

codecovBot commented Sep 8, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 20.20725% with 154 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.84%. Comparing base (5ae19b4) to head (7597140).
⚠️ Report is 8 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs20.10%146 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs0.00%3 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4061 +/- ##
==========================================
- Coverage 88.33% 87.84% -0.49% 
==========================================
Files 177 176 -1 Lines 131896 131744 -152 Branches 131896 131744 -152 ==========================================
- Hits 116512 115733 -779 - Misses 12728 13376 +648 + Partials 2656 2635 -21 
FlagCoverage Δ
fuzzing21.60% <6.21%> (-0.01%)⬇️
tests87.68% <20.20%> (-0.50%)⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@wpaulinowpaulino self-assigned this Sep 9, 2025
@wpaulinowpaulino added this to the 0.2 milestone Sep 9, 2025
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 23a1c29 to 0cbeb51CompareSeptember 9, 2025 19:59
Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines 8606 to 8623
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures", self.context.channel_id);
return Err(APIError::APIMisuseError { err });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code contains a pattern where a debug assertion claims a signing session must always be present during interactive signing, but then includes error handling for when it's None. This creates a logical contradiction - either:

  1. The assertion is correct and the error handling is unnecessary, or
  2. The error handling is necessary and the assertion is incorrect

This pattern appears multiple times throughout the code. Consider either:

  • Removing the error handling if the assertion truly holds
  • Adjusting the assertion to reflect the actual conditions where a signing session might be absent

This would make the code's intent clearer and avoid potential confusion about the expected state during interactive signing.

Suggested change
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });
// A signing session should typically be present during interactive signing,
// but handle the case where it's not
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will have to finish looking later

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
pub fn tx_signatures(&mut self, msg: &msgs::TxSignatures) -> Result<(Option<msgs::TxSignatures>, Option<Transaction>), ChannelError> {
if !self.context.channel_state.is_interactive_signing()
|| self.context.channel_state.is_their_tx_signatures_sent()
&& !self.has_pending_splice_awaiting_signatures()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why &&?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are the only two states when we should be processing an incoming tx_signatures.

Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines +4276 to +4277
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move this out of ChannelContext then?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either that, or we track the session in the context, which lets us get rid of the interactive signing flag. I'm leaning towards the latter.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or we track the session in the context, which lets us get rid of the interactive signing flag

Pushed an update that ended up doing this.

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
self.channel_state,
ChannelState::ChannelReady(f) if f.is_set(ChannelReadyFlags::QUIESCENT)
);
debug_assert!(self.channel_state.is_interactive_signing() || is_quiescent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For v2-channel establishment, is_quiescent isn't applicable, IIUC. But for splicing, wouldn't checking is_interactive_signing be sufficient?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interactive signing flag is only relevant to dual funding due to it being a flag on ChannelState::FundingNegotiated.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 0cbeb51 to 8ca96e3CompareSeptember 12, 2025 00:12

@jkczyzjkczyz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM other some minor fixes

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8ca96e3 to e60f8d7CompareSeptember 12, 2025 22:16
jkczyz
jkczyz previously approved these changes Sep 12, 2025

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few additional things, though none of them blocking this PR per se:

  • is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and its not finishing quickly.
  • We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.
  • Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).
  • I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Oh also splice_initial_commitment_signed shouldn't be public.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 78da77b to 8a574a5CompareSeptember 15, 2025 21:54
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and it's not finishing quickly.

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.

We only track the action up until we're quiescent, and at that point we're already sending the first message (if we're the initiator) for the action we want to take. When we're not the initiator, the action isn't relevant or we're not allowed to propose something anyway, so we should just accept whatever the counterparty sends. We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).

validate_splice_init already checks for self.pending_splice.is_some().

I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

It's removed in #4054.

The tests have become a bit painful to maintain, and they don't even
fully test the production code paths, so we opt to just remove them for
now. In the future, we plan to unify the dual funding code paths with
splicing.
Since the `InteractiveTxSigningSession` already tracks everything we
need, we can remove the duplicate interactive signing state tracking
within `ChannelState`.
This commit addresses an overlap of state between
`InteractiveTxSigningSession` and `ChannelState::FundingNegotiated`. The
signing session already tracks whether both holder and counterparty
`tx_signatures` have been produced, so tracking the state duplicatively
at the `ChannelState` level is unnecessary.
Comment on lines +8547 to +8601
fn on_tx_signatures_exchange(&mut self, funding_tx: Transaction) {
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());

if let Some(pending_splice) = self.pending_splice.as_mut() {
if let Some(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
} else {
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
return Err(APIError::APIMisuseError { err });
}
self.context.channel_state.clear_quiescent();
} else {
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State management bug: The on_tx_signatures_exchange function modifies channel state and moves funding to pending_funding for splices, but doesn't handle the case where pending_splice.funding_negotiation.take() returns None. The else branch only has debug_assert!(false) which is a no-op in release builds, leaving the channel in an inconsistent state when the funding negotiation is unexpectedly missing.

Suggested change
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
returnErr(APIError::APIMisuseError{ err });
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
// This should never happen - if we're in on_tx_signatures_exchange for a splice,
// we must have a pending funding negotiation in the AwaitingSignatures state.
panic!("Missing or invalid funding negotiation state in splice during signature exchange");
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unnecessary, we always check we're in the AwaitingSignatures state prior to calling this function.

Comment on lines +13083 to +13161
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_) if self.context.interactive_tx_signing_session.is_none()
));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The debug_assert condition is too restrictive. It only allows unsetting funding info when interactive_tx_signing_session.is_none(), but the function should also be callable in AwaitingChannelReady state as indicated by the original logic. This overly restrictive condition will cause assertion failures in valid scenarios where funding info needs to be unset after the signing session has completed.

Suggested change
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none()
));
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none() |
ChannelState::AwaitingChannelReady
));

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutboundV1Channel is not expected to be in AwaitingChannelReady.

Once a channel open has become locked (i.e., we've entered
`ChannelState::ChannelReady`), the channel is intended to remain within
this state for the rest of its lifetime until shutdown. Previously, we
had assumed a channel being spliced would go through the `ChannelState`
lifecycle again starting from `NegotiatingFunding` but skipping
`AwaitingChannelReady`. This inconsistency departs from what we strive
to achieve with `ChannelState` and also makes the state of a channel
harder to reason about.
This commit ensures a channel undergoing a splice remains in
`ChannelReady`, clearing the quiescent flag once the negotiation is
complete. Dual funding is unaffected by this change as the channel is
being opened and we want to maintain the same `ChannelState` lifecycle.
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8a574a5 to 7597140CompareSeptember 15, 2025 22:15
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

Had to rebase due to a small conflict.

Comment on lines +8661 to +8663
if let Some(funding_tx) = funding_tx_opt.clone() {
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The code calls self.on_tx_signatures_exchange(funding_tx) only when funding_tx_opt is Some, and asserts that tx_signatures_opt.is_some() in this case. However, this assumes that having a funding transaction always means we have tx_signatures to send, which may not be true in all signing scenarios. This could cause assertion failures or incorrect state transitions.

Suggested change
ifletSome(funding_tx) = funding_tx_opt.clone(){
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);
ifletSome(funding_tx) = funding_tx_opt.clone(){
if tx_signatures_opt.is_some(){
self.on_tx_signatures_exchange(funding_tx);
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Within the context of funding_transaction_signed, if the funding transaction is ready, it implies that the counterparty already sent their signatures first so we must always respond with ours.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

No strong opinion, honestly. Just seemed like something to highlight that we should be at least somewhat less strict.

We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Right, that was my point :)

@TheBlueMatt
TheBlueMatt merged commit f3c22a7 into lightningdevkit:mainSep 16, 2025
25 checks passed
@wpaulino
wpaulino deleted the splice-channel-state-rework branch September 16, 2025 16:18
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@wpaulino@ldk-reviews-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Splice channel state rework by wpaulino · Pull Request #4061 · lightningdevkit/rust-lightning · GitHub
Skip to content

Splice channel state rework - #4061

Merged
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework
Sep 16, 2025
Merged

Splice channel state rework#4061
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework

Conversation

@wpaulino

Copy link
Copy Markdown
Contributor

This was pulled out of #4054 to ease review. It depends on #4060 to not bother with cfg(splicing) anymore.

@ldk-reviews-bot

ldk-reviews-bot commented Sep 5, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

Comment threadlightning/src/ln/channel.rs Outdated
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation has a potential logic issue. The FIXME comment correctly identifies that is_interactive_signing() is being used as a replacement for the removed OUR_TX_SIGNATURES_READY flag, but these represent different states:

  • is_interactive_signing() is true during the entire interactive signing process
  • OUR_TX_SIGNATURES_READY was specifically true only after our signatures were ready to send

This semantic difference could cause funding transactions to be incorrectly marked as non-broadcastable when they should be broadcastable. Consider either:

  1. Restoring the OUR_TX_SIGNATURES_READY flag for this specific use case, or
  2. Accessing the signing session directly to check if holder signatures are ready via something like signing_session.holder_tx_signatures().is_some()

This would more accurately represent the original intent of the code.

Suggested change
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),
// We need to check if our signatures are ready to send, which means the transaction
// is ready to be broadcast.
ChannelState::FundingNegotiated(flags) => {
ifletSome(signing_session) = &self.funding_tx_signing_session{
signing_session.holder_tx_signatures().is_none()
}else{
!flags.is_interactive_signing()
}
},

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @valentinewallace! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@wpaulino
wpaulino removed the request for review from valentinewallaceSeptember 8, 2025 20:42
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 9272b39 to 23a1c29CompareSeptember 8, 2025 21:49
@codecov

codecovBot commented Sep 8, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 20.20725% with 154 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.84%. Comparing base (5ae19b4) to head (7597140).
⚠️ Report is 8 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs20.10%146 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs0.00%3 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4061 +/- ##
==========================================
- Coverage 88.33% 87.84% -0.49% 
==========================================
Files 177 176 -1 Lines 131896 131744 -152 Branches 131896 131744 -152 ==========================================
- Hits 116512 115733 -779 - Misses 12728 13376 +648 + Partials 2656 2635 -21 
FlagCoverage Δ
fuzzing21.60% <6.21%> (-0.01%)⬇️
tests87.68% <20.20%> (-0.50%)⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@wpaulinowpaulino self-assigned this Sep 9, 2025
@wpaulinowpaulino added this to the 0.2 milestone Sep 9, 2025
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 23a1c29 to 0cbeb51CompareSeptember 9, 2025 19:59
Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines 8606 to 8623
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures", self.context.channel_id);
return Err(APIError::APIMisuseError { err });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code contains a pattern where a debug assertion claims a signing session must always be present during interactive signing, but then includes error handling for when it's None. This creates a logical contradiction - either:

  1. The assertion is correct and the error handling is unnecessary, or
  2. The error handling is necessary and the assertion is incorrect

This pattern appears multiple times throughout the code. Consider either:

  • Removing the error handling if the assertion truly holds
  • Adjusting the assertion to reflect the actual conditions where a signing session might be absent

This would make the code's intent clearer and avoid potential confusion about the expected state during interactive signing.

Suggested change
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });
// A signing session should typically be present during interactive signing,
// but handle the case where it's not
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will have to finish looking later

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
pub fn tx_signatures(&mut self, msg: &msgs::TxSignatures) -> Result<(Option<msgs::TxSignatures>, Option<Transaction>), ChannelError> {
if !self.context.channel_state.is_interactive_signing()
|| self.context.channel_state.is_their_tx_signatures_sent()
&& !self.has_pending_splice_awaiting_signatures()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why &&?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are the only two states when we should be processing an incoming tx_signatures.

Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines +4276 to +4277
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move this out of ChannelContext then?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either that, or we track the session in the context, which lets us get rid of the interactive signing flag. I'm leaning towards the latter.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or we track the session in the context, which lets us get rid of the interactive signing flag

Pushed an update that ended up doing this.

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
self.channel_state,
ChannelState::ChannelReady(f) if f.is_set(ChannelReadyFlags::QUIESCENT)
);
debug_assert!(self.channel_state.is_interactive_signing() || is_quiescent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For v2-channel establishment, is_quiescent isn't applicable, IIUC. But for splicing, wouldn't checking is_interactive_signing be sufficient?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interactive signing flag is only relevant to dual funding due to it being a flag on ChannelState::FundingNegotiated.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 0cbeb51 to 8ca96e3CompareSeptember 12, 2025 00:12

@jkczyzjkczyz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM other some minor fixes

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8ca96e3 to e60f8d7CompareSeptember 12, 2025 22:16
jkczyz
jkczyz previously approved these changes Sep 12, 2025

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few additional things, though none of them blocking this PR per se:

  • is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and its not finishing quickly.
  • We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.
  • Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).
  • I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Oh also splice_initial_commitment_signed shouldn't be public.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 78da77b to 8a574a5CompareSeptember 15, 2025 21:54
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and it's not finishing quickly.

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.

We only track the action up until we're quiescent, and at that point we're already sending the first message (if we're the initiator) for the action we want to take. When we're not the initiator, the action isn't relevant or we're not allowed to propose something anyway, so we should just accept whatever the counterparty sends. We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).

validate_splice_init already checks for self.pending_splice.is_some().

I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

It's removed in #4054.

The tests have become a bit painful to maintain, and they don't even
fully test the production code paths, so we opt to just remove them for
now. In the future, we plan to unify the dual funding code paths with
splicing.
Since the `InteractiveTxSigningSession` already tracks everything we
need, we can remove the duplicate interactive signing state tracking
within `ChannelState`.
This commit addresses an overlap of state between
`InteractiveTxSigningSession` and `ChannelState::FundingNegotiated`. The
signing session already tracks whether both holder and counterparty
`tx_signatures` have been produced, so tracking the state duplicatively
at the `ChannelState` level is unnecessary.
Comment on lines +8547 to +8601
fn on_tx_signatures_exchange(&mut self, funding_tx: Transaction) {
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());

if let Some(pending_splice) = self.pending_splice.as_mut() {
if let Some(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
} else {
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
return Err(APIError::APIMisuseError { err });
}
self.context.channel_state.clear_quiescent();
} else {
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State management bug: The on_tx_signatures_exchange function modifies channel state and moves funding to pending_funding for splices, but doesn't handle the case where pending_splice.funding_negotiation.take() returns None. The else branch only has debug_assert!(false) which is a no-op in release builds, leaving the channel in an inconsistent state when the funding negotiation is unexpectedly missing.

Suggested change
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
returnErr(APIError::APIMisuseError{ err });
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
// This should never happen - if we're in on_tx_signatures_exchange for a splice,
// we must have a pending funding negotiation in the AwaitingSignatures state.
panic!("Missing or invalid funding negotiation state in splice during signature exchange");
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unnecessary, we always check we're in the AwaitingSignatures state prior to calling this function.

Comment on lines +13083 to +13161
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_) if self.context.interactive_tx_signing_session.is_none()
));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The debug_assert condition is too restrictive. It only allows unsetting funding info when interactive_tx_signing_session.is_none(), but the function should also be callable in AwaitingChannelReady state as indicated by the original logic. This overly restrictive condition will cause assertion failures in valid scenarios where funding info needs to be unset after the signing session has completed.

Suggested change
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none()
));
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none() |
ChannelState::AwaitingChannelReady
));

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutboundV1Channel is not expected to be in AwaitingChannelReady.

Once a channel open has become locked (i.e., we've entered
`ChannelState::ChannelReady`), the channel is intended to remain within
this state for the rest of its lifetime until shutdown. Previously, we
had assumed a channel being spliced would go through the `ChannelState`
lifecycle again starting from `NegotiatingFunding` but skipping
`AwaitingChannelReady`. This inconsistency departs from what we strive
to achieve with `ChannelState` and also makes the state of a channel
harder to reason about.
This commit ensures a channel undergoing a splice remains in
`ChannelReady`, clearing the quiescent flag once the negotiation is
complete. Dual funding is unaffected by this change as the channel is
being opened and we want to maintain the same `ChannelState` lifecycle.
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8a574a5 to 7597140CompareSeptember 15, 2025 22:15
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

Had to rebase due to a small conflict.

Comment on lines +8661 to +8663
if let Some(funding_tx) = funding_tx_opt.clone() {
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The code calls self.on_tx_signatures_exchange(funding_tx) only when funding_tx_opt is Some, and asserts that tx_signatures_opt.is_some() in this case. However, this assumes that having a funding transaction always means we have tx_signatures to send, which may not be true in all signing scenarios. This could cause assertion failures or incorrect state transitions.

Suggested change
ifletSome(funding_tx) = funding_tx_opt.clone(){
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);
ifletSome(funding_tx) = funding_tx_opt.clone(){
if tx_signatures_opt.is_some(){
self.on_tx_signatures_exchange(funding_tx);
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Within the context of funding_transaction_signed, if the funding transaction is ready, it implies that the counterparty already sent their signatures first so we must always respond with ours.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

No strong opinion, honestly. Just seemed like something to highlight that we should be at least somewhat less strict.

We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Right, that was my point :)

@TheBlueMatt
TheBlueMatt merged commit f3c22a7 into lightningdevkit:mainSep 16, 2025
25 checks passed
@wpaulino
wpaulino deleted the splice-channel-state-rework branch September 16, 2025 16:18
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@wpaulino@ldk-reviews-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Splice channel state rework by wpaulino · Pull Request #4061 · lightningdevkit/rust-lightning · GitHub
Skip to content

Splice channel state rework - #4061

Merged
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework
Sep 16, 2025
Merged

Splice channel state rework#4061
TheBlueMatt merged 4 commits into
lightningdevkit:mainfrom
wpaulino:splice-channel-state-rework

Conversation

@wpaulino

Copy link
Copy Markdown
Contributor

This was pulled out of #4054 to ease review. It depends on #4060 to not bother with cfg(splicing) anymore.

@ldk-reviews-bot

ldk-reviews-bot commented Sep 5, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

Comment threadlightning/src/ln/channel.rs Outdated
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation has a potential logic issue. The FIXME comment correctly identifies that is_interactive_signing() is being used as a replacement for the removed OUR_TX_SIGNATURES_READY flag, but these represent different states:

  • is_interactive_signing() is true during the entire interactive signing process
  • OUR_TX_SIGNATURES_READY was specifically true only after our signatures were ready to send

This semantic difference could cause funding transactions to be incorrectly marked as non-broadcastable when they should be broadcastable. Consider either:

  1. Restoring the OUR_TX_SIGNATURES_READY flag for this specific use case, or
  2. Accessing the signing session directly to check if holder signatures are ready via something like signing_session.holder_tx_signatures().is_some()

This would more accurately represent the original intent of the code.

Suggested change
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),
// We need to check if our signatures are ready to send, which means the transaction
// is ready to be broadcast.
ChannelState::FundingNegotiated(flags) => {
ifletSome(signing_session) = &self.funding_tx_signing_session{
signing_session.holder_tx_signatures().is_none()
}else{
!flags.is_interactive_signing()
}
},

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @valentinewallace! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@wpaulino
wpaulino removed the request for review from valentinewallaceSeptember 8, 2025 20:42
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 9272b39 to 23a1c29CompareSeptember 8, 2025 21:49
@codecov

codecovBot commented Sep 8, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 20.20725% with 154 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.84%. Comparing base (5ae19b4) to head (7597140).
⚠️ Report is 8 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs20.10%146 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs0.00%3 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4061 +/- ##
==========================================
- Coverage 88.33% 87.84% -0.49% 
==========================================
Files 177 176 -1 Lines 131896 131744 -152 Branches 131896 131744 -152 ==========================================
- Hits 116512 115733 -779 - Misses 12728 13376 +648 + Partials 2656 2635 -21 
FlagCoverage Δ
fuzzing21.60% <6.21%> (-0.01%)⬇️
tests87.68% <20.20%> (-0.50%)⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@wpaulinowpaulino self-assigned this Sep 9, 2025
@wpaulinowpaulino added this to the 0.2 milestone Sep 9, 2025
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 23a1c29 to 0cbeb51CompareSeptember 9, 2025 19:59
Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines 8606 to 8623
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures", self.context.channel_id);
return Err(APIError::APIMisuseError { err });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code contains a pattern where a debug assertion claims a signing session must always be present during interactive signing, but then includes error handling for when it's None. This creates a logical contradiction - either:

  1. The assertion is correct and the error handling is unnecessary, or
  2. The error handling is necessary and the assertion is incorrect

This pattern appears multiple times throughout the code. Consider either:

  • Removing the error handling if the assertion truly holds
  • Adjusting the assertion to reflect the actual conditions where a signing session might be absent

This would make the code's intent clearer and avoid potential confusion about the expected state during interactive signing.

Suggested change
debug_assert!(
false,
"A signing session must always be present while interactive signing"
);
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });
// A signing session should typically be present during interactive signing,
// but handle the case where it's not
let err =
format!("Channel {} not expecting funding signatures",self.context.channel_id);
returnErr(APIError::APIMisuseError{ err });

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt@jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will have to finish looking later

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
pub fn tx_signatures(&mut self, msg: &msgs::TxSignatures) -> Result<(Option<msgs::TxSignatures>, Option<Transaction>), ChannelError> {
if !self.context.channel_state.is_interactive_signing()
|| self.context.channel_state.is_their_tx_signatures_sent()
&& !self.has_pending_splice_awaiting_signatures()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why &&?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are the only two states when we should be processing an incoming tx_signatures.

Comment threadlightning/src/ln/channel.rs Outdated
Comment on lines +4276 to +4277
// FIXME: Either we bring back the `OUR_TX_SIGNATURES_READY` flag for this specific
// case, or we somehow access the signing session to check it instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move this out of ChannelContext then?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either that, or we track the session in the context, which lets us get rid of the interactive signing flag. I'm leaning towards the latter.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or we track the session in the context, which lets us get rid of the interactive signing flag

Pushed an update that ended up doing this.

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
self.channel_state,
ChannelState::ChannelReady(f) if f.is_set(ChannelReadyFlags::QUIESCENT)
);
debug_assert!(self.channel_state.is_interactive_signing() || is_quiescent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For v2-channel establishment, is_quiescent isn't applicable, IIUC. But for splicing, wouldn't checking is_interactive_signing be sufficient?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interactive signing flag is only relevant to dual funding due to it being a flag on ChannelState::FundingNegotiated.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 0cbeb51 to 8ca96e3CompareSeptember 12, 2025 00:12

@jkczyzjkczyz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM other some minor fixes

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8ca96e3 to e60f8d7CompareSeptember 12, 2025 22:16
jkczyz
jkczyz previously approved these changes Sep 12, 2025

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few additional things, though none of them blocking this PR per se:

  • is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and its not finishing quickly.
  • We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.
  • Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).
  • I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

Comment threadlightning/src/ln/channel.rs Outdated
Comment threadlightning/src/ln/channel.rs Outdated
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Oh also splice_initial_commitment_signed shouldn't be public.

@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 78da77b to 8a574a5CompareSeptember 15, 2025 21:54
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

is_waiting_on_peer_pending_channel_update/should_disconnect_peer_awaiting_response may need updating to give us more time to finish a splice. RN we just get mad because we're in quiescence and it's not finishing quickly.

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

We might still need a splicing flag or something to track which type of quiescent action is happening. It doesn't have to happen now but right now we enter quiescence and then start accepting splicing messages.

We only track the action up until we're quiescent, and at that point we're already sending the first message (if we're the initiator) for the action we want to take. When we're not the initiator, the action isn't relevant or we're not allowed to propose something anyway, so we should just accept whatever the counterparty sends. We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Relatedly, we currently don't reject receiving a second splice_init message from our counterparty after we're already splicing (and similarly accept it if we initiated a splice, afaict).

validate_splice_init already checks for self.pending_splice.is_some().

I'm confused why we still have this in funding_tx_constructed: // TODO(splicing) Forced error, as the use case is not complete

It's removed in #4054.

The tests have become a bit painful to maintain, and they don't even
fully test the production code paths, so we opt to just remove them for
now. In the future, we plan to unify the dual funding code paths with
splicing.
Since the `InteractiveTxSigningSession` already tracks everything we
need, we can remove the duplicate interactive signing state tracking
within `ChannelState`.
This commit addresses an overlap of state between
`InteractiveTxSigningSession` and `ChannelState::FundingNegotiated`. The
signing session already tracks whether both holder and counterparty
`tx_signatures` have been produced, so tracking the state duplicatively
at the `ChannelState` level is unnecessary.
Comment on lines +8547 to +8601
fn on_tx_signatures_exchange(&mut self, funding_tx: Transaction) {
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());

if let Some(pending_splice) = self.pending_splice.as_mut() {
if let Some(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
} else {
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
return Err(APIError::APIMisuseError { err });
}
self.context.channel_state.clear_quiescent();
} else {
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State management bug: The on_tx_signatures_exchange function modifies channel state and moves funding to pending_funding for splices, but doesn't handle the case where pending_splice.funding_negotiation.take() returns None. The else branch only has debug_assert!(false) which is a no-op in release builds, leaving the channel in an inconsistent state when the funding negotiation is unexpectedly missing.

Suggested change
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
debug_assert!(false);
let err = format!(
"Channel {} with pending splice is not expecting funding signatures yet",
self.context.channel_id
);
returnErr(APIError::APIMisuseError{ err });
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}
fn on_tx_signatures_exchange(&mutself,funding_tx:Transaction){
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
ifletSome(pending_splice) = self.pending_splice.as_mut(){
ifletSome(FundingNegotiation::AwaitingSignatures(mut funding)) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
self.pending_funding.push(funding);
}else{
// This should never happen - if we're in on_tx_signatures_exchange for a splice,
// we must have a pending funding negotiation in the AwaitingSignatures state.
panic!("Missing or invalid funding negotiation state in splice during signature exchange");
}
self.context.channel_state.clear_quiescent();
}else{
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
}
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unnecessary, we always check we're in the AwaitingSignatures state prior to calling this function.

Comment on lines +13083 to +13161
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_) if self.context.interactive_tx_signing_session.is_none()
));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The debug_assert condition is too restrictive. It only allows unsetting funding info when interactive_tx_signing_session.is_none(), but the function should also be callable in AwaitingChannelReady state as indicated by the original logic. This overly restrictive condition will cause assertion failures in valid scenarios where funding info needs to be unset after the signing session has completed.

Suggested change
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none()
));
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_)ifself.context.interactive_tx_signing_session.is_none() |
ChannelState::AwaitingChannelReady
));

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutboundV1Channel is not expected to be in AwaitingChannelReady.

Once a channel open has become locked (i.e., we've entered
`ChannelState::ChannelReady`), the channel is intended to remain within
this state for the rest of its lifetime until shutdown. Previously, we
had assumed a channel being spliced would go through the `ChannelState`
lifecycle again starting from `NegotiatingFunding` but skipping
`AwaitingChannelReady`. This inconsistency departs from what we strive
to achieve with `ChannelState` and also makes the state of a channel
harder to reason about.
This commit ensures a channel undergoing a splice remains in
`ChannelReady`, clearing the quiescent flag once the negotiation is
complete. Dual funding is unaffected by this change as the channel is
being opened and we want to maintain the same `ChannelState` lifecycle.
@wpaulino
wpaulinoforce-pushed the splice-channel-state-rework branch from 8a574a5 to 7597140CompareSeptember 15, 2025 22:15
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

Had to rebase due to a small conflict.

Comment on lines +8661 to +8663
if let Some(funding_tx) = funding_tx_opt.clone() {
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic error: The code calls self.on_tx_signatures_exchange(funding_tx) only when funding_tx_opt is Some, and asserts that tx_signatures_opt.is_some() in this case. However, this assumes that having a funding transaction always means we have tx_signatures to send, which may not be true in all signing scenarios. This could cause assertion failures or incorrect state transitions.

Suggested change
ifletSome(funding_tx) = funding_tx_opt.clone(){
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);
ifletSome(funding_tx) = funding_tx_opt.clone(){
if tx_signatures_opt.is_some(){
self.on_tx_signatures_exchange(funding_tx);
}

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Within the context of funding_transaction_signed, if the funding transaction is ready, it implies that the counterparty already sent their signatures first so we must always respond with ours.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

If the counterparty is still enforcing a stricter timeout then it may not matter what we do? I guess we could reset the timer once we get to the signing phase, or would you rather have it happen at every single step of the splice negotiation (seems overkill IMO)?

No strong opinion, honestly. Just seemed like something to highlight that we should be at least somewhat less strict.

We will need to ensure once we support other quiescent actions that the counterparty is only sending messages for a specific one.

Right, that was my point :)

@TheBlueMatt
TheBlueMatt merged commit f3c22a7 into lightningdevkit:mainSep 16, 2025
25 checks passed
@wpaulino
wpaulino deleted the splice-channel-state-rework branch September 16, 2025 16:18
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@wpaulino@ldk-reviews-bot@TheBlueMatt@jkczyz