Skip to content

Exchange splice_locked messages #3741

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from

Conversation

jkczyz
Copy link
Contributor

@jkczyz jkczyz commented Apr 16, 2025

After a splice has been negotiated, each party must send a splice_locked message to the other party once the splice transaction has had an acceptable number of confirmations. Update the logic for processing newly confirmed transactions and updated best block to send splice_locked when appropriate.

Likewise, handle splice_locked and promote the channel's FundingScope once both splice_locked messages have been exchanged.

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Apr 16, 2025

👋 Thanks for assigning @TheBlueMatt 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.

@jkczyz jkczyz requested a review from wpaulino April 16, 2025 17:11
@jkczyz
Copy link
Contributor Author

jkczyz commented Apr 16, 2025

The logic around determining if the splice_locked messages have been exchanged is still a work in progress. It may need to be re-considered to work with chained 0-conf splices. See lightning/bolts#1160 (comment).

jkczyz added 2 commits April 18, 2025 17:56
When processing confirmed transactions and updates to the best block,
ChannelManager may be instructed to send a channel_ready message when a
channel's funding transaction has confirmed and has met the required
number of confirmations. A similar action is needed for sending
splice_locked once splice transaction has confirmed with required number
of confirmations. Generalize do_chain_event signature to allow for
either scenario.
When processing confirmed transactions, if the funding transaction is
found then information about it in the ChannelContext is updated. In
preparation for splicing, move this data to FundingScope.
@jkczyz jkczyz force-pushed the 2025-04-splice-locked branch from dfbc04e to e4c0566 Compare April 18, 2025 23:15
@jkczyz
Copy link
Contributor Author

jkczyz commented Apr 18, 2025

@wpaulino Ok, this is in better shape for a high-level look. I don't believe it correctly handles unconfirmed splice transactions yet. Also, doesn't yet re-send splice_locked on channel reestablishment, thought that may come in a follow-up.

Copy link

codecov bot commented Apr 18, 2025

Codecov Report

Attention: Patch coverage is 86.16505% with 57 lines in your changes missing coverage. Please review.

Project coverage is 90.34%. Comparing base (7b45811) to head (ef048e6).
Report is 82 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/ln/channel.rs 90.24% 27 Missing and 5 partials ⚠️
lightning/src/events/mod.rs 0.00% 23 Missing ⚠️
lightning/src/ln/channelmanager.rs 96.15% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3741      +/-   ##
==========================================
+ Coverage   89.10%   90.34%   +1.24%     
==========================================
  Files         156      158       +2     
  Lines      123431   135603   +12172     
  Branches   123431   135603   +12172     
==========================================
+ Hits       109985   122515   +12530     
+ Misses      10760    10568     -192     
+ Partials     2686     2520     -166     

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

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @wpaulino! 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
Contributor

@optout21 optout21 left a comment

Choose a reason for hiding this comment

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

LGTM. The preparational changes are very clear. The WIP part also makes sense so far.

