Skip to content

Commit d7ee094

Browse files
committed
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 remaining error 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. Err(Error::PersistenceFailed) on the BOLT11 leg is now terminal, mirroring how DuplicatePayment is handled, and aborts the unified payment instead of falling back to on-chain. This is a regression hazard raised during review of the DuplicatePayment fix (PR #1038). It is pre-existing and orthogonal to #1033; tracked here as the unified variant of the broader post-commit persistence hazard. Per review discussion: rather than a dedicated node/channel fixture, build unified_send_receive_bip21_uri's node_a on a PaymentFailingStore (inert until armed) from the start, and add a PersistenceFailed assertion at the end of that test using a fresh BOLT11-only URI. This reuses the funding/channel/announcement setup and the successful-send flow the test already has, rather than duplicating it. Adds PaymentFailingStore (a KVStore wrapper that fails writes to the payments namespace on demand) and setup_two_nodes_with_failing_store_a (mirrors setup_two_nodes, but node_a is built on PaymentFailingStore).
1 parent 9c271d7 commit d7ee094

2 files changed

Lines changed: 136 additions & 10 deletions

File tree

src/payment/unified.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,14 @@ impl UnifiedPayment {
333333
log_error!(self.logger, "Failed to send BOLT11 invoice: DuplicatePayment. This is part of a unified payment. Aborting to avoid duplicate payment.");
334334
return Err(Error::DuplicatePayment);
335335
},
336+
// A persistence failure may occur after the Lightning payment has
337+
// already been initiated with the ChannelManager. Falling back to
338+
// the on-chain method in that case would double-pay, so we abort
339+
// instead of proceeding to the next payment method.
340+
Err(Error::PersistenceFailed) => {
341+
log_error!(self.logger, "Failed to send BOLT11 invoice: PersistenceFailed. This is part of a unified payment. Aborting to avoid a potential duplicate payment.");
342+
return Err(Error::PersistenceFailed);
343+
},
336344
Err(e) => {
337345
log_error!(self.logger, "Failed to send BOLT11 invoice: {:?}. This is part of a unified payment. Falling back to the on-chain transaction.", e);
338346
},

tests/integration_tests_rust.rs

Lines changed: 128 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,16 @@ use common::logging::{
2222
init_log_logger, validate_log_entry, CollectingLogWriter, MultiNodeLogger, TestLogWriter,
2323
};
2424
use common::{
25-
bump_fee_and_broadcast, distribute_funds_unconfirmed, do_channel_full_cycle,
26-
expect_channel_pending_event, expect_channel_ready_event, expect_channel_ready_events,
27-
expect_event, expect_payment_claimable_event, expect_payment_received_event,
28-
expect_payment_successful_event, expect_splice_negotiated_event, generate_blocks_and_wait,
29-
generate_listening_addresses, invalidate_blocks, open_channel, open_channel_no_wait,
30-
open_channel_push_amt, open_channel_with_all, premine_and_distribute_funds, premine_blocks,
31-
prepare_rbf, random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_builder,
32-
setup_node, setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, InMemoryStore,
33-
NodePaymentExt, TestChainSource, TestConfig, TestStoreType, TestSyncStore,
25+
bump_fee_and_broadcast, configure_chain_source, distribute_funds_unconfirmed,
26+
do_channel_full_cycle, expect_channel_pending_event, expect_channel_ready_event,
27+
expect_channel_ready_events, expect_event, expect_payment_claimable_event,
28+
expect_payment_received_event, expect_payment_successful_event, expect_splice_negotiated_event,
29+
generate_blocks_and_wait, generate_listening_addresses, invalidate_blocks, open_channel,
30+
open_channel_no_wait, open_channel_push_amt, open_channel_with_all,
31+
premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config,
32+
setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, splice_in_with_all,
33+
wait_for_block, wait_for_tx, InMemoryStore, NodePaymentExt, TestChainSource, TestConfig,
34+
TestNode, TestStoreType, TestSyncStore,
3435
};
3536
use electrsd::corepc_node::{self, Node as BitcoinD};
3637
use electrsd::ElectrsD;
@@ -3315,12 +3316,99 @@ async fn unified_receive_rejects_msat_overflow() {
33153316
);
33163317
}
33173318

3319+
/// A [`KVStore`] that fails every `write` to the payments namespace once `fail_writes` is set,
3320+
/// while keeping everything else operational. Used to arm a `PersistenceFailed` regression case
3321+
/// on top of an otherwise-normal node, without needing a dedicated node/channel fixture.
3322+
struct PaymentFailingStore {
3323+
inner: Arc<InMemoryStore>,
3324+
fail_writes: Arc<AtomicBool>,
3325+
}
3326+
3327+
impl KVStore for PaymentFailingStore {
3328+
fn read(
3329+
&self, primary_namespace: &str, secondary_namespace: &str, key: &str,
3330+
) -> impl Future<Output = Result<Vec<u8>, lightning::io::Error>> + 'static + Send {
3331+
KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key)
3332+
}
3333+
3334+
fn write(
3335+
&self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec<u8>,
3336+
) -> impl Future<Output = Result<(), lightning::io::Error>> + 'static + Send {
3337+
let inner = Arc::clone(&self.inner);
3338+
let fail_writes = Arc::clone(&self.fail_writes);
3339+
let primary_namespace = primary_namespace.to_string();
3340+
let secondary_namespace = secondary_namespace.to_string();
3341+
let key = key.to_string();
3342+
async move {
3343+
// Only fail payment-store writes. Failing every write (e.g. channel monitor
3344+
// updates) would crash the background processor, defeating the test.
3345+
if fail_writes.load(Ordering::Acquire) && primary_namespace == "payments" {
3346+
return Err(lightning::io::Error::new(
3347+
lightning::io::ErrorKind::Other,
3348+
"injected payment persistence failure",
3349+
));
3350+
}
3351+
KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await
3352+
}
3353+
}
3354+
3355+
fn remove(
3356+
&self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool,
3357+
) -> impl Future<Output = Result<(), lightning::io::Error>> + 'static + Send {
3358+
KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy)
3359+
}
3360+
3361+
fn list(
3362+
&self, primary_namespace: &str, secondary_namespace: &str,
3363+
) -> impl Future<Output = Result<Vec<String>, lightning::io::Error>> + 'static + Send {
3364+
KVStore::list(&*self.inner, primary_namespace, secondary_namespace)
3365+
}
3366+
}
3367+
3368+
impl PaginatedKVStore for PaymentFailingStore {
3369+
fn list_paginated(
3370+
&self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>,
3371+
) -> impl Future<Output = Result<PaginatedListResponse, lightning::io::Error>> + 'static + Send
3372+
{
3373+
PaginatedKVStore::list_paginated(
3374+
&*self.inner,
3375+
primary_namespace,
3376+
secondary_namespace,
3377+
page_token,
3378+
)
3379+
}
3380+
}
3381+
3382+
/// Builds `node_a` on a [`PaymentFailingStore`] the caller can arm later via `fail_writes`, and
3383+
/// `node_b` on the default store — otherwise identical to `setup_two_nodes`. Lets a single test
3384+
/// flow cover the `PersistenceFailed` fallback hazard on top of the fixture it already needs for
3385+
/// the normal unified-payment paths, instead of duplicating that fixture in a standalone test.
3386+
fn setup_two_nodes_with_failing_store_a(
3387+
chain_source: &TestChainSource, fail_writes: Arc<AtomicBool>,
3388+
) -> (TestNode, TestNode) {
3389+
let config_a = random_config();
3390+
setup_builder!(builder_a, config_a.node_config);
3391+
configure_chain_source(chain_source, &mut builder_a, &config_a);
3392+
builder_a.set_async_payments_role(config_a.async_payments_role).unwrap();
3393+
let failing_store = PaymentFailingStore { inner: Arc::new(InMemoryStore::new()), fail_writes };
3394+
let node_a = builder_a.build_with_store(config_a.node_entropy.into(), failing_store).unwrap();
3395+
node_a.start().unwrap();
3396+
3397+
let mut config_b = random_config();
3398+
config_b.node_config.manually_handle_unknown_bolt11_payments = true;
3399+
let node_b = setup_node(chain_source, config_b);
3400+
3401+
(node_a, node_b)
3402+
}
3403+
33183404
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
33193405
async fn unified_send_receive_bip21_uri() {
33203406
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
33213407
let chain_source = random_chain_source(&bitcoind, &electrsd);
33223408

3323-
let (node_a, node_b) = setup_two_nodes(&chain_source, false, false);
3409+
let fail_writes = Arc::new(AtomicBool::new(false));
3410+
let (node_a, node_b) =
3411+
setup_two_nodes_with_failing_store_a(&chain_source, Arc::clone(&fail_writes));
33243412

33253413
let address_a = node_a.onchain_payment().new_address().unwrap();
33263414
let premined_sats = 5_000_000;
@@ -3441,6 +3529,36 @@ async fn unified_send_receive_bip21_uri() {
34413529

34423530
assert_eq!(node_b.list_balances().total_onchain_balance_sats, 800_000);
34433531
assert_eq!(node_b.list_balances().total_lightning_balance_sats, 200_000);
3532+
3533+
// Regression test: a payment-store persistence failure on the BOLT11 leg — which can occur
3534+
// after the Lightning payment has already been initiated with the ChannelManager — must also
3535+
// abort rather than fall back to on-chain. Arm node_a's store and send a fresh BOLT11-only
3536+
// URI to isolate the BOLT11 leg from the BOLT12/on-chain legs already exercised above.
3537+
fail_writes.store(true, Ordering::Release);
3538+
3539+
let fresh_amount_sats = 50_000;
3540+
let fresh_uri = node_b.unified_payment().receive(fresh_amount_sats, "asdf", 4_000).unwrap();
3541+
let fresh_uri_bolt11_only = fresh_uri.split("&lno=").next().unwrap();
3542+
3543+
let persistence_result = node_a.unified_payment().send(fresh_uri_bolt11_only, None, None).await;
3544+
match persistence_result {
3545+
Err(NodeError::PersistenceFailed) => {
3546+
// Expected — the unified payment must abort, not fall back to on-chain.
3547+
},
3548+
Ok(UnifiedPaymentResult::Onchain { txid }) => {
3549+
panic!("Regression: PersistenceFailed fell back to on-chain. txid={}", txid);
3550+
},
3551+
other => panic!("Expected PersistenceFailed error, got: {:?}", other),
3552+
}
3553+
3554+
let onchain_payments = node_a.list_all_payments().into_iter().any(|p| {
3555+
matches!(p.kind, PaymentKind::Onchain { .. })
3556+
&& p.amount_msat == Some(fresh_amount_sats as u64 * 1000)
3557+
});
3558+
assert!(
3559+
!onchain_payments,
3560+
"An on-chain payment for the fresh amount was broadcast despite PersistenceFailed"
3561+
);
34443562
}
34453563

34463564
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]

0 commit comments

Comments
 (0)