Skip to content

Expose BOLT11 underpayment sends#939

Open
benthecarman wants to merge 2 commits into
lightningdevkit:mainfrom
benthecarman:underpay-htlc
Open

Expose BOLT11 underpayment sends#939
benthecarman wants to merge 2 commits into
lightningdevkit:mainfrom
benthecarman:underpay-htlc

Conversation

@benthecarman

Copy link
Copy Markdown
Contributor

Needed for orange-sdk

Add a BOLT11 payment API for sending less than the invoice amount while
using the invoice amount as the declared total MPP value. Cover the path
with an integration test where two nodes each pay half of one invoice and
the receiver claims the full amount.

Did a refactor before to clean up and unify the bolt11 send functions.

@benthecarman benthecarman requested a review from tnull June 12, 2026 20:36
@ldk-reviews-bot

ldk-reviews-bot commented Jun 12, 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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

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

Few comments, but generally looks good. What will be the BOLT12 story for partial payments?

Comment thread src/payment/bolt11.rs Outdated
Comment thread tests/integration_tests_rust.rs Outdated
node.sync_wallets().unwrap();
}

expect_channel_ready_event!(node_c, node_a.node_id());

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 is flaky:

thread 'split_underpaid_bolt11_payment' (63174) panicked at tests/integration_tests_rust.rs:358:5:
assertion left == right failed
left: Some(PublicKey(188a7ccb0c94bfc5d135a2fcc02a37cca78d4c2c98f72912990ed0e594f5b765547537a41c32c32a0c87302c2d6ced61f6033d2b7c2e4109bb45c29b41439326))
right: Some(PublicKey(8cfa601d1be90d36097200793e66c50ac90b4836998149d5b04fc13caaadad08a5e6fa0651778c0eaa5826c52112c8aef2dd211b8fd92efff371063335858396))
note: run with RUST_BACKTRACE=1 environment variable to display a backtrace

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.

fixed, needed to use expect_channel_ready_events because the ordering for the two channel ready events is ~random

Comment thread tests/integration_tests_rust.rs Outdated
premine_and_distribute_funds(
&bitcoind.client,
&electrsd.client,
vec![addr_c],

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.

If you only fund the one node, the others should reject incoming channels due to lack of reserves. This will have you fall back to staticremotekey, likely also fueling the flake below.

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.

fixed

@benthecarman

benthecarman commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

What will be the BOLT12 story for partial payments?

lightningdevkit/rust-lightning#4380

Share the common BOLT11 payment send flow between fixed-amount and
explicit-amount sends so follow-up API variants can reuse the same
payment-store and error handling path.

AI-Tool-Disclosure: Created with OpenAI Codex.
Add a BOLT11 payment API for sending less than the invoice amount while
using the invoice amount as the declared total MPP value. Cover the path
with an integration test where two nodes each pay half of one invoice and
the receiver claims the full amount.

AI-Tool-Disclosure: Created with OpenAI Codex.
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd Reminder

Hey @tnull! 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.

@comwanga

Copy link
Copy Markdown

Not assigned to this PR, but wanted to share a few observations;

The refactor into send_internal is a clean move — it removes the duplicated routing and payment-store logic across send and send_using_amount, and the new send_using_amount_underpaying builds on that foundation in a straightforward way. The integration test covers the core scenario well, and the fix for the channel-ready event ordering flake (expect_channel_ready_events! plural) is the right call.

A few things worth discussing:

1. amount_msat vs payment_amount_msat passed to pay_for_bolt11_invoice

In send_internal, you compute payment_amount_msat to resolve the actual amount to send, but the call to channel_manager.pay_for_bolt11_invoice still passes the original amount_msat (the Option<u64> parameter), not Some(payment_amount_msat). For send(), this passes None, which is intentional — LDK reads the amount from the invoice. But it is worth double-checking whether this is also the intended behavior when send_using_amount calls send_internal with Some(amount_msat). The two values are the same in that path so there is no bug, but it would be clearer to pass Some(payment_amount_msat) consistently to make the intent explicit and avoid confusion if send_internal is extended later.

2. invalid_amount_log is reused for two distinct error conditions

The same invalid_amount_log string is logged for both the case where no amount can be resolved (zero-amount invoice with no override) and the Bolt11PaymentError::InvalidAmount from LDK. These are different failures — one is a precondition check on our side, the other is a protocol-level rejection from LDK. Sharing one log string means the log message may be misleading depending on which path fires. Consider whether it is worth using separate log strings for each arm, or at least adding a note in the doc comment about what constitutes an "insufficient" amount.

3. PaymentDetails stores the sent amount, not the invoice amount — verify this is documented

For send_using_amount_underpaying, PaymentDetails records Some(half_amount_msat) while the invoice is for full_amount_msat. This is technically correct since it reflects what this node sent, and the integration test asserts this. But callers querying list_payments may be surprised to see an amount that does not match the invoice they paid. It may be worth adding a note to the doc comment on send_using_amount_underpaying explaining that the stored amount reflects the amount sent by this node, not the declared MPP total.

4. Error type for amount >= invoice_amount check

In send_using_amount_underpaying, when amount_msat >= invoice_amount_msat, you return Err(Error::InvalidAmount). The validation in send_using_amount for an over-amount scenario returns Err(Error::InvalidAmount) too, so this is consistent. Just confirming this is intentional rather than Error::InvalidInvoice.

5. Test: missing assertion that payment fails if amount equals invoice amount

The integration test verifies the happy path where both halves add up to the full amount. There is no test for the boundary case where a caller passes amount_msat == invoice_amount_msat to send_using_amount_underpaying, which should return InvalidAmount based on the >= check. A small negative test for this edge would strengthen confidence in that guard.

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