Skip to content

Add storage for forwarded payments - #772

Open
benthecarman wants to merge 2 commits into
lightningdevkit:mainfrom
benthecarman:save-fwd-payment
Open

Add storage for forwarded payments #772
benthecarman wants to merge 2 commits into
lightningdevkit:mainfrom
benthecarman:save-fwd-payment

Conversation

@benthecarman

Copy link
Copy Markdown
Contributor

Routing nodes and LSPs want to track forwarded payments so they can run accounting on fees earned and track profitability across time. We now store these to make it easier to track and allows for future accounting utils in the future.

This shouldn't effect edge user nodes as they should never be forwarding payments.

Implementation is mostly just copied how we currently handle normal payments and adapted for forwarded payments.

@benthecarman
benthecarman requested a review from tnull January 27, 2026 17:33
@ldk-reviews-bot

ldk-reviews-bot commented Jan 27, 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.

@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! I do wonder if we should really enable storing all forwards by default, or rather make this optional.

Also more generally I wonder if users really expect us to store all forwarded payments forever, or if we should only keep the last X entries in the store? Also, with general-purpose HTLC interception coming up, maybe storing forwards might even be something we entirely want to leave entirely to the user after all?

What do you think?

Comment thread src/io/utils.rs Outdated
Comment thread src/io/utils.rs Outdated
Comment thread src/payment/store.rs Outdated
@benthecarman

Copy link
Copy Markdown
Contributor Author

Addressed comments.

I think this makes sense to include in ldk-node and not just leaving it up to the user. If we are focusing on LSPs this will be an essential feature, especially if we want to add accounting tools down the line.

I think it can make sense to disable this and/or add a function to prune the storage for it. Maybe just an option that tracks totals per channel rather than individual htlcs

Comment thread src/lib.rs Outdated
@benthecarman benthecarman self-assigned this Jan 29, 2026
@benthecarman
benthecarman force-pushed the save-fwd-payment branch 3 times, most recently from 970a395 to c3f7714 Compare February 4, 2026 21:48

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

IMO storing granular forwarding information long-term is dangerous. Instead, can we store information that allows for easier compaction? eg total forwarding on a per-X basis between channel pairs?

@tnull

tnull commented Feb 5, 2026

Copy link
Copy Markdown
Collaborator

IMO storing granular forwarding information long-term is dangerous. Instead, can we store information that allows for easier compaction? eg total forwarding on a per-X basis between channel pairs?

Agree it's not great to store everything forever. However, it's also hard to guess which values users are interested in. For example, one metric they def. want to see is fee revenue, and I suspect they might even want individual values for each payment forwarded rather than aggregated numbers.

@benthecarman

Copy link
Copy Markdown
Contributor Author

I started working on a version where you set an interval, say 12 hours, and we store individual events and after the time has lapsed we combine them into a single entry. Does that sound good?

@TheBlueMatt

Copy link
Copy Markdown
Contributor

I suspect they might even want individual values for each payment forwarded rather than aggregated numbers.

Yea, I kinda wonder what kind of aggregate stats they might want. Pairwise totals and counts over discrete time horizons suffices for things like "average forwarded per payment over this channel pair", but median is of course trickier. Maybe that's okay?

@TheBlueMatt

Copy link
Copy Markdown
Contributor

I started working on a version where you set an interval, say 12 hours, and we store individual events and after the time has lapsed we combine them into a single entry. Does that sound good?

I think its totally fair to store the last N hours of individual forwards, yea! After that question is format.

@benthecarman
benthecarman force-pushed the save-fwd-payment branch 2 times, most recently from 19c5b89 to cbb235e Compare February 6, 2026 01:26
@benthecarman

Copy link
Copy Markdown
Contributor Author

Made it so the Detailed mode now only stores for a configured time period and will aggregate them into channel pair stats

@tnull

tnull commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

It seems this should be unblocked by now, but needs a considerable rebase?

@benthecarman

Copy link
Copy Markdown
Contributor Author

Rebased and updated for pagination.

However probably worth holding off on until https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4766 is fixed

@tnull

tnull commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Rebased and updated for pagination.

However probably worth holding off on until https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4766 is fixed

Ugh, okay, that means that the fix needs to happen for 0.3 still then.

@benthecarman

Copy link
Copy Markdown
Contributor Author

Rebased and now uses the updated HTLCLocator. Main open questions for this is that we currently persist the aggregate bucket length and don't let the user change it as there's no real way to reconcile previous ones. This is kinda ugly, we could alternatively not allow this to be configured and just have a hard coded number.

@tnull
tnull self-requested a review July 14, 2026 10:11

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

Needs a rebase, some comments.

Comment thread src/payment/store.rs
Comment thread src/ffi/types.rs Outdated
Comment thread src/config.rs Outdated
Comment thread src/event.rs Outdated
}]);
}

// LDK reports the incoming and outgoing HTLCs, but not which incoming HTLC funded each

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.

It seems like here we're trying to re-establish an API contract that LDK doesn't provide, and it seems error prone. Can we avoid this? If we think this matching is important we might need to change upstream first?

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.

