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();