From 305a54d1a5a326b3c8dd1cc6919509659de07ac7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Jun 2026 10:59:12 +0200 Subject: [PATCH 01/22] Bump BDK wallet dependencies Update the direct BDK wallet stack to the latest crate releases. This lets follow-up wallet event code use the upstream BDK API. It also preserves temporary transaction cleanup after BDK removed its cancel_tx helper. Co-Authored-By: HAL 9000 --- Cargo.toml | 6 +++--- src/wallet/mod.rs | 24 ++++++++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bed984f07..dcd4d0860 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,10 +54,10 @@ lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } -bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} +bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] } +bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"]} bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"]} -bdk_wallet = { version = "2.3.0", default-features = false, features = ["std", "keys-bip39"]} +bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "keys-bip39"]} bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] } rustls = { version = "0.23", default-features = false } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 76f2aa9ce..9f3ba9c42 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -13,10 +13,9 @@ use std::sync::{Arc, Mutex}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; -use bdk_wallet::event::WalletEvent; #[allow(deprecated)] use bdk_wallet::SignOptions; -use bdk_wallet::{Balance, KeychainKind, PersistedWallet, Update}; +use bdk_wallet::{Balance, KeychainKind, PersistedWallet, Update, WalletEvent}; use bitcoin::address::NetworkUnchecked; use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; use bitcoin::blockdata::locktime::absolute::LockTime; @@ -517,7 +516,7 @@ impl Wallet { let mut locked_wallet = self.inner.lock().expect("lock"); let mut locked_persister = self.persister.lock().expect("lock"); - locked_wallet.cancel_tx(tx); + Self::cancel_tx_inner(&mut locked_wallet, tx); self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed @@ -526,6 +525,19 @@ impl Wallet { Ok(()) } + fn cancel_tx_inner( + locked_wallet: &mut PersistedWallet, tx: &Transaction, + ) { + for txout in &tx.output { + if let Some((keychain, index)) = + locked_wallet.derivation_of_spk(txout.script_pubkey.clone()) + { + // This mirrors the removed BDK helper: it only frees superficial usage marks. + locked_wallet.unmark_used(keychain, index); + } + } + } + pub(crate) fn get_balances( &self, total_anchor_channels_reserve_sats: u64, ) -> Result<(u64, u64), Error> { @@ -678,7 +690,7 @@ impl Wallet { None, )?; - locked_wallet.cancel_tx(&tmp_psbt.unsigned_tx); + Self::cancel_tx_inner(&mut locked_wallet, &tmp_psbt.unsigned_tx); Ok(max_amount) } @@ -708,7 +720,7 @@ impl Wallet { Some(&shared_input), )?; - locked_wallet.cancel_tx(&tmp_psbt.unsigned_tx); + Self::cancel_tx_inner(&mut locked_wallet, &tmp_psbt.unsigned_tx); Ok(splice_amount) } @@ -764,7 +776,7 @@ impl Wallet { e })?; - locked_wallet.cancel_tx(&tmp_psbt.unsigned_tx); + Self::cancel_tx_inner(&mut locked_wallet, &tmp_psbt.unsigned_tx); let mut tx_builder = locked_wallet.build_tx(); tx_builder From bba9652e83f2e8e9ce83a2746ec5908e8d51219b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Jun 2026 11:00:31 +0200 Subject: [PATCH 02/22] Use BDK mempool wallet events Use BDK's wallet event helper for mempool updates. This removes the local event diffing copy now that BDK exposes the needed event API. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 132 +++------------------------------------------- 1 file changed, 7 insertions(+), 125 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 9f3ba9c42..1b7383137 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -185,29 +185,13 @@ impl Wallet { let mut locked_wallet = self.inner.lock().expect("lock"); - let chain_tip1 = locked_wallet.latest_checkpoint().block_id(); - let wallet_txs1 = locked_wallet - .transactions() - .map(|wtx| (wtx.tx_node.txid, (wtx.tx_node.tx.clone(), wtx.chain_position))) - .collect::, bdk_chain::ChainPosition), - >>(); - - locked_wallet.apply_unconfirmed_txs(unconfirmed_txs); - locked_wallet.apply_evicted_txs(evicted_txids); - - let chain_tip2 = locked_wallet.latest_checkpoint().block_id(); - let wallet_txs2 = locked_wallet - .transactions() - .map(|wtx| (wtx.tx_node.txid, (wtx.tx_node.tx.clone(), wtx.chain_position))) - .collect::, bdk_chain::ChainPosition), - >>(); - - let events = - wallet_events(&mut *locked_wallet, chain_tip1, chain_tip2, wallet_txs1, wallet_txs2); + let events = locked_wallet + .events_helper(|wallet| -> Result<(), std::convert::Infallible> { + wallet.apply_unconfirmed_txs(unconfirmed_txs); + wallet.apply_evicted_txs(evicted_txids); + Ok(()) + }) + .expect("applying mempool updates cannot fail"); self.update_payment_store(&mut *locked_wallet, events).map_err(|e| { log_error!(self.logger, "Failed to update payment store: {}", e); @@ -1767,105 +1751,3 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { .saturating_sub(EMPTY_SCRIPT_SIG_WEIGHT + EMPTY_WITNESS_COUNT_WEIGHT), ) } - -// FIXME/TODO: This is copied-over from bdk_wallet and only used to generate `WalletEvent`s after -// applying mempool transactions. We should drop this when BDK offers to generate events for -// mempool transactions natively. -pub(crate) fn wallet_events( - wallet: &mut bdk_wallet::Wallet, chain_tip1: bdk_chain::BlockId, - chain_tip2: bdk_chain::BlockId, - wallet_txs1: std::collections::BTreeMap< - Txid, - (Arc, bdk_chain::ChainPosition), - >, - wallet_txs2: std::collections::BTreeMap< - Txid, - (Arc, bdk_chain::ChainPosition), - >, -) -> Vec { - let mut events: Vec = Vec::new(); - - if chain_tip1 != chain_tip2 { - events.push(WalletEvent::ChainTipChanged { old_tip: chain_tip1, new_tip: chain_tip2 }); - } - - wallet_txs2.iter().for_each(|(txid2, (tx2, cp2))| { - if let Some((tx1, cp1)) = wallet_txs1.get(txid2) { - assert_eq!(tx1.compute_txid(), *txid2); - match (cp1, cp2) { - ( - bdk_chain::ChainPosition::Unconfirmed { .. }, - bdk_chain::ChainPosition::Confirmed { anchor, .. }, - ) => { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor, - old_block_time: None, - }); - }, - ( - bdk_chain::ChainPosition::Confirmed { anchor, .. }, - bdk_chain::ChainPosition::Unconfirmed { .. }, - ) => { - events.push(WalletEvent::TxUnconfirmed { - txid: *txid2, - tx: tx2.clone(), - old_block_time: Some(*anchor), - }); - }, - ( - bdk_chain::ChainPosition::Confirmed { anchor: anchor1, .. }, - bdk_chain::ChainPosition::Confirmed { anchor: anchor2, .. }, - ) => { - if *anchor1 != *anchor2 { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor2, - old_block_time: Some(*anchor1), - }); - } - }, - ( - bdk_chain::ChainPosition::Unconfirmed { .. }, - bdk_chain::ChainPosition::Unconfirmed { .. }, - ) => { - // do nothing if still unconfirmed - }, - } - } else { - match cp2 { - bdk_chain::ChainPosition::Confirmed { anchor, .. } => { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor, - old_block_time: None, - }); - }, - bdk_chain::ChainPosition::Unconfirmed { .. } => { - events.push(WalletEvent::TxUnconfirmed { - txid: *txid2, - tx: tx2.clone(), - old_block_time: None, - }); - }, - } - } - }); - - // find tx that are no longer canonical - wallet_txs1.iter().for_each(|(txid1, (tx1, _))| { - if !wallet_txs2.contains_key(txid1) { - let conflicts = wallet.tx_graph().direct_conflicts(tx1).collect::>(); - if !conflicts.is_empty() { - events.push(WalletEvent::TxReplaced { txid: *txid1, tx: tx1.clone(), conflicts }); - } else { - events.push(WalletEvent::TxDropped { txid: *txid1, tx: tx1.clone() }); - } - } - }); - - events -} From 61d2b195361c105f8ec7338a3097d1965f5ffce1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:48:09 +0200 Subject: [PATCH 03/22] Track reorged on-chain payments as pending Move affected on-chain payments back to pending when BDK reports that their transaction is unconfirmed again. This keeps payment history aligned with wallet events after a reorg. It does not update payment records directly from disconnected-block notifications. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 2 +- tests/integration_tests_rust.rs | 78 +++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1b7383137..3f6a09c79 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -345,7 +345,7 @@ impl Wallet { } } }, - WalletEvent::TxUnconfirmed { txid, tx, old_block_time: None } => { + WalletEvent::TxUnconfirmed { txid, tx, .. } => { let payment_id = self .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 0b06716b2..34873f19d 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -21,10 +21,11 @@ use common::{ expect_channel_pending_event, expect_channel_ready_event, expect_channel_ready_events, expect_event, expect_payment_claimable_event, expect_payment_received_event, expect_payment_successful_event, expect_splice_negotiated_event, generate_blocks_and_wait, - generate_listening_addresses, open_channel, open_channel_push_amt, open_channel_with_all, - premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config, - setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, splice_in_with_all, - wait_for_tx, TestChainSource, TestConfig, TestStoreType, TestSyncStore, + generate_listening_addresses, invalidate_blocks, open_channel, open_channel_push_amt, + open_channel_with_all, premine_and_distribute_funds, premine_blocks, prepare_rbf, + random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_builder, setup_node, + setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, TestChainSource, TestConfig, + TestStoreType, TestSyncStore, }; use electrsd::corepc_node::Node as BitcoinD; use electrsd::ElectrsD; @@ -42,6 +43,7 @@ use lightning::routing::router::RouteParametersConfig; use lightning_invoice::{Bolt11InvoiceDescription, Description}; use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; +use serde_json::json; #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle() { @@ -577,6 +579,74 @@ async fn onchain_send_receive() { assert_eq!(node_b_payments.len(), 5); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn reorged_onchain_payment_returns_to_unconfirmed() { + 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 = 500_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let amount_to_send_sats = 100_000; + let txid = + node_b.onchain_payment().send_to_address(&addr_a, amount_to_send_sats, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let payment_id = PaymentId(txid.to_byte_array()); + for node in [&node_a, &node_b] { + let payment = node.payment(&payment_id).unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + match payment.kind { + PaymentKind::Onchain { status, .. } => { + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + _ => panic!("Unexpected payment kind"), + } + } + + let original_height = + bitcoind.client.get_blockchain_info().expect("failed to get blockchain info").blocks; + invalidate_blocks(&bitcoind.client, 1); + let replacement_address = bitcoind.client.new_address().expect("failed to get new address"); + for _ in 0..2 { + let _res: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(replacement_address.to_string()), json!([])]) + .expect("failed to generate empty block"); + } + wait_for_block(&electrsd.client, original_height as usize + 1).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + for node in [&node_a, &node_b] { + let payment = node.payment(&payment_id).unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + match payment.kind { + PaymentKind::Onchain { status, .. } => { + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + }, + _ => panic!("Unexpected payment kind"), + } + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn onchain_send_all_retains_reserve() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From bc2b0701a5a34253397d53880bf6aadf7d50e2b1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:48:55 +0200 Subject: [PATCH 04/22] Group pending payment storage constants Keep pending payment namespace constants next to the primary payment store constants. This keeps related persistence keys discoverable together. Co-Authored-By: HAL 9000 --- src/io/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/io/mod.rs b/src/io/mod.rs index e16a99975..a01aa59a8 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -29,6 +29,10 @@ pub(crate) const PEER_INFO_PERSISTENCE_KEY: &str = "peers"; pub(crate) const PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "payments"; pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// The pending payment information will be persisted under this prefix. +pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; +pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + /// The node metrics will be persisted under this key. pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = ""; pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = ""; @@ -80,7 +84,3 @@ pub(crate) const BDK_WALLET_INDEXER_KEY: &str = "indexer"; /// /// [`StaticInvoice`]: lightning::offers::static_invoice::StaticInvoice pub(crate) const STATIC_INVOICE_STORE_PRIMARY_NAMESPACE: &str = "static_invoices"; - -/// The pending payment information will be persisted under this prefix. -pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; -pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; From af7dc4c7ab5547dfc2b45cf974dfe7f577fe137c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:49:47 +0200 Subject: [PATCH 05/22] Keep pending payment details internal Stop exporting the pending payment index record from the public payment module. The pending index is an internal persistence detail and should not become public API before this ships. Co-Authored-By: HAL 9000 --- src/payment/mod.rs | 2 +- src/payment/pending_payment_store.rs | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 71daa48b0..ee53ed7f8 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -20,7 +20,7 @@ pub use bolt11::Bolt11Payment; pub(crate) use bolt11::PaymentMetadata; pub use bolt12::Bolt12Payment; pub use onchain::OnchainPayment; -pub use pending_payment_store::PendingPaymentDetails; +pub(crate) use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; pub use store::{ ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind, diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index eb72f89ec..4ff497334 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -26,11 +26,6 @@ impl PendingPaymentDetails { pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec) -> Self { Self { details, conflicting_txids } } - - /// Convert to finalized payment for the main payment store - pub fn into_payment_details(self) -> PaymentDetails { - self.details - } } impl_writeable_tlv_based!(PendingPaymentDetails, { From 9acb2ad14cae8da3910435650c64cb972052f59e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:51:23 +0200 Subject: [PATCH 06/22] Co-locate pending payment indexes Move the pending payment index record into the payment store module. This keeps the primary payment record and its pending index within the same persistence boundary. Co-Authored-By: HAL 9000 --- src/payment/mod.rs | 3 +- src/payment/pending_payment_store.rs | 89 ---------------------------- src/payment/store.rs | 75 +++++++++++++++++++++++ 3 files changed, 76 insertions(+), 91 deletions(-) delete mode 100644 src/payment/pending_payment_store.rs diff --git a/src/payment/mod.rs b/src/payment/mod.rs index ee53ed7f8..8e2aa00ee 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -11,7 +11,6 @@ pub(crate) mod asynchronous; mod bolt11; mod bolt12; mod onchain; -pub(crate) mod pending_payment_store; mod spontaneous; pub(crate) mod store; mod unified; @@ -20,8 +19,8 @@ pub use bolt11::Bolt11Payment; pub(crate) use bolt11::PaymentMetadata; pub use bolt12::Bolt12Payment; pub use onchain::OnchainPayment; -pub(crate) use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; +pub(crate) use store::PendingPaymentDetails; pub use store::{ ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs deleted file mode 100644 index 4ff497334..000000000 --- a/src/payment/pending_payment_store.rs +++ /dev/null @@ -1,89 +0,0 @@ -// This file is Copyright its original authors, visible in version control history. -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in -// accordance with one or both of these licenses. - -use bitcoin::Txid; -use lightning::impl_writeable_tlv_based; -use lightning::ln::channelmanager::PaymentId; - -use crate::data_store::{StorableObject, StorableObjectUpdate}; -use crate::payment::store::PaymentDetailsUpdate; -use crate::payment::PaymentDetails; - -/// Represents a pending payment -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PendingPaymentDetails { - /// The full payment details - pub details: PaymentDetails, - /// Transaction IDs that have replaced or conflict with this payment. - pub conflicting_txids: Vec, -} - -impl PendingPaymentDetails { - pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec) -> Self { - Self { details, conflicting_txids } - } -} - -impl_writeable_tlv_based!(PendingPaymentDetails, { - (0, details, required), - (2, conflicting_txids, optional_vec), -}); - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct PendingPaymentDetailsUpdate { - pub id: PaymentId, - pub payment_update: Option, - pub conflicting_txids: Option>, -} - -impl StorableObject for PendingPaymentDetails { - type Id = PaymentId; - type Update = PendingPaymentDetailsUpdate; - - fn id(&self) -> Self::Id { - self.details.id - } - - fn update(&mut self, update: Self::Update) -> bool { - let mut updated = false; - - // Update the underlying payment details if present - if let Some(payment_update) = update.payment_update { - updated |= self.details.update(payment_update); - } - - if let Some(new_conflicting_txids) = update.conflicting_txids { - if self.conflicting_txids != new_conflicting_txids { - self.conflicting_txids = new_conflicting_txids; - updated = true; - } - } - - updated - } - - fn to_update(&self) -> Self::Update { - self.into() - } -} - -impl StorableObjectUpdate for PendingPaymentDetailsUpdate { - fn id(&self) -> ::Id { - self.id - } -} - -impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { - fn from(value: &PendingPaymentDetails) -> Self { - let conflicting_txids = if value.conflicting_txids.is_empty() { - None - } else { - Some(value.conflicting_txids.clone()) - }; - Self { id: value.id(), payment_update: Some(value.details.to_update()), conflicting_txids } - } -} diff --git a/src/payment/store.rs b/src/payment/store.rs index f80ab6f8a..03c1b7ed6 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -586,6 +586,81 @@ impl StorableObjectUpdate for PaymentDetailsUpdate { } } +/// Represents a pending payment +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PendingPaymentDetails { + /// The full payment details + pub details: PaymentDetails, + /// Transaction IDs that have replaced or conflict with this payment. + pub conflicting_txids: Vec, +} + +impl PendingPaymentDetails { + pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec) -> Self { + Self { details, conflicting_txids } + } +} + +impl_writeable_tlv_based!(PendingPaymentDetails, { + (0, details, required), + (2, conflicting_txids, optional_vec), +}); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PendingPaymentDetailsUpdate { + pub id: PaymentId, + pub payment_update: Option, + pub conflicting_txids: Option>, +} + +impl StorableObject for PendingPaymentDetails { + type Id = PaymentId; + type Update = PendingPaymentDetailsUpdate; + + fn id(&self) -> Self::Id { + self.details.id + } + + fn update(&mut self, update: Self::Update) -> bool { + let mut updated = false; + + // Update the underlying payment details if present + if let Some(payment_update) = update.payment_update { + updated |= self.details.update(payment_update); + } + + if let Some(new_conflicting_txids) = update.conflicting_txids { + if self.conflicting_txids != new_conflicting_txids { + self.conflicting_txids = new_conflicting_txids; + updated = true; + } + } + + updated + } + + fn to_update(&self) -> Self::Update { + self.into() + } +} + +impl StorableObjectUpdate for PendingPaymentDetailsUpdate { + fn id(&self) -> ::Id { + self.id + } +} + +impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { + fn from(value: &PendingPaymentDetails) -> Self { + let conflicting_txids = if value.conflicting_txids.is_empty() { + None + } else { + Some(value.conflicting_txids.clone()) + }; + Self { id: value.id(), payment_update: Some(value.details.to_update()), conflicting_txids } + } +} + #[cfg(test)] mod tests { use lightning::util::ser::{Readable, Writeable}; From 5668ca9921c50ea02155ba48ee56d0ff5c7ef7aa Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:56:58 +0200 Subject: [PATCH 07/22] Store compact pending payment index records Persist only the payment id, current txid, and conflict txids in the pending payment index. This avoids duplicating payment state before the format ships and keeps RBF lookup aliases intact across replacement events. Co-Authored-By: HAL 9000 --- src/payment/store.rs | 52 ++++++++++++++----------- src/wallet/mod.rs | 91 ++++++++++++++++++++++++++++---------------- 2 files changed, 87 insertions(+), 56 deletions(-) diff --git a/src/payment/store.rs b/src/payment/store.rs index 03c1b7ed6..80623411d 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -589,28 +589,31 @@ impl StorableObjectUpdate for PaymentDetailsUpdate { /// Represents a pending payment #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingPaymentDetails { - /// The full payment details - pub details: PaymentDetails, + /// The payment id tracked in the main payment store. + pub payment_id: PaymentId, + /// The canonical transaction id currently associated with the payment. + pub txid: Txid, /// Transaction IDs that have replaced or conflict with this payment. pub conflicting_txids: Vec, } impl PendingPaymentDetails { - pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec) -> Self { - Self { details, conflicting_txids } + pub(crate) fn new(payment_id: PaymentId, txid: Txid, conflicting_txids: Vec) -> Self { + Self { payment_id, txid, conflicting_txids } } } impl_writeable_tlv_based!(PendingPaymentDetails, { - (0, details, required), - (2, conflicting_txids, optional_vec), + (0, payment_id, required), + (2, txid, required), + (4, conflicting_txids, optional_vec), }); #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingPaymentDetailsUpdate { - pub id: PaymentId, - pub payment_update: Option, - pub conflicting_txids: Option>, + pub payment_id: PaymentId, + pub txid: Txid, + pub conflicting_txids: Vec, } impl StorableObject for PendingPaymentDetails { @@ -618,20 +621,24 @@ impl StorableObject for PendingPaymentDetails { type Update = PendingPaymentDetailsUpdate; fn id(&self) -> Self::Id { - self.details.id + self.payment_id } fn update(&mut self, update: Self::Update) -> bool { let mut updated = false; - // Update the underlying payment details if present - if let Some(payment_update) = update.payment_update { - updated |= self.details.update(payment_update); + if self.txid != update.txid { + let old_txid = self.txid; + self.txid = update.txid; + if old_txid != self.txid && !self.conflicting_txids.contains(&old_txid) { + self.conflicting_txids.push(old_txid); + } + updated = true; } - if let Some(new_conflicting_txids) = update.conflicting_txids { - if self.conflicting_txids != new_conflicting_txids { - self.conflicting_txids = new_conflicting_txids; + for txid in update.conflicting_txids { + if txid != self.txid && !self.conflicting_txids.contains(&txid) { + self.conflicting_txids.push(txid); updated = true; } } @@ -646,18 +653,17 @@ impl StorableObject for PendingPaymentDetails { impl StorableObjectUpdate for PendingPaymentDetailsUpdate { fn id(&self) -> ::Id { - self.id + self.payment_id } } impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { fn from(value: &PendingPaymentDetails) -> Self { - let conflicting_txids = if value.conflicting_txids.is_empty() { - None - } else { - Some(value.conflicting_txids.clone()) - }; - Self { id: value.id(), payment_update: Some(value.details.to_update()), conflicting_txids } + Self { + payment_id: value.id(), + txid: value.txid, + conflicting_txids: value.conflicting_txids.clone(), + } } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3f6a09c79..4c6f7524f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -270,48 +270,62 @@ impl Wallet { if payment_status == PaymentStatus::Pending { let pending_payment = - self.create_pending_payment_from_tx(payment, Vec::new()); + self.create_pending_payment_from_tx(payment_id, txid, Vec::new()); self.runtime.block_on( self.pending_payment_store.insert_or_update(pending_payment), )?; + } else { + self.runtime.block_on(self.pending_payment_store.remove(&payment_id))?; } }, WalletEvent::ChainTipChanged { new_tip, .. } => { let pending_payments: Vec = - self.pending_payment_store.list_filter(|p| { - debug_assert!( - p.details.status == PaymentStatus::Pending, - "Non-pending payment {:?} found in pending store", - p.details.id, - ); - p.details.status == PaymentStatus::Pending - && matches!(p.details.kind, PaymentKind::Onchain { .. }) - }); + self.pending_payment_store.list_filter(|_| true); let mut unconfirmed_outbound_txids: Vec = Vec::new(); - for mut payment in pending_payments { - match payment.details.kind { + for pending_payment in pending_payments { + let Some(mut payment) = self.payment_store.get(&pending_payment.payment_id) + else { + self.runtime.block_on( + self.pending_payment_store.remove(&pending_payment.payment_id), + )?; + continue; + }; + + debug_assert!( + payment.status == PaymentStatus::Pending, + "Non-pending payment {:?} found in pending store", + payment.id, + ); + if payment.status != PaymentStatus::Pending { + self.runtime.block_on( + self.pending_payment_store.remove(&pending_payment.payment_id), + )?; + continue; + } + + match &payment.kind { PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { height, .. }, .. } => { - let payment_id = payment.details.id; - if new_tip.height >= height + ANTI_REORG_DELAY - 1 { - payment.details.status = PaymentStatus::Succeeded; + if new_tip.height >= *height + ANTI_REORG_DELAY - 1 { + payment.status = PaymentStatus::Succeeded; + self.runtime + .block_on(self.payment_store.insert_or_update(payment))?; self.runtime.block_on( - self.payment_store.insert_or_update(payment.details), + self.pending_payment_store + .remove(&pending_payment.payment_id), )?; - self.runtime - .block_on(self.pending_payment_store.remove(&payment_id))?; } }, PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, - } if payment.details.direction == PaymentDirection::Outbound => { - unconfirmed_outbound_txids.push(txid); + } if payment.direction == PaymentDirection::Outbound => { + unconfirmed_outbound_txids.push(*txid); }, _ => {}, } @@ -359,7 +373,7 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ); let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); + self.create_pending_payment_from_tx(payment_id, txid, Vec::new()); self.runtime.block_on(self.payment_store.insert_or_update(payment))?; self.runtime .block_on(self.pending_payment_store.insert_or_update(pending_payment))?; @@ -389,8 +403,22 @@ impl Wallet { ); let payment = self.payment_store.get(&payment_id).ok_or(Error::InvalidPaymentId)?; - let pending_payment_details = - self.create_pending_payment_from_tx(payment, conflict_txids.clone()); + let payment_txid = match &payment.kind { + PaymentKind::Onchain { txid, .. } => *txid, + _ => { + log_error!( + self.logger, + "Payment {:?} is not on-chain during WalletEvent::TxReplaced", + payment_id, + ); + continue; + }, + }; + let pending_payment_details = self.create_pending_payment_from_tx( + payment_id, + payment_txid, + conflict_txids, + ); self.runtime.block_on( self.pending_payment_store.insert_or_update(pending_payment_details), @@ -409,7 +437,7 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ); let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); + self.create_pending_payment_from_tx(payment_id, txid, Vec::new()); self.runtime.block_on(self.payment_store.insert_or_update(payment))?; self.runtime .block_on(self.pending_payment_store.insert_or_update(pending_payment))?; @@ -1207,9 +1235,9 @@ impl Wallet { } fn create_pending_payment_from_tx( - &self, payment: PaymentDetails, conflicting_txids: Vec, + &self, payment_id: PaymentId, txid: Txid, conflicting_txids: Vec, ) -> PendingPaymentDetails { - PendingPaymentDetails::new(payment, conflicting_txids) + PendingPaymentDetails::new(payment_id, txid, conflicting_txids) } fn find_payment_by_txid(&self, target_txid: Txid) -> Option { @@ -1220,13 +1248,10 @@ impl Wallet { if let Some(replaced_details) = self .pending_payment_store - .list_filter(|p| { - matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid) - || p.conflicting_txids.contains(&target_txid) - }) + .list_filter(|p| p.txid == target_txid || p.conflicting_txids.contains(&target_txid)) .first() { - return Some(replaced_details.details.id); + return Some(replaced_details.payment_id); } None @@ -1428,11 +1453,11 @@ impl Wallet { ); let pending_payment_store = - self.create_pending_payment_from_tx(new_payment.clone(), Vec::new()); + self.create_pending_payment_from_tx(new_payment.id, new_txid, vec![txid]); + self.runtime.block_on(self.payment_store.insert_or_update(new_payment))?; self.runtime .block_on(self.pending_payment_store.insert_or_update(pending_payment_store))?; - self.runtime.block_on(self.payment_store.insert_or_update(new_payment))?; log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid); From 008b937d2635f2f15725855b6c1acc72525c8e99 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 15 Jun 2026 10:07:47 +0200 Subject: [PATCH 08/22] Pin BDK 3.1 splice fee accounting Update splice integration expectations for BDK 3.1's fee rounding fix. This keeps the remaining LDK/BDK fee-accounting drift explicit. It prevents future dependency changes from restoring the larger surplus. Refs #901 Co-Authored-By: HAL 9000 --- tests/integration_tests_rust.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 34873f19d..a4974bcb5 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1150,15 +1150,13 @@ async fn splice_channel() { expect_channel_ready_event!(node_b, node_a.node_id()); let expected_splice_in_fee_sat = 251; - let expected_splice_in_onchain_cost_sat = 254; + let expected_splice_in_onchain_cost_sat = 253; - // LDK's fee calculation differs from BDK wallet's, which over pays on fees. Rather than giving - // the extra fees to the miner, LDK sends it to the channel balance since there may not be a - // change output. - // - // TODO: Some of the discrepancy is addressed upstream, so this number should be adjusted when - // updating the BDK wallet dependency. See: https://github.com/bitcoindevkit/bdk_wallet/pull/479 - let expected_splice_in_lightning_balance_sat = 4_000_003; + // BDK 3.1.0 avoids the previous per-UTXO fee rounding during coin selection. Keep the + // remaining 2-sat LDK/BDK fee-accounting drift explicit so a dependency change cannot silently + // reintroduce the larger surplus. Rather than giving the extra sats to the miner, LDK sends + // them to the channel balance since there may not be a change output. + let expected_splice_in_lightning_balance_sat = 4_000_002; let payments = node_b.list_payments(); let payment = From da753769956fe16adc85b6cd18cf53729f9f835f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 15 Jun 2026 10:26:34 +0200 Subject: [PATCH 09/22] Preserve anchor reserve during RBF RBF can spend fee increases from the original transaction's change output. Check the replacement fee increase against the current anchor-channel reserve before signing. This prevents high manual fee rates from consuming funds reserved for anchor spends. This finding was discovered by Project Loupe. Co-Authored-By: HAL 9000 --- src/payment/onchain.rs | 9 +++++- src/wallet/mod.rs | 37 ++++++++++++++++++++++++- tests/integration_tests_rust.rs | 49 +++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index 9d00968fc..da2685970 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -134,11 +134,18 @@ impl OnchainPayment { /// The new transaction will have the same outputs as the original but with a /// higher fee, resulting in faster confirmation potential. /// + /// This will respect any on-chain reserve we need to keep, i.e., won't allow to cut into + /// [`BalanceDetails::total_anchor_channels_reserve_sats`]. + /// /// Returns the [`Txid`] of the new replacement transaction if successful. + /// + /// [`BalanceDetails::total_anchor_channels_reserve_sats`]: crate::BalanceDetails::total_anchor_channels_reserve_sats pub fn bump_fee_rbf( &self, payment_id: PaymentId, fee_rate: Option, ) -> Result { + let cur_anchor_reserve_sats = + crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); - self.wallet.bump_fee_rbf(payment_id, fee_rate_opt) + self.wallet.bump_fee_rbf(payment_id, fee_rate_opt, cur_anchor_reserve_sats) } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 4c6f7524f..9de2284dd 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1259,7 +1259,7 @@ impl Wallet { #[allow(deprecated)] pub(crate) fn bump_fee_rbf( - &self, payment_id: PaymentId, fee_rate: Option, + &self, payment_id: PaymentId, fee_rate: Option, cur_anchor_reserve_sats: u64, ) -> Result { let payment = self.payment_store.get(&payment_id).ok_or_else(|| { log_error!(self.logger, "Payment {} not found in payment store", payment_id); @@ -1407,6 +1407,41 @@ impl Wallet { }? }; + let old_fee_sats = locked_wallet + .calculate_fee(&old_tx) + .map_err(|e| { + log_error!(self.logger, "Failed to calculate fee of transaction {}: {}", txid, e); + Error::WalletOperationFailed + })? + .to_sat(); + let replacement_fee_sats = locked_wallet + .calculate_fee(&psbt.unsigned_tx) + .map_err(|e| { + log_error!( + self.logger, + "Failed to calculate fee of replacement transaction for {}: {}", + txid, + e + ); + Error::WalletOperationFailed + })? + .to_sat(); + let additional_fee_sats = replacement_fee_sats.saturating_sub(old_fee_sats); + let balance = locked_wallet.balance(); + let spendable_amount_sats = + self.get_balances_inner(balance, cur_anchor_reserve_sats).map(|(_, s)| s).unwrap_or(0); + if spendable_amount_sats < additional_fee_sats { + log_error!( + self.logger, + "Unable to bump fee due to insufficient reserve-preserving funds. \ + Available: {}sats, required additional fee: {}sats, reserve: {}sats", + spendable_amount_sats, + additional_fee_sats, + cur_anchor_reserve_sats, + ); + return Err(Error::InsufficientFunds); + } + match locked_wallet.sign(&mut psbt, SignOptions::default()) { Ok(finalized) => { if !finalized { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index a4974bcb5..35cc81e27 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2920,6 +2920,55 @@ async fn onchain_fee_bump_rbf() { assert_eq!(node_a_received_payment[0].status, PaymentStatus::Succeeded); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn onchain_fee_bump_rbf_respects_anchor_reserve() { + 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 = 1_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a.clone(), addr_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_b, &node_a, 200_000, false, &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_b, node_a.node_id()); + + let balances_before = node_b.list_balances(); + let reserve = balances_before.total_anchor_channels_reserve_sats; + assert!(reserve > 0, "Anchor reserve should be non-zero after channel open"); + let spendable_before = balances_before.spendable_onchain_balance_sats; + + let buffer_sats = 5_000; + assert!(spendable_before > buffer_sats); + let amount_to_send_sats = spendable_before - buffer_sats; + let txid = + node_b.onchain_payment().send_to_address(&addr_a, amount_to_send_sats, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + node_b.sync_wallets().unwrap(); + + let payment_id = PaymentId(txid.to_byte_array()); + let high_fee_rate = bitcoin::FeeRate::from_sat_per_kwu(20_000); + assert_eq!( + Err(NodeError::InsufficientFunds), + node_b.onchain_payment().bump_fee_rbf(payment_id, Some(high_fee_rate)) + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn open_channel_with_all_with_anchors() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 66711db7da435a3eb5e002c254d9efc0bb87e702 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 11:24:58 -0500 Subject: [PATCH 10/22] Classify on-chain payments with a durable transaction type On-chain payment records don't capture what a transaction was for -- a channel open, splice, close, sweep, or a plain send. Record that classification on each on-chain payment, derived from the type LDK reports when broadcasting the transaction, so it survives restarts alongside the payment. The tag keeps only which channels a transaction relates to; amounts and fees stay on the payment. Existing records keep decoding unchanged. Compatible with the on-chain transaction classification proposed in #791. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/payment/mod.rs | 4 +- src/payment/store.rs | 230 +++++++++++++++++++++++++++++++- src/wallet/mod.rs | 3 +- tests/integration_tests_rust.rs | 4 +- 4 files changed, 233 insertions(+), 8 deletions(-) diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 8e2aa00ee..7477617e7 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -22,7 +22,7 @@ pub use onchain::OnchainPayment; pub use spontaneous::SpontaneousPayment; pub(crate) use store::PendingPaymentDetails; pub use store::{ - ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind, - PaymentStatus, + Channel, ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind, + PaymentStatus, TransactionType, }; pub use unified::{UnifiedPayment, UnifiedPaymentResult}; diff --git a/src/payment/store.rs b/src/payment/store.rs index 80623411d..dc4fe6dbf 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -7,9 +7,12 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use bitcoin::secp256k1::PublicKey; use bitcoin::{BlockHash, Txid}; +use lightning::chain::chaininterface::TransactionType as LdkTransactionType; use lightning::ln::channelmanager::PaymentId; use lightning::ln::msgs::DecodeError; +use lightning::ln::types::ChannelId; use lightning::offers::offer::OfferId; use lightning::util::ser::{Readable, Writeable}; use lightning::{ @@ -282,6 +285,15 @@ impl StorableObject for PaymentDetails { } } + if let Some(tx_type_update) = update.tx_type { + match self.kind { + PaymentKind::Onchain { ref mut tx_type, .. } => { + update_if_necessary!(*tx_type, tx_type_update); + }, + _ => {}, + } + } + if updated { self.latest_update_timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -330,6 +342,156 @@ impl_writeable_tlv_based_enum!(PaymentStatus, (4, Failed) => {} ); +/// A channel referenced by a [`TransactionType`]. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct Channel { + /// The `node_id` of the channel counterparty. + pub counterparty_node_id: PublicKey, + /// The ID of the channel. + pub channel_id: ChannelId, +} + +impl_writeable_tlv_based!(Channel, { + (0, counterparty_node_id, required), + (2, channel_id, required), +}); + +/// The classification of a [`PaymentKind::Onchain`] transaction, as reported by LDK when the +/// transaction was broadcast. +/// +/// Mirrors [`lightning::chain::chaininterface::TransactionType`], retaining the channel references +/// but dropping the broadcast-time contribution data; a transaction's amount and fee are tracked on +/// the [`PaymentDetails`] itself. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum TransactionType { + /// A funding transaction establishing one or more new channels. + Funding { + /// The channels being funded. + channels: Vec, + }, + /// A transaction cooperatively closing a channel. + CooperativeClose { + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The ID of the channel being closed. + channel_id: ChannelId, + }, + /// A transaction force-closing a channel. + UnilateralClose { + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The ID of the channel being force-closed. + channel_id: ChannelId, + }, + /// An anchor transaction CPFP fee-bumping a closing transaction. + AnchorBump { + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The ID of the channel whose closing transaction is being fee-bumped. + channel_id: ChannelId, + }, + /// A transaction resolving an output spendable by both us and our counterparty. + Claim { + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The ID of the channel from which outputs are being claimed. + channel_id: ChannelId, + }, + /// A transaction sweeping spendable outputs to the on-chain wallet. + Sweep { + /// The channels from which outputs are being swept, if known. + channels: Vec, + }, + /// An interactively-negotiated funding transaction: a splice, or (once supported) a V2 + /// dual-funded channel open. + InteractiveFunding { + /// The channels participating in the negotiation. + channels: Vec, + }, +} + +impl_writeable_tlv_based_enum!(TransactionType, + (0, Funding) => { + (0, channels, optional_vec), + }, + (2, CooperativeClose) => { + (0, counterparty_node_id, required), + (2, channel_id, required), + }, + (4, UnilateralClose) => { + (0, counterparty_node_id, required), + (2, channel_id, required), + }, + (6, AnchorBump) => { + (0, counterparty_node_id, required), + (2, channel_id, required), + }, + (8, Claim) => { + (0, counterparty_node_id, required), + (2, channel_id, required), + }, + (10, Sweep) => { + (0, channels, optional_vec), + }, + (12, InteractiveFunding) => { + (0, channels, optional_vec), + } +); + +impl From for TransactionType { + fn from(tx_type: LdkTransactionType) -> Self { + let to_channels = |channels: Vec<(PublicKey, ChannelId)>| -> Vec { + channels + .into_iter() + .map(|(counterparty_node_id, channel_id)| Channel { + counterparty_node_id, + channel_id, + }) + .collect() + }; + match tx_type { + LdkTransactionType::Funding { channels } => { + TransactionType::Funding { channels: to_channels(channels) } + }, + LdkTransactionType::CooperativeClose { counterparty_node_id, channel_id } => { + TransactionType::CooperativeClose { counterparty_node_id, channel_id } + }, + LdkTransactionType::UnilateralClose { counterparty_node_id, channel_id } => { + TransactionType::UnilateralClose { counterparty_node_id, channel_id } + }, + LdkTransactionType::AnchorBump { counterparty_node_id, channel_id } => { + TransactionType::AnchorBump { counterparty_node_id, channel_id } + }, + LdkTransactionType::Claim { counterparty_node_id, channel_id } => { + TransactionType::Claim { counterparty_node_id, channel_id } + }, + LdkTransactionType::Sweep { channels } => { + TransactionType::Sweep { channels: to_channels(channels) } + }, + LdkTransactionType::InteractiveFunding { candidates } => { + // Every candidate (the original negotiation plus any RBF replacements) references + // the same channel(s); take the active (last) candidate's channel references. + let channels = candidates + .last() + .map(|candidate| { + candidate + .channels + .iter() + .map(|cf| Channel { + counterparty_node_id: cf.counterparty_node_id, + channel_id: cf.channel_id, + }) + .collect() + }) + .unwrap_or_default(); + TransactionType::InteractiveFunding { channels } + }, + } + } +} + /// Represents the kind of a payment. #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] @@ -345,6 +507,11 @@ pub enum PaymentKind { txid: Txid, /// The confirmation status of this payment. status: ConfirmationStatus, + /// The classification of this transaction, if known. + /// + /// `None` for plain on-chain sends, and for records written by versions of LDK Node that + /// predate on-chain transaction classification. + tx_type: Option, }, /// A [BOLT 11] payment. /// @@ -423,6 +590,7 @@ pub enum PaymentKind { impl_writeable_tlv_based_enum!(PaymentKind, (0, Onchain) => { (0, txid, required), + (1, tx_type, option), (2, status, required), }, (2, Bolt11) => { @@ -522,6 +690,7 @@ pub(crate) struct PaymentDetailsUpdate { pub status: Option, pub confirmation_status: Option, pub txid: Option, + pub tx_type: Option>, } impl PaymentDetailsUpdate { @@ -538,6 +707,7 @@ impl PaymentDetailsUpdate { status: None, confirmation_status: None, txid: None, + tx_type: None, } } } @@ -552,9 +722,11 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate { _ => (None, None, None), }; - let (confirmation_status, txid) = match &value.kind { - PaymentKind::Onchain { status, txid, .. } => (Some(*status), Some(*txid)), - _ => (None, None), + let (confirmation_status, txid, tx_type) = match &value.kind { + PaymentKind::Onchain { status, txid, tx_type } => { + (Some(*status), Some(*txid), Some(tx_type.clone())) + }, + _ => (None, None, None), }; let counterparty_skimmed_fee_msat = match value.kind { @@ -576,6 +748,7 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate { status: Some(value.status), confirmation_status, txid, + tx_type, } } } @@ -778,6 +951,57 @@ mod tests { } } + #[derive(Clone, Debug, PartialEq, Eq)] + struct OldOnchainKind { + txid: Txid, + status: ConfirmationStatus, + } + + impl_writeable_tlv_based!(OldOnchainKind, { + (0, txid, required), + (2, status, required), + }); + + #[test] + fn onchain_tx_type_deser_compat() { + use bitcoin::hashes::Hash; + use std::str::FromStr; + + let txid = Txid::from_byte_array([7u8; 32]); + let status = ConfirmationStatus::Unconfirmed; + + // An `Onchain` record written before `tx_type` existed (only txid + status) must read back + // with `tx_type: None`. + let old = OldOnchainKind { txid, status }; + let mut on_disk = Vec::new(); + 0u8.write(&mut on_disk).unwrap(); // the `Onchain` enum discriminant + on_disk.extend_from_slice(&old.encode()); + match PaymentKind::read(&mut &*on_disk).unwrap() { + PaymentKind::Onchain { txid: t, status: s, tx_type } => { + assert_eq!(t, txid); + assert_eq!(s, status); + assert_eq!(tx_type, None); + }, + other => panic!("Unexpected kind: {:?}", other), + } + + // A populated `tx_type` round-trips. + let kind = PaymentKind::Onchain { + txid, + status, + tx_type: Some(TransactionType::InteractiveFunding { + channels: vec![Channel { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([3u8; 32]), + }], + }), + }; + assert_eq!(kind, PaymentKind::read(&mut &*kind.encode()).unwrap()); + } + #[derive(Clone, Debug, PartialEq, Eq)] struct LegacyBolt11JitKind { hash: PaymentHash, diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 9de2284dd..22adf29c7 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -324,6 +324,7 @@ impl Wallet { PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, + .. } if payment.direction == PaymentDirection::Outbound => { unconfirmed_outbound_txids.push(*txid); }, @@ -1201,7 +1202,7 @@ impl Wallet { // here to determine the `PaymentKind`, but that's not really satisfactory, so // we're punting on it until we can come up with a better solution. - let kind = PaymentKind::Onchain { txid, status: confirmation_status }; + let kind = PaymentKind::Onchain { txid, status: confirmation_status, tx_type: None }; let fee = locked_wallet.calculate_fee(tx).unwrap_or(Amount::ZERO); let (sent, received) = locked_wallet.sent_and_received(tx); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 35cc81e27..da4adbeaa 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -515,7 +515,7 @@ async fn onchain_send_receive() { let payment_a = node_a.payment(&payment_id).unwrap(); match payment_a.kind { - PaymentKind::Onchain { txid: _txid, status } => { + PaymentKind::Onchain { txid: _txid, status, .. } => { assert_eq!(_txid, txid); assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); }, @@ -524,7 +524,7 @@ async fn onchain_send_receive() { let payment_b = node_a.payment(&payment_id).unwrap(); match payment_b.kind { - PaymentKind::Onchain { txid: _txid, status } => { + PaymentKind::Onchain { txid: _txid, status, .. } => { assert_eq!(_txid, txid); assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); }, From 06a83cfec0fade20fac38a5cf0a222023d9027df Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 11:59:40 -0500 Subject: [PATCH 11/22] Track channel-open and splice payments through wallet sync Record channel-open and splice funding transactions as on-chain payments at broadcast, and carry them to Succeeded through ANTI_REORG_DELAY confirmations like any other on-chain payment, instead of tying their status to the Lightning channel lifecycle. A splice's recorded amount and fee are this node's share of the funding contribution, which wallet sync preserves rather than overwriting with its own view of the (possibly multi-party) transaction. On-chain RBF of these payments is rejected: LDK drives funding and splice transactions, so replacing one would broadcast a transaction it isn't tracking and, for a splice, can't re-sign. Addresses review feedback to keep on-chain payment status confirmation- driven rather than gated on ChannelReady. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/builder.rs | 2 + src/chain/mod.rs | 25 +++- src/tx_broadcaster.rs | 51 ++++++- src/wallet/mod.rs | 315 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 382 insertions(+), 11 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 3df594b7c..efa5c50da 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1618,6 +1618,8 @@ fn build_with_store_internal( Arc::clone(&pending_payment_store), )); + tx_broadcaster.set_wallet(Arc::downgrade(&wallet)); + // Initialize the KeysManager let cur_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_err(|e| { log_error!(logger, "Failed to get current time: {}", e); diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 92c4bdb64..d3b671eed 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Duration; -use bitcoin::{Script, Txid}; +use bitcoin::{Script, Transaction, Txid}; use lightning::chain::{BlockLocator, Filter}; use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient}; @@ -24,7 +24,7 @@ use crate::config::{ WALLET_SYNC_INTERVAL_MINIMUM_SECS, }; use crate::fee_estimator::OnchainFeeEstimator; -use crate::logger::{log_debug, log_info, log_trace, LdkLogger, Logger}; +use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -453,15 +453,30 @@ impl ChainSource { return; } Some(next_package) = receiver.recv() => { + // Classify funding broadcasts into payment records before sending. If + // classification fails we skip the broadcast, since broadcasting a tx we + // failed to record would leave it on-chain without a payment. + let package = match self.tx_broadcaster.classify_package(next_package).await { + Ok(package) => package, + Err(e) => { + log_error!( + tx_bcast_logger, + "Skipping broadcast: failed to persist payment records: {:?}", + e, + ); + continue; + }, + }; + let txs: Vec = package.into_iter().map(|(tx, _)| tx).collect(); match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_broadcast_package(next_package).await + esplora_chain_source.process_broadcast_package(txs).await }, ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_broadcast_package(next_package).await + electrum_chain_source.process_broadcast_package(txs).await }, ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_broadcast_package(next_package).await + bitcoind_chain_source.process_broadcast_package(txs).await }, } } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 7084135b0..cdffe7383 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -6,21 +6,34 @@ // accordance with one or both of these licenses. use std::ops::Deref; +use std::sync::{Mutex as StdMutex, Weak}; use bitcoin::Transaction; use lightning::chain::chaininterface::{BroadcasterInterface, TransactionType}; use tokio::sync::{mpsc, Mutex, MutexGuard}; use crate::logger::{log_error, LdkLogger}; +use crate::types::Wallet; +use crate::Error; const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; +/// A package of transactions that LDK handed to the broadcaster in one +/// `broadcast_transactions` call, along with each transaction's type. Queued until the +/// background task classifies and broadcasts it. +pub(crate) type BroadcastPackage = Vec<(Transaction, TransactionType)>; + pub(crate) struct TransactionBroadcaster where L::Target: LdkLogger, { - queue_sender: mpsc::Sender>, - queue_receiver: Mutex>>, + queue_sender: mpsc::Sender, + queue_receiver: Mutex>, + /// Weak handle to the [`Wallet`] that classifies funding broadcasts (channel opens and + /// splices) into payment records. Remains `None` while the builder is wiring the node up, + /// during which broadcasts are forwarded to the queue but no payment record is written. + /// [`Self::set_wallet`] installs the handle once the [`Wallet`] exists. + wallet: StdMutex>>, logger: L, } @@ -30,14 +43,41 @@ where { pub(crate) fn new(logger: L) -> Self { let (queue_sender, queue_receiver) = mpsc::channel(BCAST_PACKAGE_QUEUE_SIZE); - Self { queue_sender, queue_receiver: Mutex::new(queue_receiver), logger } + Self { + queue_sender, + queue_receiver: Mutex::new(queue_receiver), + wallet: StdMutex::new(None), + logger, + } + } + + /// Installs the [`Wallet`] handle used to classify funding broadcasts (channel opens and + /// splices) into payment records. Called once the builder has constructed both the + /// broadcaster and the wallet. + pub(crate) fn set_wallet(&self, wallet: Weak) { + *self.wallet.lock().expect("lock") = Some(wallet); } pub(crate) async fn get_broadcast_queue( &self, - ) -> MutexGuard<'_, mpsc::Receiver>> { + ) -> MutexGuard<'_, mpsc::Receiver> { self.queue_receiver.lock().await } + + /// Classifies a queued package into payment records and returns the package ready for the + /// chain client. Returns `Err` if any classification fails; callers must not broadcast the + /// package in that case, since a crash would leave the transaction on-chain without a record. + pub(crate) async fn classify_package( + &self, package: BroadcastPackage, + ) -> Result { + let wallet_opt = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade); + if let Some(wallet) = wallet_opt { + for (tx, tx_type) in &package { + wallet.classify_broadcast(tx, tx_type).await?; + } + } + Ok(package) + } } impl BroadcasterInterface for TransactionBroadcaster @@ -45,7 +85,8 @@ where L::Target: LdkLogger, { fn broadcast_transactions(&self, txs: &[(&Transaction, TransactionType)]) { - let package = txs.iter().map(|(t, _)| (*t).clone()).collect::>(); + let package: BroadcastPackage = + txs.iter().map(|(tx, tx_type)| ((*tx).clone(), tx_type.clone())).collect(); self.queue_sender.try_send(package).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 22adf29c7..e0e8f036b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -31,14 +31,17 @@ use bitcoin::{ WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::{ - BroadcasterInterface, INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, + BroadcasterInterface, FundingCandidate, TransactionType as LdkTransactionType, + INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::{BlockLocator, ClaimId, Listen}; use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::FundingContribution; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; use lightning::ln::script::ShutdownScript; +use lightning::ln::types::ChannelId; use lightning::sign::{ ChangeDestinationSource, EntropySource, InMemorySigner, KeysManager, NodeSigner, OutputSpender, PeerStorageKey, Recipient, SignerProvider, SpendableOutputDescriptor, @@ -56,6 +59,7 @@ use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger use crate::payment::store::ConfirmationStatus; use crate::payment::{ PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, PendingPaymentDetails, + TransactionType, }; use crate::runtime::Runtime; use crate::types::{Broadcaster, PaymentStore, PendingPaymentStore}; @@ -257,6 +261,10 @@ impl Wallet { .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); + if self.apply_funding_status_update(payment_id, txid, confirmation_status)? { + continue; + } + let payment = self.create_payment_from_tx( locked_wallet, txid, @@ -365,6 +373,14 @@ impl Wallet { .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); + if self.apply_funding_status_update( + payment_id, + txid, + ConfirmationStatus::Unconfirmed, + )? { + continue; + } + let payment = self.create_payment_from_tx( locked_wallet, txid, @@ -429,6 +445,15 @@ impl Wallet { let payment_id = self .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); + + if self.apply_funding_status_update( + payment_id, + txid, + ConfirmationStatus::Unconfirmed, + )? { + continue; + } + let payment = self.create_payment_from_tx( locked_wallet, txid, @@ -1185,6 +1210,183 @@ impl Wallet { Ok(tx) } + /// Classifies a funding broadcast (channel open or splice) handed to the broadcaster by LDK, + /// recording a payment for it before it is sent. Other transaction types are left for wallet + /// sync to record normally. + pub(crate) async fn classify_broadcast( + &self, tx: &Transaction, tx_type: &LdkTransactionType, + ) -> Result<(), Error> { + match tx_type { + LdkTransactionType::Funding { channels } => { + self.classify_funding(tx, channels, tx_type.clone().into()).await + }, + LdkTransactionType::InteractiveFunding { candidates } => { + self.classify_interactive_funding(tx, candidates, tx_type.clone().into()).await + }, + _ => Ok(()), + } + } + + /// Records a single-channel funding (channel open) broadcast as a pending on-chain payment, + /// tagged with its transaction type. Amount and fee come from the wallet's view of the + /// transaction. Batched funding is left for wallet sync. + async fn classify_funding( + &self, tx: &Transaction, channels: &[(PublicKey, ChannelId)], tx_type: TransactionType, + ) -> Result<(), Error> { + if channels.len() != 1 { + if channels.len() > 1 { + log_trace!( + self.logger, + "Skipping funding classification for batched broadcast ({} channels)", + channels.len() + ); + } + return Ok(()); + } + + let (_counterparty_node_id, channel_id) = channels[0]; + let txid = tx.compute_txid(); + let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); + + let payment_id = PaymentId(txid.to_byte_array()); + let details = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(tx_type), + }, + amount_msat, + fee_paid_msat, + direction, + PaymentStatus::Pending, + ); + self.persist_funding_payment(details, txid).await?; + log_debug!( + self.logger, + "Recorded channel-funding broadcast {} for channel {}", + txid, + channel_id, + ); + Ok(()) + } + + /// Records an interactive-funding broadcast (splice, or a V2 dual-funded open) as a pending + /// on-chain payment, tagged with its transaction type. Amount and fee are this node's share, + /// derived from the active candidate's contributions; broadcasts we didn't contribute to, or + /// that don't move wallet funds, are left for wallet sync. + async fn classify_interactive_funding( + &self, tx: &Transaction, candidates: &[FundingCandidate], tx_type: TransactionType, + ) -> Result<(), Error> { + // `InteractiveFunding` carries the full negotiated history; the currently-broadcast + // candidate is the last entry, earlier entries are RBF predecessors. + let active = match candidates.last() { + Some(c) => c, + None => return Ok(()), + }; + let first = match candidates.first() { + Some(c) => c, + None => return Ok(()), + }; + + let txid = tx.compute_txid(); + debug_assert_eq!(active.txid, txid, "broadcast tx must match the active candidate"); + + let aggregate = aggregate_local_stakes(active); + let amount_msat = match aggregate.amount_msat { + Some(amt) => Some(amt), + None => { + log_trace!( + self.logger, + "Skipping interactive-funding broadcast {}: no local contribution", + txid, + ); + return Ok(()); + }, + }; + let fee_paid_msat = aggregate.fee_paid_msat; + let direction = aggregate.direction; + + // Skip broadcasts that don't move funds in or out of our on-chain wallet — e.g. a + // splice-out we initiated toward an external address. + let (wallet_amount_msat, _wallet_fee_msat, _wallet_direction) = + self.onchain_payment_fields(tx); + if wallet_amount_msat == Some(0) { + log_trace!( + self.logger, + "Skipping interactive-funding broadcast {}: no wallet-level activity", + txid, + ); + return Ok(()); + } + + // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable + // across RBF replacements. + let payment_id = PaymentId(first.txid.to_byte_array()); + let details = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(tx_type), + }, + amount_msat, + fee_paid_msat, + direction, + PaymentStatus::Pending, + ); + self.persist_funding_payment(details, txid).await?; + log_debug!( + self.logger, + "Recorded interactive-funding broadcast {} ({} candidates, {} channels)", + txid, + candidates.len(), + active.channels.len(), + ); + Ok(()) + } + + /// Writes a freshly-classified funding payment to the authoritative payment store and adds a + /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. + async fn persist_funding_payment( + &self, details: PaymentDetails, txid: Txid, + ) -> Result<(), Error> { + let payment_id = details.id; + self.payment_store.insert_or_update(details).await?; + let pending = PendingPaymentDetails::new(payment_id, txid, Vec::new()); + self.pending_payment_store.insert_or_update(pending).await?; + Ok(()) + } + + /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. + pub(crate) fn onchain_payment_fields( + &self, tx: &Transaction, + ) -> (Option, Option, PaymentDirection) { + let locked_wallet = self.inner.lock().expect("lock"); + let fee = locked_wallet.calculate_fee(tx).unwrap_or(Amount::ZERO); + let (sent, received) = locked_wallet.sent_and_received(tx); + let fee_sat = fee.to_sat(); + + let (direction, amount_msat) = if sent > received { + ( + PaymentDirection::Outbound, + Some( + (sent.to_sat().saturating_sub(fee_sat).saturating_sub(received.to_sat())) + * 1000, + ), + ) + } else { + ( + PaymentDirection::Inbound, + Some( + received.to_sat().saturating_sub(sent.to_sat().saturating_sub(fee_sat)) * 1000, + ), + ) + }; + + (amount_msat, Some(fee_sat * 1000), direction) + } + fn create_payment_from_tx( &self, locked_wallet: &PersistedWallet, txid: Txid, payment_id: PaymentId, tx: &Transaction, payment_status: PaymentStatus, @@ -1258,6 +1460,34 @@ impl Wallet { None } + /// If `payment_id` refers to a classified funding payment, refreshes its confirmation status + /// and the candidate txid the event refers to, while preserving the contribution-derived + /// amount/fee and `tx_type` that wallet sync must not recompute from its own view. Returns + /// `true` when it handled the payment, so the caller skips the default on-chain path. + /// Graduation to `Succeeded` is left to `ChainTipChanged` via `ANTI_REORG_DELAY`. + fn apply_funding_status_update( + &self, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, + ) -> Result { + let Some(mut payment) = self.payment_store.get(&payment_id) else { + return Ok(false); + }; + let tx_type = match &payment.kind { + PaymentKind::Onchain { + tx_type: + tx_type @ Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + .. + } => tx_type.clone(), + _ => return Ok(false), + }; + payment.kind = + PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; + self.runtime.block_on(self.payment_store.insert_or_update(payment))?; + Ok(true) + } + #[allow(deprecated)] pub(crate) fn bump_fee_rbf( &self, payment_id: PaymentId, fee_rate: Option, cur_anchor_reserve_sats: u64, @@ -1267,6 +1497,24 @@ impl Wallet { Error::InvalidPaymentId })?; + // Funding transactions (channel opens and splices) are driven by LDK's funding/splice + // lifecycle, not the on-chain wallet. Replacing one via on-chain RBF would broadcast a + // transaction LDK isn't tracking (and, for splices, can't sign). Fee-bumping a pending + // splice goes through `bump_channel_funding_fee` instead. + if let PaymentKind::Onchain { + tx_type: + Some(TransactionType::Funding { .. } | TransactionType::InteractiveFunding { .. }), + .. + } = &payment.kind + { + log_error!( + self.logger, + "Cannot RBF funding payment {} via bump_fee_rbf; use bump_channel_funding_fee instead", + payment_id, + ); + return Err(Error::InvalidPaymentId); + } + if let PaymentKind::Onchain { status, .. } = &payment.kind { match status { ConfirmationStatus::Confirmed { .. } => { @@ -1501,6 +1749,71 @@ impl Wallet { } } +/// Returns this node's share of the on-chain fee for a funding transaction (channel open or +/// splice), in millisatoshis. Sourced from the contribution's per-contributor estimated fee. +fn our_actual_fee_msat(contribution: &FundingContribution) -> u64 { + contribution.estimated_fee().to_sat() * 1000 +} + +struct LocalStakeAggregate { + amount_msat: Option, + fee_paid_msat: Option, + direction: PaymentDirection, +} + +/// Aggregates local-stake amount/fee/direction across the channels of a single +/// [`FundingCandidate`]. Each channel's contribution (when present) is treated as local-stake-only, +/// so contributions across channels are summed without double-counting. +fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { + let mut amount_outbound: u64 = 0; + let mut amount_inbound: u64 = 0; + let mut fee: u64 = 0; + let mut have_contribution = false; + for channel in &candidate.channels { + if let Some(c) = channel.contribution.as_ref() { + have_contribution = true; + fee = fee.saturating_add(our_actual_fee_msat(c)); + match contribution_direction(c) { + Some((PaymentDirection::Outbound, amt)) => { + amount_outbound = amount_outbound.saturating_add(amt); + }, + Some((PaymentDirection::Inbound, amt)) => { + amount_inbound = amount_inbound.saturating_add(amt); + }, + None => {}, + } + } + } + if !have_contribution { + return LocalStakeAggregate { + amount_msat: None, + fee_paid_msat: None, + direction: PaymentDirection::Outbound, + }; + } + let (direction, amount_msat) = if amount_outbound >= amount_inbound { + (PaymentDirection::Outbound, amount_outbound.saturating_sub(amount_inbound)) + } else { + (PaymentDirection::Inbound, amount_inbound.saturating_sub(amount_outbound)) + }; + LocalStakeAggregate { amount_msat: Some(amount_msat), fee_paid_msat: Some(fee), direction } +} + +/// Returns this contribution's direction and magnitude in msat, or `None` if it can't be classified +/// as a single inbound or outbound payment. +fn contribution_direction(contribution: &FundingContribution) -> Option<(PaymentDirection, u64)> { + let value_added = contribution.value_added(); + let outputs_total: Amount = contribution.outputs().iter().map(|o| o.value).sum(); + + if value_added > Amount::ZERO && outputs_total == Amount::ZERO { + Some((PaymentDirection::Outbound, value_added.to_sat() * 1000)) + } else if value_added == Amount::ZERO && outputs_total > Amount::ZERO { + Some((PaymentDirection::Inbound, outputs_total.to_sat() * 1000)) + } else { + None + } +} + impl Listen for Wallet { fn filtered_block_connected( &self, _header: &bitcoin::block::Header, From dc6b89f75fcedc1f1707057808709b676046d6e0 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 24 Jun 2026 15:48:04 -0500 Subject: [PATCH 12/22] f - sum contribution net values to classify the funding direction --- src/wallet/mod.rs | 64 +++++++++++++++-------------------------------- 1 file changed, 20 insertions(+), 44 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index e0e8f036b..442e9f1c9 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -27,8 +27,8 @@ use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; use bitcoin::secp256k1::{All, PublicKey, Scalar, Secp256k1, SecretKey}; use bitcoin::transaction::Sequence; use bitcoin::{ - Address, Amount, FeeRate, OutPoint, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, Weight, - WitnessProgram, WitnessVersion, + Address, Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, Transaction, TxOut, Txid, + WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::{ BroadcasterInterface, FundingCandidate, TransactionType as LdkTransactionType, @@ -37,7 +37,6 @@ use lightning::chain::chaininterface::{ use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::{BlockLocator, ClaimId, Listen}; use lightning::ln::channelmanager::PaymentId; -use lightning::ln::funding::FundingContribution; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; use lightning::ln::script::ShutdownScript; @@ -1749,39 +1748,25 @@ impl Wallet { } } -/// Returns this node's share of the on-chain fee for a funding transaction (channel open or -/// splice), in millisatoshis. Sourced from the contribution's per-contributor estimated fee. -fn our_actual_fee_msat(contribution: &FundingContribution) -> u64 { - contribution.estimated_fee().to_sat() * 1000 -} - struct LocalStakeAggregate { amount_msat: Option, fee_paid_msat: Option, direction: PaymentDirection, } -/// Aggregates local-stake amount/fee/direction across the channels of a single -/// [`FundingCandidate`]. Each channel's contribution (when present) is treated as local-stake-only, -/// so contributions across channels are summed without double-counting. +/// Aggregates our net stake across the channels of a single [`FundingCandidate`] by summing each +/// channel's signed [`FundingContribution::net_value`]. Returns no amount if we contributed to none +/// of them. fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { - let mut amount_outbound: u64 = 0; - let mut amount_inbound: u64 = 0; - let mut fee: u64 = 0; + let mut net_stake = SignedAmount::ZERO; + let mut fee = Amount::ZERO; let mut have_contribution = false; for channel in &candidate.channels { - if let Some(c) = channel.contribution.as_ref() { + if let Some(contribution) = channel.contribution.as_ref() { have_contribution = true; - fee = fee.saturating_add(our_actual_fee_msat(c)); - match contribution_direction(c) { - Some((PaymentDirection::Outbound, amt)) => { - amount_outbound = amount_outbound.saturating_add(amt); - }, - Some((PaymentDirection::Inbound, amt)) => { - amount_inbound = amount_inbound.saturating_add(amt); - }, - None => {}, - } + net_stake += contribution.net_value(); + // `estimated_fee` is our per-contributor share, so summing across channels is correct. + fee += contribution.estimated_fee(); } } if !have_contribution { @@ -1791,26 +1776,17 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { direction: PaymentDirection::Outbound, }; } - let (direction, amount_msat) = if amount_outbound >= amount_inbound { - (PaymentDirection::Outbound, amount_outbound.saturating_sub(amount_inbound)) + // Direction is from our on-chain wallet's perspective: a positive net stake funds the channel + // (Outbound), while a negative one is a splice-out that returns funds to the wallet (Inbound). + let direction = if net_stake >= SignedAmount::ZERO { + PaymentDirection::Outbound } else { - (PaymentDirection::Inbound, amount_inbound.saturating_sub(amount_outbound)) + PaymentDirection::Inbound }; - LocalStakeAggregate { amount_msat: Some(amount_msat), fee_paid_msat: Some(fee), direction } -} - -/// Returns this contribution's direction and magnitude in msat, or `None` if it can't be classified -/// as a single inbound or outbound payment. -fn contribution_direction(contribution: &FundingContribution) -> Option<(PaymentDirection, u64)> { - let value_added = contribution.value_added(); - let outputs_total: Amount = contribution.outputs().iter().map(|o| o.value).sum(); - - if value_added > Amount::ZERO && outputs_total == Amount::ZERO { - Some((PaymentDirection::Outbound, value_added.to_sat() * 1000)) - } else if value_added == Amount::ZERO && outputs_total > Amount::ZERO { - Some((PaymentDirection::Inbound, outputs_total.to_sat() * 1000)) - } else { - None + LocalStakeAggregate { + amount_msat: Some(net_stake.unsigned_abs().to_sat() * 1000), + fee_paid_msat: Some(fee.to_sat() * 1000), + direction, } } From 4da7335a0ab58dc22af0c95620f8df949861b544 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 24 Jun 2026 17:02:49 -0500 Subject: [PATCH 13/22] f - clarify the wallet-involvement skip comment --- src/wallet/mod.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 442e9f1c9..881e90307 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1306,8 +1306,9 @@ impl Wallet { let fee_paid_msat = aggregate.fee_paid_msat; let direction = aggregate.direction; - // Skip broadcasts that don't move funds in or out of our on-chain wallet — e.g. a - // splice-out we initiated toward an external address. + // A contribution doesn't mean the tx touches our on-chain wallet: a splice-out to an + // external address sends channel funds to a third party, which BDK sees as zero wallet + // movement. Nothing for the on-chain payment store to record, so skip it. let (wallet_amount_msat, _wallet_fee_msat, _wallet_direction) = self.onchain_payment_fields(tx); if wallet_amount_msat == Some(0) { From 737b4b2e88b069cef84b68974aa4606c820fbad6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 24 Jun 2026 17:13:52 -0500 Subject: [PATCH 14/22] f - log that we skip the payment record, not the broadcast --- src/wallet/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 881e90307..2caa83123 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1297,7 +1297,7 @@ impl Wallet { None => { log_trace!( self.logger, - "Skipping interactive-funding broadcast {}: no local contribution", + "Not recording interactive-funding broadcast {} as a payment: no local contribution", txid, ); return Ok(()); @@ -1314,7 +1314,7 @@ impl Wallet { if wallet_amount_msat == Some(0) { log_trace!( self.logger, - "Skipping interactive-funding broadcast {}: no wallet-level activity", + "Not recording interactive-funding broadcast {} as a payment: no wallet-level activity", txid, ); return Ok(()); From 02575edd5d30285cc3909a3e9c0c1e9851c9e20b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 24 Jun 2026 17:28:29 -0500 Subject: [PATCH 15/22] f - make BroadcastPackage a struct built from a single broadcast call --- src/chain/mod.rs | 2 +- src/tx_broadcaster.rs | 32 ++++++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index d3b671eed..7e9e674c7 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -467,7 +467,7 @@ impl ChainSource { continue; }, }; - let txs: Vec = package.into_iter().map(|(tx, _)| tx).collect(); + let txs: Vec = package.into_transactions(); match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { esplora_chain_source.process_broadcast_package(txs).await diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index cdffe7383..5722a3ebe 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -18,10 +18,28 @@ use crate::Error; const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; -/// A package of transactions that LDK handed to the broadcaster in one -/// `broadcast_transactions` call, along with each transaction's type. Queued until the -/// background task classifies and broadcasts it. -pub(crate) type BroadcastPackage = Vec<(Transaction, TransactionType)>; +/// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions` +/// call, along with each transaction's type. Queued until the background task classifies and +/// broadcasts it. Built only via [`BroadcastPackage::new`] from such a call, so unrelated +/// transactions can't be grouped into one package by accident. +pub(crate) struct BroadcastPackage(Vec<(Transaction, TransactionType)>); + +impl BroadcastPackage { + /// Builds a package from the transactions of a single `broadcast_transactions` call. + fn new(txs: &[(&Transaction, TransactionType)]) -> Self { + Self(txs.iter().map(|(tx, tx_type)| ((*tx).clone(), tx_type.clone())).collect()) + } + + /// The packaged transactions and their types, for classification. + fn transactions(&self) -> &[(Transaction, TransactionType)] { + &self.0 + } + + /// Consumes the package into its transactions, ready for the chain client. + pub(crate) fn into_transactions(self) -> Vec { + self.0.into_iter().map(|(tx, _)| tx).collect() + } +} pub(crate) struct TransactionBroadcaster where @@ -72,7 +90,7 @@ where ) -> Result { let wallet_opt = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade); if let Some(wallet) = wallet_opt { - for (tx, tx_type) in &package { + for (tx, tx_type) in package.transactions() { wallet.classify_broadcast(tx, tx_type).await?; } } @@ -85,9 +103,7 @@ where L::Target: LdkLogger, { fn broadcast_transactions(&self, txs: &[(&Transaction, TransactionType)]) { - let package: BroadcastPackage = - txs.iter().map(|(tx, tx_type)| ((*tx).clone(), tx_type.clone())).collect(); - self.queue_sender.try_send(package).unwrap_or_else(|e| { + self.queue_sender.try_send(BroadcastPackage::new(txs)).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); } From 5ed2d4331efc007405915ffda1c71c3a350dd60c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 12:04:48 -0500 Subject: [PATCH 16/22] Add bump_channel_funding_fee to fee-bump a pending splice A splice's funding transaction can be stuck at too low a fee rate with no way to raise it: on-chain RBF is rejected for funding transactions, and re-issuing splice_in / splice_out errors while a splice is already pending. Add bump_channel_funding_fee, which replaces the pending splice's funding transaction at a higher fee rate while preserving its amount and destination, and point the "a prior splice contribution is pending" errors at it. Replacing the transaction also requires signing a funding input the wallet already treats as spent by the splice being replaced, which it would otherwise skip after syncing. Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/ldk_node.udl | 2 ++ src/lib.rs | 66 +++++++++++++++++++++++++++++++++++++++++-- src/wallet/mod.rs | 11 ++++++-- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 851583c5a..5621f1751 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -124,6 +124,8 @@ interface Node { [Throws=NodeError] void splice_out([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, [ByRef]Address address, u64 splice_amount_sats); [Throws=NodeError] + void bump_channel_funding_fee([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); + [Throws=NodeError] void close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); [Throws=NodeError] void force_close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, string? reason); diff --git a/src/lib.rs b/src/lib.rs index b45064287..8bdd1daeb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1647,7 +1647,7 @@ impl Node { if funding_template.prior_contribution().is_some() { log_error!( self.logger, - "Failed to splice channel: a prior splice contribution is pending" + "Failed to splice channel: a prior splice contribution is pending; use bump_channel_funding_fee to bump its fee" ); return Err(Error::ChannelSplicingFailed); } @@ -1770,7 +1770,7 @@ impl Node { if funding_template.prior_contribution().is_some() { log_error!( self.logger, - "Failed to splice channel: a prior splice contribution is pending" + "Failed to splice channel: a prior splice contribution is pending; use bump_channel_funding_fee to bump its fee" ); return Err(Error::ChannelSplicingFailed); } @@ -1807,6 +1807,68 @@ impl Node { } } + /// Fee-bumps the pending splice on a channel by replacing its in-flight funding transaction + /// (RBF). The splice's amount and destination are preserved; only the fee rate is raised. + /// Errors if the channel has no pending splice to bump. + pub fn bump_channel_funding_fee( + &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, + ) -> Result<(), Error> { + let open_channels = + self.channel_manager.list_channels_with_counterparty(&counterparty_node_id); + if let Some(channel_details) = + open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) + { + let min_feerate = + self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); + let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2); + + let funding_template = self + .channel_manager + .splice_channel(&channel_details.channel_id, &counterparty_node_id) + .map_err(|e| { + log_error!(self.logger, "Failed to RBF channel: {:?}", e); + Error::ChannelSplicingFailed + })?; + + if funding_template.min_rbf_feerate().is_none() { + log_error!(self.logger, "Failed to RBF channel: no pending splice to replace"); + return Err(Error::ChannelSplicingFailed); + } + + let contribution = self + .runtime + .block_on(funding_template.rbf_prior_contribution( + None, + max_feerate, + Arc::clone(&self.wallet), + )) + .map_err(|e| { + log_error!(self.logger, "Failed to RBF channel: {}", e); + Error::ChannelSplicingFailed + })?; + + self.channel_manager + .funding_contributed( + &channel_details.channel_id, + &counterparty_node_id, + contribution, + None, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to RBF channel: {:?}", e); + Error::ChannelSplicingFailed + }) + } else { + log_error!( + self.logger, + "Channel not found for user_channel_id {} and counterparty {}", + user_channel_id, + counterparty_node_id + ); + Err(Error::ChannelSplicingFailed) + } + } + /// Manually sync the LDK and BDK wallets with the current chain state and update the fee rate /// cache. /// diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 2caa83123..05fe911e0 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -5,6 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +use std::collections::HashMap; use std::future::Future; use std::ops::Deref; use std::str::FromStr; @@ -15,7 +16,7 @@ use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; #[allow(deprecated)] use bdk_wallet::SignOptions; -use bdk_wallet::{Balance, KeychainKind, PersistedWallet, Update, WalletEvent}; +use bdk_wallet::{Balance, KeychainKind, LocalOutput, PersistedWallet, Update, WalletEvent}; use bitcoin::address::NetworkUnchecked; use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; use bitcoin::blockdata::locktime::absolute::LockTime; @@ -1149,9 +1150,13 @@ impl Wallet { let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).map_err(|e| { log_error!(self.logger, "Failed to construct PSBT: {}", e); })?; + // Use list_output rather than get_utxo to include outputs spent by unconfirmed + // transactions (e.g., a prior splice being replaced via RBF), which a synced wallet would + // otherwise no longer treat as an owned UTXO. + let mut wallet_outputs: HashMap = + locked_wallet.list_output().map(|output| (output.outpoint, output)).collect(); for (i, txin) in psbt.unsigned_tx.input.iter().enumerate() { - if let Some(utxo) = locked_wallet.get_utxo(txin.previous_output) { - debug_assert!(!utxo.is_spent); + if let Some(utxo) = wallet_outputs.remove(&txin.previous_output) { psbt.inputs[i] = locked_wallet.get_psbt_input(utxo, None, true).map_err(|e| { log_error!(self.logger, "Failed to construct PSBT input: {}", e); })?; From b6f32aab5599e332816813dc73895e2877b8669b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 24 Jun 2026 15:37:53 -0500 Subject: [PATCH 17/22] f - select the bump feerate from the current estimate and RBF minimum --- src/lib.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8bdd1daeb..345339120 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1820,7 +1820,6 @@ impl Node { { let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); - let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2); let funding_template = self .channel_manager @@ -1830,15 +1829,25 @@ impl Node { Error::ChannelSplicingFailed })?; - if funding_template.min_rbf_feerate().is_none() { + let Some(min_rbf_feerate) = funding_template.min_rbf_feerate() else { log_error!(self.logger, "Failed to RBF channel: no pending splice to replace"); return Err(Error::ChannelSplicingFailed); - } + }; + + let Some((target_feerate, max_feerate)) = + rbf_splice_feerates(min_feerate, min_rbf_feerate) + else { + log_error!( + self.logger, + "Failed to RBF channel: the RBF minimum feerate exceeds our maximum" + ); + return Err(Error::ChannelSplicingFailed); + }; let contribution = self .runtime .block_on(funding_template.rbf_prior_contribution( - None, + Some(target_feerate), max_feerate, Arc::clone(&self.wallet), )) @@ -2378,12 +2387,44 @@ pub(crate) fn new_channel_anchor_reserve_sats( }) } +/// The most we are willing to pay for a channel funding transaction: `1.5x` our funding feerate +/// estimate. Used as the `max_feerate` ceiling for splices and their RBF fee bumps. +fn max_funding_feerate(estimate: FeeRate) -> FeeRate { + FeeRate::from_sat_per_kwu(estimate.to_sat_per_kwu() * 3 / 2) +} + +/// Picks the `(target, max)` feerates for replacing a pending splice's in-flight funding +/// transaction via RBF, or `None` if the RBF can't be done within our fee ceiling. +/// +/// `max` is the most we are willing to pay (see [`max_funding_feerate`]), which tracks our current +/// estimate and so may have risen or fallen since the original splice; it is never inflated to meet +/// the RBF minimum. `target` is what we actually pay — our current estimate, or the template's RBF +/// minimum if that is higher (required to replace the transaction). If that minimum exceeds `max`, +/// we can't RBF. +fn rbf_splice_feerates(estimate: FeeRate, min_rbf_feerate: FeeRate) -> Option<(FeeRate, FeeRate)> { + let max = max_funding_feerate(estimate); + let target = estimate.max(min_rbf_feerate); + (target <= max).then_some((target, max)) +} + #[cfg(test)] mod tests { use lightning::util::ser::{Readable, Writeable}; use super::*; + #[test] + fn rbf_splice_feerates_target_and_max() { + let kwu = FeeRate::from_sat_per_kwu; + // Estimate below the RBF minimum but within our ceiling: pay the minimum to replace the + // transaction; the max stays 1.5x the estimate (never inflated) and already clears it. + assert_eq!(rbf_splice_feerates(kwu(253), kwu(278)), Some((kwu(278), kwu(253 * 3 / 2)))); + // Estimate risen above the RBF minimum: pay the higher estimate, not the stale minimum. + assert_eq!(rbf_splice_feerates(kwu(500), kwu(278)), Some((kwu(500), kwu(500 * 3 / 2)))); + // RBF minimum above our max (1.5x a fallen estimate): we can't RBF within our ceiling. + assert_eq!(rbf_splice_feerates(kwu(100), kwu(278)), None); + } + #[test] fn node_metrics_reads_legacy_rgs_snapshot_timestamp() { // Pre-#615, `NodeMetrics` persisted `latest_rgs_snapshot_timestamp` as an optional From 243c2be726fce4e02930a843f6e69d5c8a1e6ab0 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 14:30:42 -0500 Subject: [PATCH 18/22] Test funding-payment tracking through wallet sync Cover the wallet-event-driven funding payment lifecycle end to end: a channel-open funding payment reaches Succeeded from wallet sync alone, asserted before any ChannelReady event is drained to show payment status no longer depends on the channel-ready signal; and a splice fee-bumped via RBF stays a single on-chain payment that follows the winning candidate while keeping its interactive-funding classification across the replacement. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration_tests_rust.rs | 240 +++++++++++++++++++++++++++++++- 1 file changed, 238 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index da4adbeaa..2795957c2 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -27,14 +27,14 @@ use common::{ setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, TestChainSource, TestConfig, TestStoreType, TestSyncStore, }; -use electrsd::corepc_node::Node as BitcoinD; +use electrsd::corepc_node::{self, Node as BitcoinD}; use electrsd::ElectrsD; use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig}; use ldk_node::entropy::NodeEntropy; use ldk_node::liquidity::LSPS2ServiceConfig; use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, - UnifiedPaymentResult, + TransactionType, UnifiedPaymentResult, }; use ldk_node::{Builder, Event, NodeError}; use lightning::ln::channelmanager::PaymentId; @@ -1222,6 +1222,242 @@ async fn splice_channel() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn rbf_splice_channel() { + // Use a custom bitcoind config with a lower incrementalrelayfee so that the +25 sat/kwu + // (0.1 sat/vB) RBF feerate bump satisfies BIP125's absolute fee increase requirement. + let bitcoind_exe = std::env::var("BITCOIND_EXE") + .ok() + .or_else(|| corepc_node::downloaded_exe_path().ok()) + .expect( + "you need to provide an env var BITCOIND_EXE or specify a bitcoind version feature", + ); + let mut bitcoind_conf = corepc_node::Conf::default(); + bitcoind_conf.network = "regtest"; + bitcoind_conf.args.push("-rest"); + bitcoind_conf.args.push("-incrementalrelayfee=0.00000100"); + let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap(); + + let electrs_exe = std::env::var("ELECTRS_EXE") + .ok() + .or_else(electrsd::downloaded_exe_path) + .expect("you need to provide env var ELECTRS_EXE or specify an electrsd version feature"); + let mut electrsd_conf = electrsd::Conf::default(); + electrsd_conf.http_enabled = true; + electrsd_conf.network = "regtest"; + let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + // bump_channel_funding_fee should fail when there's no pending splice + assert_eq!( + node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id()), + Err(NodeError::ChannelSplicingFailed), + ); + + // Initiate a splice-in to create a pending splice + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + + let original_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + + // Sync so the original splice candidate is recorded as a canonical wallet transaction before + // the RBF below replaces it. The post-RBF sync then observes the original candidate being + // replaced (a `WalletEvent::TxReplaced`), which must not drop the payment's durable funding + // classification — the `tx_type` assertion below catches a regression deterministically. + wait_for_tx(&electrsd.client, original_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // splice_in should fail when there's a pending splice (RBF guard) + assert_eq!( + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000), + Err(NodeError::ChannelSplicingFailed), + ); + + // splice_out should fail when there's a pending splice (RBF guard) + let address = node_a.onchain_payment().new_address().unwrap(); + assert_eq!( + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 100_000), + Err(NodeError::ChannelSplicingFailed), + ); + + // bump_channel_funding_fee should succeed when there's a pending splice + node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id()).unwrap(); + + let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + + assert_ne!(original_txo, rbf_txo, "RBF should produce a different funding txo"); + + // Wait for the RBF transaction to replace the original in the mempool. + wait_for_tx(&electrsd.client, rbf_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // After RBF but before confirmation, node_b (the initiator) should have a single on-chain + // payment covering both candidates: id anchored to the first broadcast, `kind.txid` pointing + // at the latest (RBF) candidate, and the durable interactive-funding `tx_type` preserved across + // the replacement. + { + let payment_id = PaymentId(original_txo.txid.to_byte_array()); + let payment = node_b.payment(&payment_id).expect("splice payment exists"); + match payment.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } => { + assert_eq!(txid, rbf_txo.txid); + }, + ref other => { + panic!("expected Onchain Unconfirmed interactive-funding, got {:?}", other) + }, + } + assert_eq!(payment.status, PaymentStatus::Pending); + // Only one Onchain Pending payment for this splice attempt (not one per candidate). + let splice_payments = node_b.list_payments_with_filter(|p| { + p.direction == PaymentDirection::Outbound + && matches!(p.kind, PaymentKind::Onchain { .. }) + && p.status == PaymentStatus::Pending + }); + assert_eq!( + splice_payments.len(), + 1, + "expected exactly one pending Onchain payment for the splice, got {}: {:#?}", + splice_payments.len(), + splice_payments, + ); + } + + // Mine blocks and confirm the RBF splice + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // Verify the RBF transaction is the one that locked, not the original + match node_a.next_event_async().await { + Event::ChannelReady { funding_txo, counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, Some(node_b.node_id())); + assert_eq!(funding_txo, Some(rbf_txo)); + node_a.event_handled().unwrap(); + }, + ref e => panic!("node_a got unexpected event: {:?}", e), + } + match node_b.next_event_async().await { + Event::ChannelReady { funding_txo, counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, Some(node_a.node_id())); + assert_eq!(funding_txo, Some(rbf_txo)); + node_b.event_handled().unwrap(); + }, + ref e => panic!("node_b got unexpected event: {:?}", e), + } + + // The splice payment graduates to `Succeeded` purely from wallet sync reaching + // `ANTI_REORG_DELAY` confirmations — the `ChannelReady` events above are a separate + // channel-lifecycle signal, not what drives payment status. Its `kind.txid` reflects the + // winning RBF candidate, and `fee_paid_msat` carries this node's `FundingContribution` fee. + { + let payment_id = PaymentId(original_txo.txid.to_byte_array()); + let payment = node_b.payment(&payment_id).expect("splice payment graduated"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + match payment.kind { + PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } => { + assert_eq!(txid, rbf_txo.txid); + }, + ref other => panic!("expected Onchain Confirmed, got {:?}", other), + } + assert!( + payment.fee_paid_msat.is_some(), + "splice payment should carry a fee from its FundingContribution", + ); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn funding_payment_graduates_without_channel_ready() { + 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 address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // node_a funds the channel, so it holds the funding payment. `open_channel` drains only the + // `ChannelPending` events, leaving any `ChannelReady` queued and undrained. + let funding_txo = open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + + // Mine past `ANTI_REORG_DELAY` and sync only node_a. node_b stays behind, so it cannot yet + // send `channel_ready` and node_a therefore cannot have emitted a `ChannelReady` event — any + // graduation below must come from wallet sync alone. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + + // The funding payment is `Succeeded` purely from wallet sync reaching `ANTI_REORG_DELAY` + // confirmations, asserted before draining any LDK event — so graduation is not driven by the + // Lightning `ChannelReady` signal. + let payment_id = PaymentId(funding_txo.txid.to_byte_array()); + let payment = node_a.payment(&payment_id).expect("funding payment exists"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + match payment.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::Funding { .. }), + } => assert_eq!(txid, funding_txo.txid), + ref other => panic!("expected Onchain Confirmed funding payment, got {:?}", other), + } + + // Let node_b catch up so the channel completes; the `ChannelReady` events follow the + // already-`Succeeded` payment rather than driving it. + 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()); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 95b3720e71821989cfeb04205ca7902ec7d2cbe2 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 16:06:24 -0500 Subject: [PATCH 19/22] Report the confirmed splice candidate's fee, not the last broadcast's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A splice funding payment can be fee-bumped via RBF, producing several candidate transactions with increasing fees. The payment recorded the last-broadcast candidate's amount and fee and kept them on confirmation, but the candidate that actually confirms need not be the last one broadcast — so an earlier, lower-fee candidate confirming left the payment over-reporting its fee. Record each candidate's amount and fee, keyed by txid, so that on confirmation the payment reflects the candidate that actually confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/payment/store.rs | 92 ++++++++++++++++++++++++++++++++- src/wallet/mod.rs | 40 +++++++++++--- tests/integration_tests_rust.rs | 72 +++++++++++++++++++++----- 3 files changed, 184 insertions(+), 20 deletions(-) diff --git a/src/payment/store.rs b/src/payment/store.rs index dc4fe6dbf..73cb61443 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -759,6 +759,29 @@ impl StorableObjectUpdate for PaymentDetailsUpdate { } } +/// One candidate transaction in an interactive-funding (splice) RBF history, holding this node's +/// share of the funding amount and fee for that candidate. Both are `None` for a candidate this +/// node did not contribute to — e.g. a counterparty-initiated round before our `splice_in` joined +/// it via RBF. Recorded per pending payment so that, on confirmation, the payment reports the +/// figures of the candidate that actually confirmed, which need not be the last one broadcast. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FundingTxCandidate { + /// The candidate's broadcast transaction id. + pub txid: Txid, + /// This node's share of the funding amount for this candidate, in millisatoshis, or `None` if + /// this node did not contribute to it. + pub amount_msat: Option, + /// This node's share of the on-chain fee for this candidate, in millisatoshis, or `None` if + /// this node did not contribute to it. + pub fee_paid_msat: Option, +} + +impl_writeable_tlv_based!(FundingTxCandidate, { + (0, txid, required), + (2, amount_msat, option), + (4, fee_paid_msat, option), +}); + /// Represents a pending payment #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingPaymentDetails { @@ -768,11 +791,23 @@ pub(crate) struct PendingPaymentDetails { pub txid: Txid, /// Transaction IDs that have replaced or conflict with this payment. pub conflicting_txids: Vec, + /// For interactive funding (splices), this node's per-candidate funding figures across the + /// RBF history, keyed by each candidate's txid. Empty for non-funding payments and for + /// records written before per-candidate tracking existed. + pub candidates: Vec, } impl PendingPaymentDetails { - pub(crate) fn new(payment_id: PaymentId, txid: Txid, conflicting_txids: Vec) -> Self { - Self { payment_id, txid, conflicting_txids } + pub(crate) fn new( + payment_id: PaymentId, txid: Txid, conflicting_txids: Vec, + candidates: Vec, + ) -> Self { + Self { payment_id, txid, conflicting_txids, candidates } + } + + /// Returns this node's recorded funding figures for the candidate with the given txid, if any. + pub(crate) fn candidate(&self, txid: Txid) -> Option<&FundingTxCandidate> { + self.candidates.iter().find(|candidate| candidate.txid == txid) } } @@ -780,6 +815,7 @@ impl_writeable_tlv_based!(PendingPaymentDetails, { (0, payment_id, required), (2, txid, required), (4, conflicting_txids, optional_vec), + (6, candidates, optional_vec), }); #[derive(Clone, Debug, PartialEq, Eq)] @@ -787,6 +823,7 @@ pub(crate) struct PendingPaymentDetailsUpdate { pub payment_id: PaymentId, pub txid: Txid, pub conflicting_txids: Vec, + pub candidates: Vec, } impl StorableObject for PendingPaymentDetails { @@ -816,6 +853,13 @@ impl StorableObject for PendingPaymentDetails { } } + // Each classify passes the complete candidate history, so a non-empty update replaces the + // stored list. An empty update (e.g. a non-funding payment) leaves it untouched. + if !update.candidates.is_empty() && self.candidates != update.candidates { + self.candidates = update.candidates; + updated = true; + } + updated } @@ -836,6 +880,7 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { payment_id: value.id(), txid: value.txid, conflicting_txids: value.conflicting_txids.clone(), + candidates: value.candidates.clone(), } } } @@ -1065,4 +1110,47 @@ mod tests { assert_eq!(reencoded[0], 2); assert_eq!(decoded, PaymentKind::read(&mut &*reencoded).unwrap()); } + + #[test] + fn pending_payment_candidate_lookup() { + use bitcoin::hashes::Hash; + + let payment_id = PaymentId([1u8; 32]); + let first_txid = Txid::from_byte_array([2u8; 32]); + let rbf_txid = Txid::from_byte_array([3u8; 32]); + + // A leading counterparty-initiated round we didn't contribute to (no figures), then our own + // original and RBF candidates. + let counterparty_txid = Txid::from_byte_array([4u8; 32]); + let candidates = vec![ + FundingTxCandidate { txid: counterparty_txid, amount_msat: None, fee_paid_msat: None }, + FundingTxCandidate { + txid: first_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(1_000), + }, + FundingTxCandidate { + txid: rbf_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(5_000), + }, + ]; + let pending = PendingPaymentDetails::new( + payment_id, + rbf_txid, + vec![first_txid, counterparty_txid], + candidates, + ); + + // Each candidate resolves to its own figures, so a non-last candidate that confirms reports + // its own (lower) fee rather than the last-broadcast candidate's. + assert_eq!(pending.candidate(first_txid).and_then(|c| c.fee_paid_msat), Some(1_000)); + assert_eq!(pending.candidate(rbf_txid).and_then(|c| c.fee_paid_msat), Some(5_000)); + // A candidate we didn't contribute to carries no figures, so the payment reports `None` + // rather than another candidate's stale figures. + let counterparty = pending.candidate(counterparty_txid).expect("candidate is recorded"); + assert_eq!(counterparty.amount_msat, None); + assert_eq!(counterparty.fee_paid_msat, None); + assert_eq!(pending.candidate(Txid::from_byte_array([9u8; 32])), None); + } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 05fe911e0..a1ee92e74 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -56,7 +56,7 @@ use persist::KVStoreWalletPersister; use crate::config::Config; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; -use crate::payment::store::ConfirmationStatus; +use crate::payment::store::{ConfirmationStatus, FundingTxCandidate}; use crate::payment::{ PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, PendingPaymentDetails, TransactionType, @@ -1265,7 +1265,7 @@ impl Wallet { direction, PaymentStatus::Pending, ); - self.persist_funding_payment(details, txid).await?; + self.persist_funding_payment(details, txid, Vec::new()).await?; log_debug!( self.logger, "Recorded channel-funding broadcast {} for channel {}", @@ -1328,6 +1328,23 @@ impl Wallet { // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable // across RBF replacements. let payment_id = PaymentId(first.txid.to_byte_array()); + + // Record every candidate's figures (`None` for any round we didn't contribute to, e.g. a + // counterparty-initiated splice our `splice_in` later joined via RBF) so the confirmed + // candidate's amount/fee can be applied on confirmation, even if it isn't the last one + // broadcast or one we contributed to. + let candidate_records: Vec = candidates + .iter() + .map(|candidate| { + let aggregate = aggregate_local_stakes(candidate); + FundingTxCandidate { + txid: candidate.txid, + amount_msat: aggregate.amount_msat, + fee_paid_msat: aggregate.fee_paid_msat, + } + }) + .collect(); + let details = PaymentDetails::new( payment_id, PaymentKind::Onchain { @@ -1340,7 +1357,7 @@ impl Wallet { direction, PaymentStatus::Pending, ); - self.persist_funding_payment(details, txid).await?; + self.persist_funding_payment(details, txid, candidate_records).await?; log_debug!( self.logger, "Recorded interactive-funding broadcast {} ({} candidates, {} channels)", @@ -1354,11 +1371,11 @@ impl Wallet { /// Writes a freshly-classified funding payment to the authoritative payment store and adds a /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. async fn persist_funding_payment( - &self, details: PaymentDetails, txid: Txid, + &self, details: PaymentDetails, txid: Txid, candidates: Vec, ) -> Result<(), Error> { let payment_id = details.id; self.payment_store.insert_or_update(details).await?; - let pending = PendingPaymentDetails::new(payment_id, txid, Vec::new()); + let pending = PendingPaymentDetails::new(payment_id, txid, Vec::new(), candidates); self.pending_payment_store.insert_or_update(pending).await?; Ok(()) } @@ -1445,7 +1462,7 @@ impl Wallet { fn create_pending_payment_from_tx( &self, payment_id: PaymentId, txid: Txid, conflicting_txids: Vec, ) -> PendingPaymentDetails { - PendingPaymentDetails::new(payment_id, txid, conflicting_txids) + PendingPaymentDetails::new(payment_id, txid, conflicting_txids, Vec::new()) } fn find_payment_by_txid(&self, target_txid: Txid) -> Option { @@ -1487,6 +1504,17 @@ impl Wallet { } => tx_type.clone(), _ => return Ok(false), }; + // Report the figures of the candidate that actually confirmed, which need not be the last + // one broadcast (an earlier, lower-fee candidate may win) and may carry no figures at all + // (`None`) for a round we didn't contribute to. (`direction` is invariant across a splice's + // candidates and cannot be changed through the store anyway.) + if let Some(pending) = self.pending_payment_store.get(&payment_id) { + if let Some(candidate) = pending.candidate(event_txid) { + payment.amount_msat = candidate.amount_msat; + payment.fee_paid_msat = candidate.fee_paid_msat; + } + } + payment.kind = PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; self.runtime.block_on(self.payment_store.insert_or_update(payment))?; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 2795957c2..50ca7363f 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1224,6 +1224,15 @@ async fn splice_channel() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn rbf_splice_channel() { + run_rbf_splice_channel_test(false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn rbf_splice_channel_original_candidate_confirms() { + run_rbf_splice_channel_test(true).await; +} + +async fn run_rbf_splice_channel_test(confirm_original: bool) { // Use a custom bitcoind config with a lower incrementalrelayfee so that the +25 sat/kwu // (0.1 sat/vB) RBF feerate bump satisfies BIP125's absolute fee increase requirement. let bitcoind_exe = std::env::var("BITCOIND_EXE") @@ -1294,6 +1303,20 @@ async fn rbf_splice_channel() { node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + // For `confirm_original`, capture the original candidate's fee and raw transaction now, before + // the RBF replaces it, so it can be force-confirmed (instead of the RBF) further below. + let original_candidate: Option<(Option, String)> = if confirm_original { + let payment_id = PaymentId(original_txo.txid.to_byte_array()); + let fee = node_b.payment(&payment_id).expect("splice payment exists").fee_paid_msat; + let raw_tx: String = bitcoind + .client + .call("getrawtransaction", &[json!(original_txo.txid.to_string())]) + .expect("failed to fetch the original splice transaction"); + Some((fee, raw_tx)) + } else { + None + }; + // splice_in should fail when there's a pending splice (RBF guard) assert_eq!( node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000), @@ -1324,7 +1347,7 @@ async fn rbf_splice_channel() { // payment covering both candidates: id anchored to the first broadcast, `kind.txid` pointing // at the latest (RBF) candidate, and the durable interactive-funding `tx_type` preserved across // the replacement. - { + let rbf_candidate_fee = { let payment_id = PaymentId(original_txo.txid.to_byte_array()); let payment = node_b.payment(&payment_id).expect("splice payment exists"); match payment.kind { @@ -1353,19 +1376,35 @@ async fn rbf_splice_channel() { splice_payments.len(), splice_payments, ); - } - // Mine blocks and confirm the RBF splice - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + // The fee recorded for the latest (RBF) candidate, which is the one that confirms below. + assert!(payment.fee_paid_msat.is_some()); + payment.fee_paid_msat + }; + + // Confirm the splice. Normally the latest (RBF) candidate wins through the mempool; for + // `confirm_original` we instead mine the original candidate directly into a block so an + // earlier, lower-fee candidate is the one that confirms. + let winning_txo = if confirm_original { original_txo } else { rbf_txo }; + if let Some((_, ref original_tx_hex)) = original_candidate { + let address = bitcoind.client.new_address().expect("failed to get new address"); + let _: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(address.to_string()), json!([original_tx_hex])]) + .expect("failed to mine the original splice candidate"); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + } else { + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + } node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); - // Verify the RBF transaction is the one that locked, not the original + // Verify the candidate that locked is the one that confirmed, not necessarily the last broadcast. match node_a.next_event_async().await { Event::ChannelReady { funding_txo, counterparty_node_id, .. } => { assert_eq!(counterparty_node_id, Some(node_b.node_id())); - assert_eq!(funding_txo, Some(rbf_txo)); + assert_eq!(funding_txo, Some(winning_txo)); node_a.event_handled().unwrap(); }, ref e => panic!("node_a got unexpected event: {:?}", e), @@ -1373,7 +1412,7 @@ async fn rbf_splice_channel() { match node_b.next_event_async().await { Event::ChannelReady { funding_txo, counterparty_node_id, .. } => { assert_eq!(counterparty_node_id, Some(node_a.node_id())); - assert_eq!(funding_txo, Some(rbf_txo)); + assert_eq!(funding_txo, Some(winning_txo)); node_b.event_handled().unwrap(); }, ref e => panic!("node_b got unexpected event: {:?}", e), @@ -1389,14 +1428,23 @@ async fn rbf_splice_channel() { assert_eq!(payment.status, PaymentStatus::Succeeded); match payment.kind { PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } => { - assert_eq!(txid, rbf_txo.txid); + assert_eq!(txid, winning_txo.txid); }, ref other => panic!("expected Onchain Confirmed, got {:?}", other), } - assert!( - payment.fee_paid_msat.is_some(), - "splice payment should carry a fee from its FundingContribution", - ); + // Graduation stamps the economics of the candidate that actually confirmed. For + // `confirm_original` that is the earlier, lower-fee candidate, whose fee differs from the + // last-broadcast (RBF) candidate's — so this would fail if the payment kept the + // last-broadcast figures instead of the confirmed candidate's. + let expected_fee = match original_candidate { + Some((original_fee, _)) => { + assert_ne!(original_fee, rbf_candidate_fee); + original_fee + }, + None => rbf_candidate_fee, + }; + assert!(expected_fee.is_some()); + assert_eq!(payment.fee_paid_msat, expected_fee); } node_a.stop().unwrap(); From 5b1571d0f3c81f0ee96b48fd2e584f18434fb341 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 17:39:25 -0500 Subject: [PATCH 20/22] Cover splice-out classification and funding-payment reorg splice_channel only checked the splice-out fee; also assert it is recorded as a confirmed interactive-funding payment. Add a test that a confirmed splice payment returns to unconfirmed when its block is reorged out, exercising the unconfirm path for funding payments. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration_tests_rust.rs | 86 +++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 50ca7363f..14808ed8c 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1211,6 +1211,18 @@ async fn splice_channel() { let payment = payments.into_iter().find(|p| p.id == PaymentId(txo.txid.to_byte_array())).unwrap(); assert_eq!(payment.fee_paid_msat, Some(expected_splice_out_fee_sat * 1_000)); + // The splice-out graduated to a confirmed interactive-funding payment. Its `direction` is left + // unasserted on purpose: the destination is our own address, so it is a self-transfer (channel + // balance -> on-chain wallet) whose inbound/outbound sense is ambiguous. + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } + )); assert_eq!( node_a.list_balances().total_onchain_balance_sats, @@ -1506,6 +1518,80 @@ async fn funding_payment_graduates_without_channel_ready() { node_b.stop().unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_payment_reorged_to_unconfirmed() { + 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 address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + // node_b splices in, recording a funding payment it contributed to. + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + let splice_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, splice_txo.txid).await; + + // Confirm the splice with a single block — confirmed, but short of `ANTI_REORG_DELAY`, so the + // payment is `Confirmed`/`Pending` rather than graduated. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_b.sync_wallets().unwrap(); + + let payment_id = PaymentId(splice_txo.txid.to_byte_array()); + let payment = node_b.payment(&payment_id).expect("splice payment exists"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + + // Reorg the splice transaction out by replacing its block with a longer, transaction-free chain. + let original_height = + bitcoind.client.get_blockchain_info().expect("failed to get blockchain info").blocks; + invalidate_blocks(&bitcoind.client, 1); + let replacement_address = bitcoind.client.new_address().expect("failed to get new address"); + for _ in 0..2 { + let _res: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(replacement_address.to_string()), json!([])]) + .expect("failed to generate empty block"); + } + wait_for_block(&electrsd.client, original_height as usize + 1).await; + node_b.sync_wallets().unwrap(); + + // The funding payment returns to `Unconfirmed` and stays `Pending`, exercising the + // `TxUnconfirmed` arm for a funding payment. + let payment = node_b.payment(&payment_id).expect("splice payment still exists"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } + )); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 8d0b58ba51c2ff45d65ca475b1cfb6a39bd149ce Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 23:29:03 -0500 Subject: [PATCH 21/22] Honor the funding template's RBF minimum feerate when splicing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contributing to an already-pending splice — e.g. adding our funds to a counterparty-initiated splice via splice_in or splice_out — replaces the in-flight funding transaction, so the funding template requires at least the RBF minimum feerate. We passed our plain ChannelFunding feerate estimate, which can sit below that minimum (it does at the regtest floor), so the contribution was rejected with FeeRateBelowRbfMinimum. Raise the contribution feerate to the template's RBF minimum when one applies, capped by our max, so it can replace the pending splice. A node can therefore now contribute to a counterparty's pending splice; the rbf_splice_channel check that expected splice_out to fail while a splice was pending relied on this very bug and is dropped. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib.rs | 24 ++++++++++++-- tests/integration_tests_rust.rs | 59 ++++++++++++++++++++++++++++----- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 345339120..572b7eb05 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1652,11 +1652,21 @@ impl Node { return Err(Error::ChannelSplicingFailed); } + // When contributing to a pending splice, the funding template requires at least the RBF + // minimum feerate to replace the in-flight transaction. Use it in place of our funding + // feerate estimate when it's higher, as long as it stays within our max. + let feerate = match funding_template.min_rbf_feerate() { + Some(min_rbf_feerate) if min_rbf_feerate <= max_feerate => { + min_feerate.max(min_rbf_feerate) + }, + _ => min_feerate, + }; + let contribution = self .runtime .block_on(funding_template.splice_in( Amount::from_sat(splice_amount_sats), - min_feerate, + feerate, max_feerate, Arc::clone(&self.wallet), )) @@ -1775,12 +1785,22 @@ impl Node { return Err(Error::ChannelSplicingFailed); } + // When contributing to a pending splice, the funding template requires at least the RBF + // minimum feerate to replace the in-flight transaction. Use it in place of our funding + // feerate estimate when it's higher, as long as it stays within our max. + let feerate = match funding_template.min_rbf_feerate() { + Some(min_rbf_feerate) if min_rbf_feerate <= max_feerate => { + min_feerate.max(min_rbf_feerate) + }, + _ => min_feerate, + }; + let outputs = vec![bitcoin::TxOut { value: Amount::from_sat(splice_amount_sats), script_pubkey: address.script_pubkey(), }]; let contribution = - funding_template.splice_out(outputs, min_feerate, max_feerate).map_err(|e| { + funding_template.splice_out(outputs, feerate, max_feerate).map_err(|e| { log_error!(self.logger, "Failed to splice channel: {}", e); Error::ChannelSplicingFailed })?; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 14808ed8c..bfc0ca4c4 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1329,19 +1329,13 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { None }; - // splice_in should fail when there's a pending splice (RBF guard) + // Re-splicing the pending splice we already contributed to is rejected; the RBF guard points at + // bump_channel_funding_fee instead. assert_eq!( node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000), Err(NodeError::ChannelSplicingFailed), ); - // splice_out should fail when there's a pending splice (RBF guard) - let address = node_a.onchain_payment().new_address().unwrap(); - assert_eq!( - node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 100_000), - Err(NodeError::ChannelSplicingFailed), - ); - // bump_channel_funding_fee should succeed when there's a pending splice node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id()).unwrap(); @@ -1592,6 +1586,55 @@ async fn splice_payment_reorged_to_unconfirmed() { node_b.stop().unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_in_rbf_joins_counterparty_splice() { + 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 address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + // node_b (which didn't fund the channel open, so holds the on-chain balance) initiates a + // splice-in; node_a does not contribute to this first candidate. + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + let counterparty_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, counterparty_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // node_a contributes to the pending splice via RBF. Before honoring the funding template's RBF + // minimum feerate, this was rejected with FeeRateBelowRbfMinimum because node_a's funding + // feerate estimate sat below the minimum required to replace the in-flight transaction. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 100_000).unwrap(); + let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + assert_ne!(counterparty_txo, rbf_txo, "node_a's RBF should produce a different funding txo"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From f2990d57329e23016708af857b985f7fcba82b03 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 24 Jun 2026 15:40:14 -0500 Subject: [PATCH 22/22] Deduplicate the funding feerate ceiling in splice_in and splice_out The 1.5x-of-estimate funding feerate ceiling was open-coded identically in splice_in and splice_out. Route both through the max_funding_feerate helper so the ceiling lives in one place. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 572b7eb05..aedcab1b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1578,7 +1578,7 @@ impl Node { { let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); - let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2); + let max_feerate = max_funding_feerate(min_feerate); let splice_amount_sats = match splice_amount_sats { FundingAmount::Exact { amount_sats } => amount_sats, @@ -1767,7 +1767,7 @@ impl Node { let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); - let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2); + let max_feerate = max_funding_feerate(min_feerate); let funding_template = self .channel_manager