There isn't a mapping of the sats for when we receive them to when we send them, they are all in a single batch. I did FIFO because it's a simple way to split the amounts while keeping the totals correct. I don't know if this is something we really can upstream in ldk because there really is no "pairs" its just one batch of htlcs coming in and going out.

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.

To be honest together with #772 (comment) this seems like a blocker for this PR. If the upstream API doesn't provide sufficient data for us to do the level of detailed aggregation we want to do, and also doesn't allow us to aggregate safely without overcounting on restarts, we probably either a) start with a version that exposes fewer details or b) first change the upstream API altogether so it provides all the data we need to do this correctly and safely.

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.

Changed back to only handle prev/next_hltc len = 1 events again. LDK doesn't forward trampoline payments so we can handle these once we do

Comment thread src/lib.rs Outdated
let channel_pair_stats_store = Arc::clone(&self.channel_pair_forwarding_stats_store);
let logger = Arc::clone(&self.logger);
self.runtime.spawn_cancellable_background_task(async move {
run_forwarded_payment_aggregation(

@tnull tnull Jul 21, 2026

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'm not sure aggregating stats needs its own background task? I mean they are relatively cheap, but there's still some overhead. Do we think doing it on-demand would be very costly?

Or maybe, if we want to do it in the background, doing some prefactoring to have some of these tasks merged into a single 'maintenance task' could make sense (as we have a few by now).

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'd rather not do it on-demand, that always feels flaky and confusing to me, especially when handling potential storage errors.

I think a maintenance task could make sense as a follow up

Comment thread src/event.rs Outdated
}
}

for inbound_stats in inbound_stats_by_channel.into_values() {

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:

  1. [P1] Forwarding accounting is not idempotent across event replay.
    The handler performs additive channel-stat updates and creates detailed records with fresh random IDs before all later persistence—including the user event queue write—has succeeded. If a later write fails, ReplayEvent causes the same forward to be processed again, doubling counts,
    amounts, and fees. LDK also explicitly permits duplicate PaymentForwarded events when the fee is unknown. See /home/tnull/worktrees/ldk-node/pr-772-latest-20260721/src/event.rs:1769, /home/tnull/worktrees/ldk-node/pr-772-latest-20260721/src/payment/store.rs:1419, and LDK’s event contract
    (https://github.com/lightningdevkit/rust-lightning/blob/f680fd37d8bea69383e1b18730423f347e6a8341/lightning/src/events/mod.rs#L1489-L1514). A stable idempotency marker or atomic persistence strategy is needed.

True, though unclear how to tackle without an idempotency marker / event ID.

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.

yeah i dont really know a way around this unless we add the payment hash to the forwarded 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.

Hmm, @TheBlueMatt recently indicated the HTLC ID was added to PaymentForwarded, but it still doesn't seem to be the case. So AFAIU we have no idempotency token available here. Begs the question if we're fine shipping this with a known bug or how we'd want to deal with this.

Comment thread src/event.rs Outdated
@benthecarman
benthecarman requested a review from tnull July 27, 2026 01:31

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

Needs a rebase. As mentioned below, I'm not sure if we can move forward with this before we changed the upstream APIs. If we need it right away in LDK server, we probably need to start with a simplified version that doesn't lean on the very brittle re-derivation of channel pairs, for which we simply don't have sufficient information available.

Also here are some comments from Claude:

Findings

Bugs / must-fix

  1. Leftover doc comment, src/io/mod.rs:50 — /// The immutable forwarded-payment bucket size will be persisted under this key. sits stacked on top of the NODE_METRICS doc comment. It's a remnant of the removed bucket-size persistence and should be deleted.
  2. Event-replay double counting (known, still unresolved) — the handler at src/event.rs:1736–1830 persists inbound stats, then outbound stats, then detail records, then enqueues the user event; any later failure returns ReplayEvent, and the replay re-applies the already-persisted increments
    (and inserts duplicate detail records under fresh random IDs). The PR discussion acknowledges this. Counters can't be made idempotent without an LDK-side event ID, but the detail records could be: derive the ID deterministically from event contents (e.g., hash of HTLC set + amounts + fees)
    so replays overwrite rather than duplicate. Worth at least a doc caveat that stats are at-least-once.

Design concerns

  1. Channel-pair buckets grow without bound — detail records are pruned, but the per-(pair, hour) ChannelPairForwardingStats buckets are never compacted or expired, and all of them are loaded into memory at every startup (read_all_objects in builder.rs) and held in the DataStore HashMap
    forever. An LSP forwarding across many pairs accrues up to ~8,760 buckets per active pair per year. This partially defeats the bounded-storage goal that motivated the aggregation design. Suggest a coarser second-tier compaction (daily/weekly) or a documented retention policy.
  2. Pagination reads disk while everything is already in memory — DataStore::list_page (src/data_store.rs) does a paginated KVStore list plus one KVStore read per key, yet the same objects all sit in the in-memory map. For VSS that's a network round-trip per record for data already in RAM.
    Either serve pages from memory, or (better long-term, given point 3) stop retaining these datasets fully in memory — but the current halfway state has the costs of both.
  3. Startup blocks on aggregation, src/lib.rs:362 — start() runs aggregate_expired_forwarded_payments synchronously via block_on, and each detail removal is an individually awaited KVStore op. A Detailed-mode node that was offline for a while, on a remote store, pays that as startup latency.
    Since the periodic task exists anyway, consider making it run one pass immediately when spawned instead of only at the next bucket boundary, and dropping the blocking pass from start().

Semantics / documentation

  1. Fee attribution is asymmetric and undocumented — fees are credited to the inbound channel's ChannelForwardingStats only (src/event.rs:1758–1766), while outbound entries hardcode total_fee_earned_msat: Some(0) even when the event's fee is unknown (inbound uses total_fee_earned_msat.map(|_|
  1. and correctly poisons to None). Defensible as an attribution convention, but the field doc ("Total forwarding fees earned through this channel") doesn't say it, and users summing outbound channels will conclude those channels never earn fees. Similarly, onchain_claims_count goes once to
    the first outbound channel while the detail/pair records set claim_from_onchain_tx on every pair of the forward — the two stores won't reconcile. Both conventions should be stated on the field docs.
  1. Clock-skew edge case — timestamps come from SystemTime::now(); a backwards NTP step can insert a detail record into a bucket already committed via the contains_key marker (src/payment/store.rs:1646), and the next pass silently deletes it without counting it. Low severity, but worth a
    comment. Also inconsistent: event.rs uses .expect(...) on duration_since(UNIX_EPOCH) while store.rs uses unwrap_or(0).
  2. list_channel_pair_stats_in_range can scan the whole store — keys sort by channel pair, not time, so a sparse time range walks every page (each a blocking KVStore round-trip) before returning. The per-call behavior is documented, but callers on the FFI thread may be surprised; worth a doc
    note about cost.

Comment thread src/payment/store.rs Outdated
Comment thread src/payment/store.rs
Comment thread src/data_store.rs
Comment thread src/event.rs Outdated
}]);
}

