Skip to content

Clean up and split up is_pre_funded_state #4021

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 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 44 additions & 21 deletions lightning/src/ln/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -804,11 +804,11 @@ impl ChannelState {
}
}

fn is_pre_funded_state(&self) -> bool {
fn can_resume_on_reconnect(&self) -> bool {
match self {
ChannelState::NegotiatingFunding(_) => true,
ChannelState::FundingNegotiated(flags) => !flags.is_interactive_signing(),
_ => false,
ChannelState::NegotiatingFunding(_) => false,
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 regarding splices

ChannelState::FundingNegotiated(flags) => flags.is_interactive_signing(),
_ => true,
}
}

Expand Down Expand Up @@ -4076,11 +4076,28 @@ where
self.is_manual_broadcast = true;
}

/// Returns true if this channel can be resume after a restart, implying its past the initial
/// funding negotiation stages (and any assocated batch channels are similarly past initial
/// funding negotiation).
///
/// This is equivalent to saying the channel can be persisted to disk.
pub fn can_resume_on_restart(&self) -> bool {
self.channel_state.can_resume_on_reconnect()
&& match self.channel_state {
ChannelState::AwaitingChannelReady(flags) => !flags.is_waiting_for_batch(),
_ => true,
}
}

/// Returns true if funding_signed was sent/received and the
/// funding transaction has been broadcast if necessary.
pub fn is_funding_broadcast(&self) -> bool {
!self.channel_state.is_pre_funded_state()
&& !matches!(self.channel_state, ChannelState::AwaitingChannelReady(flags) if flags.is_set(AwaitingChannelReadyFlags::WAITING_FOR_BATCH))
fn is_funding_broadcast(&self) -> bool {
match self.channel_state {
ChannelState::NegotiatingFunding(_) => false,
ChannelState::FundingNegotiated(flags) => !flags.is_our_tx_signatures_ready(),
ChannelState::AwaitingChannelReady(flags) => !flags.is_waiting_for_batch(),
_ => true,
}
}

#[rustfmt::skip]
Expand Down Expand Up @@ -5372,15 +5389,14 @@ where
}

