Skip to content

Add BOLT11 underpaying send RPC - #242

Open
benthecarman wants to merge 1 commit into
lightningdevkit:mainfrom
benthecarman:underpay
Open

Add BOLT11 underpaying send RPC#242
benthecarman wants to merge 1 commit into
lightningdevkit:mainfrom
benthecarman:underpay

Conversation

@benthecarman

Copy link
Copy Markdown
Collaborator

Expose Bolt11SendUnderpaying through the proto API, server handler, client, CLI, MCP tool registry, tests, and docs.

@ldk-reviews-bot

ldk-reviews-bot commented Jul 6, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @tankyleo 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.

@f3r10

f3r10 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Nice addition @benthecarman — I wanted to understand this one end-to-end since send_using_amount_underpaying was new to me, so I built this branch and tested it locally against a real LND node (via Polar, regtest).

From reading the ldk-node doc comment, this works by declaring the invoice's full amount as the MPP total_msat while only sending part of it from this node — so it only completes if something else supplies the rest. Testing that: I opened a channel from ldk-server to an LND node, created a 100,000 sat invoice on LND, and sent 50,000 sats via Bolt11SendUnderpaying.
LND accepted the HTLC as a valid partial MPP payment (state: ACCEPTED, mpp_total_amt_msat: 100000000), then canceled it exactly 120 seconds later once no second part arrived, and the payment failed on the ldk-server side at the same instant. So as far as I can tell, calling this RPC on its own — which is the only way ldk-server currently exposes it — reliably locks liquidity on both sides of the route for ~2 minutes and then fails, since there's no complementary API here to send the other part of the MPP payment.

I might be missing an intended use case (e.g. something that pairs with an LSP or a second sender?) — is this meant to be used standalone, or does it assume some other piece that isn't part of this PR?

@benthecarman

Copy link
Copy Markdown
Collaborator Author

@f3r10 yes its intented to be used with another node as well, one node pays half, another node pays another half. For thing like https://github.com/lightningdevkit/orange-sdk

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

One thing I noticed: there's no test in e2e-tests/ covering this yet, even though test_cli_bolt11_send already has a nice 2-node pattern to build on there. Since the interesting behavior here specifically needs two independent payers, I put together a 3-node version (two payers, each with their own direct channel into one receiver, each sending half via bolt11-send-underpaying, asserting the receiver only gets PaymentReceived once both halves land) and ran it locally against this branch — it passes:

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_cli_bolt11_send_underpaying_split_payment() {
	let bitcoind = TestBitcoind::new();
	let server_a = LdkServerHandle::start(&bitcoind).await; // payer 1
	let server_b = LdkServerHandle::start(&bitcoind).await; // payer 2
	let server_c = LdkServerHandle::start(&bitcoind).await; // receiver

	// Subscribe to events on all three nodes before any payment is sent.
	let mut events_a = server_a.client().subscribe_events().await.unwrap();
	let mut events_b = server_b.client().subscribe_events().await.unwrap();
	let mut events_c = server_c.client().subscribe_events().await.unwrap();

	// Each payer gets its own direct channel into the receiver. Sized well above the
	// 50,000 sat HTLC each payer will send, since LDK caps a channel's max HTLC size to a
	// fraction of its capacity (a too-small channel here causes RouteNotFound).
	setup_funded_channel(&bitcoind, &server_a, &server_c, 300_000).await;
	setup_funded_channel(&bitcoind, &server_b, &server_c, 300_000).await;

	// C creates a single invoice for the full amount the two payers will jointly cover.
	let invoice_resp = server_c
		.client()
		.bolt11_receive(Bolt11ReceiveRequest {
			amount_msat: Some(100_000_000), // 100,000 sats total
			description: Some(Bolt11InvoiceDescription {
				kind: Some(bolt11_invoice_description::Kind::Direct(
					"split payment test".to_string(),
				)),
			}),
			expiry_secs: 3600,
		})
		.await
		.unwrap();

	// Both payers independently underpay by half. They're sent back-to-back so the combined
	// total reaches the invoice amount well within the receiver's MPP hold window.
	let output_a =
		run_cli(&server_a, &["bolt11-send-underpaying", &invoice_resp.invoice, "50000sat"]);
	let output_b =
		run_cli(&server_b, &["bolt11-send-underpaying", &invoice_resp.invoice, "50000sat"]);
	assert!(!output_a["payment_id"].as_str().unwrap().is_empty());
	assert!(!output_b["payment_id"].as_str().unwrap().is_empty());

	// The receiver only sees PaymentReceived once *both* partial HTLCs have arrived and the
	// combined total matches the invoice amount.
	let event_c = wait_for_event(&mut events_c, |e| matches!(e, Event::PaymentReceived(_))).await;
	assert!(matches!(&event_c.event, Some(Event::PaymentReceived(_))));

	// Both payers should see their half complete successfully.
	let event_a = wait_for_event(&mut events_a, |e| matches!(e, Event::PaymentSuccessful(_))).await;
	assert!(matches!(&event_a.event, Some(Event::PaymentSuccessful(_))));
	let event_b = wait_for_event(&mut events_b, |e| matches!(e, Event::PaymentSuccessful(_))).await;
	assert!(matches!(&event_b.event, Some(Event::PaymentSuccessful(_))));
}

(One gotcha along the way worth mentioning in case it saves someone else time: with 100,000-sat channels the test failed with RouteNotFound, because LDK caps a channel's max single-HTLC size to a fraction of its capacity — the 50,000-sat underpaying HTLC didn't fit. Bumping the channels to 300,000 sats fixed it.)

Expose Bolt11SendUnderpaying through the proto API, server handler,
client, CLI, MCP tool registry, tests, and docs.

AI-assisted-by: OpenAI Codex
@benthecarman

Copy link
Copy Markdown
Collaborator Author

@f3r10 thanks added

@benthecarman
benthecarman requested review from tankyleo and removed request for valentinewallace August 6, 2026 22:31
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.

3 participants