Skip to content

Commit f1c31b4

Browse files
committed
Add forwarded payment tracking and statistics
Routing nodes and LSPs need to track forwarded payments so they can account for earned fees and profitability over time. Store forwarded payment data and aggregate it into channel and channel-pair statistics. Detailed tracking retains individual forwarding events for the current and previous configured buckets, then aggregates older events into channel-pair statistics. Persist the first detailed bucket length and reject later mismatches so bucket geometry stays consistent across restarts and mode changes. Stats mode records new forwards only in per-channel totals and uses the persisted length to drain older details. Persist channel-pair bucket keys in reversible URL-safe Base64. This keeps them under LDK's KVStore limit without losing direct lookup or namespace scanning. Treat persisted buckets as commit markers so retries can finish detail cleanup without counting the same bucket twice. Use per-HTLC locator amounts to attribute trampoline forwards across channel pairs. Pair multi-HTLC forwards ordinally instead of inventing a Cartesian product. When one side has extra HTLCs, reuse its final entry and consume its amount across the remaining pairs in order. This keeps totals exact without inventing proportional attribution. Expose detected trampoline routing on forwarded payment events. Persist the signal and derive it for older queued events from their stored HTLC locator collections. Preserve unknown fees in channel and channel-pair aggregates instead of treating them as zero. Serialize detail insertion with bucket closure and page reads with removals so concurrent retention work cannot drop records or invalidate a page. AI-assisted-by: OpenAI Codex
1 parent 999db7c commit f1c31b4

12 files changed

Lines changed: 2663 additions & 43 deletions

File tree

bindings/ldk_node.udl

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ typedef dictionary ElectrumSyncConfig;
1111

1212
typedef dictionary TorConfig;
1313

14+
typedef enum ForwardedPaymentTrackingMode;
15+
1416
typedef interface NodeEntropy;
1517

1618
typedef interface ProbingConfig;
@@ -152,6 +154,19 @@ interface Node {
152154
void remove_payment([ByRef]PaymentId payment_id);
153155
BalanceDetails list_balances();
154156
sequence<PaymentDetails> list_payments();
157+
ForwardedPaymentDetails? forwarded_payment([ByRef]ForwardedPaymentId forwarded_payment_id);
158+
[Throws=NodeError]
159+
ForwardedPaymentDetailsPage list_forwarded_payments(PageToken? page_token);
160+
ForwardedPaymentTrackingMode forwarded_payment_tracking_mode();
161+
ChannelForwardingStats? channel_forwarding_stats([ByRef]ChannelId channel_id);
162+
[Throws=NodeError]
163+
ChannelForwardingStatsPage list_channel_forwarding_stats(PageToken? page_token);
164+
[Throws=NodeError]
165+
ChannelPairForwardingStatsPage list_channel_pair_forwarding_stats(PageToken? page_token);
166+
[Throws=NodeError]
167+
ChannelPairForwardingStatsPage list_channel_pair_forwarding_stats_in_range(u64 start_timestamp, u64 end_timestamp, PageToken? page_token);
168+
[Throws=NodeError]
169+
ChannelPairForwardingStatsPage list_channel_pair_forwarding_stats_for_pair(ChannelId prev_channel_id, ChannelId next_channel_id, PageToken? page_token);
155170
sequence<PeerDetails> list_peers();
156171
sequence<ChannelDetails> list_channels();
157172
NetworkGraph network_graph();
@@ -394,6 +409,9 @@ typedef string OfferId;
394409
[Custom]
395410
typedef string PaymentId;
396411

412+
[Custom]
413+
typedef string ForwardedPaymentId;
414+
397415
[Custom]
398416
typedef string PaymentHash;
399417

@@ -406,6 +424,12 @@ typedef string PaymentSecret;
406424
[Custom]
407425
typedef string ChannelId;
408426

427+
[Custom]
428+
typedef string ChannelPairStatsId;
429+
430+
[Custom]
431+
typedef string PageToken;
432+
409433
[Custom]
410434
typedef string UserChannelId;
411435

@@ -432,3 +456,15 @@ typedef enum Event;
432456
typedef interface HRNResolverConfig;
433457

434458
typedef dictionary HumanReadableNamesConfig;
459+
460+
typedef dictionary ForwardedPaymentDetails;
461+
462+
typedef dictionary ChannelForwardingStats;
463+
464+
typedef dictionary ChannelPairForwardingStats;
465+
466+
typedef dictionary ForwardedPaymentDetailsPage;
467+
468+
typedef dictionary ChannelForwardingStatsPage;
469+
470+
typedef dictionary ChannelPairForwardingStatsPage;

src/builder.rs

Lines changed: 151 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ use crate::chain::ChainSource;
4949
use crate::config::{
5050
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
5151
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig,
52-
TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
53-
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT,
52+
ResolvedForwardedPaymentBucketConfig, TorConfig, DEFAULT_ESPLORA_SERVER_URL,
53+
DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL, DEFAULT_MAX_PROBE_AMOUNT_MSAT,
54+
DEFAULT_MIN_PROBE_AMOUNT_MSAT,
5455
};
5556
use crate::connection::ConnectionManager;
5657
use crate::entropy::NodeEntropy;
@@ -60,12 +61,19 @@ use crate::gossip::GossipSource;
6061
use crate::io::sqlite_store::SqliteStore;
6162
use crate::io::utils::{
6263
open_or_migrate_fs_store, read_all_objects, read_event_queue,
63-
read_external_pathfinding_scores_from_cache, read_network_graph, read_node_metrics,
64-
read_output_sweeper, read_peer_info, read_scorer,
64+
read_external_pathfinding_scores_from_cache, read_forwarded_payment_bucket_size,
65+
read_network_graph, read_node_metrics, read_output_sweeper, read_peer_info, read_scorer,
66+
write_forwarded_payment_bucket_size,
6567
};
6668
use crate::io::vss_store::VssStoreBuilder;
6769
use crate::io::{
68-
self, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
70+
self, CHANNEL_FORWARDING_STATS_PERSISTENCE_PRIMARY_NAMESPACE,
71+
CHANNEL_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE,
72+
CHANNEL_PAIR_FORWARDING_STATS_PERSISTENCE_PRIMARY_NAMESPACE,
73+
CHANNEL_PAIR_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE,
74+
FORWARDED_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
75+
FORWARDED_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
76+
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
6977
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
7078
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
7179
};
@@ -82,7 +90,8 @@ use crate::probing::{
8290
use crate::runtime::{Runtime, RuntimeSpawner};
8391
use crate::tx_broadcaster::TransactionBroadcaster;
8492
use crate::types::{
85-
AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreRef, DynStoreWrapper,
93+
AsyncPersister, ChainMonitor, ChannelForwardingStatsStore, ChannelManager,
94+
ChannelPairForwardingStatsStore, DynStore, DynStoreRef, DynStoreWrapper, ForwardedPaymentStore,
8695
GossipSync, Graph, HRNResolver, KeysManager, MessageRouter, OnionMessenger, PaymentStore,
8796
PeerManager, PendingPaymentStore,
8897
};
@@ -174,6 +183,10 @@ pub enum BuildError {
174183
InvalidTorProxyAddress,
175184
/// The provided alias is invalid.
176185
InvalidNodeAlias,
186+
/// The forwarded-payment bucket length is zero or too large to represent in seconds.
187+
InvalidForwardedPaymentBucketLength,
188+
/// The forwarded-payment bucket length differs from the value previously persisted.
189+
ForwardedPaymentBucketLengthMismatch,
177190
/// An attempt to setup a runtime has failed.
178191
RuntimeSetupFailed,
179192
/// We failed to read data from the [`KVStore`].
@@ -236,6 +249,12 @@ impl fmt::Display for BuildError {
236249
Self::LoggerSetupFailed => write!(f, "Failed to setup the logger."),
237250
Self::ChainSourceSetupFailed => write!(f, "Failed to setup the chain source."),
238251
Self::InvalidNodeAlias => write!(f, "Given node alias is invalid."),
252+
Self::InvalidForwardedPaymentBucketLength => {
253+
write!(f, "Forwarded-payment bucket length is invalid.")
254+
},
255+
Self::ForwardedPaymentBucketLengthMismatch => {
256+
write!(f, "Forwarded-payment bucket length does not match persisted data.")
257+
},
239258
Self::NetworkMismatch => {
240259
write!(f, "Given network does not match the node's previously configured network.")
241260
},
@@ -1409,6 +1428,12 @@ fn build_with_store_internal(
14091428
) -> Result<Node, BuildError> {
14101429
optionally_install_rustls_cryptoprovider();
14111430

1431+
let configured_forwarded_payment_bucket_size_secs =
1432+
config.forwarded_payment_tracking_mode.bucket_size_secs().map_err(|_| {
1433+
log_error!(logger, "Forwarded-payment bucket length is zero or too large");
1434+
BuildError::InvalidForwardedPaymentBucketLength
1435+
})?;
1436+
14121437
if let Err(err) = may_announce_channel(&config) {
14131438
if config.announcement_addresses.is_some() {
14141439
log_error!(logger, "Announcement addresses were set but some required configuration options for node announcement are missing: {}", err);
@@ -1440,24 +1465,72 @@ fn build_with_store_internal(
14401465

14411466
let kv_store_ref = Arc::clone(&kv_store);
14421467
let logger_ref = Arc::clone(&logger);
1443-
let (payment_store_res, node_metris_res, pending_payment_store_res) =
1444-
runtime.block_on(async move {
1445-
tokio::join!(
1446-
read_all_objects(
1447-
&*kv_store_ref,
1448-
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1449-
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1450-
Arc::clone(&logger_ref),
1451-
),
1452-
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
1453-
read_all_objects(
1454-
&*kv_store_ref,
1455-
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1456-
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1457-
Arc::clone(&logger_ref),
1458-
)
1468+
let (
1469+
payment_store_res,
1470+
forwarded_payment_store_res,
1471+
channel_forwarding_stats_res,
1472+
channel_pair_forwarding_stats_res,
1473+
forwarded_payment_bucket_size_res,
1474+
node_metris_res,
1475+
pending_payment_store_res,
1476+
) = runtime.block_on(async move {
1477+
tokio::join!(
1478+
read_all_objects(
1479+
&*kv_store_ref,
1480+
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1481+
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1482+
Arc::clone(&logger_ref),
1483+
),
1484+
read_all_objects(
1485+
&*kv_store_ref,
1486+
FORWARDED_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1487+
FORWARDED_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1488+
Arc::clone(&logger_ref),
1489+
),
1490+
read_all_objects(
1491+
&*kv_store_ref,
1492+
CHANNEL_FORWARDING_STATS_PERSISTENCE_PRIMARY_NAMESPACE,
1493+
CHANNEL_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE,
1494+
Arc::clone(&logger_ref),
1495+
),
1496+
read_all_objects(
1497+
&*kv_store_ref,
1498+
CHANNEL_PAIR_FORWARDING_STATS_PERSISTENCE_PRIMARY_NAMESPACE,
1499+
CHANNEL_PAIR_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE,
1500+
Arc::clone(&logger_ref),
1501+
),
1502+
read_forwarded_payment_bucket_size(&*kv_store_ref, Arc::clone(&logger_ref)),
1503+
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
1504+
read_all_objects(
1505+
&*kv_store_ref,
1506+
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1507+
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1508+
Arc::clone(&logger_ref),
14591509
)
1460-
});
1510+
)
1511+
});
1512+
1513+
let persisted_forwarded_payment_bucket_size_secs = match forwarded_payment_bucket_size_res {
1514+
Ok(bucket_size_secs) => Some(bucket_size_secs),
1515+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1516+
Err(e) => {
1517+
log_error!(logger, "Failed to read forwarded-payment bucket size: {}", e);
1518+
return Err(BuildError::ReadFailed);
1519+
},
1520+
};
1521+
let forwarded_payment_bucket_config = ResolvedForwardedPaymentBucketConfig::resolve(
1522+
configured_forwarded_payment_bucket_size_secs,
1523+
persisted_forwarded_payment_bucket_size_secs,
1524+
)
1525+
.map_err(|_| {
1526+
log_error!(
1527+
logger,
1528+
"Configured forwarded-payment bucket size {:?} does not match persisted size {:?}",
1529+
configured_forwarded_payment_bucket_size_secs,
1530+
persisted_forwarded_payment_bucket_size_secs
1531+
);
1532+
BuildError::ForwardedPaymentBucketLengthMismatch
1533+
})?;
14611534

14621535
// Initialize the status fields.
14631536
let node_metrics = match node_metris_res {
@@ -1486,6 +1559,48 @@ fn build_with_store_internal(
14861559
},
14871560
};
14881561

1562+
let forwarded_payment_store = match forwarded_payment_store_res {
1563+
Ok(forwarded_payments) => Arc::new(ForwardedPaymentStore::new(
1564+
forwarded_payments,
1565+
FORWARDED_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
1566+
FORWARDED_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
1567+
Arc::clone(&kv_store),
1568+
Arc::clone(&logger),
1569+
)),
1570+
Err(e) => {
1571+
log_error!(logger, "Failed to read forwarded payment data from store: {}", e);
1572+
return Err(BuildError::ReadFailed);
1573+
},
1574+
};
1575+
1576+
let channel_forwarding_stats_store = match channel_forwarding_stats_res {
1577+
Ok(stats) => Arc::new(ChannelForwardingStatsStore::new(
1578+
stats,
1579+
CHANNEL_FORWARDING_STATS_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
1580+
CHANNEL_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
1581+
Arc::clone(&kv_store),
1582+
Arc::clone(&logger),
1583+
)),
1584+
Err(e) => {
1585+
log_error!(logger, "Failed to read channel forwarding stats from store: {}", e);
1586+
return Err(BuildError::ReadFailed);
1587+
},
1588+
};
1589+
1590+
let channel_pair_forwarding_stats_store = match channel_pair_forwarding_stats_res {
1591+
Ok(stats) => Arc::new(ChannelPairForwardingStatsStore::new(
1592+
stats,
1593+
CHANNEL_PAIR_FORWARDING_STATS_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
1594+
CHANNEL_PAIR_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
1595+
Arc::clone(&kv_store),
1596+
Arc::clone(&logger),
1597+
)),
1598+
Err(e) => {
1599+
log_error!(logger, "Failed to read channel pair forwarding stats from store: {}", e);
1600+
return Err(BuildError::ReadFailed);
1601+
},
1602+
};
1603+
14891604
let (chain_source, chain_tip_opt) = match chain_data_source_config {
14901605
Some(ChainDataSourceConfig::Esplora { server_url, headers, sync_config }) => {
14911606
let sync_config = sync_config.unwrap_or(EsploraSyncConfig::default());
@@ -2307,6 +2422,15 @@ fn build_with_store_internal(
23072422
})
23082423
});
23092424

2425+
if let Some(bucket_size_secs) = forwarded_payment_bucket_config.new_bucket_size_secs() {
2426+
runtime
2427+
.block_on(write_forwarded_payment_bucket_size(&*kv_store, bucket_size_secs))
2428+
.map_err(|e| {
2429+
log_error!(logger, "Failed to persist forwarded-payment bucket size: {}", e);
2430+
BuildError::WriteFailed
2431+
})?;
2432+
}
2433+
23102434
Ok(Node {
23112435
runtime,
23122436
stop_sender,
@@ -2334,6 +2458,10 @@ fn build_with_store_internal(
23342458
scorer,
23352459
peer_store,
23362460
payment_store,
2461+
forwarded_payment_store,
2462+
channel_forwarding_stats_store,
2463+
channel_pair_forwarding_stats_store,
2464+
forwarded_payment_bucket_size_secs: forwarded_payment_bucket_config.bucket_size_secs(),
23372465
lnurl_auth,
23382466
is_running,
23392467
node_metrics,

0 commit comments

Comments
 (0)