Skip to content

Add storage for forwarded payments #772

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

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

/// Retrieves all forwarded payments.
pub fn list_forwarded_payments(&self) -> Vec<ForwardedPaymentDetails> {

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.

This makes we wonder if we should wait for lightningdevkit/rust-lightning#4347 (and some follow-up PRs for implementations here ) to land so this can directly use pagination from the start?

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 figured we weren't doing a release until the next ldk release so that'd be a given but can hold off on it if that's not the case

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.

Yeah, given that the PaginatedKVStore is already pretty far along, let's wait on in here. Thankfully we can then directly bump the LDK dependency.

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

impl StorableObjectId for PaymentId {
fn encode_to_hex_str(&self) -> String {

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 a bit dubious why we need this refactor? Can you elaborate?

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.

ChannelPairStatsId is 80 bytes, so its hex would be 160 characters, which is larger than the KVStore key limit of 120. For ChannelPairStatsId I used base64 instead, which is 107 characters so it can fit. Couldn't really trim ChannelPairStatsId either

Comment thread src/ffi/types.rs Outdated
},
});

uniffi::custom_type!(ForwardedPaymentId, String, {

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.

Would be good to avoid the custom types if we can. Note that they are error prone as if conversion fails in a path where Error can't be emitted (i.e., we can't bubble up a Result<X, Error>), it will have the node panic. While most callsites should be safe, we therefore still aim to reduce the number of custom type implementations over time.

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

Comment thread src/config.rs Outdated
Detailed {
/// The length of each aggregation bucket, in minutes. Must be non-zero.
///
/// The first detailed configuration persists this value. Later detailed configurations for

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.

This seems like an error prone API. If users can't change this config after the fact we maybe shouldn't allow them to configure it in the first place or provide an API design that communicates that outside of docs (who users tend to read after the fact).

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.

Switched to a fixed retention time

Comment thread src/event.rs
}]);
}

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

Comment thread src/lib.rs
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
}
}

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

Comment thread src/event.rs
Update LDK and payment instructions to compatible revisions and adapt
forwarded and unified payment handling to their API changes. Keep both
native and UniFFI test targets compiling.

AI-assisted-by: OpenAI Codex
Allow storable objects to choose compact non-hexadecimal key
encodings. Forwarding statistics use this to keep composite identifiers
within the backing KVStore's key-length limit.

AI-assisted-by: OpenAI Codex
Store individual forwarding events and aggregate them into channel and
channel-pair statistics for fee and profitability tracking.

Use fixed one-hour buckets for detailed records and expose their
identifiers as opaque strings.

Infer channel-pair allocations for multi-HTLC forwards by matching
incoming and outgoing amounts in FIFO order.

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