From 6a9e1550c0fc59307f14b0512bd710fa360f1fe9 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 25 Mar 2026 14:57:48 -0500 Subject: [PATCH] Add cancel_invoice to Bolt11Payment Allow canceling a previously created BOLT11 invoice by payment hash. Enterprise integrators need this when they short-circuit an invoice with an internal database transfer or when an alternative payment method is used (e.g., on-chain payment via unified URI). cancel_invoice validates the payment is inbound and unclaimed, then delegates to fail_for_hash for the shared logic of marking the payment as failed and failing back any pending HTLCs. The PaymentClaimable event handler rejects HTLCs for cancelled invoices only when the preimage is known (auto-claim payments), preserving retry behavior for manual-claim (_for_hash) payments. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/event.rs | 13 ++++++ src/payment/bolt11.rs | 34 +++++++++++++++ tests/integration_tests_rust.rs | 75 +++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/src/event.rs b/src/event.rs index c4949a5ac..24bff479d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -688,6 +688,19 @@ where }; } + // If the invoice has been canceled, reject the HTLC. We only do this + // when the preimage is known to preserve retry behavior for `_for_hash` + // manual-claim payments, where `fail_for_hash` may have been a + // temporary rejection (e.g., preimage not yet available). + if info.status == PaymentStatus::Failed && purpose.preimage().is_some() { + log_info!( + self.logger, + "Refused inbound payment with ID {payment_id}: invoice has been canceled." + ); + self.channel_manager.fail_htlc_backwards(&payment_hash); + return Ok(()); + } + if info.status == PaymentStatus::Succeeded || matches!(info.kind, PaymentKind::Spontaneous { .. }) { diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index f2857e814..3d1194cba 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -555,6 +555,40 @@ impl Bolt11Payment { Ok(()) } + /// Allows to cancel a previously created invoice identified by the given payment hash. + /// + /// This will mark the corresponding payment as failed and cause any incoming HTLCs for this + /// invoice to be automatically failed back. + /// + /// Will check that the payment is known and has not already been claimed, and will return an + /// error otherwise. + pub fn cancel_invoice(&self, payment_hash: PaymentHash) -> Result<(), Error> { + let payment_id = PaymentId(payment_hash.0); + + if let Some(info) = self.payment_store.get(&payment_id) { + if info.direction != PaymentDirection::Inbound { + log_error!( + self.logger, + "Failed to cancel invoice for non-inbound payment with hash {payment_hash}" + ); + return Err(Error::InvalidPaymentHash); + } + + if info.status == PaymentStatus::Succeeded { + log_error!( + self.logger, + "Failed to cancel invoice with hash {payment_hash}: payment has already been claimed", + ); + return Err(Error::InvalidPaymentHash); + } + } else { + log_error!(self.logger, "Failed to cancel unknown invoice with hash {payment_hash}"); + return Err(Error::InvalidPaymentHash); + } + + self.fail_for_hash(payment_hash) + } + /// Returns a payable invoice that can be used to request and receive a payment of the amount /// given. /// diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 3fde52dc4..5bb4783c7 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1989,6 +1989,81 @@ async fn spontaneous_send_with_custom_preimage() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn cancel_invoice() { + 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 addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + let premine_amount_sat = 2_125_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let funding_amount_sat = 2_080_000; + let push_msat = (funding_amount_sat / 2) * 1000; + open_channel_push_amt(&node_a, &node_b, funding_amount_sat, Some(push_msat), true, &electrsd) + .await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Sleep a bit for gossip to propagate. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let invoice_description = + Bolt11InvoiceDescription::Direct(Description::new(String::from("asdf")).unwrap()); + let amount_msat = 2_500_000; + + // Create an invoice on node_b and immediately cancel it. + let invoice = node_b + .bolt11_payment() + .receive(amount_msat, &invoice_description.clone().into(), 9217) + .unwrap(); + + let payment_hash = PaymentHash(invoice.payment_hash().0); + let payment_id = PaymentId(payment_hash.0); + node_b.bolt11_payment().cancel_invoice(payment_hash).unwrap(); + + // Verify the payment status is now Failed. + assert_eq!(node_b.payment(&payment_id).unwrap().status, PaymentStatus::Failed); + + // Attempting to pay the canceled invoice should result in a failure on the sender side. + node_a.bolt11_payment().send(&invoice, None).unwrap(); + expect_event!(node_a, PaymentFailed); + + // Verify cancelling an already claimed payment errors. + let invoice_2 = node_b + .bolt11_payment() + .receive(amount_msat, &invoice_description.clone().into(), 9217) + .unwrap(); + let payment_id_2 = node_a.bolt11_payment().send(&invoice_2, None).unwrap(); + expect_payment_received_event!(node_b, amount_msat); + expect_payment_successful_event!(node_a, Some(payment_id_2), None); + + let payment_hash_2 = PaymentHash(invoice_2.payment_hash().0); + assert_eq!( + node_b.bolt11_payment().cancel_invoice(payment_hash_2), + Err(NodeError::InvalidPaymentHash) + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn drop_in_async_context() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();