Skip to content

Conversation

@carlaKC
Copy link
Contributor

@carlaKC carlaKC commented Feb 12, 2026

This PR contains all the changes to support trampoline forwarding in LDK.
It depends on #4304 and #4402 (and thus also #4373).


This obviously needs to be broken up into part, and could certainly use some cleaning up, but opening it up early to provide some context for the decisions make in #4304.

wpaulino and others added 30 commits February 9, 2026 14:19
We previously assumed background events would eventually be processed
prior to another `ChannelManager` write, so we would immediately remove
all in-flight monitor updates that completed since the last
`ChannelManager` serialization. This isn't always the case, so we now
keep them all around until we're ready to handle them, i.e., when
`process_background_events` is called.

This was discovered while fuzzing `chanmon_consistency_target` on the
main branch with some changes that allow it to connect blocks. It was
triggered by reloading the `ChannelManager` after a monitor update
completion for an outgoing HTLC, calling
`ChannelManager::best_block_updated`, and reloading the `ChannelManager`
once again. A test is included that provides a minimal reproduction of
this case.
At various points we've been stuck in our TLV read/write variants
but just want to break out and write some damn code to initialize
a field and some more code to decide what to write for a TLV.

We added the write-side part of this with the `legacy` TLV
read/write variant, but its useful to also be able to specify a
function which is called on the read side.

Here we add a `custom` TLV read/write variant which calls a method
both on read and write to either decide what to write or to map a
read value (if any) to the final field.
When `OutboundPayments` calls the provided `Router` to fetch a
`Route` it passes a `RouteParameters` with a specific max-fee. Here
we validate that the `Route` returned sticks to the limits
provided, and also that it meets the MPP rules of not having any
single MPP part which can be removed while still meeting the
desired payment amount.
In some uses of LDK we need the ability to send HTLCs for only a
portion of some larger MPP payment. This allows payers to make
single payments which spend funds from multiple wallets, which may
be important for ecash wallets holding funds in multiple mints or
graduated wallets which hold funds across a trusted wallet and a
self-custodial wallet.

In order to allow for this, we need to separate the concept of the
payment amount from the onion MPP amount. Here we start this
process by adding a `total_mpp_amount_msat` field to
`RecipientOnionFields` (which is the appropriate place for a field
describing something in the recipient onion).

We currently always assert that it is equal to the existing fields,
but will relax this in the coming commit(s).

We also start including a payment preimage on probe attempts,
which appears to have been the intent of the code, but which did
not work correctly.

The bulk of the test updates were done by Claude.
In some uses of LDK we need the ability to send HTLCs for only a
portion of some larger MPP payment. This allows payers to make
single payments which spend funds from multiple wallets, which may
be important for ecash wallets holding funds in multiple mints or
graduated wallets which hold funds across a trusted wallet and a
self-custodial wallet.

In the previous commit we added a new field to
`RecipientOnionFields` to describe the total value of an MPP
payment. Here we start using this field when building onions,
dropping existing arguments to onion-building methods.
In some uses of LDK we need the ability to send HTLCs for only a
portion of some larger MPP payment. This allows payers to make
single payments which spend funds from multiple wallets, which may
be important for ecash wallets holding funds in multiple mints or
graduated wallets which hold funds across a trusted wallet and a
self-custodial wallet.

In the previous commits we moved the total-MPP-value we set in
onions from being manually passed through onion-building to passing
it via `RecipientOnionFields`. This introduced a subtle bug, though
 - payments which are retried will get a fresh
`RecipientOnionFields` built from the data in
`PendingOutboundPayment::Retryable`, losing any custom
total-MPP-value settings and causing retries to fail.

Here we fix this by storing the total-MPP-value directly in
`PendingOutboundPayment::Retryable`.
In some uses of LDK we need the ability to send HTLCs for only a
portion of some larger MPP payment. This allows payers to make
single payments which spend funds from multiple wallets, which may
be important for ecash wallets holding funds in multiple mints or
graduated wallets which hold funds across a trusted wallet and a
self-custodial wallet.

In the previous few commits we added support for making these
kinds of payments when using the payment methods which explicitly
accepted a `RecipientOnionFields`. Here we also add support for
such payments made via the `pay_for_bolt11_invoice` method,
utilizing the new `OptionalBolt11PaymentParams` to hide the
parameter from most calls.

Test mostly by Claude
When we receive an HTLC as a part of a claim, we validate that the
CLTV on the HTLC is >= the CLTV that the sender requested we
receive, but then we use the CLTV value that the sender requested
we receive as the deadline to claim the HTLC anyway.

This isn't generally all that interesting (they're always the same
unless the previous-hop node gave us "free CLTV"), but for
trampoline payments where we're both a trampoline hop and the
blinded intro point and the recipient, it means we end up allowing
ourselves less claim time than we actually have.

Instead, here, we just use the actual HTLC CLTV deadline.
The docs for `RouteHop::cltv_expiry_delta` claim that it includes
any trampoline hops, but the way we actually implemented onion
building it did not.

Because the docs described a simpler and more backwards-compatible
API, we update the onion-building logic to match rather than
updating the docs.
Now that we've cleaned up trampoline CLTV building and added
`Path::total_cltv_expiry_delta`, we can use both to do some basic
validation of CLTV values on blinded tails in
`Route::debug_assert_route_meets_params`
Now that we are consistently using the
`RouteHop::cltv_expiry_delta` as the last hop's starting CLTV
rather than summing trampoline hops, `starting_htlc_offset` is a
bit confusing - its actually always the current block height. Thus,
here we rename it.
In the commits that follow, we want to be able to free the other
channel without emitting an event so that we can emit a single event
for trampoline payments with multiple incoming HTLCs. We still want
to go through the full claim flow for each incoming HTLC (and persist
the EmitEventAndFreeOtherChannel event to be picked up on restart), but
do not want multiple events for the same trampoline forward.

Changing from upgradable_required to upgradable_option is forwards
compatible - old versions of the software will always have written this
field, newer versions don't require it to be there but will be able to
read it as-is.

This change is not backwards compatible, because older versions of the
software will expect the field to be present but newer versions may not
write it. An alternative would be to add a new event type, but that
would need to have an even TLV (because the event must be understood
and processed on restart to claim the incoming HTLC), so that option
isn't backwards compatible either.
In preparation for trampoline failures, allow multiple previous channel
ids. We'll only emit a single HTLCHandlingFailed for all of our failed
back HTLCs, so we want to be able to express all of them in one event.
This commit adds a SendHTLCId for trampoline forwards, identified by
their session_priv. As with an OutboundRoute, we can expect our HTLC
to be uniquely identified by a randomly generated session_priv.

TrampolineForward could also be identified by the set of all previous
outbound scid/htlc id pairs that represent its incoming HTLC(s). We
choose the 32 byte session_priv to fix the size of this identifier
rather than 16 byte scid/id pairs that will grow with the number of
incoming htlcs.
We only have payment details for HTLCSource::TrampolineForward available
once we've dispatched the payment. If we get to the stage where we need
a HTLCId for the outbound payment, we expect dispatch details to be
present.

Co-authored-by: Arik Sosman <git@arik.io>
Co-authored-by: Maurice Poirrier <mpch@hey.com>
To create the right handling type based on source, add a helper. This
is mainly useful for PreviousHopData/TrampolineForward. This helper
maps an OutboundRoute to a HTLCHandlingFailureType::Forward. This value
isn't actually used once we reach `forward_htlc_backwards_internal`,
because we don't emit `HTLCHandlingFailed` events for our own payments.
This issue is pre-existing, and could be addressed with an API change
to the failure function, which is left out of scope of this work.
When we receive trampoline payments, we first want to validate the
values in our outer onion to ensure that we've been given the amount/
expiry that the sender was intending us to receive to make sure that
forwarding nodes haven't sent us less than they should.
Use even persistence value because we can't downgrade with a trampoline
payment in flight, we'll fail to claim the appropriate incoming HTLCs.

We track previous_hop_data in `TrampolineForwardInfo` so that we have
it on hand in our `OutboundPayment::Retryable`to build `HTLCSource` for
our retries.
When we are forwading as a trampoline within a blinded path, we need
to be able to set a blinding point in the outer onion so that
the next blinded trampoline can use it to decrypt its inner onion.

This is only used for relaying nodes in the blinded path, because the
introduction node's inner onion is encrypted using its node_id
(unblinded) pubkey so it can retrieve the path key from inside its
trampoline onion. Relaying nodes node_id is unknown to the original
sender, so their inner onion is encrypted with their blinded identity.
Relaying trampoline nodes therefore have to include the path key in the
outer payload so that the inner onion can be decrypted, which in turn
contains their blinded data for forwarding.

This isn't used for the case where we're the sending node, because all
we have to do is include the blinding point for the introduction node.
For relaying nodes, we just put their encrypted data inside of their
trampoline payload, relying on nodes in the blinded path to pass the
blinding point along.
When we're a forwarding trampoline and we receive a final error from
our route, we want to propagate that failure back to the original
sender. Surface the information so that it's available to us.
- [ ] Check whether we can get away with checking path.hops[0] directly
  (outbound_payment should always be present?)
When we settle a trampoline forward, we'd need to look up all of our
incoming/outgoing htlc amounts to calculate the fee we've earned.
Instead, we just look it up in our outbound payments.

Note that here we report the minimum fee that we charged for the
forward. It's possible that the rest of the trampoline route was under
its allowed budget, and we earned more than our required fee.
We're going to need to keep track of our trampoline HLTCs in the same
way that we keep track of incoming MPP payment to allow them to
accumulate on our incoming channel before forwarding them onwards to
the outgoing channel. To do this we'll need to store the payload
values we need to remember for forwarding in OnionPayload.

- [ ] Readable for ClaimableHTLC is incomplete
When we are a trampoline router, we need to accumulate incoming HTLCs
(if MPP is used) before forwarding the trampoline-routed outgoing
HTLC(s). This commits adds a new map in channel manager, and mimics the
handling done for claimable_payments.

This map is not placed in claimable_payments because we'll need to
be able to lock pending_outbound_payments in the commits that follow
while holding a lock on our set of trampoline payments (which is not
possible with claimable_payments).

- [ ] Need to add persistence of trampoline payments
We're going to use the same logic for trampoline and for incoming MPP
payments, so we pull this out into a separate function.
We'll only use this for non-trampoline incoming accumulated htlcs,
because we want different source/failure for trampoline.
- [ ] Need to return real errors here
- [ ] Need to be able to handle committed_to_claimable if we fail
- [ ] Not sure what this does, look into it
For trampoline payments, we don't want to enforce a minimum cltv delta
between our incoming and outer onion outgoing CLTV because we'll
calculate our delta from the inner trampoline onion's value. However,
we still want to check that we get at least the CLTV that the sending
node intended for us and we still want to validate our incoming value.
Refactor to allow setting a zero delta, for use for trampoline payments.
Remove error that was added to prevent forwarding of trampoline payments
during development, and instead process incoming trampoline htlcs. We
can't perform proper validation because we don't know the outgoing
channel id until we forward the HTLC, so we just perform a basic CLTV
check.
The blinding point that we pass in is supposed to be the "update add"
blinding point equivalent, which in blinded trampoline relay is the
one that we get in the outer onion.
Don't always blindly replace with a manually built test onion when we
run trampoline tests (only for unblinded / failure cases where we need
to mess with the onion).

The we update our replacement onion logic to correctly match our
internal behavior which adds one block to the current height when
dispatching payments.
- [ ] Right now, we assume that the presence of a trampoline means
      that we're in a blinded route. This fails when we test an
      unblinded case (which we do to get coverage for forwarding).
      We likely need to decouple trampoline and blinded tail to allow
      this to work properly.
@ldk-reviews-bot
Copy link

👋 Hi! I see this is a draft PR.
I'll wait to assign reviewers until you mark it as ready for review.
Just convert it out of draft status when you're ready for review!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants