Skip to content

Commit c3f7714

Browse files
benthecarmanclaude
andcommitted
Add per-channel forwarding statistics
Track aggregate stats (fees earned, payment counts, amounts) per channel. Add ForwardedPaymentTrackingMode config: Stats (default) for lightweight metrics only, or Detailed to also store individual payment records. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 06aee72 commit c3f7714

File tree

11 files changed

+408
-47
lines changed

11 files changed

+408
-47
lines changed

bindings/ldk_node.udl

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dictionary Config {
1313
u64 probing_liquidity_limit_multiplier;
1414
AnchorChannelsConfig? anchor_channels_config;
1515
RouteParametersConfig? route_parameters;
16+
ForwardedPaymentTrackingMode forwarded_payment_tracking_mode;
1617
};
1718

1819
dictionary AnchorChannelsConfig {
@@ -191,6 +192,9 @@ interface Node {
191192
sequence<PaymentDetails> list_payments();
192193
ForwardedPaymentDetails? forwarded_payment([ByRef]ForwardedPaymentId forwarded_payment_id);
193194
sequence<ForwardedPaymentDetails> list_forwarded_payments();
195+
ForwardedPaymentTrackingMode forwarded_payment_tracking_mode();
196+
ChannelForwardingStats? channel_forwarding_stats([ByRef]ChannelId channel_id);
197+
sequence<ChannelForwardingStats> list_channel_forwarding_stats();
194198
sequence<PeerDetails> list_peers();
195199
sequence<ChannelDetails> list_channels();
196200
NetworkGraph network_graph();
@@ -488,6 +492,11 @@ enum PaymentStatus {
488492
"Failed",
489493
};
490494

495+
enum ForwardedPaymentTrackingMode {
496+
"Detailed",
497+
"Stats",
498+
};
499+
491500
dictionary LSPFeeLimits {
492501
u64? max_total_opening_fee_msat;
493502
u64? max_proportional_opening_fee_ppm_msat;
@@ -524,6 +533,20 @@ dictionary ForwardedPaymentDetails {
524533
u64 forwarded_at_timestamp;
525534
};
526535

536+
dictionary ChannelForwardingStats {
537+
ChannelId channel_id;
538+
PublicKey? counterparty_node_id;
539+
u64 inbound_payments_forwarded;
540+
u64 outbound_payments_forwarded;
541+
u64 total_inbound_amount_msat;
542+
u64 total_outbound_amount_msat;
543+
u64 total_fee_earned_msat;
544+
u64 total_skimmed_fee_msat;
545+
u64 onchain_claims_count;
546+
u64 first_forwarded_at_timestamp;
547+
u64 last_forwarded_at_timestamp;
548+
};
549+
527550
dictionary RouteParametersConfig {
528551
u64? max_total_routing_fee_msat;
529552
u32 max_total_cltv_expiry_delta;

src/builder.rs

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,15 @@ use crate::fee_estimator::OnchainFeeEstimator;
5555
use crate::gossip::GossipSource;
5656
use crate::io::sqlite_store::SqliteStore;
5757
use crate::io::utils::{
58-
read_event_queue, read_external_pathfinding_scores_from_cache, read_forwarded_payments,
59-
read_network_graph, read_node_metrics, read_output_sweeper, read_payments, read_peer_info,
60-
read_pending_payments, read_scorer, write_node_metrics,
58+
read_channel_forwarding_stats, read_event_queue, read_external_pathfinding_scores_from_cache,
59+
read_forwarded_payments, read_network_graph, read_node_metrics, read_output_sweeper,
60+
read_payments, read_peer_info, read_pending_payments, read_scorer, write_node_metrics,
6161
};
6262
use crate::io::vss_store::VssStoreBuilder;
6363
use crate::io::{
64-
self, FORWARDED_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
64+
self, CHANNEL_FORWARDING_STATS_PERSISTENCE_PRIMARY_NAMESPACE,
65+
CHANNEL_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE,
66+
FORWARDED_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
6567
FORWARDED_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
6668
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
6769
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
@@ -77,9 +79,10 @@ use crate::peer_store::PeerStore;
7779
use crate::runtime::{Runtime, RuntimeSpawner};
7880
use crate::tx_broadcaster::TransactionBroadcaster;
7981
use crate::types::{
80-
AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreWrapper, ForwardedPaymentStore,
81-
GossipSync, Graph, KeysManager, MessageRouter, OnionMessenger, PaymentStore, PeerManager,
82-
PendingPaymentStore, Persister, SyncAndAsyncKVStore,
82+
AsyncPersister, ChainMonitor, ChannelForwardingStatsStore, ChannelManager, DynStore,
83+
DynStoreWrapper, ForwardedPaymentStore, GossipSync, Graph, KeysManager, MessageRouter,
84+
OnionMessenger, PaymentStore, PeerManager, PendingPaymentStore, Persister,
85+
SyncAndAsyncKVStore,
8386
};
8487
use crate::wallet::persist::KVStoreWalletPersister;
8588
use crate::wallet::Wallet;
@@ -1065,12 +1068,14 @@ fn build_with_store_internal(
10651068
let (
10661069
payment_store_res,
10671070
forwarded_payment_store_res,
1071+
channel_forwarding_stats_res,
10681072
node_metris_res,
10691073
pending_payment_store_res,
10701074
) = runtime.block_on(async move {
10711075
tokio::join!(
10721076
read_payments(&*kv_store_ref, Arc::clone(&logger_ref)),
10731077
read_forwarded_payments(&*kv_store_ref, Arc::clone(&logger_ref)),
1078+
read_channel_forwarding_stats(&*kv_store_ref, Arc::clone(&logger_ref)),
10741079
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
10751080
read_pending_payments(&*kv_store_ref, Arc::clone(&logger_ref))
10761081
)
@@ -1117,6 +1122,20 @@ fn build_with_store_internal(
11171122
},
11181123
};
11191124

1125+
let channel_forwarding_stats_store = match channel_forwarding_stats_res {
1126+
Ok(stats) => Arc::new(ChannelForwardingStatsStore::new(
1127+
stats,
1128+
CHANNEL_FORWARDING_STATS_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
1129+
CHANNEL_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
1130+
Arc::clone(&kv_store),
1131+
Arc::clone(&logger),
1132+
)),
1133+
Err(e) => {
1134+
log_error!(logger, "Failed to read channel forwarding stats from store: {}", e);
1135+
return Err(BuildError::ReadFailed);
1136+
},
1137+
};
1138+
11201139
let (chain_source, chain_tip_opt) = match chain_data_source_config {
11211140
Some(ChainDataSourceConfig::Esplora { server_url, headers, sync_config }) => {
11221141
let sync_config = sync_config.unwrap_or(EsploraSyncConfig::default());
@@ -1804,6 +1823,7 @@ fn build_with_store_internal(
18041823
peer_store,
18051824
payment_store,
18061825
forwarded_payment_store,
1826+
channel_forwarding_stats_store,
18071827
is_running,
18081828
node_metrics,
18091829
om_mailbox,

src/config.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@ use lightning::util::config::{
2121

2222
use crate::logger::LogLevel;
2323

24+
/// The mode used for tracking forwarded payments.
25+
///
26+
/// This determines how much detail is stored about payment forwarding activity.
27+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28+
pub enum ForwardedPaymentTrackingMode {
29+
/// Store every individual forwarded payment AND track per-channel aggregate statistics.
30+
///
31+
/// Use this when you need full history of forwarded payments for accounting, debugging,
32+
/// or detailed analytics.
33+
Detailed,
34+
/// Track only per-channel aggregate statistics without storing individual payment records.
35+
///
36+
/// This is the default mode. Use this to reduce storage requirements when you only need
37+
/// aggregate metrics like total fees earned per channel.
38+
#[default]
39+
Stats,
40+
}
41+
2442
// Config defaults
2543
const DEFAULT_NETWORK: Network = Network::Bitcoin;
2644
const DEFAULT_BDK_WALLET_SYNC_INTERVAL_SECS: u64 = 80;
@@ -127,9 +145,10 @@ pub(crate) const HRN_RESOLUTION_TIMEOUT_SECS: u64 = 5;
127145
/// | `probing_liquidity_limit_multiplier` | 3 |
128146
/// | `log_level` | Debug |
129147
/// | `anchor_channels_config` | Some(..) |
130-
/// | `route_parameters` | None |
148+
/// | `route_parameters` | None |
149+
/// | `forwarded_payment_tracking_mode` | Detailed |
131150
///
132-
/// See [`AnchorChannelsConfig`] and [`RouteParametersConfig`] for more information regarding their
151+
/// See [`AnchorChannelsConfig`], [`RouteParametersConfig`], and [`ForwardedPaymentTrackingMode`] for more information regarding their
133152
/// respective default values.
134153
///
135154
/// [`Node`]: crate::Node
@@ -192,6 +211,10 @@ pub struct Config {
192211
/// **Note:** If unset, default parameters will be used, and you will be able to override the
193212
/// parameters on a per-payment basis in the corresponding method calls.
194213
pub route_parameters: Option<RouteParametersConfig>,
214+
/// The mode used for tracking forwarded payments.
215+
///
216+
/// See [`ForwardedPaymentTrackingMode`] for more information on the available modes.
217+
pub forwarded_payment_tracking_mode: ForwardedPaymentTrackingMode,
195218
}
196219

197220
impl Default for Config {
@@ -206,6 +229,7 @@ impl Default for Config {
206229
anchor_channels_config: Some(AnchorChannelsConfig::default()),
207230
route_parameters: None,
208231
node_alias: None,
232+
forwarded_payment_tracking_mode: ForwardedPaymentTrackingMode::default(),
209233
}
210234
}
211235
}

src/event.rs

Lines changed: 89 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ use lightning_liquidity::lsps2::utils::compute_opening_fee;
3333
use lightning_types::payment::{PaymentHash, PaymentPreimage};
3434
use rand::{rng, Rng};
3535

36-
use crate::config::{may_announce_channel, Config};
36+
use crate::config::{may_announce_channel, Config, ForwardedPaymentTrackingMode};
3737
use crate::connection::ConnectionManager;
3838
use crate::data_store::DataStoreUpdateResult;
3939
use crate::fee_estimator::ConfirmationTarget;
@@ -46,12 +46,13 @@ use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger
4646
use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox;
4747
use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore;
4848
use crate::payment::store::{
49-
ForwardedPaymentDetails, ForwardedPaymentId, PaymentDetails, PaymentDetailsUpdate,
50-
PaymentDirection, PaymentKind, PaymentStatus,
49+
ChannelForwardingStats, ForwardedPaymentDetails, ForwardedPaymentId, PaymentDetails,
50+
PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus,
5151
};
5252
use crate::runtime::Runtime;
5353
use crate::types::{
54-
CustomTlvRecord, DynStore, ForwardedPaymentStore, OnionMessenger, PaymentStore, Sweeper, Wallet,
54+
ChannelForwardingStatsStore, CustomTlvRecord, DynStore, ForwardedPaymentStore, OnionMessenger,
55+
PaymentStore, Sweeper, Wallet,
5556
};
5657
use crate::{
5758
hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore,
@@ -492,6 +493,7 @@ where
492493
liquidity_source: Option<Arc<LiquiditySource<Arc<Logger>>>>,
493494
payment_store: Arc<PaymentStore>,
494495
forwarded_payment_store: Arc<ForwardedPaymentStore>,
496+
channel_forwarding_stats_store: Arc<ChannelForwardingStatsStore>,
495497
peer_store: Arc<PeerStore<L>>,
496498
runtime: Arc<Runtime>,
497499
logger: L,
@@ -512,6 +514,7 @@ where
512514
output_sweeper: Arc<Sweeper>, network_graph: Arc<Graph>,
513515
liquidity_source: Option<Arc<LiquiditySource<Arc<Logger>>>>,
514516
payment_store: Arc<PaymentStore>, forwarded_payment_store: Arc<ForwardedPaymentStore>,
517+
channel_forwarding_stats_store: Arc<ChannelForwardingStatsStore>,
515518
peer_store: Arc<PeerStore<L>>, static_invoice_store: Option<StaticInvoiceStore>,
516519
onion_messenger: Arc<OnionMessenger>, om_mailbox: Option<Arc<OnionMessageMailbox>>,
517520
runtime: Arc<Runtime>, logger: L, config: Arc<Config>,
@@ -527,6 +530,7 @@ where
527530
liquidity_source,
528531
payment_store,
529532
forwarded_payment_store,
533+
channel_forwarding_stats_store,
530534
peer_store,
531535
logger,
532536
runtime,
@@ -1370,40 +1374,99 @@ where
13701374
.await;
13711375
}
13721376

1373-
// Store the forwarded payment details
13741377
let prev_channel_id_value = prev_channel_id
13751378
.expect("prev_channel_id expected for events generated by LDK versions greater than 0.0.107.");
13761379
let next_channel_id_value = next_channel_id
13771380
.expect("next_channel_id expected for events generated by LDK versions greater than 0.0.107.");
13781381

1379-
// PaymentForwarded does not have a unique id, so we generate a random one here.
1380-
let mut id_bytes = [0u8; 32];
1381-
rng().fill(&mut id_bytes);
1382-
13831382
let forwarded_at_timestamp = SystemTime::now()
13841383
.duration_since(UNIX_EPOCH)
13851384
.expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH")
13861385
.as_secs();
13871386

1388-
let forwarded_payment = ForwardedPaymentDetails {
1389-
id: ForwardedPaymentId(id_bytes),
1390-
prev_channel_id: prev_channel_id_value,
1391-
next_channel_id: next_channel_id_value,
1392-
prev_user_channel_id: prev_user_channel_id.map(UserChannelId),
1393-
next_user_channel_id: next_user_channel_id.map(UserChannelId),
1394-
prev_node_id,
1395-
next_node_id,
1396-
total_fee_earned_msat,
1397-
skimmed_fee_msat,
1398-
claim_from_onchain_tx,
1399-
outbound_amount_forwarded_msat,
1400-
forwarded_at_timestamp,
1387+
// Calculate inbound amount (outbound + fee)
1388+
let inbound_amount_msat = outbound_amount_forwarded_msat
1389+
.unwrap_or(0)
1390+
.saturating_add(total_fee_earned_msat.unwrap_or(0));
1391+
1392+
// Update per-channel forwarding stats for the inbound channel (prev_channel)
1393+
// For new entries, this becomes the initial value; for existing entries,
1394+
// these values are used as increments via the to_update() -> update() pattern.
1395+
let inbound_stats = ChannelForwardingStats {
1396+
channel_id: prev_channel_id_value,
1397+
counterparty_node_id: prev_node_id,
1398+
inbound_payments_forwarded: 1,
1399+
outbound_payments_forwarded: 0,
1400+
total_inbound_amount_msat: inbound_amount_msat,
1401+
total_outbound_amount_msat: 0,
1402+
total_fee_earned_msat: total_fee_earned_msat.unwrap_or(0),
1403+
total_skimmed_fee_msat: skimmed_fee_msat.unwrap_or(0),
1404+
onchain_claims_count: if claim_from_onchain_tx { 1 } else { 0 },
1405+
first_forwarded_at_timestamp: forwarded_at_timestamp,
1406+
last_forwarded_at_timestamp: forwarded_at_timestamp,
1407+
};
1408+
self.channel_forwarding_stats_store
1409+
.insert_or_update(inbound_stats)
1410+
.map_err(|e| {
1411+
log_error!(
1412+
self.logger,
1413+
"Failed to update inbound channel forwarding stats: {e}"
1414+
);
1415+
ReplayEvent()
1416+
})?;
1417+
1418+
// Update per-channel forwarding stats for the outbound channel (next_channel)
1419+
let outbound_stats = ChannelForwardingStats {
1420+
channel_id: next_channel_id_value,
1421+
counterparty_node_id: next_node_id,
1422+
inbound_payments_forwarded: 0,
1423+
outbound_payments_forwarded: 1,
1424+
total_inbound_amount_msat: 0,
1425+
total_outbound_amount_msat: outbound_amount_forwarded_msat.unwrap_or(0),
1426+
total_fee_earned_msat: 0,
1427+
total_skimmed_fee_msat: 0,
1428+
onchain_claims_count: 0,
1429+
first_forwarded_at_timestamp: forwarded_at_timestamp,
1430+
last_forwarded_at_timestamp: forwarded_at_timestamp,
14011431
};
1432+
self.channel_forwarding_stats_store
1433+
.insert_or_update(outbound_stats)
1434+
.map_err(|e| {
1435+
log_error!(
1436+
self.logger,
1437+
"Failed to update outbound channel forwarding stats: {e}"
1438+
);
1439+
ReplayEvent()
1440+
})?;
14021441

1403-
self.forwarded_payment_store.insert(forwarded_payment).map_err(|e| {
1404-
log_error!(self.logger, "Failed to store forwarded payment: {e}");
1405-
ReplayEvent()
1406-
})?;
1442+
// Only store individual forwarded payment details in Detailed mode
1443+
if self.config.forwarded_payment_tracking_mode
1444+
== ForwardedPaymentTrackingMode::Detailed
1445+
{
1446+
// PaymentForwarded does not have a unique id, so we generate a random one here.
1447+
let mut id_bytes = [0u8; 32];
1448+
rng().fill(&mut id_bytes);
1449+
1450+
let forwarded_payment = ForwardedPaymentDetails {
1451+
id: ForwardedPaymentId(id_bytes),
1452+
prev_channel_id: prev_channel_id_value,
1453+
next_channel_id: next_channel_id_value,
1454+
prev_user_channel_id: prev_user_channel_id.map(UserChannelId),
1455+
next_user_channel_id: next_user_channel_id.map(UserChannelId),
1456+
prev_node_id,
1457+
next_node_id,
1458+
total_fee_earned_msat,
1459+
skimmed_fee_msat,
1460+
claim_from_onchain_tx,
1461+
outbound_amount_forwarded_msat,
1462+
forwarded_at_timestamp,
1463+
};
1464+
1465+
self.forwarded_payment_store.insert(forwarded_payment).map_err(|e| {
1466+
log_error!(self.logger, "Failed to store forwarded payment: {e}");
1467+
ReplayEvent()
1468+
})?;
1469+
}
14071470

14081471
let event = Event::PaymentForwarded {
14091472
prev_channel_id: prev_channel_id_value,

src/ffi/types.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,11 @@ pub use crate::graph::{ChannelInfo, ChannelUpdateInfo, NodeAnnouncementInfo, Nod
5454
pub use crate::liquidity::{LSPS1OrderStatus, LSPS2ServiceConfig};
5555
pub use crate::logger::{LogLevel, LogRecord, LogWriter};
5656
pub use crate::payment::store::{
57-
ConfirmationStatus, ForwardedPaymentId, LSPFeeLimits, PaymentDirection, PaymentKind,
58-
PaymentStatus,
57+
ChannelForwardingStats, ConfirmationStatus, ForwardedPaymentId, LSPFeeLimits, PaymentDirection,
58+
PaymentKind, PaymentStatus,
5959
};
6060
pub use crate::payment::{ForwardedPaymentDetails, UnifiedPaymentResult};
61+
pub use crate::config::ForwardedPaymentTrackingMode;
6162
use crate::{hex_utils, SocketAddress, UniffiCustomTypeConverter, UserChannelId};
6263

6364
impl UniffiCustomTypeConverter for PublicKey {

src/io/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = "";
3131
pub(crate) const FORWARDED_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "forwarded_payments";
3232
pub(crate) const FORWARDED_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = "";
3333

34+
/// The channel forwarding stats will be persisted under this prefix.
35+
pub(crate) const CHANNEL_FORWARDING_STATS_PERSISTENCE_PRIMARY_NAMESPACE: &str =
36+
"channel_forwarding_stats";
37+
pub(crate) const CHANNEL_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE: &str = "";
38+
3439
/// The node metrics will be persisted under this key.
3540
pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = "";
3641
pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = "";

0 commit comments

Comments
 (0)