let monitor_update = if let Some(funding_txo) = funding.get_funding_txo() {
// If we haven't yet exchanged funding signatures (ie channel_state < AwaitingChannelReady),
// returning a channel monitor update here would imply a channel monitor update before
// we even registered the channel monitor to begin with, which is invalid.
// Thus, if we aren't actually at a point where we could conceivably broadcast the
// funding transaction, don't return a funding txo (which prevents providing the
// monitor update to the user, even if we return one).
// See test_duplicate_chan_id and test_pre_lockin_no_chan_closed_update for more.
if !self.channel_state.is_pre_funded_state() {
// We should only generate a closing `ChannelMonitorUpdate` if we already have a
// `ChannelMonitor` for the disk (i.e. `cur_counterparty_commitment_transaction_number`
// has been decremented once, which hapens when we generate the initial
// `ChannelMonitor`). Otherwise, that would imply a channel monitor update before we
// even registered the channel monitor to begin with, which is invalid.
if self.cur_counterparty_commitment_transaction_number != INITIAL_COMMITMENT_NUMBER {
self.latest_monitor_update_id = self.get_latest_unblocked_monitor_update_id() + 1;

let update = ChannelMonitorUpdate {
update_id: self.latest_monitor_update_id,
updates: vec![ChannelMonitorUpdateStep::ChannelForceClosed {
Expand Down Expand Up @@ -8116,12 +8132,12 @@ where
#[rustfmt::skip]
fn remove_uncommitted_htlcs_and_mark_paused<L: Deref>(&mut self, logger: &L) -> Result<(), ()> where L::Target: Logger {
assert!(!matches!(self.context.channel_state, ChannelState::ShutdownComplete));
if self.context.channel_state.is_pre_funded_state() {
if !self.context.channel_state.can_resume_on_reconnect() {
return Err(())
}

// We only clear `peer_disconnected` if we were able to reestablish the channel. We always
// reset our awaiting response in case we failed reestablishment and are disconnecting.
// reset our awaiting response in case we failed reestablishment and are disconnecting.
self.context.sent_message_awaiting_response = None;

if self.context.channel_state.is_peer_disconnected() {
Expand Down Expand Up @@ -9107,13 +9123,20 @@ where
"Peer sent shutdown when we needed a channel_reestablish".to_owned(),
));
}
if self.context.channel_state.is_pre_funded_state() {
let mut not_broadcasted =
matches!(self.context.channel_state, ChannelState::NegotiatingFunding(_));
Copy link
Contributor

Choose a reason for hiding this comment

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

We'll also be in this state during splices, so it seems we need more context before attempting to consider it "not broadcasted"

Copy link
Contributor

Choose a reason for hiding this comment

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

If we disconnect during this state with a splice, we need to go back to ChannelReady

Copy link
Collaborator Author

@TheBlueMatt TheBlueMatt Aug 19, 2025

Choose a reason for hiding this comment

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

Probably that just implies that we should not convert to NegotiatingFunding during splices? We have too many things that depend on the state and it seems weird to jump back to the "pre-funding" state when we're not actually pre-funding.

Copy link
Contributor

Choose a reason for hiding this comment

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

We have too many things that depend on the state

How does the same not apply to us being in FundingNegotiated?

it seems weird to jump back to the "pre-funding" state when we're not actually pre-funding

Wouldn't it be better to think about it in terms of whether the channel can be resumed after a disconnect/restart instead of being funded or not? That seems to better align with some of the changes here. I see "pre-funding" more ambiguous now that a v2 channel could be considered pre-funding both before the funding transaction is built and before signatures are exchanged for it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

How does the same not apply to us being in FundingNegotiated?

I meant that we have a lot of things that look at state, and previously FundingNegotiated was part of the "this is pre-funding" and now its sometimes not pre-funding, which is a really weird change to me?

Wouldn't it be better to think about it in terms of whether the channel can be resumed after a disconnect/restart instead of being funded or not?

But it can be resumed, just not the splice itself. I think that's kinda my point here - it feels way more like a post-funding channel that is just paused (quiescent).

Copy link
Contributor

Choose a reason for hiding this comment

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

I meant that we have a lot of things that look at state, and previously FundingNegotiated was part of the "this is pre-funding" and now its sometimes not pre-funding, which is a really weird change to me?

I'm confused then, you think it's a weird change but we're doing it anyway and somehow excluding NegotiatingFunding from following the same pattern?

But it can be resumed, just not the splice itself. I think that's kinda my point here - it feels way more like a post-funding channel that is just paused (quiescent).

Then shouldn't we be relying on whether we're post-funding based on the ChannelPhase and not the ChannelState? ChannelState predates all of this so it made sense back then but not anymore. Why should ChannelState maintain its logical ordering when ChannelPhase has kind of fulfilled that role already?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Then shouldn't we be relying on whether we're post-funding based on the ChannelPhase and not the ChannelState? ChannelState predates all of this so it made sense back then but not anymore. Why should ChannelState maintain its logical ordering when ChannelPhase has kind of fulfilled that role already?

Last discussion I remember we kinda decided ChannelPhase doesn't work and we wanted to get rid of it. Its by no means set, but ChannelPhase has really turned out to be incredibly cumbersome and I'm kinda skeptical of trying to invest in it further. ChannelState has, at least up until the change you're describing here, been totally well-ordered with flags representing the non-ordered stuff.

@jkczyz may have an opinion here too.

Copy link
Contributor

Choose a reason for hiding this comment

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

Regarding ChannelPhase, I don't think we wanted to remove it entirely but rather make it an internal detail of Channel, which it now is. The structs held by the phases are valuable to expose as they restrict which methods can be called and what state is available (e.g., splice operations and pending splices are only available on FundedChannel).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Regarding ChannelPhase, I don't think we wanted to remove it entirely but rather make it an internal detail of Channel, which it now is.

No its not? channelmanager.rs has a ton of "convert state check" nonsense all over the place, with a lot of associated error handling that belongs in channel.rs/Channel.

The structs held by the phases are valuable to expose as they restrict which methods can be called and what state is available (e.g., splice operations and pending splices are only available on FundedChannel).

I'm really unconvinced by this now that we have it. We went down the same path with the interactive transaction builder, first making it an API that channel.rs would access directly, then realizing its actually really not adding much and wrapping it so that channel.rs can always access methods and failing them inside the interactive transaction builder, resulting in a bunch more code there without any real value (you still have to handle the error case of "we received a message for a state it cannot apply to", you just move around where that handling is, in this case to a worse place!).

A state-transition enum makes a lot of sense if there's really a lot of fields that are only exist in some states and that can result in removing a lot of unwraps to access fields that are guaranteed to be set in a state but not available before it. We don't have that with Channel, we have nearly everything in ChannelContext, and still have all the unwraps. Worse, its not like we even have different things in the scope structs - PendingV2Channel has a FundingScope just like FundedChannel (probably implying it should be in the global context), has an interactive signing session (just like FundedChannel, again maybe it should be in the global context...) and really there's barely any difference at all.

Worse still, we have two separate concepts of the "channel's state" - the ChannelState enum, which is granular and useful, and the struct-level thing which is not granular, doesn't save unwraps, and now because its duplicated state can represent a lot of invalid cases where the states conflict. Now, that's not to say that the state enum thing can't be useful - we could actually invest in it, basically removing the top level ChannelState enum, moving those to different structs in the top-level Channel enum, and moving the flags inside of them, allowing, for example, the removal of the ChannelContext in the pre-funded states and making lots of Options in the ChannelContext required. Or we could walk it back and just use the ChannelState everywhere robustly.

At the end of the day, the only unacceptable option is the state were in right now where we have two concepts of state that are very different and don't use either in a robust or reliable way.

Copy link
Contributor

Choose a reason for hiding this comment

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

No its not? channelmanager.rs has a ton of "convert state check" nonsense all over the place, with a lot of associated error handling that belongs in channel.rs/Channel.

Right, my point was the matches on ChannelPhase were removed and in some cases pushed into Channel. In other cases, we do something like as_funded.

I'm really unconvinced by this now that we have it. We went down the same path with the interactive transaction builder, first making it an API that channel.rs would access directly, then realizing its actually really not adding much and wrapping it so that channel.rs can always access methods and failing them inside the interactive transaction builder, resulting in a bunch more code there without any real value (you still have to handle the error case of "we received a message for a state it cannot apply to", you just move around where that handling is, in this case to a worse place!).

Was referring mainly to uses of as_funded, et al. There the checks when receiving a message remain in channelmanager.rs.

A state-transition enum makes a lot of sense if there's really a lot of fields that are only exist in some states and that can result in removing a lot of unwraps to access fields that are guaranteed to be set in a state but not available before it. We don't have that with Channel, we have nearly everything in ChannelContext, and still have all the unwraps. Worse, its not like we even have different things in the scope structs - PendingV2Channel has a FundingScope just like FundedChannel (probably implying it should be in the global context), has an interactive signing session (just like FundedChannel, again maybe it should be in the global context...) and really there's barely any difference at all.

Worse still, we have two separate concepts of the "channel's state" - the ChannelState enum, which is granular and useful, and the struct-level thing which is not granular, doesn't save unwraps, and now because its duplicated state can represent a lot of invalid cases where the states conflict. Now, that's not to say that the state enum thing can't be useful - we could actually invest in it, basically removing the top level ChannelState enum, moving those to different structs in the top-level Channel enum, and moving the flags inside of them, allowing, for example, the removal of the ChannelContext in the pre-funded states and making lots of Options in the ChannelContext required. Or we could walk it back and just use the ChannelState everywhere robustly.

FWIW, we don't have a top-level Channel enum anymore. We have a Channel struct containing a ChannelPhase enum. Totally, agree about your points about invalid cases and making fewer Options in ChannelContext.

Currently, there are a ton of places in channelmaner.rs that want ChannelContext regardless of the phase. So we'd need to tackle untangling that. Additionally, each channel sub-struct (e.g., FundedChannel) typically wants to access some field or method on ChannelContext.

At the end of the day, the only unacceptable option is the state were in right now where we have two concepts of state that are very different and don't use either in a robust or reliable way.

Yeah, I tend to agree. Let's discuss offline.

if let ChannelState::FundingNegotiated(flags) = &self.context.channel_state {
if !flags.is_our_tx_signatures_ready() {
// If we're a V1 channel or we haven't yet sent our `tx_signatures`, the funding tx
// couldn't be broadcasted yet, so just short-circuit the shutdown logic.
not_broadcasted = true;
}
}
if not_broadcasted {
// Spec says we should fail the connection, not the channel, but that's nonsense, there
// are plenty of reasons you may want to fail a channel pre-funding, and spec says you
// can do that via error message without getting a connection fail anyway...
return Err(ChannelError::close(
"Peer sent shutdown pre-funding generation".to_owned(),
));
return Err(ChannelError::close("Shutdown before funding was broadcasted".to_owned()));
}
for htlc in self.context.pending_inbound_htlcs.iter() {
if let InboundHTLCState::RemoteAnnounced(_) = htlc.state {
Expand Down
4 changes: 2 additions & 2 deletions lightning/src/ln/channelmanager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15199,7 +15199,7 @@ where
number_of_funded_channels += peer_state.channel_by_id
.values()
.filter_map(Channel::as_funded)
.filter(|chan| chan.context.is_funding_broadcast())
.filter(|chan| chan.context.can_resume_on_restart())
.count();
}

Expand All @@ -15211,7 +15211,7 @@ where
for channel in peer_state.channel_by_id
.values()
.filter_map(Channel::as_funded)
.filter(|channel| channel.context.is_funding_broadcast())
.filter(|channel| channel.context.can_resume_on_restart())
{
channel.write(writer)?;
}
Expand Down
Loading