Skip to content

Add storage for forwarded payments - #772

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

Add storage for forwarded payments #772
benthecarman wants to merge 4 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.

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

@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 Outdated
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?

Comment thread src/payment/forwarding_store.rs Outdated
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?

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.

Removed, goal was to not cache all these and just write to store

Comment thread src/data_store.rs Outdated
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 Outdated
@benthecarman

Copy link
Copy Markdown
Contributor Author

Addressed most review comments, however, can hold off until https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/pulls/4912 is merged

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

Addressed most review comments, however, can hold off until https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/pulls/4912 is merged

Cool. That should land shortly, though we'll also need a backport PR to land. That said, it seems all fixups now happened in the second commit. Can you split all changes to DataStore out to 1-2 prefactor commits, also to avoid switching to UpdatableObject and back?

Comment thread src/builder.rs Outdated
Comment thread src/event.rs Outdated
Comment thread src/data_store.rs Outdated
/// Returns whether this store contains no objects.
pub(crate) async fn is_empty(&self) -> Result<bool, Error> {
let mut page_token = None;
loop {

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.

Why do we need this loop here? That seems just like a risk that we eventually could add a bug that loops forever? Whether the store is empty should be a simple O(1) decision based on the first response, no?

Comment thread src/data_store.rs Outdated
}

/// Returns all stored objects matching `f` by reading each page from the backing store.
pub(crate) async fn list_filter_from_store<F: FnMut(&&SO) -> bool>(

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

No, one of the main design goals (i.e., why we introduced the typestate pattern and dropped list_payments_with_filter) of the recent refactor was to disallow any internal callers from making prohibitively expensive listing calls. For a long running node this will be insanely expensive as it always interatively walks the entire uncached store. We can't do this, IMO hence have to drop this and all dependent callers.

@tnull

tnull commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Needs a rebase now.

Use the latest LDK 0.3 revision so forwarding events include stable HTLC
identifiers. Update the payment-instructions pin to keep its LDK types
compatible.

This change was made with assistance from OpenAI Codex.
StorableObject required an update representation from every type a
store holds. Stores that only read, write, and delete whole objects
still had to supply update methods that no caller used.

Move merge behavior to UpdatableObject. Keep DataStore available to all
storable objects, and restrict only merge operations to updatable
objects.

AI-assisted-by: OpenAI Codex and Anthropic Fable
Add a cache policy that reads objects from persistent storage without
keeping them in memory. Add a key-only empty check for namespaces that
can grow without a bounded working set.

AI-assisted-by: OpenAI Codex and Anthropic Fable
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
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