Skip to content

Add persistent closed channel history and list_closed_channels()#882

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

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

Conversation

@f3r10

@f3r10 f3r10 commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

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 struct: channel_id, user_channel_id, counterparty_node_id (non-optional — always set for channels closed on LDK Node v0.2+), funding_txo, channel_capacity_sats, last_local_balance_msat, is_outbound, is_announced, closure_reason, closed_at
  • Event::ChannelClosed gains three new fields: channel_capacity_sats, channel_funding_txo, last_local_balance_msat

Implementation

Closed channel persistence (src/closed_channel.rs, src/io/): ClosedChannelDetails implements StorableObject with insert_or_update semantics (closed records are immutable — update() always returns false). Records are stored under the closed_channels KV namespace keyed by UserChannelId. insert_or_update makes the ChannelClosed handler idempotent: if the event is replayed after a crash between persistence and add_event, the second write is a no-op and the event goes throuh cleanly. Failure to persist returns ReplayEvent so LDK retries.

Per-channel state store (src/channel/store.rs, new): Introduces a ChannelRecord enum with a single Funded variant that durably captures counterparty_node_id, channel_id, is_outbound, and is_announced at ChannelPending time (before funding confirms). Records are stored under the channel_records KV namespace and removed once ChannelClosed is fully processed. This is more robust than a purely in-memory approach: if the node crashes between ChannelPending and ChannelClosed, the flags survive the restart. The enum design is intentionally extensible — a future pending_splice field can be added as a new optional TLV without breking existing records (motivated by the planned splice-retry support).

ChannelClosed lookup chain: is_outbound and is_announced are resolved with a priority fallback: (1) channel_record_store.get() — the durable record written at ChannelPending; (2) closed_channel_store.get() — handles the replay case where insert_or_update already succeeded but add_event failed and the channel record was alreaddy removed; (3) in-memory outbound_channel_ids / announced_channel_ids sets — fallback for channels opened before this upgrade.

Startup (src/builder.rs): Loading closed channel records and channel records joins the existing tokio::join! block alongside payments, pending payments, and node metrics — no added sequential startup cost as history grows.

Serialization compatibility: counterparty_node_id on ChannelReady and ChannelClosed events is now a required field. Events persisted by LDK Node v0.1.0 and prior that are missing this field will fail to deserialize. closed_at in ClosedChannelDetails is also required (no default_value fallback) since there are no pre-existing records to migrate.

@ldk-reviews-bot

ldk-reviews-bot commented Apr 20, 2026

Copy link
Copy Markdown

👋 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

@Camillarhi Camillarhi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@tnull tnull left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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
@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 f3r10 force-pushed the feat/persist_historical_channels branch from 20b75bb to 1960f50 Compare May 13, 2026 19:43

@Camillarhi Camillarhi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks! I left a few more comments.

Comment thread src/closed_channel.rs
Comment thread src/event.rs Outdated
@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

@tnull tnull left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Comment thread src/event.rs Outdated
Comment thread src/closed_channel.rs
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
@f3r10 f3r10 requested a review from tnull May 22, 2026 16:19
@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.

@ldk-reviews-bot

Copy link
Copy Markdown

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

@tnull tnull left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Excuse the delay here! Generally looks good mod some minor comments.

The only more fundamental question is https://github.com/lightningdevkit/ldk-node/pull/882/changes#r3340832531 which we probably should clarify before moving on.

Comment thread src/closed_channel.rs
Comment thread src/closed_channel.rs Outdated
Comment on lines +34 to +35
/// Will be `None` if the channel was closed before the counterparty's node ID could be
/// determined (e.g., very early in the channel negotiation process).

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.

Hmm, is this correct? Wouldn't we always get a ChannelClosed event that by now has the required field?

Comment thread src/closed_channel.rs Outdated
(10, last_local_balance_msat, option),
(12, is_outbound, required),
(14, closure_reason, upgradable_option),
(16, closed_at, (default_value, SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::from_secs(0)).as_secs())),

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.

Hmm, given we don't have any backwards compat. issues, let's just make this a required field and set the value upon initialization rather than via default_value?

Comment thread src/event.rs Outdated
.as_secs();

let funding_txo =
channel_funding_txo.map(|op| OutPoint { txid: op.txid, vout: op.index as u32 });

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: Should be able to use into_bitcoin_outpoint here.

Comment thread src/event.rs
// the channel is no longer in list_channels() and the in-memory sets are
// not repopulated for it, so .remove() returns false. Fall back to any
// already-persisted record so we don't overwrite correct values with false.
let (is_outbound, is_announced) = 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.

IIUC, this will fall back to (false, false) in case of a replayed event, leading to inaccurate information. This makes we wonder if we don't need to start storing channel metadata generally (i.e., prior to close), not only once it's already gone and we struggle to maintain access to all necessary information?

@f3r10 f3r10 Jun 3, 2026

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.

🤔 I have been thinking about this problem, and you're right, and the correct fix is the durable approach: persisting the flags at ChannelPending time.

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

From the previous review of codex, I chose the idempotent path because it was quicker to implement. The insert_or_update + fallback change covers the restart replay case where a prior insert did succeed, but it doesn't fully solve the problem.

