Skip to content

Commit 74d6ae1

Browse files
elnafatehclaude
andcommitted
Fix unified payment falling back to on-chain after PersistenceFailed
In `UnifiedPayment::send`, the BOLT11 leg's `bolt11_invoice.send` only returns `Err(PersistenceFailed)` *after* `pay_for_bolt11_invoice` has already succeeded and the Lightning payment is in-flight. The previous match treated every error (via `Err(e)`) as a fall-through to the next payment method, so a persistence failure after initiation would broadcast an on-chain transaction for the same URI — a duplicate payment. We now treat `Err(Error::PersistenceFailed)` on the BOLT11 leg as terminal, mirroring how `DuplicatePayment` is already handled, and abort the unified payment instead of falling back to on-chain. This is a regression hazard raised during review of the #1033 fix (PR #1038). It is pre-existing and orthogonal to #1033 (which only made `DuplicatePayment` terminal); tracked separately as the unified variant of the broader post-commit persistence hazard. Adds `unified_send_bolt11_persistence_failure_no_onchain_fallback`, which arms a failing payment-store write on a `KVStore`-backed node and asserts that `send` returns `PersistenceFailed` without recording any on-chain payment. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f17c5d1 commit 74d6ae1

2 files changed

Lines changed: 190 additions & 0 deletions

File tree

src/payment/unified.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,10 +323,20 @@ impl UnifiedPayment {
323323
Ok(payment_id) => {
324324
return Ok(UnifiedPaymentResult::Bolt11 { payment_id });
325325
},
326+
// A duplicate payment already exists, so falling back to the
327+
// on-chain method would pay the same invoice a second time.
326328
Err(Error::DuplicatePayment) => {
327329
log_error!(self.logger, "Failed to send BOLT11 invoice: DuplicatePayment. This is part of a unified payment. Aborting to avoid duplicate payment.");
328330
return Err(Error::DuplicatePayment);
329331
},
332+
// A persistence failure may occur after the Lightning payment has
333+
// already been initiated with the ChannelManager. Falling back to
334+
// the on-chain method in that case would double-pay, so we abort
335+
// instead of proceeding to the next payment method.
336+
Err(Error::PersistenceFailed) => {
337+
log_error!(self.logger, "Failed to send BOLT11 invoice: PersistenceFailed. This is part of a unified payment. Aborting to avoid a potential duplicate payment.");
338+
return Err(Error::PersistenceFailed);
339+
},
330340
Err(e) => {
331341
log_error!(self.logger, "Failed to send BOLT11 invoice: {:?}. This is part of a unified payment. Falling back to the on-chain transaction.", e);
332342
},

tests/integration_tests_rust.rs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3417,6 +3417,186 @@ async fn unified_send_receive_bip21_uri() {
34173417
assert_eq!(node_b.list_balances().total_lightning_balance_sats, 200_000);
34183418
}
34193419

3420+
/// A [`KVStore`] that fails every `write` once `fail_writes` is set, while keeping
3421+
/// reads/list/remove operational so the node can still start and run.
3422+
struct PaymentFailingStore {
3423+
inner: Arc<InMemoryStore>,
3424+
fail_writes: Arc<AtomicBool>,
3425+
}
3426+
3427+
impl KVStore for PaymentFailingStore {
3428+
fn read(
3429+
&self, primary_namespace: &str, secondary_namespace: &str, key: &str,
3430+
) -> impl Future<Output = Result<Vec<u8>, lightning::io::Error>> + 'static + Send {
3431+
KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key)
3432+
}
3433+
3434+
fn write(
3435+
&self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec<u8>,
3436+
) -> impl Future<Output = Result<(), lightning::io::Error>> + 'static + Send {
3437+
let inner = Arc::clone(&self.inner);
3438+
let fail_writes = Arc::clone(&self.fail_writes);
3439+
let primary_namespace = primary_namespace.to_string();
3440+
let secondary_namespace = secondary_namespace.to_string();
3441+
let key = key.to_string();
3442+
async move {
3443+
// Only fail payment-store writes. Failing every write (e.g. channel
3444+
// monitor updates) would crash the background processor and the node
3445+
// itself, defeating the test of the `PersistenceFailed` handling path.
3446+
if fail_writes.load(Ordering::Acquire) && primary_namespace == "payments" {
3447+
return Err(lightning::io::Error::new(
3448+
lightning::io::ErrorKind::Other,
3449+
"injected payment persistence failure",
3450+
));
3451+
}
3452+
KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await
3453+
}
3454+
}
3455+
3456+
fn remove(
3457+
&self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool,
3458+
) -> impl Future<Output = Result<(), lightning::io::Error>> + 'static + Send {
3459+
KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy)
3460+
}
3461+
3462+
fn list(
3463+
&self, primary_namespace: &str, secondary_namespace: &str,
3464+
) -> impl Future<Output = Result<Vec<String>, lightning::io::Error>> + 'static + Send {
3465+
KVStore::list(&*self.inner, primary_namespace, secondary_namespace)
3466+
}
3467+
}
3468+
3469+
impl PaginatedKVStore for PaymentFailingStore {
3470+
fn list_paginated(
3471+
&self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>,
3472+
) -> impl Future<Output = Result<PaginatedListResponse, lightning::io::Error>> + 'static + Send
3473+
{
3474+
PaginatedKVStore::list_paginated(
3475+
&*self.inner,
3476+
primary_namespace,
3477+
secondary_namespace,
3478+
page_token,
3479+
)
3480+
}
3481+
}
3482+
3483+
// Regression test for the unified-payment `PersistenceFailed` double-payment hazard: when the
3484+
// BOLT11 leg initiates the Lightning payment but the subsequent payment-store write fails, the
3485+
// error must be terminal rather than falling through to the on-chain method.
3486+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
3487+
async fn unified_send_bolt11_persistence_failure_no_onchain_fallback() {
3488+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
3489+
let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap());
3490+
let chain_source = TestChainSource::Esplora(&electrsd);
3491+
3492+
// Node B (receiver) uses the default store.
3493+
let (node_a, node_b) = setup_two_nodes(&chain_source, false, false);
3494+
3495+
let address_a = node_a.onchain_payment().new_address().unwrap();
3496+
let premined_sats = 5_000_000;
3497+
premine_and_distribute_funds(
3498+
&bitcoind.client,
3499+
&electrsd.client,
3500+
vec![address_a],
3501+
Amount::from_sat(premined_sats),
3502+
)
3503+
.await;
3504+
3505+
node_a.sync_wallets().unwrap();
3506+
open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await;
3507+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
3508+
3509+
node_a.sync_wallets().unwrap();
3510+
node_b.sync_wallets().unwrap();
3511+
3512+
expect_channel_ready_event!(node_a, node_b.node_id());
3513+
expect_channel_ready_event!(node_b, node_a.node_id());
3514+
3515+
while node_b.status().latest_node_announcement_broadcast_timestamp.is_none() {
3516+
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3517+
}
3518+
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
3519+
3520+
let expected_amount_sats = 100_000;
3521+
let expiry_sec = 4_000;
3522+
3523+
let uri_str =
3524+
node_b.unified_payment().receive(expected_amount_sats, "asdf", expiry_sec).unwrap();
3525+
// Strip the BOLT12 offer so the URI resolves to BOLT11 only.
3526+
let uri_str_bolt11_only = uri_str.split("&lno=").next().unwrap();
3527+
3528+
// Node A (sender) runs on a store that fails writes, so the payment-store insert after
3529+
// `pay_for_bolt11_invoice` succeeds will surface as `PersistenceFailed`.
3530+
let mut config_a = random_config();
3531+
setup_builder!(builder_a, config_a.node_config);
3532+
let mut sync_config = EsploraSyncConfig::default();
3533+
sync_config.background_sync_config = None;
3534+
builder_a.set_chain_source_esplora(esplora_url.clone(), Some(sync_config.clone()));
3535+
let fail_writes = Arc::new(AtomicBool::new(false));
3536+
let failing_store = PaymentFailingStore {
3537+
inner: Arc::new(InMemoryStore::new()),
3538+
fail_writes: Arc::clone(&fail_writes),
3539+
};
3540+
let node_a_failing =
3541+
builder_a.build_with_store(config_a.node_entropy.into(), failing_store).unwrap();
3542+
node_a_failing.start().unwrap();
3543+
3544+
// Fund and open a channel for the failing-store node too, so it can initiate Lightning.
3545+
let address_a_failing = node_a_failing.onchain_payment().new_address().unwrap();
3546+
premine_and_distribute_funds(
3547+
&bitcoind.client,
3548+
&electrsd.client,
3549+
vec![address_a_failing],
3550+
Amount::from_sat(premined_sats),
3551+
)
3552+
.await;
3553+
node_a_failing.sync_wallets().unwrap();
3554+
node_a_failing
3555+
.connect(
3556+
node_b.node_id(),
3557+
node_b.listening_addresses().unwrap().first().unwrap().clone(),
3558+
false,
3559+
)
3560+
.unwrap();
3561+
open_channel(&node_a_failing, &node_b, 4_000_000, true, &electrsd).await;
3562+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
3563+
node_a_failing.sync_wallets().unwrap();
3564+
// `node_b` is the shared counterparty for both channels; it must also observe the
3565+
// new funding tx's confirmations or it will never emit `ChannelReady` back.
3566+
node_b.sync_wallets().unwrap();
3567+
expect_channel_ready_event!(node_a_failing, node_b.node_id());
3568+
expect_channel_ready_event!(node_b, node_a_failing.node_id());
3569+
3570+
// Arm the failure, then send. The BOLT11 leg will initiate but the store write fails.
3571+
fail_writes.store(true, Ordering::Release);
3572+
3573+
let result = node_a_failing.unified_payment().send(uri_str_bolt11_only, None, None).await;
3574+
match result {
3575+
Err(NodeError::PersistenceFailed) => {
3576+
// Expected — the unified payment must abort, not fall back to on-chain.
3577+
},
3578+
Ok(UnifiedPaymentResult::Onchain { txid }) => {
3579+
panic!("Regression: PersistenceFailed fell back to on-chain. txid={}", txid);
3580+
},
3581+
Ok(other) => {
3582+
panic!("Expected PersistenceFailed error, got: {:?}", other);
3583+
},
3584+
Err(other) => {
3585+
panic!("Expected PersistenceFailed error, got: {:?}", other);
3586+
},
3587+
}
3588+
3589+
// Confirm no on-chain payment was recorded for the unified amount.
3590+
let onchain_payments = node_a_failing.list_all_payments().into_iter().any(|p| {
3591+
matches!(p.kind, PaymentKind::Onchain { .. })
3592+
&& p.amount_msat == Some(expected_amount_sats as u64 * 1000)
3593+
});
3594+
assert!(
3595+
!onchain_payments,
3596+
"An on-chain payment for the unified amount was broadcast despite PersistenceFailed"
3597+
);
3598+
}
3599+
34203600
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
34213601
async fn unified_send_bolt11_duplicate_payment_no_onchain_fallback() {
34223602
// Regression test for https://github.com/lightningdevkit/ldk-node/issues/1033

0 commit comments

Comments
 (0)