Skip to content

Commit f04c76a

Browse files
benthecarmanclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 804f00f commit f04c76a

File tree

3 files changed

+118
-0
lines changed

3 files changed

+118
-0
lines changed

src/event.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,19 @@ where
688688
};
689689
}
690690

691+
// If the invoice has been canceled, reject the HTLC. We only do this
692+
// when the preimage is known to preserve retry behavior for `_for_hash`
693+
// manual-claim payments, where `fail_for_hash` may have been a
694+
// temporary rejection (e.g., preimage not yet available).
695+
if info.status == PaymentStatus::Failed && purpose.preimage().is_some() {
696+
log_info!(
697+
self.logger,
698+
"Refused inbound payment with ID {payment_id}: invoice has been canceled."
699+
);
700+
self.channel_manager.fail_htlc_backwards(&payment_hash);
701+
return Ok(());
702+
}
703+
691704
if info.status == PaymentStatus::Succeeded
692705
|| matches!(info.kind, PaymentKind::Spontaneous { .. })
693706
{

src/payment/bolt11.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,40 @@ impl Bolt11Payment {
555555
Ok(())
556556
}
557557

558+
/// Allows to cancel a previously created invoice identified by the given payment hash.
559+
///
560+
/// This will mark the corresponding payment as failed and cause any incoming HTLCs for this
561+
/// invoice to be automatically failed back.
562+
///
563+
/// Will check that the payment is known and has not already been claimed, and will return an
564+
/// error otherwise.
565+
pub fn cancel_invoice(&self, payment_hash: PaymentHash) -> Result<(), Error> {
566+
let payment_id = PaymentId(payment_hash.0);
567+
568+
if let Some(info) = self.payment_store.get(&payment_id) {
569+
if info.direction != PaymentDirection::Inbound {
570+
log_error!(
571+
self.logger,
572+
"Failed to cancel invoice for non-inbound payment with hash {payment_hash}"
573+
);
574+
return Err(Error::InvalidPaymentHash);
575+
}
576+
577+
if info.status == PaymentStatus::Succeeded {
578+
log_error!(
579+
self.logger,
580+
"Failed to cancel invoice with hash {payment_hash}: payment has already been claimed",
581+
);
582+
return Err(Error::InvalidPaymentHash);
583+
}
584+
} else {
585+
log_error!(self.logger, "Failed to cancel unknown invoice with hash {payment_hash}");
586+
return Err(Error::InvalidPaymentHash);
587+
}
588+
589+
self.fail_for_hash(payment_hash)
590+
}
591+
558592
/// Returns a payable invoice that can be used to request and receive a payment of the amount
559593
/// given.
560594
///

tests/integration_tests_rust.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1989,6 +1989,77 @@ async fn spontaneous_send_with_custom_preimage() {
19891989
}
19901990
}
19911991

1992+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1993+
async fn cancel_invoice() {
1994+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
1995+
let chain_source = random_chain_source(&bitcoind, &electrsd);
1996+
let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false);
1997+
1998+
let addr_a = node_a.onchain_payment().new_address().unwrap();
1999+
let addr_b = node_b.onchain_payment().new_address().unwrap();
2000+
2001+
let premine_amount_sat = 2_125_000;
2002+
premine_and_distribute_funds(
2003+
&bitcoind.client,
2004+
&electrsd.client,
2005+
vec![addr_a, addr_b],
2006+
Amount::from_sat(premine_amount_sat),
2007+
)
2008+
.await;
2009+
node_a.sync_wallets().unwrap();
2010+
node_b.sync_wallets().unwrap();
2011+
2012+
let funding_amount_sat = 2_080_000;
2013+
let push_msat = (funding_amount_sat / 2) * 1000;
2014+
open_channel_push_amt(&node_a, &node_b, funding_amount_sat, Some(push_msat), true, &electrsd)
2015+
.await;
2016+
2017+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
2018+
node_a.sync_wallets().unwrap();
2019+
node_b.sync_wallets().unwrap();
2020+
2021+
expect_channel_ready_event!(node_a, node_b.node_id());
2022+
expect_channel_ready_event!(node_b, node_a.node_id());
2023+
2024+
// Sleep a bit for gossip to propagate.
2025+
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
2026+
2027+
let invoice_description =
2028+
Bolt11InvoiceDescription::Direct(Description::new(String::from("asdf")).unwrap());
2029+
let amount_msat = 2_500_000;
2030+
2031+
// Create an invoice on node_b and immediately cancel it.
2032+
let invoice =
2033+
node_b.bolt11_payment().receive(amount_msat, &invoice_description.clone(), 9217).unwrap();
2034+
2035+
let payment_hash = PaymentHash(invoice.payment_hash().0);
2036+
let payment_id = PaymentId(payment_hash.0);
2037+
node_b.bolt11_payment().cancel_invoice(payment_hash).unwrap();
2038+
2039+
// Verify the payment status is now Failed.
2040+
assert_eq!(node_b.payment(&payment_id).unwrap().status, PaymentStatus::Failed);
2041+
2042+
// Attempting to pay the canceled invoice should result in a failure on the sender side.
2043+
node_a.bolt11_payment().send(&invoice, None).unwrap();
2044+
expect_event!(node_a, PaymentFailed);
2045+
2046+
// Verify cancelling an already claimed payment errors.
2047+
let invoice_2 =
2048+
node_b.bolt11_payment().receive(amount_msat, &invoice_description.clone(), 9217).unwrap();
2049+
let payment_id_2 = node_a.bolt11_payment().send(&invoice_2, None).unwrap();
2050+
expect_payment_received_event!(node_b, amount_msat);
2051+
expect_payment_successful_event!(node_a, Some(payment_id_2), None);
2052+
2053+
let payment_hash_2 = PaymentHash(invoice_2.payment_hash().0);
2054+
assert_eq!(
2055+
node_b.bolt11_payment().cancel_invoice(payment_hash_2),
2056+
Err(NodeError::InvalidPaymentHash)
2057+
);
2058+
2059+
node_a.stop().unwrap();
2060+
node_b.stop().unwrap();
2061+
}
2062+
19922063
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
19932064
async fn drop_in_async_context() {
19942065
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)