Skip to content

Add persistent closed channel history and list_closed_channels()#882

Open
f3r10 wants to merge 4 commits into
lightningdevkit:mainfrom
f3r10:feat/persist_historical_channels
Open

Add persistent closed channel history and list_closed_channels()#882
f3r10 wants to merge 4 commits into
lightningdevkit:mainfrom
f3r10:feat/persist_historical_channels

Conversation

@f3r10
Copy link
Copy Markdown
Contributor

@f3r10 f3r10 commented Apr 20, 2026

Summary

Closes #851.

Adds persistent historical closed channel records so applications can query all channels that have ever closed on this node, surviving restarts.

New public API

  • Node::list_closed_channels() -> Vec<ClosedChannelDetails>
  • ClosedChannelDetails struct: channel_id, user_channel_id,
    counterparty_node_id, funding_txo, channel_capacity_sats,
    last_local_balance_msat, is_outbound, closure_reason, closed_at
  • Event::ChannelClosed gains three new fields: channel_capacity_sats,
    channel_funding_txo, last_local_balance_msat

Implementation

Persistence (src/closed_channel.rs, src/io/): ClosedChannelDetails implements StorableObject with insert-only semantics (closed records are immutable). Records are stored under the "closed_channels" KV namespace keyed by UserChannelId.

Startup (src/builder.rs): read_closed_channels() joins the existing tokio::join! block so historical records are loaded in parallel with payments, pending payments, and node metrics — no added sequential startup cost as history gronws.

Event handlinng (src/event.rs): On ChannelClosed, the handler writes a ClosedChannelDetails record to the store and emits the enriched public event. Channel direction (is_outbound) is tracked via an in-memory outbound_channel_ids set, seeded from channel_manager.list_channels() at startup and updated on ChannelPending, since ChannelClosed does not carry that information directly.

@ldk-reviews-bot
Copy link
Copy Markdown

ldk-reviews-bot commented Apr 20, 2026

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

@ldk-reviews-bot ldk-reviews-bot requested a review from tnull April 20, 2026 16:42
Copy link
Copy Markdown
Contributor

@Camillarhi Camillarhi left a comment

Choose a reason for hiding this comment

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

Thanks for looking into this. I dropped some comments. Also, CI seems to be failing due to a formatting issue. Kindly run cargo fmt --all to resolve this

Comment thread src/closed_channel.rs Outdated
closed_at: closed_at.0.ok_or(DecodeError::InvalidValue)?,
})
}
}
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.

Can we use impl_writeable_tlv_based! here instead? Matches the rest of the StorableObject types in the codebase