@@ -11725,6 +11727,13 @@ where
log_trace!(logger, "Sending channel_ready WITHOUT channel_update for {}", funded_channel.context.channel_id());
}
},
#[cfg(splicing)]
Some(FundingConfirmedMessage::Splice(splice_locked)) => {
pending_msg_events.push(MessageSendEvent::SendSpliceLocked {
Copy link
Contributor

Choose a reason for hiding this comment

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

TODO note: An event will have to be sent here as well, similar to ChannelReady -- ChannelReady with a flag indicating it is for splicing, or a new, splicing-specific event.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I take it we want this only after both splice_locked messages have been exchanged? For naming, debating either SpliceLocked or SpliceReady. Something like SpliceFunded also a possibility though it seems odd if it is a splice-out.

Related: Do we want something similar to ChannelPending for when tx_signatures have been exchanged?

Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want something similar to ChannelPending for when tx_signatures have been exchanged?

I think so, that way we can let the user know their splice was negotiated successfully and is pending confirmation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a SpliceLocked event but open to renaming it. I'm a bit uncertain on ordering wrt emitting this event and sending splice_locked, if it matters. Open question as to what should go into the event, presumably from negotiation.

Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't it just mimic channel_ready?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Discussed offline. emit_channel_ready_event has some additional checks that aren't needed here. Instead, we simply emit the event when we have Some announcement signatures.

Also, I ended up dropping the event in favor of re-using ChannelReady and added the funding_txo to the event. This can be used to determine if it corresponds to a ChannelPending or an upcoming SplicePending event.

@@ -1864,6 +1864,8 @@ impl FundingScope {
#[cfg(splicing)]
struct PendingSplice {
pub our_funding_contribution: i64,
sent_funding_txid: Option<Txid>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Where is this field set? Is it when signatures have been exchanged for the the negotiated funding transaction / it has been broadcast?

Copy link
Contributor

Choose a reason for hiding this comment

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

I believe these are specific to splice_locked, so when that is sent/received.

@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @wpaulino! 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.

self.context.minimum_depth = Some(COINBASE_MATURITY);
self.funding.minimum_depth.unwrap_or(0) > 0 &&
self.funding.minimum_depth.unwrap_or(0) < COINBASE_MATURITY {
self.funding.minimum_depth = Some(COINBASE_MATURITY);
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we somehow enforce the coinbase maturity without updating minimum_depth? We always keep the funding transaction around now so we should be able to check if it's a coinbase transaction or not. That would prevent us from tracking minimum_depth per scope.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looks like for v1 channel establishment, the acceptor doesn't have the funding transaction until they see the funding txo on chain. So we need to set it then. We could only store it if it's the coinbase transaction, but I've set it unconditionally for now.

@@ -1864,6 +1864,8 @@ impl FundingScope {
#[cfg(splicing)]
struct PendingSplice {
pub our_funding_contribution: i64,
sent_funding_txid: Option<Txid>,
Copy link
Contributor

Choose a reason for hiding this comment

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

I believe these are specific to splice_locked, so when that is sent/received.


match pending_splice.sent_funding_txid {
Some(sent_funding_txid) if confirmed_funding_txid == sent_funding_txid => {
debug_assert!(false);
Copy link
Contributor

Choose a reason for hiding this comment

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

We may need to replay splice_locked if a disconnect happened before they were able to process it. Unclear if we'd want to use this same code path for it or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

By this do you mean for channel_reestablish?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I assume there is a similar retransmission case we need to handle there, like with channel_ready.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Gonna leave this for a follow-up, if that's ok.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sure, probably better to wait for #3637 to land anyway.

};

let mut confirmed_funding_tx = None;
for &(index_in_block, tx) in txdata.iter() {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we may want to iterate over the funding scopes for each transaction instead, since we may be processing full block data with thousands of transactions and having to compute the txid of each one.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added some fixups for this as discussed offline.

@jkczyz jkczyz force-pushed the 2025-04-splice-locked branch from e4c0566 to 49f8ef6 Compare April 29, 2025 19:55
Copy link
Contributor Author

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

Addressed most comments other than adding an event and for re-sending splice_locked. See comment replies for open questions.

Also, added code to insert the new scid in short_to_chan_info. Should we remove the old one?

self.context.minimum_depth = Some(COINBASE_MATURITY);
self.funding.minimum_depth.unwrap_or(0) > 0 &&
self.funding.minimum_depth.unwrap_or(0) < COINBASE_MATURITY {
self.funding.minimum_depth = Some(COINBASE_MATURITY);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looks like for v1 channel establishment, the acceptor doesn't have the funding transaction until they see the funding txo on chain. So we need to set it then. We could only store it if it's the coinbase transaction, but I've set it unconditionally for now.

};

let mut confirmed_funding_tx = None;
for &(index_in_block, tx) in txdata.iter() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added some fixups for this as discussed offline.


match pending_splice.sent_funding_txid {
Some(sent_funding_txid) if confirmed_funding_txid == sent_funding_txid => {
debug_assert!(false);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

By this do you mean for channel_reestablish?

@@ -11725,6 +11727,13 @@ where
log_trace!(logger, "Sending channel_ready WITHOUT channel_update for {}", funded_channel.context.channel_id());
}
},
#[cfg(splicing)]
Some(FundingConfirmedMessage::Splice(splice_locked)) => {
pending_msg_events.push(MessageSendEvent::SendSpliceLocked {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I take it we want this only after both splice_locked messages have been exchanged? For naming, debating either SpliceLocked or SpliceReady. Something like SpliceFunded also a possibility though it seems odd if it is a splice-out.

Related: Do we want something similar to ChannelPending for when tx_signatures have been exchanged?

@jkczyz jkczyz marked this pull request as ready for review April 30, 2025 16:20
@jkczyz jkczyz added the weekly goal Someone wants to land this this week label Apr 30, 2025
@jkczyz jkczyz force-pushed the 2025-04-splice-locked branch from ef048e6 to f3b0989 Compare April 30, 2025 17:31
@jkczyz
Copy link
Contributor Author

jkczyz commented Apr 30, 2025

Squashed fixups

@wpaulino
Copy link
Contributor

An offline discussion we had around DiscardFunding: we still need to emit one for splices if we negotiated one but it cannot confirm due to the channel closing via a commitment broadcast of the pre-splice FundingScope or a co-op close. Easiest way to handle this may be in transactions_confirmed where we already check for spending transactions of the channel to consider it closed.

@@ -3026,7 +3026,7 @@ macro_rules! locked_close_channel {
// into the map (which prevents the `PeerState` from being cleaned up) for channels that
// never even got confirmations (which would open us up to DoS attacks).
let update_id = $channel_context.get_latest_monitor_update_id();
if $channel_context.get_funding_tx_confirmation_height().is_some() || $channel_context.minimum_depth() == Some(0) || update_id > 1 {
if $channel_funding.get_funding_tx_confirmation_height().is_some() || $channel_context.minimum_depth() == Some(0) || update_id > 1 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I thought we agreed to try to keep the concept of funding scopes outside of ChannelManager? I think for this we can move to a is_funding_confirmed_or_0conf call or something?

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 macro doesn't have access to the channel, only the context which doesn't have reference to the funding scope. So it would require much more re-work than I'd like to do in this PR.

Instead, I'd rather make a separate PR after all the changes needed for FundingScope are complete. Hopefully in a couple PRs.

Copy link
Contributor Author

@jkczyz jkczyz Apr 30, 2025

Choose a reason for hiding this comment

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

Oh, also forgot to drop these two commits since one reverts the previous one. But the general idea still stands for other occurrences.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Tried to avoid this but the problem is that locked_close_channel is called throughout the file including by convert_channel_err, which likewise is called throughout. Additionally, convert_channel_err has a rule that expects a FundedChannel so that it can pass it to get_channel_update_for_broadcast.

Might be a way to refactor this but I think it should wait for a follow-up given it's not going to be straightforward.

@@ -11639,7 +11639,7 @@ where
for chan in peer_state.channel_by_id.values().filter_map(Channel::as_funded) {
let txid_opt = chan.funding.get_funding_txo();
let height_opt = chan.context.get_funding_tx_confirmation_height();
let hash_opt = chan.context.get_funding_tx_confirmed_in();
let hash_opt = chan.funding.get_funding_tx_confirmed_in();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we avoid new references to Channel::funding in ChannelManager? I thought we wanted to remove that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was able to get rid of this one by moving get_funding_tx_confirmed_in to FundedChannel since this is the only use.

@@ -3031,7 +3031,7 @@ macro_rules! locked_close_channel {
$peer_state.closed_channel_monitor_update_ids.insert(chan_id, update_id);
}
let mut short_to_chan_info = $self.short_to_chan_info.write().unwrap();
if let Some(short_id) = $channel_context.get_short_channel_id() {
if let Some(short_id) = $channel_funding.get_short_channel_id() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same here, can we avoid this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Similarly, we'd need this to take a Channel instead of a ChannelContext, but the problem is that convert_channel_err needs a FundedChannel for one rule as mentioned in another comment. Maybe we eventually just add any methods called from those macros on both Channel and each sub-struct so that it can be used either way?

@jkczyz
Copy link
Contributor Author

jkczyz commented May 1, 2025

An offline discussion we had around DiscardFunding: we still need to emit one for splices if we negotiated one but it cannot confirm due to the channel closing via a commitment broadcast of the pre-splice FundingScope or a co-op close. Easiest way to handle this may be in transactions_confirmed where we already check for spending transactions of the channel to consider it closed.

Separate but related to this, I'm wondering now which FundingScope should be used to populate ShutdownResult when we see the commitment transaction of a pending splice confirm presumably in the same block that the splice funding confirmed.

Currently, we use the pre-splice FundingScope since the flow is:

  • ChannelManager::do_chain_event calls
  • FundedChannel::transactions_confirmed which returns ClosureReason to
  • ChannelManager::do_chain_event which calls
  • ChannelContext::force_shutdown with the ClosureReason and FundedChannel::funding and returns a ShutdownResult

After that locked_close_channel! is called with the ShutdownResult and FundedChannel::funding. Then ChannelManager::get_channel_update_for_broadcast which uses the pre-splice FundingScope. Then finally ChannelManager::finish_close_channel is called with the ShutdownResult.

So in the scenario described above, should we transition to the pending FundingScope that was just confirmed and spent -- before calling ChannelContext::force_shutdown -- so that it is reflected in ShutdownResult? We may need to also generate an Event::SpliceLocked, too.

Then that way for Event::DiscardFunding we simply include info for any pending funding scopes in ShutdownResult in order to produce that event. And in the case of transitioning to a pending FundingScope first, we'd avoid producing Event::DiscardFunding since it would not be desired in that situation.

Related discussion: #3592 (comment)

jkczyz added 6 commits May 1, 2025 14:44
When processing confirmed transactions, if the funding transaction is
found then information about it in the ChannelContext is updated. In
preparation for splicing, move this data to FundingScope.
When processing confirmed transactions, if the funding transaction is
found then information about it in the ChannelContext is updated. In
preparation for splicing, move this data to FundingScope.
When checking if channel_ready should be sent, the funding transaction
must reach minimum_depth confirmations. The same logic is needed for
splicing a channel, so refactor it into a helper method.
The minimum_depth of a channel is overridden to COINBASE_MATURITY if the
funding transaction is the coinbase transaction. However, if this is to
be reused for a splice's minimum_depth, it would be a problem since
sending splice_locked would be unnecessarily delayed. Now that
FundingScope contains the funding transaction, use this to check if it
is a coinbase transaction instead of overriding minimum_depth.
@jkczyz jkczyz force-pushed the 2025-04-splice-locked branch from f3b0989 to bd1a788 Compare May 1, 2025 21:51
@jkczyz
Copy link
Contributor Author

jkczyz commented May 1, 2025

An offline discussion we had around DiscardFunding: we still need to emit one for splices if we negotiated one but it cannot confirm due to the channel closing via a commitment broadcast of the pre-splice FundingScope or a co-op close. Easiest way to handle this may be in transactions_confirmed where we already check for spending transactions of the channel to consider it closed.

Separate but related to this, I'm wondering now which FundingScope should be used to populate ShutdownResult when we see the commitment transaction of a pending splice confirm presumably in the same block that the splice funding confirmed.

Discussed offline with @TheBlueMatt and @wpaulino. The two scenarios are closing while funding negotiation takes place and while waiting for the splice to confirm. For the latter, it would be better to a have general approach in ChannelMonitor for determining if an Event::DiscardFunding should be emitted. So no changes necessary in this PR.

For the former, we won't have a ChannelMonitor so we will need to emit the event in ChannelManager whenever the user has contributed inputs. I'd imagine we need to keep track of this in PendingSplice and return it to ChannelManager somehow. Also, since we won't have a funding transaction or a funding txo during negotiation, we'd need a new FundingInfo variant for the contributed inputs to use in Event::DiscardFunding, IIUC. (cc: @optout21 @dunxen)

@dunxen dunxen self-requested a review May 5, 2025 18:26
@jkczyz jkczyz requested review from wpaulino and TheBlueMatt May 6, 2025 20:54
jkczyz added 3 commits May 7, 2025 11:08
When transactions confirm or the best block is updated, check if any
pending splice funding transactions have confirmed to an acceptable
depth. If so, send a splice_locked message to the counterparty and -- if
the counterparty has exchanged a splice_locked message for the same
funding txid -- promote the corresponding FundingScope such that the new
funding can be utilized.
@jkczyz jkczyz force-pushed the 2025-04-splice-locked branch from b4e2b1a to a57c034 Compare May 7, 2025 16:27
@jkczyz
Copy link
Contributor Author

jkczyz commented May 7, 2025

Pushed some fixups to hopefully fix CI. Also, updated the WIP commit message.

@jkczyz
Copy link
Contributor Author

jkczyz commented May 7, 2025

One more fixup...

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @TheBlueMatt @dunxen @wpaulino! 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.

2 similar comments
@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @TheBlueMatt @dunxen @wpaulino! 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.

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @TheBlueMatt @dunxen @wpaulino! 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.

@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @TheBlueMatt @dunxen @wpaulino! 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.

2 similar comments
@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @TheBlueMatt @dunxen @wpaulino! 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.

@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @TheBlueMatt @dunxen @wpaulino! 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.

@ldk-reviews-bot
Copy link

🔔 3rd Reminder

Hey @TheBlueMatt @dunxen @wpaulino! 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.

@@ -4851,7 +4851,24 @@ impl<SP: Deref> ChannelContext<SP> where SP::Target: SignerProvider {
}

fn check_funding_confirmations(&self, funding: &mut FundingScope, height: u32) -> bool {
if funding.funding_tx_confirmation_height == 0 && self.minimum_depth != Some(0) {
let is_coinbase = funding
Copy link
Collaborator

Choose a reason for hiding this comment

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

Isn't this unreachable for splicing anyway as is_coinbase() checks that the transaction has a single input with a txid of all-0s but splicing checks the transaction -> txid conversion and requires an input that is the previous funding?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, that is correct.

.map(|tx| tx.is_coinbase())
.unwrap_or(false);

let minimum_depth = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Using this directly rather than actually setting self.minimum_depth for coinbase-tx-funded channels changes the public API to no longer display 100 for the minimum depth in ChannelDetails. I don't really feel super strongly against this, but it does seem like changing to 100 makes sense given that is actually our minimum depth?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch. I suppose we could do something similar when constructing ChannelDetails.

};

match pending_splice.sent_funding_txid {
Some(sent_funding_txid) if confirmed_funding_txid == sent_funding_txid => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm really confused what we're doing here, honestly. We have both a sent and received funding_txid, but presumably those should be identical after we finish negotiation, so why are we storing separate txids? Then here we're calling the FundingScope txid confirmed, but why? Shouldn't that be filled in when the FundingScope is built (after splicing negotiation completes, I assume? Finally, we send a SpliceLocked if either sent_funding_txid is None (makes sense) or if the FundingScope funding info doesn't match the send_funding_txid, but why can the funding txid change at all? Shouldn't it be fixed after splice negotiation finishes? Is this some kind of RBF thing?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, it is to do with RBF. sent_funding_txid and received_funding_txid correspond to the funding_txid used when sending or receiving a splice_locked, respectively. They are only set then, not after negotiation.

Here, we may have sent a splice_locked for one splice transaction but later an RBF confirms. Though, now I'm wondering if I've handled the case of unconfirming the first splice tx, which I'd imagine should clear sent_funding_txid?


#[cfg(splicing)]
fn check_get_splice_locked(
&mut self, pending_splice: &PendingSplice, funding: &mut FundingScope, height: u32,
Copy link
Collaborator

Choose a reason for hiding this comment

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

The fact that we're passing in a PendingSplice and FundingScope separately tells me our datastructures are wrong. FundedChannel holds a FundingScope for the confirmed funding and a PendingSplice (I assume) for the in-flight negotiation. Once negotiation finishes, its a bit weird that we then have a PendingSplice in the FundedChannel that refers to a FundingScope that is actually in FundedChannel::pending_funding by this point.

PendingSplice's docs say that it is "Info about a pending splice, used in the pre-splice channel", which implies to me that it should go away once we've negotiated a splice and we're ready to lock it in. Shouldn't we track it in the FundingScope that it refers to once one exists? Given that we can have multiple RBFs its a bit weird that we track our_funding_contribution singularly globally but can have many values across different RBFs.

I guess the intent here is to track "what txid of all the RBFs did they tell us is confirmed" separately from those RBFs so we can detect inconsistency between what we saw confirmed and what our peer saw confirmed, tho it might be worth noting that we actually can happily continue if we're split-brained with our peer - as long as we keep getting sigs for what we think is one chain its fine...Awkward, but not worth having an awkward design to detect.

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 fact that we're passing in a PendingSplice and FundingScope separately tells me our datastructures are wrong. FundedChannel holds a FundingScope for the confirmed funding and a PendingSplice (I assume) for the in-flight negotiation. Once negotiation finishes, its a bit weird that we then have a PendingSplice in the FundedChannel that refers to a FundingScope that is actually in FundedChannel::pending_funding by this point.

Hmm... PendingSplice doesn't refer to those. If you mean sent_funding_txid and received_funding_txid, those are only set when splice_locekd is sent or received, respectively, and could correspond to any FundingScope in FundedChannel::pending_funding. In fact, we may want to move FundedChannel::pending_funding into PendingSplice as mentioned in a TODO. Though maybe not? See below.

PendingSplice's docs say that it is "Info about a pending splice, used in the pre-splice channel", which implies to me that it should go away once we've negotiated a splice and we're ready to lock it in. Shouldn't we track it in the FundingScope that it refers to once one exists? Given that we can have multiple RBFs its a bit weird that we track our_funding_contribution singularly globally but can have many values across different RBFs.

Note that we are adding more fields in #3736.

@wpaulino Could you recall why we want to re-use PendingSplice for both current negotiation context and post-negotiation state? Was it simply to avoid adding another field / struct to FundedChannel? Perhaps it would be better to have separate structs as @TheBlueMatt points out.

I guess the intent here is to track "what txid of all the RBFs did they tell us is confirmed" separately from those RBFs so we can detect inconsistency between what we saw confirmed and what our peer saw confirmed, tho it might be worth noting that we actually can happily continue if we're split-brained with our peer - as long as we keep getting sigs for what we think is one chain its fine...Awkward, but not worth having an awkward design to detect.

Right, I think we do happily continue in this case. If sent_funding_txid and received_funding_txid differ, we never call promote_splice_funding or send announcement sigs.

Copy link
Contributor

Choose a reason for hiding this comment

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

Could you recall why we want to re-use PendingSplice for both current negotiation context and post-negotiation state? Was it simply to avoid adding another field / struct to FundedChannel? Perhaps it would be better to have separate structs as @TheBlueMatt points out.

PendingSplice and pending_funding have mostly been worked on independently, and it wasn't until #3736 that it become more obvious that they should be unified as we discussed in our last sync. pending_funding should definitely be tracked within PendingSplice now, there's no way those can be set without PendingSplice also being set, assuming PendingSplice is Some until both splice_lockeds have been exchanged.

As for some of the negotiation state (e.g., our_funding_contribution, etc.), I'd prefer having them captured within PendingSplice so that we can always rely on pending_splice being None before attempting a splice. Ideally all that state is captured under an optional member we can set to None once the negotiation is finished and we're waiting confirmations. That state may go back to being Some (temporarily) if a RBF attempt is made though.

As an aside, it seems like this method (check_get_splice_locked) doesn't belong on the ChannelContext, it should be on FundedChannel instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

PendingSplice and pending_funding have mostly been worked on independently, and it wasn't until #3736 that it become more obvious that they should be unified as we discussed in our last sync. pending_funding should definitely be tracked within PendingSplice now, there's no way those can be set without PendingSplice also being set, assuming PendingSplice is Some until both splice_lockeds have been exchanged.

Yeah, let me give that a try as discussed last time.

As for some of the negotiation state (e.g., our_funding_contribution, etc.), I'd prefer having them captured within PendingSplice so that we can always rely on pending_splice being None before attempting a splice. Ideally all that state is captured under an optional member we can set to None once the negotiation is finished and we're waiting confirmations. That state may go back to being Some (temporarily) if a RBF attempt is made though.

Ah, right, starting to recall our offline discussion of this.

As an aside, it seems like this method (check_get_splice_locked) doesn't belong on the ChannelContext, it should be on FundedChannel instead.

Currently, the borrow checker complains because the FundingScope mutable reference is passed along to check_funding_confirmations, but the self parameter is a also a mutable reference. The latter can be made immutable but that is still a problem with the borrow checker.

IIUC, a mutable reference is need because FundingScope::funding_tx_confirmation_height is set to zero when given a height that would make the local variable funding_tx_confirmations negative. I don't know if doing this is strictly necessary (looking now). The side effect is unfortunate, though.

Copy link
Contributor

Choose a reason for hiding this comment

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

IIUC, a mutable reference is need because FundingScope::funding_tx_confirmation_height is set to zero when given a height that would make the local variable funding_tx_confirmations negative. I don't know if doing this is strictly necessary (looking now). The side effect is unfortunate, though.

Ah, IMO that should be reset right as we see the height the transaction confirmed in being disconnected, not sure how much work that refactor would be though. As I was going through this logic, I also noticed we need to update ChannelManager::transaction_confirmed to consider all scopes to handle splice transactions.

None => {
// TODO: Move pending_funding into pending_splice?
debug_assert!(false);
// TODO: Error instead?
Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes please :)

}
}

self.context.check_for_funding_tx_spent(&self.funding, tx, logger)?;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Just checking against self.funding seems insufficient, no? Can't the peer see the new splice lock-in, but then never send a splice_locked and then FC the channel against the splice in the next block, which wouldn't result in us not checking if that funding is spent?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I think you're right. We could see the splice confirm in the previous block without enough confirmations, regardless, before the force close in the next block.

let mut confirmed_funding = None;
#[cfg(splicing)]
for funding in self.pending_funding.iter_mut() {
if confirmed_funding.is_some() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Huh? This is set unconditionally, so won't this fail any time we have more than one pending_funding/any RBF?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm... yeah something is screwy here. There's a similar check in transactions_confirmed to make sure we never have more than one pending splice confirm in the same block, which shouldn't be possible. Here, we really want to use whichever one has confirmed, if any. I suppose we should check FundingScope::funding_tx_confirmed_in.

}}
}

macro_rules! insert_short_channel_id {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm, ugh, I guess we need to store all past (recent-ish?) SCIDs for a channel? If we restart we still presumably need to keep relaying using past SCIDs post-splice-lock-in for an hour or three worth of blocks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm... is it worth cleaning these up after some time?

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, ugh, I guess we need to store all past (recent-ish?) SCIDs for a channel?

Yeah this is a bit unfortunate. Easiest way to clean them up as we go is probably on deserialization by checking if any pending HTLCs are still referencing an old SCID.

return Ok(self.get_announcement_sigs(node_signer, chain_hash, user_config, best_block.height, logger));
}

// TODO: Close channel?
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's not leave question TODOs behind. We should decide if we want to or not and do it (or not). I don't feel strongly here, but a TODO is the wrong answer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, this was really meant to resolve during the review. Should have left a comment.

Here we have sent a splice_locked for pending_splice.sent_funding_txid but we can't fine a corresponding FundingScope when our counterparty sends their splice_locked. This is a bad state. From our counterparty's perspective, the splice is now locked. So presumably future operations would fail. So we might as well close the channel now.

jkczyz added 8 commits May 12, 2025 15:24
Once both parties have exchanged splice_locked messages, the splice
funding is ready for use. Emit an event to the user indicating as much.
A ChannelReady event is used for both channel establishment and splicing
to indicate that the funding transaction is confirmed to an acceptable
depth and thus the channel can be used with the funding. An upcoming
SplicePending event will be emitted for each pending splice (i.e., both
the initial splice attempt and any RBF attempts). Thus, when a
ChannelReady event is emitted, the funding_txo must be included to
differentiate between which ChannelPending -- which also contains the
funding_txo -- that the event corresponds to.
@jkczyz jkczyz force-pushed the 2025-04-splice-locked branch from bbf73c7 to 81c410d Compare May 12, 2025 21:04
@@ -11639,7 +11639,7 @@ where
for chan in peer_state.channel_by_id.values().filter_map(Channel::as_funded) {
let txid_opt = chan.funding.get_funding_txo();
let height_opt = chan.context.get_funding_tx_confirmation_height();
let hash_opt = chan.context.get_funding_tx_confirmed_in();
let hash_opt = chan.funding.get_funding_tx_confirmed_in();
Copy link
Contributor

Choose a reason for hiding this comment

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

get_relevant_txids will need to look at any pending scopes for the new funding txids so that those can be notified by the backend (bitcoind, electrum, etc.) as well.


#[cfg(splicing)]
fn check_get_splice_locked(
&mut self, pending_splice: &PendingSplice, funding: &mut FundingScope, height: u32,
Copy link
Contributor

Choose a reason for hiding this comment

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

IIUC, a mutable reference is need because FundingScope::funding_tx_confirmation_height is set to zero when given a height that would make the local variable funding_tx_confirmations negative. I don't know if doing this is strictly necessary (looking now). The side effect is unfortunate, though.

Ah, IMO that should be reset right as we see the height the transaction confirmed in being disconnected, not sure how much work that refactor would be though. As I was going through this logic, I also noticed we need to update ChannelManager::transaction_confirmed to consider all scopes to handle splice transactions.

if let Some(channel_ready) = self.check_get_channel_ready(height, logger) {
log_info!(logger, "Sending a channel_ready to our peer for channel {}", &self.context.channel_id);
let announcement_sigs = self.get_announcement_sigs(node_signer, chain_hash, user_config, height, logger);
return Ok((Some(FundingConfirmedMessage::Establishment(channel_ready)), announcement_sigs));
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think returning early is ok here, don't we still need to check if it was spent?

Comment on lines 8376 to 8377
for &(_, tx) in txdata.iter() {
self.context.check_for_funding_tx_spent(&self.funding, tx, logger)?;
Copy link
Contributor

Choose a reason for hiding this comment

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

Ideally we also resume scanning from the index that we stopped at above, not a huge deal though.

Comment on lines +8434 to +8437
return Ok((Some(FundingConfirmedMessage::Splice(splice_locked)), announcement_sigs));
}

return Ok((Some(FundingConfirmedMessage::Splice(splice_locked)), None));
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment here about returning early.

}}
}

macro_rules! insert_short_channel_id {
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, ugh, I guess we need to store all past (recent-ish?) SCIDs for a channel?

Yeah this is a bit unfortunate. Easiest way to clean them up as we go is probably on deserialization by checking if any pending HTLCs are still referencing an old SCID.

let pending_splice = match self.pending_splice.as_mut() {
Some(pending_splice) => pending_splice,
None => {
return Err(ChannelError::Warn(format!("Channel is not in pending splice")));
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe ignore instead? We can receive it again upon reconnecting even if we already transitioned to the new scope.

let persist = match &res {
Err(e) if e.closes_channel() => NotifyOption::DoPersist,
Err(_) => NotifyOption::SkipPersistHandleEvents,
Ok(()) => NotifyOption::SkipPersistHandleEvents,
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we do want to persist here if either we promoted the splice funding, or we received their splice_locked but we haven't detected the confirmation yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
weekly goal Someone wants to land this this week
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants