From 93797a20b474fa8b9ff06af340710d4b02872b87 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 11:24:58 -0500 Subject: [PATCH 01/10] 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/pending_payment_store.rs | 2 +- src/payment/store.rs | 230 ++++++++++++++++++++++++++- src/wallet/mod.rs | 3 +- tests/integration_tests_rust.rs | 4 +- 5 files changed, 234 insertions(+), 9 deletions(-) diff --git a/src/payment/mod.rs b/src/payment/mod.rs index ee53ed7f8..bdc2fe96a 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -23,7 +23,7 @@ pub use onchain::OnchainPayment; pub(crate) use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; 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/pending_payment_store.rs b/src/payment/pending_payment_store.rs index a7dd916b0..311fdbf34 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -108,7 +108,7 @@ mod tests { fn pending_onchain_payment(payment_id: PaymentId, txid: Txid) -> PaymentDetails { PaymentDetails::new( payment_id, - PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed }, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type: None }, Some(1_000), Some(100), PaymentDirection::Outbound, diff --git a/src/payment/store.rs b/src/payment/store.rs index f80ab6f8a..160890895 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, } } } @@ -697,6 +870,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 f3429afbf..4b83c64e5 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -310,6 +310,7 @@ impl Wallet { PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, + .. } if payment.details.direction == PaymentDirection::Outbound => { unconfirmed_outbound_txids.push(txid); }, @@ -1171,7 +1172,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 521cb74ca..404b1a1db 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -610,7 +610,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 { .. })); }, @@ -619,7 +619,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 79fd087e5679360fa074125a1d5ede304c69587a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 11:59:40 -0500 Subject: [PATCH 02/10] 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 | 69 +++++++++- src/wallet/mod.rs | 302 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 384 insertions(+), 14 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index d142f51af..7a26ce24f 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 5a326be97..8a8115e4f 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -13,7 +13,7 @@ use std::collections::{HashMap, HashSet}; 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_transactions(); 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..5722a3ebe 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -6,21 +6,52 @@ // 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. 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 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 +61,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.transactions() { + wallet.classify_broadcast(tx, tx_type).await?; + } + } + Ok(package) + } } impl BroadcasterInterface for TransactionBroadcaster @@ -45,8 +103,7 @@ where L::Target: LdkLogger, { fn broadcast_transactions(&self, txs: &[(&Transaction, TransactionType)]) { - let package = txs.iter().map(|(t, _)| (*t).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); }); } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 4b83c64e5..28a4a3d80 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -27,11 +27,12 @@ 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, 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}; @@ -39,6 +40,7 @@ use lightning::ln::channelmanager::PaymentId; 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 +58,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 +260,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, @@ -351,6 +358,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, @@ -401,6 +416,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, @@ -1155,6 +1179,181 @@ 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).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, + "Not recording interactive-funding broadcast {} as a payment: no local contribution", + txid, + ); + return Ok(()); + }, + }; + let fee_paid_msat = aggregate.fee_paid_msat; + let direction = aggregate.direction; + + // 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) { + log_trace!( + self.logger, + "Not recording interactive-funding broadcast {} as a payment: 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).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) -> Result<(), Error> { + self.payment_store.insert_or_update(details.clone()).await?; + let pending = PendingPaymentDetails::new(details, 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, @@ -1231,6 +1430,43 @@ 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: the wallet's + /// `sent`/`received` don't capture our contribution to a shared funding output. Returns `true` + /// when it handled the payment, so the caller skips the default on-chain path. Graduation to + /// `Succeeded` is left to `ChainTipChanged` after `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.clone()))?; + // Mirror the refreshed confirmation status onto the pending entry: `ChainTipChanged` + // graduates by reading the pending entry's details, so it must see the new status. This is + // the same dual-write the default `TxConfirmed` path performs; an empty conflicting-txids + // list leaves any stored conflicts intact (the update treats absent as "unchanged"). + if payment.status == PaymentStatus::Pending { + let pending = self.create_pending_payment_from_tx(payment, Vec::new()); + self.runtime.block_on(self.pending_payment_store.insert_or_update(pending))?; + } + Ok(true) + } + #[allow(deprecated)] pub(crate) fn bump_fee_rbf( &self, payment_id: PaymentId, fee_rate: Option, cur_anchor_reserve_sats: u64, @@ -1240,6 +1476,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 { .. } => { @@ -1474,6 +1728,48 @@ impl Wallet { } } +struct LocalStakeAggregate { + amount_msat: Option, + fee_paid_msat: Option, + direction: PaymentDirection, +} + +/// 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 net_stake = SignedAmount::ZERO; + let mut fee = Amount::ZERO; + let mut have_contribution = false; + for channel in &candidate.channels { + if let Some(contribution) = channel.contribution.as_ref() { + have_contribution = true; + 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 { + return LocalStakeAggregate { + amount_msat: None, + fee_paid_msat: None, + direction: PaymentDirection::Outbound, + }; + } + // 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 + }; + LocalStakeAggregate { + amount_msat: Some(net_stake.unsigned_abs().to_sat() * 1000), + fee_paid_msat: Some(fee.to_sat() * 1000), + direction, + } +} + impl Listen for Wallet { fn filtered_block_connected( &self, _header: &bitcoin::block::Header, From c8bc878143ee47a9c8573b35fa9241fd0f281844 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 26 Jun 2026 10:06:06 -0500 Subject: [PATCH 03/10] Derive on-chain payment fields in a single place `create_payment_from_tx` duplicated the amount/fee/direction derivation that `onchain_payment_fields` already performs. Share it via a helper that operates on the already-locked wallet, so both paths agree by construction. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wallet/mod.rs | 39 +++++++++++---------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 28a4a3d80..f8208fb0b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1330,6 +1330,14 @@ impl Wallet { &self, tx: &Transaction, ) -> (Option, Option, PaymentDirection) { let locked_wallet = self.inner.lock().expect("lock"); + self.onchain_payment_fields_locked(&locked_wallet, tx) + } + + /// [`Self::onchain_payment_fields`] against an already-locked wallet, so callers that hold the + /// lock (e.g. [`Self::create_payment_from_tx`]) can reuse the derivation without re-locking. + fn onchain_payment_fields_locked( + &self, locked_wallet: &PersistedWallet, tx: &Transaction, + ) -> (Option, Option, PaymentDirection) { 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(); @@ -1373,35 +1381,10 @@ impl Wallet { 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); - let fee_sat = fee.to_sat(); + let (amount_msat, fee_paid_msat, direction) = + self.onchain_payment_fields_locked(locked_wallet, tx); - 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, - ), - ) - }; - - PaymentDetails::new( - payment_id, - kind, - amount_msat, - Some(fee_sat * 1000), - direction, - payment_status, - ) + PaymentDetails::new(payment_id, kind, amount_msat, fee_paid_msat, direction, payment_status) } fn create_pending_payment_from_tx( From c34473ddb48505a235781888114d9d38f31262f9 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 12:04:48 -0500 Subject: [PATCH 04/10] 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 | 107 +++++++++++++++++++++++++++++++++++++++++- src/wallet/mod.rs | 11 +++-- 3 files changed, 115 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 34fa7f54d..a3410db1f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1653,7 +1653,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); } @@ -1776,7 +1776,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); } @@ -1813,6 +1813,77 @@ 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 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 + })?; + + 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( + Some(target_feerate), + 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. /// @@ -2322,12 +2393,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 diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index f8208fb0b..be5c7e503 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; @@ -1119,9 +1120,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 9f98db5983ef20fea930afadf374190009ecb320 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 14:30:42 -0500 Subject: [PATCH 05/10] 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 404b1a1db..bd0068458 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; @@ -1317,6 +1317,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 e541265e5e63733adcd3537630d3eba363f22eba Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 16:06:24 -0500 Subject: [PATCH 06/10] 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/mod.rs | 1 + src/payment/pending_payment_store.rs | 112 +++++++++++++++++++++++++-- src/wallet/mod.rs | 44 +++++++++-- tests/integration_tests_rust.rs | 72 ++++++++++++++--- 4 files changed, 205 insertions(+), 24 deletions(-) diff --git a/src/payment/mod.rs b/src/payment/mod.rs index bdc2fe96a..2d3acf90e 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -20,6 +20,7 @@ pub use bolt11::Bolt11Payment; pub(crate) use bolt11::PaymentMetadata; pub use bolt12::Bolt12Payment; pub use onchain::OnchainPayment; +pub(crate) use pending_payment_store::FundingTxCandidate; pub(crate) use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; pub use store::{ diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 311fdbf34..c8b792ccb 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -13,6 +13,29 @@ use crate::data_store::{StorableObject, StorableObjectUpdate}; use crate::payment::store::PaymentDetailsUpdate; use crate::payment::{PaymentDetails, PaymentKind}; +/// 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 struct PendingPaymentDetails { @@ -20,17 +43,29 @@ pub struct PendingPaymentDetails { pub details: PaymentDetails, /// 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(crate) candidates: Vec, } impl PendingPaymentDetails { - pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec) -> Self { - Self { details, conflicting_txids } + pub(crate) fn new( + details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, + ) -> Self { + Self { details, 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) } } impl_writeable_tlv_based!(PendingPaymentDetails, { (0, details, required), (2, conflicting_txids, optional_vec), + (4, candidates, optional_vec), }); #[derive(Clone, Debug, PartialEq, Eq)] @@ -38,6 +73,7 @@ pub(crate) struct PendingPaymentDetailsUpdate { pub id: PaymentId, pub payment_update: Option, pub conflicting_txids: Option>, + pub candidates: Vec, } impl StorableObject for PendingPaymentDetails { @@ -69,6 +105,13 @@ impl StorableObject for PendingPaymentDetails { updated |= self.conflicting_txids.len() != conflicts_len; } + // 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 } @@ -90,16 +133,73 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { } else { Some(value.conflicting_txids.clone()) }; - Self { id: value.id(), payment_update: Some(value.details.to_update()), conflicting_txids } + Self { + id: value.id(), + payment_update: Some(value.details.to_update()), + conflicting_txids, + candidates: value.candidates.clone(), + } } } #[cfg(test)] mod tests { + use super::*; + use crate::payment::store::ConfirmationStatus; + use crate::payment::{PaymentDirection, PaymentKind, PaymentStatus}; use bitcoin::hashes::Hash; - use super::*; - use crate::payment::{ConfirmationStatus, PaymentDirection, PaymentKind, PaymentStatus}; + #[test] + fn pending_payment_candidate_lookup() { + 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), + }, + ]; + + // The stored details only need to be a valid funding payment; `candidate` resolves figures + // purely from the recorded candidate list. + let details = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid: rbf_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(1_000_000), + Some(5_000), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + let pending = + PendingPaymentDetails::new(details, 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); + } fn test_txid(byte: u8) -> Txid { Txid::from_byte_array([byte; 32]) @@ -125,10 +225,12 @@ mod tests { let mut pending_payment = PendingPaymentDetails::new( pending_onchain_payment(payment_id, replacement_txid), vec![original_txid], + Vec::new(), ); let update = PendingPaymentDetails::new( pending_onchain_payment(payment_id, original_txid), Vec::new(), + Vec::new(), ) .to_update(); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index be5c7e503..ad4f8d45e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -58,8 +58,8 @@ 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::{ - PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, PendingPaymentDetails, - TransactionType, + FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, + PendingPaymentDetails, TransactionType, }; use crate::runtime::Runtime; use crate::types::{Broadcaster, PaymentStore, PendingPaymentStore}; @@ -1235,7 +1235,7 @@ impl Wallet { direction, PaymentStatus::Pending, ); - self.persist_funding_payment(details).await?; + self.persist_funding_payment(details, Vec::new()).await?; log_debug!( self.logger, "Recorded channel-funding broadcast {} for channel {}", @@ -1298,6 +1298,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 { @@ -1310,7 +1327,7 @@ impl Wallet { direction, PaymentStatus::Pending, ); - self.persist_funding_payment(details).await?; + self.persist_funding_payment(details, candidate_records).await?; log_debug!( self.logger, "Recorded interactive-funding broadcast {} ({} candidates, {} channels)", @@ -1323,9 +1340,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) -> Result<(), Error> { + async fn persist_funding_payment( + &self, details: PaymentDetails, candidates: Vec, + ) -> Result<(), Error> { self.payment_store.insert_or_update(details.clone()).await?; - let pending = PendingPaymentDetails::new(details, Vec::new()); + let pending = PendingPaymentDetails::new(details, Vec::new(), candidates); self.pending_payment_store.insert_or_update(pending).await?; Ok(()) } @@ -1395,7 +1414,7 @@ impl Wallet { fn create_pending_payment_from_tx( &self, payment: PaymentDetails, conflicting_txids: Vec, ) -> PendingPaymentDetails { - PendingPaymentDetails::new(payment, conflicting_txids) + PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()) } fn find_payment_by_txid(&self, target_txid: Txid) -> Option { @@ -1441,6 +1460,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.clone()))?; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index bd0068458..e19a1ca1e 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1319,6 +1319,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") @@ -1389,6 +1398,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), @@ -1419,7 +1442,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 { @@ -1448,19 +1471,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), @@ -1468,7 +1507,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), @@ -1484,14 +1523,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 5135534ed38c01338785765f39a992fa8b38230d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 17:39:25 -0500 Subject: [PATCH 07/10] 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 e19a1ca1e..f45b31f28 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1306,6 +1306,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, @@ -1601,6 +1613,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 54eb085f61ad7736c1e40e3330f604b9759f93df Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 23:29:03 -0500 Subject: [PATCH 08/10] 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 | 61 ++++++++++++++++++++++++++++----- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a3410db1f..46db6d80c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1658,11 +1658,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), )) @@ -1781,12 +1791,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 f45b31f28..8d71faed5 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1387,7 +1387,7 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { 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_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 @@ -1424,19 +1424,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(); @@ -1687,6 +1681,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 f9a64a09a24078674d8cefe9c2a01fdcec392e8c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 24 Jun 2026 15:40:14 -0500 Subject: [PATCH 09/10] Refactor the splice funding-feerate helpers into fee_estimator.rs The 1.5x-of-estimate funding feerate ceiling was open-coded identically in splice_in and splice_out. Route both through a max_funding_feerate helper and keep it, alongside rbf_splice_feerates, in fee_estimator.rs so the splice funding-feerate policy lives in one place. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fee_estimator.rs | 39 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 42 +++++------------------------------------- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/src/fee_estimator.rs b/src/fee_estimator.rs index 34fe7b64c..b785bfca4 100644 --- a/src/fee_estimator.rs +++ b/src/fee_estimator.rs @@ -164,3 +164,42 @@ pub(crate) fn apply_post_estimation_adjustments( _ => estimated_rate, } } + +/// 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. +pub(crate) 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. +pub(crate) 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 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); + } +} diff --git a/src/lib.rs b/src/lib.rs index 46db6d80c..c97e16fe6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -119,8 +119,6 @@ pub use bitcoin; use bitcoin::secp256k1::PublicKey; #[cfg(feature = "uniffi")] pub use bitcoin::FeeRate; -#[cfg(not(feature = "uniffi"))] -use bitcoin::FeeRate; use bitcoin::{Address, Amount, BlockHash, Network}; #[cfg(feature = "uniffi")] pub use builder::ArcedNodeBuilder as Builder; @@ -138,7 +136,9 @@ pub use error::Error as NodeError; use error::Error; pub use event::Event; use event::{EventHandler, EventQueue}; -use fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; +use fee_estimator::{ + max_funding_feerate, rbf_splice_feerates, ConfirmationTarget, FeeEstimator, OnchainFeeEstimator, +}; #[cfg(feature = "uniffi")] use ffi::*; use gossip::GossipSource; @@ -1584,7 +1584,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, @@ -1773,7 +1773,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 @@ -2413,44 +2413,12 @@ 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 5d21bd7ba65556fe0e3084327407e52cf07f556b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 26 Jun 2026 15:29:25 -0500 Subject: [PATCH 10/10] Wait for funding classification before syncing in splice tests In a splice, both channel parties broadcast the funding transaction, and the tests drive a single shared bitcoind, so the counterparty's broadcast can surface it to this node's wallet sync before this node's own funding classification has run. Under parallel test execution that classification can lag far enough behind for the sync to record the transaction as a plain on-chain payment, failing the funding-payment assertions. Wait for the funding broadcast to be classified before each affected splice test syncs its wallets. This is test-only: on a real node the classification runs locally, well ahead of a counterparty's broadcast arriving over the network, so the race does not occur. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration_tests_rust.rs | 47 ++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 8d71faed5..41028b662 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -36,7 +36,7 @@ use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{Builder, Event, NodeError}; +use ldk_node::{Builder, Event, Node, NodeError}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -45,6 +45,34 @@ use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; use serde_json::json; +/// Waits until `node` has classified the funding broadcast `funding_txid` (a channel open or splice +/// candidate) into a payment record carrying a `tx_type`. Classification runs off the broadcaster's +/// queue, which can lag a `sync_wallets` call under load — and for a splice the counterparty also +/// broadcasts the same tx, so a racing sync can see it before this node classifies. Waiting here +/// keeps the next sync on the funding short-circuit instead of recording a generic on-chain payment +/// that clobbers the classification. +async fn wait_for_classified_funding_payment(node: &Node, funding_txid: Txid) { + let poll = async { + loop { + let classified = node.list_payments().into_iter().any(|p| { + matches!( + p.kind, + PaymentKind::Onchain { txid, tx_type: Some(_), .. } if txid == funding_txid + ) + }); + if classified { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + }; + tokio::time::timeout(std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), poll) + .await + .unwrap_or_else(|_| { + panic!("timed out waiting for funding broadcast {} to be classified", funding_txid) + }); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -1236,6 +1264,10 @@ async fn splice_channel() { let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); + // Node B contributed to this splice, so wait for its funding broadcast to be classified before + // syncing — otherwise a sync racing the broadcaster's queue records a generic on-chain payment. + wait_for_classified_funding_payment(&node_b, txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); @@ -1292,6 +1324,10 @@ async fn splice_channel() { let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); + // Node A contributed to this splice, so wait for its funding broadcast to be classified before + // syncing — otherwise a sync racing the broadcaster's queue records a generic on-chain payment. + wait_for_classified_funding_payment(&node_a, txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); @@ -1407,6 +1443,9 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // 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 B contributed to this splice; wait for its classification before syncing so the sync + // takes the funding short-circuit rather than racing the broadcaster's queue. + wait_for_classified_funding_payment(&node_b, original_txo.txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1441,6 +1480,9 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // Wait for the RBF transaction to replace the original in the mempool. wait_for_tx(&electrsd.client, rbf_txo.txid).await; + // Wait for node_b's re-classification of the RBF candidate before syncing, so the recorded + // candidate figures reflect the replacement rather than racing the broadcaster's queue. + wait_for_classified_funding_payment(&node_b, rbf_txo.txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1640,6 +1682,9 @@ async fn splice_payment_reorged_to_unconfirmed() { 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; + // Ensure node_b classified the splice before syncing so the test exercises a funding payment's + // reorg rather than a generic on-chain payment's. + wait_for_classified_funding_payment(&node_b, 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.