-
Notifications
You must be signed in to change notification settings - Fork 160
Fix BOLT11 DuplicatePayment triggering on-chain fallback in unified payment #1038
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ba5f712
e0fea6e
8261ca6
30aff00
9c2d37c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -287,9 +287,22 @@ impl UnifiedPayment { | |
|
|
||
| let payment_result = if let Ok(hrn) = HumanReadableName::from_encoded(uri_str) { | ||
| let hrn = maybe_wrap(hrn.clone()); | ||
| self.bolt12_payment.send_using_amount_inner(&offer, amount_msat.unwrap_or(0), None, None, route_parameters, Some(hrn)) | ||
| self.bolt12_payment.send_using_amount_inner( | ||
| &offer, | ||
| amount_msat.unwrap_or(0), | ||
| None, | ||
| None, | ||
| route_parameters, | ||
| Some(hrn), | ||
| ) | ||
| } else if let Some(amount_msat) = amount_msat { | ||
| self.bolt12_payment.send_using_amount(&offer, amount_msat, None, None, route_parameters) | ||
| self.bolt12_payment.send_using_amount( | ||
| &offer, | ||
| amount_msat, | ||
| None, | ||
| None, | ||
| route_parameters, | ||
| ) | ||
| } else { | ||
| self.bolt12_payment.send(&offer, None, None, route_parameters) | ||
| } | ||
|
|
@@ -304,14 +317,29 @@ impl UnifiedPayment { | |
| }, | ||
| PaymentMethod::LightningBolt11(invoice) => { | ||
| let invoice = maybe_wrap(invoice.clone()); | ||
| let payment_result = self.bolt11_invoice.send(&invoice, route_parameters) | ||
| .map_err(|e| { | ||
| let payment_result = self.bolt11_invoice.send(&invoice, route_parameters); | ||
|
|
||
| match payment_result { | ||
| Ok(payment_id) => { | ||
| return Ok(UnifiedPaymentResult::Bolt11 { payment_id }); | ||
| }, | ||
| // A duplicate payment already exists, so falling back to the | ||
| // on-chain method would pay the same invoice a second time. | ||
| Err(Error::DuplicatePayment) => { | ||
| log_error!(self.logger, "Failed to send BOLT11 invoice: DuplicatePayment. This is part of a unified payment. Aborting to avoid duplicate payment."); | ||
| return Err(Error::DuplicatePayment); | ||
| }, | ||
| // A persistence failure may occur after the Lightning payment has | ||
| // already been initiated with the ChannelManager. Falling back to | ||
| // the on-chain method in that case would double-pay, so we abort | ||
| // instead of proceeding to the next payment method. | ||
| Err(Error::PersistenceFailed) => { | ||
| log_error!(self.logger, "Failed to send BOLT11 invoice: PersistenceFailed. This is part of a unified payment. Aborting to avoid a potential duplicate payment."); | ||
| return Err(Error::PersistenceFailed); | ||
| }, | ||
| Err(e) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It looks like this error can be just a persistence error happening in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, persistence failures return a separate
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I understand your concern here, but that's a real pre-existing bug. It's also orthogonal to this PR, so I'll file it as a second follow-up rather than widen this change, as this PR is scoped to #1033.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seemed similar enough to me to fix here too. But indeed, this PR is an improvement on its own ofc. Can you post the follow-up issue here too?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually I now see the original issue cannot be closed with this PR, because it says: "It may also be worth reviewing other Lightning errors and separating them into: Maybe worth seeing if that's just a few more lines vs a bigger fix?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've audited every error the BOLT11 and BOLT12 legs of There are only two that indicate a payment was already initiated (or may have been): Now both errors surface after Every other error (PaymentSendingFailed, InvalidInvoice, route failures, etc.) is returned before that call succeeds, so falling back to on-chain is safe. So there are only two terminal errors, the rest safe.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. here's how i want to resolve them errors:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good. That fully addresses the original issue. Curious though how much bolt12 is. If that is similarly minimal perhaps it can all be one PR, but up to you.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the change is essentially the same, but I'd like to keep it separate. I also have updated #1060 for Bolt12. |
||
| log_error!(self.logger, "Failed to send BOLT11 invoice: {:?}. This is part of a unified payment. Falling back to the on-chain transaction.", e); | ||
| e | ||
| }); | ||
|
|
||
| if let Ok(payment_id) = payment_result { | ||
| return Ok(UnifiedPaymentResult::Bolt11 { payment_id }); | ||
| }, | ||
| } | ||
| }, | ||
| PaymentMethod::OnChain(address) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3417,6 +3417,265 @@ async fn unified_send_receive_bip21_uri() { | |
| assert_eq!(node_b.list_balances().total_lightning_balance_sats, 200_000); | ||
| } | ||
|
|
||
| /// A [`KVStore`] that fails every `write` once `fail_writes` is set, while keeping | ||
| /// reads/list/remove operational so the node can still start and run. | ||
| struct PaymentFailingStore { | ||
| inner: Arc<InMemoryStore>, | ||
| fail_writes: Arc<AtomicBool>, | ||
| } | ||
|
|
||
| impl KVStore for PaymentFailingStore { | ||
| fn read( | ||
| &self, primary_namespace: &str, secondary_namespace: &str, key: &str, | ||
| ) -> impl Future<Output = Result<Vec<u8>, lightning::io::Error>> + 'static + Send { | ||
| KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) | ||
| } | ||
|
|
||
| fn write( | ||
| &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec<u8>, | ||
| ) -> impl Future<Output = Result<(), lightning::io::Error>> + 'static + Send { | ||
| let inner = Arc::clone(&self.inner); | ||
| let fail_writes = Arc::clone(&self.fail_writes); | ||
| let primary_namespace = primary_namespace.to_string(); | ||
| let secondary_namespace = secondary_namespace.to_string(); | ||
| let key = key.to_string(); | ||
| async move { | ||
| // Only fail payment-store writes. Failing every write (e.g. channel | ||
| // monitor updates) would crash the background processor and the node | ||
| // itself, defeating the test of the `PersistenceFailed` handling path. | ||
| if fail_writes.load(Ordering::Acquire) && primary_namespace == "payments" { | ||
| return Err(lightning::io::Error::new( | ||
| lightning::io::ErrorKind::Other, | ||
| "injected payment persistence failure", | ||
| )); | ||
| } | ||
| KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await | ||
| } | ||
| } | ||
|
|
||
| fn remove( | ||
| &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, | ||
| ) -> impl Future<Output = Result<(), lightning::io::Error>> + 'static + Send { | ||
| KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) | ||
| } | ||
|
|
||
| fn list( | ||
| &self, primary_namespace: &str, secondary_namespace: &str, | ||
| ) -> impl Future<Output = Result<Vec<String>, lightning::io::Error>> + 'static + Send { | ||
| KVStore::list(&*self.inner, primary_namespace, secondary_namespace) | ||
| } | ||
| } | ||
|
|
||
| impl PaginatedKVStore for PaymentFailingStore { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a lot of test code added. Isn't there a more compact way to cover this? |
||
| fn list_paginated( | ||
| &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>, | ||
| ) -> impl Future<Output = Result<PaginatedListResponse, lightning::io::Error>> + 'static + Send | ||
| { | ||
| PaginatedKVStore::list_paginated( | ||
| &*self.inner, | ||
| primary_namespace, | ||
| secondary_namespace, | ||
| page_token, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| // Regression test for the unified-payment `PersistenceFailed` double-payment hazard: when the | ||
| // BOLT11 leg initiates the Lightning payment but the subsequent payment-store write fails, the | ||
| // error must be terminal rather than falling through to the on-chain method. | ||
| #[tokio::test(flavor = "multi_thread", worker_threads = 1)] | ||
| async fn unified_send_bolt11_persistence_failure_no_onchain_fallback() { | ||
| let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); | ||
| let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); | ||
| let chain_source = TestChainSource::Esplora(&electrsd); | ||
|
|
||
| // Node B (receiver) uses the default store. | ||
| let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); | ||
|
|
||
| let address_a = node_a.onchain_payment().new_address().unwrap(); | ||
| let premined_sats = 5_000_000; | ||
| premine_and_distribute_funds( | ||
| &bitcoind.client, | ||
| &electrsd.client, | ||
| vec![address_a], | ||
| Amount::from_sat(premined_sats), | ||
| ) | ||
| .await; | ||
|
|
||
| node_a.sync_wallets().unwrap(); | ||
| open_channel(&node_a, &node_b, 4_000_000, 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()); | ||
|
|
||
| while node_b.status().latest_node_announcement_broadcast_timestamp.is_none() { | ||
| tokio::time::sleep(std::time::Duration::from_millis(10)).await; | ||
| } | ||
| tokio::time::sleep(std::time::Duration::from_secs(1)).await; | ||
|
|
||
| let expected_amount_sats = 100_000; | ||
| let expiry_sec = 4_000; | ||
|
|
||
| let uri_str = | ||
| node_b.unified_payment().receive(expected_amount_sats, "asdf", expiry_sec).unwrap(); | ||
| // Strip the BOLT12 offer so the URI resolves to BOLT11 only. | ||
| let uri_str_bolt11_only = uri_str.split("&lno=").next().unwrap(); | ||
|
|
||
| // Node A (sender) runs on a store that fails writes, so the payment-store insert after | ||
| // `pay_for_bolt11_invoice` succeeds will surface as `PersistenceFailed`. | ||
| let config_a = random_config(); | ||
| setup_builder!(builder_a, config_a.node_config); | ||
| let mut sync_config = EsploraSyncConfig::default(); | ||
| sync_config.background_sync_config = None; | ||
| builder_a.set_chain_source_esplora(esplora_url.clone(), Some(sync_config.clone())); | ||
| let fail_writes = Arc::new(AtomicBool::new(false)); | ||
| let failing_store = PaymentFailingStore { | ||
| inner: Arc::new(InMemoryStore::new()), | ||
| fail_writes: Arc::clone(&fail_writes), | ||
| }; | ||
| let node_a_failing = | ||
| builder_a.build_with_store(config_a.node_entropy.into(), failing_store).unwrap(); | ||
| node_a_failing.start().unwrap(); | ||
|
|
||
| // Fund and open a channel for the failing-store node too, so it can initiate Lightning. | ||
| let address_a_failing = node_a_failing.onchain_payment().new_address().unwrap(); | ||
| premine_and_distribute_funds( | ||
| &bitcoind.client, | ||
| &electrsd.client, | ||
| vec![address_a_failing], | ||
| Amount::from_sat(premined_sats), | ||
| ) | ||
| .await; | ||
| node_a_failing.sync_wallets().unwrap(); | ||
| node_a_failing | ||
| .connect( | ||
| node_b.node_id(), | ||
| node_b.listening_addresses().unwrap().first().unwrap().clone(), | ||
| false, | ||
| ) | ||
| .unwrap(); | ||
| open_channel(&node_a_failing, &node_b, 4_000_000, true, &electrsd).await; | ||
| generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; | ||
| node_a_failing.sync_wallets().unwrap(); | ||
| // `node_b` is the shared counterparty for both channels; it must also observe the | ||
| // new funding tx's confirmations or it will never emit `ChannelReady` back. | ||
| node_b.sync_wallets().unwrap(); | ||
| expect_channel_ready_event!(node_a_failing, node_b.node_id()); | ||
| expect_channel_ready_event!(node_b, node_a_failing.node_id()); | ||
|
|
||
| // Arm the failure, then send. The BOLT11 leg will initiate but the store write fails. | ||
| fail_writes.store(true, Ordering::Release); | ||
|
|
||
| let result = node_a_failing.unified_payment().send(uri_str_bolt11_only, None, None).await; | ||
| match result { | ||
| Err(NodeError::PersistenceFailed) => { | ||
| // Expected — the unified payment must abort, not fall back to on-chain. | ||
| }, | ||
| Ok(UnifiedPaymentResult::Onchain { txid }) => { | ||
| panic!("Regression: PersistenceFailed fell back to on-chain. txid={}", txid); | ||
| }, | ||
| Ok(other) => { | ||
| panic!("Expected PersistenceFailed error, got: {:?}", other); | ||
| }, | ||
| Err(other) => { | ||
| panic!("Expected PersistenceFailed error, got: {:?}", other); | ||
| }, | ||
| } | ||
|
|
||
| // Confirm no on-chain payment was recorded for the unified amount. | ||
| let onchain_payments = node_a_failing.list_all_payments().into_iter().any(|p| { | ||
| matches!(p.kind, PaymentKind::Onchain { .. }) | ||
| && p.amount_msat == Some(expected_amount_sats as u64 * 1000) | ||
| }); | ||
| assert!( | ||
| !onchain_payments, | ||
| "An on-chain payment for the unified amount was broadcast despite PersistenceFailed" | ||
| ); | ||
| } | ||
|
|
||
| #[tokio::test(flavor = "multi_thread", worker_threads = 1)] | ||
| async fn unified_send_bolt11_duplicate_payment_no_onchain_fallback() { | ||
| // Regression test for https://github.com/lightningdevkit/ldk-node/issues/1033 | ||
| // | ||
| // Sending a unified BIP21 payment that resolves to BOLT11 should return | ||
| // Error::DuplicatePayment on retry, not fall back to the on-chain method. | ||
|
|
||
| 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, false); | ||
|
|
||
| let address_a = node_a.onchain_payment().new_address().unwrap(); | ||
| let premined_sats = 5_000_000; | ||
|
|
||
| premine_and_distribute_funds( | ||
| &bitcoind.client, | ||
| &electrsd.client, | ||
| vec![address_a], | ||
| Amount::from_sat(premined_sats), | ||
| ) | ||
| .await; | ||
|
|
||
| node_a.sync_wallets().unwrap(); | ||
| open_channel(&node_a, &node_b, 4_000_000, 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 until we broadcast a node announcement. | ||
| while node_b.status().latest_node_announcement_broadcast_timestamp.is_none() { | ||
| tokio::time::sleep(std::time::Duration::from_millis(10)).await; | ||
| } | ||
| tokio::time::sleep(std::time::Duration::from_secs(1)).await; | ||
|
|
||
| let expected_amount_sats = 100_000; | ||
| let expiry_sec = 4_000; | ||
|
|
||
| // Receive a unified payment on node_b — this will produce a URI with BOLT12 offer + BOLT11 invoice. | ||
| let uri_str = | ||
| node_b.unified_payment().receive(expected_amount_sats, "asdf", expiry_sec).unwrap(); | ||
|
|
||
| // Strip the BOLT12 offer so the URI resolves to BOLT11 only (no BOLT12, no on-chain fallback). | ||
| let uri_str_bolt11_only = uri_str.split("&lno=").next().unwrap(); | ||
|
|
||
| // First send: should succeed via BOLT11. | ||
| let first_result = node_a.unified_payment().send(uri_str_bolt11_only, None, None).await; | ||
| let first_payment_id = match first_result { | ||
| Ok(UnifiedPaymentResult::Bolt11 { payment_id }) => payment_id, | ||
| Ok(other) => panic!("Expected Bolt11 result on first send, got: {:?}", other), | ||
| Err(e) => panic!("Expected Bolt11 result on first send, got error: {:?}", e), | ||
| }; | ||
| expect_payment_successful_event!(node_a, first_payment_id, None); | ||
|
|
||
| // Second send with the same URI: should return DuplicatePayment, NOT fall back to on-chain. | ||
| let second_result = node_a.unified_payment().send(uri_str_bolt11_only, None, None).await; | ||
| match second_result { | ||
| Err(NodeError::DuplicatePayment) => { | ||
| // Expected — this is the fix for #1033. | ||
| }, | ||
| Ok(UnifiedPaymentResult::Onchain { txid }) => { | ||
| panic!( | ||
| "Regression: Duplicate BOLT11 payment fell back to on-chain. txid={}. See #1033", | ||
| txid | ||
| ); | ||
| }, | ||
| Ok(other) => { | ||
| panic!("Expected DuplicatePayment error on retry, got: {:?}", other); | ||
| }, | ||
| Err(other) => { | ||
| panic!("Expected DuplicatePayment error on retry, got: {:?}", other); | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| #[tokio::test(flavor = "multi_thread", worker_threads = 1)] | ||
| async fn lsps2_client_service_integration() { | ||
| do_lsps2_client_service_integration(true).await; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.