Skip to content

Conversation

wpaulino
Copy link
Contributor

@wpaulino wpaulino commented Sep 5, 2025

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

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Sep 5, 2025

👋 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.

// 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
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) => {
if let Some(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

🔔 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 valentinewallace September 8, 2025 20:42
@wpaulino wpaulino force-pushed the splice-channel-state-rework branch from 9272b39 to 23a1c29 Compare September 8, 2025 21:49
Copy link

codecov bot commented Sep 8, 2025

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 lines Patch % Lines
lightning/src/ln/channel.rs 20.10% 146 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs 0.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     
Flag Coverage Δ
fuzzing 21.60% <6.21%> (-0.01%) ⬇️
tests 87.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.

@wpaulino wpaulino self-assigned this Sep 9, 2025
@wpaulino wpaulino added this to the 0.2 milestone Sep 9, 2025
@wpaulino wpaulino force-pushed the splice-channel-state-rework branch from 23a1c29 to 0cbeb51 Compare September 9, 2025 19:59
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
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);
return Err(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);
return Err(APIError::APIMisuseError { err });

Spotted by Diamond

Fix in Graphite


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

@ldk-reviews-bot
Copy link

🔔 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

🔔 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.

Copy link
Collaborator

@TheBlueMatt TheBlueMatt left a comment

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

@@ -8655,56 +8642,51 @@ where
#[rustfmt::skip]
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
Collaborator

Choose a reason for hiding this comment

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

Why &&?

Copy link
Contributor Author

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 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
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
Contributor Author

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
Contributor Author

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.

self.channel_state,
ChannelState::ChannelReady(f) if f.is_set(ChannelReadyFlags::QUIESCENT)
);
debug_assert!(self.channel_state.is_interactive_signing() || is_quiescent);
Copy link
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
Contributor Author

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 wpaulino force-pushed the splice-channel-state-rework branch from 0cbeb51 to 8ca96e3 Compare September 12, 2025 00:12
Copy link
Contributor

@jkczyz jkczyz left a comment

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

@wpaulino wpaulino force-pushed the splice-channel-state-rework branch from 8ca96e3 to e60f8d7 Compare September 12, 2025 22:16
@wpaulino wpaulino requested a review from jkczyz September 12, 2025 22:17
jkczyz
jkczyz previously approved these changes Sep 12, 2025
Copy link
Collaborator

@TheBlueMatt TheBlueMatt left a comment

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

@TheBlueMatt
Copy link
Collaborator

Oh also splice_initial_commitment_signed shouldn't be public.

@wpaulino wpaulino force-pushed the splice-channel-state-rework branch from 78da77b to 8a574a5 Compare September 15, 2025 21:54
@wpaulino
Copy link
Contributor Author

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
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(&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());
}
}
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 {
// 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
Contributor Author

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
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(_) if self.context.interactive_tx_signing_session.is_none()
));
debug_assert!(matches!(
self.context.channel_state,
ChannelState::FundingNegotiated(_) if self.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
Contributor Author

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 wpaulino force-pushed the splice-channel-state-rework branch from 8a574a5 to 7597140 Compare September 15, 2025 22:15
@wpaulino
Copy link
Contributor Author

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
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
if let Some(funding_tx) = funding_tx_opt.clone() {
debug_assert!(tx_signatures_opt.is_some());
self.on_tx_signatures_exchange(funding_tx);
if let Some(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
Contributor Author

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
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:main Sep 16, 2025
25 checks passed
@wpaulino wpaulino deleted the splice-channel-state-rework branch September 16, 2025 16:18
Sign up for free to 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