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
46 changes: 37 additions & 9 deletions src/payment/unified.rs
Comment thread
elnafateh marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 send/send_internal, with the payment being initiated. Fall back would be a duplicate payment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No, persistence failures return a separate PersistenceFailed variant, so aborting on DuplicatePayment here is safe.

@joostjager joostjager Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PersistenceFailed is the actual concern. It is caught by the Err(e) branch, and then leads to fallback, even though the payment might already have been initiated?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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:

Errors for which fallback is safe.
Errors indicating that a payment already exists or may have been initiated, for which fallback must stop."

Maybe worth seeing if that's just a few more lines vs a bigger fix?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've audited every error the BOLT11 and BOLT12 legs of UnifiedPayment::send can surface.

There are only two that indicate a payment was already initiated (or may have been): DuplicatePayment and PersistenceFailed.

Now both errors surface after pay_for_bolt11_invoice / pay_for_offer returns Ok, i.e. once the ChannelManager has the payment in-flight.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

here's how i want to resolve them errors:

  • I want to keep the BOLT11 DuplicatePayment impl and also fold the PersistenceFailed terminal arm there, so the BOLT11 leg is fully handled.
  • A follow-up issue/PR covers the BOLT12 leg (DuplicatePayment + PersistenceFailed). WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

@elnafateh elnafateh Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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