Comment thread src/lib.rs
/// Retrieve a list of closed channels.
///
/// Channels are added to this list when they are closed and will be persisted across restarts.
pub fn list_closed_channels(&self) -> Vec<ClosedChannelDetails> {
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 new list_closed_channels() API isn't exposed in bindings.

Comment thread src/closed_channel.rs
/// [`Node::list_closed_channels`]: crate::Node::list_closed_channels
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct ClosedChannelDetails {
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.

ClosedChannelDetails isn't exposed in bindings

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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
Copy Markdown

🔔 2nd Reminder

Hey @tnull! 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
Copy Markdown

🔔 3rd Reminder

Hey @tnull! 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
Copy Markdown
Collaborator

@tnull tnull left a comment

Choose a reason for hiding this comment

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

Thanks, already looks pretty good. Some comments.

Comment thread src/io/utils.rs
let mut set = tokio::task::JoinSet::new();

// Fill JoinSet with tasks if possible
while set.len() < BATCH_SIZE && !stored_keys.is_empty() {
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.

Makes sense to DRY this up for now, but depending on when #876 lands we might need to adapt this to use the new BatchingStore.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

rebased to use the latest read_all_objects

Comment thread tests/integration_tests_rust.rs Outdated
assert_eq!(record.funding_txo.unwrap().txid, funding_txo.txid);
assert!(record.closure_reason.is_some());

closed_channel_before = record.clone();
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.

nit: Why do we clone here?

node_b.sync_wallets().unwrap();

// Verify the record is present before restart.
let closed_a = node_a.list_closed_channels();
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.

Should we also check that b also persisted the closed channel?

Comment thread src/event.rs Outdated
@@ -252,6 +255,18 @@ pub enum Event {
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.

I think we should be good breaking backwards compat with LDK node v0.1, so let's make this a required field. Then we can also avoid having the Option propagate. Please add a not regarding serialization compatibility as part of a # Pending section in CHANGELOG.md

Comment thread src/closed_channel.rs
/// [`Node::list_closed_channels`]: crate::Node::list_closed_channels
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct ClosedChannelDetails {
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.

Might be good also recording whether this was an announced channel.

@f3r10 f3r10 force-pushed the feat/persist_historical_channels branch 3 times, most recently from ac11095 to 20b75bb Compare May 13, 2026 18:55
@f3r10 f3r10 requested review from Camillarhi and tnull May 13, 2026 18:55
f3r10 added 2 commits May 13, 2026 14:43
Introduce `ClosedChannelDetails`, a new record type persisted to the KV
store under the `"closed_channels"` namespace whenever a channel closes.
Records are written in the `ChannelClosed` event handler and loaded back
at startup in parallel with other stores via `tokio::join!`.

Add `Node::list_closed_channels()` to expose the full history of closed
channels across restarts.

 Track outbound channel direction via an in-memory `outbound_channel_ids`
set seeded from `channel_manager.list_channels()` at startup and updated
on `ChannelPending` events, since `ChannelClosed` does not carry that
information directly.
…sed event

We no longer need to maintain backwards compatibility with LDK Node
v0.1.0, so we can make this a required field and avoid propagating
the Option through the API.
@f3r10 f3r10 force-pushed the feat/persist_historical_channels branch from 20b75bb to 1960f50 Compare May 13, 2026 19:43
Copy link
Copy Markdown
Contributor

@Camillarhi Camillarhi left a comment

Choose a reason for hiding this comment

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

Thanks! I left a few more comments.

Comment thread src/closed_channel.rs
/// This will be `false` for channels opened prior to this field being tracked.
pub is_announced: bool,
/// The reason for the channel closure.
pub closure_reason: Option<ClosureReason>,
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.

Is there a reason closure_reason is Option<ClosureReason>? The LDK reason on ChannelClosed isn't optional and we always wrap it in Some at write time, so I don't think we can ever persist a None. Could it just be ClosureReason?

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.

This seems valid. More generally, for all optional fields here, please add a note to the docs describing when/from which version they can be expected to be set.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

As I understand, ClosureReason is an evolving enum — LDK could add new variants over time.
If we used required + a plain ClosureReason field, an unknown future variant would cause read_all_objects to return an error, and the node would fail to start 🤔
wdyt @Camillarhi ?

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.

As I understand, ClosureReason is an evolving enum — LDK could add new variants over time. If we used required + a plain ClosureReason field, an unknown future variant would cause read_all_objects to return an error, and the node would fail to start 🤔 wdyt @Camillarhi ?

Hmm, no, LDK's compatibility guarantees will protect us from that. Upstream we'd add new fields only as optional/odd usually, to ensure we never break serialization compatibility.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

oh ok thanks @Camillarhi. I am going to remove the Option there

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@Camillarhi I tried to make the change, but it looks like ClosureReason intentionally does not implement Readable only MaybeReadable, and impl_writeable_tlv_based! returns an error 🤔

Comment thread src/event.rs
Comment on lines +1690 to +1691
counterparty_node_id: counterparty_node_id
.expect("counterparty_node_id must be set for closed channels"),
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.

This could panic as the counterparty_node_id from LDK is Option<Publickey>

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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
Copy Markdown

🔔 2nd Reminder

Hey @tnull! 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
Copy Markdown

🔔 3rd Reminder

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

@jkczyz jkczyz mentioned this pull request May 21, 2026
Copy link
Copy Markdown
Collaborator

@tnull tnull left a comment

Choose a reason for hiding this comment

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

Feel free to squash. Two minor comments, and then we should move forward with this.

Comment thread src/event.rs
user_channel_id,
counterparty_node_id,
counterparty_node_id: counterparty_node_id
.expect("counterparty_node_id must be set for closed channels"),
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.

Please provide a reason why this expectation always holds (i.e. since which version of LDK or LDK node this is the case).

Copy link
Copy Markdown
Contributor Author

@f3r10 f3r10 May 21, 2026

Choose a reason for hiding this comment

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

@tnull I added a changelog entry about this change https://github.com/lightningdevkit/ldk-node/pull/882/changes#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4edR3
The `counterparty_node_id` field of the `ChannelReady` and `ChannelClosed` events is now required. Events persisted by LDK Node v0.1.0 and prior that are missing this field will fail to deserialize.
But since LDK can return None for early-lifecycle close, maybe the right approach would be: revert Event::ChannelClosed::counterparty_node_id back to Option, remove the .expect(), and remove the CHANGELOG entry. Should I make those changes?

Copy link
Copy Markdown
Collaborator

@tnull tnull May 22, 2026

Choose a reason for hiding this comment

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

No, breaking backwards compat with LDK Node v0.1 is fine, we should just (very briefly) also mention in the except that that is what's happening (i.e., that >v0.1 this is expected to hold true).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great, I am going to add that note

Comment thread src/closed_channel.rs
/// This will be `false` for channels opened prior to this field being tracked.
pub is_announced: bool,
/// The reason for the channel closure.
pub closure_reason: Option<ClosureReason>,
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.

This seems valid. More generally, for all optional fields here, please add a note to the docs describing when/from which version they can be expected to be set.

Comment thread src/event.rs Outdated

let event = Event::ChannelClosed {
let user_channel_id = UserChannelId(user_channel_id);
let is_outbound = self
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.

Codex:

  • [P2] Persist channel flags for replayed closures — /home/tnull/workspace/ldk-node/src/event.rs:1646-1655
    When a ChannelClosed LDK event is replayed after a restart or after handle_event returns ReplayEvent, the channel can already be gone from list_channels() and these process-local sets are not reconstructed. In that case .remove() returns false, so an outbound/public channel is persisted in
    ClosedChannelDetails as inbound/unannounced; a failed first attempt also consumes the entries before replay. Store these flags durably before closure or make this path idempotent.

Comment thread src/event.rs Outdated
closed_at,
};

if let Err(e) = self.closed_channel_store.insert(record) {
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.

Codex:

  • [P2] Replay ChannelClosed when history persistence fails — /home/tnull/workspace/ldk-node/src/event.rs:1678-1685
    If closed_channel_store.insert(record) fails but event_queue.add_event succeeds, this only logs the error and eventually returns Ok(()), so LDK considers the ChannelClosed handled and will not replay it. The user still sees the close event, but list_closed_channels() permanently misses the
    record; propagate this failure as ReplayEvent like other persisted event side effects.

f3r10 added 2 commits May 22, 2026 11:16
Fall back to the already-persisted record for is_outbound/is_announced
when in-memory sets are empty on replay, use insert_or_update to avoid
overwriting correct values, and propagate persist failures as ReplayEvent.
@f3r10 f3r10 requested a review from tnull May 22, 2026 16:19
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.

Persist historical channels

4 participants