Skip to content

Commit 2397afd

Browse files
committed
Add forwarded payment tracking
Store unambiguous single-HTLC forwarding events. Aggregate them into per-channel and channel-pair statistics. Use fixed one-hour buckets for detailed records. Keep details out of the payment LRU cache. Use one persistence namespace for forwarding data. Expose analytics through Rust and UniFFI. Keep forwarding persistence and event-recording logic behind one internal store. AI-assisted-by: OpenAI Codex and Anthropic Fable
1 parent 767cbcc commit 2397afd

13 files changed

Lines changed: 2579 additions & 34 deletions

File tree

bindings/ldk_node.udl

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ typedef dictionary ElectrumSyncConfig;
1010

1111
typedef dictionary TorConfig;
1212

13+
typedef enum ForwardedPaymentTrackingMode;
14+
1315
typedef interface NodeEntropy;
1416

1517
typedef interface ProbingConfig;
@@ -68,6 +70,7 @@ interface Node {
6870
SpontaneousPayment spontaneous_payment();
6971
OnchainPayment onchain_payment();
7072
Liquidity liquidity();
73+
ForwardingAnalytics forwarding_analytics();
7174
[Throws=NodeError]
7275
void lnurl_auth(string lnurl);
7376
[Throws=NodeError]
@@ -139,6 +142,8 @@ interface FeeRate {
139142

140143
typedef interface Liquidity;
141144

145+
typedef interface ForwardingAnalytics;
146+
142147
[Error]
143148
enum NodeError {
144149
"AlreadyRunning",
@@ -177,6 +182,8 @@ enum NodeError {
177182
"InvalidOfferId",
178183
"InvalidNodeId",
179184
"InvalidPaymentId",
185+
"InvalidForwardedPaymentId",
186+
"InvalidChannelPairForwardingStatsId",
180187
"InvalidPaymentHash",
181188
"InvalidPaymentPreimage",
182189
"InvalidPaymentSecret",
@@ -358,6 +365,12 @@ typedef string OfferId;
358365
[Custom]
359366
typedef string PaymentId;
360367

368+
[Custom]
369+
typedef string ForwardedPaymentId;
370+
371+
[Custom]
372+
typedef string ChannelPairForwardingStatsId;
373+
361374
[Custom]
362375
typedef string PaymentHash;
363376

@@ -395,3 +408,15 @@ typedef enum Event;
395408
typedef interface HRNResolverConfig;
396409

397410
typedef dictionary HumanReadableNamesConfig;
411+
412+
typedef dictionary ForwardedPaymentDetails;
413+
414+
typedef dictionary ChannelForwardingStats;
415+
416+
typedef dictionary ChannelPairForwardingStats;
417+
418+
typedef dictionary ForwardedPaymentDetailsPage;
419+
420+
typedef dictionary ChannelForwardingStatsPage;
421+
422+
typedef dictionary ChannelPairForwardingStatsPage;

src/builder.rs

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ use crate::io::utils::{
8080
#[cfg(feature = "storage-vss")]
8181
use crate::io::vss_store::VssStoreBuilder;
8282
use crate::io::{
83-
self, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
83+
self, CHANNEL_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE,
84+
FORWARDED_PAYMENT_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
85+
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
8486
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
8587
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
8688
};
@@ -89,6 +91,7 @@ use crate::lnurl_auth::LnurlAuth;
8991
use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger};
9092
use crate::message_handler::NodeCustomMessageHandler;
9193
use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox;
94+
use crate::payment::forwarding_store::ForwardingStore;
9295
#[cfg(feature = "unified-payments")]
9396
use crate::payment::HRNResolver;
9497
use crate::peer_store::PeerStore;
@@ -1524,26 +1527,37 @@ fn build_with_store_internal(
15241527

15251528
let kv_store_ref = Arc::clone(&kv_store);
15261529
let logger_ref = Arc::clone(&logger);
1527-
let (payment_store_res, node_metris_res, pending_payment_store_res, address_pool_res) = runtime
1528-
.block_on(async move {
1529-
tokio::join!(
1530-
read_n_objects(
1531-
&*kv_store_ref,
1532-
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1533-
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1534-
PAYMENT_CACHE_WARMUP_COUNT,
1535-
Arc::clone(&logger_ref),
1536-
),
1537-
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
1538-
read_all_objects(
1539-
&*kv_store_ref,
1540-
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1541-
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1542-
Arc::clone(&logger_ref),
1543-
),
1544-
read_address_pool(&*kv_store_ref, &*logger_ref)
1545-
)
1546-
});
1530+
let (
1531+
payment_store_res,
1532+
channel_forwarding_stats_res,
1533+
node_metris_res,
1534+
pending_payment_store_res,
1535+
address_pool_res,
1536+
) = runtime.block_on(async move {
1537+
tokio::join!(
1538+
read_n_objects(
1539+
&*kv_store_ref,
1540+
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1541+
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1542+
PAYMENT_CACHE_WARMUP_COUNT,
1543+
Arc::clone(&logger_ref),
1544+
),
1545+
read_all_objects(
1546+
&*kv_store_ref,
1547+
FORWARDED_PAYMENT_PERSISTENCE_PRIMARY_NAMESPACE,
1548+
CHANNEL_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE,
1549+
Arc::clone(&logger_ref),
1550+
),
1551+
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
1552+
read_all_objects(
1553+
&*kv_store_ref,
1554+
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
1555+
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
1556+
Arc::clone(&logger_ref),
1557+
),
1558+
read_address_pool(&*kv_store_ref, &*logger_ref),
1559+
)
1560+
});
15471561

15481562
// Initialize the status fields.
15491563
let node_metrics = match node_metris_res {
@@ -1576,6 +1590,14 @@ fn build_with_store_internal(
15761590
},
15771591
};
15781592

1593+
let channel_forwarding_stats = match channel_forwarding_stats_res {
1594+
Ok(stats) => stats,
1595+
Err(e) => {
1596+
log_error!(logger, "Failed to read channel forwarding stats from store: {}", e);
1597+
return Err(BuildError::ReadFailed);
1598+
},
1599+
};
1600+
15791601
let (chain_source, chain_tip_opt) = match chain_data_source_config {
15801602
#[cfg(feature = "chain-esplora")]
15811603
Some(ChainDataSourceConfig::Esplora { server_url, headers, sync_config }) => {
@@ -1902,6 +1924,12 @@ fn build_with_store_internal(
19021924
Arc::clone(&wallet),
19031925
Arc::clone(&logger),
19041926
));
1927+
let forwarding_store = Arc::new(ForwardingStore::new(
1928+
channel_forwarding_stats,
1929+
config.forwarded_payment_tracking_mode,
1930+
Arc::clone(&kv_store),
1931+
Arc::clone(&logger),
1932+
));
19051933

19061934
let peer_storage_key = keys_manager.get_peer_storage_key();
19071935
let monitor_reader = Arc::new(AsyncPersister::new(
@@ -2457,6 +2485,16 @@ fn build_with_store_internal(
24572485
_leak_checker.0.push(Arc::downgrade(&wallet) as Weak<dyn Any + Send + Sync>);
24582486
}
24592487

2488+
// How long detail records are kept before being folded into channel-pair buckets. `Stats` keeps
2489+
// none of its own, and only drains records a previous `Detailed` configuration left behind.
2490+
let forwarded_payment_aggregation_retention_secs = match config.forwarded_payment_tracking_mode
2491+
{
2492+
crate::config::ForwardedPaymentTrackingMode::Detailed => {
2493+
crate::payment::forwarding_store::FORWARDED_PAYMENT_AGGREGATION_BUCKET_SIZE_SECS
2494+
},
2495+
crate::config::ForwardedPaymentTrackingMode::Stats => 0,
2496+
};
2497+
24602498
Ok(Node {
24612499
runtime,
24622500
stop_sender,
@@ -2484,6 +2522,8 @@ fn build_with_store_internal(
24842522
scorer,
24852523
peer_store,
24862524
payment_store,
2525+
forwarding_store,
2526+
forwarded_payment_aggregation_retention_secs,
24872527
lnurl_auth,
24882528
is_running,
24892529
node_metrics,

src/config.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,30 @@ pub(crate) const LIQUIDITY_DISCOVERY_RETRY_INITIAL_DELAY: Duration = Duration::f
169169
// thereafter until every configured LSP has been discovered.
170170
pub(crate) const LIQUIDITY_DISCOVERY_RETRY_MAX_DELAY: Duration = Duration::from_secs(60 * 60);
171171

172+
/// The mode used for tracking forwarded payments.
173+
///
174+
/// In either mode, a forward is tracked only when it has exactly one incoming HTLC and one outgoing
175+
/// HTLC, and LDK reports both the outbound amount and total fee.
176+
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
177+
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
178+
pub enum ForwardedPaymentTrackingMode {
179+
/// Track eligible new forwarded payments only as per-channel aggregate statistics.
180+
///
181+
/// Any detailed records left by a previous configuration are aggregated and removed after their
182+
/// current one-hour bucket closes.
183+
Stats,
184+
/// Store eligible individual forwarded payments for the current and previous one-hour buckets.
185+
///
186+
/// Payments from older buckets are aggregated into channel-pair statistics and removed.
187+
Detailed,
188+
}
189+
190+
impl Default for ForwardedPaymentTrackingMode {
191+
fn default() -> Self {
192+
Self::Stats
193+
}
194+
}
195+
172196
#[derive(Debug, Clone)]
173197
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
174198
/// Represents the configuration of an [`Node`] instance.
@@ -192,9 +216,10 @@ pub(crate) const LIQUIDITY_DISCOVERY_RETRY_MAX_DELAY: Duration = Duration::from_
192216
doc = "| `hrn_config` | HumanReadableNamesConfig::default() |"
193217
)]
194218
/// | `manually_handle_unknown_bolt11_payments` | false |
219+
/// | `forwarded_payment_tracking_mode` | Stats |
195220
///
196-
/// See [`AnchorChannelsConfig`] and [`RouteParametersConfig`] for more information regarding their
197-
/// respective default values.
221+
/// See [`AnchorChannelsConfig`], [`RouteParametersConfig`], and
222+
/// [`ForwardedPaymentTrackingMode`] for more information regarding their respective default values.
198223
///
199224
/// [`Node`]: crate::Node
200225
pub struct Config {
@@ -268,6 +293,8 @@ pub struct Config {
268293
///
269294
/// [`Event::PaymentClaimable`]: crate::Event::PaymentClaimable
270295
pub manually_handle_unknown_bolt11_payments: bool,
296+
/// The mode used for tracking forwarded payments.
297+
pub forwarded_payment_tracking_mode: ForwardedPaymentTrackingMode,
271298
}
272299

273300
impl Default for Config {
@@ -286,6 +313,7 @@ impl Default for Config {
286313
#[cfg(feature = "unified-payments")]
287314
hrn_config: HumanReadableNamesConfig::default(),
288315
manually_handle_unknown_bolt11_payments: false,
316+
forwarded_payment_tracking_mode: ForwardedPaymentTrackingMode::default(),
289317
}
290318
}
291319
}

src/error.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ pub enum Error {
8989
InvalidNodeId,
9090
/// The given payment id is invalid.
9191
InvalidPaymentId,
92+
/// The given forwarded payment id is invalid.
93+
InvalidForwardedPaymentId,
94+
/// The given channel-pair forwarding statistics id is invalid.
95+
InvalidChannelPairForwardingStatsId,
9296
/// The given payment hash is invalid.
9397
InvalidPaymentHash,
9498
/// The given payment pre-image is invalid.
@@ -194,6 +198,12 @@ impl fmt::Display for Error {
194198
Self::InvalidOfferId => write!(f, "The given offer id is invalid."),
195199
Self::InvalidNodeId => write!(f, "The given node id is invalid."),
196200
Self::InvalidPaymentId => write!(f, "The given payment id is invalid."),
201+
Self::InvalidForwardedPaymentId => {
202+
write!(f, "The given forwarded payment id is invalid.")
203+
},
204+
Self::InvalidChannelPairForwardingStatsId => {
205+
write!(f, "The given channel-pair forwarding statistics id is invalid.")
206+
},
197207
Self::InvalidPaymentHash => write!(f, "The given payment hash is invalid."),
198208
Self::InvalidPaymentPreimage => write!(f, "The given payment preimage is invalid."),
199209
Self::InvalidPaymentSecret => write!(f, "The given payment secret is invalid."),

0 commit comments

Comments
 (0)