From b5c731d4257bd0bdac86655e6bd39af43708e3ae Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 7 Oct 2026 15:51:49 +0200 Subject: [PATCH] Reject underpaid BOLT11 JIT invoices BOLT11 JIT invoices did not enforce their advertised amount at the recipient. A sender could bypass the LSP's intercept SCID and have a smaller payment accepted over an existing channel. Require the full sender-declared invoice amount for generated and caller-supplied payment hashes. Preserve negotiated LSP fee deductions and cover both invoice paths with an underpayment regression. Fixes #609 Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2.rs | 4 +- tests/integration_tests_rust.rs | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/liquidity/client/lsps2.rs b/src/liquidity/client/lsps2.rs index 6435a71e5b..8084cace78 100644 --- a/src/liquidity/client/lsps2.rs +++ b/src/liquidity/client/lsps2.rs @@ -359,7 +359,7 @@ where .channel_manager .create_inbound_payment_for_hash( payment_hash, - None, + amount_msat, expiry_secs, Some(min_final_cltv_expiry_delta), Some(encoded_payment_metadata), @@ -373,7 +373,7 @@ where None => self .channel_manager .create_inbound_payment( - None, + amount_msat, expiry_secs, Some(min_final_cltv_expiry_delta), Some(encoded_payment_metadata), diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 3da60800c6..fc1c0dbfd8 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3832,6 +3832,23 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { let mut client_config = random_config(); client_config.node_config.manually_handle_unknown_bolt11_payments = true; + let client_seed = [42; 64]; + #[cfg(not(feature = "uniffi"))] + { + client_config.node_entropy = NodeEntropy::from_seed_bytes(client_seed); + } + #[cfg(feature = "uniffi")] + { + client_config.node_entropy = NodeEntropy::from_seed_bytes(client_seed.to_vec()).unwrap(); + } + let client_master_key = + bitcoin::bip32::Xpriv::new_master(client_config.node_config.network, &client_seed).unwrap(); + let client_keys = lightning::sign::KeysManager::new( + &client_master_key.private_key.secret_bytes(), + 0, + 0, + true, + ); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); client_builder.add_liquidity_source(service_node_id, service_addr, None, true); @@ -3925,6 +3942,55 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { expect_event!(service_node, PaymentForwarded); expect_payment_received_event!(client_node, amount_msat); + // Bypass the intercept SCID using the existing service -> client channel. Re-signing a + // smaller invoice only bypasses the sending API's amount check; the recipient gets the + // original payment hash, secret, and metadata, as it would from a malicious payer. + for payment_hash in [None, Some(PaymentHash::from(PaymentPreimage([44; 32])))] { + let invoice = match payment_hash { + None => client_node.bolt11_payment().receive_via_jit_channel( + jit_amount_msat, + &invoice_description, + 1024, + None, + ), + Some(hash) => client_node.bolt11_payment().receive_via_jit_channel_for_hash( + jit_amount_msat, + &invoice_description, + 1024, + None, + hash, + ), + } + .unwrap(); + let invoice: &lightning_invoice::Bolt11Invoice = &invoice; + let underpayment = lightning_invoice::InvoiceBuilder::new(invoice.currency()) + .description("Underpay JIT invoice".to_owned()) + .payment_hash(invoice.payment_hash()) + .payment_secret(*invoice.payment_secret()) + .payment_metadata(invoice.payment_metadata().unwrap().clone()) + .amount_milli_satoshis(1_000_000) + .current_timestamp() + .min_final_cltv_expiry_delta(invoice.min_final_cltv_expiry_delta()) + .build_signed(|hash| { + bitcoin::secp256k1::Secp256k1::new() + .sign_ecdsa_recoverable(hash, &client_keys.get_node_secret_key()) + }) + .unwrap(); + assert_eq!(underpayment.recover_payee_pub_key(), client_node.node_id()); + #[cfg(feature = "uniffi")] + let underpayment = Arc::new(underpayment.into()); + let payment_id = service_node.bolt11_payment().send(&underpayment, None).unwrap(); + tokio::select! { + _ = async { + expect_event!(service_node, PaymentFailed); + assert_eq!(service_node.payment(&payment_id).unwrap().unwrap().status, PaymentStatus::Failed); + } => {}, + event = client_node.next_event_async() => { + panic!("JIT invoice accepted an underpayment (custom hash: {}): {:?}", payment_hash.is_some(), event); + }, + } + } + //////////////////////////////////////////////////////////////////////////// // receive_via_jit_channel_for_hash and claim_for_id ////////////////////////////////////////////////////////////////////////////