Skip to content

Commit 47be000

Browse files
committed
Add persistent closed channel history and list_closed_channels()
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.
1 parent 109978d commit 47be000

9 files changed

Lines changed: 390 additions & 17 deletions

File tree

bindings/ldk_node.udl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ interface Node {
137137
sequence<PaymentDetails> list_payments();
138138
sequence<PeerDetails> list_peers();
139139
sequence<ChannelDetails> list_channels();
140+
sequence<ClosedChannelDetails> list_closed_channels();
140141
NetworkGraph network_graph();
141142
string sign_message([ByRef]sequence<u8> msg);
142143
boolean verify_signature([ByRef]sequence<u8> msg, [ByRef]string sig, [ByRef]PublicKey pkey);
@@ -319,6 +320,8 @@ dictionary OutPoint {
319320

320321
typedef dictionary ChannelDetails;
321322

323+
typedef dictionary ClosedChannelDetails;
324+
322325
typedef dictionary PeerDetails;
323326

324327
[Remote]

src/builder.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ use crate::io::utils::{
6464
};
6565
use crate::io::vss_store::VssStoreBuilder;
6666
use crate::io::{
67-
self, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
67+
self, CLOSED_CHANNEL_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
68+
CLOSED_CHANNEL_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
69+
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
6870
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
6971
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
7072
};
@@ -79,9 +81,9 @@ use crate::peer_store::PeerStore;
7981
use crate::runtime::{Runtime, RuntimeSpawner};
8082
use crate::tx_broadcaster::TransactionBroadcaster;
8183
use crate::types::{
82-
AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreRef, DynStoreWrapper,
83-
GossipSync, Graph, HRNResolver, KeysManager, MessageRouter, OnionMessenger, PaymentStore,
84-
PeerManager, PendingPaymentStore, SyncAndAsyncKVStore,
84+
AsyncPersister, ChainMonitor, ChannelManager, ClosedChannelStore, DynStore, DynStoreRef,
85+
DynStoreWrapper, GossipSync, Graph, HRNResolver, KeysManager, MessageRouter, OnionMessenger,
86+
PaymentStore, PeerManager, PendingPaymentStore, SyncAndAsyncKVStore,
8587
};
8688
use crate::wallet::persist::KVStoreWalletPersister;
8789
use crate::wallet::Wallet;
@@ -1288,7 +1290,7 @@ fn build_with_store_internal(
12881290

12891291
let kv_store_ref = Arc::clone(&kv_store);
12901292
let logger_ref = Arc::clone(&logger);
1291-
let (payment_store_res, node_metris_res, pending_payment_store_res) =
1293+
let (payment_store_res, node_metris_res, pending_payment_store_res, closed_channel_store_res) =
12921294
runtime.block_on(async move {
12931295
tokio::join!(
12941296
read_all_objects(
@@ -1303,6 +1305,12 @@ fn build_with_store_internal(
13031305
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
13041306
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
13051307
Arc::clone(&logger_ref),
1308+
),
1309+
read_all_objects(
1310+
&*kv_store_ref,
1311+
CLOSED_CHANNEL_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1312+
CLOSED_CHANNEL_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1313+
Arc::clone(&logger_ref),
13061314
)
13071315
)
13081316
});
@@ -1334,6 +1342,20 @@ fn build_with_store_internal(
13341342
},
13351343
};
13361344

1345+
let closed_channel_store = match closed_channel_store_res {
1346+
Ok(channels) => Arc::new(ClosedChannelStore::new(
1347+
channels,
1348+
CLOSED_CHANNEL_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
1349+
CLOSED_CHANNEL_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
1350+
Arc::clone(&kv_store),
1351+
Arc::clone(&logger),
1352+
)),
1353+
Err(e) => {
1354+
log_error!(logger, "Failed to read closed channel data from store: {}", e);
1355+
return Err(BuildError::ReadFailed);
1356+
},
1357+
};
1358+
13371359
let (chain_source, chain_tip_opt) = match chain_data_source_config {
13381360
Some(ChainDataSourceConfig::Esplora { server_url, headers, sync_config }) => {
13391361
let sync_config = sync_config.unwrap_or(EsploraSyncConfig::default());
@@ -2063,6 +2085,7 @@ fn build_with_store_internal(
20632085
scorer,
20642086
peer_store,
20652087
payment_store,
2088+
closed_channel_store,
20662089
lnurl_auth,
20672090
is_running,
20682091
node_metrics,

src/closed_channel.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// This file is Copyright its original authors, visible in version control history.
2+
//
3+
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4+
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
5+
// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
6+
// accordance with one or both of these licenses.
7+
8+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
9+
10+
use bitcoin::secp256k1::PublicKey;
11+
use bitcoin::OutPoint;
12+
use lightning::events::ClosureReason;
13+
use lightning::impl_writeable_tlv_based;
14+
use lightning::ln::types::ChannelId;
15+
16+
use crate::data_store::{StorableObject, StorableObjectId, StorableObjectUpdate};
17+
use crate::hex_utils;
18+
use crate::types::UserChannelId;
19+
20+
/// Details of a closed channel.
21+
///
22+
/// Returned by [`Node::list_closed_channels`].
23+
///
24+
/// [`Node::list_closed_channels`]: crate::Node::list_closed_channels
25+
#[derive(Clone, Debug, PartialEq, Eq)]
26+
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
27+
pub struct ClosedChannelDetails {
28+
/// The channel's ID at the time it was closed.
29+
pub channel_id: ChannelId,
30+
/// The local identifier of the channel.
31+
pub user_channel_id: UserChannelId,
32+
/// The node ID of the channel's counterparty.
33+
pub counterparty_node_id: Option<PublicKey>,
34+
/// The channel's funding transaction outpoint.
35+
pub funding_txo: Option<OutPoint>,
36+
/// The channel's capacity in satoshis.
37+
pub channel_capacity_sats: Option<u64>,
38+
/// Our local balance in millisatoshis at the time of channel closure.
39+
pub last_local_balance_msat: Option<u64>,
40+
/// Indicates whether we initiated the channel opening.
41+
///
42+
/// `true` if the channel was opened by us (outbound), `false` if opened by the counterparty
43+
/// (inbound). This will be `false` for channels opened prior to this field being tracked.
44+
pub is_outbound: bool,
45+
/// Indicates whether the channel was publicly announced.
46+
///
47+
/// This will be `false` for channels opened prior to this field being tracked.
48+
pub is_announced: bool,
49+
/// The reason for the channel closure.
50+
pub closure_reason: Option<ClosureReason>,
51+
/// The timestamp, in seconds since start of the UNIX epoch, when the channel was closed.
52+
pub closed_at: u64,
53+
}
54+
55+
impl_writeable_tlv_based!(ClosedChannelDetails, {
56+
(0, channel_id, required),
57+
(2, user_channel_id, required),
58+
(4, counterparty_node_id, option),
59+
(6, funding_txo, option),
60+
(8, channel_capacity_sats, option),
61+
(10, last_local_balance_msat, option),
62+
(12, is_outbound, required),
63+
(14, closure_reason, upgradable_option),
64+
(16, closed_at, (default_value, SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::from_secs(0)).as_secs())),
65+
(18, is_announced, required),
66+
});
67+
68+
pub(crate) struct ClosedChannelDetailsUpdate(pub UserChannelId);
69+
70+
impl StorableObjectUpdate<ClosedChannelDetails> for ClosedChannelDetailsUpdate {
71+
fn id(&self) -> UserChannelId {
72+
self.0
73+
}
74+
}
75+
76+
impl StorableObject for ClosedChannelDetails {
77+
type Id = UserChannelId;
78+
type Update = ClosedChannelDetailsUpdate;
79+
80+
fn id(&self) -> UserChannelId {
81+
self.user_channel_id
82+
}
83+
84+
fn update(&mut self, _update: Self::Update) -> bool {
85+
// Closed channel records are immutable once written.
86+
false
87+
}
88+
89+
fn to_update(&self) -> Self::Update {
90+
ClosedChannelDetailsUpdate(self.user_channel_id)
91+
}
92+
}
93+
94+
impl StorableObjectId for UserChannelId {
95+
fn encode_to_hex_str(&self) -> String {
96+
hex_utils::to_string(&self.0.to_be_bytes())
97+
}
98+
}

src/event.rs

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77

88
use core::future::Future;
99
use core::task::{Poll, Waker};
10-
use std::collections::VecDeque;
10+
use std::collections::{HashSet, VecDeque};
1111
use std::ops::Deref;
1212
use std::sync::{Arc, Mutex};
13+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
1314

1415
use bitcoin::blockdata::locktime::absolute::LockTime;
1516
use bitcoin::secp256k1::PublicKey;
@@ -33,6 +34,7 @@ use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum};
3334
use lightning_liquidity::lsps2::utils::compute_opening_fee;
3435
use lightning_types::payment::{PaymentHash, PaymentPreimage};
3536

37+
use crate::closed_channel::ClosedChannelDetails;
3638
use crate::config::{may_announce_channel, Config};
3739
use crate::connection::ConnectionManager;
3840
use crate::data_store::DataStoreUpdateResult;
@@ -52,7 +54,8 @@ use crate::payment::store::{
5254
};
5355
use crate::runtime::Runtime;
5456
use crate::types::{
55-
CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet,
57+
ClosedChannelStore, CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore,
58+
Sweeper, Wallet,
5659
};
5760
use crate::{
5861
hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore,
@@ -269,6 +272,18 @@ pub enum Event {
269272
counterparty_node_id: Option<PublicKey>,
270273
/// This will be `None` for events serialized by LDK Node v0.2.1 and prior.
271274
reason: Option<ClosureReason>,
275+
/// The channel's capacity in satoshis.
276+
///
277+
/// This will be `None` for events serialized by LDK Node v0.8.0 and prior.
278+
channel_capacity_sats: Option<u64>,
279+
/// The channel's funding transaction outpoint.
280+
///
281+
/// This will be `None` for events serialized by LDK Node v0.8.0 and prior.
282+
channel_funding_txo: Option<OutPoint>,
283+
/// Our local balance in millisatoshis at the time of channel closure.
284+
///
285+
/// This will be `None` for events serialized by LDK Node v0.8.0 and prior.
286+
last_local_balance_msat: Option<u64>,
272287
},
273288
/// A channel splice is pending confirmation on-chain.
274289
SplicePending {
@@ -331,6 +346,9 @@ impl_writeable_tlv_based_enum!(Event,
331346
(1, counterparty_node_id, option),
332347
(2, user_channel_id, required),
333348
(3, reason, upgradable_option),
349+
(5, channel_capacity_sats, option),
350+
(7, channel_funding_txo, option),
351+
(9, last_local_balance_msat, option),
334352
},
335353
(6, PaymentClaimable) => {
336354
(0, payment_hash, required),
@@ -536,6 +554,13 @@ where
536554
liquidity_source: Option<Arc<LiquiditySource<Arc<Logger>>>>,
537555
payment_store: Arc<PaymentStore>,
538556
peer_store: Arc<PeerStore<L>>,
557+
closed_channel_store: Arc<ClosedChannelStore>,
558+
// Tracks which user_channel_ids correspond to outbound channels. Populated at startup from
559+
// list_channels() and updated on ChannelPending events. Consumed on ChannelClosed events.
560+
outbound_channel_ids: Mutex<HashSet<UserChannelId>>,
561+
// Tracks which user_channel_ids correspond to announced channels. Populated at startup from
562+
// list_channels() and updated on ChannelPending events. Consumed on ChannelClosed events.
563+
announced_channel_ids: Mutex<HashSet<UserChannelId>>,
539564
keys_manager: Arc<KeysManager>,
540565
runtime: Arc<Runtime>,
541566
logger: L,
@@ -556,10 +581,27 @@ where
556581
output_sweeper: Arc<Sweeper>, network_graph: Arc<Graph>,
557582
liquidity_source: Option<Arc<LiquiditySource<Arc<Logger>>>>,
558583
payment_store: Arc<PaymentStore>, peer_store: Arc<PeerStore<L>>,
559-
keys_manager: Arc<KeysManager>, static_invoice_store: Option<StaticInvoiceStore>,
560-
onion_messenger: Arc<OnionMessenger>, om_mailbox: Option<Arc<OnionMessageMailbox>>,
561-
runtime: Arc<Runtime>, logger: L, config: Arc<Config>,
584+
closed_channel_store: Arc<ClosedChannelStore>, keys_manager: Arc<KeysManager>,
585+
static_invoice_store: Option<StaticInvoiceStore>, onion_messenger: Arc<OnionMessenger>,
586+
om_mailbox: Option<Arc<OnionMessageMailbox>>, runtime: Arc<Runtime>, logger: L,
587+
config: Arc<Config>,
562588
) -> Self {
589+
// Seed outbound_channel_ids and announced_channel_ids from currently open channels so we
590+
// correctly classify channels that were already open when this node started.
591+
let (outbound_channel_ids, announced_channel_ids) = {
592+
let mut outbound = HashSet::new();
593+
let mut announced = HashSet::new();
594+
for chan in channel_manager.list_channels() {
595+
if chan.is_outbound {
596+
outbound.insert(UserChannelId(chan.user_channel_id));
597+
}
598+
if chan.is_announced {
599+
announced.insert(UserChannelId(chan.user_channel_id));
600+
}
601+
}
602+
(Mutex::new(outbound), Mutex::new(announced))
603+
};
604+
563605
Self {
564606
event_queue,
565607
wallet,
@@ -571,6 +613,9 @@ where
571613
liquidity_source,
572614
payment_store,
573615
peer_store,
616+
closed_channel_store,
617+
outbound_channel_ids,
618+
announced_channel_ids,
574619
keys_manager,
575620
logger,
576621
runtime,
@@ -1506,6 +1551,20 @@ where
15061551
if let Some(pending_channel) =
15071552
channels.into_iter().find(|c| c.channel_id == channel_id)
15081553
{
1554+
if pending_channel.is_outbound {
1555+
self.outbound_channel_ids
1556+
.lock()
1557+
.expect("Lock poisoned")
1558+
.insert(UserChannelId(user_channel_id));
1559+
}
1560+
1561+
if pending_channel.is_announced {
1562+
self.announced_channel_ids
1563+
.lock()
1564+
.expect("Lock poisoned")
1565+
.insert(UserChannelId(user_channel_id));
1566+
}
1567+
15091568
if !pending_channel.is_outbound
15101569
&& self.peer_store.get_peer(&counterparty_node_id).is_none()
15111570
{
@@ -1581,15 +1640,62 @@ where
15811640
reason,
15821641
user_channel_id,
15831642
counterparty_node_id,
1584-
..
1643+
channel_capacity_sats,
1644+
channel_funding_txo,
1645+
last_local_balance_msat,
15851646
} => {
15861647
log_info!(self.logger, "Channel {} closed due to: {}", channel_id, reason);
15871648

1649+
let user_channel_id = UserChannelId(user_channel_id);
1650+
let is_outbound = self
1651+
.outbound_channel_ids
1652+
.lock()
1653+
.expect("Lock poisoned")
1654+
.remove(&user_channel_id);
1655+
let is_announced = self
1656+
.announced_channel_ids
1657+
.lock()
1658+
.expect("Lock poisoned")
1659+
.remove(&user_channel_id);
1660+
1661+
let closed_at = SystemTime::now()
1662+
.duration_since(UNIX_EPOCH)
1663+
.unwrap_or(Duration::ZERO)
1664+
.as_secs();
1665+
1666+
let funding_txo =
1667+
channel_funding_txo.map(|op| OutPoint { txid: op.txid, vout: op.index as u32 });
1668+
1669+
let record = ClosedChannelDetails {
1670+
channel_id,
1671+
user_channel_id,
1672+
counterparty_node_id,
1673+
funding_txo,
1674+
channel_capacity_sats,
1675+
last_local_balance_msat,
1676+
is_outbound,
1677+
is_announced,
1678+
closure_reason: Some(reason.clone()),
1679+
closed_at,
1680+
};
1681+
1682+
if let Err(e) = self.closed_channel_store.insert(record) {
1683+
log_error!(
1684+
self.logger,
1685+
"Failed to persist closed channel {}: {}",
1686+
channel_id,
1687+
e
1688+
);
1689+
}
1690+
15881691
let event = Event::ChannelClosed {
15891692
channel_id,
1590-
user_channel_id: UserChannelId(user_channel_id),
1693+
user_channel_id,
15911694
counterparty_node_id,
15921695
reason: Some(reason),
1696+
channel_capacity_sats,
1697+
channel_funding_txo: funding_txo,
1698+
last_local_balance_msat,
15931699
};
15941700

15951701
match self.event_queue.add_event(event).await {

0 commit comments

Comments
 (0)