From 38b65b2cccb16867f0cda95b3fdf676d1e370442 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 12 Jun 2026 15:05:03 -0500 Subject: [PATCH 1/2] Extract BOLT11 send helper 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. --- src/payment/bolt11.rs | 150 +++++++++++++----------------------------- 1 file changed, 47 insertions(+), 103 deletions(-) diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 068269997..1761133f2 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -279,20 +279,15 @@ mod tests { } } -#[cfg_attr(feature = "uniffi", uniffi::export)] impl Bolt11Payment { - /// Send a payment given an invoice. - /// - /// If `route_parameters` are provided they will override the default as well as the - /// node-wide parameters configured via [`Config::route_parameters`] on a per-field basis. - pub fn send( - &self, invoice: &Bolt11Invoice, route_parameters: Option, + fn send_internal( + &self, invoice: &LdkBolt11Invoice, amount_msat: Option, + route_parameters: Option, invalid_amount_log: &'static str, ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } - let invoice = maybe_deref(invoice); let payment_hash = invoice.payment_hash(); let payment_id = PaymentId(invoice.payment_hash().0); if let Some(payment) = self.payment_store.get(&payment_id) { @@ -308,6 +303,13 @@ impl Bolt11Payment { route_parameters.or(self.config.route_parameters).unwrap_or_default(); let retry_strategy = Retry::Timeout(LDK_PAYMENT_RETRY_TIMEOUT); let payment_secret = Some(*invoice.payment_secret()); + let payment_amount_msat = match amount_msat.or_else(|| invoice.amount_milli_satoshis()) { + Some(amount_msat) => amount_msat, + None => { + log_error!(self.logger, "{}", invalid_amount_log); + return Err(Error::InvalidInvoice); + }, + }; let optional_params = OptionalBolt11PaymentParams { retry_strategy, @@ -317,14 +319,17 @@ impl Bolt11Payment { match self.channel_manager.pay_for_bolt11_invoice( invoice, payment_id, - None, + amount_msat, optional_params, ) { Ok(()) => { let payee_pubkey = invoice.recover_payee_pub_key(); - let amt_msat = - invoice.amount_milli_satoshis().expect("invoice amount should be set"); - log_info!(self.logger, "Initiated sending {}msat to {}", amt_msat, payee_pubkey); + log_info!( + self.logger, + "Initiated sending {} msat to {}", + payment_amount_msat, + payee_pubkey + ); let kind = PaymentKind::Bolt11 { hash: payment_hash, @@ -335,7 +340,7 @@ impl Bolt11Payment { let payment = PaymentDetails::new( payment_id, kind, - invoice.amount_milli_satoshis(), + Some(payment_amount_msat), None, PaymentDirection::Outbound, PaymentStatus::Pending, @@ -346,9 +351,7 @@ impl Bolt11Payment { Ok(payment_id) }, Err(Bolt11PaymentError::InvalidAmount) => { - log_error!(self.logger, - "Failed to send payment due to the given invoice being \"zero-amount\". Please use send_using_amount instead." - ); + log_error!(self.logger, "{}", invalid_amount_log); return Err(Error::InvalidInvoice); }, Err(Bolt11PaymentError::SendingFailed(e)) => { @@ -365,7 +368,7 @@ impl Bolt11Payment { let payment = PaymentDetails::new( payment_id, kind, - invoice.amount_milli_satoshis(), + Some(payment_amount_msat), None, PaymentDirection::Outbound, PaymentStatus::Failed, @@ -378,6 +381,29 @@ impl Bolt11Payment { }, } } +} + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl Bolt11Payment { + /// Send a payment given an invoice. + /// + /// If `route_parameters` are provided they will override the default as well as the + /// node-wide parameters configured via [`Config::route_parameters`] on a per-field basis. + pub fn send( + &self, invoice: &Bolt11Invoice, route_parameters: Option, + ) -> Result { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + + let invoice = maybe_deref(invoice); + self.send_internal( + invoice, + None, + route_parameters, + "Failed to send payment due to the given invoice being \"zero-amount\". Please use send_using_amount instead.", + ) + } /// Send a payment given an invoice and an amount in millisatoshis. /// @@ -406,94 +432,12 @@ impl Bolt11Payment { } } - let payment_hash = invoice.payment_hash(); - let payment_id = PaymentId(invoice.payment_hash().0); - if let Some(payment) = self.payment_store.get(&payment_id) { - if payment.status == PaymentStatus::Pending - || payment.status == PaymentStatus::Succeeded - { - log_error!(self.logger, "Payment error: an invoice must not be paid twice."); - return Err(Error::DuplicatePayment); - } - } - - let route_params_config = - route_parameters.or(self.config.route_parameters).unwrap_or_default(); - let retry_strategy = Retry::Timeout(LDK_PAYMENT_RETRY_TIMEOUT); - let payment_secret = Some(*invoice.payment_secret()); - - let optional_params = OptionalBolt11PaymentParams { - retry_strategy, - route_params_config, - ..Default::default() - }; - match self.channel_manager.pay_for_bolt11_invoice( + self.send_internal( invoice, - payment_id, Some(amount_msat), - optional_params, - ) { - Ok(()) => { - let payee_pubkey = invoice.recover_payee_pub_key(); - log_info!( - self.logger, - "Initiated sending {} msat to {}", - amount_msat, - payee_pubkey - ); - - let kind = PaymentKind::Bolt11 { - hash: payment_hash, - preimage: None, - secret: payment_secret, - counterparty_skimmed_fee_msat: None, - }; - - let payment = PaymentDetails::new( - payment_id, - kind, - Some(amount_msat), - None, - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - self.runtime.block_on(self.payment_store.insert(payment))?; - - Ok(payment_id) - }, - Err(Bolt11PaymentError::InvalidAmount) => { - log_error!( - self.logger, - "Failed to send payment due to amount given being insufficient." - ); - return Err(Error::InvalidInvoice); - }, - Err(Bolt11PaymentError::SendingFailed(e)) => { - log_error!(self.logger, "Failed to send payment: {:?}", e); - match e { - RetryableSendFailure::DuplicatePayment => Err(Error::DuplicatePayment), - _ => { - let kind = PaymentKind::Bolt11 { - hash: payment_hash, - preimage: None, - secret: payment_secret, - counterparty_skimmed_fee_msat: None, - }; - let payment = PaymentDetails::new( - payment_id, - kind, - Some(amount_msat), - None, - PaymentDirection::Outbound, - PaymentStatus::Failed, - ); - - self.runtime.block_on(self.payment_store.insert(payment))?; - Err(Error::PaymentSendingFailed) - }, - } - }, - } + route_parameters, + "Failed to send payment due to amount given being insufficient.", + ) } /// Allows to attempt manually claiming payments with the given preimage that have previously From d3faf8e68660b7b676b9f85a075feda0fef536d5 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 12 Jun 2026 15:17:20 -0500 Subject: [PATCH 2/2] Expose BOLT11 underpayment sends 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. --- src/payment/bolt11.rs | 51 +++++++++++++++++- tests/integration_tests_rust.rs | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 1761133f2..bda11b96d 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -282,7 +282,8 @@ mod tests { impl Bolt11Payment { fn send_internal( &self, invoice: &LdkBolt11Invoice, amount_msat: Option, - route_parameters: Option, invalid_amount_log: &'static str, + route_parameters: Option, + declared_total_mpp_value_msat_override: Option, invalid_amount_log: &'static str, ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); @@ -314,6 +315,7 @@ impl Bolt11Payment { let optional_params = OptionalBolt11PaymentParams { retry_strategy, route_params_config, + declared_total_mpp_value_msat_override, ..Default::default() }; match self.channel_manager.pay_for_bolt11_invoice( @@ -401,6 +403,7 @@ impl Bolt11Payment { invoice, None, route_parameters, + None, "Failed to send payment due to the given invoice being \"zero-amount\". Please use send_using_amount instead.", ) } @@ -436,6 +439,52 @@ impl Bolt11Payment { invoice, Some(amount_msat), route_parameters, + None, + "Failed to send payment due to amount given being insufficient.", + ) + } + + /// Send a payment given an invoice and an amount lower than the invoice amount. + /// + /// This uses LDK's partial MPP support by declaring the invoice amount as the total MPP value + /// while only sending `amount_msat` from this node. The receiving node must be willing to + /// accept underpaying HTLCs for the payment to complete. + /// + /// This will fail if the invoice is a zero-amount invoice, or if the amount given is greater + /// than or equal to the value required by the invoice. Use [`Self::send_using_amount`] instead + /// when paying a zero-amount invoice or paying at least the invoice amount. + /// + /// If `route_parameters` are provided they will override the default as well as the + /// node-wide parameters configured via [`Config::route_parameters`] on a per-field basis. + pub fn send_using_amount_underpaying( + &self, invoice: &Bolt11Invoice, amount_msat: u64, + route_parameters: Option, + ) -> Result { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + + let invoice = maybe_deref(invoice); + let invoice_amount_msat = invoice.amount_milli_satoshis().ok_or_else(|| { + log_error!(self.logger, "Failed to underpay as the given invoice is \"zero-amount\"."); + Error::InvalidInvoice + })?; + + if amount_msat >= invoice_amount_msat { + log_error!( + self.logger, + "Failed to underpay as the given amount needs to be less than the invoice amount: required less than {}msat, gave {}msat.", + invoice_amount_msat, + amount_msat + ); + return Err(Error::InvalidAmount); + } + + self.send_internal( + invoice, + Some(amount_msat), + route_parameters, + Some(invoice_amount_msat), "Failed to send payment due to amount given being insufficient.", ) } diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 309d5bf4d..b1aa090a2 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -304,6 +304,101 @@ async fn multi_hop_sending() { expect_payment_successful_event!(nodes[0], payment_id, Some(fee_paid_msat)); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn split_underpaid_bolt11_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let node_c = setup_node(&chain_source, random_config(true)); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + let addr_c = node_c.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b, addr_c], + Amount::from_sat(premine_amount_sat), + ) + .await; + + for node in [&node_a, &node_b, &node_c] { + node.sync_wallets().unwrap(); + assert_eq!(node.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + } + + // The receiver opens both channels and pushes liquidity to both payers so each payer can send + // half of the invoice back. + let channel_amount_sat = 1_000_000; + let push_amount_msat = Some(500_000_000); + for payer in [&node_a, &node_b] { + node_c + .open_channel( + payer.node_id(), + payer.listening_addresses().unwrap().first().unwrap().clone(), + channel_amount_sat, + push_amount_msat, + None, + ) + .unwrap(); + + let funding_txo_c = expect_channel_pending_event!(node_c, payer.node_id()); + let funding_txo_payer = expect_channel_pending_event!(payer, node_c.node_id()); + assert_eq!(funding_txo_c, funding_txo_payer); + wait_for_tx(&electrsd.client, funding_txo_c.txid).await; + + node_c.sync_wallets().unwrap(); + } + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + for node in [&node_a, &node_b, &node_c] { + node.sync_wallets().unwrap(); + } + + expect_channel_ready_events!(node_c, node_a.node_id(), node_b.node_id()); + expect_channel_ready_event!(node_a, node_c.node_id()); + expect_channel_ready_event!(node_b, node_c.node_id()); + + let amount_msat = 100_000_000; + let half_amount_msat = amount_msat / 2; + let invoice_description = + Bolt11InvoiceDescription::Direct(Description::new(String::from("split")).unwrap()); + let invoice = + node_c.bolt11_payment().receive(amount_msat, &invoice_description.into(), 3600).unwrap(); + + // Each payer sends only half the invoice amount, while declaring the full invoice amount as + // the total MPP value. The receiver should claim only once both HTLCs arrive. + let payment_id_a = node_a + .bolt11_payment() + .send_using_amount_underpaying(&invoice, half_amount_msat, None) + .unwrap(); + let payment_id_b = node_b + .bolt11_payment() + .send_using_amount_underpaying(&invoice, half_amount_msat, None) + .unwrap(); + + let receiver_payment_id = expect_payment_received_event!(node_c, amount_msat); + assert_eq!(receiver_payment_id, Some(PaymentId(invoice.payment_hash().0))); + expect_payment_successful_event!(node_a, Some(payment_id_a), None); + expect_payment_successful_event!(node_b, Some(payment_id_b), None); + + // The receiver records the full invoice amount; each payer records only its own half. + let receiver_payments = + node_c.list_payments_with_filter(|p| p.id == receiver_payment_id.unwrap()); + assert_eq!(receiver_payments.len(), 1); + assert_eq!(receiver_payments.first().unwrap().amount_msat, Some(amount_msat)); + + let node_a_payments = node_a.list_payments_with_filter(|p| p.id == payment_id_a); + assert_eq!(node_a_payments.len(), 1); + assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(half_amount_msat)); + + let node_b_payments = node_b.list_payments_with_filter(|p| p.id == payment_id_b); + assert_eq!(node_b_payments.len(), 1); + assert_eq!(node_b_payments.first().unwrap().amount_msat, Some(half_amount_msat)); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn start_stop_reinit() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();