I can try the durable approach but it looks more difficult and would take more time. Should I make this change on this PR or open a follow-up issue? wdyt @tnull ?

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.

We're not super pressed for time here, so it would be good to do it in one PR. Given it's basically a pretty different approach you could even consider whether to open it as an alternative PR.

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.

ok cool, I am going to start with the durable approach and let you know when is ready

@jkczyz jkczyz Jun 10, 2026

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.

During Monday's dev sync, @tnull suggested that this storage could be generalized further to hold channel splice intents.

Essentially, when a user initiates a splice, LDK needs to reach quiescence with the counterparty, negotiated and sign the new funding transaction, and wait for the splice to lock. If we restart before signatures are exchanged, we need to retry the splice. Similar for v2 opens, but we don't support those yet.

I had Claude plan this out to work with this PR. We'd store a ChannelRecord that has three variants (Pending, Funded and Closed) where the Funded variant can hold a SpliceIntent. Something like this:

enum ChannelRecord {                     // src/...; StorableObject, Id = UserChannelId
    /// Channel being established — holds the open/funding intent.        [opens — later]
    Unfunded { counterparty_node_id: PublicKey, intent: OpenIntent },
    /// Channel live — holds an in-flight splice intent, if any.
    Funded {
        counterparty_node_id: PublicKey,
        channel_id: ChannelId,
        pending_splice: Option<SpliceIntent>,                            // [this PR]
        // is_outbound / funding_txo / capacity_sats / is_announced fold in with #882
    },
    /// Channel closed — #882's immutable snapshot.                        [#882 — later]
    Closed { /* channel_id, funding_txo, capacity_sats, is_outbound, is_announced, reason, closed_at */ },
}

struct SpliceIntent {
    pre_splice_funding_txo: OutPoint,    // completion discriminator — changes only when the splice locks
    contribution: FundingContribution,   // re-fed → funding_contributed(.., None)
    kind: SpliceKind,                    // user params — rebuilds a fresh contribution when the stored one is stale
    attempts: u8,
}
enum SpliceKind { In { amount_sats: u64 }, Out { outputs: Vec<TxOut> }, Rbf }
// `kind` serves two purposes: Rbf changes the probe interpretation (prior `Some` is *expected*),
// and In/Out carry the params needed to rebuild a contribution via a fresh splice_channel template.
// enum OpenIntent { V1 { amount_sats, push_msat, config, announce, zero_reserve },  // → create_channel  [later]
//                   V2 { contribution: FundingContribution } }                       // → funding_contributed  [later]

@f3r10 Would this be sufficient to cover your use cases?

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.

Here's the draft implementation: 492bd32

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 great thanks @jkczyz I am going to check the draft and push a commit

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.

ready @jkczyz 2c7e16f
Let me know if something is missing

@f3r10 f3r10 force-pushed the feat/persist_historical_channels branch from 2c7e16f to f532dca Compare June 19, 2026 17:25
f3r10 added 3 commits June 19, 2026 12:45
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.
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 added 3 commits June 19, 2026 12:47
…s()`

Replace the in-memory set + `insert_or_update` fallback approach with a
fully durable solution for tracking `is_outbound`/`is_announced` flags.

The previous approach stored these flags in ephemeral `HashSet`s that were
seeded from `list_channels()` at startup and consumed on `ChannelClosed`.
A fallback to any existing `ClosedChannelDetails` record was added to
handle `ReplayEvent`, but a gap remained: if `insert_or_update` failed
(returning `ReplayEvent`) and the node restarted before the retry, the
in-memory sets would be empty (closed channels don't appear in
`list_channels()`) and no persisted record would exist yet, causing both
flags to silently default to `falsee`.

Fix this by persisting a `PendingChannelInfo` record (containing
`is_outbound` and `is_announced`) to the KV store at `ChannelPending`
time under a new `pending_channels/` namespace. The `ChannelClosed`
handler now resolves the flags with the following priority:

  1. `pending_channel_store` — durable, survives restarts and replays
  2. In-memory sets — covers channels opened before this version
  3. Existing `ClosedChannelDetails` record — idempotency guard

The `PendingChannelInfo` record is deleted after `event_queue.add_event`
succeeds. It is intentionally kept alive until that point so that any
replay of `ChannelClosed` (e.g. due to a failed `insert_or_update` or
`add_event`) still finds the correct flags in the store.
…s()`

Generalize PendingChannelInfo into ChannelRecord::Funded.
Rather than shipping two separate per-channel stores, introduce a
ChannelRecord enum with a single Funded variant that already carries the
fields the splice PR will need (counterparty_node_id, channel_id) in
addition to is_outbound/is_announced flags. This lets the splice PR add
pending_splice: Option<SpliceIntent> as a new optional TLV field without
touching the store infrastructure.
@f3r10 f3r10 force-pushed the feat/persist_historical_channels branch from f532dca to 1ff7639 Compare June 19, 2026 18:18
@f3r10 f3r10 requested review from Camillarhi, jkczyz and tnull June 19, 2026 18:21
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

5 participants