// LDK reports the incoming and outgoing HTLCs, but not which incoming HTLC funded each

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.

To be honest together with #772 (comment) this seems like a blocker for this PR. If the upstream API doesn't provide sufficient data for us to do the level of detailed aggregation we want to do, and also doesn't allow us to aggregate safely without overcounting on restarts, we probably either a) start with a version that exposes fewer details or b) first change the upstream API altogether so it provides all the data we need to do this correctly and safely.

@tnull

tnull commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Please rebase now that #1024 landed.

benthecarman and others added 2 commits August 19, 2026 04:46
StorableObject required an update representation from every type a
store holds, but only DataStore merges updates into an existing
object. A store that just reads, writes and deletes whole objects
still had to supply an Update type, plus update and to_update
methods that nothing would ever call.

Move those three onto a new UpdatableObject trait, and leave
StorableObject with the id and serialization any store needs.
DataStore bounds on UpdatableObject, so its behavior is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Store only unambiguous single-HTLC forwarding events.

Aggregate events into channel and channel-pair statistics.

Use fixed one-hour buckets for detailed records.

Expose bucket identifiers as opaque strings.

AI-assisted-by: OpenAI Codex

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

Some comments, I think the two main things remaining is that we still don't have a way to handle replayed events and that we should probably reuse DataStore for this (fwiw, there are a few fixed bugs in DiskStore that we can just avoid when we drop the fork/DRY up the code).

Comment thread src/io/mod.rs
pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments";
pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = "";

/// The forwarded payment information will be persisted under this prefix.

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.

Shouldn't all forwarded-payment-store related objects be stored under the same primary namespace, and be discerned by secondary?

pub(crate) const FORWARDED_PAYMENT_AGGREGATION_BUCKET_SIZE_SECS: u64 = 60 * 60;

/// A disk-backed store for forwarding data that is too large to keep in memory.
pub(crate) struct DiskStore<SO: StorableObject> {

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 don't understand why we need this? The idea of DataStore being generic is exactly to avoid adding specialized stores for every instance?

Comment thread src/data_store.rs
}

pub(crate) struct DataStore<SO: StorableObject, L: Deref, P: CachePolicy = KeepAllEntries>
pub(crate) struct DataStore<SO: UpdatableObject, L: Deref, P: CachePolicy = KeepAllEntries>

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 if we leave the general bound SO: StorableObject and move only update, insert_or_update, and related merge methods in a separate impl block bound by UO: UpdatableObject, we should be good to just reuse DataStore for the forwarded payment store, even without adding dummy implementations for update.

Comment thread src/event.rs Outdated
}
}

for inbound_stats in inbound_stats_by_channel.into_values() {

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, @TheBlueMatt recently indicated the HTLC ID was added to PaymentForwarded, but it still doesn't seem to be the case. So AFAIU we have no idempotency token available here. Begs the question if we're fine shipping this with a known bug or how we'd want to deal with this.

Comment thread src/payment/forwarding.rs
///
/// [`Node::forwarding`]: crate::Node::forwarding
#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
pub struct Forwarding {

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.

Ngl, Forwarding is a bit of an odd name - "forwarding what?"

Maybe ForwardingAnalytics or ForwardedPayments?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants