Skip to content

Conversation

@TheBlueMatt
Copy link
Collaborator

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.

This adds support for it both in the more manual send_payment flow as well as pay_for_bolt11_invoice. Adding support for BOLT 12 is left for a followup. cc @benthecarman

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Feb 2, 2026

👋 I see @jkczyz was un-assigned.
If you'd like another reviewer assignment, please click here.

@TheBlueMatt TheBlueMatt force-pushed the 2026-02-partial-mpp-payments branch 2 times, most recently from a4b00d4 to e8d40e1 Compare February 3, 2026 02:39
@codecov
Copy link

codecov bot commented Feb 3, 2026

Codecov Report

❌ Patch coverage is 83.20312% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.01%. Comparing base (56f12c8) to head (db6aa28).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/routing/router.rs 48.83% 21 Missing and 1 partial ⚠️
lightning/src/ln/outbound_payment.rs 86.53% 11 Missing and 3 partials ⚠️
lightning/src/ln/channelmanager.rs 90.24% 2 Missing and 2 partials ⚠️
lightning/src/ln/onion_utils.rs 94.33% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4373      +/-   ##
==========================================
- Coverage   86.02%   86.01%   -0.02%     
==========================================
  Files         156      156              
  Lines      103046   103169     +123     
  Branches   103046   103169     +123     
==========================================
+ Hits        88646    88739      +93     
- Misses      11888    11910      +22     
- Partials     2512     2520       +8     
Flag Coverage Δ
tests 86.01% <83.20%> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TheBlueMatt TheBlueMatt force-pushed the 2026-02-partial-mpp-payments branch 3 times, most recently from 93228cf to b6b973f Compare February 3, 2026 14:12
@TheBlueMatt TheBlueMatt added this to the 0.3 milestone Feb 4, 2026
@TheBlueMatt
Copy link
Collaborator Author

Opportunistically tagging this 0.3 since its rather important for some of our users (and its important for my wallet project!)

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

if let Some(proc_macro2::TokenTree::Group(group)) = ty_tokens {
let first_token = group.stream().into_iter().next();
if let Some(proc_macro2::TokenTree::Ident(ident)) = first_token {
if is_init && ident == "legacy" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this also suppose to check for "custom"? Without doing so, I'm unable to use this for a legacy field where it needs to be read in order to update another field.

error[E0560]: struct `FundingTxInput` has no field named `_sequence`
   --> lightning/src/ln/funding.rs:120:6
    |
120 |     (3, _sequence, (custom, Sequence,
    |         ^^^^^^^^^ `FundingTxInput` does not have this field
    |
    = note: all struct fields are already assigned

For more information about this error, try `rustc --explain E0560`.
error: could not compile `lightning` (lib) due to previous error

This is what I tried in #4290.

diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 3b7c0bedb..2de763118 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -117,7 +117,19 @@ pub struct FundingTxInput {
 
 impl_writeable_tlv_based!(FundingTxInput, {
        (1, utxo, required),
-       (3, _sequence, (legacy, Sequence, |input: &FundingTxInput| Some(input.utxo.sequence))),
+       (3, _sequence, (custom, Sequence,
+               |read_val: Option<Sequence>| {
+                       if let Some(sequence) = read_val {
+                               // Utxo contains sequence now, so update it if the value read here differs since
+                               // this indicates Utxo::sequence was read with the default_value
+                               let utxo: &mut Utxo = utxo.0.as_mut().expect("utxo is required");
+                               if utxo.sequence != sequence {
+                                       utxo.sequence = sequence;
+                               }
+                       }
+                       Ok(read_val)
+               },
+               |input: &FundingTxInput| Some(input.utxo.sequence))),
        (5, prevtx, required),
 });

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, no, grrrrrrr. I was using custom for the "we have a field we need to initialize, but we need to do so via some special logic" rather than what you really needed which is "we have removed this field and want to have some logic when reading its old TLV". I think what we actually need is a read method to legacy that works like the custom case but is ignored for field-initialization cases.

Copy link
Contributor

@jkczyz jkczyz Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about checking if the field started with an underscore? Too risky / non-obvious?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, seems weird. legacy was kinda all about "item removed" whereas custom is about "item needs special handling". IMO it makes sense to have a method called on legacy after its read.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #4390.

@wpaulino wpaulino requested review from valentinewallace and removed request for wpaulino February 5, 2026 19:05
Copy link
Contributor

@valentinewallace valentinewallace left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs rebase, getting comments out so GH doesn't eat them

@TheBlueMatt TheBlueMatt force-pushed the 2026-02-partial-mpp-payments branch 2 times, most recently from 2abd54b to 3d72f76 Compare February 6, 2026 17:05
@TheBlueMatt
Copy link
Collaborator Author

Rebased and addressed feedback. Note that I noticed the CLTV delta stuff for trampoline is a bit wrong so that's not checked here.

@TheBlueMatt TheBlueMatt force-pushed the 2026-02-partial-mpp-payments branch 2 times, most recently from 1199749 to db6aa28 Compare February 6, 2026 17:43
@TheBlueMatt TheBlueMatt force-pushed the 2026-02-partial-mpp-payments branch from db6aa28 to d0a914c Compare February 10, 2026 12:25
@TheBlueMatt
Copy link
Collaborator Author

Rebased without conflicts, not sure why github thought this had conflicts. Because #4402 is now built on this would be good to move quicker.

Copy link
Contributor

@valentinewallace valentinewallace left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically looks good! I haven't looked at the mechanical changes too closely. Fuzz CI needs a fix.

/// If this is lower than the `amount_msats` passed to
/// [`ChannelManager::pay_for_bolt11_invoice`] the call will fail with
/// [`Bolt11PaymentError::InvalidAmount`].
pub declared_total_mpp_value_override: Option<u64>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplify to total_mpp_value_override? It feels like we might want to include _msat in the name as well?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplify to total_mpp_value_override?

It felt like declared added a "this is the amount we're telling the recipient is the MPP value" rather than allowing it to be confusing for the actual MPP value we send.

It feels like we might want to include _msat in the name as well?

Should be fixed by #4408, anyway :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It felt like declared added a "this is the amount we're telling the recipient is the MPP value" rather than allowing it to be confusing for the actual MPP value we send.

I feel like "override" already accomplishes that. Whatevs though

Comment on lines 1053 to 1059
if mpp_amt < amount {
return Err(Bolt11PaymentError::InvalidAmount);
}
if let Some(invoice_amount) = invoice.amount_milli_satoshis() {
if mpp_amt < invoice_amount {
return Err(Bolt11PaymentError::InvalidAmount);
}
Copy link
Contributor

@valentinewallace valentinewallace Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have test coverage of the error cases, just the happy path. Also missing coverage of the retries portion. Could also be interesting to test MPP-within-the-multi-node-MPP?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I had claude write a test that just does a simple retry. I didn't bother trying to push it to do an MPP but could ask if it you feel strongly on it - i figured it didn't matter.

@jkczyz jkczyz removed their request for review February 11, 2026 19:37
@ldk-reviews-bot
Copy link

✅ Added second reviewer: @tankyleo

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
@TheBlueMatt TheBlueMatt force-pushed the 2026-02-partial-mpp-payments branch from cdb4435 to 85686ab Compare February 12, 2026 15:15
@TheBlueMatt
Copy link
Collaborator Author

Had to rebase again.

payment_metadata: payment_metadata.clone(),
custom_tlvs: custom_tlvs.clone(),
total_mpp_amount_msat: *total_msat,
total_mpp_amount_msat: *onion_total_msat,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's somewhat out-of-scope, but we could prevent subtle bugs like this by encoding the entire RecipientOnionFields in the ::Retryable variant instead of the individual fields.

/// If this is lower than the `amount_msats` passed to
/// [`ChannelManager::pay_for_bolt11_invoice`] the call will fail with
/// [`Bolt11PaymentError::InvalidAmount`].
pub declared_total_mpp_value_override: Option<u64>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It felt like declared added a "this is the amount we're telling the recipient is the MPP value" rather than allowing it to be confusing for the actual MPP value we send.

I feel like "override" already accomplishes that. Whatevs though

}

/// We write the [`ClaimableHTLC`] [`RecipientOnionFields`] separately as they were added sometime
/// later. Because [`ClaimableHTLC`] only implements [`ReadableArgs`] and have to add a wrapper
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: s/and have/we have

}

impl RecipientOnionFields {
/// Creates a [`RecipientOnionFields`] from only a [`PaymentSecret`]. This is the most common
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs are outdated here now

}
}

/// Creates a new [`RecipientOnionFields`] with no fields. This generally does not create
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar here

/// later. Because [`ClaimableHTLC`] only implements [`ReadableArgs`] and have to add a wrapper
/// which reads them without [`RecipientOnionFields::total_mpp_amount_msat`] and then fill them in
/// later.
struct AmountlessClaimablePaymentHTLCOnion(RecipientOnionFields);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this hunk should move to above/below the channelmanager read code

{
return Err(DecodeError::InvalidValue);
}
onion.0.total_mpp_amount_msat = htlcs_total_msat;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of the way we're handling serialization/backwards compat in this PR. Given the RecipientOnionFields new total_msat field is redundant on the receiver-side, maybe it would be better to make a new struct for that that omits the field, to also sidestep these concerns?

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.

4 participants