Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { .. })
{
Expand Down
34 changes: 34 additions & 0 deletions src/payment/bolt11.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Copy link
Collaborator

@tnull tnull Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I always found LND's terminology of 'cancelling an invoice' very confusing. In LDK Node it's even more so confusing as we don't store invoices (yet, cf. #811), so I really don't know what 'cancelling an invoice' is supposed to mean in this context.

As part of the move towards the payment metadata store we will also finally stop tracking expected inbound BOLT11 payments in the payment store (but then rather store the actual invoices/offers for the user), which means the logic in the event handler won't work as it does now. Let me see to finish #811 and #784 finally, and then we can decide how invoice 'cancellation' could make the most sense.

Somewhat orthogonally I do wonder if it would be best to actually do this properly in LDK, i.e., add a remove_payment method on ChannelManager that has it forget the preimage, simply?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somewhat orthogonally I do wonder if it would be best to actually do this properly in LDK, i.e., add a remove_payment method on ChannelManager that has it forget the preimage, simply?

I don't think that is possible, invoice creation is stateless, its not storing the preimage.

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.
///
Expand Down
75 changes: 75 additions & 0 deletions tests/integration_tests_rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading