From 129005abe9a5b28dbbed73202f7919aa8d73d580 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 18 Aug 2026 14:27:39 -0500 Subject: [PATCH 01/37] Only adopt a funding payment's own transactions from wallet sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallet sync resolves a funding payment's id for any transaction linked to the record through its conflicting txids, and then adopted that transaction's txid and confirmation outright. A cooperative close conflicts with a pending splice in exactly that way: the splice record would report the close's txid and confirmation under its InteractiveFunding type and contribution figures and graduate as if the splice had confirmed, while the close's own record never received its confirmation. Adopt a transaction only when it is part of the payment's funding history — the record's current txid or a classified candidate. Anything else is recorded under its own txid-keyed id, which also delivers the close's confirmation to the close's own record. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 --- src/wallet/mod.rs | 189 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 164 insertions(+), 25 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 13a8ef4e0..12b9374b9 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -346,12 +346,12 @@ impl Wallet { // duplicating) the record classification just wrote. let guard = self.funding_payment_update_lock.lock().await; - let payment_id = self + let mut payment_id = self .find_payment_by_txid(txid) .await? .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self + match self .apply_funding_status_update_locked( &guard, payment_id, @@ -360,7 +360,13 @@ impl Wallet { ) .await? { - continue; + FundingStatusUpdate::Applied => continue, + FundingStatusUpdate::NotFunding => {}, + // Not part of the funding payment's history (e.g. a close spending the + // funding outpoint): record it under its own id below instead. + FundingStatusUpdate::Foreign => { + payment_id = PaymentId(txid.to_byte_array()); + }, } let payment = { @@ -487,12 +493,12 @@ impl Wallet { // with classification. let guard = self.funding_payment_update_lock.lock().await; - let payment_id = self + let mut payment_id = self .find_payment_by_txid(txid) .await? .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self + match self .apply_funding_status_update_locked( &guard, payment_id, @@ -501,7 +507,13 @@ impl Wallet { ) .await? { - continue; + FundingStatusUpdate::Applied => continue, + FundingStatusUpdate::NotFunding => {}, + // Not part of the funding payment's history (e.g. a close spending the + // funding outpoint): record it under its own id below instead. + FundingStatusUpdate::Foreign => { + payment_id = PaymentId(txid.to_byte_array()); + }, } let payment = { @@ -563,12 +575,12 @@ impl Wallet { // with classification. let guard = self.funding_payment_update_lock.lock().await; - let payment_id = self + let mut payment_id = self .find_payment_by_txid(txid) .await? .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self + match self .apply_funding_status_update_locked( &guard, payment_id, @@ -577,7 +589,13 @@ impl Wallet { ) .await? { - continue; + FundingStatusUpdate::Applied => continue, + FundingStatusUpdate::NotFunding => {}, + // Not part of the funding payment's history (e.g. a close spending the + // funding outpoint): record it under its own id below instead. + FundingStatusUpdate::Foreign => { + payment_id = PaymentId(txid.to_byte_array()); + }, } let payment = { @@ -1949,9 +1967,11 @@ impl Wallet { /// 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`. + /// `sent`/`received` don't capture our contribution to a shared funding output. Returns + /// [`FundingStatusUpdate::Applied`] when it handled the payment, so the caller skips the + /// default on-chain path — or [`FundingStatusUpdate::Foreign`] when the transaction is not + /// part of the payment's funding history, so the caller records it under its own id. + /// Graduation to `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. /// /// The caller must hold [`Self::funding_payment_update_lock`] — from resolving `payment_id` /// through its own last write, not just across this call — so that classification's two-store @@ -1960,38 +1980,51 @@ impl Wallet { async fn apply_funding_status_update_locked( &self, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, - ) -> Result { + ) -> Result { // The caller's wallet-level lock keeps the candidate history stable while we await its - // read. The funding-type gate and write then share the payment store's mutation lock: - // against a separate payment `get`, a classification merging in between would have its - // `tx_type` and contribution figures clobbered by this stale snapshot. + // read. The funding-type gate, the candidate lookup, and the write then share the payment + // store's mutation lock: against a separate payment `get`, a classification merging in + // between would have its `tx_type` and contribution figures clobbered by this stale + // snapshot. let pending_payment = self.pending_payment_store.get(&payment_id).await?; + let mut outcome = FundingStatusUpdate::NotFunding; let mut handled = None; self.payment_store .mutate(&payment_id, |existing| { let payment = existing?; - let tx_type = match &payment.kind { + let (current_txid, tx_type) = match &payment.kind { PaymentKind::Onchain { + txid, tx_type: tx_type @ Some( TransactionType::Funding { .. } | TransactionType::InteractiveFunding { .. }, ), .. - } => tx_type.clone(), + } => (*txid, tx_type.clone()), _ => return None, }; + // Adopt the event's txid only when the transaction is part of this payment's + // funding history: its current txid or a classified candidate. A conflicting + // transaction that is neither — a close also spends the funding outpoint — must + // not overwrite the record. + let owns_event_tx = event_txid == current_txid + || pending_payment.as_ref().is_some_and(|p| p.candidate(event_txid).is_some()); + if !owns_event_tx { + outcome = FundingStatusUpdate::Foreign; + return None; + } // 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.) let mut target = payment.clone(); - if let Some(pending) = pending_payment.as_ref() { - if let Some(candidate) = pending.candidate(event_txid) { - target.amount_msat = candidate.amount_msat; - target.fee_paid_msat = candidate.fee_paid_msat; - } + if let Some(candidate) = + pending_payment.as_ref().and_then(|p| p.candidate(event_txid)) + { + target.amount_msat = candidate.amount_msat; + target.fee_paid_msat = candidate.fee_paid_msat; } target.kind = PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; @@ -2009,7 +2042,7 @@ impl Wallet { }) .await?; let Some(payment) = handled else { - return Ok(false); + return Ok(outcome); }; // 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 @@ -2019,7 +2052,7 @@ impl Wallet { let pending = self.create_pending_payment_from_tx(payment, Vec::new()); self.pending_payment_store.insert_or_update(pending).await?; } - Ok(true) + Ok(FundingStatusUpdate::Applied) } #[allow(deprecated)] @@ -2322,6 +2355,20 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { } } +/// The outcome of [`Wallet::apply_funding_status_update_locked`]. +enum FundingStatusUpdate { + /// The event's transaction belongs to the funding payment; its refreshed confirmation status + /// was applied (or was already current). + Applied, + /// The resolved payment is not a classified funding payment; the caller's default on-chain + /// handling applies under the resolved id. + NotFunding, + /// The event's transaction is not part of the funding payment's history — e.g. a close + /// spending the same funding outpoint — so the funding record must not adopt it; the caller + /// should record the transaction under its own txid-derived id. + Foreign, +} + impl Listen for Wallet { fn filtered_block_connected( &self, _header: &bitcoin::block::Header, @@ -4048,6 +4095,98 @@ mod tests { wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap(); } + /// A cooperative close conflicts with a pending splice's funding transaction — both spend the + /// pre-splice funding outpoint — so sync records the close among the splice record's + /// conflicting txids, and the close's confirmation then resolves to the splice's PaymentId. + /// The funding record must not adopt the close's txid and confirmation as its own: the close + /// is not a round of the splice. It must land on a record keyed by the close's own id. + #[tokio::test] + async fn funding_record_does_not_adopt_a_conflicting_close() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let funding_outpoint = + bitcoin::OutPoint { txid: Txid::from_byte_array([3u8; 32]), vout: 0 }; + + // The close pays the shutdown script, which is a wallet address. + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let close_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: funding_outpoint, + script_sig: bitcoin::ScriptBuf::new(), + sequence: bitcoin::Sequence::MAX, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + }; + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + + // Sync saw the close double-spend the splice's funding transaction. + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + }) + .await + .unwrap(); + + let event = WalletEvent::TxConfirmed { + txid: close_txid, + tx: Arc::new(close_tx), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let funding = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + match &funding.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid, "the record must not adopt the close's txid"); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(funding.amount_msat, Some(1_000_000)); + assert_eq!(funding.fee_paid_msat, Some(500)); + + let close = wallet + .payment_store + .get(&PaymentId(close_txid.to_byte_array())) + .await + .unwrap() + .unwrap(); + match &close.kind { + PaymentKind::Onchain { txid, status, .. } => { + assert_eq!(*txid, close_txid); + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + kind => panic!("unexpected kind {:?}", kind), + } + } + /// A funding-typed broadcast that doesn't touch the on-chain wallet must not be recorded. /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding /// path, so a splice the interactive-funding classification deliberately declined — no local From bca2cdd049f9518b63ddc2cef0c9d71b32b1de32 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 18 Aug 2026 17:07:12 -0500 Subject: [PATCH 02/37] Retry funding-broadcast classification instead of dropping it A queued broadcast whose payment-record classification failed was dropped outright, on the theory that broadcasting a transaction we failed to record would leave it on-chain without a payment. For interactive funding that theory doesn't hold: the counterparty broadcasts the same transaction once the signature exchange completes, so dropping the package keeps nothing off-chain -- it only guarantees the round is never recorded as a candidate on our side. The funding-status ownership gate then treats the round's confirmation as foreign to the funding record and re-keys it to a stray duplicate record, which shadows the funding record's txid lookups permanently: the splice payment stays Pending forever while an untyped duplicate holds the confirmation. Keep the package alive instead: retry classification after a short delay, holding the broadcast back until it succeeds. Other packages keep flowing while a retry waits, and pending retries are dropped when the node stops -- a retry that outlived a stop would classify and broadcast a stale package after a later start. Classification failures are persistence failures, so there is no limit on attempts -- a store that never recovers keeps the node from functioning anyway -- and every failed round is logged. The waiting packages are deduplicated and bounded. LDK re-broadcasts pending claims every 30 seconds and regenerates sweeps once per block until they confirm, so over a long store outage a copy per rebroadcast would otherwise pile up and replay as a burst on recovery. A package whose transactions already await a retry is not queued again. At the bound, an incoming package that LDK would re-broadcast anyway makes room by dropping the oldest such waiting package, whose transactions return with the next rebroadcast; if every waiting package is one nothing re-broadcasts, the incoming package is dropped instead. Fundings and cooperative closes are never dropped to make room and never refused at the bound, since nothing re-broadcasts them: a dropped funding would leave its transaction confirming without a recorded candidate, and a dropped cooperative close might lose the only copy of the signed closing transaction. Fee-bumped rebroadcasts carry new txids, so the bound, not the deduplication, is what limits their accumulation. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 --- src/chain/mod.rs | 113 ++++++++---- src/tx_broadcaster.rs | 388 +++++++++++++++++++++++++++++++++++++++++- src/wallet/mod.rs | 182 +++++++++++++++++++- 3 files changed, 639 insertions(+), 44 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index f01c1c8cb..2d53cf9d6 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -37,9 +37,15 @@ use crate::config::{BackgroundSyncConfig, Config, WALLET_SYNC_INTERVAL_MINIMUM_S use crate::fee_estimator::OnchainFeeEstimator; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::runtime::Runtime; +use crate::tx_broadcaster::{BroadcastPackage, RetryQueue, ScheduleOutcome}; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; +/// How long to wait before re-classifying a package whose classification failed. Long enough to +/// give a struggling store room to recover, short against the ~minutes until the transaction +/// could confirm. +const FAILED_CLASSIFY_RETRY_DELAY: Duration = Duration::from_secs(2); + /// We use this parent-child TRUC package to make sure the configured chain source supports /// broadcasting packages via the `submitpackage` Bitcoin Core RPC. const PARENT_TXID: &str = "9a015f93fac6cb203c2b994e18b85176eb0354a22a468255516f3c6002d3f696"; @@ -562,50 +568,91 @@ impl ChainSource { } } + /// Classifies the package's funding broadcasts into payment records, then broadcasts it. + /// Returns the package back on classification failure so the caller can retry it after a + /// delay: broadcasting a tx we failed to record would leave it on-chain without a payment, + /// while dropping the package would not keep an interactively funded tx off-chain (the + /// counterparty broadcasts it regardless), only leave it confirming without a recorded + /// candidate. + async fn classify_and_broadcast( + &self, package: BroadcastPackage, + ) -> Result<(), BroadcastPackage> { + if let Err(e) = self.tx_broadcaster.classify_package(&package).await { + log_error!( + self.logger, + "Delaying broadcast: failed to persist payment records, will retry: {:?}", + e, + ); + return Err(package); + } + let package = package.into_sorted_transactions(); + match &self.kind { + #[cfg(feature = "chain-esplora")] + ChainSourceKind::Esplora(esplora_chain_source) => { + esplora_chain_source.process_transaction_broadcast(package).await + }, + #[cfg(feature = "chain-electrum")] + ChainSourceKind::Electrum(electrum_chain_source) => { + electrum_chain_source.process_transaction_broadcast(package).await + }, + #[cfg(feature = "chain-bitcoind")] + ChainSourceKind::Bitcoind(bitcoind_chain_source) => { + bitcoind_chain_source.process_transaction_broadcast(package).await + }, + } + Ok(()) + } + pub(crate) async fn continuously_process_broadcast_queue( &self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>, ) { let mut receiver = self.tx_broadcaster.get_broadcast_queue().await; + // Packages whose classification failed, each waiting out FAILED_CLASSIFY_RETRY_DELAY + // before its next attempt. New packages keep flowing while these wait, and pending + // retries die with the loop on shutdown rather than resurfacing after a later start. + let mut retries = RetryQueue::new(); loop { - let tx_bcast_logger = Arc::clone(&self.logger); - tokio::select! { + let next_retry_at = retries.next_retry_at(); + let package = tokio::select! { _ = stop_tx_bcast_receiver.changed() => { log_debug!( - tx_bcast_logger, + self.logger, "Stopping broadcasting transactions.", ); 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 package = package.into_sorted_transactions(); - match &self.kind { - #[cfg(feature = "chain-esplora")] - ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_transaction_broadcast(package).await - }, - #[cfg(feature = "chain-electrum")] - ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_transaction_broadcast(package).await - }, - #[cfg(feature = "chain-bitcoind")] - ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_transaction_broadcast(package).await - }, - } + Some(next_package) = receiver.recv() => next_package, + _ = tokio::time::sleep_until( + next_retry_at.unwrap_or_else(tokio::time::Instant::now) + ), if next_retry_at.is_some() => { + retries.pop_next().expect("a retry is queued") + } + }; + if let Err(package) = self.classify_and_broadcast(package).await { + let retry_at = tokio::time::Instant::now() + FAILED_CLASSIFY_RETRY_DELAY; + match retries.schedule(package, retry_at) { + ScheduleOutcome::Scheduled { dropped: None } => {}, + ScheduleOutcome::Scheduled { dropped: Some(dropped) } => { + log_error!( + self.logger, + "Dropped the oldest package awaiting a classification retry; LDK re-broadcasts its transactions periodically: {:?}", + dropped.sorted_txids(), + ); + }, + ScheduleOutcome::AlreadyQueued(duplicate) => { + log_debug!( + self.logger, + "Dropped a re-broadcast package; an identical one already awaits a classification retry: {:?}", + duplicate.sorted_txids(), + ); + }, + ScheduleOutcome::Refused(package) => { + log_error!( + self.logger, + "Dropped a package failing classification; too many await retries: {:?}", + package.sorted_txids(), + ); + }, } } } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 782112dad..3e5b846da 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -5,14 +5,16 @@ // 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::VecDeque; use std::ops::Deref; use std::sync::{Mutex as StdMutex, Weak}; -use bitcoin::Transaction; +use bitcoin::{Transaction, Txid}; use lightning::chain::chaininterface::{ BroadcasterInterface, TransactionType as LdkTransactionType, }; use tokio::sync::{mpsc, Mutex, MutexGuard}; +use tokio::time::Instant; use crate::logger::{log_error, LdkLogger}; use crate::types::Wallet; @@ -20,6 +22,13 @@ use crate::Error; const BCAST_PACKAGE_QUEUE_SIZE: usize = 256; +/// The most droppable packages [`RetryQueue`] holds. Claims and sweeps re-enter the broadcast +/// queue on LDK's periodic rebroadcast timers, so one dropped here resurfaces on its own once +/// the store recovers. Packages nothing re-broadcasts — fundings and cooperative closes — +/// don't count against the bound: they are finite — one per negotiated funding candidate and +/// one per closing channel, since a copy of a waiting package is never queued twice. +const MAX_QUEUED_RETRIES: usize = BCAST_PACKAGE_QUEUE_SIZE; + /// 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 @@ -47,6 +56,115 @@ impl BroadcastPackage { let txs = self.0.into_iter().map(|(tx, _)| tx).collect(); SortedTransactions::sort_parents_child_package_topologically(txs) } + + /// The packaged transactions' txids in sorted order, identifying the package's effect on + /// chain: two packages with the same txids broadcast the same transactions. + pub(crate) fn sorted_txids(&self) -> Vec { + let mut txids: Vec = self.0.iter().map(|(tx, _)| tx.compute_txid()).collect(); + txids.sort_unstable(); + txids + } + + /// Whether the package may be dropped to keep [`RetryQueue`] within its bound: every + /// transaction in it is re-broadcast by its originator, so a dropped package resurfaces on + /// its own. LDK re-hands claims, anchor bumps, and force-close commitments to the + /// broadcaster periodically, and the sweeper regenerates sweeps once per block. Nothing + /// re-broadcasts a funding transaction (a channel open or splice, whose classification + /// writes the payment record tracking the funding) or a cooperative close (whose channel is + /// gone from the `ChannelManager` by broadcast time), so a package containing either is + /// never dropped. + fn is_droppable(&self) -> bool { + self.0.iter().all(|(_, tx_type)| match tx_type { + Some( + LdkTransactionType::Funding { .. } + | LdkTransactionType::InteractiveFunding { .. } + | LdkTransactionType::CooperativeClose { .. }, + ) => false, + Some( + LdkTransactionType::UnilateralClose { .. } + | LdkTransactionType::AnchorBump { .. } + | LdkTransactionType::Claim { .. } + | LdkTransactionType::Sweep { .. }, + ) => true, + // Wallet-originated: re-submitted on chain tip changes. Never queued anyway, since + // classification of an untyped package is a no-op that can't fail. + None => true, + }) + } +} + +/// What [`RetryQueue::schedule`] did with a package, so the caller can log the cases in which +/// the package won't be retried as-is. +pub(crate) enum ScheduleOutcome { + /// The package waits for its retry deadline. When the bound was reached, the oldest waiting + /// droppable package was dropped to make room and is returned — its transactions resurface + /// with LDK's next periodic rebroadcast. + Scheduled { dropped: Option }, + /// A package broadcasting the same transactions already waits, and its retry covers this + /// one: the incoming package is dropped and returned. + AlreadyQueued(BroadcastPackage), + /// The bound was reached and every waiting package is one that must not be dropped (a + /// funding or a cooperative close): the incoming package is refused and returned. + Refused(BroadcastPackage), +} + +/// Packages whose classification failed, each waiting out a retry delay before its next attempt. +/// Deduplicated and bounded: LDK re-broadcasts pending claims every 30 seconds (and sweeps once +/// per block) until they confirm, so while the store is unavailable, copies would otherwise +/// accumulate without bound and replay as a burst on recovery. An identical copy is never queued +/// twice — the waiting entry and its deadline stand; fee-bumped rebroadcast variants carry new +/// txids, so the bound — not the dedup — is what limits their accumulation. +pub(crate) struct RetryQueue(VecDeque<(Instant, Vec, BroadcastPackage)>); + +impl RetryQueue { + pub(crate) fn new() -> Self { + Self(VecDeque::new()) + } + + /// The deadline of the next retry, if a package is waiting. Packages are scheduled with a fixed + /// delay, so the front entry is always the next to retry. + pub(crate) fn next_retry_at(&self) -> Option { + self.0.front().map(|(deadline, _, _)| *deadline) + } + + /// Removes and returns the package scheduled to retry first. + pub(crate) fn pop_next(&mut self) -> Option { + self.0.pop_front().map(|(_, _, package)| package) + } + + /// Schedules a package to retry at `retry_at`, unless a package with the same transactions already + /// waits or accepting it would exceed [`MAX_QUEUED_RETRIES`] with no droppable package to + /// make room with; see [`ScheduleOutcome`]. + pub(crate) fn schedule( + &mut self, package: BroadcastPackage, retry_at: Instant, + ) -> ScheduleOutcome { + let txids = package.sorted_txids(); + if self.0.iter().any(|(_, waiting, _)| *waiting == txids) { + // Same transactions, same classification outcome: keep the waiting entry and its + // earlier deadline. The one same-txid package with a *different* type is LDK's + // re-typed generic-funding rebroadcast of a promoted 0conf splice, which always + // arrives after the interactive-funding original (the zero-conf rebroadcast canary + // tests assert that ordering), so the entry kept is the richer of the two — and its + // classification declines the downgrade anyway. + return ScheduleOutcome::AlreadyQueued(package); + } + + let mut dropped = None; + if package.is_droppable() && self.0.len() >= MAX_QUEUED_RETRIES { + // Drop the oldest droppable package: its transactions are re-broadcast + // periodically, while the incoming package may carry a fresher fee-bumped variant. + // A funding package is never dropped — nothing would re-broadcast it, and losing it + // leaves its transaction confirming without a recorded candidate. Neither is a + // cooperative close, whose queued package may hold the only copy of the signed + // closing transaction. + match self.0.iter().position(|(_, _, waiting)| waiting.is_droppable()) { + Some(oldest) => dropped = self.0.remove(oldest).map(|(_, _, package)| package), + None => return ScheduleOutcome::Refused(package), + } + } + self.0.push_back((retry_at, txids, package)); + ScheduleOutcome::Scheduled { dropped } + } } pub(crate) struct SortedTransactions(Vec); @@ -133,12 +251,10 @@ where 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 { + /// Classifies a queued package into payment records. 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 — but must retry it later rather than drop it. + pub(crate) async fn classify_package(&self, package: &BroadcastPackage) -> Result<(), Error> { 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() { @@ -147,7 +263,7 @@ where } } } - Ok(package) + Ok(()) } pub(crate) fn broadcast_unclassified_transaction(&self, tx: Transaction) { @@ -173,7 +289,10 @@ mod tests { use bitcoin::hashes::Hash; use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; - use super::SortedTransactions; + use super::{ + BroadcastPackage, LdkTransactionType, RetryQueue, ScheduleOutcome, SortedTransactions, + MAX_QUEUED_RETRIES, + }; fn txin(txid: Txid, vout: u32) -> TxIn { TxIn { @@ -314,4 +433,255 @@ mod tests { fn topological_sort_accepts_empty_vec() { SortedTransactions::sort_parents_child_package_topologically(Vec::new()); } + + fn funding_package(tx: &Transaction) -> BroadcastPackage { + BroadcastPackage::new(&[(tx, LdkTransactionType::Funding { channels: vec![] })]) + } + + fn test_counterparty_node_id() -> bitcoin::secp256k1::PublicKey { + use std::str::FromStr; + bitcoin::secp256k1::PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap() + } + + fn coop_close_package(tx: &Transaction) -> BroadcastPackage { + BroadcastPackage::new(&[( + tx, + LdkTransactionType::CooperativeClose { + counterparty_node_id: test_counterparty_node_id(), + channel_id: lightning::ln::types::ChannelId([13u8; 32]), + }, + )]) + } + + fn claim_package(tx: &Transaction) -> BroadcastPackage { + BroadcastPackage::new(&[( + tx, + LdkTransactionType::Claim { + counterparty_node_id: test_counterparty_node_id(), + channel_id: lightning::ln::types::ChannelId([13u8; 32]), + }, + )]) + } + + fn deadline(secs: u64) -> tokio::time::Instant { + tokio::time::Instant::now() + std::time::Duration::from_secs(secs) + } + + /// A re-broadcast of the same transactions is not queued again: the waiting entry keeps its + /// earlier deadline and its package — the first arrival carries the richer classification + /// when LDK later re-types a rebroadcast. + #[tokio::test] + async fn retry_queue_queues_identical_transactions_once() { + let tx = parent_tx(1); + let mut retries = RetryQueue::new(); + + let first_deadline = deadline(2); + assert!(matches!( + retries.schedule(funding_package(&tx), first_deadline), + ScheduleOutcome::Scheduled { dropped: None } + )); + assert!(matches!( + retries.schedule(BroadcastPackage::unclassified(tx.clone()), deadline(4)), + ScheduleOutcome::AlreadyQueued(_) + )); + + assert_eq!(retries.next_retry_at(), Some(first_deadline)); + let kept = retries.pop_next().expect("the first package is kept"); + assert!( + matches!(kept.transactions()[0].1, Some(LdkTransactionType::Funding { .. })), + "the first-scheduled package must be kept" + ); + assert!(retries.pop_next().is_none()); + } + + #[tokio::test] + async fn retry_queue_retries_in_schedule_order() { + let (tx_a, tx_b) = (parent_tx(1), parent_tx(2)); + let mut retries = RetryQueue::new(); + + assert!(matches!( + retries.schedule(BroadcastPackage::unclassified(tx_a.clone()), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + assert!(matches!( + retries.schedule(BroadcastPackage::unclassified(tx_b.clone()), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + + let popped = retries.pop_next().expect("first package"); + assert_eq!(popped.sorted_txids(), vec![tx_a.compute_txid()]); + let popped = retries.pop_next().expect("second package"); + assert_eq!(popped.sorted_txids(), vec![tx_b.compute_txid()]); + } + + /// Distinct transactions (e.g. fee-bumped claim variants during a store outage) are held to + /// the bound: the oldest droppable package is dropped for an incoming one, never a funding + /// package. + #[tokio::test] + async fn retry_queue_drops_the_oldest_droppable_package_at_the_bound() { + fn numbered_tx(n: u32) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([7u8; 32]), n)], + output: vec![txout(1_000)], + } + } + + let mut retries = RetryQueue::new(); + let funding_tx = numbered_tx(0); + assert!(matches!( + retries.schedule(funding_package(&funding_tx), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + let oldest_claim = numbered_tx(1); + for n in 1..(MAX_QUEUED_RETRIES as u32) { + assert!(matches!( + retries.schedule(BroadcastPackage::unclassified(numbered_tx(n)), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + } + + // At the bound, an incoming droppable package drops the oldest waiting one — not the + // older funding package. + let new_claim = numbered_tx(MAX_QUEUED_RETRIES as u32); + match retries.schedule(BroadcastPackage::unclassified(new_claim.clone()), deadline(2)) { + ScheduleOutcome::Scheduled { dropped: Some(dropped) } => { + assert_eq!(dropped.sorted_txids(), vec![oldest_claim.compute_txid()]); + }, + _ => panic!("the incoming claim must be scheduled by dropping the oldest one"), + } + + // An incoming funding package is never dropped for the bound. + let new_funding_tx = numbered_tx(MAX_QUEUED_RETRIES as u32 + 1); + assert!(matches!( + retries.schedule(funding_package(&new_funding_tx), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + + let mut remaining = Vec::new(); + while let Some(package) = retries.pop_next() { + remaining.extend(package.sorted_txids()); + } + assert!(remaining.contains(&funding_tx.compute_txid()), "funding is never dropped"); + assert!(remaining.contains(&new_claim.compute_txid())); + assert!(!remaining.contains(&oldest_claim.compute_txid())); + } + + /// When only funding packages wait at the bound, an incoming droppable package is refused: + /// LDK re-broadcasts claims and sweeps periodically, while a dropped funding package would + /// leave its transaction confirming without a recorded candidate. + #[tokio::test] + async fn retry_queue_refuses_a_droppable_package_over_waiting_funding_packages() { + fn numbered_tx(n: u32) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([8u8; 32]), n)], + output: vec![txout(1_000)], + } + } + + let mut retries = RetryQueue::new(); + for n in 0..(MAX_QUEUED_RETRIES as u32) { + assert!(matches!( + retries.schedule(funding_package(&numbered_tx(n)), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + } + + let claim = numbered_tx(MAX_QUEUED_RETRIES as u32); + assert!(matches!( + retries.schedule(BroadcastPackage::unclassified(claim), deadline(2)), + ScheduleOutcome::Refused(_) + )); + } + + /// A cooperative close is never dropped at the bound: nothing re-broadcasts it, and the + /// queued package may hold the only copy of the signed closing transaction. + #[tokio::test] + async fn retry_queue_never_drops_a_cooperative_close_at_the_bound() { + fn numbered_tx(n: u32) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([9u8; 32]), n)], + output: vec![txout(1_000)], + } + } + + let mut retries = RetryQueue::new(); + let coop_close_tx = numbered_tx(0); + assert!(matches!( + retries.schedule(coop_close_package(&coop_close_tx), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + let oldest_claim = numbered_tx(1); + for n in 1..(MAX_QUEUED_RETRIES as u32) { + assert!(matches!( + retries.schedule(claim_package(&numbered_tx(n)), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + } + + // At the bound, an incoming claim drops the oldest waiting claim — not the older + // cooperative close. + let new_claim = numbered_tx(MAX_QUEUED_RETRIES as u32); + match retries.schedule(claim_package(&new_claim), deadline(2)) { + ScheduleOutcome::Scheduled { dropped: Some(dropped) } => { + assert_eq!(dropped.sorted_txids(), vec![oldest_claim.compute_txid()]); + }, + _ => panic!("the incoming claim must be scheduled by dropping the oldest one"), + } + + // An incoming cooperative close is never dropped for the bound either. + let new_coop_close_tx = numbered_tx(MAX_QUEUED_RETRIES as u32 + 1); + assert!(matches!( + retries.schedule(coop_close_package(&new_coop_close_tx), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + + let mut remaining = Vec::new(); + while let Some(package) = retries.pop_next() { + remaining.extend(package.sorted_txids()); + } + assert!( + remaining.contains(&coop_close_tx.compute_txid()), + "a cooperative close is never dropped" + ); + assert!(remaining.contains(&new_coop_close_tx.compute_txid())); + assert!(!remaining.contains(&oldest_claim.compute_txid())); + } + + /// When only cooperative closes wait at the bound, an incoming claim is refused: LDK + /// re-broadcasts the claim periodically, while a dropped close would lose the only copy of + /// its signed closing transaction. + #[tokio::test] + async fn retry_queue_refuses_a_claim_over_waiting_cooperative_closes() { + fn numbered_tx(n: u32) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([10u8; 32]), n)], + output: vec![txout(1_000)], + } + } + + let mut retries = RetryQueue::new(); + for n in 0..(MAX_QUEUED_RETRIES as u32) { + assert!(matches!( + retries.schedule(coop_close_package(&numbered_tx(n)), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + } + + let claim = numbered_tx(MAX_QUEUED_RETRIES as u32); + assert!(matches!( + retries.schedule(claim_package(&claim), deadline(2)), + ScheduleOutcome::Refused(_) + )); + } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 12b9374b9..b09c99be8 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2745,7 +2745,7 @@ fn funding_reclassification_update( #[cfg(all(test, any(feature = "chain-esplora", feature = "chain-electrum")))] mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::Duration; use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate}; @@ -2775,11 +2775,13 @@ mod tests { const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; - /// An in-memory store whose writes can be made to fail on demand. + /// An in-memory store whose writes can be made to fail on demand, counting the failures so + /// tests can wait for a write to have actually failed rather than guessing with a sleep. #[derive(Clone)] struct FailSwitchStore { inner: Arc, fail_writes: Arc, + failed_writes: Arc, } impl FailSwitchStore { @@ -2787,6 +2789,7 @@ mod tests { Self { inner: Arc::new(InMemoryStore::new()), fail_writes: Arc::new(AtomicBool::new(false)), + failed_writes: Arc::new(AtomicUsize::new(0)), } } } @@ -2803,11 +2806,13 @@ mod tests { ) -> impl Future> + 'static + Send { let inner = Arc::clone(&self.inner); let fail_writes = Arc::clone(&self.fail_writes); + let failed_writes = Arc::clone(&self.failed_writes); let primary_namespace = primary_namespace.to_string(); let secondary_namespace = secondary_namespace.to_string(); let key = key.to_string(); async move { if fail_writes.load(Ordering::Acquire) { + failed_writes.fetch_add(1, Ordering::AcqRel); return Err(io::Error::new(io::ErrorKind::Other, "writes disabled")); } KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await @@ -4329,6 +4334,179 @@ mod tests { assert_unchanged(&wallet, payment_id, true).await; } + /// A funding broadcast whose classification fails must be retried, not dropped: for + /// interactive funding the counterparty broadcasts the same transaction regardless of + /// whether we do, so dropping the package permanently leaves the confirming transaction + /// unrecorded as a candidate — and the funding-status ownership gate then routes its + /// confirmation to a stray duplicate record instead of the funding record. + #[tokio::test] + async fn failed_funding_classification_is_retried_not_dropped() { + use lightning::chain::chaininterface::BroadcasterInterface; + + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.broadcaster.set_wallet(Arc::downgrade(&wallet)); + + // Run the production broadcast-queue loop. The broadcast itself fails fast against the + // fixture's unroutable Esplora server, which is irrelevant here: the record is written + // during classification, before the broadcast attempt. + let (stop_sender, stop_receiver) = tokio::sync::watch::channel(()); + let chain_source = Arc::clone(&wallet.chain_source); + let loop_task = tokio::spawn(async move { + chain_source.continuously_process_broadcast_queue(stop_receiver).await + }); + + // A funding transaction paying the wallet passes the wallet-activity guard, so its + // classification reaches the payment-store write. + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + + // Queue the broadcast while payment persistence is failing. + fail_store.fail_writes.store(true, Ordering::Release); + wallet.broadcaster.broadcast_transactions(&[( + &tx, + LdkTransactionType::Funding { + channels: vec![(counterparty_node_id, ChannelId([7u8; 32]))], + }, + )]); + + // Wait until the loop has actually failed a classification write; re-enabling writes + // before the first attempt would let the first attempt succeed and the test pass + // without any retry happening. A failed classification must not leave a partial + // record behind. + let mut failed_writes = 0; + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + failed_writes = fail_store.failed_writes.load(Ordering::Acquire); + if failed_writes > 0 { + break; + } + } + assert!(failed_writes > 0, "classification never attempted a payment-store write"); + assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); + + // Once writes recover, the package must still be alive to classify. + fail_store.fail_writes.store(false, Ordering::Release); + let mut recorded = Vec::new(); + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + recorded = wallet.payment_store.list_page(None).await.unwrap().objects; + if !recorded.is_empty() { + break; + } + } + assert!( + !recorded.is_empty(), + "the failed classification was never retried; the package was dropped" + ); + assert_eq!(recorded.len(), 1); + assert!(matches!( + recorded[0].kind, + PaymentKind::Onchain { tx_type: Some(TransactionType::Funding { .. }), .. } + )); + + stop_sender.send(()).unwrap(); + loop_task.await.unwrap(); + } + + /// A package awaiting a classification retry must die when the node stops. When the retry + /// was a detached task, it outlived the broadcast loop: its re-send into the still-open + /// queue succeeded after `stop()`, so a later `start()` would classify and broadcast the + /// stale package. + #[tokio::test] + async fn failed_classification_retry_dies_at_stop() { + use lightning::chain::chaininterface::BroadcasterInterface; + + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.broadcaster.set_wallet(Arc::downgrade(&wallet)); + + let (stop_sender, stop_receiver) = tokio::sync::watch::channel(()); + let chain_source = Arc::clone(&wallet.chain_source); + let loop_task = tokio::spawn(async move { + chain_source.continuously_process_broadcast_queue(stop_receiver).await + }); + + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + + // Queue the broadcast while payment persistence is failing and wait for the loop to + // fail a classification attempt, leaving a retry pending. + fail_store.fail_writes.store(true, Ordering::Release); + wallet.broadcaster.broadcast_transactions(&[( + &tx, + LdkTransactionType::Funding { + channels: vec![(counterparty_node_id, ChannelId([7u8; 32]))], + }, + )]); + let mut failed_writes = 0; + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + failed_writes = fail_store.failed_writes.load(Ordering::Acquire); + if failed_writes > 0 { + break; + } + } + assert!(failed_writes > 0, "classification never attempted a payment-store write"); + + // Stop the node with the retry still pending, then bring the loop back up with + // working persistence, as a stop()/start() cycle would. + stop_sender.send(()).unwrap(); + loop_task.await.unwrap(); + fail_store.fail_writes.store(false, Ordering::Release); + + let (stop_sender, stop_receiver) = tokio::sync::watch::channel(()); + let chain_source = Arc::clone(&wallet.chain_source); + let loop_task = tokio::spawn(async move { + chain_source.continuously_process_broadcast_queue(stop_receiver).await + }); + + // Watch well past the retry delay: the package from before the stop must not be + // classified or broadcast by the restarted loop. + for _ in 0..40 { + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + wallet.payment_store.list_page(None).await.unwrap().objects.is_empty(), + "a package from before stop() resurfaced after restart" + ); + } + + stop_sender.send(()).unwrap(); + loop_task.await.unwrap(); + } + /// Barrier test, classification-first ordering: wallet sync's confirmation handling must /// wait for classification's two-store write pair. Classification is parked between its /// payment-store and pending-store writes (the torn window) and only then is the From 1ba8c5d5e44256aaf58ff4843c94a98694f9fbea Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 17 Sep 2026 11:12:42 -0500 Subject: [PATCH 03/37] f - Retry funding-broadcast classification instead of dropping it Poll the broadcast queue ahead of due retries, so packages arriving during a store outage are classified before the outage's retries rather than in random order. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/chain/mod.rs | 8 ++- src/wallet/mod.rs | 137 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 142 insertions(+), 3 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 2d53cf9d6..1a12c2d6a 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -44,7 +44,7 @@ use crate::{Error, PersistedNodeMetrics}; /// How long to wait before re-classifying a package whose classification failed. Long enough to /// give a struggling store room to recover, short against the ~minutes until the transaction /// could confirm. -const FAILED_CLASSIFY_RETRY_DELAY: Duration = Duration::from_secs(2); +pub(crate) const FAILED_CLASSIFY_RETRY_DELAY: Duration = Duration::from_secs(2); /// We use this parent-child TRUC package to make sure the configured chain source supports /// broadcasting packages via the `submitpackage` Bitcoin Core RPC. @@ -614,6 +614,12 @@ impl ChainSource { loop { let next_retry_at = retries.next_retry_at(); let package = tokio::select! { + // Polled in order: a stop request first, then a fresh package, and a due retry + // only when neither is ready, so retries never hold back the broadcasts that + // keep arriving during a store outage. The retry keeps its delay regardless: + // without it, an empty channel would retry a fast-failing store back to back, + // logging an error each time. + biased; _ = stop_tx_bcast_receiver.changed() => { log_debug!( self.logger, diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index b09c99be8..61ccd3e9f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3175,14 +3175,17 @@ mod tests { } /// An in-memory store whose writes can be made to park until aborted or released, - /// signalling when a write has entered the gate, and whose writes can be made to fail. + /// signalling when a write has entered the gate, and whose writes can be made to fail, + /// counting the failures. Records the keys it wrote, in order. #[derive(Clone)] struct GatedStore { inner: Arc, gate_writes: Arc, fail_writes: Arc, + failed_writes: Arc, write_entered: Arc, release: Arc, + writes: Arc>>, } impl GatedStore { @@ -3191,10 +3194,23 @@ mod tests { inner: Arc::new(InMemoryStore::new()), gate_writes: Arc::new(AtomicBool::new(false)), fail_writes: Arc::new(AtomicBool::new(false)), + failed_writes: Arc::new(AtomicUsize::new(0)), write_entered: Arc::new(tokio::sync::Notify::new()), release: Arc::new(tokio::sync::Notify::new()), + writes: Arc::new(Mutex::new(Vec::new())), } } + + /// The keys written to `primary_namespace`, in write order. + fn written_keys(&self, primary_namespace: &str) -> Vec { + self.writes + .lock() + .unwrap() + .iter() + .filter(|(namespace, _)| namespace == primary_namespace) + .map(|(_, key)| key.clone()) + .collect() + } } impl KVStore for GatedStore { @@ -3210,8 +3226,10 @@ mod tests { let inner = Arc::clone(&self.inner); let gate_writes = Arc::clone(&self.gate_writes); let fail_writes = Arc::clone(&self.fail_writes); + let failed_writes = Arc::clone(&self.failed_writes); let write_entered = Arc::clone(&self.write_entered); let release = Arc::clone(&self.release); + let writes = Arc::clone(&self.writes); let primary_namespace = primary_namespace.to_string(); let secondary_namespace = secondary_namespace.to_string(); let key = key.to_string(); @@ -3221,9 +3239,13 @@ mod tests { release.notified().await; } if fail_writes.load(Ordering::Acquire) { + failed_writes.fetch_add(1, Ordering::AcqRel); return Err(io::Error::new(io::ErrorKind::Other, "write failed")); } - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf) + .await?; + writes.lock().unwrap().push((primary_namespace, key)); + Ok(()) } } @@ -4507,6 +4529,117 @@ mod tests { loop_task.await.unwrap(); } + /// Fresh packages go before a due retry. Both are ready at once when the loop returns from a + /// slow classification with packages waiting in the channel and a retry past its deadline; + /// the channel is polled first until it is empty, so broadcasts arriving during a store outage + /// are never held back by the outage's retries. + #[tokio::test] + async fn fresh_package_is_classified_before_a_due_retry() { + use lightning::chain::chaininterface::BroadcasterInterface; + + use crate::data_store::StorableObjectId; + + let gated_store = GatedStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(gated_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.broadcaster.set_wallet(Arc::downgrade(&wallet)); + + let (stop_sender, stop_receiver) = tokio::sync::watch::channel(()); + let chain_source = Arc::clone(&wallet.chain_source); + let loop_task = tokio::spawn(async move { + chain_source.continuously_process_broadcast_queue(stop_receiver).await + }); + + // Three funding transactions paying the wallet, so each classification reaches the + // payment-store write. + let funding_tx = |input_byte: u8| { + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: Txid::from_byte_array([input_byte; 32]), + vout: 0, + }, + ..Default::default() + }], + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + } + }; + let retried_tx = funding_tx(1); + let parked_tx = funding_tx(2); + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let funding_type = LdkTransactionType::Funding { + channels: vec![(counterparty_node_id, ChannelId([7u8; 32]))], + }; + + // The first package fails classification and is scheduled to retry after the delay. + gated_store.fail_writes.store(true, Ordering::Release); + wallet.broadcaster.broadcast_transactions(&[(&retried_tx, funding_type.clone())]); + let mut failed_writes = 0; + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + failed_writes = gated_store.failed_writes.load(Ordering::Acquire); + if failed_writes > 0 { + break; + } + } + assert!(failed_writes > 0, "classification never attempted a payment-store write"); + + // The second package parks in its payment-store write, holding the loop past the retry's + // deadline. + gated_store.fail_writes.store(false, Ordering::Release); + gated_store.gate_writes.store(true, Ordering::Release); + wallet.broadcaster.broadcast_transactions(&[(&parked_tx, funding_type.clone())]); + gated_store.write_entered.notified().await; + + // Fresh packages queue while the retry falls due: eight, so that a select polling its arms + // in random order passes the assertion below by chance about one run in twenty-five. + let fresh_txs: Vec = + (3..11).map(|input_byte| funding_tx(input_byte)).collect(); + for fresh_tx in &fresh_txs { + wallet.broadcaster.broadcast_transactions(&[(fresh_tx, funding_type.clone())]); + } + tokio::time::sleep(crate::chain::FAILED_CLASSIFY_RETRY_DELAY + Duration::from_millis(500)) + .await; + + // Once the parked package completes, the fresh packages and the due retry are all ready. + gated_store.gate_writes.store(false, Ordering::Release); + gated_store.release.notify_one(); + let expected_writes = fresh_txs.len() + 2; + let mut payment_writes = Vec::new(); + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + payment_writes = gated_store.written_keys(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + if payment_writes.len() >= expected_writes { + break; + } + } + assert_eq!(payment_writes.len(), expected_writes, "not every package was classified"); + let position = |tx: &Transaction| { + let key = PaymentId(tx.compute_txid().to_byte_array()).encode_to_hex_str(); + payment_writes.iter().position(|written| *written == key).expect("classified") + }; + let retried_position = position(&retried_tx); + assert!( + fresh_txs.iter().all(|fresh_tx| position(fresh_tx) < retried_position), + "the due retry was classified before a fresh package" + ); + + stop_sender.send(()).unwrap(); + loop_task.await.unwrap(); + } + /// Barrier test, classification-first ordering: wallet sync's confirmation handling must /// wait for classification's two-store write pair. Classification is parked between its /// payment-store and pending-store writes (the torn window) and only then is the From a76d7dcd8825c786a2d35b1d4ce05e92ad139b89 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 17 Sep 2026 11:16:03 -0500 Subject: [PATCH 04/37] f - Retry funding-broadcast classification instead of dropping it Name a package's set of txids for what it is: an identity for deduplication and logging, not a broadcast order like the topologically sorted transactions next to it. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/chain/mod.rs | 6 +++--- src/tx_broadcaster.rs | 30 +++++++++++++++--------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 1a12c2d6a..a909f2486 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -642,21 +642,21 @@ impl ChainSource { log_error!( self.logger, "Dropped the oldest package awaiting a classification retry; LDK re-broadcasts its transactions periodically: {:?}", - dropped.sorted_txids(), + dropped.txids(), ); }, ScheduleOutcome::AlreadyQueued(duplicate) => { log_debug!( self.logger, "Dropped a re-broadcast package; an identical one already awaits a classification retry: {:?}", - duplicate.sorted_txids(), + duplicate.txids(), ); }, ScheduleOutcome::Refused(package) => { log_error!( self.logger, "Dropped a package failing classification; too many await retries: {:?}", - package.sorted_txids(), + package.txids(), ); }, } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 3e5b846da..49b8ca448 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -5,7 +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::VecDeque; +use std::collections::{BTreeSet, VecDeque}; use std::ops::Deref; use std::sync::{Mutex as StdMutex, Weak}; @@ -57,12 +57,10 @@ impl BroadcastPackage { SortedTransactions::sort_parents_child_package_topologically(txs) } - /// The packaged transactions' txids in sorted order, identifying the package's effect on - /// chain: two packages with the same txids broadcast the same transactions. - pub(crate) fn sorted_txids(&self) -> Vec { - let mut txids: Vec = self.0.iter().map(|(tx, _)| tx.compute_txid()).collect(); - txids.sort_unstable(); - txids + /// The txids of the packaged transactions, identifying the package's effect on chain: two + /// packages with the same txids broadcast the same transactions. + pub(crate) fn txids(&self) -> BTreeSet { + self.0.iter().map(|(tx, _)| tx.compute_txid()).collect() } /// Whether the package may be dropped to keep [`RetryQueue`] within its bound: every @@ -114,7 +112,7 @@ pub(crate) enum ScheduleOutcome { /// accumulate without bound and replay as a burst on recovery. An identical copy is never queued /// twice — the waiting entry and its deadline stand; fee-bumped rebroadcast variants carry new /// txids, so the bound — not the dedup — is what limits their accumulation. -pub(crate) struct RetryQueue(VecDeque<(Instant, Vec, BroadcastPackage)>); +pub(crate) struct RetryQueue(VecDeque<(Instant, BTreeSet, BroadcastPackage)>); impl RetryQueue { pub(crate) fn new() -> Self { @@ -138,7 +136,7 @@ impl RetryQueue { pub(crate) fn schedule( &mut self, package: BroadcastPackage, retry_at: Instant, ) -> ScheduleOutcome { - let txids = package.sorted_txids(); + let txids = package.txids(); if self.0.iter().any(|(_, waiting, _)| *waiting == txids) { // Same transactions, same classification outcome: keep the waiting entry and its // earlier deadline. The one same-txid package with a *different* type is LDK's @@ -286,6 +284,8 @@ where #[cfg(test)] mod tests { + use std::collections::BTreeSet; + use bitcoin::hashes::Hash; use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; @@ -512,9 +512,9 @@ mod tests { )); let popped = retries.pop_next().expect("first package"); - assert_eq!(popped.sorted_txids(), vec![tx_a.compute_txid()]); + assert_eq!(popped.txids(), BTreeSet::from([tx_a.compute_txid()])); let popped = retries.pop_next().expect("second package"); - assert_eq!(popped.sorted_txids(), vec![tx_b.compute_txid()]); + assert_eq!(popped.txids(), BTreeSet::from([tx_b.compute_txid()])); } /// Distinct transactions (e.g. fee-bumped claim variants during a store outage) are held to @@ -550,7 +550,7 @@ mod tests { let new_claim = numbered_tx(MAX_QUEUED_RETRIES as u32); match retries.schedule(BroadcastPackage::unclassified(new_claim.clone()), deadline(2)) { ScheduleOutcome::Scheduled { dropped: Some(dropped) } => { - assert_eq!(dropped.sorted_txids(), vec![oldest_claim.compute_txid()]); + assert_eq!(dropped.txids(), BTreeSet::from([oldest_claim.compute_txid()])); }, _ => panic!("the incoming claim must be scheduled by dropping the oldest one"), } @@ -564,7 +564,7 @@ mod tests { let mut remaining = Vec::new(); while let Some(package) = retries.pop_next() { - remaining.extend(package.sorted_txids()); + remaining.extend(package.txids()); } assert!(remaining.contains(&funding_tx.compute_txid()), "funding is never dropped"); assert!(remaining.contains(&new_claim.compute_txid())); @@ -632,7 +632,7 @@ mod tests { let new_claim = numbered_tx(MAX_QUEUED_RETRIES as u32); match retries.schedule(claim_package(&new_claim), deadline(2)) { ScheduleOutcome::Scheduled { dropped: Some(dropped) } => { - assert_eq!(dropped.sorted_txids(), vec![oldest_claim.compute_txid()]); + assert_eq!(dropped.txids(), BTreeSet::from([oldest_claim.compute_txid()])); }, _ => panic!("the incoming claim must be scheduled by dropping the oldest one"), } @@ -646,7 +646,7 @@ mod tests { let mut remaining = Vec::new(); while let Some(package) = retries.pop_next() { - remaining.extend(package.sorted_txids()); + remaining.extend(package.txids()); } assert!( remaining.contains(&coop_close_tx.compute_txid()), From 613afea8dd01b39f860a1f373bb1a784a22da450 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 22 Sep 2026 09:01:37 -0700 Subject: [PATCH 05/37] f - Retry funding-broadcast classification instead of dropping it Queue fresh packages and those awaiting a classification retry together, in the broadcaster rather than in the task draining them. One queue means one bound and one rule for what may be dropped at it: until now a full queue dropped whichever package arrived next, a funding or a cooperative close included, while only the retries spared the packages nothing re-broadcasts. A re-broadcast of a package awaiting a retry is recognized as it is queued, rather than after one more failed write and error log per copy over a store outage. And since the queue outlives the task, a package awaiting a retry when the node stops is classified and broadcast after the next start, as a package not yet attempted always was; a funding package has no other way back. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/chain/mod.rs | 48 +--- src/tx_broadcaster.rs | 588 ++++++++++++++++++++++++++++++------------ src/wallet/mod.rs | 123 +++++++-- 3 files changed, 538 insertions(+), 221 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index a909f2486..e34d067b1 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -37,7 +37,7 @@ use crate::config::{BackgroundSyncConfig, Config, WALLET_SYNC_INTERVAL_MINIMUM_S use crate::fee_estimator::OnchainFeeEstimator; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::runtime::Runtime; -use crate::tx_broadcaster::{BroadcastPackage, RetryQueue, ScheduleOutcome}; +use crate::tx_broadcaster::BroadcastPackage; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -606,19 +606,11 @@ impl ChainSource { pub(crate) async fn continuously_process_broadcast_queue( &self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>, ) { - let mut receiver = self.tx_broadcaster.get_broadcast_queue().await; - // Packages whose classification failed, each waiting out FAILED_CLASSIFY_RETRY_DELAY - // before its next attempt. New packages keep flowing while these wait, and pending - // retries die with the loop on shutdown rather than resurfacing after a later start. - let mut retries = RetryQueue::new(); loop { - let next_retry_at = retries.next_retry_at(); let package = tokio::select! { - // Polled in order: a stop request first, then a fresh package, and a due retry - // only when neither is ready, so retries never hold back the broadcasts that - // keep arriving during a store outage. The retry keeps its delay regardless: - // without it, an empty channel would retry a fast-failing store back to back, - // logging an error each time. + // A stop request is polled first, so a queue that always has a package ready + // cannot starve it. Which package comes next — a fresh one before a due retry — + // is decided in `BroadcastQueue::next`. biased; _ = stop_tx_bcast_receiver.changed() => { log_debug!( @@ -627,39 +619,11 @@ impl ChainSource { ); return; } - Some(next_package) = receiver.recv() => next_package, - _ = tokio::time::sleep_until( - next_retry_at.unwrap_or_else(tokio::time::Instant::now) - ), if next_retry_at.is_some() => { - retries.pop_next().expect("a retry is queued") - } + package = self.tx_broadcaster.next_package() => package, }; if let Err(package) = self.classify_and_broadcast(package).await { let retry_at = tokio::time::Instant::now() + FAILED_CLASSIFY_RETRY_DELAY; - match retries.schedule(package, retry_at) { - ScheduleOutcome::Scheduled { dropped: None } => {}, - ScheduleOutcome::Scheduled { dropped: Some(dropped) } => { - log_error!( - self.logger, - "Dropped the oldest package awaiting a classification retry; LDK re-broadcasts its transactions periodically: {:?}", - dropped.txids(), - ); - }, - ScheduleOutcome::AlreadyQueued(duplicate) => { - log_debug!( - self.logger, - "Dropped a re-broadcast package; an identical one already awaits a classification retry: {:?}", - duplicate.txids(), - ); - }, - ScheduleOutcome::Refused(package) => { - log_error!( - self.logger, - "Dropped a package failing classification; too many await retries: {:?}", - package.txids(), - ); - }, - } + self.tx_broadcaster.retry_package(package, retry_at); } } } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 49b8ca448..46af8ba2e 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -13,21 +13,20 @@ use bitcoin::{Transaction, Txid}; use lightning::chain::chaininterface::{ BroadcasterInterface, TransactionType as LdkTransactionType, }; -use tokio::sync::{mpsc, Mutex, MutexGuard}; +use tokio::sync::Notify; use tokio::time::Instant; -use crate::logger::{log_error, LdkLogger}; +use crate::logger::{log_debug, log_error, LdkLogger}; use crate::types::Wallet; use crate::Error; -const BCAST_PACKAGE_QUEUE_SIZE: usize = 256; - -/// The most droppable packages [`RetryQueue`] holds. Claims and sweeps re-enter the broadcast -/// queue on LDK's periodic rebroadcast timers, so one dropped here resurfaces on its own once -/// the store recovers. Packages nothing re-broadcasts — fundings and cooperative closes — -/// don't count against the bound: they are finite — one per negotiated funding candidate and -/// one per closing channel, since a copy of a waiting package is never queued twice. -const MAX_QUEUED_RETRIES: usize = BCAST_PACKAGE_QUEUE_SIZE; +/// The most packages [`BroadcastQueue`] holds, fresh and awaiting a retry together. Claims and +/// sweeps re-enter the queue on LDK's periodic rebroadcast timers, so one dropped at the bound +/// resurfaces on its own once the store recovers. Packages nothing re-broadcasts — fundings and +/// cooperative closes — are never dropped or refused for the bound, though they count toward it: +/// what LDK hands over of them is finite — one per negotiated funding candidate and one per +/// closing channel — and a copy of a package awaiting a retry is never queued twice. +const MAX_QUEUED_PACKAGES: usize = 256; /// 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 @@ -63,7 +62,7 @@ impl BroadcastPackage { self.0.iter().map(|(tx, _)| tx.compute_txid()).collect() } - /// Whether the package may be dropped to keep [`RetryQueue`] within its bound: every + /// Whether the package may be dropped to keep [`BroadcastQueue`] within its bound: every /// transaction in it is re-broadcast by its originator, so a dropped package resurfaces on /// its own. LDK re-hands claims, anchor bumps, and force-close commitments to the /// broadcaster periodically, and the sweeper regenerates sweeps once per block. Nothing @@ -84,84 +83,162 @@ impl BroadcastPackage { | LdkTransactionType::Claim { .. } | LdkTransactionType::Sweep { .. }, ) => true, - // Wallet-originated: re-submitted on chain tip changes. Never queued anyway, since - // classification of an untyped package is a no-op that can't fail. + // Wallet-originated: the wallet re-submits its unconfirmed transactions on each chain + // tip change. Classification of an untyped package is a no-op that can't fail, so one + // never awaits a retry. None => true, }) } } -/// What [`RetryQueue::schedule`] did with a package, so the caller can log the cases in which -/// the package won't be retried as-is. -pub(crate) enum ScheduleOutcome { - /// The package waits for its retry deadline. When the bound was reached, the oldest waiting - /// droppable package was dropped to make room and is returned — its transactions resurface - /// with LDK's next periodic rebroadcast. - Scheduled { dropped: Option }, - /// A package broadcasting the same transactions already waits, and its retry covers this - /// one: the incoming package is dropped and returned. +/// What [`BroadcastQueue`] did with a package, so the caller can log the cases in which the +/// package won't be classified and broadcast as-is. +pub(crate) enum QueueOutcome { + /// The package is queued. When the bound was reached, the oldest droppable package was + /// dropped to make room and is returned — its transactions resurface with LDK's next + /// periodic rebroadcast. + Queued { dropped: Option }, + /// A package broadcasting the same transactions already awaits a classification retry, and + /// that retry covers this one: the incoming package is dropped and returned. AlreadyQueued(BroadcastPackage), - /// The bound was reached and every waiting package is one that must not be dropped (a + /// The bound was reached and every queued package is one that must not be dropped (a /// funding or a cooperative close): the incoming package is refused and returned. Refused(BroadcastPackage), } -/// Packages whose classification failed, each waiting out a retry delay before its next attempt. +/// The packages handed to the broadcaster, waiting for the background task to classify and +/// broadcast them: fresh packages in arrival order, and packages whose classification failed, +/// each waiting out a retry delay. One queue holds both, so one bound and one rule for what may +/// be dropped at it cover fresh packages and retries alike, and a re-broadcast of a package +/// awaiting a retry is recognized as it is queued rather than after one more failed attempt. +/// /// Deduplicated and bounded: LDK re-broadcasts pending claims every 30 seconds (and sweeps once /// per block) until they confirm, so while the store is unavailable, copies would otherwise /// accumulate without bound and replay as a burst on recovery. An identical copy is never queued -/// twice — the waiting entry and its deadline stand; fee-bumped rebroadcast variants carry new -/// txids, so the bound — not the dedup — is what limits their accumulation. -pub(crate) struct RetryQueue(VecDeque<(Instant, BTreeSet, BroadcastPackage)>); +/// while one awaits a retry — the waiting entry and its deadline stand; fee-bumped rebroadcast +/// variants carry new txids, so the bound — not the dedup — is what limits their accumulation. +/// +/// The queue belongs to the broadcaster and outlives the task draining it: what is queued when +/// the node stops, fresh or awaiting a retry, is classified and broadcast after the next start. +pub(crate) struct BroadcastQueue { + state: StdMutex, + /// Wakes the draining task when a package is queued. + notify: Notify, +} + +struct QueueState { + /// Packages not yet attempted, in arrival order. + fresh: VecDeque, + /// Packages whose classification failed, with their txids and retry deadlines. Retries are + /// scheduled with a fixed delay, so the front entry is always the next to fall due. + retries: VecDeque<(Instant, BTreeSet, BroadcastPackage)>, +} -impl RetryQueue { +impl BroadcastQueue { pub(crate) fn new() -> Self { - Self(VecDeque::new()) + let state = QueueState { fresh: VecDeque::new(), retries: VecDeque::new() }; + Self { state: StdMutex::new(state), notify: Notify::new() } } - /// The deadline of the next retry, if a package is waiting. Packages are scheduled with a fixed - /// delay, so the front entry is always the next to retry. - pub(crate) fn next_retry_at(&self) -> Option { - self.0.front().map(|(deadline, _, _)| *deadline) + /// Queues a fresh package, unless a package with the same transactions already awaits a + /// retry or accepting it would exceed [`MAX_QUEUED_PACKAGES`] with no droppable package to + /// make room with; see [`QueueOutcome`]. + pub(crate) fn push(&self, package: BroadcastPackage) -> QueueOutcome { + self.admit(package, None) } - /// Removes and returns the package scheduled to retry first. - pub(crate) fn pop_next(&mut self) -> Option { - self.0.pop_front().map(|(_, _, package)| package) + /// Queues a package whose classification failed, to be attempted again at `retry_at`, under + /// the same conditions as [`Self::push`]. + pub(crate) fn retry(&self, package: BroadcastPackage, retry_at: Instant) -> QueueOutcome { + self.admit(package, Some(retry_at)) } - /// Schedules a package to retry at `retry_at`, unless a package with the same transactions already - /// waits or accepting it would exceed [`MAX_QUEUED_RETRIES`] with no droppable package to - /// make room with; see [`ScheduleOutcome`]. - pub(crate) fn schedule( - &mut self, package: BroadcastPackage, retry_at: Instant, - ) -> ScheduleOutcome { + fn admit(&self, package: BroadcastPackage, retry_at: Option) -> QueueOutcome { + let outcome = self.state.lock().expect("lock").admit(package, retry_at); + if matches!(outcome, QueueOutcome::Queued { .. }) { + self.notify.notify_one(); + } + outcome + } + + /// The next package to classify and broadcast: a fresh package if any is queued, otherwise + /// the retry whose deadline has passed, waiting for one or the other when neither is ready. + /// Fresh packages go first so broadcasts arriving during a store outage are never held back + /// by the outage's retries. A retry keeps its delay regardless: without it, an otherwise idle + /// queue would retry a fast-failing store back to back, logging an error each time. + /// + /// Safe to drop before completion: a package leaves the queue only as the future completes. + pub(crate) async fn next(&self) -> BroadcastPackage { + loop { + let next_deadline = { + let mut state = self.state.lock().expect("lock"); + if let Some(package) = state.fresh.pop_front() { + return package; + } + match state.retries.front() { + Some((deadline, _, _)) if *deadline <= Instant::now() => { + let (_, _, package) = state.retries.pop_front().expect("front entry"); + return package; + }, + Some((deadline, _, _)) => Some(*deadline), + None => None, + } + }; + // A package queued between the check above and the wait below is not missed: with + // no task waiting, `notify_one` stores a permit that completes the next `notified`. + match next_deadline { + Some(deadline) => { + tokio::select! { + _ = self.notify.notified() => {}, + _ = tokio::time::sleep_until(deadline) => {}, + } + }, + None => self.notify.notified().await, + } + } + } +} + +impl QueueState { + fn admit(&mut self, package: BroadcastPackage, retry_at: Option) -> QueueOutcome { let txids = package.txids(); - if self.0.iter().any(|(_, waiting, _)| *waiting == txids) { + if self.retries.iter().any(|(_, waiting, _)| *waiting == txids) { // Same transactions, same classification outcome: keep the waiting entry and its // earlier deadline. The one same-txid package with a *different* type is LDK's // re-typed generic-funding rebroadcast of a promoted 0conf splice, which always // arrives after the interactive-funding original (the zero-conf rebroadcast canary // tests assert that ordering), so the entry kept is the richer of the two — and its // classification declines the downgrade anyway. - return ScheduleOutcome::AlreadyQueued(package); + return QueueOutcome::AlreadyQueued(package); } let mut dropped = None; - if package.is_droppable() && self.0.len() >= MAX_QUEUED_RETRIES { - // Drop the oldest droppable package: its transactions are re-broadcast - // periodically, while the incoming package may carry a fresher fee-bumped variant. - // A funding package is never dropped — nothing would re-broadcast it, and losing it - // leaves its transaction confirming without a recorded candidate. Neither is a - // cooperative close, whose queued package may hold the only copy of the signed - // closing transaction. - match self.0.iter().position(|(_, _, waiting)| waiting.is_droppable()) { - Some(oldest) => dropped = self.0.remove(oldest).map(|(_, _, package)| package), - None => return ScheduleOutcome::Refused(package), + if package.is_droppable() && self.fresh.len() + self.retries.len() >= MAX_QUEUED_PACKAGES { + // Drop the oldest droppable package, a waiting retry before a fresh package: its + // transactions are re-broadcast periodically, while the incoming package may carry + // a fresher fee-bumped variant. A funding package is never dropped — nothing would + // re-broadcast it, and losing it leaves its transaction confirming without a + // recorded candidate. Neither is a cooperative close, whose queued package may hold + // the only copy of the signed closing transaction. + dropped = self.drop_oldest_droppable(); + if dropped.is_none() { + return QueueOutcome::Refused(package); } } - self.0.push_back((retry_at, txids, package)); - ScheduleOutcome::Scheduled { dropped } + match retry_at { + Some(retry_at) => self.retries.push_back((retry_at, txids, package)), + None => self.fresh.push_back(package), + } + QueueOutcome::Queued { dropped } + } + + fn drop_oldest_droppable(&mut self) -> Option { + if let Some(oldest) = self.retries.iter().position(|(_, _, waiting)| waiting.is_droppable()) + { + return self.retries.remove(oldest).map(|(_, _, package)| package); + } + let oldest = self.fresh.iter().position(|waiting| waiting.is_droppable())?; + self.fresh.remove(oldest) } } @@ -212,11 +289,10 @@ pub(crate) struct TransactionBroadcaster where L::Target: LdkLogger, { - queue_sender: mpsc::Sender, - queue_receiver: Mutex>, + queue: BroadcastQueue, /// 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. + /// during which broadcasts are queued but no payment record is written. /// [`Self::set_wallet`] installs the handle once the [`Wallet`] exists. wallet: StdMutex>>, logger: L, @@ -227,13 +303,7 @@ where L::Target: LdkLogger, { 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), - wallet: StdMutex::new(None), - logger, - } + Self { queue: BroadcastQueue::new(), wallet: StdMutex::new(None), logger } } /// Installs the [`Wallet`] handle used to classify funding broadcasts (channel opens and @@ -243,10 +313,46 @@ where *self.wallet.lock().expect("lock") = Some(wallet); } - pub(crate) async fn get_broadcast_queue( - &self, - ) -> MutexGuard<'_, mpsc::Receiver> { - self.queue_receiver.lock().await + /// The next queued package to classify and broadcast, waiting for one when none is ready; + /// see [`BroadcastQueue::next`]. + pub(crate) async fn next_package(&self) -> BroadcastPackage { + self.queue.next().await + } + + /// Queues a package whose classification failed, to be attempted again at `retry_at`. + pub(crate) fn retry_package(&self, package: BroadcastPackage, retry_at: Instant) { + self.log_dropped(self.queue.retry(package, retry_at)); + } + + fn queue_package(&self, package: BroadcastPackage) { + self.log_dropped(self.queue.push(package)); + } + + fn log_dropped(&self, outcome: QueueOutcome) { + match outcome { + QueueOutcome::Queued { dropped: None } => {}, + QueueOutcome::Queued { dropped: Some(dropped) } => { + log_error!( + self.logger, + "Dropped the oldest queued package to make room; its transactions are re-broadcast periodically: {:?}", + dropped.txids(), + ); + }, + QueueOutcome::AlreadyQueued(duplicate) => { + log_debug!( + self.logger, + "Dropped a re-broadcast package; an identical one already awaits a classification retry: {:?}", + duplicate.txids(), + ); + }, + QueueOutcome::Refused(package) => { + log_error!( + self.logger, + "Dropped a package; too many packages await classification and broadcast: {:?}", + package.txids(), + ); + }, + } } /// Classifies a queued package into payment records. Returns `Err` if any classification @@ -265,9 +371,7 @@ where } pub(crate) fn broadcast_unclassified_transaction(&self, tx: Transaction) { - self.queue_sender.try_send(BroadcastPackage::unclassified(tx)).unwrap_or_else(|e| { - log_error!(self.logger, "Failed to broadcast transactions: {}", e); - }); + self.queue_package(BroadcastPackage::unclassified(tx)); } } @@ -276,9 +380,7 @@ where L::Target: LdkLogger, { fn broadcast_transactions(&self, txs: &[(&Transaction, LdkTransactionType)]) { - self.queue_sender.try_send(BroadcastPackage::new(txs)).unwrap_or_else(|e| { - log_error!(self.logger, "Failed to broadcast transactions: {}", e); - }); + self.queue_package(BroadcastPackage::new(txs)); } } @@ -290,8 +392,8 @@ mod tests { use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; use super::{ - BroadcastPackage, LdkTransactionType, RetryQueue, ScheduleOutcome, SortedTransactions, - MAX_QUEUED_RETRIES, + BroadcastPackage, BroadcastQueue, LdkTransactionType, QueueOutcome, SortedTransactions, + MAX_QUEUED_PACKAGES, }; fn txin(txid: Txid, vout: u32) -> TxIn { @@ -470,58 +572,174 @@ mod tests { tokio::time::Instant::now() + std::time::Duration::from_secs(secs) } - /// A re-broadcast of the same transactions is not queued again: the waiting entry keeps its - /// earlier deadline and its package — the first arrival carries the richer classification - /// when LDK later re-types a rebroadcast. + /// A due retry: `next` hands out a retry once its deadline has passed. + fn due() -> tokio::time::Instant { + tokio::time::Instant::now() + } + + /// Everything `next` hands out before the queue goes quiet, in order. + async fn drain(queue: &BroadcastQueue) -> Vec { + let mut txids = Vec::new(); + while let Ok(package) = + tokio::time::timeout(std::time::Duration::from_millis(200), queue.next()).await + { + txids.extend(package.txids()); + } + txids + } + + /// While a package awaits a retry, another with the same transactions is not queued, whether + /// it arrives as a retry or fresh: the waiting entry keeps its deadline and its package. #[tokio::test] - async fn retry_queue_queues_identical_transactions_once() { + async fn identical_transactions_are_queued_once_while_a_retry_waits() { let tx = parent_tx(1); - let mut retries = RetryQueue::new(); + let queue = BroadcastQueue::new(); - let first_deadline = deadline(2); assert!(matches!( - retries.schedule(funding_package(&tx), first_deadline), - ScheduleOutcome::Scheduled { dropped: None } + queue.retry(funding_package(&tx), due()), + QueueOutcome::Queued { dropped: None } + )); + assert!(matches!( + queue.retry(BroadcastPackage::unclassified(tx.clone()), deadline(4)), + QueueOutcome::AlreadyQueued(_) )); assert!(matches!( - retries.schedule(BroadcastPackage::unclassified(tx.clone()), deadline(4)), - ScheduleOutcome::AlreadyQueued(_) + queue.push(BroadcastPackage::unclassified(tx.clone())), + QueueOutcome::AlreadyQueued(_) )); - assert_eq!(retries.next_retry_at(), Some(first_deadline)); - let kept = retries.pop_next().expect("the first package is kept"); + // The kept entry is due now; the duplicate's later deadline must not have replaced it. + let kept = tokio::time::timeout(std::time::Duration::from_secs(1), queue.next()) + .await + .expect("the waiting entry keeps its earlier deadline"); assert!( matches!(kept.transactions()[0].1, Some(LdkTransactionType::Funding { .. })), "the first-scheduled package must be kept" ); - assert!(retries.pop_next().is_none()); + assert!(drain(&queue).await.is_empty()); + } + + /// Fresh packages are not deduplicated against each other: two arrivals of the same + /// transactions before either is attempted are both classified, as with the channel before. + #[tokio::test] + async fn fresh_packages_are_not_deduplicated_against_each_other() { + let tx = parent_tx(1); + let queue = BroadcastQueue::new(); + + assert!(matches!(queue.push(funding_package(&tx)), QueueOutcome::Queued { dropped: None })); + assert!(matches!( + queue.push(BroadcastPackage::unclassified(tx.clone())), + QueueOutcome::Queued { dropped: None } + )); + assert_eq!(drain(&queue).await, vec![tx.compute_txid(), tx.compute_txid()]); + } + + /// Fresh packages go before due retries, each group in arrival order. + #[tokio::test] + async fn fresh_packages_come_before_due_retries() { + let (tx_a, tx_b, tx_c, tx_d) = (parent_tx(1), parent_tx(2), parent_tx(3), parent_tx(4)); + let queue = BroadcastQueue::new(); + + assert!(matches!( + queue.retry(BroadcastPackage::unclassified(tx_a.clone()), due()), + QueueOutcome::Queued { dropped: None } + )); + assert!(matches!( + queue.retry(BroadcastPackage::unclassified(tx_b.clone()), due()), + QueueOutcome::Queued { dropped: None } + )); + assert!(matches!( + queue.push(BroadcastPackage::unclassified(tx_c.clone())), + QueueOutcome::Queued { dropped: None } + )); + assert!(matches!( + queue.push(BroadcastPackage::unclassified(tx_d.clone())), + QueueOutcome::Queued { dropped: None } + )); + + assert_eq!( + drain(&queue).await, + vec![ + tx_c.compute_txid(), + tx_d.compute_txid(), + tx_a.compute_txid(), + tx_b.compute_txid() + ] + ); } + /// `next` waits for a package when none is queued and wakes when one is pushed. #[tokio::test] - async fn retry_queue_retries_in_schedule_order() { - let (tx_a, tx_b) = (parent_tx(1), parent_tx(2)); - let mut retries = RetryQueue::new(); + async fn next_wakes_on_a_push() { + let tx = parent_tx(1); + let queue = BroadcastQueue::new(); + + assert!(tokio::time::timeout(std::time::Duration::from_millis(100), queue.next()) + .await + .is_err()); + + let (pushed, next) = tokio::join!( + async { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + queue.push(BroadcastPackage::unclassified(tx.clone())) + }, + tokio::time::timeout(std::time::Duration::from_secs(5), queue.next()), + ); + assert!(matches!(pushed, QueueOutcome::Queued { dropped: None })); + assert_eq!(next.expect("woken by the push").txids(), BTreeSet::from([tx.compute_txid()])); + } + + /// A fresh package pushed while `next` waits out a retry's delay is handed out at once; the + /// retry keeps waiting. + #[tokio::test] + async fn next_wakes_on_a_push_while_a_retry_waits() { + let (retry_tx, fresh_tx) = (parent_tx(1), parent_tx(2)); + let queue = BroadcastQueue::new(); assert!(matches!( - retries.schedule(BroadcastPackage::unclassified(tx_a.clone()), deadline(2)), - ScheduleOutcome::Scheduled { dropped: None } + queue.retry(BroadcastPackage::unclassified(retry_tx.clone()), deadline(5)), + QueueOutcome::Queued { dropped: None } )); + let (pushed, next) = tokio::join!( + async { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + queue.push(BroadcastPackage::unclassified(fresh_tx.clone())) + }, + tokio::time::timeout(std::time::Duration::from_secs(2), queue.next()), + ); + assert!(matches!(pushed, QueueOutcome::Queued { dropped: None })); + assert_eq!( + next.expect("woken by the push before the retry falls due").txids(), + BTreeSet::from([fresh_tx.compute_txid()]) + ); + assert!(drain(&queue).await.is_empty(), "the retry was handed out before its deadline"); + } + + /// `next` holds a retry back until its deadline, then hands it out on its own. + #[tokio::test] + async fn next_waits_for_a_retry_deadline() { + let tx = parent_tx(1); + let queue = BroadcastQueue::new(); + + let retry_at = tokio::time::Instant::now() + std::time::Duration::from_secs(2); assert!(matches!( - retries.schedule(BroadcastPackage::unclassified(tx_b.clone()), deadline(2)), - ScheduleOutcome::Scheduled { dropped: None } + queue.retry(BroadcastPackage::unclassified(tx.clone()), retry_at), + QueueOutcome::Queued { dropped: None } )); + assert!(tokio::time::timeout(std::time::Duration::from_millis(500), queue.next()) + .await + .is_err()); - let popped = retries.pop_next().expect("first package"); - assert_eq!(popped.txids(), BTreeSet::from([tx_a.compute_txid()])); - let popped = retries.pop_next().expect("second package"); - assert_eq!(popped.txids(), BTreeSet::from([tx_b.compute_txid()])); + let next = tokio::time::timeout(std::time::Duration::from_secs(5), queue.next()).await; + assert!(tokio::time::Instant::now() >= retry_at, "the retry was handed out early"); + assert_eq!(next.expect("due retry").txids(), BTreeSet::from([tx.compute_txid()])); } /// Distinct transactions (e.g. fee-bumped claim variants during a store outage) are held to - /// the bound: the oldest droppable package is dropped for an incoming one, never a funding - /// package. + /// the bound across fresh and waiting packages: the oldest droppable package is dropped for + /// an incoming one, a waiting retry before a fresh package and never a funding package. #[tokio::test] - async fn retry_queue_drops_the_oldest_droppable_package_at_the_bound() { + async fn bound_drops_the_oldest_droppable_retry_before_a_fresh_package() { fn numbered_tx(n: u32) -> Transaction { Transaction { version: bitcoin::transaction::Version::TWO, @@ -531,51 +749,96 @@ mod tests { } } - let mut retries = RetryQueue::new(); + let queue = BroadcastQueue::new(); let funding_tx = numbered_tx(0); assert!(matches!( - retries.schedule(funding_package(&funding_tx), deadline(2)), - ScheduleOutcome::Scheduled { dropped: None } + queue.retry(funding_package(&funding_tx), due()), + QueueOutcome::Queued { dropped: None } )); let oldest_claim = numbered_tx(1); - for n in 1..(MAX_QUEUED_RETRIES as u32) { + let retried = MAX_QUEUED_PACKAGES as u32 / 2; + for n in 1..retried { assert!(matches!( - retries.schedule(BroadcastPackage::unclassified(numbered_tx(n)), deadline(2)), - ScheduleOutcome::Scheduled { dropped: None } + queue.retry(BroadcastPackage::unclassified(numbered_tx(n)), due()), + QueueOutcome::Queued { dropped: None } + )); + } + let oldest_fresh = numbered_tx(retried); + for n in retried..(MAX_QUEUED_PACKAGES as u32) { + assert!(matches!( + queue.push(BroadcastPackage::unclassified(numbered_tx(n))), + QueueOutcome::Queued { dropped: None } )); } - // At the bound, an incoming droppable package drops the oldest waiting one — not the - // older funding package. - let new_claim = numbered_tx(MAX_QUEUED_RETRIES as u32); - match retries.schedule(BroadcastPackage::unclassified(new_claim.clone()), deadline(2)) { - ScheduleOutcome::Scheduled { dropped: Some(dropped) } => { + // At the bound, an incoming droppable package drops the oldest waiting retry — not the + // older funding package, and not a fresh package. + let new_claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); + match queue.push(BroadcastPackage::unclassified(new_claim.clone())) { + QueueOutcome::Queued { dropped: Some(dropped) } => { assert_eq!(dropped.txids(), BTreeSet::from([oldest_claim.compute_txid()])); }, - _ => panic!("the incoming claim must be scheduled by dropping the oldest one"), + _ => panic!("the incoming claim must be queued by dropping the oldest one"), } // An incoming funding package is never dropped for the bound. - let new_funding_tx = numbered_tx(MAX_QUEUED_RETRIES as u32 + 1); + let new_funding_tx = numbered_tx(MAX_QUEUED_PACKAGES as u32 + 1); assert!(matches!( - retries.schedule(funding_package(&new_funding_tx), deadline(2)), - ScheduleOutcome::Scheduled { dropped: None } + queue.push(funding_package(&new_funding_tx)), + QueueOutcome::Queued { dropped: None } )); - let mut remaining = Vec::new(); - while let Some(package) = retries.pop_next() { - remaining.extend(package.txids()); - } + let remaining = drain(&queue).await; + assert_eq!(remaining.len(), MAX_QUEUED_PACKAGES + 1); assert!(remaining.contains(&funding_tx.compute_txid()), "funding is never dropped"); + assert!(remaining.contains(&oldest_fresh.compute_txid()), "a retry is dropped first"); assert!(remaining.contains(&new_claim.compute_txid())); + assert!(remaining.contains(&new_funding_tx.compute_txid())); assert!(!remaining.contains(&oldest_claim.compute_txid())); } - /// When only funding packages wait at the bound, an incoming droppable package is refused: - /// LDK re-broadcasts claims and sweeps periodically, while a dropped funding package would - /// leave its transaction confirming without a recorded candidate. + /// With no retry waiting, the bound falls on the fresh packages: the oldest droppable one + /// is dropped for an incoming one. #[tokio::test] - async fn retry_queue_refuses_a_droppable_package_over_waiting_funding_packages() { + async fn bound_drops_the_oldest_droppable_fresh_package() { + fn numbered_tx(n: u32) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([11u8; 32]), n)], + output: vec![txout(1_000)], + } + } + + let queue = BroadcastQueue::new(); + let oldest = numbered_tx(0); + for n in 0..(MAX_QUEUED_PACKAGES as u32) { + assert!(matches!( + queue.push(claim_package(&numbered_tx(n))), + QueueOutcome::Queued { dropped: None } + )); + } + + let new_claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); + match queue.push(claim_package(&new_claim)) { + QueueOutcome::Queued { dropped: Some(dropped) } => { + assert_eq!(dropped.txids(), BTreeSet::from([oldest.compute_txid()])); + }, + _ => panic!("the incoming claim must be queued by dropping the oldest one"), + } + + let remaining = drain(&queue).await; + assert_eq!(remaining.len(), MAX_QUEUED_PACKAGES); + assert!(!remaining.contains(&oldest.compute_txid())); + assert_eq!(remaining.last(), Some(&new_claim.compute_txid())); + } + + /// When only funding packages are queued at the bound, an incoming droppable package is + /// refused, fresh or retried: LDK re-broadcasts claims and sweeps periodically, while a + /// dropped funding package would leave its transaction confirming without a recorded + /// candidate. + #[tokio::test] + async fn bound_refuses_a_droppable_package_over_queued_funding_packages() { fn numbered_tx(n: u32) -> Transaction { Transaction { version: bitcoin::transaction::Version::TWO, @@ -585,25 +848,29 @@ mod tests { } } - let mut retries = RetryQueue::new(); - for n in 0..(MAX_QUEUED_RETRIES as u32) { + let queue = BroadcastQueue::new(); + for n in 0..(MAX_QUEUED_PACKAGES as u32) { assert!(matches!( - retries.schedule(funding_package(&numbered_tx(n)), deadline(2)), - ScheduleOutcome::Scheduled { dropped: None } + queue.retry(funding_package(&numbered_tx(n)), deadline(60)), + QueueOutcome::Queued { dropped: None } )); } - let claim = numbered_tx(MAX_QUEUED_RETRIES as u32); + let claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); assert!(matches!( - retries.schedule(BroadcastPackage::unclassified(claim), deadline(2)), - ScheduleOutcome::Refused(_) + queue.push(BroadcastPackage::unclassified(claim.clone())), + QueueOutcome::Refused(_) + )); + assert!(matches!( + queue.retry(BroadcastPackage::unclassified(claim), deadline(60)), + QueueOutcome::Refused(_) )); } /// A cooperative close is never dropped at the bound: nothing re-broadcasts it, and the /// queued package may hold the only copy of the signed closing transaction. #[tokio::test] - async fn retry_queue_never_drops_a_cooperative_close_at_the_bound() { + async fn bound_never_drops_a_cooperative_close() { fn numbered_tx(n: u32) -> Transaction { Transaction { version: bitcoin::transaction::Version::TWO, @@ -613,41 +880,38 @@ mod tests { } } - let mut retries = RetryQueue::new(); + let queue = BroadcastQueue::new(); let coop_close_tx = numbered_tx(0); assert!(matches!( - retries.schedule(coop_close_package(&coop_close_tx), deadline(2)), - ScheduleOutcome::Scheduled { dropped: None } + queue.retry(coop_close_package(&coop_close_tx), due()), + QueueOutcome::Queued { dropped: None } )); let oldest_claim = numbered_tx(1); - for n in 1..(MAX_QUEUED_RETRIES as u32) { + for n in 1..(MAX_QUEUED_PACKAGES as u32) { assert!(matches!( - retries.schedule(claim_package(&numbered_tx(n)), deadline(2)), - ScheduleOutcome::Scheduled { dropped: None } + queue.retry(claim_package(&numbered_tx(n)), due()), + QueueOutcome::Queued { dropped: None } )); } // At the bound, an incoming claim drops the oldest waiting claim — not the older // cooperative close. - let new_claim = numbered_tx(MAX_QUEUED_RETRIES as u32); - match retries.schedule(claim_package(&new_claim), deadline(2)) { - ScheduleOutcome::Scheduled { dropped: Some(dropped) } => { + let new_claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); + match queue.retry(claim_package(&new_claim), due()) { + QueueOutcome::Queued { dropped: Some(dropped) } => { assert_eq!(dropped.txids(), BTreeSet::from([oldest_claim.compute_txid()])); }, - _ => panic!("the incoming claim must be scheduled by dropping the oldest one"), + _ => panic!("the incoming claim must be queued by dropping the oldest one"), } // An incoming cooperative close is never dropped for the bound either. - let new_coop_close_tx = numbered_tx(MAX_QUEUED_RETRIES as u32 + 1); + let new_coop_close_tx = numbered_tx(MAX_QUEUED_PACKAGES as u32 + 1); assert!(matches!( - retries.schedule(coop_close_package(&new_coop_close_tx), deadline(2)), - ScheduleOutcome::Scheduled { dropped: None } + queue.push(coop_close_package(&new_coop_close_tx)), + QueueOutcome::Queued { dropped: None } )); - let mut remaining = Vec::new(); - while let Some(package) = retries.pop_next() { - remaining.extend(package.txids()); - } + let remaining = drain(&queue).await; assert!( remaining.contains(&coop_close_tx.compute_txid()), "a cooperative close is never dropped" @@ -656,11 +920,11 @@ mod tests { assert!(!remaining.contains(&oldest_claim.compute_txid())); } - /// When only cooperative closes wait at the bound, an incoming claim is refused: LDK + /// When only cooperative closes are queued at the bound, an incoming claim is refused: LDK /// re-broadcasts the claim periodically, while a dropped close would lose the only copy of /// its signed closing transaction. #[tokio::test] - async fn retry_queue_refuses_a_claim_over_waiting_cooperative_closes() { + async fn bound_refuses_a_claim_over_queued_cooperative_closes() { fn numbered_tx(n: u32) -> Transaction { Transaction { version: bitcoin::transaction::Version::TWO, @@ -670,18 +934,18 @@ mod tests { } } - let mut retries = RetryQueue::new(); - for n in 0..(MAX_QUEUED_RETRIES as u32) { + let queue = BroadcastQueue::new(); + for n in 0..(MAX_QUEUED_PACKAGES as u32) { assert!(matches!( - retries.schedule(coop_close_package(&numbered_tx(n)), deadline(2)), - ScheduleOutcome::Scheduled { dropped: None } + queue.push(coop_close_package(&numbered_tx(n))), + QueueOutcome::Queued { dropped: None } )); } - let claim = numbered_tx(MAX_QUEUED_RETRIES as u32); + let claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); assert!(matches!( - retries.schedule(claim_package(&claim), deadline(2)), - ScheduleOutcome::Refused(_) + queue.retry(claim_package(&claim), deadline(60)), + QueueOutcome::Refused(_) )); } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 61ccd3e9f..0bfb01776 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -4447,12 +4447,12 @@ mod tests { loop_task.await.unwrap(); } - /// A package awaiting a classification retry must die when the node stops. When the retry - /// was a detached task, it outlived the broadcast loop: its re-send into the still-open - /// queue succeeded after `stop()`, so a later `start()` would classify and broadcast the - /// stale package. + /// A package awaiting a classification retry survives a stop, as a package the loop has not + /// reached yet always has: the queue belongs to the broadcaster, not to the loop, so the next + /// `start()` classifies and broadcasts whatever was queued when the node stopped. A funding + /// package in particular has no other way back: no timer re-broadcasts it. #[tokio::test] - async fn failed_classification_retry_dies_at_stop() { + async fn packages_awaiting_retry_survive_a_stop() { use lightning::chain::chaininterface::BroadcasterInterface; let fail_store = FailSwitchStore::new(); @@ -4515,24 +4515,112 @@ mod tests { chain_source.continuously_process_broadcast_queue(stop_receiver).await }); - // Watch well past the retry delay: the package from before the stop must not be - // classified or broadcast by the restarted loop. - for _ in 0..40 { + // The restarted loop classifies the package from before the stop once its retry falls + // due. + let mut payments = Vec::new(); + for _ in 0..100 { tokio::time::sleep(Duration::from_millis(100)).await; - assert!( - wallet.payment_store.list_page(None).await.unwrap().objects.is_empty(), - "a package from before stop() resurfaced after restart" - ); + payments = wallet.payment_store.list_page(None).await.unwrap().objects; + if !payments.is_empty() { + break; + } + } + assert_eq!( + payments.len(), + 1, + "the package from before stop() was not classified after restart" + ); + assert!( + matches!(&payments[0].kind, PaymentKind::Onchain { txid, .. } if *txid == tx.compute_txid()), + "the record does not track the package's transaction" + ); + + stop_sender.send(()).unwrap(); + loop_task.await.unwrap(); + } + + /// A re-broadcast of a package awaiting a classification retry is dropped as it is queued, + /// without another classification attempt: LDK re-hands pending claims every 30 seconds, so + /// over a store outage each copy would otherwise cost a failed write and an error log before + /// the queue recognized it. + #[tokio::test] + async fn rebroadcast_of_a_waiting_package_is_not_classified_again() { + use lightning::chain::chaininterface::BroadcasterInterface; + + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.broadcaster.set_wallet(Arc::downgrade(&wallet)); + + let (stop_sender, stop_receiver) = tokio::sync::watch::channel(()); + let chain_source = Arc::clone(&wallet.chain_source); + let loop_task = tokio::spawn(async move { + chain_source.continuously_process_broadcast_queue(stop_receiver).await + }); + + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let funding_type = LdkTransactionType::Funding { + channels: vec![(counterparty_node_id, ChannelId([7u8; 32]))], + }; + + // The first copy fails classification and waits for its retry. + fail_store.fail_writes.store(true, Ordering::Release); + wallet.broadcaster.broadcast_transactions(&[(&tx, funding_type.clone())]); + let mut failed_writes = 0; + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + failed_writes = fail_store.failed_writes.load(Ordering::Acquire); + if failed_writes > 0 { + break; + } + } + assert_eq!(failed_writes, 1, "classification never attempted a payment-store write"); + + // A second copy arrives well within the retry delay. It must not reach the store. + wallet.broadcaster.broadcast_transactions(&[(&tx, funding_type)]); + tokio::time::sleep(Duration::from_millis(500)).await; + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 1, + "a re-broadcast of a package awaiting a retry was classified again" + ); + + // Once the store recovers, the waiting package is classified once. + fail_store.fail_writes.store(false, Ordering::Release); + let mut payments = Vec::new(); + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + payments = wallet.payment_store.list_page(None).await.unwrap().objects; + if !payments.is_empty() { + break; + } } + assert_eq!(payments.len(), 1, "the waiting package was not classified after recovery"); stop_sender.send(()).unwrap(); loop_task.await.unwrap(); } /// Fresh packages go before a due retry. Both are ready at once when the loop returns from a - /// slow classification with packages waiting in the channel and a retry past its deadline; - /// the channel is polled first until it is empty, so broadcasts arriving during a store outage - /// are never held back by the outage's retries. + /// slow classification with fresh packages queued and a retry past its deadline; the queue + /// hands out every fresh package first, so broadcasts arriving during a store outage are never + /// held back by the outage's retries. #[tokio::test] async fn fresh_package_is_classified_before_a_due_retry() { use lightning::chain::chaininterface::BroadcasterInterface; @@ -4603,8 +4691,9 @@ mod tests { wallet.broadcaster.broadcast_transactions(&[(&parked_tx, funding_type.clone())]); gated_store.write_entered.notified().await; - // Fresh packages queue while the retry falls due: eight, so that a select polling its arms - // in random order passes the assertion below by chance about one run in twenty-five. + // Fresh packages queue while the retry falls due: several, so a queue that only sometimes + // hands out a fresh package ahead of a due retry cannot pass the ordering assertion below + // by luck. let fresh_txs: Vec = (3..11).map(|input_byte| funding_tx(input_byte)).collect(); for fresh_tx in &fresh_txs { From a0d3a94490cadc5cb4717bdeb7c8a0fe906fd064 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 2 Sep 2026 13:53:47 -0500 Subject: [PATCH 06/37] Fail funding payments lost to a confirmed conflict Since declining to adopt a conflicting close's confirmation, a funding payment whose transaction was double-spent stayed Pending forever -- nothing wrote a terminal status for an on-chain record -- and the sync loop kept re-queueing the dead transaction for rebroadcast on every tip change. Mark such a record Failed once a conflict from outside its candidate history has confirmed through ANTI_REORG_DELAY while neither its own transaction nor any RBF candidate can still confirm, mirroring the anti-reorg finality the Succeeded transition already assumes. Removing the payment's pending entry then stops the re-queueing. Settling also removes the entry that maps candidate txids to the record, so a later wallet event for a dead candidate falls back to keying by that candidate's txid -- which, for the first candidate, is the record's own id. Skip such events rather than let the generic handling resurrect the settled record, and let a replayed replacement event finish an entry removal a crash interrupted instead of stamping the terminal status into the leftover entry. Implemented with Claude Code. Co-Authored-By: Claude Fable 5 --- src/wallet/mod.rs | 684 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 682 insertions(+), 2 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 0bfb01776..fe47d8678 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -12,6 +12,7 @@ use std::str::FromStr; use std::sync::{Arc, Mutex}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; +use bdk_chain::ChainPosition; use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; #[allow(deprecated)] @@ -369,6 +370,17 @@ impl Wallet { }, } + // The fallback id belongs to a settled funding payment whose entry is gone: + // skip rather than resurrect it (see `has_funding_record`). + if self.has_funding_record(&payment_id).await? { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); + continue; + } + let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -455,8 +467,16 @@ impl Wallet { txid, status: ConfirmationStatus::Unconfirmed, .. - } if payment.details.direction == PaymentDirection::Outbound => { - unconfirmed_outbound_txids.push(txid); + } => { + if self + .fail_funding_payment_lost_to_conflict(&payment, new_tip.height) + .await? + { + continue; + } + if payment.details.direction == PaymentDirection::Outbound { + unconfirmed_outbound_txids.push(txid); + } }, _ => {}, } @@ -516,6 +536,17 @@ impl Wallet { }, } + // The fallback id belongs to a settled funding payment whose entry is gone: + // skip rather than resurrect it (see `has_funding_record`). + if self.has_funding_record(&payment_id).await? { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); + continue; + } + let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -565,6 +596,18 @@ impl Wallet { payment_id, ); let payment = stored_payment.ok_or(Error::InvalidPaymentId)?; + + // A terminal record means the entry is the leftover of an interrupted settle + // — the record write landed, the entry removal was lost to a crash — and this + // event is the restart's replay of the same transition. Re-embedding the + // record would stamp the terminal status into the entry and hide it from the + // pending listing that repairs such leftovers; finish the interrupted removal + // instead. + if payment.status != PaymentStatus::Pending { + self.pending_payment_store.remove(&payment_id).await?; + continue; + } + let pending_payment_details = self.create_pending_payment_from_tx(payment, conflict_txids.clone()); @@ -598,6 +641,17 @@ impl Wallet { }, } + // The fallback id belongs to a settled funding payment whose entry is gone: + // skip rather than resurrect it (see `has_funding_record`). + if self.has_funding_record(&payment_id).await? { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); + continue; + } + let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -623,6 +677,152 @@ impl Wallet { Ok(()) } + /// Whether a funding-classified record exists under the given id. A funding record's id is + /// anchored to its first candidate's txid, so a wallet event for that transaction falls back + /// to this id whenever the pending entry no longer maps it — which only happens once the + /// negotiation settled and the entry was removed. The generic event handling must then skip + /// its write: merging a wallet-view `Pending` payment into the settled record would resurrect + /// it with figures no classification derived. + async fn has_funding_record(&self, payment_id: &PaymentId) -> Result { + Ok(self.payment_store.get(payment_id).await?.is_some_and(|payment| { + matches!( + payment.kind, + PaymentKind::Onchain { + tx_type: Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. } + ), + .. + } + ) + })) + } + + /// Fails a funding payment whose transaction has irrevocably lost a conflict: a transaction + /// outside the record's candidate history — e.g. a channel close double-spending a pending + /// splice's shared input — has confirmed through [`ANTI_REORG_DELAY`] while neither the + /// record's transaction nor any candidate is canonical anymore. Returns whether the payment + /// was failed; failing also removes the pending entry, dropping the dead record from the + /// tip-change pass. (Its transaction was already excluded from rebroadcast by the same + /// canonical-only `get_tx` gate used below.) + /// + /// Only funding-classified records are considered: nothing re-submits a replaced funding + /// transaction under the same record (an RBF round is a new candidate), so a buried foreign + /// conflict is final for them. The liveness check guards the case where the conflict + /// double-spent only one round of the negotiation: as long as some candidate — including one + /// classification hasn't recorded yet — can still confirm, the record must stay pending. + async fn fail_funding_payment_lost_to_conflict( + &self, payment: &PendingPaymentDetails, tip_height: u32, + ) -> Result { + match payment.details.kind { + PaymentKind::Onchain { + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + .. + } => {}, + _ => return Ok(false), + } + if payment.conflicting_txids.is_empty() { + return Ok(false); + } + + // Serialize with classification, whose retries extend the candidate history: the + // decision below must see that history in its settled form, and holding the lock keeps a + // concurrent write from resurrecting the entry removed at the end. + let _guard = self.funding_payment_update_lock.lock().await; + + // Re-read the entry under the lock; the listing snapshot may predate a classification. + let entry = match self.pending_payment_store.get(&payment.details.id).await? { + Some(entry) => entry, + None => return Ok(false), + }; + let record_txid = match entry.details.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + } => txid, + _ => return Ok(false), + }; + + let foreign_conflicts: Vec = entry + .conflicting_txids + .iter() + .copied() + .filter(|conflict| *conflict != record_txid && entry.candidate(*conflict).is_none()) + .collect(); + if foreign_conflicts.is_empty() { + return Ok(false); + } + + let lost = { + let locked_wallet = self.inner.lock().expect("lock"); + // `get_tx` is canonical-only: a transaction that lost to a confirmed conflict + // returns `None`, while one that can still confirm is `Some`. + let a_candidate_is_live = locked_wallet.get_tx(record_txid).is_some() + || entry.candidates.iter().any(|c| locked_wallet.get_tx(c.txid).is_some()); + !a_candidate_is_live + && foreign_conflicts.iter().any(|conflict| { + match locked_wallet.get_tx(*conflict).map(|tx| tx.chain_position) { + Some(ChainPosition::Confirmed { anchor, .. }) => { + tip_height >= anchor.block_id.height + ANTI_REORG_DELAY - 1 + }, + _ => false, + } + }) + }; + if !lost { + return Ok(false); + } + + // As with graduation, decide from the live record and write only the status. A record + // already `Failed` — a prior pass whose entry removal below was lost to a crash — still + // matches, no-ops the update, and gets its lingering entry removed. + let payment_id = entry.details.id; + let mut failed = false; + self.payment_store + .mutate(&payment_id, |existing| { + let current = existing?; + match current.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + } if txid == record_txid => { + failed = true; + let mut update = PaymentDetailsUpdate::new(payment_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = current.clone(); + updated.update(update).then_some(updated) + }, + _ => None, + } + }) + .await?; + if failed { + self.pending_payment_store.remove(&payment_id).await?; + log_info!( + self.logger, + "Failed funding payment {}: transaction {} lost to a conflicting transaction confirmed beyond the reorg depth", + payment_id, + record_txid, + ); + } + Ok(failed) + } + #[allow(deprecated)] pub(crate) async fn create_funding_transaction( &self, output_script: ScriptBuf, amount: Amount, confirmation_target: ConfirmationTarget, @@ -3799,6 +3999,57 @@ mod tests { } } + /// Inserts `tx` into the BDK wallet as canonically confirmed at `height`, extending the + /// local chain to that height. + fn insert_confirmed_tx(wallet: &Wallet, tx: Transaction, height: u32) { + let txid = tx.compute_txid(); + let mut locked = wallet.inner.lock().unwrap(); + let block = + BlockId { height, hash: bitcoin::BlockHash::from_byte_array([height as u8; 32]) }; + let chain = locked.latest_checkpoint().insert(block); + let mut tx_update = bdk_chain::TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.anchors = + [(ConfirmationBlockTime { block_id: block, confirmation_time: 100 }, txid)].into(); + locked + .apply_update(Update { tx_update, chain: Some(chain), ..Default::default() }) + .unwrap(); + } + + /// Inserts `tx` into the BDK wallet as canonically unconfirmed (seen in the mempool). + fn insert_unconfirmed_tx(wallet: &Wallet, tx: Transaction) { + let txid = tx.compute_txid(); + let mut locked = wallet.inner.lock().unwrap(); + let mut tx_update = bdk_chain::TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.seen_ats = [(txid, 100)].into(); + locked.apply_update(Update { tx_update, ..Default::default() }).unwrap(); + } + + /// Builds a transaction paying a wallet address, spending an outpoint derived from + /// `input_byte` (distinct bytes yield non-conflicting transactions). + fn wallet_paying_tx(wallet: &Wallet, input_byte: u8) -> Transaction { + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([input_byte; 32]), + vout: 0, + }, + ..Default::default() + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + } + } + #[test] fn funding_reclassification_update_substitutes_the_confirmed_candidate() { let confirmed_txid = Txid::from_byte_array([1u8; 32]); @@ -4214,6 +4465,435 @@ mod tests { } } + /// Continues the story above: once the conflicting close confirms through the anti-reorg + /// depth, the splice's funding transaction can never confirm — its shared input is spent for + /// good. The record must fail rather than stay `Pending` forever, and removing the pending + /// entry stops the dead transaction's rebroadcast on every tip change. + #[tokio::test] + async fn funding_payment_fails_once_a_foreign_conflict_confirms_to_depth() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + }) + .await + .unwrap(); + + // The close is canonically confirmed; the splice transaction, having lost the conflict, + // is no longer canonical (here: never inserted at all). + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + match &payment.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid, "failing must not adopt the conflict's txid"); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(payment.amount_msat, Some(1_000_000)); + assert_eq!(payment.fee_paid_msat, Some(500)); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none(), + "the entry must go so the dead transaction stops being rebroadcast" + ); + } + + /// A confirmed conflict that is one of the record's own candidates is RBF resolution, not a + /// loss: classification adopts it into the record, so the failure pass must leave the record + /// alone. + #[tokio::test] + async fn funding_payment_survives_a_confirmed_conflict_that_is_a_candidate() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let bumped_tx = wallet_paying_tx(&wallet, 3); + let bumped_txid = bumped_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }, + FundingTxCandidate { + txid: bumped_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + }, + ]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![bumped_txid]), + candidates: Vec::new(), + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, bumped_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some(), + "the entry must survive for classification to adopt the confirmed candidate" + ); + } + + /// A foreign conflict that has confirmed but not yet through the anti-reorg depth may still + /// be reorged out, letting the funding transaction confirm after all; the record must stay + /// pending until the conflict's confirmation is final. + #[tokio::test] + async fn funding_payment_survives_a_foreign_conflict_short_of_depth() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 2), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some()); + } + + /// A conflict may double-spend only one round of the negotiation — e.g. it shares an input + /// with an RBF attempt but not with the original candidate. While any candidate is still + /// canonical it can still confirm, so the record must stay pending. + #[tokio::test] + async fn funding_payment_survives_while_a_candidate_can_still_confirm() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let conflict_tx = wallet_paying_tx(&wallet, 3); + let conflict_txid = conflict_tx.compute_txid(); + // A live candidate: spends a different outpoint, so the conflict didn't kill it. + let live_candidate_tx = wallet_paying_tx(&wallet, 4); + let live_candidate_txid = live_candidate_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }, + FundingTxCandidate { + txid: live_candidate_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + }, + ]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![conflict_txid]), + candidates: Vec::new(), + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, conflict_tx, 5); + insert_unconfirmed_tx(&wallet, live_candidate_tx); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some(), + "a candidate can still confirm, so the record must stay pending" + ); + } + + /// The failure write pair is record first, entry second: a crash in between leaves a + /// `Failed` record with a lingering entry. The next tip pass must finish the job — remove + /// the entry without disturbing the record. + #[tokio::test] + async fn a_failed_funding_payment_with_a_lingering_entry_is_cleaned_up() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut recorded = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_store.insert_or_update(recorded).await.unwrap(); + + // The entry embeds the pre-failure snapshot, as a crash between the two writes leaves it. + let snapshot = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the repair pass must not rewrite"); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none(), + "the lingering entry must be removed" + ); + } + + /// A crash between the failure's record write and its entry removal loses the wallet + /// changeset too, so the restart's catch-up sync replays the same events: `TxReplaced` for + /// the dead funding transaction resolves through the lingering entry to the already-`Failed` + /// record. Re-embedding that record would stamp `Failed` into the entry and hide it from the + /// pending listing that repairs it; the replay must instead finish the interrupted removal. + #[tokio::test] + async fn replayed_replacement_finishes_an_interrupted_failure() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut recorded = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_store.insert_or_update(recorded).await.unwrap(); + + let snapshot = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let events = vec![ + WalletEvent::TxReplaced { + txid: splice_txid, + tx: Arc::new(dummy_tx()), + conflicts: vec![(0, close_txid)], + }, + WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }, + ]; + wallet.update_payment_store(events).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the replay must not rewrite the record"); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none(), + "the replay must finish the interrupted entry removal" + ); + } + + /// A funding record's id is anchored to its first candidate's txid. Once the payment settles + /// and its entry is removed, a wallet event for that candidate no longer resolves through the + /// candidate history — the fallback keys it by its own txid, colliding with the record's id. + /// Recording the event there would merge a fresh wallet-view `Pending` payment into the + /// terminal record; such events must be skipped. + #[tokio::test] + async fn candidate_event_does_not_resurrect_a_settled_funding_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // The record's id derives from the first candidate r1; its txid rotated to the RBF round + // r2. The payment failed and its pending entry is gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let r2 = Txid::from_byte_array([4u8; 32]); + let payment_id = PaymentId(r1.to_byte_array()); + let mut recorded = interactive_funding_details(payment_id, r2, Some(1_000_000), Some(600)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_store.insert_or_update(recorded).await.unwrap(); + + // r1 reappears in the mempool after the failure... + let event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + + // ...and even confirms: the record settled as `Failed` and must stay that way. + let event = WalletEvent::TxConfirmed { + txid: r1, + tx: Arc::new(dummy_tx()), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + } + + /// The failure transition must apply regardless of the payment's direction: a splice-out + /// records as `Inbound` (funds return to the wallet) and dies to a conflicting close the + /// same way an outbound one does. + #[tokio::test] + async fn inbound_funding_payment_fails_once_a_foreign_conflict_confirms_to_depth() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let mut details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + details.direction = PaymentDirection::Inbound; + wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + } + /// A funding-typed broadcast that doesn't touch the on-chain wallet must not be recorded. /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding /// path, so a splice the interactive-funding classification deliberately declined — no local From c71ce2f8f85d1f634ffb104e78694f090b289880 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 17 Sep 2026 11:21:45 -0500 Subject: [PATCH 07/37] f - Fail funding payments lost to a confirmed conflict Drop the extra payment store read per synced transaction. The funding-status check has already read the record under the resolved id, so when that id is the transaction's own, whether a settled funding record sits there is known without another read. Only a fallback from a different id still reads, and a transaction the pending store maps to no funding record reads nothing more before the generic write. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 254 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 196 insertions(+), 58 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index fe47d8678..d485bd787 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -364,23 +364,23 @@ impl Wallet { FundingStatusUpdate::Applied => continue, FundingStatusUpdate::NotFunding => {}, // Not part of the funding payment's history (e.g. a close spending the - // funding outpoint): record it under its own id below instead. + // funding outpoint): record it under its own id below instead, unless a + // settled funding payment sits there already. FundingStatusUpdate::Foreign => { - payment_id = PaymentId(txid.to_byte_array()); + match self.foreign_transaction_payment_id(payment_id, txid).await? { + Some(fallback_id) => payment_id = fallback_id, + None => { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); + continue; + }, + } }, } - // The fallback id belongs to a settled funding payment whose entry is gone: - // skip rather than resurrect it (see `has_funding_record`). - if self.has_funding_record(&payment_id).await? { - log_debug!( - self.logger, - "Skipping wallet event for transaction {} of a settled funding payment", - txid, - ); - continue; - } - let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -530,23 +530,23 @@ impl Wallet { FundingStatusUpdate::Applied => continue, FundingStatusUpdate::NotFunding => {}, // Not part of the funding payment's history (e.g. a close spending the - // funding outpoint): record it under its own id below instead. + // funding outpoint): record it under its own id below instead, unless a + // settled funding payment sits there already. FundingStatusUpdate::Foreign => { - payment_id = PaymentId(txid.to_byte_array()); + match self.foreign_transaction_payment_id(payment_id, txid).await? { + Some(fallback_id) => payment_id = fallback_id, + None => { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); + continue; + }, + } }, } - // The fallback id belongs to a settled funding payment whose entry is gone: - // skip rather than resurrect it (see `has_funding_record`). - if self.has_funding_record(&payment_id).await? { - log_debug!( - self.logger, - "Skipping wallet event for transaction {} of a settled funding payment", - txid, - ); - continue; - } - let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -635,23 +635,23 @@ impl Wallet { FundingStatusUpdate::Applied => continue, FundingStatusUpdate::NotFunding => {}, // Not part of the funding payment's history (e.g. a close spending the - // funding outpoint): record it under its own id below instead. + // funding outpoint): record it under its own id below instead, unless a + // settled funding payment sits there already. FundingStatusUpdate::Foreign => { - payment_id = PaymentId(txid.to_byte_array()); + match self.foreign_transaction_payment_id(payment_id, txid).await? { + Some(fallback_id) => payment_id = fallback_id, + None => { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); + continue; + }, + } }, } - // The fallback id belongs to a settled funding payment whose entry is gone: - // skip rather than resurrect it (see `has_funding_record`). - if self.has_funding_record(&payment_id).await? { - log_debug!( - self.logger, - "Skipping wallet event for transaction {} of a settled funding payment", - txid, - ); - continue; - } - let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -677,25 +677,37 @@ impl Wallet { Ok(()) } - /// Whether a funding-classified record exists under the given id. A funding record's id is - /// anchored to its first candidate's txid, so a wallet event for that transaction falls back - /// to this id whenever the pending entry no longer maps it — which only happens once the - /// negotiation settled and the entry was removed. The generic event handling must then skip - /// its write: merging a wallet-view `Pending` payment into the settled record would resurrect - /// it with figures no classification derived. - async fn has_funding_record(&self, payment_id: &PaymentId) -> Result { - Ok(self.payment_store.get(payment_id).await?.is_some_and(|payment| { - matches!( - payment.kind, - PaymentKind::Onchain { - tx_type: Some( - TransactionType::Funding { .. } - | TransactionType::InteractiveFunding { .. } - ), - .. - } - ) - })) + /// The id to record a transaction under that the funding-status check found foreign to the + /// funding record resolved for it as `resolved_id`: its own txid-derived id, or `None` when a + /// funding record sits there already. A funding record's id is anchored to its first + /// candidate's txid, so a wallet event for that transaction falls back to this id whenever the + /// pending entry no longer maps it — which only happens once the negotiation settled and the + /// entry was removed. The generic event handling must then skip its write: merging a + /// wallet-view `Pending` payment into the settled record would resurrect it with figures no + /// classification derived. When `resolved_id` is the txid-derived id already, the + /// funding-status check has read that record, and finding the transaction foreign to it is + /// this very case; only a fallback from a different id needs a read. + async fn foreign_transaction_payment_id( + &self, resolved_id: PaymentId, txid: Txid, + ) -> Result, Error> { + let fallback_id = PaymentId(txid.to_byte_array()); + if resolved_id == fallback_id { + return Ok(None); + } + let has_funding_record = + self.payment_store.get(&fallback_id).await?.is_some_and(|payment| { + matches!( + payment.kind, + PaymentKind::Onchain { + tx_type: Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. } + ), + .. + } + ) + }); + Ok(if has_funding_record { None } else { Some(fallback_id) }) } /// Fails a funding payment whose transaction has irrevocably lost a conflict: a transaction @@ -2975,6 +2987,71 @@ mod tests { const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; + /// An in-memory store counting the reads it serves, by primary namespace, so tests can pin + /// how many backend reads an operation costs. + #[derive(Clone)] + struct ReadCountingStore { + inner: Arc, + reads: Arc>>, + } + + impl ReadCountingStore { + fn new() -> Self { + Self { inner: Arc::new(InMemoryStore::new()), reads: Arc::new(Mutex::new(Vec::new())) } + } + + /// The number of reads served from `primary_namespace` so far. + fn reads(&self, primary_namespace: &str) -> usize { + self.reads + .lock() + .unwrap() + .iter() + .filter(|namespace| *namespace == primary_namespace) + .count() + } + } + + impl KVStore for ReadCountingStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + self.reads.lock().unwrap().push(primary_namespace.to_string()); + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + KVStore::write(&*self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for ReadCountingStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + /// An in-memory store whose writes can be made to fail on demand, counting the failures so /// tests can wait for a write to have actually failed rather than guessing with a sleep. #[derive(Clone)] @@ -4799,6 +4876,27 @@ mod tests { ); } + /// Recording a transaction the payment store does not know costs two reads of it: the + /// funding-status check looks the resolved id up, and the generic write merges against the + /// store. Nothing in between re-reads what the funding-status check has already seen. + #[tokio::test] + async fn unknown_transaction_is_recorded_after_two_payment_store_reads() { + let counting_store = ReadCountingStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let tx = wallet_paying_tx(&wallet, 1); + let txid = tx.compute_txid(); + let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let event = WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; + + let payment_id = PaymentId(txid.to_byte_array()); + assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_some()); + assert_eq!(reads, 2, "recording an unknown transaction re-read the payment store"); + } + /// A funding record's id is anchored to its first candidate's txid. Once the payment settles /// and its entry is removed, a wallet event for that candidate no longer resolves through the /// candidate history — the fallback keys it by its own txid, colliding with the record's id. @@ -4846,6 +4944,46 @@ mod tests { assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); } + /// The same collision through a conflict list: a pending entry naming a settled funding + /// record's transaction as a conflict of its own round resolves an event for that transaction + /// to the entry's record, which finds it foreign, and the fallback to the transaction's own id + /// lands on the settled record. That id is read before anything is written under it. + #[tokio::test] + async fn conflict_listed_event_does_not_resurrect_a_settled_funding_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // A settled funding record under the txid-derived id of r1, its pending entry gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let settled_id = PaymentId(r1.to_byte_array()); + let mut settled = interactive_funding_details(settled_id, r1, Some(1_000_000), Some(600)); + settled.status = PaymentStatus::Failed; + settled.latest_update_timestamp = 7; + wallet.payment_store.insert_or_update(settled).await.unwrap(); + + // A live funding record whose entry lists r1 as a conflict of its round r2. + let r2 = Txid::from_byte_array([4u8; 32]); + let live_id = PaymentId(r2.to_byte_array()); + let live = interactive_funding_details(live_id, r2, Some(2_000_000), Some(700)); + wallet.payment_store.insert_or_update(live.clone()).await.unwrap(); + wallet + .pending_payment_store + .insert_or_update(PendingPaymentDetails::new(live.clone(), vec![r1], Vec::new())) + .await + .unwrap(); + assert_eq!(wallet.find_payment_by_txid(r1).await.unwrap(), Some(live_id)); + + let event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&settled_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the settled record must not resurrect"); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet.pending_payment_store.get(&settled_id).await.unwrap().is_none()); + assert_eq!(wallet.payment_store.get(&live_id).await.unwrap(), Some(live)); + } + /// The failure transition must apply regardless of the payment's direction: a splice-out /// records as `Inbound` (funds return to the wallet) and dies to a conflicting close the /// same way an outbound one does. From 8fb329826303e9f8ea35ba4ec2c79a6ed4a187e5 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 4 Sep 2026 21:56:15 -0500 Subject: [PATCH 08/37] Record splice funding payments when signing Wallet sync can learn of a splice transaction before broadcast-time classification records it: once tx_signatures are exchanged, the counterparty may broadcast first, and sync then files the round under a duplicate record keyed by its txid, which shadows the funding record's txid lookups from then on. Retrying a failed classification only narrows that window: a round the counterparty broadcasts is still observed before our record exists. Record the funding payment while handling FundingTransactionReadyForSigning, before funding_transaction_signed hands our signatures to LDK. The counterparty cannot broadcast without them, so the record precedes anything wallet sync can observe, and every later observer resolves to it. The record is written in full from the channel's pending splice history, so the round's broadcast has nothing left to record and records nothing. If the record cannot be written, the event is replayed rather than proceeding unrecorded: LDK re-offers it in-session and regenerates it across restarts while the transaction remains unsigned. A failed write leaves no half-written record behind for the replayed event to build on. Should undoing it fail as well, the replayed event removes what was left of a first round once the round is gone from the channel's history; the leftovers of a bump live under an earlier round's record, which wallet sync moves on as that round confirms or fails. Recording before the round is negotiated means a recorded round can still be abandoned: the counterparty may abort after we sign but before its commitment_signed, or the channel may close, and until LDK has released our signatures nothing can ever broadcast the transaction. Left in place, the record would wait forever on a payment nothing can confirm. The signed round is therefore marked as awaiting broadcast until LDK reports the splice negotiated, which it does as it hands the fully signed round to the broadcaster: from then on the counterparty holds our signatures and can broadcast on its own. If the mark cannot be cleared, that event is replayed as well. A marked round is dropped once LDK no longer holds it, unless the wallet has seen its transaction: the counterparty may broadcast a round it received our signatures for while LDK still waits on its own. A round whose negotiation LDK has reported keeps its place whether or not wallet sync has seen it yet, and so does the channel's current funding: a zero-conf splice becomes the funding as soon as splice_locked is exchanged, before its transaction confirms or LDK's report of its negotiation has necessarily been handled. Dropping a round leaves the record on the last remaining round this node contributed to, moving it there if it still names the dropped round, or removes the record when none remains. LDK's view is consulted when it reports the failed negotiation of a channel it still lists, when the channel closes -- a round awaiting the counterparty's signatures gets no failure report then, and a failure reported once the channel is gone is resolved by what this report carries, the channel's last funding, and by the rounds its monitor still watches -- and at startup, before any background task runs: LDK reports the loss of a negotiation its last channel manager write carried mid-way, but a round committed, negotiated and signed since that write gets no report if the node stops before the next one. The channel manager forgets a closed channel's pending rounds, but its monitor keeps watching every round the counterparty's commitment_signed reached, and our signatures cannot have left the node before that message: the counterparty may hold the fully signed transaction and broadcast it, as when this node's contributed input value is the smaller and its tx_signatures therefore go first, so such a round is kept for wallet sync to resolve should it confirm, while a marked round the monitor never watched is dropped, as nothing can broadcast it. A round already missing from the channel's history when the signing event is handled is not recorded at all. Rounds without a local contribution emit no signing event and are not recorded at broadcast either, as before; they are left to wallet sync. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/chain/mod.rs | 6 +- src/event.rs | 158 ++- src/lib.rs | 17 + src/payment/pending_payment_store.rs | 58 +- src/tx_broadcaster.rs | 36 +- src/wallet/mod.rs | 1627 +++++++++++++++++++++++++- tests/integration_tests_rust.rs | 269 ++++- 7 files changed, 2065 insertions(+), 106 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index e34d067b1..cf310f0bb 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -571,9 +571,9 @@ impl ChainSource { /// Classifies the package's funding broadcasts into payment records, then broadcasts it. /// Returns the package back on classification failure so the caller can retry it after a /// delay: broadcasting a tx we failed to record would leave it on-chain without a payment, - /// while dropping the package would not keep an interactively funded tx off-chain (the - /// counterparty broadcasts it regardless), only leave it confirming without a recorded - /// candidate. + /// while dropping the package would keep a funding transaction off-chain until LDK re-hands + /// it when the channel next resumes — no timer re-broadcasts it, and the wallet's tip-change + /// re-broadcast covers recorded transactions only. async fn classify_and_broadcast( &self, package: BroadcastPackage, ) -> Result<(), BroadcastPackage> { diff --git a/src/event.rs b/src/event.rs index 728f625ab..ca35ad14e 100644 --- a/src/event.rs +++ b/src/event.rs @@ -13,8 +13,9 @@ use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, OutPoint}; +use bitcoin::{Amount, OutPoint, Txid}; use lightning::blinded_path::message::NextMessageHop; +use lightning::chain::chaininterface::FundingCandidate; use lightning::events::bump_transaction::BumpTransactionEvent; #[cfg(not(feature = "uniffi"))] use lightning::events::PaidBolt12Invoice; @@ -57,8 +58,10 @@ use crate::payment::PaymentMetadata; use crate::probing::Prober; use crate::runtime::Runtime; use crate::types::{ - CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, + ChainMonitor, CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, + Wallet, }; +use crate::wallet::{closed_channel_held_rounds, funding_candidates, held_splice_rounds}; use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, UserChannelId, @@ -559,6 +562,7 @@ where wallet: Arc, bump_tx_event_handler: Arc, channel_manager: Arc, + chain_monitor: Arc, connection_manager: Arc>, output_sweeper: Arc, network_graph: Arc, @@ -583,19 +587,21 @@ where pub fn new( event_queue: Arc>, wallet: Arc, bump_tx_event_handler: Arc, - channel_manager: Arc, connection_manager: Arc>, - output_sweeper: Arc, network_graph: Arc, - liquidity_source: Arc>>, payment_store: Arc, - forwarding_store: Arc, peer_store: Arc>, - keys_manager: Arc, static_invoice_store: Option, - onion_messenger: Arc, om_mailbox: Option>, - prober: Option>, runtime: Arc, logger: L, config: Arc, + channel_manager: Arc, chain_monitor: Arc, + connection_manager: Arc>, output_sweeper: Arc, + network_graph: Arc, liquidity_source: Arc>>, + payment_store: Arc, forwarding_store: Arc, + peer_store: Arc>, keys_manager: Arc, + static_invoice_store: Option, onion_messenger: Arc, + om_mailbox: Option>, prober: Option>, + runtime: Arc, logger: L, config: Arc, ) -> Self { Self { event_queue, wallet, bump_tx_event_handler, channel_manager, + chain_monitor, connection_manager, output_sweeper, network_graph, @@ -733,6 +739,31 @@ where Ok((payment_id, None)) } + /// The channel's pending splice rounds that have a transaction, as LDK currently holds them. + fn pending_splice_rounds( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Vec { + let splice_details = self + .channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .and_then(|channel| channel.splice_details); + funding_candidates(splice_details.as_ref(), counterparty_node_id, channel_id) + } + + /// The splice rounds LDK holds for the channel, as [`held_splice_rounds`] lists them, or + /// `None` once the channel is gone. + fn held_splice_rounds( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Option> { + self.channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .map(|channel| held_splice_rounds(channel.splice_details.as_ref(), channel.funding_txo)) + } + pub async fn handle_event(&self, event: LdkEvent) -> Result<(), ReplayEvent> { match event { LdkEvent::FundingGenerationReady { @@ -1907,10 +1938,40 @@ where reason, user_channel_id, counterparty_node_id, + channel_funding_txo, .. } => { log_info!(self.logger, "Channel {} closed due to: {}", channel_id, reason); + // A splice round this node signed dies with the channel unless LDK had already + // handed it to the broadcaster. LDK reports no failed negotiation for a round still + // awaiting the counterparty's signatures when the channel closes, so its record is + // taken back here. The channel manager holds only the closed channel's last funding, + // but the channel's monitor still watches every round the counterparty committed + // to, and our signatures may have left the node for such a round, so it is kept + // (see `closed_channel_held_rounds`). The monitor's guard is not `Send`, so its + // watched transactions are collected before anything is awaited. + let watched_txids: Vec = self + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| { + monitor.get_outputs_to_watch().into_iter().map(|(txid, _)| txid).collect() + }) + .unwrap_or_default(); + let held_rounds = closed_channel_held_rounds(channel_funding_txo, watched_txids); + if let Err(e) = + self.wallet.drop_abandoned_splice_rounds(channel_id, &held_rounds).await + { + log_error!( + self.logger, + "Failed to drop the splice rounds of closed channel {} from its funding \ + payment: {}", + channel_id, + e, + ); + return Err(ReplayEvent()); + } + // `counterparty_node_id` has been set on every `ChannelClosed` since LDK 0.0.117. let counterparty_node_id = counterparty_node_id .expect("counterparty_node_id is always set since LDK 0.0.117"); @@ -2166,6 +2227,26 @@ where .. } => match self.wallet.sign_owned_inputs(unsigned_transaction) { Ok(partially_signed_tx) => { + // Record the splice's funding payment before handing our signatures to LDK: + // `funding_transaction_signed` releases them to the counterparty, after which + // either party may broadcast — and wallet sync could observe the transaction + // before this node has recorded it. The record is written from the channel's + // pending splice history, and the round's broadcast adds nothing to it. On a + // failed write, replay rather than proceed unrecorded: LDK re-offers the event + // in-session and regenerates it across restarts while the transaction is + // unsigned. + let candidates = self.pending_splice_rounds(counterparty_node_id, channel_id); + if let Err(e) = + self.wallet.record_signed_funding(&partially_signed_tx, &candidates).await + { + log_error!( + self.logger, + "Failed to record the splice funding payment for channel {}: {}", + channel_id, + e, + ); + return Err(ReplayEvent()); + } match self.channel_manager.funding_transaction_signed( &channel_id, &counterparty_node_id, @@ -2180,9 +2261,18 @@ where ); }, Err(e) => { - // TODO(splicing): Abort splice once supported in LDK 0.3 - debug_assert!(false, "Failed signing funding transaction: {:?}", e); - log_error!(self.logger, "Failed signing funding transaction: {:?}", e); + // Either the round was reset after its history was read above — LDK + // then reports the failure through `SpliceNegotiationFailed`, whose + // handling takes the record back — or LDK rejected the witnesses, in + // which case the round stays pending in LDK, and the record with it. + // TODO(splicing): cancel the contribution here through + // `ChannelManager::cancel_funding_contributed`; a follow-up wires it. + log_error!( + self.logger, + "LDK refused the signed funding transaction for channel {}: {:?}", + channel_id, + e, + ); }, } }, @@ -2203,6 +2293,26 @@ where new_funding_txo, ); + // LDK emits this event as it hands the fully signed round to the broadcaster: the + // counterparty holds our signatures now and may broadcast on its own, so the + // round's funding payment, recorded when the round was signed, no longer awaits + // broadcast. On a failed write, replay: LDK re-offers the event in-session and + // persists it across restarts. + if let Err(e) = self + .wallet + .record_broadcast_splice_round(channel_id, new_funding_txo.txid) + .await + { + log_error!( + self.logger, + "Failed to mark splice round {} of channel {} as broadcast: {}", + new_funding_txo.txid, + channel_id, + e, + ); + return Err(ReplayEvent()); + } + let event = Event::SpliceNegotiated { channel_id, user_channel_id: UserChannelId(user_channel_id), @@ -2231,6 +2341,30 @@ where counterparty_node_id, ); + // A round this node signed was recorded when signing; if the failed round was + // among them, nothing can broadcast it anymore, so take its record back. The + // rounds LDK still holds tell which recorded ones it abandoned (a contribution + // can fail while an earlier signed round still awaits its signatures). A closed + // channel is left to its `ChannelClosed` event: LDK queues one for every channel it + // removes — before the failures a force-close reports, after the one a cooperative + // close reports — and that event carries the channel's last funding, which this + // handler can no longer read from the channel. + if let Some(held_rounds) = self.held_splice_rounds(counterparty_node_id, channel_id) + { + if let Err(e) = + self.wallet.drop_abandoned_splice_rounds(channel_id, &held_rounds).await + { + log_error!( + self.logger, + "Failed to drop the abandoned splice round of channel {} from its \ + funding payment: {}", + channel_id, + e, + ); + return Err(ReplayEvent()); + } + } + let event = Event::SpliceNegotiationFailed { channel_id, user_channel_id: UserChannelId(user_channel_id), diff --git a/src/lib.rs b/src/lib.rs index 9b0700b96..43cf6d5d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -366,6 +366,22 @@ impl Node { ) })?; + // A splice round recorded when this node signed it is taken back once LDK reports the + // negotiation failed or the channel closed. LDK reports the loss of a negotiation its last + // channel manager write carried mid-way, but a round committed, negotiated and signed + // since that write gets no report if the node stopped before the next one, so drop what + // LDK's persisted state does not hold before anything runs on the records: no background + // task has started yet, so a failure here fails the start cleanly. A channel LDK no + // longer lists is left to its `ChannelClosed` event. + let channels = self.channel_manager.list_channels(); + self.runtime.block_on(self.wallet.drop_splice_rounds_lost_across_restart( + |channel_id| { + channels.iter().find(|channel| channel.channel_id == channel_id).map(|channel| { + wallet::held_splice_rounds(channel.splice_details.as_ref(), channel.funding_txo) + }) + }, + ))?; + // Spawn background task continuously syncing onchain, lightning, and fee rate cache. let stop_sync_receiver = self.stop_sender.subscribe(); let chain_source = Arc::clone(&self.chain_source); @@ -685,6 +701,7 @@ impl Node { Arc::clone(&self.wallet), bump_tx_event_handler, Arc::clone(&self.channel_manager), + Arc::clone(&self.chain_monitor), Arc::clone(&self.connection_manager), Arc::clone(&self.output_sweeper), Arc::clone(&self.network_graph), diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index e14f64c38..517a3db81 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -28,12 +28,20 @@ pub(crate) struct FundingTxCandidate { /// 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, + /// Whether this node signed the candidate but the signatures have yet to be exchanged. Set + /// when the round is recorded at signing time, cleared when LDK reports the splice negotiated + /// (`SpliceNegotiated`, emitted as it hands the fully signed round to the broadcaster). Only + /// such a round can be abandoned without a trace — the counterparty aborts, or the channel + /// closes, before the signatures are exchanged — so only such a round may be dropped from the + /// history. + pub awaiting_broadcast: bool, } impl_writeable_tlv_based!(FundingTxCandidate, { (0, txid, required), (2, amount_msat, option), (4, fee_paid_msat, option), + (6, awaiting_broadcast, required), }); /// Represents a pending payment @@ -108,8 +116,10 @@ impl UpdatableObject 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. + // Each funding-record write passes the candidate history as of its own round, so a + // non-empty update replaces the stored list. An empty update (e.g. a non-funding payment) + // leaves it untouched. Dropping an abandoned round, the only writer that shrinks it, goes + // through the store's `mutate` instead. if !update.candidates.is_empty() && self.candidates != update.candidates { self.candidates = update.candidates; updated = true; @@ -145,6 +155,40 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { } } +/// Builds a [`FundingContribution`] for tests through its `Readable` impl — the only path open +/// outside `rust-lightning`, which keeps its builder private. The length-prefixed stream holds +/// the required TLV records (the given estimated fee in satoshis, feerate, max feerate, and the +/// is-splice flag) plus the given contributed outputs. +/// +/// [`FundingContribution`]: lightning::ln::funding::FundingContribution +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_outputs( + estimated_fee_sat: u64, feerate: u64, outputs: &[bitcoin::TxOut], +) -> lightning::ln::funding::FundingContribution { + use lightning::util::ser::Writeable; + let mut records = vec![1, 8]; // (1, estimated_fee) + records.extend_from_slice(&estimated_fee_sat.to_be_bytes()); + if !outputs.is_empty() { + let mut output_bytes = Vec::new(); + for output in outputs { + output.write(&mut output_bytes).expect("in-memory write must succeed"); + } + records.push(5); // (5, outputs) + records.push(u8::try_from(output_bytes.len()).expect("test outputs must stay small")); + records.extend_from_slice(&output_bytes); + } + records.extend_from_slice(&[9, 8]); // (9, feerate) + records.extend_from_slice(&feerate.to_be_bytes()); + records.extend_from_slice(&[11, 8]); // (11, max_feerate) + records.extend_from_slice(&feerate.to_be_bytes()); + records.extend_from_slice(&[13, 1, 1]); // (13, is_splice: true) + // BigSize length prefix over the TLV records above; single-byte as long as they stay short. + let mut tlv_bytes = vec![u8::try_from(records.len()).expect("test TLV stream must stay small")]; + tlv_bytes.extend(records); + lightning::util::ser::Readable::read(&mut &tlv_bytes[..]) + .expect("hand-built TLV stream must decode") +} + #[cfg(test)] mod tests { use bitcoin::hashes::Hash; @@ -163,16 +207,23 @@ mod tests { // 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: counterparty_txid, + amount_msat: None, + fee_paid_msat: None, + awaiting_broadcast: false, + }, FundingTxCandidate { txid: first_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(1_000), + awaiting_broadcast: false, }, FundingTxCandidate { txid: rbf_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(5_000), + awaiting_broadcast: false, }, ]; @@ -282,6 +333,7 @@ mod tests { txid, amount_msat: fresh.amount_msat, fee_paid_msat: fresh.fee_paid_msat, + awaiting_broadcast: false, }]; // The old fresh-insert path merged the full fresh record, downgrading the mirrored diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 46af8ba2e..3c12f4113 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -22,10 +22,12 @@ use crate::Error; /// The most packages [`BroadcastQueue`] holds, fresh and awaiting a retry together. Claims and /// sweeps re-enter the queue on LDK's periodic rebroadcast timers, so one dropped at the bound -/// resurfaces on its own once the store recovers. Packages nothing re-broadcasts — fundings and +/// resurfaces on its own once the store recovers. Packages no timer re-broadcasts — fundings and /// cooperative closes — are never dropped or refused for the bound, though they count toward it: -/// what LDK hands over of them is finite — one per negotiated funding candidate and one per -/// closing channel — and a copy of a package awaiting a retry is never queued twice. +/// what LDK hands over of them is finite — one per closing channel, and one per funding under the +/// `Funding` type each time its channel resumes while it is unconfirmed (splice rounds have +/// nothing to classify, so none ever awaits a retry) — and a copy of a package awaiting a retry +/// is never queued twice. const MAX_QUEUED_PACKAGES: usize = 256; /// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions` @@ -65,11 +67,12 @@ impl BroadcastPackage { /// Whether the package may be dropped to keep [`BroadcastQueue`] within its bound: every /// transaction in it is re-broadcast by its originator, so a dropped package resurfaces on /// its own. LDK re-hands claims, anchor bumps, and force-close commitments to the - /// broadcaster periodically, and the sweeper regenerates sweeps once per block. Nothing - /// re-broadcasts a funding transaction (a channel open or splice, whose classification - /// writes the payment record tracking the funding) or a cooperative close (whose channel is - /// gone from the `ChannelManager` by broadcast time), so a package containing either is - /// never dropped. + /// broadcaster periodically, and the sweeper regenerates sweeps once per block. No timer + /// re-broadcasts a funding transaction: LDK re-hands an unconfirmed funding only when its + /// channel resumes, and the wallet's tip-change re-broadcast covers recorded transactions + /// only, which a funding whose classification failed is not. Nothing re-broadcasts a + /// cooperative close, whose channel is gone from the `ChannelManager` by broadcast time. A + /// package containing either is never dropped. fn is_droppable(&self) -> bool { self.0.iter().all(|(_, tx_type)| match tx_type { Some( @@ -204,11 +207,10 @@ impl QueueState { let txids = package.txids(); if self.retries.iter().any(|(_, waiting, _)| *waiting == txids) { // Same transactions, same classification outcome: keep the waiting entry and its - // earlier deadline. The one same-txid package with a *different* type is LDK's - // re-typed generic-funding rebroadcast of a promoted 0conf splice, which always - // arrives after the interactive-funding original (the zero-conf rebroadcast canary - // tests assert that ordering), so the entry kept is the richer of the two — and its - // classification declines the downgrade anyway. + // earlier deadline. The one same-txid package LDK hands over under a different type, + // its re-typed generic-funding rebroadcast of a promoted 0conf splice, never meets + // the original here: an interactive-funding broadcast has nothing to classify, so it + // never awaits a retry. return QueueOutcome::AlreadyQueued(package); } @@ -216,10 +218,10 @@ impl QueueState { if package.is_droppable() && self.fresh.len() + self.retries.len() >= MAX_QUEUED_PACKAGES { // Drop the oldest droppable package, a waiting retry before a fresh package: its // transactions are re-broadcast periodically, while the incoming package may carry - // a fresher fee-bumped variant. A funding package is never dropped — nothing would - // re-broadcast it, and losing it leaves its transaction confirming without a - // recorded candidate. Neither is a cooperative close, whose queued package may hold - // the only copy of the signed closing transaction. + // a fresher fee-bumped variant. A funding package is never dropped — no timer would + // re-broadcast it, and it must be recorded before it is broadcast. Neither is a + // cooperative close, whose queued package may hold the only copy of the signed + // closing transaction. dropped = self.drop_oldest_droppable(); if dropped.is_none() { return QueueOutcome::Refused(package); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d485bd787..960194f27 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -5,7 +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, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::future::Future; use std::ops::Deref; use std::str::FromStr; @@ -33,11 +33,13 @@ use bitcoin::{ WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::{ - FundingCandidate, TransactionType as LdkTransactionType, + ChannelFunding, FundingCandidate, FundingPurpose, TransactionType as LdkTransactionType, INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::chain::{BlockLocator, ClaimId, Listen}; +use lightning::ln::channel_state::{SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; @@ -59,7 +61,7 @@ use crate::data_store::UpdatableObject; #[cfg(test)] use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; -use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::logger::{log_debug, log_error, log_info, log_trace, log_warn, LdkLogger, Logger}; use crate::payment::pending_payment_store::PendingPaymentDetailsUpdate; use crate::payment::store::{ConfirmationStatus, PaymentDetailsUpdate}; use crate::payment::{ @@ -721,8 +723,9 @@ impl Wallet { /// Only funding-classified records are considered: nothing re-submits a replaced funding /// transaction under the same record (an RBF round is a new candidate), so a buried foreign /// conflict is final for them. The liveness check guards the case where the conflict - /// double-spent only one round of the negotiation: as long as some candidate — including one - /// classification hasn't recorded yet — can still confirm, the record must stay pending. + /// double-spent only one round of the negotiation: as long as some candidate — any recorded + /// round, or the record's own transaction should wallet sync have rotated it to an + /// unrecorded one — can still confirm, the record must stay pending. async fn fail_funding_payment_lost_to_conflict( &self, payment: &PendingPaymentDetails, tip_height: u32, ) -> Result { @@ -742,12 +745,12 @@ impl Wallet { return Ok(false); } - // Serialize with classification, whose retries extend the candidate history: the + // Serialize with the funding-record writers, which extend the candidate history: the // decision below must see that history in its settled form, and holding the lock keeps a // concurrent write from resurrecting the entry removed at the end. let _guard = self.funding_payment_update_lock.lock().await; - // Re-read the entry under the lock; the listing snapshot may predate a classification. + // Re-read the entry under the lock; the listing snapshot may predate a record write. let entry = match self.pending_payment_store.get(&payment.details.id).await? { Some(entry) => entry, None => return Ok(false), @@ -1754,9 +1757,11 @@ impl Wallet { 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 - }, + // A splice round this node contributed to is recorded when it is signed + // ([`Self::record_signed_funding`]) and marked as broadcast once LDK reports the splice + // negotiated ([`Self::record_broadcast_splice_round`]), so its broadcast has nothing + // left to record; a round without a contribution of ours is left for wallet sync. + LdkTransactionType::InteractiveFunding { .. } => Ok(()), LdkTransactionType::UnilateralClose { .. } => Ok(()), LdkTransactionType::CooperativeClose { .. } | LdkTransactionType::AnchorBump { .. } @@ -1790,10 +1795,10 @@ impl Wallet { // A funding transaction that moves no wallet funds carries nothing to record — e.g. LDK // re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding path, - // including splices the interactive-funding classification deliberately declined (no - // local contribution, or a splice-out moving no wallet funds). Recording it here would + // including splices the signing-time recording deliberately declined (no local + // contribution, or a splice-out moving no wallet funds). Recording it here would // mint a zero-amount payment that nothing ever confirms. Skip on the wallet-derived - // amount alone — the condition `classify_interactive_funding` declines on; anything + // amount alone — the condition `interactive_funding_record` declines on; anything // declined there must be skipped here, or its re-broadcast resurrects the record. The fee // is no participation signal: the wallet resolves a splice's shared input whenever the // previous funding transaction touched it (e.g. it funded the original channel open). @@ -1818,8 +1823,7 @@ impl Wallet { // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed // and carrying wallet-view figures; `funding_reclassification_update` declines the // downgrade, leaving no trace that a re-broadcast arrived. Log the arrival so tests can - // observe the traffic. The read cannot go stale: only the broadcast loop writes - // interactive-funding classifications, and it runs this classification too. + // observe the traffic; the read serves the log line alone, so a stale read costs no more. if let Some(current) = self.payment_store.get(&payment_id).await? { if matches!( current.kind, @@ -1858,26 +1862,15 @@ impl Wallet { 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"); + /// Builds the payment record and the per-candidate figures for recording the `active` round + /// of an interactive funding whose negotiated history is `candidates`. Returns `None` when + /// there is nothing to record: no local contribution to the round, or no wallet-level activity. + fn interactive_funding_record( + &self, candidates: &[FundingCandidate], active: &FundingCandidate, tx: &Transaction, + tx_type: TransactionType, + ) -> Option<(PaymentDetails, Vec)> { + let first = candidates.first()?; + let txid = active.txid; let aggregate = aggregate_local_stakes(active); let amount_msat = match aggregate.amount_msat { @@ -1885,10 +1878,10 @@ impl Wallet { None => { log_trace!( self.logger, - "Not recording interactive-funding broadcast {} as a payment: no local contribution", + "Not recording signed funding {} as a payment: no local contribution", txid, ); - return Ok(()); + return None; }, }; let fee_paid_msat = aggregate.fee_paid_msat; @@ -1902,10 +1895,10 @@ impl Wallet { if wallet_amount_msat == Some(0) { log_trace!( self.logger, - "Not recording interactive-funding broadcast {} as a payment: no wallet-level activity", + "Not recording signed funding {} as a payment: no wallet-level activity", txid, ); - return Ok(()); + return None; } // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable @@ -1924,6 +1917,7 @@ impl Wallet { txid: candidate.txid, amount_msat: aggregate.amount_msat, fee_paid_msat: aggregate.fee_paid_msat, + awaiting_broadcast: false, } }) .collect(); @@ -1940,17 +1934,405 @@ impl Wallet { direction, PaymentStatus::Pending, ); - self.persist_funding_payment(details, candidate_records).await?; + Some((details, candidate_records)) + } + + /// Records the funding payment of a splice round this node is about to sign, before + /// [`ChannelManager::funding_transaction_signed`] releases our signatures: without them the + /// counterparty cannot broadcast, so the record precedes anything wallet sync could observe. + /// The round's broadcast adds nothing to the record; its `SpliceNegotiated` event only marks it + /// as broadcast ([`Self::record_broadcast_splice_round`]). + /// + /// `candidates` is the channel's pending splice history as [`funding_candidates`] lists it from + /// the channel's [`SpliceDetails`], so the record is written in full, under the first + /// candidate's txid as id. The signed round is marked as awaiting broadcast until LDK reports + /// the splice negotiated and [`Self::record_broadcast_splice_round`] clears the mark: only such + /// a round can be abandoned without a trace, and [`Self::drop_abandoned_splice_rounds`] takes + /// it back once LDK no longer holds it. + /// + /// Nothing is recorded for a round missing from the history (reset between the event's + /// emission and its handling, so LDK will refuse the signed transaction), already recorded (a + /// replayed event), or without a local contribution or wallet-level activity. A failed write + /// leaves no half-written record behind for the replayed event to build on. + /// + /// [`ChannelManager::funding_transaction_signed`]: lightning::ln::channelmanager::ChannelManager::funding_transaction_signed + pub(crate) async fn record_signed_funding( + &self, tx: &Transaction, candidates: &[FundingCandidate], + ) -> Result<(), Error> { + let txid = tx.compute_txid(); + let signed_round = match candidates.iter().find(|candidate| candidate.txid == txid) { + Some(round) => round, + None => { + log_trace!( + self.logger, + "Not recording signed funding {}: not among the channel's pending splice rounds", + txid, + ); + // An earlier attempt at recording the round may have failed between the two + // stores and failed to roll back; the round is gone, so what it left goes too. + return self.drop_unindexed_signing_record(txid).await; + }, + }; + let tx_type = + LdkTransactionType::InteractiveFunding { candidates: candidates.to_vec() }.into(); + let (details, mut history) = + match self.interactive_funding_record(candidates, signed_round, tx, tx_type) { + Some(record) => record, + None => return Ok(()), + }; + let payment_id = details.id; + // Only the signed round awaits broadcast: LDK broadcast the others once their signatures + // were exchanged. + if let Some(signed) = history.iter_mut().find(|candidate| candidate.txid == txid) { + signed.awaiting_broadcast = true; + } + + // The reads and the write below must share one lock acquisition, as in every funding-record + // write: read outside it, the record could change under us before the write. + let guard = self.funding_payment_update_lock.lock().await; + + let prior_pending = self.pending_payment_store.get(&payment_id).await?; + // A replayed signing event re-offers a transaction already recorded; nothing to add. + if prior_pending.as_ref().is_some_and(|entry| entry.candidate(txid).is_some()) { + return Ok(()); + } + // Merge LDK's history into the recorded one — refreshing the rounds both list, appending + // the new ones — rather than replace it: LDK's history omits a recorded round it has since + // abandoned, whose removal is `drop_abandoned_splice_rounds`' job once LDK reports the + // failure, so a recorded round LDK no longer lists must survive the write. + // + // Refreshing an earlier round clears its awaiting-broadcast mark, which is right only + // because LDK refuses a new negotiation while one awaits signatures and handles events in + // order, stopping at the first failure: the earlier round's `SpliceNegotiated` event was + // pushed before this signing event and has been handled by now. Should LDK ever reorder + // them, this would clear the mark of a round whose event has not been handled yet. + let mut recorded = + prior_pending.as_ref().map(|entry| entry.candidates.clone()).unwrap_or_default(); + for candidate in history { + match recorded.iter_mut().find(|stored| stored.txid == candidate.txid) { + Some(stored) => *stored = candidate, + None => recorded.push(candidate), + } + } + + // The write pair can fail between its two stores. The lock keeps the other writers of this + // record out, bar graduation, which only ever moves a record out of `Pending`: put the + // payment store back as it was while the record is still pending, or the replayed event + // would find the half-written record and take it for prior state. + let prior_details = self.payment_store.get(&payment_id).await?; + if let Err(e) = self.persist_funding_payment_locked(&guard, details, recorded).await { + let rollback = match &prior_details { + Some(prior) => self + .payment_store + .mutate(&payment_id, |existing| { + let current = existing?; + (current.status == PaymentStatus::Pending && current != prior) + .then(|| prior.clone()) + }) + .await + .map(|_| ()), + None => self.payment_store.remove(&payment_id).await, + }; + if let Err(rollback_error) = rollback { + log_error!( + self.logger, + "Failed to roll back the half-written funding record of payment {}: {}", + payment_id, + rollback_error, + ); + } + return Err(e); + } log_debug!( self.logger, - "Recorded interactive-funding broadcast {} ({} candidates, {} channels)", + "Recorded signed splice funding {} ({} candidates)", txid, candidates.len(), - active.channels.len(), ); Ok(()) } + /// Marks a splice round recorded when signing ([`Self::record_signed_funding`]) as broadcast + /// once LDK reports the splice negotiated: `SpliceNegotiated` is emitted as LDK hands the fully + /// signed round to the broadcaster, so the counterparty holds our signatures by then and the + /// round can no longer be abandoned without a trace. Nothing is written for a round no funding + /// payment of `channel_id` tracks (no local contribution, or no wallet-level activity) or one + /// already marked (a replayed event). + pub(crate) async fn record_broadcast_splice_round( + &self, channel_id: ChannelId, txid: Txid, + ) -> Result<(), Error> { + // Serialize with the other funding-record writers, which all hold this lock from their + // reads through their last write. + let _guard = self.funding_payment_update_lock.lock().await; + + let entries = self + .pending_payment_store + .list_filter(|entry| { + let tracks_channel = match &entry.details.kind { + PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + } => channels.iter().any(|channel| channel.channel_id == channel_id), + _ => false, + }; + tracks_channel + && entry.candidate(txid).is_some_and(|candidate| candidate.awaiting_broadcast) + }) + .await; + for entry in entries { + let payment_id = entry.details.id; + self.pending_payment_store + .mutate(&payment_id, |existing| { + let mut entry = existing?.clone(); + let round = entry + .candidates + .iter_mut() + .find(|candidate| candidate.txid == txid && candidate.awaiting_broadcast)?; + round.awaiting_broadcast = false; + Some(entry) + }) + .await?; + log_debug!( + self.logger, + "Marked splice round {} of channel {} as broadcast in funding payment {}", + txid, + channel_id, + payment_id, + ); + } + Ok(()) + } + + /// Drops from a channel's funding records the splice rounds LDK abandoned before they could be + /// broadcast. A round this node signed is recorded before our signatures leave the node + /// ([`Self::record_signed_funding`]) and marked as awaiting broadcast until its + /// `SpliceNegotiated` event clears the mark ([`Self::record_broadcast_splice_round`]). Should + /// LDK drop the round in between — the counterparty aborts before the signatures are exchanged, + /// or the channel closes — nothing can broadcast it anymore, and left in place the record would + /// wait forever on a payment nothing can confirm. + /// + /// `held_rounds` lists the rounds LDK still holds for the channel, as [`held_splice_rounds`] + /// reads them (for a closed channel, its last funding and the rounds its monitor still watches, + /// as [`closed_channel_held_rounds`] reads them). A recorded round is dropped if it awaits + /// broadcast, LDK no longer holds it, and the wallet has not seen its transaction either — the + /// counterparty may broadcast a round it received our signatures for while LDK still waits on + /// its own. A round LDK handed the broadcaster keeps its place once its `SpliceNegotiated` + /// event has cleared the mark, whether wallet sync has seen it yet or not; one whose event is + /// still unhandled when the channel closes is listed in `held_rounds` because the channel's + /// monitor, which saw the counterparty commit to it, still watches it, and so keeps its place + /// as well. Dropping the record's current round hands the record back to the last remaining + /// round this node contributed to, figures included; dropping the last such round removes the + /// record, as whatever rounds remain are not this node's payment (LDK keeps this node's + /// contributions to a suffix of the rounds). A record that no longer waits on the dropped round + /// — wallet sync moved it on, or an earlier drop was cut short after moving it — keeps its + /// state and only loses the round from its history. + pub(crate) async fn drop_abandoned_splice_rounds( + &self, channel_id: ChannelId, held_rounds: &[Txid], + ) -> Result<(), Error> { + // Serialize with the other funding-record writers, which all hold this lock from their + // reads through their last write. + let _guard = self.funding_payment_update_lock.lock().await; + + let entries = self + .pending_payment_store + .list_filter(|entry| { + let tracks_channel = match &entry.details.kind { + PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + } => channels.iter().any(|channel| channel.channel_id == channel_id), + _ => false, + }; + tracks_channel + && entry.candidates.iter().any(|candidate| candidate.awaiting_broadcast) + }) + .await; + + for entry in entries { + let payment_id = entry.details.id; + let (abandoned, remaining): (Vec, Vec) = { + let locked_wallet = self.inner.lock().expect("lock"); + // TODO(#1037): the graph learns a round LDK broadcast from wallet sync alone + // today, so this check only adds what a sync has already seen to `held_rounds`. + // It catches every broadcast round by itself, whichever caller — the startup + // sweep or a live event — runs the drop, only once the `InteractiveFunding` + // broadcast arm applies the round to the graph, which #1037 does not do: it + // prepares only `Funding`-typed packages. + entry.candidates.iter().cloned().partition(|candidate| { + candidate.awaiting_broadcast + && !held_rounds.contains(&candidate.txid) + && locked_wallet.tx_graph().get_tx(candidate.txid).is_none() + }) + }; + if abandoned.is_empty() { + continue; + } + let abandoned_txids: Vec = abandoned.iter().map(|c| c.txid).collect(); + // The record's transaction and figures are only handed back while they still describe + // an abandoned round; a record wallet sync has since moved on is left as it stands, + // and only its history shrinks. + let waits_on_abandoned = |record: &PaymentDetails| { + record.status == PaymentStatus::Pending + && matches!( + &record.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } + if abandoned_txids.contains(txid) + ) + }; + let record = self.payment_store.get(&payment_id).await?; + let hands_back = record.as_ref().map_or(true, waits_on_abandoned); + // A last remaining round without a contribution of ours means no remaining round has + // one. + let handed_back = remaining.last().filter(|round| round.amount_msat.is_some()); + + if hands_back && handed_back.is_none() { + // Nothing of this node's was ever broadcast under the record, so it goes rather + // than fail a payment for a transaction that never existed. The payment record + // goes first: the entry keeps resolving the rounds' txids, so a removal that + // fails midway is finished by the replayed event. + self.payment_store.remove(&payment_id).await?; + self.pending_payment_store.remove(&payment_id).await?; + log_info!( + self.logger, + "Dropped abandoned splice round(s) {:?} and funding payment {} with them", + abandoned_txids, + payment_id, + ); + continue; + } + + let mut mirrored = None; + match handed_back { + Some(active) if hands_back => { + self.payment_store + .mutate(&payment_id, |existing| { + let current = existing?; + if !waits_on_abandoned(current) { + mirrored = Some(current.clone()); + return None; + } + let mut update = PaymentDetailsUpdate::new(payment_id); + update.txid = Some(active.txid); + update.confirmation_status = Some(ConfirmationStatus::Unconfirmed); + update.amount_msat = Some(active.amount_msat); + update.fee_paid_msat = Some(active.fee_paid_msat); + let mut updated = current.clone(); + updated.update(update); + mirrored = Some(updated.clone()); + Some(updated) + }) + .await?; + }, + _ => { + // The record does not wait on the dropped rounds: wallet sync moved it on, or + // an earlier drop was cut short between the two stores. Only its history + // shrinks, and the entry's copy of the record catches up with the record while + // the record is still pending. + mirrored = record.filter(|current| current.status == PaymentStatus::Pending); + log_warn!( + self.logger, + "Funding payment {} does not wait on abandoned splice round(s) {:?}: \ + dropping them from its history only", + payment_id, + abandoned_txids, + ); + }, + } + self.pending_payment_store + .mutate(&payment_id, |existing| { + let mut entry = existing?.clone(); + entry.candidates.retain(|c| !abandoned_txids.contains(&c.txid)); + if let Some(mirrored) = mirrored { + entry.details = mirrored; + } + Some(entry) + }) + .await?; + log_info!( + self.logger, + "Dropped abandoned splice round(s) {:?} from funding payment {}", + abandoned_txids, + payment_id, + ); + } + Ok(()) + } + + /// Drops the splice rounds recorded when signing that LDK does not hold once the node restarts. + /// LDK reports the loss of a negotiation its last channel manager write carried mid-way, but a + /// round committed, negotiated and signed since that write is gone without a report if the + /// node stopped before the next one. `held_rounds` yields the rounds LDK holds for a channel, + /// as [`held_splice_rounds`] lists them, or `None` for a channel LDK no longer lists, which is + /// left to its `ChannelClosed` event: LDK queues one for every channel it drops, and handling + /// it takes back what neither the closed channel's funding nor its monitor holds. Runs before + /// events are processed again, so no round is recorded while LDK's view is being read. + pub(crate) async fn drop_splice_rounds_lost_across_restart( + &self, held_rounds: impl Fn(ChannelId) -> Option>, + ) -> Result<(), Error> { + let channels: HashSet = self + .pending_payment_store + .list_filter(|entry| { + entry.candidates.iter().any(|candidate| candidate.awaiting_broadcast) + }) + .await + .iter() + .flat_map(|entry| match &entry.details.kind { + PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + } => channels.iter().map(|channel| channel.channel_id).collect(), + _ => Vec::new(), + }) + .collect(); + for channel_id in channels { + let Some(held) = held_rounds(channel_id) else { + log_debug!( + self.logger, + "Leaving the signed splice rounds of channel {} to its ChannelClosed event", + channel_id, + ); + continue; + }; + self.drop_abandoned_splice_rounds(channel_id, &held).await?; + } + Ok(()) + } + + /// Removes the half-written record of a signed round LDK has since abandoned: its write failed + /// between the two stores and the rollback failed as well, leaving the payment record without + /// the pending entry that indexes it. The replayed signing event, finding the round gone from + /// the history, ends up here; a fully recorded round (its entry in place) is left to + /// [`Self::drop_abandoned_splice_rounds`]. Only a first round is recorded under its own txid: + /// the record of a bump lives under an earlier round's id and keeps its entry, and wallet sync + /// moves it on as that earlier round confirms or fails. + async fn drop_unindexed_signing_record(&self, txid: Txid) -> Result<(), Error> { + let _guard = self.funding_payment_update_lock.lock().await; + let payment_id = PaymentId(txid.to_byte_array()); + if self.pending_payment_store.get(&payment_id).await?.is_some() { + return Ok(()); + } + let unindexed = self.payment_store.get(&payment_id).await?.is_some_and(|record| { + record.status == PaymentStatus::Pending + && matches!( + &record.kind, + PaymentKind::Onchain { + txid: recorded, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } if *recorded == txid + ) + }); + if unindexed { + self.payment_store.remove(&payment_id).await?; + log_info!( + self.logger, + "Dropped the half-written funding record of abandoned splice round {}", + txid, + ); + } + Ok(()) + } + /// Records a non-funding LDK broadcast as an on-chain payment, tagged with its transaction type. /// Wallet sync later refreshes confirmation status while preserving the type. async fn classify_regular_broadcast( @@ -1992,8 +2374,16 @@ impl Wallet { ) -> Result<(), Error> { // Hold the cross-store lock across both writes so a funding confirmation never observes // the record classified but the candidate history it needs still missing. - let _guard = self.funding_payment_update_lock.lock().await; + let guard = self.funding_payment_update_lock.lock().await; + self.persist_funding_payment_locked(&guard, details, candidates).await + } + /// [`Self::persist_funding_payment`] for a caller already holding the cross-store lock, whose + /// reads the write must not be separated from. + async fn persist_funding_payment_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, details: PaymentDetails, + candidates: Vec, + ) -> Result<(), Error> { // Everything this write does depends on the record's current state, so all of it must be // decided inside the store's critical section. When a record exists — no matter when it // appeared — only the classification (`tx_type`) and the figures of whichever candidate @@ -2050,10 +2440,10 @@ impl Wallet { // The payment already advanced beyond Pending: the graduation path removed // the entry and it must not be re-created. None => None, - // The entry predates this classification — wallet sync recorded the - // transaction before it was classified (its arms and this write pair + // The entry predates this write — wallet sync recorded the transaction + // before it was recorded as a funding (its arms and this write pair // serialize on the cross-store lock, so nothing lands in between): merge - // only the classification into the existing entry. + // only the funding classification into the existing entry. Some(mut entry) => { let pending_update = PendingPaymentDetailsUpdate { id, @@ -2567,6 +2957,83 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { } } +/// Lists a channel's pending splice rounds that have a transaction — the negotiated predecessors +/// and the round awaiting signatures, in LDK's order, each with this node's contribution to it — +/// as the [`FundingCandidate`]s LDK hands the broadcaster for the round, for recording the round +/// when signing it. A contribution still queued behind the pending rounds has no transaction and +/// is left out; a channel with no pending splice yields nothing. +pub(crate) fn funding_candidates( + details: Option<&SpliceDetails>, counterparty_node_id: PublicKey, channel_id: ChannelId, +) -> Vec { + details + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]) + .iter() + .filter_map(|candidate| { + let txid = round_txid(candidate)?; + Some(FundingCandidate { + txid, + channels: vec![ChannelFunding { + counterparty_node_id, + channel_id, + purpose: FundingPurpose::Splice, + contribution: candidate.contribution.clone(), + }], + }) + }) + .collect() +} + +/// The transaction of a pending splice round, once it has one: a negotiated round's, or the +/// round awaiting signatures'. +fn round_txid(candidate: &SpliceCandidateDetails) -> Option { + match &candidate.status { + SpliceCandidateStatus::Negotiated { txid, .. } + | SpliceCandidateStatus::AwaitingSignatures { txid, .. } => Some(*txid), + _ => None, + } +} + +/// The splice rounds LDK holds for a channel, as [`Wallet::drop_abandoned_splice_rounds`] takes +/// them: the pending rounds with a transaction, as [`funding_candidates`] lists them, and the +/// channel's current funding. A zero-conf splice is promoted to the funding as soon as +/// `splice_locked` is exchanged, before its transaction confirms, so it leaves the pending rounds +/// while its record may still await the `SpliceNegotiated` event that marks it broadcast. +pub(crate) fn held_splice_rounds( + details: Option<&SpliceDetails>, funding_txo: Option, +) -> Vec { + let mut held: Vec = details + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]) + .iter() + .filter_map(round_txid) + .collect(); + held.extend(funding_txo.map(|funding| funding.txid)); + held +} + +/// The splice rounds a closed channel may still see confirm, as +/// [`Wallet::drop_abandoned_splice_rounds`] takes them: the channel's last funding — which a +/// zero-conf splice may have become before its transaction confirmed — and every transaction the +/// channel's monitor still watches. The channel manager forgets a pending round with the channel +/// and reports no failed negotiation for one awaiting the counterparty's signatures, but the +/// monitor keeps watching every round the counterparty's `commitment_signed` reached, and our +/// signatures cannot have left the node before that message: such a round may yet confirm and is +/// left to wallet sync or `DiscardFunding` to resolve, while a round the monitor never watched +/// never had our signatures released. The watched transactions also include the funding and +/// whatever spent it on chain, which no recorded round is. +pub(crate) fn closed_channel_held_rounds( + funding_txo: Option, watched_txids: impl IntoIterator, +) -> Vec { + let mut held: Vec = funding_txo.map(|funding| funding.txid).into_iter().collect(); + for txid in watched_txids { + if !held.contains(&txid) { + held.push(txid); + } + } + held +} + /// The outcome of [`Wallet::apply_funding_status_update_locked`]. enum FundingStatusUpdate { /// The event's transaction belongs to the funding payment; its refreshed confirmation status @@ -2965,6 +3432,7 @@ mod tests { use bitcoin::hashes::Hash; use bitcoin::Network; use lightning::io; + use lightning::ln::funding::FundingContribution; use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; use super::*; @@ -2981,6 +3449,7 @@ mod tests { PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; + use crate::payment::pending_payment_store::test_funding_contribution_with_outputs; use crate::types::{DynStore, DynStoreWrapper}; use crate::{NodeMetrics, PersistedNodeMetrics}; @@ -3059,6 +3528,8 @@ mod tests { inner: Arc, fail_writes: Arc, failed_writes: Arc, + /// When set, only writes to this primary namespace fail while `fail_writes` is on. + failing_namespace: Option, } impl FailSwitchStore { @@ -3067,8 +3538,14 @@ mod tests { inner: Arc::new(InMemoryStore::new()), fail_writes: Arc::new(AtomicBool::new(false)), failed_writes: Arc::new(AtomicUsize::new(0)), + failing_namespace: None, } } + + /// Like [`Self::new`], but only writes to `primary_namespace` fail. + fn failing_only(primary_namespace: &str) -> Self { + Self { failing_namespace: Some(primary_namespace.to_string()), ..Self::new() } + } } impl KVStore for FailSwitchStore { @@ -3084,11 +3561,13 @@ mod tests { let inner = Arc::clone(&self.inner); let fail_writes = Arc::clone(&self.fail_writes); let failed_writes = Arc::clone(&self.failed_writes); + let may_fail = + self.failing_namespace.as_deref().map_or(true, |ns| ns == primary_namespace); let primary_namespace = primary_namespace.to_string(); let secondary_namespace = secondary_namespace.to_string(); let key = key.to_string(); async move { - if fail_writes.load(Ordering::Acquire) { + if may_fail && fail_writes.load(Ordering::Acquire) { failed_writes.fetch_add(1, Ordering::AcqRel); return Err(io::Error::new(io::ErrorKind::Other, "writes disabled")); } @@ -4127,6 +4606,1027 @@ mod tests { } } + /// A counterparty and channel for splice rounds in tests. + fn test_counterparty_and_channel() -> (PublicKey, ChannelId) { + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + (counterparty_node_id, ChannelId([7u8; 32])) + } + + /// Builds one [`FundingCandidate`] per `(txid, contribution)` round of a single channel, in + /// the given order — the shape LDK hands both the signing-time recording and the broadcaster. + fn splice_candidates( + counterparty_node_id: PublicKey, channel_id: ChannelId, + rounds: &[(Txid, Option)], + ) -> Vec { + use lightning::chain::chaininterface::{ChannelFunding, FundingPurpose}; + rounds + .iter() + .map(|(txid, contribution)| FundingCandidate { + txid: *txid, + channels: vec![ChannelFunding { + counterparty_node_id, + channel_id, + purpose: FundingPurpose::Splice, + contribution: contribution.clone(), + }], + }) + .collect() + } + + /// Marks `txid` as evicted from the mempool after it was seen, so the BDK wallet still holds + /// the transaction but no longer considers it canonical. + fn evict_tx(wallet: &Wallet, txid: Txid) { + let mut locked = wallet.inner.lock().unwrap(); + let mut tx_update = bdk_chain::TxUpdate::default(); + tx_update.evicted_ats = [(txid, 101)].into(); + locked.apply_update(Update { tx_update, ..Default::default() }).unwrap(); + } + + /// A splice-out round returning `value_sat` to an external address at an estimated fee of + /// `fee_sat`, so `value_sat + fee_sat` leaves the channel: the contribution as LDK would + /// negotiate it, and the transaction carrying it, + /// which also pays a wallet address so the wallet sees movement (spending an outpoint derived + /// from `input_byte`). + fn splice_out_round( + wallet: &Wallet, input_byte: u8, value_sat: u64, fee_sat: u64, + ) -> (Transaction, FundingContribution) { + let splice_out = + TxOut { value: Amount::from_sat(value_sat), script_pubkey: ScriptBuf::new() }; + let contribution = + test_funding_contribution_with_outputs(fee_sat, 253, std::slice::from_ref(&splice_out)); + let mut tx = wallet_paying_tx(wallet, input_byte); + tx.output.push(splice_out); + (tx, contribution) + } + + /// Signing a splice round records its funding payment under the first candidate's txid as id, + /// with the channel's full pending splice history, so a wallet sync that observes the + /// transaction before the broadcast (the counterparty may broadcast first) resolves to the + /// funding record instead of filing the round as a foreign duplicate. Only the signed round + /// awaits broadcast; LDK broadcast the negotiated predecessor already. + #[tokio::test] + async fn signing_records_the_round_under_the_first_candidate_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // The signed round is an RBF of a counterparty-initiated round (`prior_txid`, no + // contribution of ours), so the history LDK reports has two entries. + let prior_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + let id = PaymentId(prior_txid.to_byte_array()); + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let payment = &payments[0]; + assert_eq!(payment.id, id); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(payment.fee_paid_msat, Some(300_000)); + assert_eq!(payment.direction, PaymentDirection::Inbound); + assert_eq!(payment.status, PaymentStatus::Pending); + match &payment.kind { + PaymentKind::Onchain { + txid: recorded_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels }), + } => { + assert_eq!(*recorded_txid, txid); + assert_eq!(channels.len(), 1); + assert_eq!(channels[0].counterparty_node_id, counterparty_node_id); + assert_eq!(channels[0].channel_id, channel_id); + }, + kind => panic!("unexpected kind {:?}", kind), + } + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!( + record.candidates.iter().map(|c| c.txid).collect::>(), + vec![prior_txid, txid] + ); + let prior = record.candidate(prior_txid).unwrap(); + assert_eq!(prior.amount_msat, None); + assert!(!prior.awaiting_broadcast); + let signed = record.candidate(txid).unwrap(); + assert_eq!(signed.amount_msat, Some(500_300_000)); + assert_eq!(signed.fee_paid_msat, Some(300_000)); + assert!(signed.awaiting_broadcast); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); + } + + /// Once LDK reports a round recorded at signing negotiated, there is nothing to add but the + /// broadcast itself: the round's awaiting-broadcast mark is cleared and the record left as + /// written. + #[tokio::test] + async fn negotiation_of_a_signed_round_marks_it_broadcast() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let prior_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = PaymentId(prior_txid.to_byte_array()); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert!(record.candidate(txid).unwrap().awaiting_broadcast); + + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(payment)); + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!( + record.candidates.iter().map(|c| c.txid).collect::>(), + vec![prior_txid, txid] + ); + assert!(!record.candidate(txid).unwrap().awaiting_broadcast); + assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); + } + + /// A splice round this node contributed to is recorded when it is signed, so its broadcast has + /// nothing left to record: classifying it writes nothing, and the round keeps awaiting the + /// `SpliceNegotiated` event that marks it broadcast. + #[tokio::test] + async fn classifying_an_interactive_funding_broadcast_writes_nothing() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = PaymentId(txid.to_byte_array()); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert!(record.candidate(txid).unwrap().awaiting_broadcast); + + fail_store.fail_writes.store(true, Ordering::Release); + let tx_type = LdkTransactionType::InteractiveFunding { candidates }; + wallet.classify_broadcast(&tx, &tx_type).await.unwrap(); + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 0, + "classifying a recorded round must write nothing" + ); + assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(payment)); + assert_eq!(wallet.pending_payment_store.get(&id).await.unwrap(), Some(record)); + } + + /// A replayed `SpliceNegotiated` event names a round already marked broadcast; nothing is + /// written. + #[tokio::test] + async fn marking_a_broadcast_round_again_writes_nothing() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + fail_store.fail_writes.store(true, Ordering::Release); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 0, + "marking a round broadcast again must produce no new write" + ); + } + + /// A round no funding payment tracks — this node contributed nothing to it, so signing never + /// recorded it — has no mark to clear; nothing is written. + #[tokio::test] + async fn marking_an_unrecorded_round_broadcast_writes_nothing() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + fail_store.fail_writes.store(true, Ordering::Release); + let txid = Txid::from_byte_array([0xAA; 32]); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 0); + assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); + } + + /// A replayed signing event re-offers a transaction already recorded; nothing is written. + #[tokio::test] + async fn signing_a_recorded_round_again_writes_nothing() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + fail_store.fail_writes.store(true, Ordering::Release); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 0, + "a replayed signing must produce no new write" + ); + } + + /// A signed round absent from the channel's pending splice history was reset between the + /// event's emission and its handling (the counterparty aborted): LDK will refuse the signed + /// transaction, so nothing is recorded for it — not even when the history holds another round + /// this node contributed to. + #[tokio::test] + async fn signing_skips_a_round_missing_from_the_splice_history() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let other_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(other_txid, Some(contribution))], + ); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_signed_funding(&tx, &[]).await.unwrap(); + assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); + assert!(wallet.pending_payment_store.list_page(None).await.unwrap().objects.is_empty()); + } + + /// A round this node did not contribute to is not its payment: the signing-time recording + /// declines it. + #[tokio::test] + async fn signing_skips_a_round_without_a_local_contribution() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(tx.compute_txid(), None)]); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); + assert!(wallet.pending_payment_store.list_page(None).await.unwrap().objects.is_empty()); + } + + /// A splice-out to an external address moves no wallet funds; the signing-time recording + /// declines it — wallet sync cannot observe it either, so there is no race to close. + #[tokio::test] + async fn signing_skips_a_wallet_untouched_transaction() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let splice_out = + TxOut { value: Amount::from_sat(500_000), script_pubkey: ScriptBuf::new() }; + let contribution = + test_funding_contribution_with_outputs(300, 253, std::slice::from_ref(&splice_out)); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: OutPoint { txid: Txid::from_byte_array([1u8; 32]), vout: 0 }, + ..Default::default() + }], + output: vec![splice_out], + }; + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(tx.compute_txid(), Some(contribution))], + ); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); + assert!(wallet.pending_payment_store.list_page(None).await.unwrap().objects.is_empty()); + } + + /// The signing write merges LDK's history into the recorded one instead of replacing it: a + /// recorded round LDK no longer lists survives the write, since dropping the rounds LDK + /// abandoned is [`Wallet::drop_abandoned_splice_rounds`]'s job, once LDK reports the failure. + #[tokio::test] + async fn signing_merges_ldk_history_into_the_recorded_one() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let prior_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + let (next_tx, next_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let next_txid = next_tx.compute_txid(); + let next_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (next_txid, Some(next_contribution))], + ); + wallet.record_signed_funding(&next_tx, &next_candidates).await.unwrap(); + + let id = PaymentId(prior_txid.to_byte_array()); + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + assert_eq!(payments[0].id, id); + assert!( + matches!(&payments[0].kind, PaymentKind::Onchain { txid: t, .. } if *t == next_txid) + ); + assert_eq!(payments[0].amount_msat, Some(400_700_000)); + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!( + record.candidates.iter().map(|c| c.txid).collect::>(), + vec![prior_txid, txid, next_txid] + ); + assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); + assert_eq!(record.candidate(next_txid).unwrap().amount_msat, Some(400_700_000)); + } + + /// LDK abandoned a signed first round (the counterparty aborted before the signatures were + /// exchanged) and reports the failure: nothing was ever broadcast under the record, so it goes, + /// leaving no payment nothing can confirm — while another channel's record is left alone. + #[tokio::test] + async fn dropping_an_abandoned_first_round_removes_its_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let other_channel_id = ChannelId([8u8; 32]); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let (other_tx, other_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let other_txid = other_tx.compute_txid(); + let other_candidates = splice_candidates( + counterparty_node_id, + other_channel_id, + &[(other_txid, Some(other_contribution))], + ); + wallet.record_signed_funding(&other_tx, &other_candidates).await.unwrap(); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + let id = PaymentId(txid.to_byte_array()); + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + let other_id = PaymentId(other_txid.to_byte_array()); + assert!(wallet.payment_store.get(&other_id).await.unwrap().is_some()); + assert_eq!(wallet.find_payment_by_txid(other_txid).await.unwrap(), Some(other_id)); + } + + /// LDK abandoned a signed fee bump while the round it replaces stays pending: the bump leaves + /// the recorded history and the record tracks the original round again, figures included. + #[tokio::test] + async fn dropping_an_abandoned_bump_restores_the_prior_round() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = PaymentId(txid.to_byte_array()); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid), + "the original round must be the actively-tracked transaction again" + ); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(payment.fee_paid_msat, Some(300_000)); + assert_eq!(record.details, payment); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + } + + /// A round awaiting broadcast that the wallet has nonetheless seen — the counterparty broadcast + /// it with our signatures while LDK still waited on its own, and the channel then closed — may + /// still confirm and keeps its place, even once evicted from the mempool: the lookup is not + /// canonical-only. + #[tokio::test] + async fn dropping_keeps_a_round_the_wallet_has_seen() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + insert_unconfirmed_tx(&wallet, tx); + evict_tx(&wallet, txid); + assert!(wallet.inner.lock().unwrap().get_tx(txid).is_none(), "evicted: not canonical"); + let id = PaymentId(txid.to_byte_array()); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(payment.status, PaymentStatus::Pending); + } + + /// The channel force-closed with a negotiated round unconfirmed and a fee bump of it signed + /// but never exchanged, before wallet sync picked the negotiated round up: LDK lists neither + /// anymore, but the negotiated round was handed to the broadcaster and may still confirm, so + /// only the bump is dropped. + #[tokio::test] + async fn dropping_keeps_rounds_handed_to_the_broadcaster() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + let id = PaymentId(txid.to_byte_array()); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(payment.fee_paid_msat, Some(300_000)); + assert_eq!(payment.status, PaymentStatus::Pending); + } + + /// LDK abandoned the only round this node contributed to, an RBF of a counterparty-initiated + /// round it did not: what remains is not this node's payment, so the record goes instead of + /// being handed to a round the wallet will never observe. + #[tokio::test] + async fn dropping_the_last_contributed_round_removes_the_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let prior_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = PaymentId(prior_txid.to_byte_array()); + assert!(wallet.payment_store.get(&id).await.unwrap().is_some()); + + wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); + + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + } + + /// The record moved on before the drop: wallet sync confirmed the original round while its + /// bump awaited signatures, then LDK abandoned the bump. The confirmed record is left as it + /// stands; only the bump leaves the recorded history. + #[tokio::test] + async fn dropping_leaves_a_record_that_moved_on() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = PaymentId(txid.to_byte_array()); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + insert_confirmed_tx(&wallet, tx.clone(), 105); + let event = WalletEvent::TxConfirmed { + txid, + tx: Arc::new(tx), + block_time: confirmed_block_time(105), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } + if t == txid + )); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(payment.clone())); + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.details, payment); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); + } + + /// A removal that was cut short between the two stores — the payment record went, the pending + /// entry stayed — is finished by the replayed drop: the entry alone still resolves the round's + /// txid, so it is what the replayed event finds and removes. + #[tokio::test] + async fn a_cut_short_removal_is_finished_by_the_replayed_drop() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = PaymentId(txid.to_byte_array()); + wallet.payment_store.remove(&id).await.unwrap(); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + } + + /// A hand-back that was cut short between the two stores — the payment record tracks the + /// original round again, the pending entry still lists the bump and mirrors the record as it + /// was — is finished by the replayed drop: the bump leaves the history and the entry's copy of + /// the record catches up with the record. + #[tokio::test] + async fn a_cut_short_hand_back_is_finished_by_the_replayed_drop() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = PaymentId(txid.to_byte_array()); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + // The first half of the hand-back: the payment record alone tracks the original round. + let mut update = PaymentDetailsUpdate::new(id); + update.txid = Some(txid); + update.confirmation_status = Some(ConfirmationStatus::Unconfirmed); + update.amount_msat = Some(Some(500_300_000)); + update.fee_paid_msat = Some(Some(300_000)); + wallet.payment_store.update(update).await.unwrap(); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + assert!( + matches!(entry.details.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid) + ); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(entry.details, payment); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); + } + + /// The replayed signing event removes only the half-written record of a first round: a funding + /// record that has graduated, and a pending on-chain record that is not a funding payment, + /// stay as they are even though neither has a pending entry. + #[tokio::test] + async fn a_replayed_signing_leaves_records_that_are_not_half_written_rounds() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let (tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let id = PaymentId(txid.to_byte_array()); + let mut graduated = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); + graduated.status = PaymentStatus::Succeeded; + wallet.payment_store.insert_or_update(graduated.clone()).await.unwrap(); + + let (other_tx, _) = splice_out_round(&wallet, 2, 400_000, 700); + let other_txid = other_tx.compute_txid(); + let other_id = PaymentId(other_txid.to_byte_array()); + let untyped = PaymentDetails::new( + other_id, + PaymentKind::Onchain { + txid: other_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(90_000_000), + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + wallet.payment_store.insert_or_update(untyped.clone()).await.unwrap(); + + wallet.record_signed_funding(&tx, &[]).await.unwrap(); + wallet.record_signed_funding(&other_tx, &[]).await.unwrap(); + + assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(graduated)); + assert_eq!(wallet.payment_store.get(&other_id).await.unwrap(), Some(untyped)); + } + + /// The rounds LDK holds for a channel are its pending rounds with a transaction and its current + /// funding, which a zero-conf splice becomes before its transaction confirms. + #[test] + fn held_splice_rounds_include_the_current_funding() { + let pending_txid = Txid::from_byte_array([0xAA; 32]); + let funding_txid = Txid::from_byte_array([0xBB; 32]); + let details = SpliceDetails { + candidates: vec![ + SpliceCandidateDetails { + status: SpliceCandidateStatus::AwaitingSignatures { + is_initiator: true, + funding_feerate_sat_per_1000_weight: 253, + new_channel_value_satoshis: 110_000, + txid: pending_txid, + }, + contribution: None, + }, + SpliceCandidateDetails { + status: SpliceCandidateStatus::WaitingOnLock, + contribution: None, + }, + ], + confirmed_candidate: None, + received_splice_locked_txid: None, + }; + let funding = LdkOutPoint { txid: funding_txid, index: 0 }; + + assert_eq!( + held_splice_rounds(Some(&details), Some(funding)), + vec![pending_txid, funding_txid] + ); + assert_eq!(held_splice_rounds(None, Some(funding)), vec![funding_txid]); + assert!(held_splice_rounds(None, None).is_empty()); + } + + /// The rounds a closed channel may still see confirm are its last funding and every transaction + /// its monitor still watches: a splice round the counterparty committed to stays watched once + /// the channel manager has forgotten it with the channel. Without a monitor, only the funding + /// is held. + #[test] + fn closed_channel_held_rounds_include_the_watched_transactions() { + let funding_txid = Txid::from_byte_array([0xBB; 32]); + let watched_txid = Txid::from_byte_array([0xCC; 32]); + let funding = LdkOutPoint { txid: funding_txid, index: 0 }; + + assert_eq!( + closed_channel_held_rounds(Some(funding), [funding_txid, watched_txid]), + vec![funding_txid, watched_txid] + ); + assert_eq!(closed_channel_held_rounds(Some(funding), []), vec![funding_txid]); + assert_eq!(closed_channel_held_rounds(None, [watched_txid]), vec![watched_txid]); + assert!(closed_channel_held_rounds(None, []).is_empty()); + } + + /// The node restarted with a signed round LDK never wrote out — it stopped between LDK handing + /// the round out for signing and its next channel manager write, and the round was committed + /// after the last one — so LDK holds nothing for it and reports no failure: the startup sweep + /// drops it, while a round LDK still holds stays, and so does the round of a channel LDK no + /// longer lists, which is left to the channel's `ChannelClosed` event. + #[tokio::test] + async fn startup_drops_the_rounds_ldk_no_longer_holds() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let other_channel_id = ChannelId([8u8; 32]); + let closed_channel_id = ChannelId([9u8; 32]); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let (other_tx, other_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let other_txid = other_tx.compute_txid(); + let other_candidates = splice_candidates( + counterparty_node_id, + other_channel_id, + &[(other_txid, Some(other_contribution))], + ); + wallet.record_signed_funding(&other_tx, &other_candidates).await.unwrap(); + let (closed_tx, closed_contribution) = splice_out_round(&wallet, 3, 300_000, 500); + let closed_txid = closed_tx.compute_txid(); + let closed_candidates = splice_candidates( + counterparty_node_id, + closed_channel_id, + &[(closed_txid, Some(closed_contribution))], + ); + wallet.record_signed_funding(&closed_tx, &closed_candidates).await.unwrap(); + + wallet + .drop_splice_rounds_lost_across_restart(|channel| { + if channel == other_channel_id { + Some(vec![other_txid]) + } else if channel == closed_channel_id { + None + } else { + Some(Vec::new()) + } + }) + .await + .unwrap(); + + let id = PaymentId(txid.to_byte_array()); + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + let other_id = PaymentId(other_txid.to_byte_array()); + assert!(wallet.payment_store.get(&other_id).await.unwrap().is_some()); + assert!(wallet.pending_payment_store.get(&other_id).await.unwrap().is_some()); + let closed_id = PaymentId(closed_txid.to_byte_array()); + assert!(wallet.payment_store.get(&closed_id).await.unwrap().is_some()); + assert!(wallet.pending_payment_store.get(&closed_id).await.unwrap().is_some()); + } + + /// A record that graduated while its pending entry lingers — the entry's removal is still + /// owed — loses the dropped round from its history but keeps the entry's pending copy of the + /// record: the pass that cleans up lingering entries goes by that copy, and a graduated one + /// would leave the entry behind for good. + #[tokio::test] + async fn dropping_leaves_the_entry_of_a_graduated_record_pending() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = PaymentId(txid.to_byte_array()); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + let mut update = PaymentDetailsUpdate::new(id); + update.status = Some(PaymentStatus::Succeeded); + wallet.payment_store.update(update).await.unwrap(); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(entry.details.status, PaymentStatus::Pending); + } + + /// The signing write failed between its two stores and the rollback failed as well, leaving + /// the payment record without its pending entry; the round was then reset. The replayed + /// signing event, finding the round gone, drops the half-written record — and leaves a fully + /// recorded round to the negotiation-failure handling. + #[tokio::test] + async fn a_replayed_signing_drops_the_half_written_record_of_a_reset_round() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let id = PaymentId(txid.to_byte_array()); + let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); + wallet.payment_store.insert_or_update(half_written).await.unwrap(); + + wallet.record_signed_funding(&tx, &[]).await.unwrap(); + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_signed_funding(&tx, &[]).await.unwrap(); + assert!(wallet.payment_store.get(&id).await.unwrap().is_some()); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); + } + + /// The signing write fails between its two stores — the payment record lands, the pending + /// entry does not — so the payment store is put back as it was, and the replayed event + /// records the round in full once the store recovers instead of building on a half-written + /// record. + #[tokio::test] + async fn a_failed_first_round_signing_write_leaves_no_half_written_record() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + let id = PaymentId(txid.to_byte_array()); + + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.record_signed_funding(&tx, &candidates).await.is_err()); + assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 1); + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + + fail_store.fail_writes.store(false, Ordering::Release); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + } + + /// The same failure while signing a fee bump: the record is put back to the original round, + /// figures included, rather than left pointing at a bump the pending entry knows nothing of. + #[tokio::test] + async fn a_failed_bump_signing_write_restores_the_prior_round() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = PaymentId(txid.to_byte_array()); + let prior = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.record_signed_funding(&bump_tx, &bump_candidates).await.is_err()); + assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 1); + + assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(prior)); + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); + } + + /// The candidates handed to the signing-time recording are the channel's pending splice + /// rounds that have a transaction — negotiated predecessors and the round awaiting + /// signatures, in LDK's order, each with this node's contribution to it. A contribution + /// still queued behind the pending rounds has no transaction and is left out. + #[test] + fn funding_candidates_list_the_rounds_with_a_transaction() { + use lightning::chain::chaininterface::FundingPurpose; + use lightning::ln::channel_state::{ + SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails, + }; + + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let prior_txid = Txid::from_byte_array([9u8; 32]); + let signing_txid = Txid::from_byte_array([10u8; 32]); + let contribution = test_funding_contribution_with_outputs(0, 253, &[]); + let details = SpliceDetails { + candidates: vec![ + SpliceCandidateDetails { + contribution: None, + status: SpliceCandidateStatus::Negotiated { + txid: prior_txid, + new_channel_value_satoshis: 100_000, + }, + }, + SpliceCandidateDetails { + contribution: Some(contribution.clone()), + status: SpliceCandidateStatus::AwaitingSignatures { + is_initiator: true, + funding_feerate_sat_per_1000_weight: 253, + new_channel_value_satoshis: 110_000, + txid: signing_txid, + }, + }, + SpliceCandidateDetails { + contribution: Some(test_funding_contribution_with_outputs(0, 500, &[])), + status: SpliceCandidateStatus::WaitingOnLock, + }, + ], + confirmed_candidate: None, + received_splice_locked_txid: None, + }; + + let candidates = funding_candidates(Some(&details), counterparty_node_id, channel_id); + + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].txid, prior_txid); + assert_eq!(candidates[0].channels.len(), 1); + assert_eq!(candidates[0].channels[0].contribution, None); + assert_eq!(candidates[1].txid, signing_txid); + assert_eq!(candidates[1].channels.len(), 1); + assert_eq!(candidates[1].channels[0].counterparty_node_id, counterparty_node_id); + assert_eq!(candidates[1].channels[0].channel_id, channel_id); + assert_eq!(candidates[1].channels[0].purpose, FundingPurpose::Splice); + assert_eq!(candidates[1].channels[0].contribution, Some(contribution)); + + assert!(funding_candidates(None, counterparty_node_id, channel_id).is_empty()); + } + #[test] fn funding_reclassification_update_substitutes_the_confirmed_candidate() { let confirmed_txid = Txid::from_byte_array([1u8; 32]); @@ -4136,11 +5636,13 @@ mod tests { txid: confirmed_txid, amount_msat: Some(2_000_000), fee_paid_msat: Some(999), + awaiting_broadcast: false, }, FundingTxCandidate { txid: active_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }, ]; let details = onchain_details(active_txid, ConfirmationStatus::Unconfirmed); @@ -4159,6 +5661,7 @@ mod tests { txid: confirmed_txid, amount_msat: None, fee_paid_msat: None, + awaiting_broadcast: false, }]; let update = funding_reclassification_update(details.clone(), &uncontributed, Some(¤t)); @@ -4174,6 +5677,7 @@ mod tests { txid: active_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let details = onchain_details(active_txid, ConfirmationStatus::Unconfirmed); @@ -4351,16 +5855,19 @@ mod tests { txid: txid1, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }, FundingTxCandidate { txid: txid2, amount_msat: Some(1_000_000), fee_paid_msat: Some(600), + awaiting_broadcast: false, }, FundingTxCandidate { txid: txid3, amount_msat: Some(1_000_000), fee_paid_msat: Some(700), + awaiting_broadcast: false, }, ]; let details = interactive_funding_details(payment_id, txid3, Some(1_000_000), Some(700)); @@ -4490,6 +5997,7 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); @@ -4560,6 +6068,7 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); @@ -4623,11 +6132,13 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }, FundingTxCandidate { txid: bumped_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(600), + awaiting_broadcast: false, }, ]; let details = @@ -4679,6 +6190,7 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); @@ -4730,11 +6242,13 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }, FundingTxCandidate { txid: live_candidate_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(600), + awaiting_broadcast: false, }, ]; let details = @@ -4796,6 +6310,7 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); @@ -4846,6 +6361,7 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); @@ -5001,6 +6517,7 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let mut details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); @@ -5034,7 +6551,7 @@ mod tests { /// A funding-typed broadcast that doesn't touch the on-chain wallet must not be recorded. /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding - /// path, so a splice the interactive-funding classification deliberately declined — no local + /// path, so a splice the signing-time recording deliberately declined — no local /// contribution, or none of the moved funds are the wallet's — would otherwise come back as /// a spurious zero-amount record that nothing ever confirms. #[tokio::test] @@ -5130,6 +6647,7 @@ mod tests { txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); wallet.persist_funding_payment(details, candidates).await.unwrap(); @@ -5174,11 +6692,11 @@ mod tests { assert_unchanged(&wallet, payment_id, true).await; } - /// A funding broadcast whose classification fails must be retried, not dropped: for - /// interactive funding the counterparty broadcasts the same transaction regardless of - /// whether we do, so dropping the package permanently leaves the confirming transaction - /// unrecorded as a candidate — and the funding-status ownership gate then routes its - /// confirmation to a stray duplicate record instead of the funding record. + /// A funding broadcast whose classification fails must be retried, not dropped: no timer + /// re-broadcasts a funding transaction, so a dropped package would keep the funding off-chain + /// until LDK re-hands it when the channel next resumes. The record is written before the + /// broadcast so that the confirmation refreshes it rather than minting an untyped record that + /// the retried classification types only once it lands. #[tokio::test] async fn failed_funding_classification_is_retried_not_dropped() { use lightning::chain::chaininterface::BroadcasterInterface; @@ -5567,11 +7085,13 @@ mod tests { txid: txid1, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }, FundingTxCandidate { txid: txid2, amount_msat: Some(2_000_000), fee_paid_msat: Some(999), + awaiting_broadcast: false, }, ]; let details = interactive_funding_details(payment_id, txid2, Some(2_000_000), Some(999)); @@ -5657,6 +7177,7 @@ mod tests { txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 83e80c104..ff02e8ddd 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -45,7 +45,7 @@ use ldk_node::payment::{ ConfirmationStatus, ForwardedPaymentId, PayerProofOptions, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType}; +use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType, UserChannelId}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -55,12 +55,13 @@ 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. +/// Waits until `node` has recorded the funding broadcast `funding_txid` (a channel open or splice +/// candidate) as a payment carrying a `tx_type`. A splice contributor records the payment when it +/// signs the funding transaction, before the transaction can even be broadcast, so for splices +/// this settles immediately and only stabilizes assertion timing. A channel open is classified off +/// the broadcaster's queue, which can lag a `sync_wallets` call under load; waiting 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 { @@ -89,6 +90,8 @@ struct ContendedStore { serializer: Arc>, block_writes: Arc, wallet_write_started: Arc, + /// When set, only writes to this primary namespace go through `serializer`; the rest bypass it. + serialized_namespace: Option, } impl KVStore for ContendedStore { @@ -105,6 +108,8 @@ impl KVStore for ContendedStore { let serializer = Arc::clone(&self.serializer); let block_writes = Arc::clone(&self.block_writes); let wallet_write_started = Arc::clone(&self.wallet_write_started); + let serialized = + self.serialized_namespace.as_deref().map_or(true, |ns| ns == primary_namespace); let primary_namespace = primary_namespace.to_string(); let secondary_namespace = secondary_namespace.to_string(); let key = key.to_string(); @@ -112,7 +117,7 @@ impl KVStore for ContendedStore { if block_writes.load(Ordering::Acquire) { wallet_write_started.notify_one(); } - let _guard = serializer.read().await; + let _guard = if serialized { Some(serializer.read().await) } else { None }; KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await } } @@ -162,6 +167,7 @@ fn wallet_store_contention_does_not_stall_runtime() { serializer: Arc::new(tokio::sync::RwLock::new(())), block_writes: Arc::new(AtomicBool::new(false)), wallet_write_started: Arc::new(tokio::sync::Notify::new()), + serialized_namespace: None, }; let node = builder .build_with_store(test_config.node_entropy.into(), store.clone()) @@ -2219,8 +2225,6 @@ async fn splice_channel() { let txo = 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; wait_for_tx(&electrsd.client, txo.txid).await; @@ -2279,8 +2283,6 @@ async fn splice_channel() { let txo = expect_splice_negotiated_event!(node_a, node_b.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; wait_for_tx(&electrsd.client, txo.txid).await; @@ -2465,6 +2467,12 @@ async fn zero_conf_splice_in_funding_rebroadcast_canary() { node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000).unwrap(); let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); wait_for_classified_funding_payment(&node_a, txo.txid).await; + // Node A recorded the round when signing it; `SpliceNegotiated`, handled before the user event + // above was queued, marked it broadcast. + assert!( + logger_a.wait_for(&format!("{} {} of channel", ROUND_MARKED_BROADCAST, txo.txid)).await, + "node A never marked the negotiated splice round as broadcast" + ); // The 0conf splice locks without confirmations, re-signaled as `ChannelReady`. expect_channel_ready_event!(node_a, node_b.node_id()); @@ -2588,8 +2596,6 @@ 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(); @@ -2625,8 +2631,6 @@ 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(); @@ -2826,8 +2830,8 @@ async fn splice_payment_reorged_to_unconfirmed() { node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); let splice_txo = 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. + // node_b recorded the splice's funding payment when signing it, so the sync below 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 @@ -2918,6 +2922,235 @@ async fn splice_in_rbf_joins_counterparty_splice() { node_b.stop().unwrap(); } +/// Builds and starts a node over a [`ContendedStore`], whose writes — all of them, or only those +/// to `serialized_namespace` — a test holds back by taking the store's `serializer` write lock, +/// logging into a [`CollectingLogWriter`]. +fn setup_contended_node( + chain_source: &TestChainSource, mut config: TestConfig, serialized_namespace: Option<&str>, +) -> (TestNode, ContendedStore, Arc) { + let logs = Arc::new(CollectingLogWriter::new()); + config.log_writer = TestLogWriter::Custom(logs.clone()); + let store = ContendedStore { + inner: Arc::new(InMemoryStore::new()), + serializer: Arc::new(tokio::sync::RwLock::new(())), + block_writes: Arc::new(AtomicBool::new(false)), + wallet_write_started: Arc::new(tokio::sync::Notify::new()), + serialized_namespace: serialized_namespace.map(str::to_string), + }; + setup_builder!(builder, config.node_config); + common::configure_chain_source(chain_source, &mut builder, &config); + if let TestLogWriter::Custom(writer) = &config.log_writer { + builder.set_custom_logger(Arc::clone(writer)); + } + let node = builder.build_with_store(config.node_entropy.into(), store.clone()).unwrap(); + node.start().unwrap(); + (node, store, logs) +} + +/// Has `node_b` fund a channel to `node_a` and a splice into it, leaving `node_a` to join that +/// pending splice. `node_a` gets one small UTXO and `node_b` one large one; `node_b` opens the +/// channel and splices in from its change. A `splice_in` by `node_a` then joins the pending splice +/// as an RBF round it initiates, whose contributed input value — the shared funding, which the +/// initiator counts as its own, plus `node_a`'s UTXO — is the smaller, so `node_a` sends its +/// `tx_signatures` first. Returns `node_a`'s id for the channel. +async fn open_and_splice_from_counterparty( + bitcoind: &BitcoinD, electrsd: &ElectrsD, node_a: &TestNode, node_b: &TestNode, +) -> UserChannelId { + let address_a = node_a.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(1_000_000), + ) + .await; + let address_b = node_b.onchain_payment().new_address().unwrap(); + distribute_funds_unconfirmed( + &bitcoind.client, + &electrsd.client, + vec![address_b], + Amount::from_sat(10_000_000), + ) + .await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(node_b, node_a, 500_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.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + let counterparty_txo = 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(); + user_channel_id_a +} + +/// The transaction of `node`'s only payment typed as interactive funding. +fn only_interactive_funding_txid(node: &TestNode) -> Txid { + let mut txids = node.list_all_payments().into_iter().filter_map(|p| match p.kind { + PaymentKind::Onchain { + txid, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } => Some(txid), + _ => None, + }); + let txid = txids.next().expect("no interactive funding payment recorded"); + assert_eq!(txids.next(), None, "more than one interactive funding payment recorded"); + txid +} + +/// Logged by a node once it has signed a splice round of its own. +const SIGNED_FUNDING: &str = "Signed funding transaction for channel"; +/// Logged by a node once LDK reports a splice round it recorded when signing negotiated, and the +/// round's funding payment no longer awaits its broadcast. +const ROUND_MARKED_BROADCAST: &str = "Marked splice round"; +/// Logged by LDK's channel manager as it hands a fully signed splice round to the broadcaster. +const BROADCAST_FUNDING: &str = "Broadcasting interactively funded transaction with txid"; +/// Logged by LDK's peer handler when the counterparty's `tx_signatures` arrive. +const RECEIVED_TX_SIGNATURES: &str = "Received message TxSignatures"; +/// Logged by LDK's peer handler when the counterparty's `commitment_signed` arrives. +const RECEIVED_COMMITMENT_SIGNED: &str = "Received message CommitmentSigned"; + +/// A splice round this node signed stays recorded when the channel closes before the +/// counterparty's `tx_signatures` arrive, if the channel's monitor watches the round. The monitor +/// does so from the counterparty's `commitment_signed` on, and this node's signatures cannot have +/// left before that message, so the counterparty may hold the fully signed transaction and +/// broadcast it. Taking the record back at `ChannelClosed` — as the handler did for every round +/// but the channel's last funding — left such a broadcast to resurface as an untyped payment. +/// +/// The state is reached by holding back store writes, which each node's event handler makes +/// before it signs: node A's payment-store writes first, so it signs only after node B has +/// signed and sent its `commitment_signed` — its other writes go through, so a pending monitor +/// update cannot freeze the channel's own messages; then all of node B's, so the monitor update +/// its copy of node A's `commitment_signed` needs never completes and node B withholds its +/// `tx_signatures` on receiving node A's. Node A sends its `tx_signatures` first, see +/// [`open_and_splice_from_counterparty`]. Pinned to Esplora so node A's wallet syncs only on +/// demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, store_a, logs_a) = + setup_contended_node(&chain_source, random_config(), Some("payments")); + let (node_b, store_b, logs_b) = setup_contended_node(&chain_source, random_config(), None); + let user_channel_id_a = + open_and_splice_from_counterparty(&bitcoind, &electrsd, &node_a, &node_b).await; + + // Both nodes signed and exchanged signatures for node B's splice already; count from here. + let signed_a = logs_a.count(SIGNED_FUNDING); + let signed_b = logs_b.count(SIGNED_FUNDING); + let received_a = logs_a.count(RECEIVED_TX_SIGNATURES); + let received_b = logs_b.count(RECEIVED_TX_SIGNATURES); + let broadcast_b = logs_b.count(BROADCAST_FUNDING); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000).unwrap(); + // Recording the round writes the payment store before the round is signed, so node A does not + // sign while those writes are held, and node B's `commitment_signed` is stashed until it has. + let hold_a = Arc::clone(&store_a.serializer).write_owned().await; + assert!(logs_b.wait_for_count(SIGNED_FUNDING, signed_b + 1).await, "node B never signed"); + // Node B has sent its `commitment_signed`. Its next write is the monitor update for node A's, + // which it needs before it releases its own `tx_signatures`. + let hold_b = Arc::clone(&store_b.serializer).write_owned().await; + drop(hold_a); + assert!(logs_a.wait_for_count(SIGNED_FUNDING, signed_a + 1).await, "node A never signed"); + assert!( + logs_b.wait_for_count(RECEIVED_TX_SIGNATURES, received_b + 1).await, + "node A's signatures never reached node B" + ); + assert_eq!( + logs_a.count(RECEIVED_TX_SIGNATURES), + received_a, + "node B did not withhold its signatures" + ); + let rbf_txid = only_interactive_funding_txid(&node_a); + + node_a.disconnect(node_b.node_id()).unwrap(); + node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + expect_event!(node_a, ChannelClosed); + + let payment = node_a + .list_all_payments() + .into_iter() + .find(|p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == rbf_txid)) + .expect("the signed round's record was taken back with the channel"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } + )); + + // With its monitor update through, node B holds both signature sets and broadcasts the round + // on its own: the kept record describes a transaction that may yet confirm. + drop(hold_b); + assert!( + logs_b.wait_for_count(BROADCAST_FUNDING, broadcast_b + 1).await, + "node B never broadcast the round it held both signature sets for" + ); + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice round this node signed is taken back at `ChannelClosed` when the counterparty's +/// `commitment_signed` never arrived. The round is recorded at signing, which LDK triggers at +/// `tx_complete`, before that message, and the monitor watches no round that message never +/// reached; this node's signatures cannot have left for such a round, so nothing can broadcast +/// it. Node B's writes are held from before the join: recording a round precedes signing it, so +/// node B never signs, never sends its `commitment_signed`, and node A's monitor never learns of +/// the round. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, _store_a, logs_a) = setup_contended_node(&chain_source, random_config(), None); + let (node_b, store_b, logs_b) = setup_contended_node(&chain_source, random_config(), None); + let user_channel_id_a = + open_and_splice_from_counterparty(&bitcoind, &electrsd, &node_a, &node_b).await; + + let signed_a = logs_a.count(SIGNED_FUNDING); + let signed_b = logs_b.count(SIGNED_FUNDING); + let committed_a = logs_a.count(RECEIVED_COMMITMENT_SIGNED); + + let hold_b = Arc::clone(&store_b.serializer).write_owned().await; + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000).unwrap(); + assert!(logs_a.wait_for_count(SIGNED_FUNDING, signed_a + 1).await, "node A never signed"); + let rbf_txid = only_interactive_funding_txid(&node_a); + assert_eq!(logs_b.count(SIGNED_FUNDING), signed_b, "node B signed with its writes held"); + assert_eq!( + logs_a.count(RECEIVED_COMMITMENT_SIGNED), + committed_a, + "node B's commitment_signed reached node A" + ); + + node_a.disconnect(node_b.node_id()).unwrap(); + node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + expect_event!(node_a, ChannelClosed); + + assert!( + node_a + .list_all_payments() + .iter() + .all(|p| !matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == rbf_txid)), + "the record of a round the monitor never watched was kept" + ); + + drop(hold_b); + 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 914f823d7a04f206fb630eae77bd51f3134e9613 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 10 Sep 2026 22:59:56 -0500 Subject: [PATCH 09/37] f - Record splice funding payments when signing Reword what the comments say LDK reports for a splice round still awaiting the counterparty's signatures when its channel closes, and when `SpliceNegotiated` is emitted, so they hold once the pinned LDK carries the fix for rust-lightning issue 4967 (https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4967): the channel manager then reports such rounds after `ChannelClosed`, and emits `SpliceNegotiated` at a force-close for a round whose `tx_signatures` were ready to send, without any broadcast. At both revisions the event means our signatures were ready to send, so the counterparty may hold them; the comments no longer say it does. The test of a round the monitor never watched notes what LDK reports for it once fixed. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/event.rs | 26 ++++++++++++++------------ src/payment/pending_payment_store.rs | 8 ++++---- src/wallet/mod.rs | 25 +++++++++++++------------ tests/integration_tests_rust.rs | 6 ++++++ 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/event.rs b/src/event.rs index ca35ad14e..28241ada3 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1944,13 +1944,15 @@ where log_info!(self.logger, "Channel {} closed due to: {}", channel_id, reason); // A splice round this node signed dies with the channel unless LDK had already - // handed it to the broadcaster. LDK reports no failed negotiation for a round still - // awaiting the counterparty's signatures when the channel closes, so its record is - // taken back here. The channel manager holds only the closed channel's last funding, - // but the channel's monitor still watches every round the counterparty committed - // to, and our signatures may have left the node for such a round, so it is kept - // (see `closed_channel_held_rounds`). The monitor's guard is not `Send`, so its - // watched transactions are collected before anything is awaited. + // handed it to the broadcaster. Whatever the channel manager reports for a round + // still awaiting the counterparty's signatures when the channel closes is queued + // after this event, so its record is taken back here; nothing is reported before + // https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4967 is fixed. + // The channel manager holds only the closed channel's last funding, but the + // channel's monitor still watches every round the counterparty committed to, and + // our signatures may have left the node for such a round, so it is kept (see + // `closed_channel_held_rounds`). The monitor's guard is not `Send`, so its watched + // transactions are collected before anything is awaited. let watched_txids: Vec = self .chain_monitor .get_monitor(channel_id) @@ -2293,11 +2295,11 @@ where new_funding_txo, ); - // LDK emits this event as it hands the fully signed round to the broadcaster: the - // counterparty holds our signatures now and may broadcast on its own, so the - // round's funding payment, recorded when the round was signed, no longer awaits - // broadcast. On a failed write, replay: LDK re-offers the event in-session and - // persists it across restarts. + // LDK emits this event only once our `tx_signatures` for the round are ready to + // send, so the counterparty may already hold them and may broadcast the round + // without us. The round's funding payment, recorded when the round was signed, + // therefore no longer awaits broadcast. On a failed write, replay: LDK re-offers + // the event in-session and persists it across restarts. if let Err(e) = self .wallet .record_broadcast_splice_round(channel_id, new_funding_txo.txid) diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 517a3db81..9a1d6da11 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -28,12 +28,12 @@ pub(crate) struct FundingTxCandidate { /// 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, - /// Whether this node signed the candidate but the signatures have yet to be exchanged. Set + /// Whether this node signed the candidate but LDK has yet to report the round negotiated. Set /// when the round is recorded at signing time, cleared when LDK reports the splice negotiated - /// (`SpliceNegotiated`, emitted as it hands the fully signed round to the broadcaster). Only - /// such a round can be abandoned without a trace — the counterparty aborts, or the channel + /// (`SpliceNegotiated`, emitted only once our `tx_signatures` for the round are ready to send). + /// Such a round may be abandoned without a trace — the counterparty aborts, or the channel /// closes, before the signatures are exchanged — so only such a round may be dropped from the - /// history. + /// history, and only once LDK no longer holds it. pub awaiting_broadcast: bool, } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 960194f27..43089d646 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2053,11 +2053,11 @@ impl Wallet { } /// Marks a splice round recorded when signing ([`Self::record_signed_funding`]) as broadcast - /// once LDK reports the splice negotiated: `SpliceNegotiated` is emitted as LDK hands the fully - /// signed round to the broadcaster, so the counterparty holds our signatures by then and the - /// round can no longer be abandoned without a trace. Nothing is written for a round no funding - /// payment of `channel_id` tracks (no local contribution, or no wallet-level activity) or one - /// already marked (a replayed event). + /// once LDK reports the splice negotiated: `SpliceNegotiated` is emitted only once our + /// `tx_signatures` for the round are ready to send, so the counterparty may hold them by then + /// and may broadcast the round, which is therefore no longer dropped as abandoned. Nothing is + /// written for a round no funding payment of `channel_id` tracks (no local contribution, or no + /// wallet-level activity) or one already marked (a replayed event). pub(crate) async fn record_broadcast_splice_round( &self, channel_id: ChannelId, txid: Txid, ) -> Result<(), Error> { @@ -3015,13 +3015,14 @@ pub(crate) fn held_splice_rounds( /// The splice rounds a closed channel may still see confirm, as /// [`Wallet::drop_abandoned_splice_rounds`] takes them: the channel's last funding — which a /// zero-conf splice may have become before its transaction confirmed — and every transaction the -/// channel's monitor still watches. The channel manager forgets a pending round with the channel -/// and reports no failed negotiation for one awaiting the counterparty's signatures, but the -/// monitor keeps watching every round the counterparty's `commitment_signed` reached, and our -/// signatures cannot have left the node before that message: such a round may yet confirm and is -/// left to wallet sync or `DiscardFunding` to resolve, while a round the monitor never watched -/// never had our signatures released. The watched transactions also include the funding and -/// whatever spent it on chain, which no recorded round is. +/// channel's monitor still watches. The channel manager forgets a pending round with the channel, +/// and what it reports for one awaiting the counterparty's signatures — nothing before +/// is fixed — is queued +/// after `ChannelClosed`, but the monitor keeps watching every round the counterparty's +/// `commitment_signed` reached, and our signatures cannot have left the node before that message: +/// such a round may yet confirm and is left to wallet sync or `DiscardFunding` to resolve, while a +/// round the monitor never watched never had our signatures released. The watched transactions also +/// include the funding and whatever spent it on chain, which no recorded round is. pub(crate) fn closed_channel_held_rounds( funding_txo: Option, watched_txids: impl IntoIterator, ) -> Vec { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index ff02e8ddd..40aa5942c 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3109,6 +3109,12 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { /// it. Node B's writes are held from before the join: recording a round precedes signing it, so /// node B never signs, never sends its `commitment_signed`, and node A's monitor never learns of /// the round. +/// +/// Once is fixed, LDK +/// reports the round itself after `ChannelClosed`: a `DiscardFunding` for node A's contribution, +/// whose handling reclaims its addresses, and a `SpliceNegotiationFailed` the node reports with +/// reason `ChannelClosing` and no parameters, its intent having been cleared at the close. Assert +/// both once the pinned LDK carries the fix; the assertion below holds either way. #[cfg(feature = "chain-esplora")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { From 63a079bcbf4a5c48e57afdcb2dd06358b759d32f Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 21 Sep 2026 16:19:02 -0700 Subject: [PATCH 10/37] f - Record splice funding payments when signing The pinned LDK now carries the fix for rust-lightning issue 4967 (https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4967), so the comments no longer hedge on when the channel manager reports a splice round still awaiting the counterparty's signatures at the close, and the test of a round the monitor never watched asserts what LDK reports for it after `ChannelClosed`: the `SpliceNegotiationFailed` the node passes on and the `DiscardFunding` whose handling reclaims node A's addresses. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/event.rs | 13 ++++++------- src/wallet/mod.rs | 5 ++--- tests/integration_tests_rust.rs | 16 +++++++++++----- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/event.rs b/src/event.rs index 28241ada3..955658a1d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1946,13 +1946,12 @@ where // A splice round this node signed dies with the channel unless LDK had already // handed it to the broadcaster. Whatever the channel manager reports for a round // still awaiting the counterparty's signatures when the channel closes is queued - // after this event, so its record is taken back here; nothing is reported before - // https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4967 is fixed. - // The channel manager holds only the closed channel's last funding, but the - // channel's monitor still watches every round the counterparty committed to, and - // our signatures may have left the node for such a round, so it is kept (see - // `closed_channel_held_rounds`). The monitor's guard is not `Send`, so its watched - // transactions are collected before anything is awaited. + // after this event, so its record is taken back here. The channel manager holds + // only the closed channel's last funding, but the channel's monitor still watches + // every round the counterparty committed to, and our signatures may have left the + // node for such a round, so it is kept (see `closed_channel_held_rounds`). The + // monitor's guard is not `Send`, so its watched transactions are collected before + // anything is awaited. let watched_txids: Vec = self .chain_monitor .get_monitor(channel_id) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 43089d646..f834749b0 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3016,9 +3016,8 @@ pub(crate) fn held_splice_rounds( /// [`Wallet::drop_abandoned_splice_rounds`] takes them: the channel's last funding — which a /// zero-conf splice may have become before its transaction confirmed — and every transaction the /// channel's monitor still watches. The channel manager forgets a pending round with the channel, -/// and what it reports for one awaiting the counterparty's signatures — nothing before -/// is fixed — is queued -/// after `ChannelClosed`, but the monitor keeps watching every round the counterparty's +/// and what it reports for one awaiting the counterparty's signatures is queued after +/// `ChannelClosed`, but the monitor keeps watching every round the counterparty's /// `commitment_signed` reached, and our signatures cannot have left the node before that message: /// such a round may yet confirm and is left to wallet sync or `DiscardFunding` to resolve, while a /// round the monitor never watched never had our signatures released. The watched transactions also diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 40aa5942c..fa8480041 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3017,6 +3017,8 @@ const BROADCAST_FUNDING: &str = "Broadcasting interactively funded transaction w const RECEIVED_TX_SIGNATURES: &str = "Received message TxSignatures"; /// Logged by LDK's peer handler when the counterparty's `commitment_signed` arrives. const RECEIVED_COMMITMENT_SIGNED: &str = "Received message CommitmentSigned"; +/// Logged by a node as it returns the addresses of a contribution LDK discarded to the wallet. +const RECLAIMED_ADDRESSES: &str = "Reclaiming unused addresses from channel"; /// A splice round this node signed stays recorded when the channel closes before the /// counterparty's `tx_signatures` arrive, if the channel's monitor watches the round. The monitor @@ -3110,11 +3112,9 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { /// node B never signs, never sends its `commitment_signed`, and node A's monitor never learns of /// the round. /// -/// Once is fixed, LDK -/// reports the round itself after `ChannelClosed`: a `DiscardFunding` for node A's contribution, -/// whose handling reclaims its addresses, and a `SpliceNegotiationFailed` the node reports with -/// reason `ChannelClosing` and no parameters, its intent having been cleared at the close. Assert -/// both once the pinned LDK carries the fix; the assertion below holds either way. +/// LDK reports the round itself after `ChannelClosed`: a `DiscardFunding` for node A's +/// contribution, whose handling reclaims its addresses, and a `SpliceNegotiationFailed` the node +/// passes on. #[cfg(feature = "chain-esplora")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { @@ -3128,6 +3128,7 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { let signed_a = logs_a.count(SIGNED_FUNDING); let signed_b = logs_b.count(SIGNED_FUNDING); let committed_a = logs_a.count(RECEIVED_COMMITMENT_SIGNED); + let reclaimed_a = logs_a.count(RECLAIMED_ADDRESSES); let hold_b = Arc::clone(&store_b.serializer).write_owned().await; node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000).unwrap(); @@ -3143,6 +3144,11 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { node_a.disconnect(node_b.node_id()).unwrap(); node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); expect_event!(node_a, ChannelClosed); + expect_event!(node_a, SpliceNegotiationFailed); + assert!( + logs_a.wait_for_count(RECLAIMED_ADDRESSES, reclaimed_a + 1).await, + "node A's contribution to the discarded round was not reclaimed" + ); assert!( node_a From 2314b7390885f770ef59a7d2ff79c8d79c720245 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 22 Sep 2026 10:08:03 -0700 Subject: [PATCH 11/37] f - Record splice funding payments when signing Take the payment store's prior record from the funding write's own read, instead of reading it once more just before the write: the rollback of a failed write pair puts that record back, and a write that failed before reading has nothing to roll back. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 81 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index f834749b0..f729234a9 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2018,9 +2018,14 @@ impl Wallet { // The write pair can fail between its two stores. The lock keeps the other writers of this // record out, bar graduation, which only ever moves a record out of `Pending`: put the // payment store back as it was while the record is still pending, or the replayed event - // would find the half-written record and take it for prior state. - let prior_details = self.payment_store.get(&payment_id).await?; - if let Err(e) = self.persist_funding_payment_locked(&guard, details, recorded).await { + // would find the half-written record and take it for prior state. The write hands back + // what it found in the payment store, read inside its own critical section. + if let Err(failure) = self.persist_funding_payment_locked(&guard, details, recorded).await { + let (e, prior_details) = match failure { + // The write pair failed before its first write, so there is nothing to put back. + FundingWriteError::Unread(e) => return Err(e), + FundingWriteError::Failed { error, prior } => (error, prior), + }; let rollback = match &prior_details { Some(prior) => self .payment_store @@ -2375,15 +2380,20 @@ impl Wallet { // Hold the cross-store lock across both writes so a funding confirmation never observes // the record classified but the candidate history it needs still missing. let guard = self.funding_payment_update_lock.lock().await; - self.persist_funding_payment_locked(&guard, details, candidates).await + self.persist_funding_payment_locked(&guard, details, candidates) + .await + .map(|_| ()) + .map_err(Error::from) } /// [`Self::persist_funding_payment`] for a caller already holding the cross-store lock, whose - /// reads the write must not be separated from. + /// reads the write must not be separated from. Returns the payment store's record as it was + /// before the write, read inside the write's own critical section, so a caller needs no read of + /// its own to know what the write merged into. async fn persist_funding_payment_locked( &self, _guard: &tokio::sync::MutexGuard<'_, ()>, details: PaymentDetails, candidates: Vec, - ) -> Result<(), Error> { + ) -> Result, FundingWriteError> { // Everything this write does depends on the record's current state, so all of it must be // decided inside the store's critical section. When a record exists — no matter when it // appeared — only the classification (`tx_type`) and the figures of whichever candidate @@ -2395,12 +2405,13 @@ impl Wallet { // the update still names the actively-broadcast candidate, the confirmed-figures guard // then rightly refuses it, and the record is left with figures no classification derived. let id = details.id; - let mut update = None; - self.payment_store + let mut seen = None; + let written = self + .payment_store .mutate(&id, |existing| { let reclassification = funding_reclassification_update(details.clone(), &candidates, existing); - update = Some(reclassification.clone()); + seen = Some((existing.cloned(), reclassification.clone())); match existing { None => Some(details.clone()), Some(current) => { @@ -2409,8 +2420,14 @@ impl Wallet { }, } }) - .await?; - let update = update.expect("the mutate closure always runs"); + .await; + // The closure runs only once the record has been read, so a write that failed before it ran + // wrote nothing. + written.map_err(|error| match &seen { + Some((prior, _)) => FundingWriteError::Failed { error, prior: prior.clone() }, + None => FundingWriteError::Unread(error), + })?; + let (prior, update) = seen.expect("the mutate closure always runs"); // The pending index must exist exactly while the authoritative record is Pending: // graduation and rebroadcast read it, and a graduated payment must not be re-indexed. @@ -2455,8 +2472,9 @@ impl Wallet { }, }) }) - .await?; - Ok(()) + .await + .map_err(|error| FundingWriteError::Failed { error, prior: prior.clone() })?; + Ok(prior) } /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. @@ -3367,6 +3385,23 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { ) } +/// How a funding-record write pair ([`Wallet::persist_funding_payment_locked`]) failed. +enum FundingWriteError { + /// The payment store could not be read, so nothing was written. + Unread(Error), + /// A write failed after the payment store's record was read; `prior` is that record, for a + /// caller to put the store back to. + Failed { error: Error, prior: Option }, +} + +impl From for Error { + fn from(failure: FundingWriteError) -> Self { + match failure { + FundingWriteError::Unread(error) | FundingWriteError::Failed { error, .. } => error, + } + } +} + /// Builds the payment-store update for a freshly classified funding payment. `details` describes /// the actively broadcast candidate, but when the record already confirmed a *different* /// candidate — wallet sync saw it win before this classification ran — the update instead carries @@ -5499,6 +5534,26 @@ mod tests { assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); } + /// Recording a first splice round costs one read of the payment store: the write pair reads + /// the record it merges into, and that read also serves the rollback of a failed write. + #[tokio::test] + async fn a_first_round_signing_reads_the_payment_store_once() { + let counting_store = ReadCountingStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + + let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; + assert_eq!(reads, 1, "recording a first round re-read the payment store"); + } + /// The signing write fails between its two stores — the payment record lands, the pending /// entry does not — so the payment store is put back as it was, and the replayed event /// records the round in full once the store recovers instead of building on a half-written From ee6397cb83c7ddfb60d46411e224b021b49174a7 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 22 Sep 2026 10:09:05 -0700 Subject: [PATCH 12/37] f - Record splice funding payments when signing Decide whether a record still waits on the abandoned rounds inside the write that hands the remaining round back, from the record found there, instead of reading the record first; only a removal, which has no such critical section, still reads. The entry's copy of a record that graduated meanwhile is now left alone on both paths, where before that depended on whether the graduation landed before or after the read. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 104 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 72 insertions(+), 32 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index f729234a9..49cdc61f2 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2184,36 +2184,23 @@ impl Wallet { if abandoned_txids.contains(txid) ) }; - let record = self.payment_store.get(&payment_id).await?; - let hands_back = record.as_ref().map_or(true, waits_on_abandoned); // A last remaining round without a contribution of ours means no remaining round has // one. let handed_back = remaining.last().filter(|round| round.amount_msat.is_some()); - if hands_back && handed_back.is_none() { - // Nothing of this node's was ever broadcast under the record, so it goes rather - // than fail a payment for a transaction that never existed. The payment record - // goes first: the entry keeps resolving the rounds' txids, so a removal that - // fails midway is finished by the replayed event. - self.payment_store.remove(&payment_id).await?; - self.pending_payment_store.remove(&payment_id).await?; - log_info!( - self.logger, - "Dropped abandoned splice round(s) {:?} and funding payment {} with them", - abandoned_txids, - payment_id, - ); - continue; - } - let mut mirrored = None; + let mut history_only = false; match handed_back { - Some(active) if hands_back => { + Some(active) => { + // Whether the record still waits on the dropped rounds is decided inside the + // write's critical section, from the record found there. self.payment_store .mutate(&payment_id, |existing| { let current = existing?; if !waits_on_abandoned(current) { - mirrored = Some(current.clone()); + history_only = true; + mirrored = Some(current.clone()) + .filter(|current| current.status == PaymentStatus::Pending); return None; } let mut update = PaymentDetailsUpdate::new(payment_id); @@ -2228,21 +2215,41 @@ impl Wallet { }) .await?; }, - _ => { - // The record does not wait on the dropped rounds: wallet sync moved it on, or - // an earlier drop was cut short between the two stores. Only its history - // shrinks, and the entry's copy of the record catches up with the record while - // the record is still pending. + None => { + // A removal has no critical section to decide in, so the record is read first. + let record = self.payment_store.get(&payment_id).await?; + if record.as_ref().map_or(true, waits_on_abandoned) { + // Nothing of this node's was ever broadcast under the record, so it goes + // rather than fail a payment for a transaction that never existed. The + // payment record goes first: the entry keeps resolving the rounds' txids, + // so a removal that fails midway is finished by the replayed event. + self.payment_store.remove(&payment_id).await?; + self.pending_payment_store.remove(&payment_id).await?; + log_info!( + self.logger, + "Dropped abandoned splice round(s) {:?} and funding payment {} with them", + abandoned_txids, + payment_id, + ); + continue; + } + history_only = true; mirrored = record.filter(|current| current.status == PaymentStatus::Pending); - log_warn!( - self.logger, - "Funding payment {} does not wait on abandoned splice round(s) {:?}: \ - dropping them from its history only", - payment_id, - abandoned_txids, - ); }, } + if history_only { + // The record does not wait on the dropped rounds: wallet sync moved it on, or an + // earlier drop was cut short between the two stores. Only its history shrinks, and + // the entry's copy of the record catches up with the record while the record is + // still pending. + log_warn!( + self.logger, + "Funding payment {} does not wait on abandoned splice round(s) {:?}: \ + dropping them from its history only", + payment_id, + abandoned_txids, + ); + } self.pending_payment_store .mutate(&payment_id, |existing| { let mut entry = existing?.clone(); @@ -5238,6 +5245,39 @@ mod tests { assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); } + /// The record moved on to a bump whose signing write was cut short after the payment store, + /// so the entry still lists only the original round. Abandoning that round, with no round of + /// ours remaining, leaves the record as it stands and only shrinks the entry's history, its + /// copy of the record catching up. + #[tokio::test] + async fn dropping_the_last_round_leaves_a_record_that_moved_on() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + // The bump's signing write landed in the payment store only. + let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let mut moved_on = PaymentDetailsUpdate::new(id); + moved_on.txid = Some(bump_txid); + wallet.payment_store.update(moved_on).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(payment.clone())); + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert!(record.candidates.is_empty()); + assert_eq!(record.details, payment); + } + /// A removal that was cut short between the two stores — the payment record went, the pending /// entry stayed — is finished by the replayed drop: the entry alone still resolves the round's /// txid, so it is what the replayed event finds and removes. From 475cfc3738890127d0940c378adb53457c0a9778 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 22 Sep 2026 10:10:27 -0700 Subject: [PATCH 13/37] f - Record splice funding payments when signing Say what became of a record whose abandoned rounds were never broadcast, and log both drops at debug: LDK and the event handler already report the failed negotiation at info, and what the wallet then did with its records is detail beneath that. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 49cdc61f2..83b474235 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2225,9 +2225,10 @@ impl Wallet { // so a removal that fails midway is finished by the replayed event. self.payment_store.remove(&payment_id).await?; self.pending_payment_store.remove(&payment_id).await?; - log_info!( + log_debug!( self.logger, - "Dropped abandoned splice round(s) {:?} and funding payment {} with them", + "Dropped abandoned splice round(s) {:?} and removed funding payment {}: nothing of ours \ + was broadcast under it", abandoned_txids, payment_id, ); @@ -2260,7 +2261,7 @@ impl Wallet { Some(entry) }) .await?; - log_info!( + log_debug!( self.logger, "Dropped abandoned splice round(s) {:?} from funding payment {}", abandoned_txids, From 4d4a85dfbf500a020846d4a927937bf47232a200 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 7 Sep 2026 13:11:34 -0500 Subject: [PATCH 14/37] Resolve funding payments when LDK discards a splice round A splice round this node signed is kept at `ChannelClosed` when the channel's monitor watches it: the counterparty committed to it, so our signatures may have left the node, and the counterparty may broadcast the round and see it confirm. A close the wallet sees as a conflict -- a cooperative close spending an input the round shares -- fails the payment once it confirms beyond the reorg depth, but nothing resolved such a record when a commitment transaction, which pays no wallet script, won instead. Once the close matures -- after the reorg delay for a counterparty's commitment transaction, and once the to_self_delay on our balance has passed for one of our own -- the monitor stops watching the rounds it kept and queues a `DiscardFunding` event for each, and the handler only reclaimed the contribution's addresses: the funding payment stayed `Pending` forever. Likewise for a round of ours that a sibling round this node did not contribute to replaced on an open channel: LDK discards our round as the sibling locks, and the payment stayed `Pending` for a transaction that can no longer confirm. Resolve the channel's funding payments by the rounds LDK holds. A round nothing ever broadcast is dropped first, as `ChannelClosed` already did, and with it a record no broadcast round of ours remains under. A payment is then left alone if a round of ours that LDK still holds remains in its record -- the round that locked, or one still pending -- or one LDK promoted to the funding before, and failed otherwise: no round of ours can confirm anymore, whether the channel closed on a commitment transaction or a round we did not contribute to locked. The rounds LDK holds are the channel's pending rounds and funding while the manager lists the channel, and once it does not, the funding its monitor settled on plus whatever the monitor still watches. The monitor is left out for a listed channel: its updates land after the manager's, deferred to the background processor's flush, so it may still watch a round the manager let go. The event names this node's contribution, not the round: the inputs and output scripts LDK returns of it. Matching that to a recorded round would take the parts of every contribution on record. LDK discards the round's siblings as it promotes the round and reports the promotion through `ChannelReady`, so that event resolves the payments of a listed channel instead: it records the promotion and resolves the channel's other payments by the rounds the manager holds once updated -- the promoted round, and whatever was negotiated behind it. For a channel the manager no longer lists it records the promotion alone and leaves the payments to the close. A `DiscardFunding` for a listed channel then only drops a round nothing broadcast that the manager no longer holds and reclaims the contribution's addresses. A zero-conf splice is promoted to the funding as `splice_locked` is exchanged, before its transaction confirms, and a later splice moves the funding on again: at the close neither the manager nor the monitor holds the earlier round, although it can still confirm, the later round descending from it. So the funding payment records each promotion LDK reports through `ChannelReady`, and a round promoted once counts as one that can confirm wherever the rounds LDK holds decide: as a sibling round is promoted, and when the channel closes. The monitor's events can reach the handler ahead of the channel's `ChannelClosed` when one sync delivers the close and its maturity: the channel manager polls the monitor's report of the close at the start of each event pass and on peer traffic, and the monitor's own events are handled right after the manager's. Each event then finds the channel still listed and leaves the payments, there being no promotion to resolve them. So `ChannelClosed` fails every payment of the channel left with no round of ours the monitor watches and none promoted before, and a `DiscardFunding` event for a channel the manager no longer lists resolves each record the same way, by the funding its monitor settled on and whatever it still watches. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/event.rs | 109 +++- src/payment/pending_payment_store.rs | 94 ++- src/wallet/mod.rs | 900 +++++++++++++++++++++++++-- tests/common/logging.rs | 5 + tests/integration_tests_rust.rs | 727 +++++++++++++++++++++- 5 files changed, 1767 insertions(+), 68 deletions(-) diff --git a/src/event.rs b/src/event.rs index 955658a1d..6d3b2037c 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1914,6 +1914,39 @@ where ); } + // A splice round LDK promoted to the funding — a zero-conf splice before its + // transaction confirms — can still confirm once a later splice builds on it and + // once the channel closes, when LDK holds it no longer, so its funding payment + // records the promotion and is kept at the close (see + // `closed_channel_held_rounds`). LDK discards the round's siblings as it promotes + // the round, so the channel's other funding payments are resolved now, by the + // rounds the channel manager holds once the channel is updated — the promoted + // round, and whatever was negotiated behind it — or left to the close for a + // channel the manager no longer lists (see + // `Wallet::resolve_promoted_splice_round`). + if let Some(funding_txo) = funding_txo { + let held_rounds = self.held_splice_rounds(counterparty_node_id, channel_id); + if let Err(e) = self + .wallet + .resolve_promoted_splice_round( + channel_id, + funding_txo.txid, + held_rounds.as_deref(), + ) + .await + { + log_error!( + self.logger, + "Failed to resolve the funding payments of channel {} as splice round \ + {} locked: {}", + channel_id, + funding_txo.txid, + e, + ); + return Err(ReplayEvent()); + } + } + self.liquidity_source .lsps2_service() .handle_channel_ready(user_channel_id, &channel_id, &counterparty_node_id) @@ -1948,10 +1981,14 @@ where // still awaiting the counterparty's signatures when the channel closes is queued // after this event, so its record is taken back here. The channel manager holds // only the closed channel's last funding, but the channel's monitor still watches - // every round the counterparty committed to, and our signatures may have left the - // node for such a round, so it is kept (see `closed_channel_held_rounds`). The - // monitor's guard is not `Send`, so its watched transactions are collected before - // anything is awaited. + // every pending round the counterparty committed to, and our signatures may have + // left the node for such a round, so it is kept (see `closed_channel_held_rounds`). + // A payment left with no round of ours the monitor watches, and none LDK promoted + // to the funding before, is failed: the monitor's `DiscardFunding` events settle + // such payments once the close matures, but reach the handler ahead of this event + // when one sync delivers the close and its maturity, and then find the channel + // still listed with every round held. The monitor's guard is not `Send`, so its + // watched transactions are collected before anything is awaited. let watched_txids: Vec = self .chain_monitor .get_monitor(channel_id) @@ -1961,12 +1998,11 @@ where .unwrap_or_default(); let held_rounds = closed_channel_held_rounds(channel_funding_txo, watched_txids); if let Err(e) = - self.wallet.drop_abandoned_splice_rounds(channel_id, &held_rounds).await + self.wallet.resolve_closed_channel_splice_rounds(channel_id, &held_rounds).await { log_error!( self.logger, - "Failed to drop the splice rounds of closed channel {} from its funding \ - payment: {}", + "Failed to resolve the funding payments of channel {} at its close: {}", channel_id, e, ); @@ -2030,6 +2066,65 @@ where } }, LdkEvent::DiscardFunding { channel_id, funding_info } => { + // LDK lets a splice round go with this event — a sibling round locked, or the + // channel's close matured — naming this node's contribution to the round rather + // than the round, so the event itself resolves no funding payment. For a channel + // the manager lists, the payments were resolved as the sibling's promotion was + // handled, from the rounds the manager holds (see + // `Wallet::resolve_promoted_splice_round`), and the event only takes back a round + // nothing broadcast that the manager no longer holds: its pending rounds and its + // funding, the monitor left out — its updates land after the manager's, deferred + // to the background processor's flush, so it may still watch a round the manager + // let go. For a channel the manager no longer lists — the monitor's events for the + // rounds of a closed channel — the funding its monitor settled on and whatever it + // still watches decide, as at `ChannelClosed`. The monitor's guard is not `Send`, + // so its state is collected before anything is awaited. + let channel = self + .channel_manager + .list_channels() + .into_iter() + .find(|channel| channel.channel_id == channel_id); + let resolved = match channel { + Some(channel) => { + let held_rounds = held_splice_rounds( + channel.splice_details.as_ref(), + channel.funding_txo, + ); + log_debug!( + self.logger, + "LDK discarded a splice round of channel {} while the channel is \ + listed: its funding payments were resolved as the channel's funding \ + locked, or are left to its close", + channel_id, + ); + self.wallet.drop_abandoned_splice_rounds(channel_id, &held_rounds).await + }, + None => { + let held_rounds = match self.chain_monitor.get_monitor(channel_id) { + Ok(monitor) => closed_channel_held_rounds( + Some(monitor.get_funding_txo()), + monitor.get_outputs_to_watch().into_iter().map(|(txid, _)| txid), + ), + Err(()) => Vec::new(), + }; + self.wallet + .resolve_closed_channel_splice_rounds(channel_id, &held_rounds) + .await + }, + }; + if let Err(e) = resolved { + log_error!( + self.logger, + "Failed to resolve the funding payments of channel {} for a discarded \ + splice round: {}", + channel_id, + e, + ); + return Err(ReplayEvent()); + } + + // TODO(#1037): once inputs are locked at coin selection, `inputs` are locks this + // event returns: unlock them here. if let FundingInfo::Contribution { inputs: _, outputs } = funding_info { log_info!( self.logger, diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 9a1d6da11..11f09d53f 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -55,13 +55,19 @@ pub struct PendingPaymentDetails { /// 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, + /// The candidates LDK promoted to the channel's funding, as `ChannelReady` reported them. A + /// zero-conf splice locks before its transaction confirms, and every later splice builds on + /// it, so such a round can still confirm once the channel's funding has moved on from it and + /// once the channel has closed, when LDK holds it no longer. Kept apart from the candidates, + /// which each funding-record write replaces as a whole. + pub(crate) locked_rounds: Vec, } impl PendingPaymentDetails { pub(crate) fn new( details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, ) -> Self { - Self { details, conflicting_txids, candidates } + Self { details, conflicting_txids, candidates, locked_rounds: Vec::new() } } /// Returns this node's recorded funding figures for the candidate with the given txid, if any. @@ -74,6 +80,7 @@ impl_writeable_tlv_based!(PendingPaymentDetails, { (0, details, required), (2, conflicting_txids, optional_vec), (4, candidates, optional_vec), + (6, locked_rounds, optional_vec), }); #[derive(Clone, Debug, PartialEq, Eq)] @@ -165,25 +172,65 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { pub(crate) fn test_funding_contribution_with_outputs( estimated_fee_sat: u64, feerate: u64, outputs: &[bitcoin::TxOut], ) -> lightning::ln::funding::FundingContribution { - use lightning::util::ser::Writeable; + test_funding_contribution_with_parts(estimated_fee_sat, feerate, &[], outputs, None) +} + +/// Builds a [`FundingContribution`] for tests from its parts: the given estimated fee, an input +/// spending output 0 — which must be P2WPKH — of each given previous transaction, the given +/// contributed outputs and change output, and the given input-selection feerate (also used as +/// the maximum), with the is-splice flag set. +/// +/// [`FundingContribution`]: lightning::ln::funding::FundingContribution +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_parts( + estimated_fee_sat: u64, feerate: u64, prevtxs: &[bitcoin::Transaction], + outputs: &[bitcoin::TxOut], change_output: Option<&bitcoin::TxOut>, +) -> lightning::ln::funding::FundingContribution { + use lightning::util::ser::{BigSize, Writeable}; + use lightning::util::wallet_utils::ConfirmedUtxo; let mut records = vec![1, 8]; // (1, estimated_fee) records.extend_from_slice(&estimated_fee_sat.to_be_bytes()); + if !prevtxs.is_empty() { + let mut input_bytes = Vec::new(); + for prevtx in prevtxs { + ConfirmedUtxo::new_p2wpkh(prevtx.clone(), 0) + .expect("test prevtx output 0 must be P2WPKH") + .write(&mut input_bytes) + .expect("in-memory write must succeed"); + } + records.push(3); // (3, inputs) + BigSize(input_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); + records.extend_from_slice(&input_bytes); + } if !outputs.is_empty() { let mut output_bytes = Vec::new(); for output in outputs { output.write(&mut output_bytes).expect("in-memory write must succeed"); } records.push(5); // (5, outputs) - records.push(u8::try_from(output_bytes.len()).expect("test outputs must stay small")); + BigSize(output_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); records.extend_from_slice(&output_bytes); } + if let Some(change_output) = change_output { + let change_bytes = change_output.encode(); + records.push(7); // (7, change_output) + BigSize(change_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); + records.extend_from_slice(&change_bytes); + } records.extend_from_slice(&[9, 8]); // (9, feerate) records.extend_from_slice(&feerate.to_be_bytes()); records.extend_from_slice(&[11, 8]); // (11, max_feerate) records.extend_from_slice(&feerate.to_be_bytes()); records.extend_from_slice(&[13, 1, 1]); // (13, is_splice: true) - // BigSize length prefix over the TLV records above; single-byte as long as they stay short. - let mut tlv_bytes = vec![u8::try_from(records.len()).expect("test TLV stream must stay small")]; + let mut tlv_bytes = Vec::new(); + // BigSize length prefix over the TLV records above. + BigSize(records.len() as u64).write(&mut tlv_bytes).expect("in-memory write must succeed"); tlv_bytes.extend(records); lightning::util::ser::Readable::read(&mut &tlv_bytes[..]) .expect("hand-built TLV stream must decode") @@ -192,6 +239,7 @@ pub(crate) fn test_funding_contribution_with_outputs( #[cfg(test)] mod tests { use bitcoin::hashes::Hash; + use lightning::util::ser::{Readable, Writeable}; use super::*; use crate::payment::store::ConfirmationStatus; @@ -372,4 +420,40 @@ mod tests { assert_eq!(merged.details.amount_msat, Some(1_000)); assert_eq!(merged.details.fee_paid_msat, Some(100)); } + + /// A candidate with the given txid byte, with a stake of ours in it if `ours`. + fn candidate(txid_byte: u8, ours: bool) -> FundingTxCandidate { + FundingTxCandidate { + txid: test_txid(txid_byte), + amount_msat: ours.then_some(1_000), + fee_paid_msat: ours.then_some(100), + awaiting_broadcast: false, + } + } + + fn entry(candidates: Vec) -> PendingPaymentDetails { + let payment_id = PaymentId([1u8; 32]); + let txid = candidates.last().expect("at least one candidate").txid; + PendingPaymentDetails::new(pending_onchain_payment(payment_id, txid), vec![], candidates) + } + + /// The rounds LDK promoted round-trip with the entry, absent or present, and the merge of a + /// record's full update, as wallet sync writes it, leaves them. + #[test] + fn locked_rounds_round_trip_and_survive_a_merge() { + let mut stored = entry(vec![candidate(2, false)]); + let decoded: PendingPaymentDetails = + Readable::read(&mut &stored.encode()[..]).expect("encoding must round-trip"); + assert_eq!(decoded.locked_rounds, Vec::::new()); + + stored.locked_rounds.push(test_txid(2)); + let decoded: PendingPaymentDetails = + Readable::read(&mut &stored.encode()[..]).expect("encoding must round-trip"); + assert_eq!(decoded, stored); + + let synced = entry(vec![candidate(2, false), candidate(3, false)]); + assert!(stored.update(synced.to_update())); + assert_eq!(stored.candidates.len(), 2); + assert_eq!(stored.locked_rounds, vec![test_txid(2)]); + } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 83b474235..ea3dbde0b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -798,11 +798,37 @@ impl Wallet { return Ok(false); } - // As with graduation, decide from the live record and write only the status. A record - // already `Failed` — a prior pass whose entry removal below was lost to a crash — still - // matches, no-ops the update, and gets its lingering entry removed. let payment_id = entry.details.id; - let mut failed = false; + let outcome = + self.fail_unconfirmed_funding_payment_locked(&_guard, payment_id, record_txid).await?; + match outcome { + FundingPaymentFailure::Failed => log_info!( + self.logger, + "Failed funding payment {}: transaction {} lost to a conflicting transaction confirmed beyond the reorg depth", + payment_id, + record_txid, + ), + FundingPaymentFailure::EntryRemoved => log_info!( + self.logger, + "Removed the lingering entry of failed funding payment {}: transaction {} lost to \ + a conflicting transaction confirmed beyond the reorg depth", + payment_id, + record_txid, + ), + FundingPaymentFailure::MovedOn => {}, + } + Ok(outcome != FundingPaymentFailure::MovedOn) + } + + /// Fails the funding payment `payment_id` while its record still waits on the unconfirmed + /// funding transaction `record_txid`, and removes its pending entry, reporting what it did. As + /// with graduation, the decision is made from the live record and only the status is written. + /// A record already `Failed` — a prior pass whose entry removal was lost to a crash — still + /// matches, no-ops the update, and gets its lingering entry removed. + async fn fail_unconfirmed_funding_payment_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, record_txid: Txid, + ) -> Result { + let mut outcome = FundingPaymentFailure::MovedOn; self.payment_store .mutate(&payment_id, |existing| { let current = existing?; @@ -816,26 +842,260 @@ impl Wallet { | TransactionType::InteractiveFunding { .. }, ), } if txid == record_txid => { - failed = true; let mut update = PaymentDetailsUpdate::new(payment_id); update.status = Some(PaymentStatus::Failed); let mut updated = current.clone(); - updated.update(update).then_some(updated) + if updated.update(update) { + outcome = FundingPaymentFailure::Failed; + Some(updated) + } else { + outcome = FundingPaymentFailure::EntryRemoved; + None + } }, _ => None, } }) .await?; - if failed { + if outcome != FundingPaymentFailure::MovedOn { self.pending_payment_store.remove(&payment_id).await?; + } + Ok(outcome) + } + + /// Resolves the funding payments of the closed channel `channel_id`, whose monitor settled on + /// and still watches `held_rounds` (as [`closed_channel_held_rounds`] lists them): a round + /// nothing ever broadcast is dropped from its record, as [`Self::drop_abandoned_splice_rounds`] + /// does, and every payment left waiting on an unconfirmed splice round with no round of ours + /// among `held_rounds`, and none LDK promoted to the channel's funding before, is failed. The + /// monitor watches every pending round of ours that can still confirm, and a round that was + /// the funding once — a zero-conf splice locks before its transaction confirms — can confirm + /// still, every later splice building on it, so such a payment waits for a transaction that + /// cannot. + /// + /// In the usual order the monitor still watches every pending round when the channel closes, + /// and the `DiscardFunding` events it queues once the close matures find the channel no longer + /// listed and resolve the payments the same way, by what the monitor holds then. The order + /// flips when one sync delivers the close and its maturity while the background processor is + /// between the channel manager's event pass and the chain monitor's: the monitor's events then + /// find the channel still listed, and an event for a listed channel resolves no payment — the + /// promotion of a sibling round does, when there is one, and here there is none. This settles + /// what those events left behind. + pub(crate) async fn resolve_closed_channel_splice_rounds( + &self, channel_id: ChannelId, held_rounds: &[Txid], + ) -> Result<(), Error> { + // Serialize with the other funding-record writers, which all hold this lock from their + // reads through their last write. + let guard = self.funding_payment_update_lock.lock().await; + self.resolve_closed_channel_splice_rounds_locked(&guard, channel_id, held_rounds).await + } + + /// [`Self::resolve_closed_channel_splice_rounds`] for a caller already holding the + /// funding-record writers' lock. + async fn resolve_closed_channel_splice_rounds_locked( + &self, guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, held_rounds: &[Txid], + ) -> Result<(), Error> { + self.drop_abandoned_splice_rounds_locked(guard, channel_id, held_rounds).await?; + self.fail_funding_payments_without_held_round_locked( + guard, + channel_id, + held_rounds, + FundingResolution::Close, + ) + .await?; + // Logged whatever the two passes found: a payment graduated by a sync running alongside + // leaves them nothing to log, and the decision should still show. + log_debug!( + self.logger, + "Resolved the funding payments of channel {} after its close by the {} round(s) its \ + monitor holds", + channel_id, + held_rounds.len(), + ); + Ok(()) + } + + /// Fails every funding payment of `channel_id` still waiting on an unconfirmed splice round + /// while no round of ours in its record is among `held_rounds` or was promoted to the channel's + /// funding (see [`Self::resolve_promoted_splice_round`]), removing its pending entry; a payment + /// with such a round is left as it is. The rounds of ours are the candidates recorded with a + /// stake and the record's own transaction. A payment that moved on — its round confirmed, or + /// it was failed already — is not touched beyond the entry a failure cut short left behind. + /// `resolution` names the occasion in what is logged. + async fn fail_funding_payments_without_held_round_locked( + &self, guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, + held_rounds: &[Txid], resolution: FundingResolution, + ) -> Result<(), Error> { + let occasion = match resolution { + FundingResolution::Close => format!("of closed channel {}", channel_id), + FundingResolution::Promotion(promoted) => { + format!("of channel {} once splice round {} locked", channel_id, promoted) + }, + }; + let entries = + self.pending_payment_store.list_filter(|entry| tracks_channel(entry, channel_id)).await; + for entry in entries { + let payment_id = entry.details.id; + let record_txid = match &entry.details.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } => *txid, + _ => { + log_debug!( + self.logger, + "Funding payment {} {} no longer waits on an unconfirmed round", + payment_id, + occasion, + ); + continue; + }, + }; + let mut rounds_of_ours = entry + .candidates + .iter() + .filter(|candidate| candidate.amount_msat.is_some()) + .map(|candidate| candidate.txid) + .chain(std::iter::once(record_txid)); + if let Some(kept) = rounds_of_ours + .find(|txid| held_rounds.contains(txid) || entry.locked_rounds.contains(txid)) + { + log_info!( + self.logger, + "Splice round {} of ours can still confirm: keeping funding payment {} {}", + kept, + payment_id, + occasion, + ); + continue; + } + match self + .fail_unconfirmed_funding_payment_locked(guard, payment_id, record_txid) + .await? + { + FundingPaymentFailure::Failed => log_info!( + self.logger, + "Failed funding payment {} {}: no round of ours can confirm", + payment_id, + occasion, + ), + FundingPaymentFailure::EntryRemoved => log_info!( + self.logger, + "Removed the lingering entry of failed funding payment {} {}", + payment_id, + occasion, + ), + FundingPaymentFailure::MovedOn => log_warn!( + self.logger, + "Funding payment {} {} moved on from transaction {}: leaving it as it is", + payment_id, + occasion, + record_txid, + ), + } + } + Ok(()) + } + + /// Resolves what LDK's promotion of the splice round `promoted` to the funding of `channel_id`, + /// as its `ChannelReady` reports, means for the channel's funding payments. `held_rounds` lists + /// the rounds LDK holds for the channel once promoted, as [`held_splice_rounds`] does — the + /// promoted round alone, unless a contribution queued behind it was negotiated already — or is + /// `None` for a channel the manager no longer lists, whose close settles its payments. + /// + /// The promotion is recorded first, in the funding payment whose record holds the round. A + /// zero-conf splice is promoted as soon as `splice_locked` is exchanged, before its transaction + /// confirms, and every later splice builds on it, so the round can still confirm once the + /// channel's funding has moved on from it and once the channel has closed — when neither the + /// channel manager nor the monitor holds it anymore — and its payment is kept then. Nothing is + /// recorded for a round no funding payment holds — this node did not contribute to it, or its + /// record graduated already — or recorded as promoted already (a replayed event). + /// + /// LDK discards the round's siblings as it promotes the round, queuing a `DiscardFunding` for + /// each contribution of ours it returns — one naming the contribution, not the round — so the + /// channel's other payments are resolved here, from the rounds LDK holds: a round nothing ever + /// broadcast is dropped from its record, as [`Self::drop_abandoned_splice_rounds`] does, and + /// every payment left waiting on an unconfirmed round with no round of ours among `held_rounds` + /// and none promoted before is failed: no round of ours can confirm anymore, a round this node + /// did not contribute to having locked. A replayed event finds the promoted round recorded and + /// keeps its payment whatever LDK holds by then. + pub(crate) async fn resolve_promoted_splice_round( + &self, channel_id: ChannelId, promoted: Txid, held_rounds: Option<&[Txid]>, + ) -> Result<(), Error> { + // Serialize with the other funding-record writers, which all hold this lock from their + // reads through their last write. + let guard = self.funding_payment_update_lock.lock().await; + self.record_locked_splice_round_locked(&guard, channel_id, promoted).await?; + let held_rounds = match held_rounds { + Some(held_rounds) => held_rounds, + None => { + log_debug!( + self.logger, + "Channel {} is no longer listed as splice round {} locks: leaving its funding \ + payments to its close", + channel_id, + promoted, + ); + return Ok(()); + }, + }; + // The drop goes first: a round nothing broadcast is taken back rather than failed, and + // the payment recorded for it alone goes with it. + self.drop_abandoned_splice_rounds_locked(&guard, channel_id, held_rounds).await?; + self.fail_funding_payments_without_held_round_locked( + &guard, + channel_id, + held_rounds, + FundingResolution::Promotion(promoted), + ) + .await?; + log_debug!( + self.logger, + "Resolved the funding payments of channel {} as splice round {} locked, by the {} \ + round(s) LDK holds", + channel_id, + promoted, + held_rounds.len(), + ); + Ok(()) + } + + /// Records that LDK promoted the splice round `txid` to the funding of `channel_id` in the + /// funding payment whose record holds the round, for a caller holding the funding-record + /// writers' lock (see [`Self::resolve_promoted_splice_round`]). + async fn record_locked_splice_round_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, txid: Txid, + ) -> Result<(), Error> { + let entries = self + .pending_payment_store + .list_filter(|entry| { + tracks_channel(entry, channel_id) + && entry.candidate(txid).is_some() + && !entry.locked_rounds.contains(&txid) + }) + .await; + for entry in entries { + let payment_id = entry.details.id; + self.pending_payment_store + .mutate(&payment_id, |existing| { + let mut entry = existing?.clone(); + if entry.locked_rounds.contains(&txid) { + return None; + } + entry.locked_rounds.push(txid); + Some(entry) + }) + .await?; log_info!( self.logger, - "Failed funding payment {}: transaction {} lost to a conflicting transaction confirmed beyond the reorg depth", + "Splice round {} of funding payment {} locked as the funding of channel {}", + txid, payment_id, - record_txid, + channel_id, ); } - Ok(failed) + Ok(()) } #[allow(deprecated)] @@ -2073,14 +2333,7 @@ impl Wallet { let entries = self .pending_payment_store .list_filter(|entry| { - let tracks_channel = match &entry.details.kind { - PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { channels }), - .. - } => channels.iter().any(|channel| channel.channel_id == channel_id), - _ => false, - }; - tracks_channel + tracks_channel(entry, channel_id) && entry.candidate(txid).is_some_and(|candidate| candidate.awaiting_broadcast) }) .await; @@ -2125,30 +2378,33 @@ impl Wallet { /// event has cleared the mark, whether wallet sync has seen it yet or not; one whose event is /// still unhandled when the channel closes is listed in `held_rounds` because the channel's /// monitor, which saw the counterparty commit to it, still watches it, and so keeps its place - /// as well. Dropping the record's current round hands the record back to the last remaining - /// round this node contributed to, figures included; dropping the last such round removes the - /// record, as whatever rounds remain are not this node's payment (LDK keeps this node's - /// contributions to a suffix of the rounds). A record that no longer waits on the dropped round - /// — wallet sync moved it on, or an earlier drop was cut short after moving it — keeps its - /// state and only loses the round from its history. + /// as well, as does a round LDK promoted to the channel's funding (recorded by + /// [`Self::resolve_promoted_splice_round`]), broadcast with its signatures exchanged whether + /// or not its `SpliceNegotiated` event has cleared the mark yet. Dropping the record's current + /// round hands the record back to the last remaining round this node contributed to, figures + /// included; dropping the last such round removes the record, as whatever rounds remain are not + /// this node's payment (LDK keeps this node's contributions to a suffix of the rounds). A record + /// that no longer waits on the dropped round — wallet sync moved it on, or an earlier drop was + /// cut short after moving it — keeps its state and only loses the round from its history. pub(crate) async fn drop_abandoned_splice_rounds( &self, channel_id: ChannelId, held_rounds: &[Txid], ) -> Result<(), Error> { // Serialize with the other funding-record writers, which all hold this lock from their // reads through their last write. - let _guard = self.funding_payment_update_lock.lock().await; + let guard = self.funding_payment_update_lock.lock().await; + self.drop_abandoned_splice_rounds_locked(&guard, channel_id, held_rounds).await + } + /// [`Self::drop_abandoned_splice_rounds`] for a caller already holding the funding-record + /// writers' lock. + async fn drop_abandoned_splice_rounds_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, + held_rounds: &[Txid], + ) -> Result<(), Error> { let entries = self .pending_payment_store .list_filter(|entry| { - let tracks_channel = match &entry.details.kind { - PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { channels }), - .. - } => channels.iter().any(|channel| channel.channel_id == channel_id), - _ => false, - }; - tracks_channel + tracks_channel(entry, channel_id) && entry.candidates.iter().any(|candidate| candidate.awaiting_broadcast) }) .await; @@ -2166,6 +2422,7 @@ impl Wallet { entry.candidates.iter().cloned().partition(|candidate| { candidate.awaiting_broadcast && !held_rounds.contains(&candidate.txid) + && !entry.locked_rounds.contains(&candidate.txid) && locked_wallet.tx_graph().get_tx(candidate.txid).is_none() }) }; @@ -2983,6 +3240,17 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { } } +/// Whether `entry` is the funding payment of a splice into `channel_id`. +fn tracks_channel(entry: &PendingPaymentDetails, channel_id: ChannelId) -> bool { + match &entry.details.kind { + PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + } => channels.iter().any(|channel| channel.channel_id == channel_id), + _ => false, + } +} + /// Lists a channel's pending splice rounds that have a transaction — the negotiated predecessors /// and the round awaiting signatures, in LDK's order, each with this node's contribution to it — /// as the [`FundingCandidate`]s LDK hands the broadcaster for the round, for recording the round @@ -3038,16 +3306,19 @@ pub(crate) fn held_splice_rounds( held } -/// The splice rounds a closed channel may still see confirm, as +/// The splice rounds LDK still holds for a closed channel, as /// [`Wallet::drop_abandoned_splice_rounds`] takes them: the channel's last funding — which a /// zero-conf splice may have become before its transaction confirmed — and every transaction the /// channel's monitor still watches. The channel manager forgets a pending round with the channel, /// and what it reports for one awaiting the counterparty's signatures is queued after -/// `ChannelClosed`, but the monitor keeps watching every round the counterparty's -/// `commitment_signed` reached, and our signatures cannot have left the node before that message: -/// such a round may yet confirm and is left to wallet sync or `DiscardFunding` to resolve, while a -/// round the monitor never watched never had our signatures released. The watched transactions also -/// include the funding and whatever spent it on chain, which no recorded round is. +/// `ChannelClosed`, but the monitor keeps watching every pending round the counterparty's +/// `commitment_signed` reached, until a sibling locks or the close matures, and our signatures +/// cannot have left the node before that message: such a round may yet confirm and is left to +/// wallet sync or `DiscardFunding` to resolve, while a round the monitor never watched never had +/// our signatures released. The watched transactions also include the funding and whatever spent it +/// on chain, which no recorded round is. A funding the channel moved on from before it confirmed — +/// a zero-conf splice a later splice built on — is held by neither and can confirm still; the +/// funding payments keep such rounds themselves (see [`Wallet::resolve_promoted_splice_round`]). pub(crate) fn closed_channel_held_rounds( funding_txo: Option, watched_txids: impl IntoIterator, ) -> Vec { @@ -3060,6 +3331,28 @@ pub(crate) fn closed_channel_held_rounds( held } +/// The occasion on which [`Wallet::fail_funding_payments_without_held_round_locked`] resolves a +/// channel's funding payments by the rounds LDK holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FundingResolution { + /// The channel closed. + Close, + /// LDK promoted the given splice round to the channel's funding. + Promotion(Txid), +} + +/// The outcome of [`Wallet::fail_unconfirmed_funding_payment_locked`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FundingPaymentFailure { + /// The payment was failed and its pending entry removed. + Failed, + /// The payment was failed already — by a pass whose entry removal was lost to a crash — and + /// only the lingering entry was removed. + EntryRemoved, + /// The record no longer waits on the transaction; nothing was touched. + MovedOn, +} + /// The outcome of [`Wallet::apply_funding_status_update_locked`]. enum FundingStatusUpdate { /// The event's transaction belongs to the funding payment; its refreshed confirmation status @@ -3492,7 +3785,9 @@ mod tests { PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; - use crate::payment::pending_payment_store::test_funding_contribution_with_outputs; + use crate::payment::pending_payment_store::{ + test_funding_contribution_with_outputs, test_funding_contribution_with_parts, + }; use crate::types::{DynStore, DynStoreWrapper}; use crate::{NodeMetrics, PersistedNodeMetrics}; @@ -7388,4 +7683,529 @@ mod tests { ); assert_ne!(locked_wallet.next_unused_address(KeychainKind::Internal).index, 0); } + + /// A previous transaction with a P2WPKH output at index 0 for a contribution input to spend; + /// `seed` varies the output script, and with it the txid. + fn test_prevtx(seed: u8) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + } + } + + /// Records `rounds` as their signing did — the last round signed, the others negotiated + /// before — then marks the signed round as broadcast, as its `SpliceNegotiated` event would. + /// Returns the record's id. + async fn record_broadcast_rounds( + wallet: &Wallet, tx: &Transaction, rounds: &[(Txid, Option)], + ) -> PaymentId { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let candidates = splice_candidates(counterparty_node_id, channel_id, rounds); + wallet.record_signed_funding(tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, tx.compute_txid()).await.unwrap(); + PaymentId(rounds[0].0.to_byte_array()) + } + + /// The close finds no round of ours held — the channel closed on a commitment transaction and + /// the monitor watches the round no longer — so the only round's payment is failed and its + /// entry removed. The record keeps describing the round. + #[tokio::test] + async fn closing_without_a_round_of_ours_held_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let id = record_broadcast_rounds(&wallet, &tx, &[(txid, Some(contribution))]).await; + + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: recorded, status: ConfirmationStatus::Unconfirmed, .. } + if recorded == txid + )); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// LDK promoted a round of ours and discarded the counterparty's round it replaced with the + /// promotion, so the payment stays as it is, the promotion recorded and the discarded round + /// still in its history. + #[tokio::test] + async fn promoting_a_round_of_ours_keeps_its_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + + wallet.resolve_promoted_splice_round(channel_id, txid, Some(&[txid])).await.unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + assert_eq!(entry.candidates.len(), 2); + assert_eq!(entry.locked_rounds, vec![txid]); + } + + /// LDK promoted a sibling this node did not contribute to — the counterparty's round locked on + /// a channel that stays open, and the channel manager holds it as the funding and no pending + /// round by the time the event is handled — so no round of ours can confirm anymore and the + /// payment is failed, although the channel holds a round of the splice. The channel's monitor, + /// updated only later, may still watch our round; it is not consulted. The record keeps + /// describing our round. + #[tokio::test] + async fn promoting_a_round_not_ours_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: recorded, status: ConfirmationStatus::Unconfirmed, .. } + if recorded == txid + )); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// A round of ours nothing had broadcast when the counterparty's round locked — our + /// signatures were never exchanged — is dropped with the promotion, and its record with it, + /// rather than failed: no transaction of ours ever existed to fail a payment for. + #[tokio::test] + async fn promoting_a_round_drops_a_round_nothing_broadcast() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = PaymentId(counterparty_txid.to_byte_array()); + assert!(wallet.payment_store.get(&id).await.unwrap().is_some(), "the round was recorded"); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// Failing the payment writes the record before it removes the entry; a replay after the + /// removal was lost finds the record failed already and finishes the removal. + #[tokio::test] + async fn promoting_a_round_finishes_a_failure_cut_short() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + wallet + .payment_store + .mutate(&id, |existing| { + let mut update = PaymentDetailsUpdate::new(id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// A promotion reported for a channel the manager no longer lists — the channel closed before + /// the event was handled — records the round and leaves the payments to the close, which + /// resolves them by what the monitor holds: nothing of ours here, the promoted round being the + /// counterparty's, so the payment is failed then. Recording the counterparty's round does not + /// keep it. + #[tokio::test] + async fn promoting_a_round_on_an_unlisted_channel_records_it_alone() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + + wallet.resolve_promoted_splice_round(channel_id, counterparty_txid, None).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + assert_eq!(entry.locked_rounds, vec![counterparty_txid]); + + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) + .await + .unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// A payment whose round LDK promoted before is kept when a later splice's round is promoted + /// — the round can still confirm, the later one descending from it — while the later round's + /// payment is kept for the round LDK holds. The close after that keeps both as well. + #[tokio::test] + async fn a_later_promotion_keeps_a_payment_whose_round_locked_before() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (first_tx, first) = splice_in_round(&wallet, 1); + let first_txid = first_tx.compute_txid(); + let first_id = + record_broadcast_rounds(&wallet, &first_tx, &[(first_txid, Some(first))]).await; + wallet + .resolve_promoted_splice_round(channel_id, first_txid, Some(&[first_txid])) + .await + .unwrap(); + + let (second_tx, second) = splice_in_round(&wallet, 2); + let second_txid = second_tx.compute_txid(); + let second_id = + record_broadcast_rounds(&wallet, &second_tx, &[(second_txid, Some(second))]).await; + wallet + .resolve_promoted_splice_round(channel_id, second_txid, Some(&[second_txid])) + .await + .unwrap(); + + for (id, locked) in [(first_id, first_txid), (second_id, second_txid)] { + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = + wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + assert_eq!(entry.locked_rounds, vec![locked]); + } + + wallet.resolve_closed_channel_splice_rounds(channel_id, &[second_txid]).await.unwrap(); + for id in [first_id, second_id] { + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); + } + } + + /// A fee bump nothing broadcast is dropped when the round it was to replace is promoted — the + /// counterparty's `splice_locked` for the round arrived as the bump was signed — and the + /// record is handed back to the promoted round, figures included, with the promotion recorded. + #[tokio::test] + async fn promoting_a_round_drops_an_abandoned_bump_and_hands_the_record_back() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (first_tx, first) = splice_out_round(&wallet, 1, 500_000, 300); + let (bump_tx, bump) = splice_out_round(&wallet, 2, 500_000, 600); + let (first_txid, bump_txid) = (first_tx.compute_txid(), bump_tx.compute_txid()); + let id = + record_broadcast_rounds(&wallet, &first_tx, &[(first_txid, Some(first.clone()))]).await; + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record exists"); + let first_figures = (payment.amount_msat, payment.fee_paid_msat); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(first_txid, Some(first)), (bump_txid, Some(bump))], + ); + wallet.record_signed_funding(&bump_tx, &candidates).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record exists"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == bump_txid)); + assert_ne!((payment.amount_msat, payment.fee_paid_msat), first_figures); + + wallet + .resolve_promoted_splice_round(channel_id, first_txid, Some(&[first_txid])) + .await + .unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == first_txid)); + assert_eq!((payment.amount_msat, payment.fee_paid_msat), first_figures); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + assert_eq!(entry.candidates.iter().map(|c| c.txid).collect::>(), vec![first_txid]); + assert_eq!(entry.locked_rounds, vec![first_txid]); + } + + /// A zero-conf splice round of ours locked before its transaction confirmed and a later splice + /// built on it, so at the close the monitor holds the later round as the funding and watches + /// neither. The promotion LDK reported keeps the payment: the round can still confirm, the + /// later round descending from it. Reporting the promotion again — a replayed `ChannelReady` — + /// records it once and keeps the payment, and reporting one for a round no funding payment + /// holds records nothing and keeps the payment for the round recorded before. + #[tokio::test] + async fn closing_keeps_a_payment_whose_round_locked() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_in_round(&wallet, 1); + let txid = tx.compute_txid(); + let id = record_broadcast_rounds(&wallet, &tx, &[(txid, Some(contribution))]).await; + let later_funding_txid = Txid::from_byte_array([0xF1; 32]); + for locked in [txid, txid, later_funding_txid] { + wallet + .resolve_promoted_splice_round(channel_id, locked, Some(&[locked])) + .await + .unwrap(); + } + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + assert_eq!(entry.locked_rounds, vec![txid]); + + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[later_funding_txid]) + .await + .unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some(), "the entry stays"); + } + + /// A promoted round whose `SpliceNegotiated` event is still unhandled when the channel closes + /// is not taken back as abandoned: LDK broadcast it as the signatures were exchanged, before it + /// locked. + #[tokio::test] + async fn closing_keeps_a_locked_round_whose_negotiation_event_is_unhandled() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_in_round(&wallet, 1); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = PaymentId(txid.to_byte_array()); + wallet.resolve_promoted_splice_round(channel_id, txid, Some(&[txid])).await.unwrap(); + + let later_funding_txid = Txid::from_byte_array([0xF1; 32]); + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[later_funding_txid]) + .await + .unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + assert!(entry.candidate(txid).is_some_and(|round| round.awaiting_broadcast)); + } + + /// A splice-in round spending output 0 of `test_prevtx(seed)`: the contribution as LDK would + /// negotiate it, its input its only part, and the transaction carrying it, which also pays a + /// wallet address so the wallet sees movement. Rounds with distinct seeds have distinct parts, + /// as a fee bump that had to select other inputs has. + fn splice_in_round(wallet: &Wallet, seed: u8) -> (Transaction, FundingContribution) { + let prevtx = test_prevtx(seed); + let contribution = test_funding_contribution_with_parts( + 300, + 253, + std::slice::from_ref(&prevtx), + &[], + None, + ); + (wallet_paying_tx(wallet, seed), contribution) + } + + /// Both broadcast rounds of ours were discarded while the channel manager still listed the + /// channel — the monitor's events reached the handler ahead of the channel's close — and an + /// event for a listed channel only drops the rounds nothing broadcast, so the payment is left. + /// The close that follows finds no round of ours the monitor watches and fails it. + #[tokio::test] + async fn rounds_discarded_while_the_channel_is_listed_fail_at_close() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (first_tx, first) = splice_in_round(&wallet, 1); + let (bump_tx, bump) = splice_in_round(&wallet, 2); + let (first_txid, bump_txid) = (first_tx.compute_txid(), bump_tx.compute_txid()); + let rounds = [(first_txid, Some(first)), (bump_txid, Some(bump))]; + let id = record_broadcast_rounds(&wallet, &bump_tx, &rounds).await; + let funding_txid = Txid::from_byte_array([0xF0; 32]); + // The listed channel's pending rounds and funding, as LDK still reports them. + let held = [first_txid, bump_txid, funding_txid]; + for _ in 0..2 { + wallet.drop_abandoned_splice_rounds(channel_id, &held).await.unwrap(); + } + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + assert_eq!(entry.candidates.len(), 2); + + // At the close the monitor has settled on the funding and watches neither round. + wallet.resolve_closed_channel_splice_rounds(channel_id, &[funding_txid]).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } if txid == bump_txid + )); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// The close leaves a payment alone while the monitor watches a round of ours in its record: + /// the round may yet confirm, and wallet sync or the monitor's `DiscardFunding` resolves it. + #[tokio::test] + async fn closing_keeps_a_payment_whose_round_the_monitor_watches() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (first_tx, first) = splice_in_round(&wallet, 1); + let (bump_tx, bump) = splice_in_round(&wallet, 2); + let (first_txid, bump_txid) = (first_tx.compute_txid(), bump_tx.compute_txid()); + let rounds = [(first_txid, Some(first)), (bump_txid, Some(bump))]; + let id = record_broadcast_rounds(&wallet, &bump_tx, &rounds).await; + let funding_txid = Txid::from_byte_array([0xF0; 32]); + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[funding_txid, bump_txid]) + .await + .unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + assert_eq!(entry.candidates.len(), 2); + } + + /// The close does not touch a payment that no longer waits on an unconfirmed round: one whose + /// round confirmed keeps its state, and the entry a graduation cut short left behind is left + /// to the replayed graduation. + #[tokio::test] + async fn closing_leaves_a_confirmed_payment_alone() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_in_round(&wallet, 1); + let txid = tx.compute_txid(); + let id = record_broadcast_rounds(&wallet, &tx, &[(txid, Some(contribution))]).await; + let confirmed = ConfirmationStatus::Confirmed { + block_hash: bitcoin::BlockHash::all_zeros(), + height: 100, + timestamp: 1_700_000_000, + }; + wallet + .payment_store + .mutate(&id, |existing| { + let mut updated = existing?.clone(); + if let PaymentKind::Onchain { status, .. } = &mut updated.kind { + *status = confirmed; + } + updated.status = PaymentStatus::Succeeded; + Some(updated) + }) + .await + .unwrap(); + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); + } + + /// Failing the payment writes the record before it removes the entry; the close replayed after + /// the removal was lost finds the record failed already and finishes the removal. + #[tokio::test] + async fn closing_finishes_a_failure_cut_short() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_in_round(&wallet, 1); + let txid = tx.compute_txid(); + let id = record_broadcast_rounds(&wallet, &tx, &[(txid, Some(contribution))]).await; + wallet + .payment_store + .mutate(&id, |existing| { + let mut update = PaymentDetailsUpdate::new(id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// The close resolves every record of the channel — two splices signed under different + /// first-candidate ids, as two negotiations from the same coins are — each by the rounds the + /// monitor holds: nothing of ours here, so both are failed. + #[tokio::test] + async fn closing_resolves_every_record_of_the_channel() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (first_tx, contribution) = splice_in_round(&wallet, 1); + let (second_tx, _) = splice_in_round(&wallet, 2); + let (first_txid, second_txid) = (first_tx.compute_txid(), second_tx.compute_txid()); + let first_id = record_broadcast_rounds( + &wallet, + &first_tx, + &[(first_txid, Some(contribution.clone()))], + ) + .await; + let second_id = + record_broadcast_rounds(&wallet, &second_tx, &[(second_txid, Some(contribution))]) + .await; + let funding_txid = Txid::from_byte_array([0xF0; 32]); + wallet.resolve_closed_channel_splice_rounds(channel_id, &[funding_txid]).await.unwrap(); + for id in [first_id, second_id] { + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + } } diff --git a/tests/common/logging.rs b/tests/common/logging.rs index 3b231b3cd..5e2f2e5dc 100644 --- a/tests/common/logging.rs +++ b/tests/common/logging.rs @@ -192,6 +192,11 @@ impl CollectingLogWriter { self.logs.lock().unwrap().iter().filter(|message| message.contains(text)).count() } + /// Every message logged so far, in order. + pub(crate) fn lines(&self) -> Vec { + self.logs.lock().unwrap().clone() + } + /// Waits up to ten seconds for a logged message containing `text`, returning whether one /// arrived. Polling beats a fixed sleep: it returns as soon as the line lands and only pays /// the full timeout when the line never comes. diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index fa8480041..f3b5ebefc 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -15,9 +15,10 @@ use std::sync::{mpsc, Arc}; use std::time::Duration; use bitcoin::address::NetworkUnchecked; +use bitcoin::hashes::hex::FromHex; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; -use bitcoin::{Address, Amount, ScriptBuf, Txid}; +use bitcoin::{Address, Amount, ScriptBuf, Transaction, Txid}; use common::logging::{ init_log_logger, validate_log_entry, CollectingLogWriter, MultiNodeLogger, TestLogWriter, }; @@ -46,7 +47,8 @@ use ldk_node::payment::{ PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType, UserChannelId}; -use lightning::ln::channelmanager::PaymentId; +use lightning::chain::channelmonitor::ANTI_REORG_DELAY; +use lightning::ln::channelmanager::{PaymentId, BREAKDOWN_TIMEOUT}; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; @@ -90,8 +92,26 @@ struct ContendedStore { serializer: Arc>, block_writes: Arc, wallet_write_started: Arc, - /// When set, only writes to this primary namespace go through `serializer`; the rest bypass it. - serialized_namespace: Option, + /// When set, only writes to this primary namespace — and, when one is named, to this key — go + /// through `serializer`; the rest bypass it. + serialized: Option<(String, Option)>, + /// The writes going through `serializer` that have not returned yet, those held back included. + serialized_in_flight: Arc, +} + +impl ContendedStore { + /// Waits for a write going through `serializer` to start — one a test holds back by holding + /// the write lock, or one on its way through. + async fn wait_for_serialized_write(&self) { + let poll = async { + while self.serialized_in_flight.load(Ordering::Acquire) == 0 { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }; + tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), poll) + .await + .expect("timed out waiting for a serialized write to start"); + } } impl KVStore for ContendedStore { @@ -108,8 +128,10 @@ impl KVStore for ContendedStore { let serializer = Arc::clone(&self.serializer); let block_writes = Arc::clone(&self.block_writes); let wallet_write_started = Arc::clone(&self.wallet_write_started); - let serialized = - self.serialized_namespace.as_deref().map_or(true, |ns| ns == primary_namespace); + let serialized_in_flight = Arc::clone(&self.serialized_in_flight); + let serialized = self.serialized.as_ref().map_or(true, |(namespace, only_key)| { + namespace == primary_namespace && only_key.as_deref().map_or(true, |k| k == key) + }); let primary_namespace = primary_namespace.to_string(); let secondary_namespace = secondary_namespace.to_string(); let key = key.to_string(); @@ -117,8 +139,18 @@ impl KVStore for ContendedStore { if block_writes.load(Ordering::Acquire) { wallet_write_started.notify_one(); } - let _guard = if serialized { Some(serializer.read().await) } else { None }; - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + let _guard = if serialized { + serialized_in_flight.fetch_add(1, Ordering::AcqRel); + Some(serializer.read().await) + } else { + None + }; + let result = + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await; + if serialized { + serialized_in_flight.fetch_sub(1, Ordering::AcqRel); + } + result } } @@ -167,7 +199,8 @@ fn wallet_store_contention_does_not_stall_runtime() { serializer: Arc::new(tokio::sync::RwLock::new(())), block_writes: Arc::new(AtomicBool::new(false)), wallet_write_started: Arc::new(tokio::sync::Notify::new()), - serialized_namespace: None, + serialized: None, + serialized_in_flight: Arc::new(AtomicUsize::new(0)), }; let node = builder .build_with_store(test_config.node_entropy.into(), store.clone()) @@ -2923,10 +2956,11 @@ async fn splice_in_rbf_joins_counterparty_splice() { } /// Builds and starts a node over a [`ContendedStore`], whose writes — all of them, or only those -/// to `serialized_namespace` — a test holds back by taking the store's `serializer` write lock, -/// logging into a [`CollectingLogWriter`]. +/// to the primary namespace `serialized` names and, when it names one, its key — a test holds back +/// by taking the store's `serializer` write lock, logging into a [`CollectingLogWriter`]. fn setup_contended_node( - chain_source: &TestChainSource, mut config: TestConfig, serialized_namespace: Option<&str>, + chain_source: &TestChainSource, mut config: TestConfig, + serialized: Option<(&str, Option<&str>)>, ) -> (TestNode, ContendedStore, Arc) { let logs = Arc::new(CollectingLogWriter::new()); config.log_writer = TestLogWriter::Custom(logs.clone()); @@ -2935,7 +2969,9 @@ fn setup_contended_node( serializer: Arc::new(tokio::sync::RwLock::new(())), block_writes: Arc::new(AtomicBool::new(false)), wallet_write_started: Arc::new(tokio::sync::Notify::new()), - serialized_namespace: serialized_namespace.map(str::to_string), + serialized: serialized + .map(|(namespace, key)| (namespace.to_string(), key.map(str::to_string))), + serialized_in_flight: Arc::new(AtomicUsize::new(0)), }; setup_builder!(builder, config.node_config); common::configure_chain_source(chain_source, &mut builder, &config); @@ -3006,6 +3042,189 @@ fn only_interactive_funding_txid(node: &TestNode) -> Txid { txid } +/// `node`'s payment for the funding transaction `funding_txid`, which it must have recorded. +fn funding_payment(node: &TestNode, funding_txid: Txid) -> PaymentDetails { + node.list_all_payments() + .into_iter() + .find(|p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == funding_txid)) + .unwrap_or_else(|| panic!("no payment recorded for funding transaction {}", funding_txid)) +} + +/// The `what` transaction, matched by `matches`, that a node handed the broadcaster: from the +/// mempool when bitcoind accepted it, else from the bytes the node logs when the broadcast is +/// refused — as a commitment transaction is while a splice round spending the same funding sits +/// in the mempool, or a splice round whose fee falls short of replacing the round it joins. +async fn wait_for_broadcast( + bitcoind: &BitcoinD, logs: &CollectingLogWriter, matches: impl Fn(&Transaction) -> bool, + what: &str, +) -> Transaction { + let decode = |hex: &str| { + Vec::::from_hex(hex) + .ok() + .and_then(|bytes| bitcoin::consensus::encode::deserialize::(&bytes).ok()) + }; + let poll = async { + loop { + let mempool: Vec = + bitcoind.client.call("getrawmempool", &[]).expect("failed to list the mempool"); + for txid in mempool { + // The transaction may leave the mempool between the two calls. + let hex: Result = + bitcoind.client.call("getrawtransaction", &[json!(txid)]); + if let Some(tx) = hex.ok().and_then(|hex| decode(&hex)).filter(&matches) { + return tx; + } + } + if let Some(tx) = + logs.lines().iter().find_map(|line| decode(line.trim()).filter(&matches)) + { + return tx; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }; + tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), poll) + .await + .unwrap_or_else(|_| panic!("timed out waiting for the {} to be broadcast", what)) +} + +/// Whether `tx` spends `outpoint`. +fn spends(tx: &Transaction, outpoint: bitcoin::OutPoint) -> bool { + tx.input.iter().any(|input| input.previous_output == outpoint) +} + +/// Whether `tx` is a commitment transaction of the channel funded by `funding_txo`: it spends the +/// funding, and the upper byte of its locktime is the 0x20 BOLT 3 prescribes, where a splice round +/// spending the same funding carries a block height. +fn is_commitment(tx: &Transaction, funding_txo: bitcoin::OutPoint) -> bool { + spends(tx, funding_txo) && tx.lock_time.to_consensus_u32() >> 24 == 0x20 +} + +/// Mines a block holding `tx`, whatever the mempool holds — a transaction conflicting with it may +/// sit there, which the block then evicts. +fn mine_transaction(bitcoind: &BitcoinD, tx: &Transaction) { + let address = bitcoind.client.new_address().expect("failed to get new address"); + let hex = bitcoin::consensus::encode::serialize_hex(tx); + let _: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(address.to_string()), json!([hex])]) + .expect("failed to mine the transaction"); +} + +/// The raw transaction `txid`, as bitcoind holds it. +fn raw_transaction_hex(bitcoind: &BitcoinD, txid: Txid) -> String { + bitcoind + .client + .call("getrawtransaction", &[json!(txid.to_string())]) + .expect("failed to fetch the transaction") +} + +/// Mines a block holding the transactions `hexes` encode and nothing else — an empty block for +/// none — whatever the mempool holds, and waits for electrs to see it. +async fn mine_block_with(bitcoind: &BitcoinD, electrsd: &ElectrsD, hexes: &[String]) { + let height = + bitcoind.client.get_blockchain_info().expect("failed to get blockchain info").blocks + as usize; + 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!(hexes)]) + .expect("failed to mine the block"); + wait_for_block(&bitcoind.client, &electrsd.client, height + 1).await; +} + +/// Waits for `node` to have no peer left, connected or known: a peer's leaving is handled after +/// the connection drops. +async fn wait_for_no_peers(node: &TestNode) { + let poll = async { + while !node.list_peers().is_empty() { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }; + tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), poll) + .await + .expect("timed out waiting for the node's peers to leave"); +} + +/// A channel with two broadcast rounds of one splice, as [`open_and_join_counterparty_splice`] +/// leaves it. +struct TwoRoundSplice { + user_channel_id_a: UserChannelId, + /// The round node B initiated, which node A did not contribute to. + first_txid: Txid, + first_tx: Transaction, + /// The round node A initiated to join the splice, replacing the first. + rbf_txid: Txid, + rbf_tx: Transaction, +} + +/// Funds both nodes, has `node_a` open a channel to `node_b`, `node_b` splice into it, and `node_a` +/// join that splice with a fee-bumping round of its own, as +/// [`splice_in_rbf_joins_counterparty_splice`] does. Both rounds are broadcast, so both are in +/// `node_a`'s record of the splice, and both are returned in full — the joining round from +/// `node_a`'s logs when its fee falls short of replacing the first in the mempool — so either can +/// be mined. +async fn open_and_join_counterparty_splice( + bitcoind: &BitcoinD, electrsd: &ElectrsD, node_a: &TestNode, logs_a: &CollectingLogWriter, + node_b: &TestNode, +) -> TwoRoundSplice { + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(5_000_000), + ) + .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.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + let first_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, first_txo.txid).await; + let is_first = |tx: &Transaction| tx.compute_txid() == first_txo.txid; + let first_tx = wait_for_broadcast(bitcoind, logs_a, is_first, "first round").await; + wait_for_classified_funding_payment(node_b, first_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + 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!(first_txo, rbf_txo, "node A's round should replace node B's"); + let is_rbf = |tx: &Transaction| tx.compute_txid() == rbf_txo.txid; + let rbf_tx = wait_for_broadcast(bitcoind, logs_a, is_rbf, "joining round").await; + wait_for_classified_funding_payment(node_a, rbf_txo.txid).await; + wait_for_classified_funding_payment(node_b, rbf_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + TwoRoundSplice { + user_channel_id_a, + first_txid: first_txo.txid, + first_tx, + rbf_txid: rbf_txo.txid, + rbf_tx, + } +} + +/// Builds and starts a node logging into a [`CollectingLogWriter`]. +fn setup_logged_node( + chain_source: &TestChainSource, mut config: TestConfig, +) -> (TestNode, Arc) { + let logs = Arc::new(CollectingLogWriter::new()); + config.log_writer = TestLogWriter::Custom(logs.clone()); + (setup_node(chain_source, config), logs) +} + /// Logged by a node once it has signed a splice round of its own. const SIGNED_FUNDING: &str = "Signed funding transaction for channel"; /// Logged by a node once LDK reports a splice round it recorded when signing negotiated, and the @@ -3017,8 +3236,30 @@ const BROADCAST_FUNDING: &str = "Broadcasting interactively funded transaction w const RECEIVED_TX_SIGNATURES: &str = "Received message TxSignatures"; /// Logged by LDK's peer handler when the counterparty's `commitment_signed` arrives. const RECEIVED_COMMITMENT_SIGNED: &str = "Received message CommitmentSigned"; +/// Logged by a node as it leaves a funding payment on a round of its own that can still confirm +/// while resolving the channel's funding payments, at a promotion or at the close. +const ROUND_CAN_STILL_CONFIRM: &str = "of ours can still confirm"; +/// Logged by a node as it fails a funding payment none of whose rounds can confirm anymore. +const NO_ROUND_CAN_CONFIRM: &str = "no round of ours can confirm"; +/// Logged by a node as it drops a signed round nothing ever broadcast. +const DROPPED_ABANDONED_ROUND: &str = "Dropped abandoned splice round(s)"; +/// Logged by a node as it resolves a funding payment of a closed channel by the rounds the +/// channel's monitor holds, however it does: at `ChannelClosed`, and for a round LDK discards after +/// the close. +const CLOSED_CHANNEL_PAYMENT_RESOLVED: &str = "of closed channel"; +/// Logged by a node as it resolves a funding payment of an open channel by the rounds LDK holds +/// once it promoted a splice round to the channel's funding, however it does. +const PROMOTED_ROUND_PAYMENT_RESOLVED: &str = "once splice round"; /// Logged by a node as it returns the addresses of a contribution LDK discarded to the wallet. const RECLAIMED_ADDRESSES: &str = "Reclaiming unused addresses from channel"; +/// Logged by a node once it has decided the funding payments of a closed channel by the rounds the +/// channel's monitor holds, at `ChannelClosed` and for a round LDK discards after the close. Unlike +/// [`CLOSED_CHANNEL_PAYMENT_RESOLVED`], logged whatever was found, so also when no payment of the +/// channel is left to resolve. +const CLOSED_CHANNEL_ROUNDS_RESOLVED: &str = "round(s) its monitor holds"; +/// Logged by a node as it records that LDK promoted a splice round of ours to the channel's +/// funding. +const ROUND_LOCKED: &str = "locked as the funding of channel"; /// A splice round this node signed stays recorded when the channel closes before the /// counterparty's `tx_signatures` arrive, if the channel's monitor watches the round. The monitor @@ -3035,13 +3276,18 @@ const RECLAIMED_ADDRESSES: &str = "Reclaiming unused addresses from channel"; /// `tx_signatures` on receiving node A's. Node A sends its `tx_signatures` first, see /// [`open_and_splice_from_counterparty`]. Pinned to Esplora so node A's wallet syncs only on /// demand. +/// +/// The kept record is resolved once the close settles: node A's commitment transaction confirms +/// and its `to_self_delay` passes, the monitor stops watching the round and reports it discarded, +/// and the record of a round node A never saw broadcast goes rather than fail a payment for a +/// transaction that never existed. #[cfg(feature = "chain-esplora")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = TestChainSource::Esplora(&electrsd); let (node_a, store_a, logs_a) = - setup_contended_node(&chain_source, random_config(), Some("payments")); + setup_contended_node(&chain_source, random_config(), Some(("payments", None))); let (node_b, store_b, logs_b) = setup_contended_node(&chain_source, random_config(), None); let user_channel_id_a = open_and_splice_from_counterparty(&bitcoind, &electrsd, &node_a, &node_b).await; @@ -3073,6 +3319,12 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { "node B did not withhold its signatures" ); let rbf_txid = only_interactive_funding_txid(&node_a); + let funding_txo = node_a + .list_channels() + .into_iter() + .find(|channel| channel.user_channel_id == user_channel_id_a) + .and_then(|channel| channel.funding_txo) + .expect("the channel has a funding"); node_a.disconnect(node_b.node_id()).unwrap(); node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); @@ -3093,8 +3345,34 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { } )); - // With its monitor update through, node B holds both signature sets and broadcasts the round - // on its own: the kept record describes a transaction that may yet confirm. + // The close settles first. Node A's commitment transaction is refused by the mempool while + // node B's first round, which spends the same funding, sits there, so it is mined directly. + // The monitor settles a close by node A's own commitment only once the `to_self_delay` on its + // balance has passed, not after the six blocks that settle a counterparty's; it then reports + // the rounds it watched as discarded, and node A never saw its round broadcast, so the record + // goes. + let commitment = + wait_for_broadcast(&bitcoind, &logs_a, |tx| is_commitment(tx, funding_txo), "commitment") + .await; + mine_transaction(&bitcoind, &commitment); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, BREAKDOWN_TIMEOUT as usize).await; + node_a.sync_wallets().unwrap(); + assert!( + logs_a.wait_for(DROPPED_ABANDONED_ROUND).await, + "the discarded round's record was not taken back" + ); + assert!( + !node_a + .list_all_payments() + .iter() + .any(|p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == rbf_txid)), + "the record of a round nothing broadcast outlived the close" + ); + assert!(!logs_a.contains(NO_ROUND_CAN_CONFIRM), "a round nothing broadcast was failed"); + + // With its monitor update through, node B holds both signature sets and hands the round to its + // broadcaster on its own — too late to confirm, the commitment having spent the funding — so + // the kept record described a round the counterparty could release without this node. drop(hold_b); assert!( logs_b.wait_for_count(BROADCAST_FUNDING, broadcast_b + 1).await, @@ -3104,6 +3382,181 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { node_b.stop().unwrap(); } +/// A splice round this node broadcast dies with the channel when the close confirms instead: once +/// the close settles — for a commitment of the node's own, when its `to_self_delay` has passed — +/// the channel's monitor reports the round discarded, and its funding payment is failed: a +/// transaction that existed and lost, unlike a round nothing ever broadcast, whose record is +/// dropped. Pinned to Esplora so the wallet syncs only on demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn broadcast_splice_round_lost_to_a_close_fails_its_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, logs_a) = setup_logged_node(&chain_source, random_config()); + let node_b = setup_node(&chain_source, random_config()); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(5_000_000), + ) + .await; + node_a.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()); + expect_channel_ready_event!(node_b, node_a.node_id()); + let funding_txo = node_a + .list_channels() + .into_iter() + .find(|channel| channel.user_channel_id == user_channel_id_a) + .and_then(|channel| channel.funding_txo) + .expect("the channel has a funding"); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap(); + let splice_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_tx(&electrsd.client, splice_txo.txid).await; + wait_for_classified_funding_payment(&node_a, splice_txo.txid).await; + node_a.sync_wallets().unwrap(); + assert_eq!(funding_payment(&node_a, splice_txo.txid).status, PaymentStatus::Pending); + + node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + expect_event!(node_a, ChannelClosed); + // The splice round spends the funding too and sits in the mempool, so the commitment is refused + // and mined directly; the close settles once the `to_self_delay` on node A's balance passes. + let commitment = + wait_for_broadcast(&bitcoind, &logs_a, |tx| is_commitment(tx, funding_txo), "commitment") + .await; + mine_transaction(&bitcoind, &commitment); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, BREAKDOWN_TIMEOUT as usize).await; + node_a.sync_wallets().unwrap(); + + assert!(logs_a.wait_for(NO_ROUND_CAN_CONFIRM).await, "the lost round's payment was not failed"); + let payment = funding_payment(&node_a, splice_txo.txid); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } + )); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice round of ours that confirms after the channel closed keeps its payment when the +/// monitor discards the splice's other rounds: the confirmed round became the closed channel's +/// funding, and the payment reports it. Node A joined node B's splice with a fee-bumping round, +/// then force-closed; its round is mined ahead of the commitment transaction. Pinned to Esplora so +/// the wallet syncs only on demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_round_confirmed_after_a_close_keeps_its_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, logs_a) = setup_logged_node(&chain_source, random_config()); + let node_b = setup_node(&chain_source, random_config()); + let splice = + open_and_join_counterparty_splice(&bitcoind, &electrsd, &node_a, &logs_a, &node_b).await; + assert_eq!(funding_payment(&node_a, splice.rbf_txid).status, PaymentStatus::Pending); + + node_a.force_close_channel(&splice.user_channel_id_a, node_b.node_id(), None).unwrap(); + expect_event!(node_a, ChannelClosed); + mine_transaction(&bitcoind, &splice.rbf_tx); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + node_a.sync_wallets().unwrap(); + + // The close kept the payment, its round watched; the other round's discard, which the monitor + // queues as the round of ours settles, is handled while the sync graduates the payment: before + // the sync records the confirmation, between that and the graduation, or once the graduation + // has removed the pending entry, when the handler finds no payment to leave a line for. The + // decision is logged in every case, once at the close and once for the discard. + assert!( + logs_a.wait_for_count(CLOSED_CHANNEL_ROUNDS_RESOLVED, 2).await, + "the other round's discard was not handled" + ); + assert!(!logs_a.contains(NO_ROUND_CAN_CONFIRM), "the confirmed round's payment was failed"); + let payment = funding_payment(&node_a, splice.rbf_txid); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + assert!( + !node_a.list_all_payments().iter().any( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == splice.first_txid) + ), + "a round node A did not contribute to got a payment of its own" + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice round of ours that loses to a sibling round on a channel that stays open has its +/// payment failed as the sibling's lock is handled: LDK holds the sibling alone by then, so no +/// round we contributed to can confirm anymore, and the discard LDK queues with the lock returns +/// what our round reserved. Node A joined node B's splice with a fee-bumping round; node B's round +/// is mined instead. Node B, which contributed to both rounds, keeps its payment, which reports the +/// round that confirmed. Pinned to Esplora so the wallets sync only on demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_round_superseded_on_an_open_channel_fails_its_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, logs_a) = setup_logged_node(&chain_source, random_config()); + let node_b = setup_node(&chain_source, random_config()); + let splice = + open_and_join_counterparty_splice(&bitcoind, &electrsd, &node_a, &logs_a, &node_b).await; + + mine_transaction(&bitcoind, &splice.first_tx); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + assert!(logs_a.wait_for(NO_ROUND_CAN_CONFIRM).await, "the superseded round was not failed"); + assert!( + logs_a.lines().iter().any(|line| line.contains(NO_ROUND_CAN_CONFIRM) + && line.contains(PROMOTED_ROUND_PAYMENT_RESOLVED)), + "the promotion did not fail the payment" + ); + assert!( + logs_a.wait_for(RECLAIMED_ADDRESSES).await, + "the discarded round's addresses were not reclaimed" + ); + let payment = funding_payment(&node_a, splice.rbf_txid); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } + )); + let channel = node_a + .list_channels() + .into_iter() + .find(|channel| channel.user_channel_id == splice.user_channel_id_a) + .expect("the channel stays open"); + assert_eq!(channel.funding_txo.map(|txo| txo.txid), Some(splice.first_txid)); + + let payment_b = funding_payment(&node_b, splice.first_txid); + assert_eq!(payment_b.status, PaymentStatus::Succeeded); + assert!(matches!( + payment_b.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + /// A splice round this node signed is taken back at `ChannelClosed` when the counterparty's /// `commitment_signed` never arrived. The round is recorded at signing, which LDK triggers at /// `tx_complete`, before that message, and the monitor watches no round that message never @@ -3163,6 +3616,248 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { node_b.stop().unwrap(); } +/// A zero-conf splice round of ours stays recorded when the channel closes after a later splice +/// built on it. LDK promoted the round to the funding as `splice_locked` was exchanged, before its +/// transaction confirmed, and moved on again as the later splice locked, so at the close neither +/// the channel manager nor the monitor holds the round — although it can still confirm, the later +/// round and the commitment transaction both descending from it. Node A splices into its zero-conf +/// channel with node B, then splices out of it, and force-closes before either round confirms; the +/// first round's payment is kept, and both graduate once the rounds confirm. Pinned to Esplora so +/// the wallet syncs only on demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn superseded_zero_conf_splice_round_keeps_its_payment_at_close() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, logs_a) = setup_logged_node(&chain_source, random_config()); + let mut config_b = random_config(); + config_b.node_config.trusted_peers_0conf.push(node_a.node_id()); + let node_b = setup_node(&chain_source, config_b); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(5_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 2_000_000, false, &electrsd).await; + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + // Confirm the original funding so the splices below are the only unconfirmed rounds and node + // A's change from the open is spendable for the splice-in. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000).unwrap(); + let first = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, first.txid).await; + // The zero-conf splice locks without confirmations, re-signaled as `ChannelReady`, and node A + // records the promotion as it handles it. + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + assert_eq!(logs_a.count(ROUND_LOCKED), 1, "the promotion of the first round was not recorded"); + + let address = node_a.onchain_payment().new_address().unwrap(); + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 500_000).unwrap(); + let second = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, second.txid).await; + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + assert_eq!(logs_a.count(ROUND_LOCKED), 2, "the promotion of the second round was not recorded"); + assert_eq!(funding_payment(&node_a, first.txid).status, PaymentStatus::Pending); + assert_eq!(funding_payment(&node_a, second.txid).status, PaymentStatus::Pending); + + node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + expect_event!(node_a, ChannelClosed); + assert!( + logs_a.wait_for_count(CLOSED_CHANNEL_PAYMENT_RESOLVED, 2).await, + "the close did not resolve both funding payments" + ); + assert!(!logs_a.contains(NO_ROUND_CAN_CONFIRM), "the superseded round's payment was failed"); + assert_eq!(funding_payment(&node_a, first.txid).status, PaymentStatus::Pending); + assert_eq!(funding_payment(&node_a, second.txid).status, PaymentStatus::Pending); + + // Both rounds confirm, the second spending the first, and the payments graduate. Six blocks are + // the exact minimum, so wait for the rounds to reach the chain source before mining them. + wait_for_tx(&electrsd.client, second.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + for txid in [first.txid, second.txid] { + let payment = funding_payment(&node_a, txid); + assert_eq!(payment.status, PaymentStatus::Succeeded, "round {} did not graduate", txid); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// The monitor's `DiscardFunding` events for the rounds of a closed channel's splice reach the +/// handler ahead of the channel's `ChannelClosed` when one sync delivers the close and its +/// maturity: the channel manager polls the monitor's report of the close at the start of each event +/// pass and on peer traffic, and the monitor's own events are handled right after the manager's. +/// Each event then finds the channel listed and, both rounds having been broadcast, only returns +/// the round's contribution, leaving the payment to the `ChannelClosed` that follows, which fails +/// it, no round of ours being watched anymore. Node A splices into its channel with node B and +/// bumps the round's fee from another coin, so the two rounds are contributions of their own; node +/// B closes while node A's event handler sits in a held event-queue write — for a channel node C +/// opened to it — until the close and its maturity are synced. Pinned to Esplora so the wallet +/// syncs only on demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_b, logs_b) = setup_logged_node(&chain_source, random_config()); + // Keeping no anchor reserve back from node B, node A's splice-in takes its whole balance and + // leaves no change for a fee bump to draw on. + let mut config_a = random_config(); + config_a.node_config.anchor_channels_config.trusted_peers_no_reserve.push(node_b.node_id()); + let (node_a, store_a, logs_a) = + setup_contended_node(&chain_source, config_a, Some(("", Some("events")))); + let node_c = setup_node(&chain_source, random_config()); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let address_c = node_c.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b, address_c], + Amount::from_sat(1_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + let funding_txo = open_channel(&node_a, &node_b, 600_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 contributes nothing to either round, so only node A hears of them. + node_a.splice_in_with_all(&user_channel_id_a, node_b.node_id()).unwrap(); + let first_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_tx(&electrsd.client, first_txo.txid).await; + wait_for_classified_funding_payment(&node_a, first_txo.txid).await; + let first_round = + wait_for_broadcast(&bitcoind, &logs_a, |tx| tx.compute_txid() == first_txo.txid, "round") + .await; + assert_eq!(first_round.output.len(), 1, "the splice-in left change"); + // The wallet learns the round from the sync and gets a fresh coin for the bump, which then + // spends nothing of the first round's but the funding. + node_a.sync_wallets().unwrap(); + let coin_address = node_a.onchain_payment().new_address().unwrap(); + let coin_txid = distribute_funds_unconfirmed( + &bitcoind.client, + &electrsd.client, + vec![coin_address], + Amount::from_sat(3_000_000), + ) + .await; + mine_block_with(&bitcoind, &electrsd, &[raw_transaction_hex(&bitcoind, coin_txid)]).await; + node_a.sync_wallets().unwrap(); + + node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id()).unwrap(); + let bump_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + assert_ne!(first_txo, bump_txo, "the bump produced the same funding"); + // The mempool may refuse the bump, which pays little more than the first round; the round + // counts either way. + wait_for_classified_funding_payment(&node_a, bump_txo.txid).await; + let bump_round = + wait_for_broadcast(&bitcoind, &logs_a, |tx| tx.compute_txid() == bump_txo.txid, "bump") + .await; + let shared: Vec<_> = bump_round + .input + .iter() + .map(|input| input.previous_output) + .filter(|outpoint| spends(&first_round, *outpoint)) + .collect(); + assert_eq!(shared, vec![funding_txo], "the bump reused an input of the first round"); + let payment_id = PaymentId(first_txo.txid.to_byte_array()); + let payment = node_a.payment(&payment_id).unwrap().expect("the splice has a payment"); + assert_eq!(payment.status, PaymentStatus::Pending); + + // Neither node reconnects to the other: node B closes on its own and node A learns of the + // close from the chain alone. The commitment conflicts with the round in the mempool, so it is + // refused and mined directly, below. + node_a.disconnect(node_b.node_id()).unwrap(); + node_b.disconnect(node_a.node_id()).unwrap(); + node_b.force_close_channel(&user_channel_id_b, node_a.node_id(), None).unwrap(); + expect_event!(node_b, ChannelClosed); + let commitment = + wait_for_broadcast(&bitcoind, &logs_b, |tx| is_commitment(tx, funding_txo), "commitment") + .await; + node_b.stop().unwrap(); + + // Node A's event handler is held in the write queueing node C's channel for the user, so + // nothing polls the monitor's report of the close until it is released. Node C leaves before + // the close is mined: a peer's messages, or its leaving, would have node A poll too. + let hold_a = Arc::clone(&store_a.serializer).write_owned().await; + let listening_address = node_a.listening_addresses().unwrap().first().unwrap().clone(); + node_c.open_channel(node_a.node_id(), listening_address, 500_000, None, None).unwrap(); + expect_channel_pending_event!(node_c, node_a.node_id()); + store_a.wait_for_serialized_write().await; + node_c.stop().unwrap(); + wait_for_no_peers(&node_a).await; + let kept_before = logs_a.count(ROUND_CAN_STILL_CONFIRM); + let commitment_hex = bitcoin::consensus::encode::serialize_hex(&commitment); + mine_block_with(&bitcoind, &electrsd, &[commitment_hex]).await; + for _ in 1..ANTI_REORG_DELAY { + mine_block_with(&bitcoind, &electrsd, &[]).await; + } + node_a.sync_wallets().unwrap(); + drop(hold_a); + + expect_channel_pending_event!(node_a, node_c.node_id()); + expect_event!(node_a, ChannelClosed); + assert!(logs_a.wait_for(NO_ROUND_CAN_CONFIRM).await, "the payment was not failed"); + assert!( + logs_a.lines().iter().any(|line| line.contains(NO_ROUND_CAN_CONFIRM) + && line.contains(CLOSED_CHANNEL_PAYMENT_RESOLVED)), + "the close did not fail the payment" + ); + assert_eq!( + logs_a.count(ROUND_CAN_STILL_CONFIRM), + kept_before, + "a discard while the channel was listed resolved the payment" + ); + assert_eq!( + logs_a.count(RECLAIMED_ADDRESSES), + 2, + "the monitor's events did not each return the round's contribution" + ); + // The record names the round the wallet last heard of: the sync that delivered the close + // saw the mempool drop the first round, and moved the record from the bump to it. + let payment = node_a.payment(&payment_id).unwrap().expect("the splice has a payment"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!( + matches!( + payment.kind, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } if txid == first_txo.txid || txid == bump_txo.txid + ), + "unexpected kind {:?} for rounds {} and {}", + payment.kind, + first_txo.txid, + bump_txo.txid + ); + node_a.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 24d12327085b31c1bcd7d011e2612501b48721b3 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 10 Sep 2026 23:00:36 -0500 Subject: [PATCH 15/37] f - Resolve funding payments when LDK discards a splice round Say which rounds a closed channel's monitor watches: those the background processor has flushed to it, as LDK Node's `ChainMonitor` defers updates to that flush. A round whose `commitment_signed` the manager processed since the last flush therefore looks unwatched at `ChannelClosed` and is dropped from its record, which is right: our `tx_signatures` are released only once the monitor update has been persisted, so the counterparty cannot broadcast such a round. The test of a round the monitor watches notes how its end state changes once the pinned LDK carries the fix for rust-lightning issue 4967, which reports `SpliceNegotiated` for the round at the close. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/event.rs | 17 +++++++++-------- src/wallet/mod.rs | 21 ++++++++++++++------- tests/integration_tests_rust.rs | 8 ++++++++ 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/event.rs b/src/event.rs index 6d3b2037c..4eac302ea 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1981,14 +1981,15 @@ where // still awaiting the counterparty's signatures when the channel closes is queued // after this event, so its record is taken back here. The channel manager holds // only the closed channel's last funding, but the channel's monitor still watches - // every pending round the counterparty committed to, and our signatures may have - // left the node for such a round, so it is kept (see `closed_channel_held_rounds`). - // A payment left with no round of ours the monitor watches, and none LDK promoted - // to the funding before, is failed: the monitor's `DiscardFunding` events settle - // such payments once the close matures, but reach the handler ahead of this event - // when one sync delivers the close and its maturity, and then find the channel - // still listed with every round held. The monitor's guard is not `Send`, so its - // watched transactions are collected before anything is awaited. + // every pending round the counterparty committed to and the background processor + // has flushed to it, and our signatures may have left the node for such a round, so + // it is kept (see `closed_channel_held_rounds`). A payment left with no round of + // ours the monitor watches, and none LDK promoted to the funding before, is failed: + // the monitor's `DiscardFunding` events settle such payments once the close + // matures, but reach the handler ahead of this event when one sync delivers the + // close and its maturity, and then find the channel still listed with every round + // held. The monitor's guard is not `Send`, so its watched transactions are + // collected before anything is awaited. let watched_txids: Vec = self .chain_monitor .get_monitor(channel_id) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index ea3dbde0b..442379dd3 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3312,13 +3312,20 @@ pub(crate) fn held_splice_rounds( /// channel's monitor still watches. The channel manager forgets a pending round with the channel, /// and what it reports for one awaiting the counterparty's signatures is queued after /// `ChannelClosed`, but the monitor keeps watching every pending round the counterparty's -/// `commitment_signed` reached, until a sibling locks or the close matures, and our signatures -/// cannot have left the node before that message: such a round may yet confirm and is left to -/// wallet sync or `DiscardFunding` to resolve, while a round the monitor never watched never had -/// our signatures released. The watched transactions also include the funding and whatever spent it -/// on chain, which no recorded round is. A funding the channel moved on from before it confirmed — -/// a zero-conf splice a later splice built on — is held by neither and can confirm still; the -/// funding payments keep such rounds themselves (see [`Wallet::resolve_promoted_splice_round`]). +/// `commitment_signed` reached and the background processor has flushed to it — the monitor's +/// updates land after the manager's, deferred to that flush — until a sibling locks or the close +/// matures, and our signatures cannot have left the node before that update was persisted: such a +/// round may yet confirm and is left to wallet sync or `DiscardFunding` to resolve, while a round +/// the monitor never watched never had our signatures released. A round whose `commitment_signed` +/// the manager processed since the last flush therefore still looks unwatched here, and is dropped +/// from its record as one nothing broadcast. That is the right outcome for the record: our +/// `tx_signatures` for a splice round are released only once the monitor update its +/// `commitment_signed` produced has been persisted, whichever side sends first, so the counterparty +/// holds nothing it could broadcast. The watched transactions also include the funding and whatever +/// spent it on chain, which no recorded round is. A funding the channel moved on from before it +/// confirmed — a zero-conf splice a later splice built on — is held by neither and can confirm +/// still; the funding payments keep such rounds themselves (see +/// [`Wallet::resolve_promoted_splice_round`]). pub(crate) fn closed_channel_held_rounds( funding_txo: Option, watched_txids: impl IntoIterator, ) -> Vec { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index f3b5ebefc..d593fc0ec 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3281,6 +3281,14 @@ const ROUND_LOCKED: &str = "locked as the funding of channel"; /// and its `to_self_delay` passes, the monitor stops watching the round and reports it discarded, /// and the record of a round node A never saw broadcast goes rather than fail a payment for a /// transaction that never existed. +/// +/// Once is fixed, LDK +/// reports `SpliceNegotiated` for this round after `ChannelClosed`, node A having sent its +/// `tx_signatures`, so the node clears the round's awaiting-broadcast mark and the record is kept: +/// at maturity the payment ends `Failed` with `NO_ROUND_CAN_CONFIRM` logged instead of being +/// removed with `DROPPED_ABANDONED_ROUND`. The test's own tail shows node B does broadcast the +/// round, which is why that is the right end state. The maturity assertions and the two comments +/// describing the removal must change at that pin move, not before. #[cfg(feature = "chain-esplora")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { From a9e2edb5273277dcc8301f9b7c7f99f40c84e49d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 17 Sep 2026 11:25:33 -0500 Subject: [PATCH 16/37] f - Resolve funding payments when LDK discards a splice round Count the record's own transaction as a round of ours only when no candidate records it. Wallet sync moves the record onto whichever of its candidates it sees, so once it names a round we did not contribute to, a promotion or close holding that round kept the payment pending although no round of ours could confirm. Such a round pays and spends nothing of ours, so the wallet should never see it and the record should never move onto one; the rule now holds without relying on that. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 92 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 442379dd3..a72af2885 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -919,8 +919,10 @@ impl Wallet { /// while no round of ours in its record is among `held_rounds` or was promoted to the channel's /// funding (see [`Self::resolve_promoted_splice_round`]), removing its pending entry; a payment /// with such a round is left as it is. The rounds of ours are the candidates recorded with a - /// stake and the record's own transaction. A payment that moved on — its round confirmed, or - /// it was failed already — is not touched beyond the entry a failure cut short left behind. + /// stake, and the record's own transaction only when no candidate records it, as for a record + /// from before candidates were tracked: a recorded candidate counts by its stake alone, + /// whichever round the record names. A payment that moved on — its round confirmed, or it was + /// failed already — is not touched beyond the entry a failure cut short left behind. /// `resolution` names the occasion in what is logged. async fn fail_funding_payments_without_held_round_locked( &self, guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, @@ -952,12 +954,16 @@ impl Wallet { continue; }, }; + // Wallet sync moves the record onto whichever of its candidates it sees, ours or not, + // so a recorded candidate counts by its stake alone; the record's transaction counts + // only where no candidate records it. + let recorded_round = entry.candidate(record_txid).is_none().then_some(record_txid); let mut rounds_of_ours = entry .candidates .iter() .filter(|candidate| candidate.amount_msat.is_some()) .map(|candidate| candidate.txid) - .chain(std::iter::once(record_txid)); + .chain(recorded_round); if let Some(kept) = rounds_of_ours .find(|txid| held_rounds.contains(txid) || entry.locked_rounds.contains(txid)) { @@ -7801,6 +7807,86 @@ mod tests { assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); } + /// Wallet sync moved the record onto the counterparty's round before LDK promoted it, so the + /// promoted round is the record's own transaction. It is recorded without a stake all the + /// same, so it is no round of ours, and the payment is failed as it is when the record still + /// names our round. + #[tokio::test] + async fn promoting_a_round_not_ours_the_record_adopted_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let rounds = [(counterparty_txid, None), (tx.compute_txid(), Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + let event = WalletEvent::TxUnconfirmed { + txid: counterparty_txid, + tx: Arc::new(dummy_tx()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().unwrap(); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) + ); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } + if txid == counterparty_txid + )); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// The same at a close whose monitor holds the counterparty's round the record moved onto: + /// the record naming a round recorded without a stake does not make it a round of ours. + #[tokio::test] + async fn closing_with_a_held_round_not_ours_the_record_adopted_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let rounds = [(counterparty_txid, None), (tx.compute_txid(), Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + let event = WalletEvent::TxUnconfirmed { + txid: counterparty_txid, + tx: Arc::new(dummy_tx()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().unwrap(); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) + ); + + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) + .await + .unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } + if txid == counterparty_txid + )); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + /// A round of ours nothing had broadcast when the counterparty's round locked — our /// signatures were never exchanged — is dropped with the promotion, and its record with it, /// rather than failed: no transaction of ours ever existed to fail a payment for. From 1e242469e2f4815cd4e35f4c4fcdc29a98c12d8a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 21 Sep 2026 16:25:08 -0700 Subject: [PATCH 17/37] f - Resolve funding payments when LDK discards a splice round The pinned LDK now reports `SpliceNegotiated` after `ChannelClosed` for a splice round whose `tx_signatures` this node had sent when the channel was force-closed (rust-lightning issue 4967), so the test of a round the monitor watches asserts that report and, at maturity, that the payment fails rather than being dropped as one nothing broadcast: the counterparty holds the fully signed round and may broadcast it, as the test's tail shows. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- tests/integration_tests_rust.rs | 44 ++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index d593fc0ec..96e8a373b 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3279,16 +3279,11 @@ const ROUND_LOCKED: &str = "locked as the funding of channel"; /// /// The kept record is resolved once the close settles: node A's commitment transaction confirms /// and its `to_self_delay` passes, the monitor stops watching the round and reports it discarded, -/// and the record of a round node A never saw broadcast goes rather than fail a payment for a -/// transaction that never existed. -/// -/// Once is fixed, LDK -/// reports `SpliceNegotiated` for this round after `ChannelClosed`, node A having sent its -/// `tx_signatures`, so the node clears the round's awaiting-broadcast mark and the record is kept: -/// at maturity the payment ends `Failed` with `NO_ROUND_CAN_CONFIRM` logged instead of being -/// removed with `DROPPED_ABANDONED_ROUND`. The test's own tail shows node B does broadcast the -/// round, which is why that is the right end state. The maturity assertions and the two comments -/// describing the removal must change at that pin move, not before. +/// and the payment fails, no round of ours being left that can confirm. LDK reports +/// `SpliceNegotiated` for this round after `ChannelClosed`, node A having sent its `tx_signatures`, +/// so the node clears the round's awaiting-broadcast mark and the record is not dropped at maturity +/// as one nothing broadcast. The test's own tail shows node B does broadcast the round, which is +/// why `Failed` is the right end state. #[cfg(feature = "chain-esplora")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { @@ -3306,6 +3301,7 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { let received_a = logs_a.count(RECEIVED_TX_SIGNATURES); let received_b = logs_b.count(RECEIVED_TX_SIGNATURES); let broadcast_b = logs_b.count(BROADCAST_FUNDING); + let marked_a = logs_a.count(ROUND_MARKED_BROADCAST); node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000).unwrap(); // Recording the round writes the payment store before the round is signed, so node A does not @@ -3337,6 +3333,12 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { node_a.disconnect(node_b.node_id()).unwrap(); node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); expect_event!(node_a, ChannelClosed); + let new_funding_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + assert_eq!(new_funding_txo.txid, rbf_txid, "LDK reported a different round negotiated"); + assert!( + logs_a.wait_for_count(ROUND_MARKED_BROADCAST, marked_a + 1).await, + "the round's awaiting-broadcast mark was not cleared" + ); let payment = node_a .list_all_payments() @@ -3357,8 +3359,8 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { // node B's first round, which spends the same funding, sits there, so it is mined directly. // The monitor settles a close by node A's own commitment only once the `to_self_delay` on its // balance has passed, not after the six blocks that settle a counterparty's; it then reports - // the rounds it watched as discarded, and node A never saw its round broadcast, so the record - // goes. + // the rounds it watched as discarded, and no round of ours is left that can confirm, so the + // payment fails. let commitment = wait_for_broadcast(&bitcoind, &logs_a, |tx| is_commitment(tx, funding_txo), "commitment") .await; @@ -3366,17 +3368,19 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { generate_blocks_and_wait(&bitcoind.client, &electrsd.client, BREAKDOWN_TIMEOUT as usize).await; node_a.sync_wallets().unwrap(); assert!( - logs_a.wait_for(DROPPED_ABANDONED_ROUND).await, - "the discarded round's record was not taken back" + logs_a.wait_for(NO_ROUND_CAN_CONFIRM).await, + "the discarded round's payment was not failed" ); + let payment = node_a + .list_all_payments() + .into_iter() + .find(|p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == rbf_txid)) + .expect("the record of a round node B could broadcast was taken back"); + assert_eq!(payment.status, PaymentStatus::Failed); assert!( - !node_a - .list_all_payments() - .iter() - .any(|p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == rbf_txid)), - "the record of a round nothing broadcast outlived the close" + !logs_a.contains(DROPPED_ABANDONED_ROUND), + "a round node B could broadcast was dropped" ); - assert!(!logs_a.contains(NO_ROUND_CAN_CONFIRM), "a round nothing broadcast was failed"); // With its monitor update through, node B holds both signature sets and hands the round to its // broadcaster on its own — too late to confirm, the commitment having spent the funding — so From a4aeaf9f4f2cca94e9184a58f49af643d98c7514 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 21 Sep 2026 23:22:41 -0700 Subject: [PATCH 18/37] Resolve a transaction to the record that owns it before one listing it as a conflict A pending-store entry lists the transactions that replaced its own, so a cooperative close (or any other wallet transaction) that a splice round replaces lists the round among its conflicting txids. The round's events then matched two entries, its own record's and the close's, and the pending cache's iteration order decided which one won. About one time in five the round's confirmation landed on the close's record, which took the round's txid, figures and confirmation and graduated, while the splice's payment never learned of the confirmation and stayed pending for good. Prefer the entry that records the transaction as its own, whether as its current transaction or as a negotiated candidate, and fall back to an entry that only lists it as a conflict when no entry owns it. The conflict listing stays: it is how a replaced round of a record without candidates, an ordinary payment's RBF history or the replacement of an inbound transaction, maps back to its record. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 161 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 148 insertions(+), 13 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index a72af2885..bc84e46e9 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2836,20 +2836,23 @@ impl Wallet { return Ok(Some(direct_payment_id)); } - if let Some(replaced_details) = self + let owns = |p: &PendingPaymentDetails| { + matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid) + // A middle RBF round is not the record's current txid and may never have + // received a `TxReplaced` event of its own, so map any of its candidate + // txids (an earlier RBF round may confirm) back to the record. + || p.candidate(target_txid).is_some() + }; + let matches = self .pending_payment_store - .list_filter(|p| { - matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid) - || p.conflicting_txids.contains(&target_txid) - // A middle RBF round is not the record's current txid and may never have - // received a `TxReplaced` event of its own, so map any of its candidate - // txids (an earlier RBF round may confirm) back to the record. - || p.candidate(target_txid).is_some() - }) - .await - .first() - { - return Ok(Some(replaced_details.details.id)); + .list_filter(|p| owns(p) || p.conflicting_txids.contains(&target_txid)) + .await; + // An entry lists the transactions that replaced its own, so a transaction another entry + // records as its own (a splice round that replaced a close, say) matches both. The entry + // that owns it is its record; the conflict listing is only how a replaced round of a + // record with no candidates (an ordinary payment's RBF history) maps back to its record. + if let Some(entry) = matches.iter().find(|p| owns(p)).or(matches.first()) { + return Ok(Some(entry.details.id)); } Ok(None) @@ -6454,6 +6457,138 @@ mod tests { } } + /// The mirror image of [`funding_record_does_not_adopt_a_conflicting_close`]: a cooperative + /// close that a splice round replaces lists the round among its conflicting txids, so the + /// round's confirmation resolves to the close's entry as readily as to the splice's, which + /// records the round as its own. It must land on the splice's record whichever entry the + /// pending cache lists first: the close's record is not the round's, and merging the round + /// into it leaves the splice's payment pending for good. Several closes and several fresh + /// wallets, each with its own cache order, make the splice's entry unlikely to come first + /// every time. + #[tokio::test] + async fn close_record_does_not_adopt_a_conflicting_splice_round() { + let secp = bitcoin::secp256k1::Secp256k1::new(); + let counterparty_node_id = bitcoin::secp256k1::PublicKey::from_secret_key( + &secp, + &bitcoin::secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(), + ); + let channel_id = lightning::ln::types::ChannelId::from_bytes([4u8; 32]); + + for _ in 0..12 { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // The round pays a wallet address, so the wallet's view of it carries figures of its own. + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let splice_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: Txid::from_byte_array([3u8; 32]), + vout: 0, + }, + script_sig: bitcoin::ScriptBuf::new(), + sequence: bitcoin::Sequence::MAX, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + }; + let splice_txid = splice_tx.compute_txid(); + // Keyed away from the round's txid, as a later round of a splice is. + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + + // Each close was recorded at broadcast, seen unconfirmed, then replaced by the round: + // what the `TxReplaced` arm leaves behind. + let close_txids: Vec = + (7u8..11).map(|byte| Txid::from_byte_array([byte; 32])).collect(); + for close_txid in &close_txids { + let close_details = PaymentDetails::new( + PaymentId(close_txid.to_byte_array()), + PaymentKind::Onchain { + txid: *close_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::CooperativeClose { + counterparty_node_id, + channel_id, + }), + }, + Some(50_000_000), + Some(1_000), + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + wallet.payment_store.insert_or_update(close_details.clone()).await.unwrap(); + let entry = + PendingPaymentDetails::new(close_details, vec![splice_txid], Vec::new()); + wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + } + + assert_eq!( + wallet.find_payment_by_txid(splice_txid).await.unwrap(), + Some(payment_id), + "the round resolved to a record that only lists it as a conflict" + ); + + let event = WalletEvent::TxConfirmed { + txid: splice_txid, + tx: Arc::new(splice_tx), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let funding = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + match &funding.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid); + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(funding.amount_msat, Some(1_000_000)); + assert_eq!(funding.fee_paid_msat, Some(500)); + + for close_txid in &close_txids { + let close = wallet + .payment_store + .get(&PaymentId(close_txid.to_byte_array())) + .await + .unwrap() + .unwrap(); + match &close.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!( + *txid, *close_txid, + "the close's record adopted the round's txid" + ); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::CooperativeClose { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(close.amount_msat, Some(50_000_000)); + assert_eq!(close.fee_paid_msat, Some(1_000)); + } + } + } + /// Continues the story above: once the conflicting close confirms through the anti-reorg /// depth, the splice's funding transaction can never confirm — its shared input is spent for /// good. The record must fail rather than stay `Pending` forever, and removing the pending From 5a889fcf4b3e703452e3b3888cb330a52d66c701 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 22 Sep 2026 10:13:27 -0700 Subject: [PATCH 19/37] Take the funding re-broadcast trace from the write's own read Classifying a funding-typed broadcast read the payment store only to log that a re-broadcast of a promoted splice had met its interactive-funding record, then read it again inside the write. The write hands back what it found, so the log comes from that read and a channel-open funding costs one read fewer. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 74 +++++++++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 28 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index bc84e46e9..ad2b78cfb 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2085,27 +2085,6 @@ impl Wallet { } let payment_id = PaymentId(txid.to_byte_array()); - - // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed - // and carrying wallet-view figures; `funding_reclassification_update` declines the - // downgrade, leaving no trace that a re-broadcast arrived. Log the arrival so tests can - // observe the traffic; the read serves the log line alone, so a stale read costs no more. - if let Some(current) = self.payment_store.get(&payment_id).await? { - if matches!( - current.kind, - PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } - ) { - log_trace!( - self.logger, - "Keeping interactive-funding classification over funding-typed rebroadcast {}", - txid, - ); - } - } - let details = PaymentDetails::new( payment_id, PaymentKind::Onchain { @@ -2118,7 +2097,26 @@ impl Wallet { direction, PaymentStatus::Pending, ); - self.persist_funding_payment(details, Vec::new()).await?; + let prior = self.persist_funding_payment(details, Vec::new()).await?; + // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed + // and carrying wallet-view figures; `funding_reclassification_update` declines the + // downgrade, leaving no trace that a re-broadcast arrived. Log the arrival so tests can + // observe the traffic, from the record the write found rather than a read of our own. + if prior.is_some_and(|prior| { + matches!( + prior.kind, + PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } + ) + }) { + log_trace!( + self.logger, + "Keeping interactive-funding classification over funding-typed rebroadcast {}", + txid, + ); + } log_debug!( self.logger, "Recorded channel-funding broadcast {} for channel {}", @@ -2644,17 +2642,15 @@ 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`. + /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. Returns the + /// payment store's record as it was before the write. async fn persist_funding_payment( &self, details: PaymentDetails, candidates: Vec, - ) -> Result<(), Error> { + ) -> Result, Error> { // Hold the cross-store lock across both writes so a funding confirmation never observes // the record classified but the candidate history it needs still missing. let guard = self.funding_payment_update_lock.lock().await; - self.persist_funding_payment_locked(&guard, details, candidates) - .await - .map(|_| ()) - .map_err(Error::from) + Ok(self.persist_funding_payment_locked(&guard, details, candidates).await?) } /// [`Self::persist_funding_payment`] for a caller already holding the cross-store lock, whose @@ -6931,6 +6927,28 @@ mod tests { ); } + /// Classifying a channel-open funding the payment store does not know costs one read of it: + /// the write merges against the record it reads, and whether a promoted splice's re-broadcast + /// met its record is told from that same read. + #[tokio::test] + async fn unknown_funding_broadcast_is_recorded_after_one_payment_store_read() { + let counting_store = ReadCountingStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let tx = wallet_paying_tx(&wallet, 1); + let tx_type = + LdkTransactionType::Funding { channels: vec![(counterparty_node_id, channel_id)] }; + let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.classify_broadcast(&tx, &tx_type).await.unwrap(); + let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; + + let payment_id = PaymentId(tx.compute_txid().to_byte_array()); + assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_some()); + assert_eq!(reads, 1, "classifying an unknown funding re-read the payment store"); + } + /// Recording a transaction the payment store does not know costs two reads of it: the /// funding-status check looks the resolved id up, and the generic write merges against the /// store. Nothing in between re-reads what the funding-status check has already seen. From 20a852ae8d74efb263d3ea520b512b8c8978a5de Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 18 Aug 2026 14:39:41 -0500 Subject: [PATCH 20/37] Assign random PaymentIds to funding records Funding records were keyed by a PaymentId derived from a funding txid: the broadcast txid in the generic classification path, the first negotiated candidate's txid in the interactive path. A txid is no identity for a replaceable transaction -- the record deliberately outlives RBF rounds of its funding, so its key carried the txid of whichever round happened to come first, and code could be tempted to re-derive the id from a txid instead of resolving it. Generate the id from the OS entropy source when the record is created, and resolve existing records through their transaction history (find_payment_by_txid) everywhere. RBF stability now comes from resolution instead of derivation. Resolution must share one lock acquisition with the record writes: resolved outside it, the id could go stale against a record wallet sync creates for the same transaction, producing a divergent record -- so both the classification path and the interactive path resolve the id under the lock they write under. Resolution also reaches records that have graduated out of the pending store. Without that, a funding classified again after graduation -- LDK re-broadcasting a 0conf splice whose confirmation landed while the node was offline -- would get a duplicate record under a fresh id, and a reorg after graduation would never reach the record. A record already failed is passed over when a newly signed round resolves its id. Wallet sync fails a funding payment whose round lost to a conflicting spend confirmed while the channel stays open, but LDK still holds the round, so a fee bump of it is signed with the failed round among its candidates. Filed under the failed record, the bump would stay failed and untracked, so nothing would graduate it once it confirmed. The bump gets a record of its own instead. The funding-record surface (classification, candidates, stable ids) debuts in the upcoming release -- v0.7.0 shipped splice_in with no record machinery -- so changing the scheme now costs nothing, while one release later it would break payment(&PaymentId(funding_txid)) lookups for new records. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 --- src/wallet/mod.rs | 429 ++++++++++++++++++++++++++------ tests/integration_tests_rust.rs | 45 ++-- 2 files changed, 373 insertions(+), 101 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index ad2b78cfb..e01cbc1ed 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -163,9 +163,9 @@ pub(crate) struct Wallet { logger: Arc, pending_payment_store: Arc, // Serializes the writers that must observe the payment record and its pending-store entry - // (candidate history included) as one consistent unit: classification holds it across its - // two-store write pair, and wallet sync's event arms hold it from payment-id resolution - // through their last write. Without it, a confirmation landing between classification's two + // (candidate history included) as one consistent unit: classification and wallet sync's event + // arms each hold it from payment-id resolution through their last write (classification's + // being its two-store pair). Without it, a confirmation landing between classification's two // writes sees the record classified but the candidate history absent — resolving the wrong // payment id or stamping the confirmed candidate with another candidate's figures — and a // classification landing inside an arm's decision sequence gets overwritten by the arm's @@ -2084,7 +2084,16 @@ impl Wallet { return Ok(()); } - let payment_id = PaymentId(txid.to_byte_array()); + // Resolution and the writes below must share one lock acquisition: resolved outside it, + // the id could go stale against a record wallet sync creates for the same transaction, + // and the write below would create a divergent record. + let guard = self.funding_payment_update_lock.lock().await; + + // Adopt the id of a record that already tracks this transaction — e.g. a 0conf splice + // re-broadcast through LDK's generic funding path resolves back to its + // interactive-funding record here — otherwise generate a fresh id. + let payment_id = self.find_payment_by_txid(txid).await?.unwrap_or_else(random_payment_id); + let details = PaymentDetails::new( payment_id, PaymentKind::Onchain { @@ -2097,7 +2106,7 @@ impl Wallet { direction, PaymentStatus::Pending, ); - let prior = self.persist_funding_payment(details, Vec::new()).await?; + let prior = self.persist_funding_payment_locked(&guard, details, Vec::new()).await?; // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed // and carrying wallet-view figures; `funding_reclassification_update` declines the // downgrade, leaving no trace that a re-broadcast arrived. Log the arrival so tests can @@ -2126,14 +2135,43 @@ impl Wallet { Ok(()) } - /// Builds the payment record and the per-candidate figures for recording the `active` round - /// of an interactive funding whose negotiated history is `candidates`. Returns `None` when - /// there is nothing to record: no local contribution to the round, or no wallet-level activity. + /// Resolves the id under which the interactive funding with negotiated history `candidates` is + /// recorded: that of a record already tracking any of its rounds (wallet sync may record a + /// round before this node does), else a fresh one. A record already failed is passed over: + /// wallet sync fails a payment whose round lost to a conflicting spend confirmed while the + /// channel stays open, LDK still holds the round and a fee bump of it is signed with the round + /// among its candidates, and nothing revisits a failed record's status, so the bump filed under + /// it would go untracked. An id derived from a txid would tie the record's identity to one + /// round of a replaceable transaction — resolution through the record's txid history is what + /// keeps its identity stable across RBF replacements. The caller holds the cross-store lock: + /// resolved outside it, the id could go stale against a record wallet sync creates for the same + /// transaction before the caller's write. + async fn resolve_interactive_funding_id( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, candidates: &[FundingCandidate], + ) -> Result { + for candidate in candidates.iter() { + if let Some(id) = self.find_payment_by_txid(candidate.txid).await? { + let failed = self + .payment_store + .get(&id) + .await? + .is_some_and(|payment| payment.status == PaymentStatus::Failed); + if !failed { + return Ok(id); + } + } + } + Ok(random_payment_id()) + } + + /// Builds the payment record, under the resolved `payment_id`, and the per-candidate figures + /// for recording the `active` round of an interactive funding whose negotiated history is + /// `candidates`. Returns `None` when there is nothing to record: no local contribution to the + /// round, or no wallet-level activity. fn interactive_funding_record( - &self, candidates: &[FundingCandidate], active: &FundingCandidate, tx: &Transaction, - tx_type: TransactionType, + &self, payment_id: PaymentId, candidates: &[FundingCandidate], active: &FundingCandidate, + tx: &Transaction, tx_type: TransactionType, ) -> Option<(PaymentDetails, Vec)> { - let first = candidates.first()?; let txid = active.txid; let aggregate = aggregate_local_stakes(active); @@ -2165,10 +2203,6 @@ impl Wallet { return None; } - // 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 @@ -2208,11 +2242,12 @@ impl Wallet { /// as broadcast ([`Self::record_broadcast_splice_round`]). /// /// `candidates` is the channel's pending splice history as [`funding_candidates`] lists it from - /// the channel's [`SpliceDetails`], so the record is written in full, under the first - /// candidate's txid as id. The signed round is marked as awaiting broadcast until LDK reports - /// the splice negotiated and [`Self::record_broadcast_splice_round`] clears the mark: only such - /// a round can be abandoned without a trace, and [`Self::drop_abandoned_splice_rounds`] takes - /// it back once LDK no longer holds it. + /// the channel's [`SpliceDetails`], so the record is written in full, under the id + /// [`Self::resolve_interactive_funding_id`] resolves (that of a record already tracking any + /// round of the history, else a fresh one). The signed round is marked as awaiting broadcast + /// until LDK reports the splice negotiated and [`Self::record_broadcast_splice_round`] clears + /// the mark: only such a round can be abandoned without a trace, and + /// [`Self::drop_abandoned_splice_rounds`] takes it back once LDK no longer holds it. /// /// Nothing is recorded for a round missing from the history (reset between the event's /// emission and its handling, so LDK will refuse the signed transaction), already recorded (a @@ -2239,22 +2274,27 @@ impl Wallet { }; let tx_type = LdkTransactionType::InteractiveFunding { candidates: candidates.to_vec() }.into(); - let (details, mut history) = - match self.interactive_funding_record(candidates, signed_round, tx, tx_type) { - Some(record) => record, - None => return Ok(()), - }; - let payment_id = details.id; + + // Resolution, the reads and the writes below must share one lock acquisition, as in every + // funding-record write: done outside it, the record could change under us before the write. + let guard = self.funding_payment_update_lock.lock().await; + let payment_id = self.resolve_interactive_funding_id(&guard, candidates).await?; + let (details, mut history) = match self.interactive_funding_record( + payment_id, + candidates, + signed_round, + tx, + tx_type, + ) { + Some(record) => record, + None => return Ok(()), + }; // Only the signed round awaits broadcast: LDK broadcast the others once their signatures // were exchanged. if let Some(signed) = history.iter_mut().find(|candidate| candidate.txid == txid) { signed.awaiting_broadcast = true; } - // The reads and the write below must share one lock acquisition, as in every funding-record - // write: read outside it, the record could change under us before the write. - let guard = self.funding_payment_update_lock.lock().await; - let prior_pending = self.pending_payment_store.get(&payment_id).await?; // A replayed signing event re-offers a transaction already recorded; nothing to add. if prior_pending.as_ref().is_some_and(|entry| entry.candidate(txid).is_some()) { @@ -2576,12 +2616,15 @@ impl Wallet { /// between the two stores and the rollback failed as well, leaving the payment record without /// the pending entry that indexes it. The replayed signing event, finding the round gone from /// the history, ends up here; a fully recorded round (its entry in place) is left to - /// [`Self::drop_abandoned_splice_rounds`]. Only a first round is recorded under its own txid: - /// the record of a bump lives under an earlier round's id and keeps its entry, and wallet sync - /// moves it on as that earlier round confirms or fails. + /// [`Self::drop_abandoned_splice_rounds`]. Only a first round can be left so: the record of a + /// bump keeps the entry of the rounds before it, and wallet sync moves it on as an earlier + /// round confirms or fails. async fn drop_unindexed_signing_record(&self, txid: Txid) -> Result<(), Error> { let _guard = self.funding_payment_update_lock.lock().await; - let payment_id = PaymentId(txid.to_byte_array()); + let payment_id = match self.find_payment_by_txid(txid).await? { + Some(id) => id, + None => return Ok(()), + }; if self.pending_payment_store.get(&payment_id).await?.is_some() { return Ok(()); } @@ -2644,6 +2687,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`. Returns the /// payment store's record as it was before the write. + /// + /// Production callers go through [`Self::persist_funding_payment_locked`] because they resolve + /// the record's id under the same lock acquisition; this wrapper models that acquisition for + /// tests writing a record mid-flow. + #[cfg(test)] async fn persist_funding_payment( &self, details: PaymentDetails, candidates: Vec, ) -> Result, Error> { @@ -2653,10 +2701,14 @@ impl Wallet { Ok(self.persist_funding_payment_locked(&guard, details, candidates).await?) } - /// [`Self::persist_funding_payment`] for a caller already holding the cross-store lock, whose - /// reads the write must not be separated from. Returns the payment store's record as it was - /// before the write, read inside the write's own critical section, so a caller needs no read of - /// its own to know what the write merged into. + /// Writes a freshly recorded funding payment to the authoritative payment store and adds a + /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. The + /// caller holds the cross-store lock, resolving the record's id and performing both store + /// writes under one acquisition, so a funding confirmation never observes the record written + /// but the candidate history it needs still missing, and the resolved id never goes stale + /// against a concurrent sync write. Returns the payment store's record as it was before the + /// write, read inside the write's own critical section, so a caller needs no read of its own to + /// know what the write merged into. async fn persist_funding_payment_locked( &self, _guard: &tokio::sync::MutexGuard<'_, ()>, details: PaymentDetails, candidates: Vec, @@ -2851,6 +2903,25 @@ impl Wallet { return Ok(Some(entry.details.id)); } + // The pending store only indexes in-flight records — graduation removes the entry — so a + // graduated record's transaction resolves through the payment store itself. Without this, a + // funding-typed broadcast classified after graduation (e.g. LDK re-broadcasting a promoted + // 0conf splice whose confirmation landed while the node was offline) would create a + // duplicate record, and a post-graduation reorg's events would never reach the record. + let mut page_token = None; + loop { + let page = self.payment_store.list_page(page_token).await?; + if let Some(payment) = page.objects.iter().find( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), + ) { + return Ok(Some(payment.id)); + } + match page.next_page_token { + Some(token) => page_token = Some(token), + None => break, + } + } + Ok(None) } @@ -3365,6 +3436,15 @@ enum FundingPaymentFailure { MovedOn, } +/// Generates a fresh funding-record [`PaymentId`] from the OS entropy source. A funding record's id +/// carries no meaning beyond uniqueness: the record is found through its transaction history +/// ([`Wallet::find_payment_by_txid`]), never re-derived from a txid. +fn random_payment_id() -> PaymentId { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("getrandom failed"); + PaymentId(bytes) +} + /// The outcome of [`Wallet::apply_funding_status_update_locked`]. enum FundingStatusUpdate { /// The event's transaction belongs to the funding payment; its refreshed confirmation status @@ -3723,9 +3803,9 @@ impl From for Error { /// classification. /// /// `current` is the record as observed inside the payment store's `mutate` critical section — its -/// sole caller, [`Wallet::persist_funding_payment`], builds and applies the update within one -/// closure — so the candidate choice cannot go stale against a concurrent confirmation before the -/// update lands. [`PaymentDetails::update`]'s confirmed-figures rule still arbitrates which +/// sole caller, [`Wallet::persist_funding_payment_locked`], builds and applies the update within +/// one closure — so the candidate choice cannot go stale against a concurrent confirmation before +/// the update lands. [`PaymentDetails::update`]'s confirmed-figures rule still arbitrates which /// figures may land on the record. fn funding_reclassification_update( details: PaymentDetails, candidates: &[FundingTxCandidate], current: Option<&PaymentDetails>, @@ -5012,13 +5092,13 @@ mod tests { (tx, contribution) } - /// Signing a splice round records its funding payment under the first candidate's txid as id, - /// with the channel's full pending splice history, so a wallet sync that observes the - /// transaction before the broadcast (the counterparty may broadcast first) resolves to the - /// funding record instead of filing the round as a foreign duplicate. Only the signed round - /// awaits broadcast; LDK broadcast the negotiated predecessor already. + /// Signing a splice round records its funding payment with the channel's full pending splice + /// history, so a wallet sync that observes the transaction before the broadcast (the + /// counterparty may broadcast first) resolves to the funding record through any round of that + /// history instead of filing the round as a foreign duplicate. Only the signed round awaits + /// broadcast; LDK broadcast the negotiated predecessor already. #[tokio::test] - async fn signing_records_the_round_under_the_first_candidate_id() { + async fn signing_records_the_round_with_the_full_splice_history() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); @@ -5036,11 +5116,12 @@ mod tests { wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(prior_txid.to_byte_array()); let payments = wallet.payment_store.list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1); let payment = &payments[0]; - assert_eq!(payment.id, id); + let id = payment.id; + assert_ne!(id, PaymentId(prior_txid.to_byte_array())); + assert_ne!(id, PaymentId(txid.to_byte_array())); assert_eq!(payment.amount_msat, Some(500_300_000)); assert_eq!(payment.fee_paid_msat, Some(300_000)); assert_eq!(payment.direction, PaymentDirection::Inbound); @@ -5074,6 +5155,62 @@ mod tests { assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); } + /// A fee bump of a round whose payment wallet sync failed — the round lost to a conflicting + /// spend confirmed while the channel stayed open, so LDK still holds it and offers the bump — + /// is signed with the failed round among its candidates. The failed record takes no round: + /// nothing revisits its status, so the bump would go untracked under it. The bump gets a + /// record of its own. + #[tokio::test] + async fn signing_a_bump_of_a_failed_round_gets_a_record_of_its_own() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_store + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.pending_payment_store.remove(&failed_id).await.unwrap(); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + let bump_id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("a record"); + assert_ne!(bump_id, failed_id); + let payment = wallet.payment_store.get(&bump_id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = wallet.pending_payment_store.get(&bump_id).await.unwrap().expect("entry"); + assert_eq!(entry.details, payment); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let failed = + wallet.payment_store.get(&failed_id).await.unwrap().expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert!(wallet.pending_payment_store.get(&failed_id).await.unwrap().is_none()); + } + /// Once LDK reports a round recorded at signing negotiated, there is nothing to add but the /// broadcast itself: the round's awaiting-broadcast mark is cleared and the record left as /// written. @@ -5092,7 +5229,7 @@ mod tests { &[(prior_txid, None), (txid, Some(contribution))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(prior_txid.to_byte_array()); + let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); assert!(record.candidate(txid).unwrap().awaiting_broadcast); @@ -5124,7 +5261,7 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); assert!(record.candidate(txid).unwrap().awaiting_broadcast); @@ -5306,10 +5443,10 @@ mod tests { ); wallet.record_signed_funding(&next_tx, &next_candidates).await.unwrap(); - let id = PaymentId(prior_txid.to_byte_array()); let payments = wallet.payment_store.list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1); - assert_eq!(payments[0].id, id); + let id = payments[0].id; + assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); assert!( matches!(&payments[0].kind, PaymentKind::Onchain { txid: t, .. } if *t == next_txid) ); @@ -5346,14 +5483,14 @@ mod tests { &[(other_txid, Some(other_contribution))], ); wallet.record_signed_funding(&other_tx, &other_candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let other_id = wallet.find_payment_by_txid(other_txid).await.unwrap().expect("other id"); wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); - let other_id = PaymentId(other_txid.to_byte_array()); assert!(wallet.payment_store.get(&other_id).await.unwrap().is_some()); assert_eq!(wallet.find_payment_by_txid(other_txid).await.unwrap(), Some(other_id)); } @@ -5374,7 +5511,7 @@ mod tests { &[(txid, Some(contribution.clone()))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); @@ -5421,7 +5558,7 @@ mod tests { insert_unconfirmed_tx(&wallet, tx); evict_tx(&wallet, txid); assert!(wallet.inner.lock().unwrap().get_tx(txid).is_none(), "evicted: not canonical"); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); @@ -5451,7 +5588,7 @@ mod tests { ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); @@ -5491,7 +5628,7 @@ mod tests { &[(prior_txid, None), (txid, Some(contribution))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(prior_txid.to_byte_array()); + let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); assert!(wallet.payment_store.get(&id).await.unwrap().is_some()); wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); @@ -5518,7 +5655,7 @@ mod tests { &[(txid, Some(contribution.clone()))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); @@ -5600,7 +5737,7 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); wallet.payment_store.remove(&id).await.unwrap(); assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); @@ -5628,7 +5765,7 @@ mod tests { &[(txid, Some(contribution.clone()))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); let bump_candidates = splice_candidates( @@ -5671,14 +5808,14 @@ mod tests { let (tx, _) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); - let id = PaymentId(txid.to_byte_array()); + let id = PaymentId([11u8; 32]); let mut graduated = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); graduated.status = PaymentStatus::Succeeded; wallet.payment_store.insert_or_update(graduated.clone()).await.unwrap(); let (other_tx, _) = splice_out_round(&wallet, 2, 400_000, 700); let other_txid = other_tx.compute_txid(); - let other_id = PaymentId(other_txid.to_byte_array()); + let other_id = PaymentId([12u8; 32]); let untyped = PaymentDetails::new( other_id, PaymentKind::Onchain { @@ -5788,6 +5925,9 @@ mod tests { &[(closed_txid, Some(closed_contribution))], ); wallet.record_signed_funding(&closed_tx, &closed_candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let other_id = wallet.find_payment_by_txid(other_txid).await.unwrap().expect("other id"); + let closed_id = wallet.find_payment_by_txid(closed_txid).await.unwrap().expect("closed id"); wallet .drop_splice_rounds_lost_across_restart(|channel| { @@ -5802,13 +5942,10 @@ mod tests { .await .unwrap(); - let id = PaymentId(txid.to_byte_array()); assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); - let other_id = PaymentId(other_txid.to_byte_array()); assert!(wallet.payment_store.get(&other_id).await.unwrap().is_some()); assert!(wallet.pending_payment_store.get(&other_id).await.unwrap().is_some()); - let closed_id = PaymentId(closed_txid.to_byte_array()); assert!(wallet.payment_store.get(&closed_id).await.unwrap().is_some()); assert!(wallet.pending_payment_store.get(&closed_id).await.unwrap().is_some()); } @@ -5831,7 +5968,7 @@ mod tests { &[(txid, Some(contribution.clone()))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); let bump_candidates = splice_candidates( @@ -5867,16 +6004,19 @@ mod tests { let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); - let id = PaymentId(txid.to_byte_array()); + let id = PaymentId([9u8; 32]); let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); wallet.payment_store.insert_or_update(half_written).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); wallet.record_signed_funding(&tx, &[]).await.unwrap(); assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); wallet.record_signed_funding(&tx, &[]).await.unwrap(); assert!(wallet.payment_store.get(&id).await.unwrap().is_some()); assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); @@ -5918,16 +6058,16 @@ mod tests { let txid = tx.compute_txid(); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - let id = PaymentId(txid.to_byte_array()); fail_store.fail_writes.store(true, Ordering::Release); assert!(wallet.record_signed_funding(&tx, &candidates).await.is_err()); assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 1); - assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); fail_store.fail_writes.store(false, Ordering::Release); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); @@ -5952,7 +6092,7 @@ mod tests { &[(txid, Some(contribution.clone()))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let prior = wallet.payment_store.get(&id).await.unwrap().expect("payment"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); @@ -6360,6 +6500,31 @@ mod tests { wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap(); } + /// A graduated funding record has no pending entry — graduation removes it — so its txid must + /// resolve through the payment store itself. Without that fallback, a funding-typed broadcast + /// classified after graduation (e.g. LDK re-broadcasting a promoted 0conf splice whose + /// confirmation landed while the node was offline) would miss the record and create a duplicate + /// under a fresh id. + #[tokio::test] + async fn find_payment_by_txid_resolves_graduated_records() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let txid = Txid::from_byte_array([6u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut graduated = + interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); + graduated.kind = PaymentKind::Onchain { + txid, + status: confirmed_status(), + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + graduated.status = PaymentStatus::Succeeded; + wallet.payment_store.insert_or_update(graduated).await.unwrap(); + + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(payment_id)); + } + /// A cooperative close conflicts with a pending splice's funding transaction — both spend the /// pre-splice funding outpoint — so sync records the close among the splice record's /// conflicting txids, and the close's confirmation then resolves to the splice's PaymentId. @@ -6944,8 +7109,7 @@ mod tests { wallet.classify_broadcast(&tx, &tx_type).await.unwrap(); let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; - let payment_id = PaymentId(tx.compute_txid().to_byte_array()); - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_some()); + assert!(wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap().is_some()); assert_eq!(reads, 1, "classifying an unknown funding re-read the payment store"); } @@ -7168,7 +7332,114 @@ mod tests { wallet.classify_funding(&funded_tx, &channels, tx_type).await.unwrap(); let payments = wallet.payment_store.list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1); - assert_eq!(payments[0].id, PaymentId(funded_tx.compute_txid().to_byte_array())); + match &payments[0].kind { + PaymentKind::Onchain { txid, .. } => assert_eq!(*txid, funded_tx.compute_txid()), + kind => panic!("unexpected kind {:?}", kind), + } + } + + /// A funding record's PaymentId is generated at record creation instead of being derived from a + /// txid: a replaceable transaction's txid is no stable identity for the record. Every lookup + /// resolves the record through its txid history (current txid, candidates, conflicts) rather + /// than re-deriving the id, so nothing may rely on the id and the txid coinciding. + #[tokio::test] + async fn funding_record_is_keyed_by_a_generated_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; + let tx_type = TransactionType::Funding { channels: vec![] }; + + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let funded_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let txid = funded_tx.compute_txid(); + wallet.classify_funding(&funded_tx, &channels, tx_type).await.unwrap(); + + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let record = &payments[0]; + assert_ne!(record.id, PaymentId(txid.to_byte_array()), "the id must not be the txid"); + match &record.kind { + PaymentKind::Onchain { txid: kind_txid, .. } => assert_eq!(*kind_txid, txid), + kind => panic!("unexpected kind {:?}", kind), + } + // The pending entry shares the id, and txid lookups resolve to the record. + assert!(wallet.pending_payment_store.get(&record.id).await.unwrap().is_some()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(record.id)); + } + + /// A funding transaction classified again — e.g. a 0conf splice re-broadcast through LDK's + /// generic funding path after a restart — must resolve to the record's generated id rather + /// than create a second record for the same transaction. + #[tokio::test] + async fn funding_rebroadcast_resolves_to_the_generated_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; + + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let funded_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let txid = funded_tx.compute_txid(); + + // The interactive-funding record written when the round was signed, keyed by a generated + // id. + let payment_id = PaymentId([42u8; 32]); + let candidates = vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + + // The re-typed rebroadcast comes back through the generic funding path. + wallet + .classify_funding(&funded_tx, &channels, TransactionType::Funding { channels: vec![] }) + .await + .unwrap(); + + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the rebroadcast must not create a second record"); + assert_eq!(payments[0].id, payment_id); + // The interactive classification and contribution figures survive the generic + // wallet-view update (`funding_reclassification_update` declines the downgrade). + assert!(matches!( + payments[0].kind, + PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } + )); + assert_eq!(payments[0].amount_msat, Some(1_000_000)); } /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding @@ -7874,7 +8145,7 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, rounds); wallet.record_signed_funding(tx, &candidates).await.unwrap(); wallet.record_broadcast_splice_round(channel_id, tx.compute_txid()).await.unwrap(); - PaymentId(rounds[0].0.to_byte_array()) + wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap().expect("recorded") } /// The close finds no round of ours held — the channel closed on a commitment transaction and @@ -8057,7 +8328,7 @@ mod tests { &[(counterparty_txid, None), (txid, Some(contribution))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(counterparty_txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); assert!(wallet.payment_store.get(&id).await.unwrap().is_some(), "the round was recorded"); wallet @@ -8269,7 +8540,7 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); wallet.resolve_promoted_splice_round(channel_id, txid, Some(&[txid])).await.unwrap(); let later_funding_txid = Txid::from_byte_array([0xF1; 32]); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 96e8a373b..108ac5a55 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2278,9 +2278,7 @@ async fn splice_channel() { // them to the channel balance since there may not be a change output. let expected_splice_in_lightning_balance_sat = 4_000_002; - let payments = node_b.list_all_payments(); - let payment = - payments.into_iter().find(|p| p.id == PaymentId(txo.txid.to_byte_array())).unwrap(); + let payment = funding_payment(&node_b, txo.txid); assert_eq!(payment.fee_paid_msat, Some(expected_splice_in_fee_sat * 1_000)); assert_eq!( @@ -2329,9 +2327,7 @@ async fn splice_channel() { let expected_splice_out_fee_sat = 183; - let payments = node_a.list_all_payments(); - let payment = - payments.into_iter().find(|p| p.id == PaymentId(txo.txid.to_byte_array())).unwrap(); + let payment = funding_payment(&node_a, txo.txid); 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 @@ -2630,15 +2626,20 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // classification — the `tx_type` assertion below catches a regression deterministically. wait_for_tx(&electrsd.client, original_txo.txid).await; wait_for_classified_funding_payment(&node_b, original_txo.txid).await; + // The record's random id is fixed at creation; capture it while the original candidate is + // current so its stability can be asserted across the RBF rounds below. + let splice_payment_id = funding_payment(&node_b, original_txo.txid).id; 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).unwrap().expect("splice payment exists").fee_paid_msat; + let fee = node_b + .payment(&splice_payment_id) + .unwrap() + .expect("splice payment exists") + .fee_paid_msat; let raw_tx: String = bitcoind .client .call("getrawtransaction", &[json!(original_txo.txid.to_string())]) @@ -2669,12 +2670,11 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { 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. + // payment covering both candidates: still under the id it was created with, `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).unwrap().expect("splice payment exists"); + let payment = node_b.payment(&splice_payment_id).unwrap().expect("splice payment exists"); match payment.kind { PaymentKind::Onchain { txid, @@ -2748,8 +2748,8 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // 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).unwrap().expect("splice payment graduated"); + let payment = + node_b.payment(&splice_payment_id).unwrap().expect("splice payment graduated"); assert_eq!(payment.status, PaymentStatus::Succeeded); match payment.kind { PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } => { @@ -2809,8 +2809,7 @@ async fn funding_payment_graduates_without_channel_ready() { // 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).unwrap().expect("funding payment exists"); + let payment = funding_payment(&node_a, funding_txo.txid); assert_eq!(payment.status, PaymentStatus::Succeeded); match payment.kind { PaymentKind::Onchain { @@ -2872,8 +2871,8 @@ async fn splice_payment_reorged_to_unconfirmed() { 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).unwrap().expect("splice payment exists"); + let payment = funding_payment(&node_b, splice_txo.txid); + let payment_id = payment.id; assert_eq!(payment.status, PaymentStatus::Pending); assert!(matches!( payment.kind, @@ -3043,6 +3042,8 @@ fn only_interactive_funding_txid(node: &TestNode) -> Txid { } /// `node`'s payment for the funding transaction `funding_txid`, which it must have recorded. +/// Funding records are keyed by a random id generated at creation, so they are found through their +/// transaction history rather than by deriving an id from a txid. fn funding_payment(node: &TestNode, funding_txid: Txid) -> PaymentDetails { node.list_all_payments() .into_iter() @@ -3796,9 +3797,9 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { .filter(|outpoint| spends(&first_round, *outpoint)) .collect(); assert_eq!(shared, vec![funding_txo], "the bump reused an input of the first round"); - let payment_id = PaymentId(first_txo.txid.to_byte_array()); - let payment = node_a.payment(&payment_id).unwrap().expect("the splice has a payment"); + let payment = funding_payment(&node_a, bump_txo.txid); assert_eq!(payment.status, PaymentStatus::Pending); + let payment_id = payment.id; // Neither node reconnects to the other: node B closes on its own and node A learns of the // close from the chain alone. The commitment conflicts with the round in the mempool, so it is From 2a421e688bd92e116acf6c811310df790c3b429a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 17 Sep 2026 11:35:53 -0500 Subject: [PATCH 21/37] f - Assign random PaymentIds to funding records Find the record of each classified transaction instead of deriving its payment-store key from the txid, which no longer names a funding record's id. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index e01cbc1ed..4727a04db 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -7871,16 +7871,27 @@ mod tests { gated_store.release.notify_one(); let expected_writes = fresh_txs.len() + 2; let mut payment_writes = Vec::new(); + let mut payments = Vec::new(); for _ in 0..100 { tokio::time::sleep(Duration::from_millis(100)).await; payment_writes = gated_store.written_keys(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); - if payment_writes.len() >= expected_writes { + payments = wallet.payment_store.list_page(None).await.unwrap().objects; + if payment_writes.len() >= expected_writes && payments.len() >= expected_writes { break; } } assert_eq!(payment_writes.len(), expected_writes, "not every package was classified"); + // A funding record's id is its own, so find each transaction's record to learn the key + // its write went under. let position = |tx: &Transaction| { - let key = PaymentId(tx.compute_txid().to_byte_array()).encode_to_hex_str(); + let txid = tx.compute_txid(); + let record = payments + .iter() + .find(|payment| { + matches!(payment.kind, PaymentKind::Onchain { txid: recorded, .. } if recorded == txid) + }) + .expect("classified"); + let key = record.id.encode_to_hex_str(); payment_writes.iter().position(|written| *written == key).expect("classified") }; let retried_position = position(&retried_tx); From 65198ab5a01b9c6354cdd394f18e2851460034f9 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 17 Sep 2026 12:00:53 -0500 Subject: [PATCH 22/37] f - Assign random PaymentIds to funding records Say where a funding record still carries a txid-derived id now that classification generates ids: only a record wallet sync created before classification keeps the id of that transaction, and the settled-record collision the wallet-sync fallback guards against arises for those records alone. Three docs still described the derived id as the rule. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 4727a04db..58ecdd3c1 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -681,14 +681,14 @@ impl Wallet { /// The id to record a transaction under that the funding-status check found foreign to the /// funding record resolved for it as `resolved_id`: its own txid-derived id, or `None` when a - /// funding record sits there already. A funding record's id is anchored to its first - /// candidate's txid, so a wallet event for that transaction falls back to this id whenever the - /// pending entry no longer maps it — which only happens once the negotiation settled and the - /// entry was removed. The generic event handling must then skip its write: merging a - /// wallet-view `Pending` payment into the settled record would resurrect it with figures no - /// classification derived. When `resolved_id` is the txid-derived id already, the - /// funding-status check has read that record, and finding the transaction foreign to it is - /// this very case; only a fallback from a different id needs a read. + /// funding record sits there already. A funding record wallet sync created before + /// classification keeps the txid-derived id of that transaction, so a wallet event for it + /// falls back to this id whenever the pending entry no longer maps it — which only happens + /// once the negotiation settled and the entry was removed. The generic event handling must + /// then skip its write: merging a wallet-view `Pending` payment into the settled record would + /// resurrect it with figures no classification derived. When `resolved_id` is the txid-derived + /// id already, the funding-status check has read that record, and finding the transaction + /// foreign to it is this very case; only a fallback from a different id needs a read. async fn foreign_transaction_payment_id( &self, resolved_id: PaymentId, txid: Txid, ) -> Result, Error> { @@ -6382,8 +6382,8 @@ mod tests { } /// A middle RBF candidate must map back to the funding record: it is neither the record's - /// id (derived from the first candidate), nor its current txid (the active candidate), nor - /// in `conflicting_txids` (it never got a `TxReplaced` event of its own). + /// id (here the txid-derived id of the first candidate), nor its current txid (the active + /// candidate), nor in `conflicting_txids` (it never got a `TxReplaced` event of its own). #[tokio::test] async fn find_payment_by_txid_maps_candidate_txids() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); @@ -7134,18 +7134,19 @@ mod tests { assert_eq!(reads, 2, "recording an unknown transaction re-read the payment store"); } - /// A funding record's id is anchored to its first candidate's txid. Once the payment settles - /// and its entry is removed, a wallet event for that candidate no longer resolves through the - /// candidate history — the fallback keys it by its own txid, colliding with the record's id. - /// Recording the event there would merge a fresh wallet-view `Pending` payment into the - /// terminal record; such events must be skipped. + /// A funding record wallet sync created before classification keeps the txid-derived id of + /// its first candidate. Once the payment settles and its entry is removed, a wallet event for + /// that candidate no longer resolves through the candidate history — the fallback keys it by + /// its own txid, colliding with the record's id. Recording the event there would merge a fresh + /// wallet-view `Pending` payment into the terminal record; such events must be skipped. #[tokio::test] async fn candidate_event_does_not_resurrect_a_settled_funding_payment() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(store, false).await; - // The record's id derives from the first candidate r1; its txid rotated to the RBF round - // r2. The payment failed and its pending entry is gone. + // The record keeps the txid-derived id of its first candidate r1, as one wallet sync + // created first does; its txid rotated to the RBF round r2. The payment failed and its + // pending entry is gone. let r1 = Txid::from_byte_array([2u8; 32]); let r2 = Txid::from_byte_array([4u8; 32]); let payment_id = PaymentId(r1.to_byte_array()); From f1b5a40f453092985ca1e58d198437ddc7c68b74 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 3 Aug 2026 10:33:13 -0500 Subject: [PATCH 23/37] Model pending payments as an enum for pre-broadcast splices A user-initiated splice dropped before LDK persists it leaves no trace in LDK. Recovering whatever the splice reserved and describing later events about it in terms of the original request both require persisting the splice intent before handing it to LDK, which happens before negotiation and therefore before any funding transaction exists. The pending-payment record was built around an on-chain PaymentDetails carrying a txid, which cannot represent a splice that has not been broadcast yet. Reshape PendingPaymentDetails into an enum: a PendingSplice variant that holds only the generated PaymentId and the splice intent, and a Tracked variant that is the previous record plus an optional intent retained until the splice locks. Add the SpliceIntent and SpliceKind types that record what was handed to LDK and the API call that produced it. The wallet's pending-store writes that depend on a payment's status now make that check and the write atomically, replacing racy read-then-write pairs. They share one helper whose closure re-reads the payment's status inside the critical section -- only Pending payments belong in the pending store, and a status read taken outside it can go stale against graduation -- and promotes a bare PendingSplice to a Tracked record once a payment exists under its id: a plain payment-tracking merge would silently no-op against the variant, leaving the splice invisible to txid lookups. This is groundwork; nothing constructs a PendingSplice yet. A later commit adds the classification that reads the variant; the entry points that persist splice intents land with the splice tracking built on this. Generated with assistance from Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Claude Fable 5 --- src/payment/pending_payment_store.rs | 461 ++++++++++++++++++++++----- src/wallet/mod.rs | 294 ++++++++++------- 2 files changed, 564 insertions(+), 191 deletions(-) diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 11f09d53f..fe3f1f2cb 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -5,9 +5,13 @@ // 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 bitcoin::Txid; -use lightning::impl_writeable_tlv_based; +use bitcoin::secp256k1::PublicKey; +use bitcoin::{TxOut, Txid}; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::FundingContribution; +use lightning::ln::types::ChannelId; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use crate::data_store::{StorableObject, StorableObjectUpdate, UpdatableObject}; use crate::payment::store::PaymentDetailsUpdate; @@ -44,44 +48,216 @@ impl_writeable_tlv_based!(FundingTxCandidate, { (6, awaiting_broadcast, required), }); -/// Represents a pending payment +/// The parameters of the API call that initiated a splice, recording what was attempted +/// independently of the contribution built from them. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PendingPaymentDetails { - /// The full payment details - pub details: PaymentDetails, - /// Transaction IDs that have replaced or conflict with this payment. - pub conflicting_txids: Vec, - /// 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, - /// The candidates LDK promoted to the channel's funding, as `ChannelReady` reported them. A - /// zero-conf splice locks before its transaction confirms, and every later splice builds on - /// it, so such a round can still confirm once the channel's funding has moved on from it and - /// once the channel has closed, when LDK holds it no longer. Kept apart from the candidates, - /// which each funding-record write replaces as a whole. - pub(crate) locked_rounds: Vec, +pub(crate) enum SpliceKind { + /// [`Node::splice_in`] with a resolved amount. + /// + /// [`Node::splice_in`]: crate::Node::splice_in + In { amount_sats: u64 }, + /// [`Node::splice_out`] to the given outputs. + /// + /// [`Node::splice_out`]: crate::Node::splice_out + Out { outputs: Vec }, + /// [`Node::bump_channel_funding_fee`] of a pending splice. + /// + /// [`Node::bump_channel_funding_fee`]: crate::Node::bump_channel_funding_fee + Rbf {}, +} + +impl_writeable_tlv_based_enum!(SpliceKind, + (0, In) => { + (0, amount_sats, required), + }, + (2, Out) => { + (0, outputs, required_vec), + }, + (4, Rbf) => {}, +); + +/// A user-initiated splice that has been handed to LDK but is not yet guaranteed to survive a +/// restart. LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, and it +/// abandons an in-progress negotiation whenever the peer disconnects (which includes stopping the +/// node). Until the new funding transaction locks we keep enough state to recognize a splice LDK +/// no longer knows about and to describe events about it in terms of the original request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SpliceIntent { + /// The channel counterparty. + pub counterparty_node_id: PublicKey, + /// The channel being spliced. + pub channel_id: ChannelId, + /// The channel's funding outpoint when the splice was initiated. It only changes once a splice + /// locks, so a mismatch with the channel's current funding outpoint means the splice (or a + /// replacement) completed and the intent is stale. + pub pre_splice_funding_txo: LdkOutPoint, + /// The contribution handed to [`ChannelManager::funding_contributed`], kept to match later + /// events about the splice back to this intent. + /// + /// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed + pub contribution: FundingContribution, + /// The parameters of the originating API call. + pub kind: SpliceKind, +} + +impl_writeable_tlv_based!(SpliceIntent, { + (0, counterparty_node_id, required), + (2, channel_id, required), + (4, pre_splice_funding_txo, required), + (6, contribution, required), + (8, kind, required), +}); + +/// A pending payment tracked by LDK Node, keyed by [`PaymentId`]. +/// +/// A user-initiated splice is persisted as a [`PendingSplice`] before its contribution is handed +/// to LDK — at which point no funding transaction, and therefore no [`PaymentDetails`], exists yet. +/// Once the splice is recorded as a funding payment it becomes a [`Tracked`] payment carrying the +/// real [`PaymentDetails`], while retaining its [`SpliceIntent`] until the splice locks. +/// +/// [`PendingSplice`]: Self::PendingSplice +/// [`Tracked`]: Self::Tracked +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum PendingPaymentDetails { + /// A user-initiated splice persisted before hand-off to LDK; no funding transaction exists yet. + /// Keyed by the generated [`PaymentId`]; never mirrored into the payment store. + PendingSplice { id: PaymentId, intent: SpliceIntent }, + /// A pending payment tracked toward confirmation, optionally still carrying a live splice + /// intent until the splice locks. + /// + /// Each field is written by a different subsystem: wallet sync records `conflicting_txids` + /// for any wallet transaction (splice fundings included), the signing-time recording + /// records `candidates` for interactive funding, the `ChannelReady` arm records + /// `locked_rounds`, and `splice_intent` is carried over from a [`PendingSplice`] record when + /// the payment is promoted — nothing persists an intent at splice initiation yet; that lands + /// with the splice tracking built on this. A splice uses all of them; the fields do not + /// partition by payment type. + /// + /// [`PendingSplice`]: Self::PendingSplice + Tracked { + /// The full payment details. + details: PaymentDetails, + /// Transaction IDs wallet sync observed to have replaced or to conflict with this + /// payment, used to map later events about those txids back to this record. This is + /// BDK's view, distinct from `candidates`: it can hold conflicts that were never + /// negotiated candidates, while a candidate replaced between wallet syncs may never + /// appear here (it gets no `TxReplaced` event of its own). + conflicting_txids: Vec, + /// For interactive funding (splices), this node's per-candidate funding figures across the + /// RBF history, keyed by each candidate's txid and recorded as each round is signed. + /// Empty for non-funding payments. + candidates: Vec, + /// The live splice intent, or `None` for a non-splice payment or a splice that has + /// locked. It lives here as well as on + /// [`PendingSplice`] because a fee bump — a fresh negotiation LDK likewise abandons if the + /// peer disconnects before signing — would share the broadcast splice's record rather than + /// get one of its own. + /// + /// [`PendingSplice`]: Self::PendingSplice + splice_intent: Option, + /// The candidates LDK promoted to the channel's funding, as `ChannelReady` reported them. + /// A zero-conf splice locks before its transaction confirms, and every later splice builds + /// on it, so such a round can still confirm once the channel's funding has moved on from + /// it and once the channel has closed, when LDK holds it no longer. Kept apart from the + /// candidates, which each funding-record write replaces as a whole. + locked_rounds: Vec, + }, } impl PendingPaymentDetails { pub(crate) fn new( details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, ) -> Self { - Self { details, conflicting_txids, candidates, locked_rounds: Vec::new() } + Self::tracked(details, conflicting_txids, candidates, None) + } + + pub(crate) fn tracked( + details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, + splice_intent: Option, + ) -> Self { + Self::Tracked { + details, + conflicting_txids, + candidates, + splice_intent, + locked_rounds: Vec::new(), + } + } + + /// The full payment details, or `None` for a splice not yet broadcast. + pub(crate) fn details(&self) -> Option<&PaymentDetails> { + match self { + Self::PendingSplice { .. } => None, + Self::Tracked { details, .. } => Some(details), + } + } + + /// Transaction IDs that have replaced or conflict with this payment. + pub(crate) fn conflicting_txids(&self) -> &[Txid] { + match self { + Self::PendingSplice { .. } => &[], + Self::Tracked { conflicting_txids, .. } => conflicting_txids, + } + } + + /// The rounds LDK promoted to the channel's funding, as `ChannelReady` reported them; empty + /// for a splice without a funding transaction yet. + pub(crate) fn locked_rounds(&self) -> &[Txid] { + match self { + Self::PendingSplice { .. } => &[], + Self::Tracked { locked_rounds, .. } => locked_rounds, + } + } + + /// Records that LDK promoted the round with the given txid to the channel's funding. Returns + /// whether the record changed: a round recorded as promoted already, or a splice without a + /// funding transaction yet, leaves it as it is. + pub(crate) fn record_locked_round(&mut self, txid: Txid) -> bool { + match self { + Self::PendingSplice { .. } => false, + Self::Tracked { locked_rounds, .. } => { + if locked_rounds.contains(&txid) { + return false; + } + locked_rounds.push(txid); + true + }, + } } /// 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) + match self { + Self::PendingSplice { .. } => None, + Self::Tracked { candidates, .. } => { + candidates.iter().find(|candidate| candidate.txid == txid) + }, + } + } + + /// This node's recorded funding figures across the candidate history, in LDK's order; empty for + /// a splice without a funding transaction yet and for non-funding payments. + pub(crate) fn candidates(&self) -> &[FundingTxCandidate] { + match self { + Self::PendingSplice { .. } => &[], + Self::Tracked { candidates, .. } => candidates, + } } } -impl_writeable_tlv_based!(PendingPaymentDetails, { - (0, details, required), - (2, conflicting_txids, optional_vec), - (4, candidates, optional_vec), - (6, locked_rounds, optional_vec), -}); +impl_writeable_tlv_based_enum!(PendingPaymentDetails, + (0, PendingSplice) => { + (0, id, required), + (2, intent, required), + }, + (2, Tracked) => { + (0, details, required), + (2, conflicting_txids, optional_vec), + (4, candidates, optional_vec), + (6, splice_intent, option), + (8, locked_rounds, optional_vec), + }, +); #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingPaymentDetailsUpdate { @@ -89,13 +265,20 @@ pub(crate) struct PendingPaymentDetailsUpdate { pub payment_update: Option, pub conflicting_txids: Option>, pub candidates: Vec, + /// The splice intent to set (`Some(Some(..))`) or clear (`Some(None)`), or `None` to leave it + /// unchanged. Setting it on a [`PendingPaymentDetails::PendingSplice`] replaces the intent; + /// clearing a pre-broadcast splice is done by removing the record, not through this field. + pub splice_intent: Option>, } impl StorableObject for PendingPaymentDetails { type Id = PaymentId; fn id(&self) -> Self::Id { - self.details.id + match self { + Self::PendingSplice { id, .. } => *id, + Self::Tracked { details, .. } => details.id, + } } } @@ -103,36 +286,58 @@ impl UpdatableObject for PendingPaymentDetails { type Update = PendingPaymentDetailsUpdate; fn update(&mut self, update: Self::Update) -> bool { - let mut updated = false; - - // Update the underlying payment details if present - if let Some(payment_update) = update.payment_update { - updated |= self.details.update(payment_update); - } - - if let Some(new_conflicting_txids) = update.conflicting_txids { - if self.conflicting_txids != new_conflicting_txids { - self.conflicting_txids = new_conflicting_txids; - updated = true; - } - } - - if let PaymentKind::Onchain { txid, .. } = &self.details.kind { - let conflicts_len = self.conflicting_txids.len(); - self.conflicting_txids.retain(|conflicting_txid| conflicting_txid != txid); - updated |= self.conflicting_txids.len() != conflicts_len; - } - - // Each funding-record write passes the candidate history as of its own round, so a - // non-empty update replaces the stored list. An empty update (e.g. a non-funding payment) - // leaves it untouched. Dropping an abandoned round, the only writer that shrinks it, goes - // through the store's `mutate` instead. - if !update.candidates.is_empty() && self.candidates != update.candidates { - self.candidates = update.candidates; - updated = true; + match self { + Self::PendingSplice { intent, .. } => { + // A pre-broadcast record only carries a splice intent; the only meaningful update + // is replacing that intent. Clearing it is done by removing the record. + if let Some(Some(new_intent)) = update.splice_intent { + if *intent != new_intent { + *intent = new_intent; + return true; + } + } + false + }, + Self::Tracked { details, conflicting_txids, candidates, splice_intent, .. } => { + let mut updated = false; + + // Update the underlying payment details if present + if let Some(payment_update) = update.payment_update { + updated |= details.update(payment_update); + } + + if let Some(new_conflicting_txids) = update.conflicting_txids { + if *conflicting_txids != new_conflicting_txids { + *conflicting_txids = new_conflicting_txids; + updated = true; + } + } + + if let PaymentKind::Onchain { txid, .. } = &details.kind { + let conflicts_len = conflicting_txids.len(); + conflicting_txids.retain(|conflicting_txid| conflicting_txid != txid); + updated |= conflicting_txids.len() != conflicts_len; + } + + // Each funding-record write passes the candidate history as of its own round, so a + // non-empty update replaces the stored list. An empty update (e.g. a non-funding + // payment) leaves it untouched. Dropping an abandoned round, the only writer that + // shrinks it, goes through the store's `mutate` instead. + if !update.candidates.is_empty() && *candidates != update.candidates { + *candidates = update.candidates; + updated = true; + } + + if let Some(new_splice_intent) = update.splice_intent { + if *splice_intent != new_splice_intent { + *splice_intent = new_splice_intent; + updated = true; + } + } + + updated + }, } - - updated } fn to_update(&self) -> Self::Update { @@ -148,16 +353,34 @@ impl StorableObjectUpdate for PendingPaymentDetailsUpdate impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { fn from(value: &PendingPaymentDetails) -> Self { - let conflicting_txids = if value.conflicting_txids.is_empty() { - None - } else { - Some(value.conflicting_txids.clone()) - }; - Self { - id: value.id(), - payment_update: Some(value.details.to_update()), - conflicting_txids, - candidates: value.candidates.clone(), + match value { + PendingPaymentDetails::PendingSplice { id, intent } => Self { + id: *id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(Some(intent.clone())), + }, + PendingPaymentDetails::Tracked { + details, + conflicting_txids, + candidates, + splice_intent, + .. + } => { + let conflicting_txids = if conflicting_txids.is_empty() { + None + } else { + Some(conflicting_txids.clone()) + }; + Self { + id: details.id, + payment_update: Some(details.to_update()), + conflicting_txids, + candidates: candidates.clone(), + splice_intent: Some(splice_intent.clone()), + } + }, } } } @@ -236,6 +459,23 @@ pub(crate) fn test_funding_contribution_with_parts( .expect("hand-built TLV stream must decode") } +/// Builds a [`FundingContribution`] for tests carrying just the required TLV records: a zero +/// estimated fee, the default feerate, and no contributed outputs. +/// +/// [`FundingContribution`]: lightning::ln::funding::FundingContribution +#[cfg(test)] +pub(crate) fn test_funding_contribution() -> lightning::ln::funding::FundingContribution { + test_funding_contribution_with_feerate(253) +} + +/// Like [`test_funding_contribution`], but with the given input-selection feerate in sat/kwu. +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_feerate( + feerate: u64, +) -> lightning::ln::funding::FundingContribution { + test_funding_contribution_with_outputs(0, feerate, &[]) +} + #[cfg(test)] mod tests { use bitcoin::hashes::Hash; @@ -339,7 +579,7 @@ mod tests { assert!(pending_payment.update(update)); assert_eq!( - pending_payment.conflicting_txids, + pending_payment.conflicting_txids(), Vec::::new(), "current txid must not remain in its own conflict list" ); @@ -392,7 +632,7 @@ mod tests { assert!(downgraded.update(full_update)); assert!( matches!( - downgraded.details.kind, + downgraded.details().expect("tracked").kind, PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } ), "a full merge of a fresh classification downgrades a mirrored confirmation", @@ -407,18 +647,86 @@ mod tests { payment_update: Some(PaymentDetailsUpdate::funding_reclassification(fresh)), conflicting_txids: None, candidates: candidates.clone(), + splice_intent: None, }; assert!(merged.update(narrow_update)); + let merged_details = merged.details().expect("tracked"); assert!( matches!( - merged.details.kind, + merged_details.kind, PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } ), "a narrow classification update must not downgrade a mirrored confirmation", ); - assert_eq!(merged.candidates, candidates); - assert_eq!(merged.details.amount_msat, Some(1_000)); - assert_eq!(merged.details.fee_paid_msat, Some(100)); + assert_eq!(merged.candidate(txid), Some(&candidates[0])); + assert_eq!(merged_details.amount_msat, Some(1_000)); + assert_eq!(merged_details.fee_paid_msat, Some(100)); + } + + #[test] + fn splice_kind_round_trips() { + for kind in [ + SpliceKind::In { amount_sats: 500_000 }, + SpliceKind::Out { + outputs: vec![TxOut { + value: bitcoin::Amount::from_sat(400_000), + script_pubkey: bitcoin::ScriptBuf::new(), + }], + }, + SpliceKind::Rbf {}, + ] { + let encoded = kind.encode(); + let decoded = SpliceKind::read(&mut &encoded[..]).unwrap(); + assert_eq!(kind, decoded); + } + } + + #[test] + fn pending_splice_round_trips() { + use std::str::FromStr; + + let id = PaymentId([10u8; 32]); + let intent = SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution: test_funding_contribution(), + kind: SpliceKind::In { amount_sats: 500_000 }, + }; + let record = PendingPaymentDetails::PendingSplice { id, intent }; + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + assert_eq!(decoded.id(), id); + assert!(decoded.details().is_none()); + } + + #[test] + fn tracked_payment_round_trips() { + // The `PendingSplice` variant round-trips in `pending_splice_round_trips`; here we cover + // the `Tracked` variant and its enum discriminant. + let payment_id = PaymentId([7u8; 32]); + let txid = Txid::from_byte_array([8u8; 32]); + let record = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, txid), + vec![Txid::from_byte_array([9u8; 32])], + vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000), + fee_paid_msat: Some(100), + awaiting_broadcast: false, + }], + ); + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + assert_eq!(decoded.id(), payment_id); + assert!(decoded.details().is_some()); } /// A candidate with the given txid byte, with a stake of ours in it if `ours`. @@ -444,16 +752,17 @@ mod tests { let mut stored = entry(vec![candidate(2, false)]); let decoded: PendingPaymentDetails = Readable::read(&mut &stored.encode()[..]).expect("encoding must round-trip"); - assert_eq!(decoded.locked_rounds, Vec::::new()); + assert!(decoded.locked_rounds().is_empty()); - stored.locked_rounds.push(test_txid(2)); + assert!(stored.record_locked_round(test_txid(2))); + assert!(!stored.record_locked_round(test_txid(2))); let decoded: PendingPaymentDetails = Readable::read(&mut &stored.encode()[..]).expect("encoding must round-trip"); assert_eq!(decoded, stored); let synced = entry(vec![candidate(2, false), candidate(3, false)]); assert!(stored.update(synced.to_update())); - assert_eq!(stored.candidates.len(), 2); - assert_eq!(stored.locked_rounds, vec![test_txid(2)]); + assert_eq!(stored.candidates().len(), 2); + assert_eq!(stored.locked_rounds(), &[test_txid(2)]); } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 58ecdd3c1..66f69663d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -57,9 +57,9 @@ use lightning_invoice::RawBolt11Invoice; use persist::KVStoreWalletPersister; use crate::config::{Config, ADDRESS_POOL_SIZE}; -use crate::data_store::UpdatableObject; #[cfg(test)] use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed}; +use crate::data_store::{StorableObject, UpdatableObject}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; use crate::logger::{log_debug, log_error, log_info, log_trace, log_warn, LdkLogger, Logger}; use crate::payment::pending_payment_store::PendingPaymentDetailsUpdate; @@ -398,35 +398,41 @@ impl Wallet { self.payment_store.insert_or_update(payment.clone()).await?; if payment_status == PaymentStatus::Pending { - let pending_payment = - self.create_pending_payment_from_tx(payment, Vec::new()); - - self.pending_payment_store.insert_or_update(pending_payment).await?; + self.upsert_pending_payment(payment, Vec::new()).await?; } }, WalletEvent::ChainTipChanged { new_tip, .. } => { let pending_payments: Vec = self .pending_payment_store - .list_filter(|p| { - debug_assert!( - p.details.status == PaymentStatus::Pending, - "Non-pending payment {:?} found in pending store", - p.details.id, - ); - p.details.status == PaymentStatus::Pending - && matches!(p.details.kind, PaymentKind::Onchain { .. }) + .list_filter(|p| match p.details() { + // A pre-broadcast splice intent carries no payment yet and cannot + // graduate. + None => false, + Some(details) => { + debug_assert!( + details.status == PaymentStatus::Pending, + "Non-pending payment {:?} found in pending store", + details.id, + ); + details.status == PaymentStatus::Pending + && matches!(details.kind, PaymentKind::Onchain { .. }) + }, }) .await; let mut unconfirmed_outbound_txids: Vec = Vec::new(); for payment in pending_payments { - match payment.details.kind { + // The filter admits only Tracked funding payments. + let PendingPaymentDetails::Tracked { ref details, .. } = payment else { + continue; + }; + match details.kind { PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { height, .. }, .. } => { - let payment_id = payment.details.id; + let payment_id = details.id; if new_tip.height >= height + ANTI_REORG_DELAY - 1 { // Graduate from the live record, not the snapshot listed // above: a classification landing since then must not have @@ -476,7 +482,7 @@ impl Wallet { { continue; } - if payment.details.direction == PaymentDirection::Outbound { + if details.direction == PaymentDirection::Outbound { unconfirmed_outbound_txids.push(txid); } }, @@ -560,10 +566,8 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ) }; - let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.payment_store.insert_or_update(payment).await?; - self.pending_payment_store.insert_or_update(pending_payment).await?; + self.payment_store.insert_or_update(payment.clone()).await?; + self.upsert_pending_payment(payment, Vec::new()).await?; }, WalletEvent::TxReplaced { txid, conflicts, .. } => { // See `TxConfirmed`: id resolution and the writes below must not interleave @@ -610,10 +614,7 @@ impl Wallet { continue; } - let pending_payment_details = - self.create_pending_payment_from_tx(payment, conflict_txids.clone()); - - self.pending_payment_store.insert_or_update(pending_payment_details).await?; + self.upsert_pending_payment(payment, conflict_txids).await?; }, WalletEvent::TxDropped { txid, tx } => { // See `TxConfirmed`: id resolution and the writes below must not interleave @@ -665,10 +666,8 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ) }; - let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.payment_store.insert_or_update(payment).await?; - self.pending_payment_store.insert_or_update(pending_payment).await?; + self.payment_store.insert_or_update(payment.clone()).await?; + self.upsert_pending_payment(payment, Vec::new()).await?; }, _ => { continue; @@ -729,19 +728,22 @@ impl Wallet { async fn fail_funding_payment_lost_to_conflict( &self, payment: &PendingPaymentDetails, tip_height: u32, ) -> Result { - match payment.details.kind { - PaymentKind::Onchain { - status: ConfirmationStatus::Unconfirmed, - tx_type: - Some( - TransactionType::Funding { .. } - | TransactionType::InteractiveFunding { .. }, - ), - .. - } => {}, - _ => return Ok(false), - } - if payment.conflicting_txids.is_empty() { + let payment_id = match payment.details() { + Some(details) => match details.kind { + PaymentKind::Onchain { + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + .. + } => details.id, + _ => return Ok(false), + }, + None => return Ok(false), + }; + if payment.conflicting_txids().is_empty() { return Ok(false); } @@ -751,11 +753,15 @@ impl Wallet { let _guard = self.funding_payment_update_lock.lock().await; // Re-read the entry under the lock; the listing snapshot may predate a record write. - let entry = match self.pending_payment_store.get(&payment.details.id).await? { + let entry = match self.pending_payment_store.get(&payment_id).await? { Some(entry) => entry, None => return Ok(false), }; - let record_txid = match entry.details.kind { + let PendingPaymentDetails::Tracked { details, conflicting_txids, candidates, .. } = &entry + else { + return Ok(false); + }; + let record_txid = match details.kind { PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, @@ -768,8 +774,7 @@ impl Wallet { _ => return Ok(false), }; - let foreign_conflicts: Vec = entry - .conflicting_txids + let foreign_conflicts: Vec = conflicting_txids .iter() .copied() .filter(|conflict| *conflict != record_txid && entry.candidate(*conflict).is_none()) @@ -783,7 +788,7 @@ impl Wallet { // `get_tx` is canonical-only: a transaction that lost to a confirmed conflict // returns `None`, while one that can still confirm is `Some`. let a_candidate_is_live = locked_wallet.get_tx(record_txid).is_some() - || entry.candidates.iter().any(|c| locked_wallet.get_tx(c.txid).is_some()); + || candidates.iter().any(|c| locked_wallet.get_tx(c.txid).is_some()); !a_candidate_is_live && foreign_conflicts.iter().any(|conflict| { match locked_wallet.get_tx(*conflict).map(|tx| tx.chain_position) { @@ -798,7 +803,7 @@ impl Wallet { return Ok(false); } - let payment_id = entry.details.id; + let payment_id = entry.id(); let outcome = self.fail_unconfirmed_funding_payment_locked(&_guard, payment_id, record_txid).await?; match outcome { @@ -937,8 +942,12 @@ impl Wallet { let entries = self.pending_payment_store.list_filter(|entry| tracks_channel(entry, channel_id)).await; for entry in entries { - let payment_id = entry.details.id; - let record_txid = match &entry.details.kind { + let details = match entry.details() { + Some(details) => details, + None => continue, + }; + let payment_id = details.id; + let record_txid = match &details.kind { PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, @@ -959,13 +968,13 @@ impl Wallet { // only where no candidate records it. let recorded_round = entry.candidate(record_txid).is_none().then_some(record_txid); let mut rounds_of_ours = entry - .candidates + .candidates() .iter() .filter(|candidate| candidate.amount_msat.is_some()) .map(|candidate| candidate.txid) .chain(recorded_round); if let Some(kept) = rounds_of_ours - .find(|txid| held_rounds.contains(txid) || entry.locked_rounds.contains(txid)) + .find(|txid| held_rounds.contains(txid) || entry.locked_rounds().contains(txid)) { log_info!( self.logger, @@ -1078,18 +1087,17 @@ impl Wallet { .list_filter(|entry| { tracks_channel(entry, channel_id) && entry.candidate(txid).is_some() - && !entry.locked_rounds.contains(&txid) + && !entry.locked_rounds().contains(&txid) }) .await; for entry in entries { - let payment_id = entry.details.id; + let payment_id = entry.id(); self.pending_payment_store .mutate(&payment_id, |existing| { let mut entry = existing?.clone(); - if entry.locked_rounds.contains(&txid) { + if !entry.record_locked_round(txid) { return None; } - entry.locked_rounds.push(txid); Some(entry) }) .await?; @@ -2311,7 +2319,7 @@ impl Wallet { // pushed before this signing event and has been handled by now. Should LDK ever reorder // them, this would clear the mark of a round whose event has not been handled yet. let mut recorded = - prior_pending.as_ref().map(|entry| entry.candidates.clone()).unwrap_or_default(); + prior_pending.as_ref().map(|entry| entry.candidates().to_vec()).unwrap_or_default(); for candidate in history { match recorded.iter_mut().find(|stored| stored.txid == candidate.txid) { Some(stored) => *stored = candidate, @@ -2382,12 +2390,14 @@ impl Wallet { }) .await; for entry in entries { - let payment_id = entry.details.id; + let payment_id = entry.id(); self.pending_payment_store .mutate(&payment_id, |existing| { let mut entry = existing?.clone(); - let round = entry - .candidates + let PendingPaymentDetails::Tracked { candidates, .. } = &mut entry else { + return None; + }; + let round = candidates .iter_mut() .find(|candidate| candidate.txid == txid && candidate.awaiting_broadcast)?; round.awaiting_broadcast = false; @@ -2449,12 +2459,15 @@ impl Wallet { .pending_payment_store .list_filter(|entry| { tracks_channel(entry, channel_id) - && entry.candidates.iter().any(|candidate| candidate.awaiting_broadcast) + && entry.candidates().iter().any(|candidate| candidate.awaiting_broadcast) }) .await; for entry in entries { - let payment_id = entry.details.id; + let payment_id = match entry.details() { + Some(details) => details.id, + None => continue, + }; let (abandoned, remaining): (Vec, Vec) = { let locked_wallet = self.inner.lock().expect("lock"); // TODO(#1037): the graph learns a round LDK broadcast from wallet sync alone @@ -2463,10 +2476,10 @@ impl Wallet { // sweep or a live event — runs the drop, only once the `InteractiveFunding` // broadcast arm applies the round to the graph, which #1037 does not do: it // prepares only `Funding`-typed packages. - entry.candidates.iter().cloned().partition(|candidate| { + entry.candidates().iter().cloned().partition(|candidate| { candidate.awaiting_broadcast && !held_rounds.contains(&candidate.txid) - && !entry.locked_rounds.contains(&candidate.txid) + && !entry.locked_rounds().contains(&candidate.txid) && locked_wallet.tx_graph().get_tx(candidate.txid).is_none() }) }; @@ -2555,9 +2568,11 @@ impl Wallet { self.pending_payment_store .mutate(&payment_id, |existing| { let mut entry = existing?.clone(); - entry.candidates.retain(|c| !abandoned_txids.contains(&c.txid)); - if let Some(mirrored) = mirrored { - entry.details = mirrored; + if let PendingPaymentDetails::Tracked { details, candidates, .. } = &mut entry { + candidates.retain(|c| !abandoned_txids.contains(&c.txid)); + if let Some(mirrored) = mirrored { + *details = mirrored; + } } Some(entry) }) @@ -2586,15 +2601,15 @@ impl Wallet { let channels: HashSet = self .pending_payment_store .list_filter(|entry| { - entry.candidates.iter().any(|candidate| candidate.awaiting_broadcast) + entry.candidates().iter().any(|candidate| candidate.awaiting_broadcast) }) .await .iter() - .flat_map(|entry| match &entry.details.kind { - PaymentKind::Onchain { + .flat_map(|entry| match entry.details().map(|details| &details.kind) { + Some(PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { channels }), .. - } => channels.iter().map(|channel| channel.channel_id).collect(), + }) => channels.iter().map(|channel| channel.channel_id).collect(), _ => Vec::new(), }) .collect(); @@ -2786,6 +2801,7 @@ impl Wallet { payment_update: Some(update), conflicting_txids: None, candidates, + splice_intent: None, }; entry.update(pending_update).then_some(entry) }, @@ -2858,10 +2874,52 @@ impl Wallet { PaymentDetails::new(payment_id, kind, amount_msat, fee_paid_msat, direction, payment_status) } - fn create_pending_payment_from_tx( + /// Inserts or refreshes the pending-store entry tracking `payment` toward graduation, + /// atomically with reading the entry's current state. + async fn upsert_pending_payment( &self, payment: PaymentDetails, conflicting_txids: Vec, - ) -> PendingPaymentDetails { - PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()) + ) -> Result<(), Error> { + let id = payment.id; + let payment_store = Arc::clone(&self.payment_store); + self.pending_payment_store + .mutate_async(&id, move |existing| async move { + // Only `Pending` payments belong in the pending store. Like in + // [`Self::persist_funding_payment`], the authoritative status is re-read inside + // the store's critical section, where it cannot go stale against graduation. + let is_pending = payment_store + .get(&id) + .await? + .map_or(payment.status == PaymentStatus::Pending, |recorded| { + recorded.status == PaymentStatus::Pending + }); + if !is_pending { + return Ok(None); + } + Ok(match existing { + None => { + Some(PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())) + }, + // Promote a pre-broadcast splice intent: wallet sync saw the splice + // transaction before this node recorded it as a funding payment. Carrying the + // intent into the `Tracked` record makes the entry visible to txid lookups + // while the retrier keeps the intent until the splice locks. + Some(PendingPaymentDetails::PendingSplice { intent, .. }) => { + Some(PendingPaymentDetails::tracked( + payment, + conflicting_txids, + Vec::new(), + Some(intent), + )) + }, + Some(mut tracked @ PendingPaymentDetails::Tracked { .. }) => { + let fresh = + PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()); + tracked.update(fresh.to_update()).then_some(tracked) + }, + }) + }) + .await?; + Ok(()) } /// Removes the payment with the given id from the payment store, along with any pending-store @@ -2885,7 +2943,9 @@ impl Wallet { } let owns = |p: &PendingPaymentDetails| { - matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid) + p.details().is_some_and( + |d| matches!(d.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), + ) // A middle RBF round is not the record's current txid and may never have // received a `TxReplaced` event of its own, so map any of its candidate // txids (an earlier RBF round may confirm) back to the record. @@ -2893,14 +2953,14 @@ impl Wallet { }; let matches = self .pending_payment_store - .list_filter(|p| owns(p) || p.conflicting_txids.contains(&target_txid)) + .list_filter(|p| owns(p) || p.conflicting_txids().contains(&target_txid)) .await; // An entry lists the transactions that replaced its own, so a transaction another entry // records as its own (a splice round that replaced a close, say) matches both. The entry // that owns it is its record; the conflict listing is only how a replaced round of a // record with no candidates (an ordinary payment's RBF history) maps back to its record. if let Some(entry) = matches.iter().find(|p| owns(p)).or(matches.first()) { - return Ok(Some(entry.details.id)); + return Ok(Some(entry.id())); } // The pending store only indexes in-flight records — graduation removes the entry — so a @@ -3010,8 +3070,7 @@ impl Wallet { // 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.pending_payment_store.insert_or_update(pending).await?; + self.upsert_pending_payment(payment, Vec::new()).await?; } Ok(FundingStatusUpdate::Applied) } @@ -3254,8 +3313,6 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ); - let pending_payment_store = - self.create_pending_payment_from_tx(new_payment.clone(), Vec::new()); let change_set = locked_wallet.take_staged().unwrap_or_default(); drop(locked_wallet); locked_persister.persist_changeset(change_set).await.map_err(|e| { @@ -3263,8 +3320,8 @@ impl Wallet { Error::PersistenceFailed })?; - self.payment_store.insert_or_update(new_payment).await?; - self.pending_payment_store.insert_or_update(pending_payment_store).await?; + self.payment_store.insert_or_update(new_payment.clone()).await?; + self.upsert_pending_payment(new_payment, Vec::new()).await?; self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx); @@ -3318,11 +3375,11 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { /// Whether `entry` is the funding payment of a splice into `channel_id`. fn tracks_channel(entry: &PendingPaymentDetails, channel_id: ChannelId) -> bool { - match &entry.details.kind { - PaymentKind::Onchain { + match entry.details().map(|details| &details.kind) { + Some(PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { channels }), .. - } => channels.iter().any(|channel| channel.channel_id == channel_id), + }) => channels.iter().any(|channel| channel.channel_id == channel_id), _ => false, } } @@ -5141,7 +5198,7 @@ mod tests { } let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); assert_eq!( - record.candidates.iter().map(|c| c.txid).collect::>(), + record.candidates().iter().map(|c| c.txid).collect::>(), vec![prior_txid, txid] ); let prior = record.candidate(prior_txid).unwrap(); @@ -5202,7 +5259,7 @@ mod tests { assert_eq!(payment.status, PaymentStatus::Pending); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); let entry = wallet.pending_payment_store.get(&bump_id).await.unwrap().expect("entry"); - assert_eq!(entry.details, payment); + assert_eq!(entry.details(), Some(&payment)); assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); let failed = wallet.payment_store.get(&failed_id).await.unwrap().expect("the failed record stays"); @@ -5239,7 +5296,7 @@ mod tests { assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(payment)); let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); assert_eq!( - record.candidates.iter().map(|c| c.txid).collect::>(), + record.candidates().iter().map(|c| c.txid).collect::>(), vec![prior_txid, txid] ); assert!(!record.candidate(txid).unwrap().awaiting_broadcast); @@ -5453,7 +5510,7 @@ mod tests { assert_eq!(payments[0].amount_msat, Some(400_700_000)); let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); assert_eq!( - record.candidates.iter().map(|c| c.txid).collect::>(), + record.candidates().iter().map(|c| c.txid).collect::>(), vec![prior_txid, txid, next_txid] ); assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); @@ -5527,7 +5584,7 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); assert!( matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid), @@ -5535,7 +5592,7 @@ mod tests { ); assert_eq!(payment.amount_msat, Some(500_300_000)); assert_eq!(payment.fee_paid_msat, Some(300_000)); - assert_eq!(record.details, payment); + assert_eq!(record.details(), Some(&payment)); assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); } @@ -5563,7 +5620,7 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); assert_eq!(payment.status, PaymentStatus::Pending); @@ -5602,7 +5659,7 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); assert_eq!(payment.amount_msat, Some(500_300_000)); @@ -5685,8 +5742,8 @@ mod tests { assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(payment.clone())); let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); - assert_eq!(record.details, payment); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.details(), Some(&payment)); assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); } @@ -5719,8 +5776,8 @@ mod tests { assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(payment.clone())); let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); - assert!(record.candidates.is_empty()); - assert_eq!(record.details, payment); + assert!(record.candidates().is_empty()); + assert_eq!(record.details(), Some(&payment)); } /// A removal that was cut short between the two stores — the payment record went, the pending @@ -5783,18 +5840,19 @@ mod tests { update.fee_paid_msat = Some(Some(300_000)); wallet.payment_store.update(update).await.unwrap(); let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); - assert!( - matches!(entry.details.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid) - ); + assert!(matches!( + entry.details().map(|details| &details.kind), + Some(PaymentKind::Onchain { txid: t, .. }) if *t == bump_txid + )); wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); - assert_eq!(entry.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); assert_eq!(payment.amount_msat, Some(500_300_000)); - assert_eq!(entry.details, payment); + assert_eq!(entry.details(), Some(&payment)); assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); } @@ -5988,8 +6046,8 @@ mod tests { assert_eq!(payment.status, PaymentStatus::Succeeded); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); - assert_eq!(entry.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); - assert_eq!(entry.details.status, PaymentStatus::Pending); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(entry.details().map(|details| details.status), Some(PaymentStatus::Pending)); } /// The signing write failed between its two stores and the rollback failed as well, leaving @@ -6071,7 +6129,7 @@ mod tests { let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); } /// The same failure while signing a fee bump: the record is put back to the original round, @@ -6108,7 +6166,7 @@ mod tests { assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(prior)); let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); } @@ -6579,6 +6637,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -6780,6 +6839,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -6851,6 +6911,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![bumped_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -6902,6 +6963,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -6961,6 +7023,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![conflict_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7252,6 +7315,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -8203,8 +8267,8 @@ mod tests { let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Pending); let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); - assert_eq!(entry.candidates.len(), 2); - assert_eq!(entry.locked_rounds, vec![txid]); + assert_eq!(entry.candidates().len(), 2); + assert_eq!(entry.locked_rounds(), &[txid]); } /// LDK promoted a sibling this node did not contribute to — the counterparty's round locked on @@ -8414,7 +8478,7 @@ mod tests { let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Pending); let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); - assert_eq!(entry.locked_rounds, vec![counterparty_txid]); + assert_eq!(entry.locked_rounds(), &[counterparty_txid]); wallet .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) @@ -8456,7 +8520,7 @@ mod tests { assert_eq!(payment.status, PaymentStatus::Pending); let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); - assert_eq!(entry.locked_rounds, vec![locked]); + assert_eq!(entry.locked_rounds(), &[locked]); } wallet.resolve_closed_channel_splice_rounds(channel_id, &[second_txid]).await.unwrap(); @@ -8502,8 +8566,8 @@ mod tests { assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == first_txid)); assert_eq!((payment.amount_msat, payment.fee_paid_msat), first_figures); let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); - assert_eq!(entry.candidates.iter().map(|c| c.txid).collect::>(), vec![first_txid]); - assert_eq!(entry.locked_rounds, vec![first_txid]); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![first_txid]); + assert_eq!(entry.locked_rounds(), &[first_txid]); } /// A zero-conf splice round of ours locked before its transaction confirmed and a later splice @@ -8528,7 +8592,7 @@ mod tests { .unwrap(); } let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); - assert_eq!(entry.locked_rounds, vec![txid]); + assert_eq!(entry.locked_rounds(), &[txid]); wallet .resolve_closed_channel_splice_rounds(channel_id, &[later_funding_txid]) @@ -8605,7 +8669,7 @@ mod tests { let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Pending); let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); - assert_eq!(entry.candidates.len(), 2); + assert_eq!(entry.candidates().len(), 2); // At the close the monitor has settled on the funding and watches neither round. wallet.resolve_closed_channel_splice_rounds(channel_id, &[funding_txid]).await.unwrap(); @@ -8642,7 +8706,7 @@ mod tests { let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Pending); let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); - assert_eq!(entry.candidates.len(), 2); + assert_eq!(entry.candidates().len(), 2); } /// The close does not touch a payment that no longer waits on an unconfirmed round: one whose From 05b840e02cbde7e22aa431ad43ebea3e12dc0832 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 21 Sep 2026 16:41:36 -0700 Subject: [PATCH 24/37] f - Model pending payments as an enum for pre-broadcast splices Check that a persisted splice intent keeps the parts its contribution inherited from the round it replaces, which the pinned LDK records in the contribution so that `reserved_inputs` and `reserved_outputs` leave them out. The contribution's equality ignores that record, so the existing round trip could lose it unnoticed. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/payment/pending_payment_store.rs | 137 +++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index fe3f1f2cb..c65c5432f 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -408,6 +408,27 @@ pub(crate) fn test_funding_contribution_with_outputs( pub(crate) fn test_funding_contribution_with_parts( estimated_fee_sat: u64, feerate: u64, prevtxs: &[bitcoin::Transaction], outputs: &[bitcoin::TxOut], change_output: Option<&bitcoin::TxOut>, +) -> lightning::ln::funding::FundingContribution { + test_funding_contribution_inheriting( + estimated_fee_sat, + feerate, + prevtxs, + outputs, + change_output, + &[], + &[], + ) +} + +/// Like [`test_funding_contribution_with_parts`], but recording `inherited_inputs` and +/// `inherited_output_scripts` as parts a still-pending splice attempt reserved before this +/// contribution, as LDK records them at the hand-off of a fee bump built from the round it +/// replaces: the contribution's `reserved_inputs` and `reserved_outputs` leave them out. +#[cfg(test)] +pub(crate) fn test_funding_contribution_inheriting( + estimated_fee_sat: u64, feerate: u64, prevtxs: &[bitcoin::Transaction], + outputs: &[bitcoin::TxOut], change_output: Option<&bitcoin::TxOut>, + inherited_inputs: &[bitcoin::OutPoint], inherited_output_scripts: &[bitcoin::ScriptBuf], ) -> lightning::ln::funding::FundingContribution { use lightning::util::ser::{BigSize, Writeable}; use lightning::util::wallet_utils::ConfirmedUtxo; @@ -451,6 +472,42 @@ pub(crate) fn test_funding_contribution_with_parts( records.extend_from_slice(&[11, 8]); // (11, max_feerate) records.extend_from_slice(&feerate.to_be_bytes()); records.extend_from_slice(&[13, 1, 1]); // (13, is_splice: true) + if !inherited_inputs.is_empty() || !inherited_output_scripts.is_empty() { + // (17, pending_components): a length-prefixed TLV stream of its own. + let mut components = Vec::new(); + if !inherited_inputs.is_empty() { + let mut bytes = Vec::new(); + for outpoint in inherited_inputs { + outpoint.write(&mut bytes).expect("in-memory write must succeed"); + } + components.push(1); // (1, inputs) + BigSize(bytes.len() as u64) + .write(&mut components) + .expect("in-memory write must succeed"); + components.extend(bytes); + } + if !inherited_output_scripts.is_empty() { + let mut bytes = Vec::new(); + for script in inherited_output_scripts { + script.write(&mut bytes).expect("in-memory write must succeed"); + } + components.push(3); // (3, output_scripts) + BigSize(bytes.len() as u64) + .write(&mut components) + .expect("in-memory write must succeed"); + components.extend(bytes); + } + let mut component_bytes = Vec::new(); + BigSize(components.len() as u64) + .write(&mut component_bytes) + .expect("in-memory write must succeed"); + component_bytes.extend(components); + records.push(17); + BigSize(component_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); + records.extend(component_bytes); + } let mut tlv_bytes = Vec::new(); // BigSize length prefix over the TLV records above. BigSize(records.len() as u64).write(&mut tlv_bytes).expect("in-memory write must succeed"); @@ -705,6 +762,86 @@ mod tests { assert!(decoded.details().is_none()); } + /// A fee bump's contribution inherits the inputs and change of the round it replaces, which + /// LDK records in the contribution at the hand-off so that `reserved_inputs` and + /// `reserved_outputs` leave them out: what a failure of the bump releases, and what a retry + /// must reserve again. That record is a private field the contribution's `PartialEq` ignores, + /// so a persisted intent's round trip is checked through those accessors. + #[test] + fn pending_splice_keeps_the_contribution_reserved_parts() { + use std::str::FromStr; + + use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, WPubkeyHash}; + + let prevtx = |seed: u8| Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + }; + let prevtxs = [prevtx(1), prevtx(2)]; + let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 }; + let script = |seed: u8| ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])); + let change = TxOut { value: Amount::from_sat(21_000), script_pubkey: script(9) }; + let splice_out = TxOut { value: Amount::from_sat(50_000), script_pubkey: script(8) }; + let reserved = |contribution: &FundingContribution| { + ( + contribution.reserved_inputs().map(|input| input.outpoint()).collect::>(), + contribution.reserved_outputs().cloned().collect::>(), + ) + }; + + // Without the record, every part counts as reserved. + let plain = test_funding_contribution_with_parts( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change), + ); + assert_eq!( + reserved(&plain), + (prevtxs.iter().map(outpoint).collect(), vec![splice_out.clone(), change.clone()]) + ); + + // The bump reuses the first input and the change address of the round it replaces; the + // second input and the splice-out output are its own. + let contribution = test_funding_contribution_inheriting( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change), + &[outpoint(&prevtxs[0])], + &[change.script_pubkey.clone()], + ); + let expected = (vec![outpoint(&prevtxs[1])], vec![splice_out]); + assert_eq!(reserved(&contribution), expected); + + let intent = SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution, + kind: SpliceKind::Rbf {}, + }; + let record = PendingPaymentDetails::PendingSplice { id: PaymentId([10u8; 32]), intent }; + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + let PendingPaymentDetails::PendingSplice { intent, .. } = decoded else { + panic!("a pending splice decoded as something else"); + }; + assert_eq!(reserved(&intent.contribution), expected); + } + #[test] fn tracked_payment_round_trips() { // The `PendingSplice` variant round-trips in `pending_splice_round_trips`; here we cover From abc64d13e01fb6b0e974c64cc4ed23890e3b0274 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 3 Aug 2026 10:34:53 -0500 Subject: [PATCH 25/37] Adopt the splice-time PaymentId when recording a splice A user-initiated splice will be keyed by a PaymentId generated at splice time rather than derived from a candidate's txid, so its splice intent, funding payment, and candidate history all share one record. Teach the signing-time recording to find a pre-broadcast splice intent by its channel and reuse that id for a splice no live record tracks yet, promoting the intent record to a tracked funding payment while preserving the intent until the splice locks. A round already on record keeps its record, whatever id it is under: the id of the first round of the history any record tracks is adopted before the channel's intent is consulted, and a fresh id is generated only when neither yields one. A record wallet sync has already failed does not count: nothing revisits a failed record, so a fee bump signed with its lost round in the history adopts the channel's intent instead, and its entry carries the intent. The intent identifies the channel, not a round, and must not decide the id of a round already on record: a splice this node joins as a fee bump of a round wallet sync recorded first converges on the record sync created, and consulting the intent first would file the bump under the intent as a second record, with wallet sync then graduating whichever of the two it finds first. Every splice round this node contributes to that the wallet records is recorded when it is signed, before our signatures are released, so the intent only ever decides the id of a splice's first signed round, or of a bump signed after wallet sync has failed every round on record before it. Splices we did not originate (counterparty-initiated or V2 dual-funded opens) have no intent. An intent submitted for a channel whose history is already on a record under another id that has not failed is never promoted and stays bare until the splice locks or fails. A splice under a generated id is no longer found by the txid-derived lookup, so it leans on find_payment_by_txid's candidate probe to map its txids back to the record. The generic funding classification already resolves an existing record the same way before generating a fresh id: LDK re-broadcasts a promoted-but-unconfirmed 0conf funding transaction through that path, and a test added here covers the rebroadcast merging into the record the signing created rather than creating a duplicate. Promotion of a pre-broadcast intent in persist_funding_payment_locked is gated on the payment still being Pending, read inside the pending store's critical section like the rest of the write's decision: a payment that confirmed through ANTI_REORG_DELAY before the write must not re-enter the pending store, which graduation and rebroadcast assume holds only Pending payments. No splice intents are created yet; the splice entry points that persist them land in a follow-up -- on this branch the intent probe stays dormant. Generated with assistance from Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Fable 5.1 --- src/payment/pending_payment_store.rs | 8 + src/wallet/mod.rs | 446 +++++++++++++++++++++++++-- 2 files changed, 421 insertions(+), 33 deletions(-) diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index c65c5432f..37c02fa14 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -225,6 +225,14 @@ impl PendingPaymentDetails { } } + /// The splice intent this record carries, if it is a splice that has not yet locked. + pub(crate) fn splice_intent(&self) -> Option<&SpliceIntent> { + match self { + Self::PendingSplice { intent, .. } => Some(intent), + Self::Tracked { splice_intent, .. } => splice_intent.as_ref(), + } + } + /// 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> { match self { diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 66f69663d..68095986d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2143,19 +2143,49 @@ impl Wallet { Ok(()) } - /// Resolves the id under which the interactive funding with negotiated history `candidates` is - /// recorded: that of a record already tracking any of its rounds (wallet sync may record a - /// round before this node does), else a fresh one. A record already failed is passed over: - /// wallet sync fails a payment whose round lost to a conflicting spend confirmed while the - /// channel stays open, LDK still holds the round and a fee bump of it is signed with the round - /// among its candidates, and nothing revisits a failed record's status, so the bump filed under - /// it would go untracked. An id derived from a txid would tie the record's identity to one - /// round of a replaceable transaction — resolution through the record's txid history is what - /// keeps its identity stable across RBF replacements. The caller holds the cross-store lock: - /// resolved outside it, the id could go stale against a record wallet sync creates for the same + /// Returns the `PaymentId` of a user-initiated splice intent for one of the channels in + /// `candidate`, if any, so the first recorded round of a splice adopts the id chosen at splice + /// time rather than a fresh one. The intent identifies the channel, not the round, so it + /// decides the id only for a history no record tracks yet + /// ([`Self::resolve_interactive_funding_id`]). A fee bump reuses the channel's existing intent, + /// so at most one in-flight intent matches and the first is unambiguous. + async fn find_splice_payment_id(&self, candidate: &FundingCandidate) -> Option { + self.pending_payment_store + .list_filter(|p| { + p.splice_intent().is_some_and(|intent| { + candidate.channels.iter().any(|channel| { + channel.channel_id == intent.channel_id + && channel.counterparty_node_id == intent.counterparty_node_id + }) + }) + }) + .await + .first() + .map(|p| p.id()) + } + + /// Resolves the id under which the `active` round of the interactive funding with negotiated + /// history `candidates` is recorded. A round already on record keeps its record: the id of the + /// first round of the history any record tracks is adopted (wallet sync may record a round + /// before this node does), so a replacement, a replayed signing and a sync-created record + /// converge on one record. A record already failed is passed over: wallet sync fails a payment + /// whose round lost to a conflicting spend confirmed while the channel stays open, LDK still + /// holds the round and a fee bump of it is signed with the round among its candidates, and + /// nothing revisits a failed record's status, so the bump filed under it would go untracked. + /// Only a history no live record tracks falls back to the channel's splice intent: a + /// user-initiated splice adopts the `PaymentId` generated when it was initiated, so its intent, + /// funding payment and candidate history share one record. The intent identifies the channel, + /// not the round, which is why it must not decide the id of a round already on record: a fee + /// bump this node signs of a round wallet sync recorded first must converge on the record sync + /// created, not be filed under the bump's intent as a second record. Otherwise a fresh id is + /// generated — an id derived from a txid would tie the record's identity to one round of a + /// replaceable transaction, and resolution through the record's txid history is what keeps its + /// identity stable across RBF replacements. The caller holds the cross-store lock: resolved + /// outside it, the id could go stale against a record wallet sync creates for the same /// transaction before the caller's write. async fn resolve_interactive_funding_id( &self, _guard: &tokio::sync::MutexGuard<'_, ()>, candidates: &[FundingCandidate], + active: &FundingCandidate, ) -> Result { for candidate in candidates.iter() { if let Some(id) = self.find_payment_by_txid(candidate.txid).await? { @@ -2169,6 +2199,9 @@ impl Wallet { } } } + if let Some(id) = self.find_splice_payment_id(active).await { + return Ok(id); + } Ok(random_payment_id()) } @@ -2252,9 +2285,10 @@ impl Wallet { /// `candidates` is the channel's pending splice history as [`funding_candidates`] lists it from /// the channel's [`SpliceDetails`], so the record is written in full, under the id /// [`Self::resolve_interactive_funding_id`] resolves (that of a record already tracking any - /// round of the history, else a fresh one). The signed round is marked as awaiting broadcast - /// until LDK reports the splice negotiated and [`Self::record_broadcast_splice_round`] clears - /// the mark: only such a round can be abandoned without a trace, and + /// round of the history, else the channel's splice intent, else a fresh one). The signed round + /// is marked as awaiting broadcast until LDK reports the splice negotiated and + /// [`Self::record_broadcast_splice_round`] clears the mark: only such a round can be abandoned + /// without a trace, and /// [`Self::drop_abandoned_splice_rounds`] takes it back once LDK no longer holds it. /// /// Nothing is recorded for a round missing from the history (reset between the event's @@ -2286,7 +2320,8 @@ impl Wallet { // Resolution, the reads and the writes below must share one lock acquisition, as in every // funding-record write: done outside it, the record could change under us before the write. let guard = self.funding_payment_update_lock.lock().await; - let payment_id = self.resolve_interactive_funding_id(&guard, candidates).await?; + let payment_id = + self.resolve_interactive_funding_id(&guard, candidates, signed_round).await?; let (details, mut history) = match self.interactive_funding_record( payment_id, candidates, @@ -2779,23 +2814,46 @@ impl Wallet { self.pending_payment_store .mutate_async(&id, move |existing| async move { // The record was written above and removal serializes on the cross-store lock held - // here, so absence means the write failed out; fall back to the fresh details. + // here, so absence means the write failed out; fall back to the fresh details. A + // promoted or (re)created entry embeds this post-write record rather than the + // fresh Unconfirmed details, so a confirmation wallet sync already recorded keeps + // driving graduation. let recorded = payment_store.get(&id).await?.unwrap_or(details); Ok(match existing { - // The inserted entry embeds the post-write record rather than the fresh - // details, so a confirmation wallet sync already recorded keeps driving - // graduation. - None if recorded.status == PaymentStatus::Pending => { - Some(PendingPaymentDetails::new(recorded, Vec::new(), candidates)) + // First time we record this funding payment — or a crash between the two + // store writes left a Pending record with no index entry: (re)create it so + // the payment can graduate and its candidate txids stay mapped. A graduated + // payment is never `Pending`, so absence with an advanced record means the + // graduation path removed the entry and it must not be re-indexed. + None => (recorded.status == PaymentStatus::Pending).then(|| { + PendingPaymentDetails::tracked(recorded, Vec::new(), candidates, None) + }), + // A user-initiated splice has a pre-broadcast `PendingSplice` intent under + // this id; carry its intent into the `Tracked` record so promotion does + // not drop it (nothing persists or consumes intents yet — that arrives + // with the follow-up that makes splice retries survive restarts). If the + // payment already advanced beyond `Pending` (wallet sync confirmed it + // through `ANTI_REORG_DELAY` first), it must not enter the pending store; + // the leftover intent record stays until that follow-up adds its clearing + // path. + Some(PendingPaymentDetails::PendingSplice { intent, .. }) => { + if recorded.status == PaymentStatus::Pending { + Some(PendingPaymentDetails::tracked( + recorded, + Vec::new(), + candidates, + Some(intent), + )) + } else { + None + } }, - // The payment already advanced beyond Pending: the graduation path removed - // the entry and it must not be re-created. - None => None, - // The entry predates this write — wallet sync recorded the transaction - // before it was recorded as a funding (its arms and this write pair - // serialize on the cross-store lock, so nothing lands in between): merge - // only the funding classification into the existing entry. - Some(mut entry) => { + // The entry predates this write — an earlier round's recording or wallet sync + // recorded the transaction before this write (sync's arms and this write pair + // serialize on the cross-store lock, so nothing lands in between): merge only + // the funding classification (`tx_type`, candidate history and the figures of + // whichever candidate the record's state makes authoritative) into it. + Some(mut tracked @ PendingPaymentDetails::Tracked { .. }) => { let pending_update = PendingPaymentDetailsUpdate { id, payment_update: Some(update), @@ -2803,7 +2861,7 @@ impl Wallet { candidates, splice_intent: None, }; - entry.update(pending_update).then_some(entry) + tracked.update(pending_update).then_some(tracked) }, }) }) @@ -2902,7 +2960,7 @@ impl Wallet { // Promote a pre-broadcast splice intent: wallet sync saw the splice // transaction before this node recorded it as a funding payment. Carrying the // intent into the `Tracked` record makes the entry visible to txid lookups - // while the retrier keeps the intent until the splice locks. + // while preserving the intent. Some(PendingPaymentDetails::PendingSplice { intent, .. }) => { Some(PendingPaymentDetails::tracked( payment, @@ -2947,8 +3005,9 @@ impl Wallet { |d| matches!(d.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), ) // A middle RBF round is not the record's current txid and may never have - // received a `TxReplaced` event of its own, so map any of its candidate - // txids (an earlier RBF round may confirm) back to the record. + // received a `TxReplaced` event of its own, and a splice keyed by a generated + // PaymentId is not found by the txid-derived id above: map any of the + // candidate txids (an earlier RBF round may confirm) back to the record. || p.candidate(target_txid).is_some() }; let matches = self @@ -3935,7 +3994,8 @@ mod tests { PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; use crate::payment::pending_payment_store::{ - test_funding_contribution_with_outputs, test_funding_contribution_with_parts, + test_funding_contribution_with_outputs, test_funding_contribution_with_parts, SpliceIntent, + SpliceKind, }; use crate::types::{DynStore, DynStoreWrapper}; use crate::{NodeMetrics, PersistedNodeMetrics}; @@ -5149,6 +5209,278 @@ mod tests { (tx, contribution) } + /// The intent of a user-initiated splice of `channel_id` with `counterparty_node_id`, anchored + /// at the channel's funding `pre_splice_funding` when the splice was submitted. + fn splice_intent_for( + counterparty_node_id: PublicKey, channel_id: ChannelId, pre_splice_funding: LdkOutPoint, + ) -> SpliceIntent { + SpliceIntent { + counterparty_node_id, + channel_id, + pre_splice_funding_txo: pre_splice_funding, + contribution: test_funding_contribution_with_outputs(300, 253, &[]), + kind: SpliceKind::Out { outputs: Vec::new() }, + } + } + + /// A round signed under the channel's splice intent that has since locked with zero + /// confirmations — clearing its intent — with a second splice submitted against the locked + /// funding before the round's `SpliceNegotiated` event was handled: the channel's intent no + /// longer belongs to the recorded round. + struct LockedRoundWithNewerIntent { + first_id: PaymentId, + tx: Transaction, + candidates: Vec, + second_id: PaymentId, + second_intent: SpliceIntent, + } + + async fn lock_a_signed_round_and_submit_another_splice( + wallet: &Wallet, + ) -> LockedRoundWithNewerIntent { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let first_id = PaymentId([31u8; 32]); + let first_intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id: first_id, intent: first_intent }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + // The round locks with zero confirmations, which clears its intent... + let cleared = PendingPaymentDetailsUpdate { + id: first_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + wallet.pending_payment_store.update(cleared).await.unwrap(); + // ...and a second splice of the channel is submitted against the new funding. + let second_id = PaymentId([32u8; 32]); + let second_intent = + splice_intent_for(counterparty_node_id, channel_id, LdkOutPoint { txid, index: 0 }); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { + id: second_id, + intent: second_intent.clone(), + }) + .await + .unwrap(); + + LockedRoundWithNewerIntent { first_id, tx, candidates, second_id, second_intent } + } + + /// A recorded round is marked broadcast in its own record once the channel carries the intent + /// of a newer splice: after a zero-conf lock, the user may submit a second splice before the + /// locked round's `SpliceNegotiated` event is handled, and the event must neither file the + /// round under the new splice as a second record nor touch the new splice's intent. + #[tokio::test] + async fn negotiation_marks_a_recorded_round_broadcast_under_a_newer_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + let txid = setup.tx.compute_txid(); + + let channel_id = setup.candidates[0].channels[0].channel_id; + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the round must not be filed as a second record"); + assert_eq!(payments[0].id, setup.first_id); + let entry = + wallet.pending_payment_store.get(&setup.first_id).await.unwrap().expect("entry"); + assert!(!entry.candidate(txid).expect("candidate").awaiting_broadcast); + assert_eq!( + wallet.pending_payment_store.get(&setup.second_id).await.unwrap(), + Some(PendingPaymentDetails::PendingSplice { + id: setup.second_id, + intent: setup.second_intent, + }), + "the newer splice's intent must be left untouched" + ); + } + + /// The signing event of a recorded round, replayed once the channel carries the intent of a + /// newer splice, writes nothing: the round is on record, so the newer intent is not consulted. + #[tokio::test] + async fn a_replayed_signing_writes_nothing_under_a_newer_intent() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + + fail_store.fail_writes.store(true, Ordering::Release); + wallet.record_signed_funding(&setup.tx, &setup.candidates).await.unwrap(); + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 0, + "a replayed signing must produce no new write" + ); + assert_eq!( + wallet.pending_payment_store.get(&setup.second_id).await.unwrap(), + Some(PendingPaymentDetails::PendingSplice { + id: setup.second_id, + intent: setup.second_intent, + }), + ); + } + + /// The first round of a user-initiated splice is on no record when it is signed, so it adopts + /// the id of the channel's splice intent: the bare intent entry becomes the round's record and + /// keeps carrying the intent. + #[tokio::test] + async fn signing_a_first_round_adopts_the_intent_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("record"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + let txid_derived_id = PaymentId(txid.to_byte_array()); + assert!(wallet.payment_store.get(&txid_derived_id).await.unwrap().is_none()); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); + } + + /// A fee bump of a round whose payment wallet sync failed — the round lost to a conflicting + /// spend confirmed while the channel stayed open — adopts the channel's splice intent rather + /// than the failed record: the failed round in its history decides nothing, so the bump is + /// recorded under the intent's id, its entry carrying the intent. + #[tokio::test] + async fn signing_a_bump_of_a_failed_round_adopts_the_channels_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_store + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.pending_payment_store.remove(&failed_id).await.unwrap(); + + // The bump's intent, recorded at submission with no record left to join. + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let id = PaymentId([31u8; 32]); + let intent = SpliceIntent { + contribution: bump_contribution.clone(), + kind: SpliceKind::Rbf {}, + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let failed = + wallet.payment_store.get(&failed_id).await.unwrap().expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert!(wallet.pending_payment_store.get(&failed_id).await.unwrap().is_none()); + } + + /// A fee bump signed while the channel's intent is still live joins the record of the round + /// it replaces: that round is on record, so the history decides the id, and the intent the + /// bump shares with the first round stays on the record. + #[tokio::test] + async fn signing_a_bump_joins_the_replaced_rounds_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the bump must join the first round's record"); + assert_eq!(payments[0].id, id); + assert!(matches!(payments[0].kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + assert_eq!( + entry.candidates().iter().map(|c| c.txid).collect::>(), + vec![txid, bump_txid] + ); + assert_eq!(entry.splice_intent(), Some(&intent)); + } + /// Signing a splice round records its funding payment with the channel's full pending splice /// history, so a wallet sync that observes the transaction before the broadcast (the /// counterparty may broadcast first) resolves to the funding record through any round of that @@ -7585,6 +7917,54 @@ mod tests { assert_unchanged(&wallet, payment_id, true).await; } + /// A user-initiated splice's record is keyed by the PaymentId chosen at splice time, not by + /// its funding txid. The generic funding path must resolve a rebroadcast of that funding tx + /// back to the existing record rather than creating a duplicate under the txid-derived id. + #[tokio::test] + async fn classify_funding_resolves_the_splice_time_payment_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let txid = tx.compute_txid(); + + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; + let tx_type = TransactionType::Funding { channels: vec![] }; + wallet.classify_funding(&tx, &channels, tx_type).await.unwrap(); + + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the rebroadcast must not create a second record"); + assert_eq!(payments[0].id, payment_id); + assert_eq!(payments[0].amount_msat, Some(1_000_000)); + assert_eq!(payments[0].fee_paid_msat, Some(500)); + } + /// A funding broadcast whose classification fails must be retried, not dropped: no timer /// re-broadcasts a funding transaction, so a dropped package would keep the funding off-chain /// until LDK re-hands it when the channel next resumes. The record is written before the From 3e03d1d50d022d5c93d6c7ce4764116e9a6971f7 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 18 Aug 2026 17:13:08 -0500 Subject: [PATCH 26/37] Merge sync-created duplicates when recording a funding round Wallet sync can observe a funding round before it is recorded as a candidate: the counterparty broadcasts a round this node did not contribute to, which nothing records until this node signs a later round of the same splice and records the channel's history with it. The funding-status gate rightly reports such a round foreign, and sync re-keys the event to the round's txid-derived id, creating an untyped duplicate record whose pending entry from then on shadows the funding record in txid resolution: even after the round is recorded as a candidate, every later event routes to the duplicate, the confirmation strands there, and the funding record never confirms or graduates. Fold the duplicate back in when its round becomes a recorded candidate: adopt its confirmation onto the funding record -- through the same status-update path wallet sync uses, so the confirmed candidate's figures land -- and remove the duplicate along with its pending entry. A duplicate for a round that never confirmed is dropped without adopting anything; the actively-broadcast candidate stays the record's current txid. The merge runs when this node signs a round and records the channel's history with it, and again when LDK reports the round negotiated, under the writer's cross-store lock acquisition, so sync cannot interleave, and is idempotent, so a replayed SpliceNegotiated event can re-run it after a partial failure. At signing time the merge is a courtesy and a failure is only logged: the signed round can have no duplicate yet, as our signatures have not left the node, the round's SpliceNegotiated event re-runs the merge and replays on failure, and failing the signing would replay it against a record whose two-store write already completed, which the write's rollback does not cover. The pending entry is removed before the payment record: a replay rediscovers the duplicate through the record, so a failure between the two removals can still be cleaned up, instead of orphaning a pending entry that would shadow txid resolution all over again. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 705 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 682 insertions(+), 23 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 68095986d..1875c97b8 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2367,7 +2367,9 @@ impl Wallet { // payment store back as it was while the record is still pending, or the replayed event // would find the half-written record and take it for prior state. The write hands back // what it found in the payment store, read inside its own critical section. - if let Err(failure) = self.persist_funding_payment_locked(&guard, details, recorded).await { + if let Err(failure) = + self.persist_funding_payment_locked(&guard, details, recorded.clone()).await + { let (e, prior_details) = match failure { // The write pair failed before its first write, so there is nothing to put back. FundingWriteError::Unread(e) => return Err(e), @@ -2401,32 +2403,49 @@ impl Wallet { txid, candidates.len(), ); + + // The record is complete; merging the duplicates wallet sync created for earlier rounds + // is a courtesy. The signed round can have no duplicate yet, as our signatures have not + // left the node, and the round's `SpliceNegotiated` event re-runs the merge, replaying on + // failure, so a failure here is logged rather than replaying the signing. + if let Err(e) = self.merge_duplicate_candidate_records(&guard, payment_id, &recorded).await + { + log_error!( + self.logger, + "Failed to merge duplicate records into funding payment {}: {}", + payment_id, + e, + ); + } Ok(()) } /// Marks a splice round recorded when signing ([`Self::record_signed_funding`]) as broadcast /// once LDK reports the splice negotiated: `SpliceNegotiated` is emitted only once our /// `tx_signatures` for the round are ready to send, so the counterparty may hold them by then - /// and may broadcast the round, which is therefore no longer dropped as abandoned. Nothing is - /// written for a round no funding payment of `channel_id` tracks (no local contribution, or no - /// wallet-level activity) or one already marked (a replayed event). + /// and may broadcast the round, which is therefore no longer dropped as abandoned. Then merges + /// the duplicate records wallet sync created for the record's candidates + /// ([`Self::merge_duplicate_candidate_records`]), completing a merge the signing left + /// unfinished. Nothing is written for a round no funding payment of `channel_id` tracks (no + /// local contribution, or no wallet-level activity); a replayed event finds the round marked + /// already and only re-runs the merge. pub(crate) async fn record_broadcast_splice_round( &self, channel_id: ChannelId, txid: Txid, ) -> Result<(), Error> { // Serialize with the other funding-record writers, which all hold this lock from their // reads through their last write. - let _guard = self.funding_payment_update_lock.lock().await; + let guard = self.funding_payment_update_lock.lock().await; let entries = self .pending_payment_store .list_filter(|entry| { - tracks_channel(entry, channel_id) - && entry.candidate(txid).is_some_and(|candidate| candidate.awaiting_broadcast) + tracks_channel(entry, channel_id) && entry.candidate(txid).is_some() }) .await; for entry in entries { let payment_id = entry.id(); - self.pending_payment_store + let marked = self + .pending_payment_store .mutate(&payment_id, |existing| { let mut entry = existing?.clone(); let PendingPaymentDetails::Tracked { candidates, .. } = &mut entry else { @@ -2439,13 +2458,19 @@ impl Wallet { Some(entry) }) .await?; - log_debug!( - self.logger, - "Marked splice round {} of channel {} as broadcast in funding payment {}", - txid, - channel_id, - payment_id, - ); + if marked.is_some() { + log_debug!( + self.logger, + "Marked splice round {} of channel {} as broadcast in funding payment {}", + txid, + channel_id, + payment_id, + ); + } + // The round's record is complete, so the duplicates wallet sync created for earlier + // rounds can be folded in. A failure replays the event, which re-runs the merge + // idempotently. + self.merge_duplicate_candidate_records(&guard, payment_id, entry.candidates()).await?; } Ok(()) } @@ -2734,9 +2759,11 @@ impl Wallet { 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`. Returns the - /// payment store's record as it was before the write. + /// Writes a freshly-classified funding payment to the authoritative payment store, adds a + /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`, and + /// merges the duplicate records wallet sync created for its candidates, as + /// [`Self::merge_duplicate_candidate_records`] describes. Returns the payment store's record as + /// it was before the write. /// /// Production callers go through [`Self::persist_funding_payment_locked`] because they resolve /// the record's id under the same lock acquisition; this wrapper models that acquisition for @@ -2748,7 +2775,11 @@ impl Wallet { // Hold the cross-store lock across both writes so a funding confirmation never observes // the record classified but the candidate history it needs still missing. let guard = self.funding_payment_update_lock.lock().await; - Ok(self.persist_funding_payment_locked(&guard, details, candidates).await?) + let id = details.id; + let prior = + self.persist_funding_payment_locked(&guard, details, candidates.clone()).await?; + self.merge_duplicate_candidate_records(&guard, id, &candidates).await?; + Ok(prior) } /// Writes a freshly recorded funding payment to the authoritative payment store and adds a @@ -2870,6 +2901,72 @@ impl Wallet { Ok(prior) } + /// Merges duplicate records wallet sync created for this funding payment's candidates before + /// they were recorded as such. Sync re-keys an event for a round it cannot attribute to the + /// funding record — not yet a candidate, so the funding-status gate reports it foreign — to + /// the round's txid-derived id, creating an untyped duplicate whose pending entry then + /// shadows the funding record in [`Self::find_payment_by_txid`]'s direct probe. Once the + /// round is a recorded candidate, the duplicate's confirmation (if any) belongs on the + /// funding record: adopt it, then remove the duplicate and its pending entry. + /// + /// Runs once a record's candidate history is written, so the funding-status gate accepts the + /// candidates it adopts, and under the writer's lock acquisition, so sync cannot interleave. + /// It is idempotent: a failure at signing time ([`Self::record_signed_funding`]) is left to the + /// signed round's `SpliceNegotiated` event ([`Self::record_broadcast_splice_round`]), which + /// re-runs the merge and replays on failure. The caller must hold + /// [`Self::funding_payment_update_lock`], per [`Self::apply_funding_status_update_locked`]'s + /// contract. + async fn merge_duplicate_candidate_records( + &self, guard: &tokio::sync::MutexGuard<'_, ()>, id: PaymentId, + candidates: &[FundingTxCandidate], + ) -> Result<(), Error> { + for candidate in candidates { + let duplicate_id = PaymentId(candidate.txid.to_byte_array()); + if duplicate_id == id { + continue; + } + let duplicate = match self.payment_store.get(&duplicate_id).await? { + Some(duplicate) => duplicate, + None => continue, + }; + // Only a duplicate view of this candidate's transaction qualifies: an untyped record + // wallet sync created, or one a funding-typed rebroadcast classified onto it. Anything + // else keyed by the txid-derived id is left alone. + let status = match &duplicate.kind { + PaymentKind::Onchain { + txid, + status, + tx_type: None | Some(TransactionType::Funding { .. }), + } if *txid == candidate.txid => status.clone(), + _ => continue, + }; + // Only a confirmation is worth adopting; an unconfirmed duplicate carries nothing the + // record needs — the actively-broadcast candidate stays the record's current txid. + if matches!(status, ConfirmationStatus::Confirmed { .. }) { + let outcome = self + .apply_funding_status_update_locked(guard, id, candidate.txid, status) + .await?; + debug_assert!(matches!(outcome, FundingStatusUpdate::Applied)); + if !matches!(outcome, FundingStatusUpdate::Applied) { + // Adoption declined; keep the duplicate rather than discard its confirmation. + continue; + } + } + log_debug!( + self.logger, + "Merging duplicate payment record for funding transaction {}", + candidate.txid, + ); + // Pending entry first: the retry of a failure between these two removals rediscovers + // the duplicate through its payment record. Removed the other way around, the + // leftover pending entry would be unreachable to the retry yet keep shadowing the + // funding record in `find_payment_by_txid`'s direct probe. + self.pending_payment_store.remove(&duplicate_id).await?; + self.payment_store.remove(&duplicate_id).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, @@ -4149,6 +4246,86 @@ mod tests { } } + /// An in-memory store that fails the next remove issued against an armed namespace, for + /// exercising cleanup paths that must survive a failure between two removals. + #[derive(Clone)] + struct FailRemoveStore { + inner: Arc, + fail_remove_in: Arc>>, + } + + impl FailRemoveStore { + fn new() -> Self { + Self { + inner: Arc::new(InMemoryStore::new()), + fail_remove_in: Arc::new(std::sync::Mutex::new(None)), + } + } + + fn fail_next_remove_in(&self, primary_namespace: &str) { + *self.fail_remove_in.lock().unwrap() = Some(primary_namespace.to_string()); + } + } + + impl KVStore for FailRemoveStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + KVStore::write(&*self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + let inner = Arc::clone(&self.inner); + let armed = Arc::clone(&self.fail_remove_in); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + async move { + let fail = { + let mut armed = armed.lock().unwrap(); + if armed.as_deref() == Some(primary_namespace.as_str()) { + *armed = None; + true + } else { + false + } + }; + if fail { + return Err(io::Error::new(io::ErrorKind::Other, "removes disabled")); + } + KVStore::remove(&*inner, &primary_namespace, &secondary_namespace, &key, lazy).await + } + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for FailRemoveStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + /// Constructs a `Wallet` around the given store, either creating a fresh BDK wallet or /// loading the one the store already holds. async fn new_test_wallet(store: Arc, load_existing: bool) -> Arc { @@ -6412,10 +6589,11 @@ mod tests { assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); } - /// Recording a first splice round costs one read of the payment store: the write pair reads - /// the record it merges into, and that read also serves the rollback of a failed write. + /// Recording a first splice round costs two reads of the payment store: the write pair reads + /// the record it merges into, which also serves the rollback of a failed write, and the + /// duplicate merge probes for a record wallet sync may have keyed by the round's own txid. #[tokio::test] - async fn a_first_round_signing_reads_the_payment_store_once() { + async fn a_first_round_signing_reads_the_payment_store_twice() { let counting_store = ReadCountingStore::new(); let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); let wallet = new_test_wallet(Arc::clone(&store), false).await; @@ -6429,7 +6607,7 @@ mod tests { let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; - assert_eq!(reads, 1, "recording a first round re-read the payment store"); + assert_eq!(reads, 2, "recording a first round re-read the payment store"); } /// The signing write fails between its two stores — the payment record lands, the pending @@ -8349,6 +8527,487 @@ mod tests { loop_task.await.unwrap(); } + /// Wallet sync can record a genuine replacement round before it is recorded as a candidate: + /// the counterparty broadcast a round this node did not contribute to, which is recorded only + /// when this node signs a later round of the splice. The funding-status gate then routes the + /// round's confirmation to a duplicate record keyed by the round's txid, whose pending entry + /// shadows the funding record in `find_payment_by_txid`'s direct probe. Once the round is + /// recorded as a candidate, the write must merge the duplicate — adopt its confirmation and + /// remove it — so a single record tracks the splice. + #[tokio::test] + async fn recording_a_round_merges_duplicate_records_for_its_candidates() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); + + // Round 1 recorded normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate: a + // duplicate untyped record under the txid-derived id, plus its pending entry. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_store.insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .pending_payment_store + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(duplicate_id)); + + // Round 2 is recorded as a candidate, with the history of a later round this node signs. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.persist_funding_payment(details, rounds).await.unwrap(); + + // One record: the funding record carries the duplicate's confirmation and the confirmed + // candidate's figures; the duplicate and its pending entry are gone, so the round's txid + // resolves to the funding record again. + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + assert_eq!(payment.amount_msat, Some(1_000_000)); + assert_eq!(payment.fee_paid_msat, Some(400)); + match &payment.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } => assert_eq!(*txid, txid2), + kind => panic!("unexpected kind {:?}", kind), + } + assert!(wallet.pending_payment_store.get(&duplicate_id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); + } + + /// A duplicate for an *unconfirmed* round carries no state the funding record needs: the + /// merge removes it without touching the record's active txid or figures, and the round's + /// txid maps back to the funding record through its candidate history. + #[tokio::test] + async fn recording_drops_unconfirmed_duplicates_without_adopting_their_txid() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); + + // Wallet sync saw round 1 — still unconfirmed — before any round was recorded. + let duplicate_id = PaymentId(txid1.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: txid1, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_store.insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .pending_payment_store + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + + // Round 2 is the active broadcast; its record lists both rounds. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.persist_funding_payment(details, rounds).await.unwrap(); + + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + // The record keeps tracking the actively-broadcast round; a duplicate that never confirmed + // has nothing to adopt. + match &payment.kind { + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } => { + assert_eq!(*txid, txid2) + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(payment.fee_paid_msat, Some(400)); + assert_eq!(wallet.find_payment_by_txid(txid1).await.unwrap(), Some(funding_id)); + } + + /// Removing the duplicate is two store writes, and the failure between them must leave a + /// state a re-run of the merge (a replayed `SpliceNegotiated` event) can finish cleaning up. + /// If the payment record went first, a failure on the pending-entry removal would orphan that + /// entry where the re-run can no longer discover it (the record lookup misses), and it would + /// keep shadowing the funding record in `find_payment_by_txid`'s direct probe — re-creating + /// the duplicate problem with no further merge coming to fix it. + #[tokio::test] + async fn a_rerun_merge_completes_a_partially_failed_duplicate_removal() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); + + // Round 1 recorded normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_store.insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .pending_payment_store + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + + // Round 2 is recorded as a candidate, but one of the duplicate's two removals fails. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let res = wallet.persist_funding_payment(details.clone(), rounds.clone()).await; + assert!(res.is_err(), "the injected remove failure must surface"); + + // The merge re-runs with the record's next write; it must finish the cleanup. + wallet.persist_funding_payment(details, rounds).await.unwrap(); + + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, funding_id); + assert!(wallet.pending_payment_store.get(&duplicate_id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); + } + + /// Signing a later round merges the duplicates of earlier rounds as a courtesy: the signed + /// round itself can have no duplicate yet, as our signatures have not left the node, and the + /// round's own `SpliceNegotiated` event re-runs the merge, replaying on failure. A merge + /// failure must therefore not fail the signing, whose record is complete once both stores are + /// written, and must not leave the record half rolled back. + #[tokio::test] + async fn signing_survives_a_failed_duplicate_merge() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // Round 1 is recorded at signing; round 2 is a counterparty-initiated replacement the + // wallet observed before it was recorded as a candidate, filed as an untyped duplicate. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_store.insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .pending_payment_store + .insert_or_update(PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new())) + .await + .unwrap(); + + // This node signs round 3, a bump of the replacement, but the duplicate's removal fails. + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + // The signing is recorded in full and the duplicate is left as it was. + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + assert_eq!( + entry.candidates().iter().map(|c| c.txid).collect::>(), + vec![txid, replacement_txid, bump_txid] + ); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(wallet.payment_store.get(&duplicate_id).await.unwrap(), Some(duplicate)); + assert!(wallet.pending_payment_store.get(&duplicate_id).await.unwrap().is_some()); + + // The bump's `SpliceNegotiated` event merges the duplicate away. + wallet.record_broadcast_splice_round(channel_id, bump_txid).await.unwrap(); + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); + assert!(wallet.pending_payment_store.get(&duplicate_id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); + } + + /// A duplicate merge failing under the `SpliceNegotiated` write must fail that write: the + /// replay it triggers is the merge's only re-run. The mark, cleared before the merge, stays + /// cleared and the duplicate is left as it was; the replayed write finds the round marked + /// already and merges the duplicate away. + #[tokio::test] + async fn a_failed_duplicate_merge_fails_the_negotiation_write_until_its_replay() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // Round 1 is recorded at signing; round 2 is a counterparty-initiated replacement the + // wallet observed before it was recorded as a candidate, filed as an untyped duplicate; + // round 3, a bump this node signs, records round 2 as a candidate, but the signing's + // merge of the duplicate fails and is left to the bump's `SpliceNegotiated` event. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_store.insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .pending_payment_store + .insert_or_update(PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new())) + .await + .unwrap(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + // The event's write meets the same failure: it must surface, so the event is replayed, + // with the mark cleared and the duplicate untouched. + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let res = wallet.record_broadcast_splice_round(channel_id, bump_txid).await; + assert!(res.is_err(), "a failed merge must fail the write"); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + assert!(!entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + assert_eq!(wallet.payment_store.get(&duplicate_id).await.unwrap(), Some(duplicate)); + assert!(wallet.pending_payment_store.get(&duplicate_id).await.unwrap().is_some()); + + // The replayed write finds the round marked already and merges the duplicate away. + wallet.record_broadcast_splice_round(channel_id, bump_txid).await.unwrap(); + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); + assert!(wallet.pending_payment_store.get(&duplicate_id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); + } + + /// A merge cut short between adopting a confirmed duplicate's confirmation and removing the + /// duplicate leaves the funding record confirmed on the duplicate's transaction, the pending + /// entry at its prior status and the duplicate untouched, and a re-run completes the removal: + /// the merge is idempotent, so the record's next write or a replayed `SpliceNegotiated` event + /// can finish what a failure cut short. The failure injected is the pending store's, which the + /// adoption writes after the payment store. + #[tokio::test] + async fn a_torn_duplicate_merge_is_completed_by_a_rerun() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // Round 1 is recorded at signing, round 2 is a counterparty-initiated replacement, and + // round 3 is this node's bump of it, recorded with the channel's history when signed. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + // Wallet sync filed the replacement's confirmation under an untyped record of its own, a + // duplicate of the funding record that already lists the replacement as a candidate. + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: confirmed_status(), + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_store.insert_or_update(duplicate.clone()).await.unwrap(); + let duplicate_entry = PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new()); + wallet.pending_payment_store.insert_or_update(duplicate_entry.clone()).await.unwrap(); + let entry_before = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + let rounds = entry_before.candidates().to_vec(); + + // The merge adopts the confirmation onto the payment record, then fails to mirror it onto + // the pending entry and stops short of removing the duplicate. + fail_store.fail_writes.store(true, Ordering::Release); + { + let guard = wallet.funding_payment_update_lock.lock().await; + let res = wallet.merge_duplicate_candidate_records(&guard, id, &rounds).await; + assert!(res.is_err(), "the injected pending-store failure must surface"); + } + fail_store.fail_writes.store(false, Ordering::Release); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } + if t == replacement_txid + )); + assert_eq!(wallet.pending_payment_store.get(&id).await.unwrap(), Some(entry_before)); + assert_eq!(wallet.payment_store.get(&duplicate_id).await.unwrap(), Some(duplicate)); + assert_eq!( + wallet.pending_payment_store.get(&duplicate_id).await.unwrap(), + Some(duplicate_entry) + ); + + // A re-run finds the confirmation adopted, mirrors it, and removes the duplicate. + { + let guard = wallet.funding_payment_update_lock.lock().await; + wallet.merge_duplicate_candidate_records(&guard, id, &rounds).await.unwrap(); + } + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); + assert!(wallet.pending_payment_store.get(&duplicate_id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); + } + /// Barrier test, classification-first ordering: wallet sync's confirmation handling must /// wait for classification's two-store write pair. Classification is parked between its /// payment-store and pending-store writes (the torn window) and only then is the From 7fcb7b45ced40a3eeb6db7731324260790ad19c1 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 1 Sep 2026 19:12:33 -0500 Subject: [PATCH 27/37] Persist splice intents until the splice locks LDK only persists a splice once its negotiation reaches AwaitingSignatures, so a splice in flight when the node stops can leave no trace in LDK. Persist each user-initiated splice as an intent record before its contribution is handed to LDK, so such a splice can be recognized at the next startup -- releasing whatever the wallet still holds for it, which a later commit adds -- and so events about the splice can be described in terms of the original request. Each splice gets a record of its own, so that its failure is described from its own intent and a restart recognizes it whatever became of the channel's other splices: a splice queued behind a pending one negotiates as a splice of its own once the pending one locks, and its rounds must not be filed under the pending splice's payment. Only a fee bump joins an existing record, that of the round it replaces. A splice is refused while the channel carries an intent anchored at another funding -- one the lock that superseded it failed to settle or to re-anchor -- rather than recorded beside it. A submission reads the channel's funding under the lock that serializes splice submissions and anchors its intent there, not at the funding the caller read before building the contribution: a splice locking in between moves the funding, and an intent anchored at the old one would never be settled by the lock that superseded it. A funding that moved refuses a fee bump, whose round has locked, and a splice-in, whose inputs the locked round may have spent; a splice-out carries no wallet inputs and proceeds. A splice submitted after the previous one locked with zero confirmations settles that splice's intent first, as the lock's event would have: LDK promotes the funding as soon as splice_locked is exchanged but only queues the event. The lock and close event handlers settle intents under the same lock, so a lock handled mid-submission cannot settle the new intent before its contribution reaches LDK. The record is undone when LDK rejects the hand-off synchronously and settled once the splice locks, its failure is surfaced, or its channel closes. A failure event settles the intent only after the event is durably queued -- a crash in between leaves the intent for the replayed event to settle, erring toward a duplicate report over a lost one -- and only when the event's contribution identifies the recorded splice: a mismatch means the failure concerns an older, superseded attempt with no record of its own. Taking back the funding record of a signed round the failure abandoned leaves its intent behind as a bare intent, so the report can still describe the splice. A splice queued behind another pending splice survives the pending splice's lock, so its intent is re-anchored to the new funding rather than settled. Failing a funding payment -- when a round other than its own locks, when its channel closes, or when wallet sync finds its round lost to a confirmed conflicting spend -- likewise keeps the intent its entry carried, as a bare intent under an id of its own. LDK carries a fee bump queued behind a round it does not overlap across that round's lock and begins a fresh splice from it, so the intent is still needed: to re-anchor it at the new funding, to file the fresh round under it when signed, and to describe the failure LDK reports if the fresh negotiation fails instead. Under the failed record's id, the fresh round would take that record and go untracked. The lock and close handlers settle the kept intent right after it is kept, unless LDK still holds its splice and the lock re-anchors it instead; one kept from wallet sync stays anchored at the channel's unchanged funding, where a later fee bump joins it and no submission is refused on its account. Wallet state staged on a splice's behalf is flushed only after the intent record persists, so nothing the wallet reserves for a splice can outlive the record through which a later startup would release it. A splice that fails before the hand-off immediately releases what the wallet holds for it and no other round uses -- a fee bump built by adjusting the fee of the round it replaces shares that round's inputs and change address, which stay reserved while the round can confirm; one LDK rejects has it returned through the DiscardFunding event instead. A lock settles an intent without releasing anything: what the locked round did not spend, LDK returns through the DiscardFunding events it queues at the promotion. Once a splice funding payment is classified, the intent is carried on the payment's record until the splice locks or the payment fails; a payment that already graduated instead removes the leftover intent record. The funding payment recorded when this node signs a splice round is filed under the record of the intent carrying the round's contribution, written while holding the lock that serializes splice submissions, so neither a fee bump replacing the intent nor a failure settling it can interleave with the write. A signing write cut short after the payment store leaves that payment under a bare intent; it records a round whose signatures never left the node, so it is dropped -- when the replayed signing finds the round gone, or with the intent once the splice settles -- rather than promoted into a record nothing could ever drive. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/builder.rs | 9 + src/channel/mod.rs | 1331 ++++++++++++++++++++++++++ src/data_store.rs | 68 ++ src/event.rs | 55 +- src/lib.rs | 117 ++- src/payment/pending_payment_store.rs | 57 +- src/wallet/mod.rs | 1149 ++++++++++++++++++++-- 7 files changed, 2650 insertions(+), 136 deletions(-) create mode 100644 src/channel/mod.rs diff --git a/src/builder.rs b/src/builder.rs index 1158044e4..a9ba3d0d3 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -53,6 +53,7 @@ use lightning_dns_resolver::OMDomainResolver; use vss_client::headers::VssHeaderProvider; use crate::chain::ChainSource; +use crate::channel::SpliceTracker; #[cfg(feature = "chain-bitcoind")] use crate::config::BitcoindRestClientConfig; use crate::config::{ @@ -2500,6 +2501,13 @@ fn build_with_store_internal( }) }); + let splice_tracker = Arc::new(SpliceTracker::new( + Arc::clone(&channel_manager), + Arc::clone(&wallet), + Arc::clone(&pending_payment_store), + Arc::clone(&logger), + )); + #[cfg(cycle_tests)] let mut _leak_checker = crate::LeakChecker(Vec::new()); #[cfg(cycle_tests)] @@ -2551,6 +2559,7 @@ fn build_with_store_internal( payment_store, forwarding_store, forwarded_payment_aggregation_retention_secs, + splice_tracker, lnurl_auth, is_running, node_metrics, diff --git a/src/channel/mod.rs b/src/channel/mod.rs new file mode 100644 index 000000000..183ef00c3 --- /dev/null +++ b/src/channel/mod.rs @@ -0,0 +1,1331 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Persistence of in-flight user-initiated splices, so a splice LDK has not durably learned of +//! yet can be recognized — and whatever it reserved recovered — after a restart. + +use std::fmt; +use std::sync::Arc; + +use bitcoin::absolute::LockTime; +use bitcoin::secp256k1::PublicKey; +use bitcoin::transaction::Version; +use bitcoin::{OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid}; +use lightning::chain::chaininterface::FundingCandidate; +use lightning::chain::transaction::OutPoint as LdkOutPoint; +use lightning::ln::channel_state::{ChannelDetails, SpliceCandidateDetails}; +use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::FundingContribution; +use lightning::ln::types::ChannelId; + +use crate::data_store::{StorableObject, UpdatableObject}; +use crate::logger::{log_error, LdkLogger, Logger}; +use crate::payment::pending_payment_store::{ + PendingPaymentDetails, PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind, +}; +use crate::payment::{PaymentKind, TransactionType}; +use crate::types::{ChannelManager, PendingPaymentStore}; +use crate::wallet::{funding_candidates, random_payment_id, Wallet}; +use crate::Error; + +/// Whether two contributions describe the same splice attempt. LDK may adjust a contribution +/// during negotiation — the quiescence tie-breaker rebuilds the acceptor's copy at a fresh +/// feerate, touching only its fee fields and change value — so fees and feerates do not identify +/// an attempt. Its inputs and outputs do: they are what the user asked to move. Contributions +/// carrying neither (channel-balance-only attempts) fall back to full equality. +pub(crate) fn is_same_splice(a: &FundingContribution, b: &FundingContribution) -> bool { + if a.inputs().is_empty() + && a.outputs().is_empty() + && b.inputs().is_empty() + && b.outputs().is_empty() + { + return a == b; + } + a.inputs().iter().map(|i| i.outpoint()).eq(b.inputs().iter().map(|i| i.outpoint())) + && a.outputs() == b.outputs() +} + +/// Tracks each user-initiated splice through a persisted [`SpliceIntent`] for as long as LDK is +/// not guaranteed to remember the splice itself: LDK only persists a splice once its negotiation +/// reaches `AwaitingSignatures`, and it abandons an in-progress negotiation whenever the peer +/// disconnects — which includes stopping the node. +/// +/// The intent is written before the contribution is handed to LDK, undone when LDK rejects the +/// hand-off synchronously, and cleared once the splice locks, its failure is surfaced, or its +/// channel closes. The record exists for recovery, not retry: a splice still recorded at the +/// next startup identifies one that was in flight when the node stopped, so anything it reserved +/// can be released, and events about the splice can be described in terms of the original +/// request. Each splice has a record of its own — a channel may carry several, a pending splice +/// and the splices queued behind it — so that each is recognized and described whatever became +/// of the others; only a fee bump joins the record of the round it replaces. +pub(crate) struct SpliceTracker { + channel_manager: Arc, + wallet: Arc, + pending_payment_store: Arc, + /// Serializes everything that reads or settles a channel's intent records against + /// [`Self::submit`]'s read-funding, persist and hand-off sequence: the settling of intents by + /// [`Self::on_negotiation_failed`], [`Self::on_channel_ready`] and + /// [`Self::on_channel_closed`], and the funding record [`Self::on_funding_ready_for_signing`] + /// files under an intent's id. Without it, the failure event of a synchronously rejected + /// hand-off could settle the just-written intent while `submit` is still deciding whether to + /// keep it, and a lock event handled between `submit`'s funding read and its persist could + /// leave the new intent anchored at a funding the channel has moved past, which nothing would + /// settle. Every public entry point takes it; the `_locked` variants assume it is held and + /// must not take it again (tokio's mutex is not reentrant). It nests outward of the wallet's + /// locks and the stores', which are taken while it is held and never hold it. An event + /// handler waiting on it waits for a `submit` to finish its bounded sequence, nothing more. + submit_lock: tokio::sync::Mutex<()>, + logger: Arc, +} + +impl SpliceTracker { + pub(crate) fn new( + channel_manager: Arc, wallet: Arc, + pending_payment_store: Arc, logger: Arc, + ) -> Self { + Self { + channel_manager, + wallet, + pending_payment_store, + submit_lock: tokio::sync::Mutex::new(()), + logger, + } + } + + /// Persists a user-initiated splice as an intent and hands its contribution to + /// [`ChannelManager::funding_contributed`]. The intent — and any wallet state staged on the + /// splice's behalf — is durable before the hand-off, so no splice is ever in flight without a + /// persisted record of it. Each splice gets a record of its own; only a fee bump joins the + /// record of the round it replaces ([`Self::persist_intent`]). + /// + /// The intent is anchored at the channel's funding as it stands under the submit lock, not at + /// `pre_splice_funding_txo`, the funding the caller read before building the contribution: a + /// splice locking in between moves the funding, and an intent anchored at the old one would + /// never be settled by the lock that superseded it. A funding that moved refuses a fee bump — + /// the round it was built to replace has locked — and a splice-in, whose inputs the locked + /// round may have spent; a splice-out carries no wallet inputs and proceeds, as LDK + /// re-validates its amount against the live balance ([`check_submission`]). Intents anchored + /// at a funding the channel has moved past are settled first, as their lock event would. + /// + /// On any failure the persisted intent is undone and the error returned for the caller to + /// surface. A failure before the hand-off also releases what the wallet holds for the + /// contribution and no other round claims ([`Self::release_contribution`]): a fee bump built + /// by adjusting the fee of the round it replaces — `prior`, the contribution it was built + /// from — reuses that round's inputs and change address, which a refusal must leave to the + /// round that has locked meanwhile. A synchronous rejection leaves the release to the + /// `DiscardFunding` event LDK queues. + /// + /// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed + pub(crate) async fn submit( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + pre_splice_funding_txo: LdkOutPoint, contribution: FundingContribution, kind: SpliceKind, + prior: Option, + ) -> Result<(), Error> { + let guard = self.submit_lock.lock().await; + let channel = self.channel(counterparty_node_id, channel_id); + let live_funding_txo = channel.as_ref().and_then(|channel| channel.funding_txo); + let candidates = channel + .as_ref() + .and_then(|channel| channel.splice_details.as_ref()) + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]); + let funding_txo = match check_submission(pre_splice_funding_txo, live_funding_txo, &kind) { + Ok(funding_txo) => funding_txo, + Err(refusal) => { + log_error!( + self.logger, + "Refusing to splice channel {} with counterparty {}: {}", + channel_id, + counterparty_node_id, + refusal, + ); + // TODO(#1037): `release_contribution` swallows a failed release. Once inputs are + // locked at coin selection, a failure here leaves locks no record names: surface + // it, or persist an intent for `reconcile` to release from. + self.release_contribution(channel_id, &contribution, candidates, prior.as_ref()) + .await; + return Err(Error::ChannelSplicingFailed); + }, + }; + // LDK promotes a zero-conf splice as soon as `splice_locked` is exchanged and only queues + // the `ChannelReady` event whose handling settles the locked splice's intent. A splice + // submitted in between builds on the new funding while the channel still carries that + // intent: settle it here as the event would. + self.settle_superseded_intents_locked( + &guard, + counterparty_node_id, + channel_id, + funding_txo.into_bitcoin_outpoint(), + channel.as_ref(), + ) + .await; + let intent = SpliceIntent { + counterparty_node_id, + channel_id, + pre_splice_funding_txo: funding_txo, + contribution: contribution.clone(), + kind, + }; + // A splice whose intent cannot be persisted is not attempted at all, rather than + // attempted without restart coverage. + let (payment_id, restore) = match self.persist_intent(intent, channel.as_ref()).await { + Ok(persisted) => persisted, + Err(e) => { + log_error!( + self.logger, + "Failed to persist the splice intent for channel {} with counterparty {}: {:?}", + channel_id, + counterparty_node_id, + e, + ); + // TODO(#1037): as at the refusal above, a failed release here leaves locks no + // record names. + self.release_contribution(channel_id, &contribution, candidates, prior.as_ref()) + .await; + return Err(e); + }, + }; + // Flush wallet state staged on the splice's behalf (e.g. input locks) only now that the + // intent record is durable: whatever the wallet holds for a splice must never outlive the + // record through which a later startup would release it. + // TODO(#1037): nothing is staged yet, and #1037 persists its input locks at coin + // selection, ahead of the intent. Stage them instead, so that this flush is what makes + // them durable. + if let Err(e) = self.wallet.persist_staged().await { + log_error!( + self.logger, + "Failed to persist staged wallet state for splicing channel {} with counterparty \ + {}: {:?}", + channel_id, + counterparty_node_id, + e, + ); + // TODO(#1037): the intent is discarded before the release; a release that fails + // leaves locks no record names. Keep the intent instead when the release fails. + self.discard_persisted_intent(&payment_id, restore).await; + self.release_contribution(channel_id, &contribution, candidates, prior.as_ref()).await; + return Err(e); + } + if let Err(e) = self.channel_manager.funding_contributed( + &channel_id, + &counterparty_node_id, + contribution, + None, + ) { + log_error!( + self.logger, + "LDK rejected the splice contribution for channel {} with counterparty {}: {:?}", + channel_id, + counterparty_node_id, + e, + ); + // LDK returns the contribution through a `DiscardFunding` event, whose handling frees + // the addresses the wallet marked for it. + // TODO(#1037): the handler ignores the event's inputs; once inputs are locked at coin + // selection, it must unlock them as well. + self.discard_persisted_intent(&payment_id, restore).await; + return Err(Error::ChannelSplicingFailed); + } + Ok(()) + } + + /// Releases what the wallet may still hold for a contribution that is going nowhere, short of + /// what another contribution claims as well — a splice candidate LDK holds for the channel, or + /// the round a fee bump was built from (`prior`): the remaining inputs are unlocked and a + /// transaction paying the remaining outputs is canceled, freeing the addresses of its change + /// and splice-out outputs ([`unclaimed_parts`]). A fee bump built by adjusting the fee of the + /// round it replaces reuses that round's inputs and change address; released along with the + /// bump, they would be free for other spends while the round can still confirm. + async fn release_contribution( + &self, channel_id: ChannelId, contribution: &FundingContribution, + candidates: &[SpliceCandidateDetails], prior: Option<&FundingContribution>, + ) { + let claimants = candidates.iter().filter_map(|c| c.contribution.as_ref()).chain(prior); + let (inputs, outputs) = unclaimed_parts(contribution, claimants); + if inputs.is_empty() && outputs.is_empty() { + return; + } + // TODO(#1037): `cancel_tx` unlocks the transaction's inputs itself once inputs are locked + // at coin selection, making this unlock redundant. + if let Err(e) = self.wallet.unlock_outpoints(&inputs).await { + log_error!( + self.logger, + "Failed to release the inputs of a splice contribution on channel {}: {}", + channel_id, + e, + ); + } + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs + .into_iter() + .map(|previous_output| TxIn { previous_output, ..TxIn::default() }) + .collect(), + output: outputs, + }; + if let Err(e) = self.wallet.cancel_tx(tx).await { + log_error!( + self.logger, + "Failed to release the outputs of a splice contribution on channel {}: {}", + channel_id, + e, + ); + } + } + + /// Persists `intent` before its contribution is handed to LDK, outliving a restart that — + /// until the negotiation reaches `AwaitingSignatures` — LDK's own state does not. + /// + /// Each splice gets a record of its own, so a channel may carry several: a splice queued + /// behind a pending one negotiates as a splice of its own once the pending one locks. Only a + /// fee bump joins an existing record, that of the round it replaces ([`place_intent`]), decided + /// from the channel's pending records and the splice rounds LDK holds for the channel + /// (`channel`, as the caller listed it). A record still anchored at another funding is one + /// [`Self::submit`] just failed to settle or to re-anchor; the new splice is refused rather + /// than recorded beside it. Returns the id and, for restoring on a rejected hand-off, `None` + /// when a fresh record was created or `Some(prior)` when an existing record's intent was + /// replaced (`prior` being `None` for a record that carried no intent). + async fn persist_intent( + &self, intent: SpliceIntent, channel: Option<&ChannelDetails>, + ) -> Result<(PaymentId, Option>), Error> { + let records = self + .pending_payment_store + .list_filter(|p| concerns_channel(p, intent.counterparty_node_id, intent.channel_id)) + .await; + let held_rounds: Vec = channel + .map(|channel| { + funding_candidates( + channel.splice_details.as_ref(), + intent.counterparty_node_id, + intent.channel_id, + ) + }) + .unwrap_or_default() + .into_iter() + .map(|candidate| candidate.txid) + .collect(); + match place_intent(&intent, &records, &held_rounds) { + IntentPlacement::Refused => { + log_error!( + self.logger, + "Refusing to splice channel {} with counterparty {}: the channel carries a \ + splice intent anchored at another funding", + intent.channel_id, + intent.counterparty_node_id, + ); + Err(Error::ChannelSplicingFailed) + }, + IntentPlacement::Reuse(payment_id) => { + let prior = records + .iter() + .find(|record| record.id() == payment_id) + .and_then(|record| record.splice_intent().cloned()); + self.pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(Some(intent)), + }) + .await?; + Ok((payment_id, Some(prior))) + }, + IntentPlacement::Fresh => { + let payment_id = random_payment_id(); + self.pending_payment_store + .insert(PendingPaymentDetails::pending_splice(payment_id, intent)) + .await?; + Ok((payment_id, None)) + }, + } + } + + /// Undoes a splice intent persisted for a hand-off that then failed before LDK took the + /// splice: restores an existing record's prior intent, or removes a freshly created record. + async fn discard_persisted_intent( + &self, payment_id: &PaymentId, restore: Option>, + ) { + let result = match restore { + Some(prior) => self + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: *payment_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(prior), + }) + .await + .map(|_| ()), + None => self.pending_payment_store.remove(payment_id).await, + }; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to undo the intent of rejected splice payment {}: a stale intent record \ + may be left behind: {}", + payment_id, + e, + ); + } + } + + /// Clears the persisted intent behind a splice that settled — it locked, its failure was + /// surfaced, or its channel closed — but only while `still_applies` holds for the stored + /// intent: a mismatch means a fee bump took over the record in the meantime, and its intent + /// must stay. A tracked record stays, with the intent cleared, so its payment keeps + /// graduating. A bare intent record is removed, along with any payment record under its id: + /// the signing-time recording of a splice round files a payment under an intent's id and + /// promotes the entry in the same write, so a payment record found under a bare intent is the + /// first half of a write that never completed, of a round whose signatures never left the + /// node, so nothing can broadcast it and no entry would ever drive the record + /// ([`Wallet::drop_unindexed_record_of_settled_intent`]). The record goes first: a bare intent + /// left behind is found and settled again, an orphaned record would not be. + async fn clear_persisted_intent bool>( + &self, payment_id: PaymentId, still_applies: F, + ) { + let still_applies = &still_applies; + let result: Result<(), Error> = async { + let mut remove_bare_record = false; + // The `move` closure would capture a plain `bool` by copy, so hand it a reference; the + // borrow ends with the mutate's future, before the flag is read below. + let removal_flag = &mut remove_bare_record; + self.pending_payment_store + .mutate(&payment_id, move |existing| { + let record = existing?; + match record.splice_intent() { + Some(intent) if still_applies(intent) => {}, + _ => return None, + } + let replacement = record_with_intent_cleared(record); + // A bare intent record cannot be cleared in place; it is removed below. + *removal_flag = replacement.is_none(); + replacement + }) + .await?; + if remove_bare_record { + self.wallet.drop_unindexed_record_of_settled_intent(payment_id).await?; + self.pending_payment_store + .remove_if(&payment_id, |record| { + record.details().is_none() + && record.splice_intent().is_some_and(still_applies) + }) + .await?; + } + Ok(()) + } + .await; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to clear the persisted intent of splice payment {}: a stale intent record \ + may be left behind: {}", + payment_id, + e, + ); + } + } + + /// Begins settling the recorded splice a failure event concerns, snapshotting the intent + /// `contribution` identifies among the channel's ([`record_of_failed_splice`]) — if any; a + /// failure of some other attempt (e.g. one superseded by a fee bump, whose failure LDK + /// reports separately) identifies nothing and settles nothing. The returned + /// [`FailureSettlement`] holds the submit lock until it is settled or dropped, so no new + /// splice can take the record in between: without it, a failure event could settle the intent + /// of an identical splice submitted while the event was being reported, or race `submit`'s + /// undo of a synchronously rejected hand-off. + /// + /// Settle only once the user-facing event is durably queued, and drop the settlement when + /// queueing fails: LDK then replays the failure event, and a cleared intent must mean the + /// failure was reported. + pub(crate) async fn on_negotiation_failed( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + contribution: Option<&FundingContribution>, + ) -> FailureSettlement<'_> { + let guard = self.submit_lock.lock().await; + let records = self.intent_records_for_channel(counterparty_node_id, channel_id).await; + let matched = record_of_failed_splice(&records, contribution); + FailureSettlement { tracker: self, _guard: guard, matched } + } + + /// Settles the persisted intents made obsolete by the channel's funding having moved on to + /// `funding_txo`, the funding a `ChannelReady` event reports as locked + /// ([`Self::settle_superseded_intents_locked`]). Takes the submit lock, so the settlement + /// cannot interleave with a splice being submitted. + pub(crate) async fn on_channel_ready( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + funding_txo: Option, + ) { + let Some(funding_txo) = funding_txo else { + return; + }; + let guard = self.submit_lock.lock().await; + let channel = self.channel(counterparty_node_id, channel_id); + self.settle_superseded_intents_locked( + &guard, + counterparty_node_id, + channel_id, + funding_txo, + channel.as_ref(), + ) + .await; + } + + /// Settles any persisted intent made obsolete by the channel's funding having moved on to + /// `funding_txo`: the funding a `ChannelReady` event reports as locked, or the one a new + /// splice builds on ([`Self::submit`]). Each of the channel's intents is decided on its own + /// ([`decide_on_lock`]), against the splice candidates LDK holds for the channel (`channel`, + /// as the caller listed it): one whose pre-splice outpoint is that funding was created after + /// the lock and stays; one LDK still holds as a queued splice candidate is re-anchored to the + /// funding it now builds on rather than settled; any other is settled, and what the wallet + /// holds for it is either spent by the locked round or returned by LDK through + /// `DiscardFunding`. The caller holds the submit lock. + async fn settle_superseded_intents_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, counterparty_node_id: PublicKey, + channel_id: ChannelId, funding_txo: OutPoint, channel: Option<&ChannelDetails>, + ) { + let records = self.intent_records_for_channel(counterparty_node_id, channel_id).await; + let candidates = channel + .and_then(|channel| channel.splice_details.as_ref()) + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]); + for record in records { + let payment_id = record.id(); + let Some(intent) = record.splice_intent().cloned() else { + continue; + }; + match decide_on_lock(&intent, funding_txo, candidates) { + LockDecision::Keep => {}, + LockDecision::Refresh => { + if let Some(new_funding_txo) = channel.and_then(|channel| channel.funding_txo) { + self.refresh_intent_funding(payment_id, &intent, new_funding_txo).await; + } + }, + LockDecision::Settle => { + // Nothing the wallet holds for the intent is released here. The inputs the + // locked round spent are gone with it, and whatever a superseded round reserved + // beyond them, LDK returns through the `DiscardFunding` events it queues at the + // promotion. A guard on the wallet's transaction graph could not tell the two + // apart: today the graph learns an interactive funding from sync alone. Once + // inputs are locked at coin selection (#1037), releasing them here would free + // the promoted round's inputs for a conflicting spend: #1037 prepares and + // unlocks only `Funding`-typed broadcasts, and a splice round is broadcast as + // `InteractiveFunding`. + self.clear_persisted_intent(payment_id, |i| *i == intent).await; + }, + } + } + } + + /// Re-anchors a still-live intent to the funding outpoint it now builds on, but only while + /// the record still carries the intent this decision was made for. + async fn refresh_intent_funding( + &self, payment_id: PaymentId, intent: &SpliceIntent, new_funding_txo: LdkOutPoint, + ) { + let refreshed = SpliceIntent { pre_splice_funding_txo: new_funding_txo, ..intent.clone() }; + let result = self + .pending_payment_store + .mutate(&payment_id, |existing| { + let mut record = existing?.clone(); + if record.splice_intent() != Some(intent) { + return None; + } + let update = PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(Some(refreshed)), + }; + record.update(update).then_some(record) + }) + .await; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to re-anchor the intent of queued splice payment {}: {}", + payment_id, + e, + ); + } + } + + /// Records the funding payment of a splice round this node has just signed but not yet handed + /// back to LDK, through [`Wallet::record_signed_funding`], so the record precedes any + /// broadcast: the counterparty cannot broadcast before receiving our `tx_signatures`, which + /// only [`ChannelManager::funding_transaction_signed`] releases. Holding the submit lock keeps + /// the channel's intent records — one of which the funding record adopts — from changing + /// mid-write: a concurrent [`Self::submit`] adding or replacing an intent, or a lock or + /// failure event settling one. + /// + /// [`ChannelManager::funding_transaction_signed`]: lightning::ln::channelmanager::ChannelManager::funding_transaction_signed + pub(crate) async fn on_funding_ready_for_signing( + &self, tx: &Transaction, candidates: &[FundingCandidate], + ) -> Result<(), Error> { + let _guard = self.submit_lock.lock().await; + self.wallet.record_signed_funding(tx, candidates).await + } + + /// Settles every persisted intent of a closed channel, as there is nothing left to splice. + /// Takes the submit lock, so the settlement cannot interleave with a splice being submitted. + /// Nothing the wallet holds for the intents is released here: a round the channel's monitor + /// watches may still confirm, and what LDK reserved for the others it returns through + /// `DiscardFunding` once the close matures. A signed round the monitor never watched — the + /// counterparty's `commitment_signed` never arrived — is released by neither. + // TODO(#1037): once inputs are locked at coin selection, such a round's inputs stay locked + // with no record to release them from after its intent is cleared here. Release the parts of + // the contribution no watched round uses before clearing. + pub(crate) async fn on_channel_closed( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) { + let _guard = self.submit_lock.lock().await; + for record in self.intent_records_for_channel(counterparty_node_id, channel_id).await { + self.clear_persisted_intent(record.id(), |_| true).await; + } + } + + /// Returns the pending records carrying a splice intent for the given channel: one per + /// splice of the channel still in flight, a fee bump sharing the record of the round it + /// replaces. + async fn intent_records_for_channel( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Vec { + self.pending_payment_store + .list_filter(|p| { + p.splice_intent().is_some_and(|i| { + i.channel_id == channel_id && i.counterparty_node_id == counterparty_node_id + }) + }) + .await + } + + /// The channel as LDK lists it, if it still does. + fn channel( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Option { + self.channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .into_iter() + .find(|channel| channel.channel_id == channel_id) + } +} + +/// The in-progress settlement of a splice failure, returned by +/// [`SpliceTracker::on_negotiation_failed`]. It snapshots the recorded intent the failure +/// identifies and holds the submit lock, so the record cannot change between the snapshot and +/// [`Self::settle`]. +pub(crate) struct FailureSettlement<'a> { + tracker: &'a SpliceTracker, + _guard: tokio::sync::MutexGuard<'a, ()>, + /// The record and intent the failure identifies, if any. + matched: Option<(PaymentId, SpliceIntent)>, +} + +impl FailureSettlement<'_> { + /// Settles the snapshotted intent, if any. Call only once the user-facing failure event is + /// durably queued. + pub(crate) async fn settle(self) { + let FailureSettlement { tracker, _guard, matched } = self; + if let Some((payment_id, intent)) = matched { + tracker.clear_persisted_intent(payment_id, move |i| *i == intent).await; + } + } +} + +/// Why a submission is refused once the channel's funding turns out to differ from the one the +/// caller built the contribution against, decided by [`check_submission`]. +#[derive(Debug, PartialEq, Eq)] +enum SubmissionRefusal { + /// LDK no longer lists the channel, or lists it without a funding. + ChannelGone, + /// The round a fee bump was built to replace has locked; there is nothing left to bump. + BumpedRoundLocked, + /// A splice locked while the splice-in's inputs were being selected, and may have spent + /// them. + InputsMayBeSpent, +} + +impl fmt::Display for SubmissionRefusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ChannelGone => write!(f, "the channel is gone or has no funding"), + Self::BumpedRoundLocked => { + write!(f, "the funding moved since the bump was built; its round has locked") + }, + Self::InputsMayBeSpent => write!( + f, + "the funding moved since the splice was built; the locked round may have spent \ + its inputs" + ), + } + } +} + +/// Whether a submission built against `requested_funding` may proceed now that the channel's +/// funding is `live_funding`, and at which funding to anchor its intent. A funding that has not +/// moved proceeds. One that has means a splice locked while the contribution was being built, +/// and only a splice-out proceeds, anchored at the live funding: it carries no wallet inputs, +/// and LDK re-validates its amount against the live balance. A fee bump was built to replace +/// that very round — a bump template is only offered for an unconfirmed, unlocked pending round +/// — and is refused rather than handed to LDK as a fresh splice reusing the locked round's +/// inputs. A splice-in is refused because its inputs were selected before the lock and may be +/// among those the locked round spent, which the wallet only learns from a sync: handed to LDK, +/// such a contribution would negotiate a splice whose transaction can never confirm, and neither +/// LDK nor this node would ever fail it. Refusing costs the caller one retry of a rare race. +// TODO(#1037): once inputs are locked from coin selection until the round is broadcast, and the +// round is applied to the wallet's transaction graph as it is broadcast, a wallet-selected input +// cannot be one a promoted round spent, and `InputsMayBeSpent` can go with its refusal. +// `BumpedRoundLocked` stays: a bump reuses the locked round's inputs. This needs the unlock and +// the graph insertion to happen together, as #1037's broadcast preparation does. +fn check_submission( + requested_funding: LdkOutPoint, live_funding: Option, kind: &SpliceKind, +) -> Result { + let live_funding = live_funding.ok_or(SubmissionRefusal::ChannelGone)?; + if live_funding == requested_funding { + return Ok(live_funding); + } + match kind { + SpliceKind::Rbf {} => Err(SubmissionRefusal::BumpedRoundLocked), + SpliceKind::In { .. } => Err(SubmissionRefusal::InputsMayBeSpent), + SpliceKind::Out { .. } => Ok(live_funding), + } +} + +/// The parts of `contribution` none of `claimants` uses: the inputs none of them spends, and the +/// outputs — change included — paying a script none of them pays. Outputs are matched by script +/// rather than as a whole, as LDK's `DiscardFunding` matches them: a fee-adjusted bump pays its +/// change to the same address as the round it replaces, at a different amount. +fn unclaimed_parts<'a>( + contribution: &FundingContribution, + claimants: impl IntoIterator, +) -> (Vec, Vec) { + let mut claimed_inputs: Vec = Vec::new(); + let mut claimed_scripts: Vec<&ScriptBuf> = Vec::new(); + for claimant in claimants { + claimed_inputs.extend(claimant.inputs().iter().map(|input| input.outpoint())); + claimed_scripts.extend( + claimant + .outputs() + .iter() + .chain(claimant.change_output()) + .map(|output| &output.script_pubkey), + ); + } + let inputs = contribution + .inputs() + .iter() + .map(|input| input.outpoint()) + .filter(|outpoint| !claimed_inputs.contains(outpoint)) + .collect(); + let outputs = contribution + .outputs() + .iter() + .chain(contribution.change_output()) + .filter(|output| !claimed_scripts.contains(&&output.script_pubkey)) + .cloned() + .collect(); + (inputs, outputs) +} + +/// Whether a pending record concerns the given channel's splices: it carries a splice intent for +/// the channel, or tracks an interactive funding payment of it. +fn concerns_channel( + record: &PendingPaymentDetails, counterparty_node_id: PublicKey, channel_id: ChannelId, +) -> bool { + if let Some(intent) = record.splice_intent() { + return intent.channel_id == channel_id + && intent.counterparty_node_id == counterparty_node_id; + } + match record.details().map(|details| &details.kind) { + Some(PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + }) => channels.iter().any(|channel| { + channel.channel_id == channel_id && channel.counterparty_node_id == counterparty_node_id + }), + _ => false, + } +} + +/// Where the intent of a new submission is recorded, decided by [`place_intent`]. +#[derive(Debug, PartialEq, Eq)] +enum IntentPlacement { + /// The channel carries an intent anchored at another funding, one [`SpliceTracker::submit`] + /// just failed to settle or to re-anchor; the submission is refused. + Refused, + /// The submission joins the given record. + Reuse(PaymentId), + /// The submission gets a record of its own. + Fresh, +} + +/// Decides where the intent of a new submission is recorded, given the channel's pending records +/// (`records`: those carrying an intent for the channel or tracking a funding payment of it) and +/// the txids of the splice rounds LDK holds for the channel (`held_rounds`, the pending rounds +/// with a transaction; not the funding). +/// +/// Every splice gets a record of its own, so that its failure is described from its own intent +/// and a restart recognizes it whatever became of the channel's other splices. A splice-in or +/// splice-out therefore always starts fresh: while a round of ours is pending and bumpable the +/// entry points refuse a new one, and a contribution LDK takes beside a pending splice — queued +/// behind it, or joining a round the counterparty is negotiating — is a splice of its own. Only +/// a fee bump joins an existing record, that of the round it replaces: the record tracking a +/// round LDK still holds — intent-less when an earlier bump failed and its settlement cleared +/// the intent — or else the channel's bare intent record, whose round negotiated but recorded +/// nothing (a splice-out to an external address). A bump joining a bare record shares its fate: +/// the bump's failure removes the record, so a later bump starts fresh. +fn place_intent( + intent: &SpliceIntent, records: &[PendingPaymentDetails], held_rounds: &[Txid], +) -> IntentPlacement { + let anchored_elsewhere = records.iter().any(|record| { + record + .splice_intent() + .is_some_and(|i| i.pre_splice_funding_txo != intent.pre_splice_funding_txo) + }); + if anchored_elsewhere { + return IntentPlacement::Refused; + } + match intent.kind { + SpliceKind::Rbf {} => { + let tracks_held_round = |record: &&PendingPaymentDetails| { + record.candidates().iter().any(|candidate| held_rounds.contains(&candidate.txid)) + }; + records + .iter() + .find(tracks_held_round) + .or_else(|| records.iter().find(|record| record.splice_intent().is_some())) + .map_or(IntentPlacement::Fresh, |record| IntentPlacement::Reuse(record.id())) + }, + SpliceKind::In { .. } | SpliceKind::Out { .. } => IntentPlacement::Fresh, + } +} + +/// The record, and its intent, of the splice a failure event identifies by `contribution` among +/// the channel's intent records: the one whose intent's contribution is the same attempt +/// ([`is_same_splice`]). A failure that reports no contribution identifies nothing, as does one +/// whose contribution matches no recorded intent — an attempt superseded by a fee bump, whose +/// failure LDK reports separately. +fn record_of_failed_splice( + records: &[PendingPaymentDetails], contribution: Option<&FundingContribution>, +) -> Option<(PaymentId, SpliceIntent)> { + let contribution = contribution?; + records.iter().find_map(|record| { + let intent = record.splice_intent()?; + is_same_splice(&intent.contribution, contribution).then(|| (record.id(), intent.clone())) + }) +} + +/// What a lock of the funding a channel has moved on to — or a new splice building on it — +/// means for one of the channel's recorded intents, decided by [`decide_on_lock`]. +#[derive(Debug, PartialEq, Eq)] +enum LockDecision { + /// The intent is anchored at that funding: its splice was submitted after the lock. + Keep, + /// LDK still holds the intent's contribution as a splice candidate — a splice queued behind + /// the one that locked, carried across the lock — so the intent is re-anchored to the new + /// funding. + Refresh, + /// The lock superseded the intent's splice: the splice locked, a replacement or a + /// counterparty splice locked instead, or the queued splice was failed at the lock. The + /// intent is settled. + /// + /// A queued splice fails at the lock when its contribution overlaps the promoted transaction. + /// LDK takes it out of the queue and reports the failure after the `ChannelReady` of the + /// lock, so the intent is settled here first and the failure surfaces without the splice's + /// parameters. The overlap check at queue time — against this node's own contributions to the + /// pending rounds — lets only a contribution naming an input or output the counterparty + /// contributed to the promoted round get this far, which this node's wallet does not produce. + Settle, +} + +/// Decides what the channel's funding having moved on to `funding_txo` means for `intent`, given +/// the splice candidates LDK holds for the channel. +fn decide_on_lock( + intent: &SpliceIntent, funding_txo: OutPoint, candidates: &[SpliceCandidateDetails], +) -> LockDecision { + if intent.pre_splice_funding_txo.into_bitcoin_outpoint() == funding_txo { + return LockDecision::Keep; + } + let still_held = candidates.iter().any(|candidate| { + candidate.contribution.as_ref().is_some_and(|c| is_same_splice(c, &intent.contribution)) + }); + if still_held { + LockDecision::Refresh + } else { + LockDecision::Settle + } +} + +/// The replacement for a pending record whose splice intent is being dropped. A tracked record +/// keeps its payment details with just the intent cleared. A bare intent record has nothing to +/// keep and is left for the caller to remove — never promoted over a payment record found under +/// its id, which is the first half of a write — for a round of ours, the signing write — that +/// never completed rather than a payment to keep graduating (see +/// [`SpliceTracker::clear_persisted_intent`]). +fn record_with_intent_cleared(existing: &PendingPaymentDetails) -> Option { + match existing { + PendingPaymentDetails::PendingSplice { .. } => None, + PendingPaymentDetails::Tracked { .. } => { + let mut tracked = existing.clone(); + let update = PendingPaymentDetailsUpdate { + id: tracked.id(), + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + tracked.update(update).then_some(tracked) + }, + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bitcoin::hashes::Hash; + use bitcoin::{Amount, Txid}; + + use super::*; + use crate::payment::pending_payment_store::{ + test_funding_contribution, test_funding_contribution_with_feerate, + test_funding_contribution_with_outputs, test_funding_contribution_with_parts, + FundingTxCandidate, + }; + use crate::payment::store::{ConfirmationStatus, PaymentDetails, PaymentKind}; + use crate::payment::{PaymentDirection, PaymentStatus}; + use lightning::ln::channel_state::SpliceCandidateStatus; + + fn test_intent() -> SpliceIntent { + SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([7u8; 32]), + pre_splice_funding_txo: LdkOutPoint { + txid: Txid::from_byte_array([3u8; 32]), + index: 0, + }, + contribution: test_funding_contribution(), + kind: SpliceKind::Rbf {}, + } + } + + fn payment_details(id: PaymentId, status: PaymentStatus) -> PaymentDetails { + PaymentDetails::new( + id, + PaymentKind::Onchain { + txid: Txid::from_byte_array([1u8; 32]), + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(1_000_000), + Some(500), + PaymentDirection::Outbound, + status, + ) + } + + /// A bare intent entry has nothing to keep once its intent is cleared: it is removed rather + /// than promoted, whatever the payment store holds under its id — a payment record there is + /// the first half of a signing write that never completed, which the caller removes as well. + #[test] + fn intent_clearing_removes_a_bare_intent_entry() { + let id = PaymentId([9u8; 32]); + let existing = PendingPaymentDetails::pending_splice(id, test_intent()); + assert!(record_with_intent_cleared(&existing).is_none()); + } + + /// A tracked record keeps its payment details; only the intent is cleared. + #[test] + fn intent_clearing_keeps_a_tracked_record() { + let id = PaymentId([9u8; 32]); + let details = payment_details(id, PaymentStatus::Pending); + let existing = PendingPaymentDetails::tracked( + details.clone(), + Vec::new(), + Vec::new(), + Some(test_intent()), + ); + + let replacement = record_with_intent_cleared(&existing); + let replacement = replacement.expect("the entry must survive with its intent cleared"); + assert_eq!(replacement.details(), Some(&details)); + assert!(replacement.splice_intent().is_none()); + } + + #[test] + fn contributions_match_by_inputs_and_outputs() { + use bitcoin::{ScriptBuf, TxOut}; + + let outputs = + vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: ScriptBuf::new() }]; + // Fee fields differ, inputs and outputs agree: the same attempt. LDK may adjust a + // contribution during negotiation — the quiescence tie-breaker rebuilds the acceptor's + // copy at a fresh feerate — and events then carry the adjusted copy, which must still + // identify the recorded splice. + let a = test_funding_contribution_with_outputs(0, 253, &outputs); + let b = test_funding_contribution_with_outputs(0, 500, &outputs); + assert!(is_same_splice(&a, &b)); + + // Different outputs are a different attempt. + let other = vec![TxOut { value: Amount::from_sat(2_000), script_pubkey: ScriptBuf::new() }]; + assert!(!is_same_splice(&a, &test_funding_contribution_with_outputs(0, 253, &other))); + + // Contributions moving nothing (no inputs, no outputs) only match themselves exactly. + assert!(is_same_splice(&test_funding_contribution(), &test_funding_contribution())); + assert!(!is_same_splice( + &test_funding_contribution(), + &test_funding_contribution_with_feerate(500) + )); + } + + fn intent_with( + kind: SpliceKind, funding_byte: u8, contribution: FundingContribution, + ) -> SpliceIntent { + SpliceIntent { + pre_splice_funding_txo: LdkOutPoint { + txid: Txid::from_byte_array([funding_byte; 32]), + index: 0, + }, + contribution, + kind, + ..test_intent() + } + } + + fn splice_out_contribution(value_sat: u64) -> FundingContribution { + use bitcoin::{ScriptBuf, TxOut}; + let outputs = + vec![TxOut { value: Amount::from_sat(value_sat), script_pubkey: ScriptBuf::new() }]; + test_funding_contribution_with_outputs(300, 253, &outputs) + } + + /// A tracked record of the test channel whose funding payment names `txid` and whose history + /// lists `candidates`, carrying `intent` if any. + fn tracked_record( + id: PaymentId, txid: Txid, candidates: &[Txid], intent: Option, + ) -> PendingPaymentDetails { + use crate::payment::store::Channel; + let base = test_intent(); + let details = PaymentDetails::new( + id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { + channels: vec![Channel { + counterparty_node_id: base.counterparty_node_id, + channel_id: base.channel_id, + }], + }), + }, + Some(1_000_000), + Some(500), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + let candidates = candidates + .iter() + .map(|txid| FundingTxCandidate { + txid: *txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }) + .collect(); + PendingPaymentDetails::tracked(details, Vec::new(), candidates, intent) + } + + fn txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + /// A splice-in or splice-out is a splice of its own, whatever the channel already carries: + /// a pending splice's bare intent, or the tracked record of its rounds. + #[test] + fn a_splice_in_or_out_gets_a_record_of_its_own() { + let pending = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(1_000)); + let records = vec![ + PendingPaymentDetails::pending_splice(PaymentId([1u8; 32]), pending.clone()), + tracked_record(PaymentId([2u8; 32]), txid(0x10), &[txid(0x10)], Some(pending)), + ]; + let held = [txid(0x10)]; + + let splice_in = + intent_with(SpliceKind::In { amount_sats: 5_000 }, 3, test_funding_contribution()); + assert_eq!(place_intent(&splice_in, &records, &held), IntentPlacement::Fresh); + let splice_out = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(2_000)); + assert_eq!(place_intent(&splice_out, &records, &held), IntentPlacement::Fresh); + assert_eq!(place_intent(&splice_out, &[], &[]), IntentPlacement::Fresh); + } + + /// A fee bump joins the record of the round it replaces: the one tracking a round LDK still + /// holds, even when that record carries no intent any more and the channel also carries a + /// bare intent. + #[test] + fn a_bump_joins_the_record_tracking_a_held_round() { + let bump = intent_with(SpliceKind::Rbf {}, 3, test_funding_contribution()); + let bare_id = PaymentId([1u8; 32]); + let tracked_id = PaymentId([2u8; 32]); + let records = vec![ + PendingPaymentDetails::pending_splice( + bare_id, + intent_with( + SpliceKind::Out { outputs: Vec::new() }, + 3, + splice_out_contribution(1_000), + ), + ), + tracked_record(tracked_id, txid(0x11), &[txid(0x10), txid(0x11)], None), + ]; + assert_eq!( + place_intent(&bump, &records, &[txid(0x11)]), + IntentPlacement::Reuse(tracked_id) + ); + + // The tracked record of a splice that already locked — its funding is no held round — is + // not the bump's; the bare intent of the round LDK negotiated but the wallet did not record + // is. + assert_eq!(place_intent(&bump, &records, &[]), IntentPlacement::Reuse(bare_id)); + + // With neither, the bump starts fresh. + let locked_only = vec![tracked_record(tracked_id, txid(0x11), &[txid(0x11)], None)]; + assert_eq!(place_intent(&bump, &locked_only, &[]), IntentPlacement::Fresh); + } + + /// An intent anchored at another funding is one the lock handling failed to settle or to + /// re-anchor; nothing is recorded beside it, whatever the new splice's kind and whatever + /// else the channel carries. + #[test] + fn an_intent_anchored_elsewhere_refuses_every_kind() { + let stale = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 4, splice_out_contribution(1_000)); + let current = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(2_000)); + let records = vec![ + PendingPaymentDetails::pending_splice(PaymentId([1u8; 32]), current), + PendingPaymentDetails::pending_splice(PaymentId([2u8; 32]), stale), + ]; + for kind in [ + SpliceKind::In { amount_sats: 5_000 }, + SpliceKind::Out { outputs: Vec::new() }, + SpliceKind::Rbf {}, + ] { + let intent = intent_with(kind, 3, test_funding_contribution()); + assert_eq!(place_intent(&intent, &records, &[]), IntentPlacement::Refused); + } + } + + /// A failure identifies the record whose intent carries the failed contribution — fee fields + /// aside — among the channel's; one reporting no contribution, or a contribution of no + /// recorded intent, identifies nothing. + #[test] + fn a_failure_identifies_the_record_carrying_its_contribution() { + let first = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(1_000)); + let second = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(2_000)); + let (first_id, second_id) = (PaymentId([1u8; 32]), PaymentId([2u8; 32])); + let records = vec![ + PendingPaymentDetails::pending_splice(first_id, first.clone()), + tracked_record(second_id, txid(0x10), &[txid(0x10)], Some(second.clone())), + ]; + + let adjusted = { + use bitcoin::{ScriptBuf, TxOut}; + let outputs = + vec![TxOut { value: Amount::from_sat(2_000), script_pubkey: ScriptBuf::new() }]; + test_funding_contribution_with_outputs(900, 1_000, &outputs) + }; + assert_eq!(record_of_failed_splice(&records, Some(&adjusted)), Some((second_id, second))); + assert_eq!( + record_of_failed_splice(&records, Some(&first.contribution)), + Some((first_id, first)) + ); + assert_eq!(record_of_failed_splice(&records, Some(&splice_out_contribution(3_000))), None); + assert_eq!(record_of_failed_splice(&records, None), None); + } + + /// A lock keeps an intent anchored at the locked funding, re-anchors one LDK still holds as a + /// candidate, and settles any other. + #[test] + fn a_lock_keeps_refreshes_or_settles_an_intent() { + let intent = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(1_000)); + let same_funding = intent.pre_splice_funding_txo.into_bitcoin_outpoint(); + let new_funding = OutPoint { txid: txid(0x20), vout: 0 }; + let held = [SpliceCandidateDetails { + contribution: Some(splice_out_contribution(1_000)), + status: SpliceCandidateStatus::WaitingOnLock, + }]; + let other = [ + SpliceCandidateDetails { + contribution: Some(splice_out_contribution(2_000)), + status: SpliceCandidateStatus::WaitingOnLock, + }, + SpliceCandidateDetails { + contribution: None, + status: SpliceCandidateStatus::WaitingOnLock, + }, + ]; + + assert_eq!(decide_on_lock(&intent, same_funding, &other), LockDecision::Keep); + assert_eq!(decide_on_lock(&intent, new_funding, &held), LockDecision::Refresh); + assert_eq!(decide_on_lock(&intent, new_funding, &other), LockDecision::Settle); + assert_eq!(decide_on_lock(&intent, new_funding, &[]), LockDecision::Settle); + } + + /// A submission proceeds at the funding it was built against while that is still the + /// channel's. Once the funding moved, only a splice-out proceeds, anchored at the live + /// funding; a bump and a splice-in are refused, as is any submission for a channel LDK no + /// longer lists with a funding. + #[test] + fn a_submission_is_checked_against_the_live_funding() { + let requested = LdkOutPoint { txid: txid(0x30), index: 0 }; + let moved = LdkOutPoint { txid: txid(0x31), index: 0 }; + let kinds = [ + SpliceKind::In { amount_sats: 5_000 }, + SpliceKind::Out { outputs: Vec::new() }, + SpliceKind::Rbf {}, + ]; + for kind in &kinds { + assert_eq!(check_submission(requested, Some(requested), kind), Ok(requested)); + assert_eq!( + check_submission(requested, None, kind), + Err(SubmissionRefusal::ChannelGone) + ); + } + assert_eq!( + check_submission(requested, Some(moved), &SpliceKind::Rbf {}), + Err(SubmissionRefusal::BumpedRoundLocked) + ); + assert_eq!( + check_submission(requested, Some(moved), &SpliceKind::In { amount_sats: 5_000 }), + Err(SubmissionRefusal::InputsMayBeSpent) + ); + assert_eq!( + check_submission(requested, Some(moved), &SpliceKind::Out { outputs: Vec::new() }), + Ok(moved) + ); + } + + /// The records concerning a channel's splices are those carrying an intent for it and those + /// tracking an interactive funding of it; records of other channels and of other payments are + /// not. + #[test] + fn records_concerning_a_channel() { + let base = test_intent(); + let (cp, channel_id) = (base.counterparty_node_id, base.channel_id); + let id = PaymentId([1u8; 32]); + assert!(concerns_channel( + &PendingPaymentDetails::pending_splice(id, base.clone()), + cp, + channel_id + )); + assert!(concerns_channel( + &tracked_record(id, txid(0x10), &[txid(0x10)], None), + cp, + channel_id + )); + + let other_channel = SpliceIntent { channel_id: ChannelId([8u8; 32]), ..base }; + assert!(!concerns_channel( + &PendingPaymentDetails::pending_splice(id, other_channel), + cp, + channel_id + )); + assert!(!concerns_channel( + &tracked_record(id, txid(0x10), &[], None), + cp, + ChannelId([8u8; 32]) + )); + let plain = PendingPaymentDetails::new( + payment_details(id, PaymentStatus::Pending), + Vec::new(), + Vec::new(), + ); + assert!(!concerns_channel(&plain, cp, channel_id)); + } + + fn negotiated_candidate(contribution: Option) -> SpliceCandidateDetails { + SpliceCandidateDetails { + contribution, + status: SpliceCandidateStatus::Negotiated { + txid: Txid::from_byte_array([9u8; 32]), + new_channel_value_satoshis: 100_000, + }, + } + } + + /// A previous transaction with a P2WPKH output at index 0 for a contribution input to spend; + /// `seed` varies the output script, and with it the txid. + fn test_prevtx(seed: u8) -> Transaction { + use bitcoin::WPubkeyHash; + + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + } + } + + /// Releasing a contribution spares the parts another contribution uses as well: a fee bump + /// built by adjusting the fee of the round it replaces shares that round's inputs and change + /// address — the change differing in amount only — so against that round nothing is released; + /// against a candidate using only some of the parts, the rest is, a counterparty-only round + /// alongside claiming nothing; against no other contribution, everything is. + #[test] + fn unclaimed_parts_spare_what_other_contributions_use() { + use bitcoin::WPubkeyHash; + + let prevtxs: Vec = (1u8..=3).map(test_prevtx).collect(); + let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 }; + let script = |seed: u8| ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])); + let change = |sats: u64| TxOut { value: Amount::from_sat(sats), script_pubkey: script(9) }; + let splice_out = TxOut { value: Amount::from_sat(50_000), script_pubkey: script(8) }; + let bump = test_funding_contribution_with_parts( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change(20_000)), + ); + + let prior = test_funding_contribution_with_parts( + 0, + 253, + &prevtxs, + &[splice_out.clone()], + Some(&change(21_000)), + ); + assert_eq!(unclaimed_parts(&bump, [&prior]), (Vec::new(), Vec::new())); + + let partial = + test_funding_contribution_with_parts(0, 253, &prevtxs[..2], &[], Some(&change(21_000))); + let candidates = [negotiated_candidate(None), negotiated_candidate(Some(partial))]; + let claimants = candidates.iter().filter_map(|candidate| candidate.contribution.as_ref()); + assert_eq!( + unclaimed_parts(&bump, claimants), + (vec![outpoint(&prevtxs[2])], vec![splice_out.clone()]) + ); + + assert_eq!( + unclaimed_parts(&bump, []), + (prevtxs.iter().map(outpoint).collect(), vec![splice_out, change(20_000)]) + ); + } +} diff --git a/src/data_store.rs b/src/data_store.rs index bdd190621..747e0b7ed 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -389,6 +389,44 @@ where Ok(()) } + /// Removes the object stored under `id` only while `predicate` holds for it. The read, the + /// predicate, and the removal share one critical section of the mutation lock, so a + /// concurrent write cannot land in between and be deleted by mistake — unlike a separate + /// [`Self::get`] followed by [`Self::remove`]. Returns whether the object was removed. + pub(crate) async fn remove_if bool>( + &self, id: &SO::Id, predicate: F, + ) -> Result { + let _guard = self.mutation_lock.write().await; + + match self.lookup(id).await? { + Some(object) if predicate(&object) => {}, + _ => return Ok(false), + } + + let store_key = id.encode_to_hex_str(); + KVStore::remove( + &*self.kv_store, + &self.primary_namespace, + &self.secondary_namespace, + &store_key, + false, + ) + .await + .map_err(|e| { + log_error!( + self.logger, + "Removing object data for key {}/{}/{} failed due to: {}", + &self.primary_namespace, + &self.secondary_namespace, + store_key, + e + ); + Error::PersistenceFailed + })?; + self.cache.lock().expect("lock").remove(id); + Ok(true) + } + /// Returns the object stored under `id`, if any. pub(crate) async fn get(&self, id: &SO::Id) -> Result, Error> { let _guard = self.mutation_lock.read().await; @@ -1163,6 +1201,36 @@ mod tests { .is_ok()); } + #[tokio::test] + async fn remove_if_only_removes_while_the_predicate_holds() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let id = TestObjectId { id: [42u8; 4] }; + let existing_object = TestObject::new(id, [23u8; 3]); + let data_store: DataStore> = DataStore::new( + vec![existing_object], + KeepAllEntries, + TEST_PRIMARY_NAMESPACE.to_string(), + TEST_SECONDARY_NAMESPACE.to_string(), + store, + logger, + ); + + // A failed predicate — the entry no longer looks like what the caller decided to delete — + // must leave the entry in place. + let result = data_store.remove_if(&id, |object| object.data != existing_object.data).await; + assert_eq!(Ok(false), result); + assert_eq!(Some(existing_object), data_store.get(&id).await.unwrap()); + + let result = data_store.remove_if(&id, |object| object.data == existing_object.data).await; + assert_eq!(Ok(true), result); + assert!(data_store.get(&id).await.unwrap().is_none()); + + // An absent entry is not an error; there is just nothing to remove. + let result = data_store.remove_if(&id, |_| true).await; + assert_eq!(Ok(false), result); + } + #[tokio::test] async fn mutate_transforms_existing_entry() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/event.rs b/src/event.rs index 4eac302ea..24dd48fc7 100644 --- a/src/event.rs +++ b/src/event.rs @@ -36,6 +36,7 @@ use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_types::payment::{PaymentHash, PaymentPreimage}; +use crate::channel::SpliceTracker; use crate::config::{may_announce_channel, Config, PEER_RECONNECTION_INTERVAL}; use crate::connection::ConnectionManager; use crate::data_store::DataStoreUpdateResult; @@ -575,6 +576,7 @@ where onion_messenger: Arc, om_mailbox: Option>, prober: Option>, + splice_tracker: Arc, runtime: Arc, logger: L, config: Arc, @@ -594,7 +596,7 @@ where peer_store: Arc>, keys_manager: Arc, static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, prober: Option>, - runtime: Arc, logger: L, config: Arc, + splice_tracker: Arc, runtime: Arc, logger: L, config: Arc, ) -> Self { Self { event_queue, @@ -614,6 +616,7 @@ where onion_messenger, om_mailbox, prober, + splice_tracker, runtime, logger, config, @@ -1952,6 +1955,10 @@ where .handle_channel_ready(user_channel_id, &channel_id, &counterparty_node_id) .await; + self.splice_tracker + .on_channel_ready(counterparty_node_id, channel_id, funding_txo) + .await; + let event = Event::ChannelReady { channel_id, user_channel_id: UserChannelId(user_channel_id), @@ -2014,6 +2021,8 @@ where let counterparty_node_id = counterparty_node_id .expect("counterparty_node_id is always set since LDK 0.0.117"); + self.splice_tracker.on_channel_closed(counterparty_node_id, channel_id).await; + // Drop the peer once its last channel with us has reached a terminal state. // For `HolderForceClosed`, retain it through one recovery reconnect so that // `channel_reestablish` can retransmit the force-close error before cleanup. @@ -2328,13 +2337,16 @@ where // `funding_transaction_signed` releases them to the counterparty, after which // either party may broadcast — and wallet sync could observe the transaction // before this node has recorded it. The record is written from the channel's - // pending splice history, and the round's broadcast adds nothing to it. On a - // failed write, replay rather than proceed unrecorded: LDK re-offers the event - // in-session and regenerates it across restarts while the transaction is - // unsigned. + // pending splice history through the splice tracker, whose lock keeps the + // channel's intent record from changing hands mid-write, and the round's + // broadcast adds nothing to it. On a failed write, replay rather than proceed + // unrecorded: LDK re-offers the event in-session and regenerates it across + // restarts while the transaction is unsigned. let candidates = self.pending_splice_rounds(counterparty_node_id, channel_id); - if let Err(e) = - self.wallet.record_signed_funding(&partially_signed_tx, &candidates).await + if let Err(e) = self + .splice_tracker + .on_funding_ready_for_signing(&partially_signed_tx, &candidates) + .await { log_error!( self.logger, @@ -2429,6 +2441,7 @@ where channel_id, user_channel_id, counterparty_node_id, + contribution, .. } => { log_info!( @@ -2440,12 +2453,13 @@ where // A round this node signed was recorded when signing; if the failed round was // among them, nothing can broadcast it anymore, so take its record back. The - // rounds LDK still holds tell which recorded ones it abandoned (a contribution - // can fail while an earlier signed round still awaits its signatures). A closed - // channel is left to its `ChannelClosed` event: LDK queues one for every channel it - // removes — before the failures a force-close reports, after the one a cooperative - // close reports — and that event carries the channel's last funding, which this - // handler can no longer read from the channel. + // splice intent the record carried stays behind as a bare intent for the report + // below. The rounds LDK still holds tell which recorded ones it abandoned (a + // contribution can fail while an earlier signed round still awaits its + // signatures). A closed channel is left to its `ChannelClosed` event: LDK queues + // one for every channel it removes — before the failures a force-close reports, + // after the one a cooperative close reports — and that event carries the + // channel's last funding, which this handler can no longer read from the channel. if let Some(held_rounds) = self.held_splice_rounds(counterparty_node_id, channel_id) { if let Err(e) = @@ -2462,6 +2476,14 @@ where } } + // Snapshot the recorded splice this failure concerns; the settlement keeps the + // channel's record from changing hands until the report is settled below. + let contribution = contribution.map(|c| c.into_contribution()); + let settlement = self + .splice_tracker + .on_negotiation_failed(counterparty_node_id, channel_id, contribution.as_ref()) + .await; + let event = Event::SpliceNegotiationFailed { channel_id, user_channel_id: UserChannelId(user_channel_id), @@ -2471,10 +2493,17 @@ where match self.event_queue.add_event(event).await { Ok(_) => {}, Err(e) => { + // Dropping the settlement leaves the intent in place for the replayed + // event to settle. log_error!(self.logger, "Failed to push to event queue: {}", e); return Err(ReplayEvent()); }, }; + + // Settle the failed splice's persisted intent only now that the report is + // durably queued: a crash in between replays this event, which must still find + // the intent to settle. + settlement.settle().await; }, } Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 43cf6d5d6..711772a2d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -91,6 +91,7 @@ compile_error!("at least one chain source feature must be enabled"); mod balance; mod builder; mod chain; +mod channel; pub mod config; mod connection; mod data_store; @@ -132,6 +133,7 @@ pub use bitcoin::FeeRate; use bitcoin::{Address, Amount, BlockHash, Network}; pub use builder::{BuildError, Builder}; use chain::ChainSource; +use channel::SpliceTracker; use config::{ default_user_config, may_announce_channel, AsyncPaymentsRole, ChannelConfig, Config, LNURL_AUTH_TIMEOUT_SECS, NODE_ANN_BCAST_INTERVAL, PEER_RECONNECTION_INTERVAL, @@ -177,6 +179,7 @@ use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; pub use payment::forwarding_store::aggregate_channel_pair_stats; use payment::forwarding_store::{run_forwarded_payment_aggregation, ForwardingStore}; +use payment::pending_payment_store::SpliceKind; use payment::{ Bolt11Payment, Bolt12Payment, ForwardingAnalytics, OnchainPayment, PaymentDetails, PaymentDetailsPage, SpontaneousPayment, @@ -275,6 +278,7 @@ pub struct Node { payment_store: Arc, forwarding_store: Arc, forwarded_payment_aggregation_retention_secs: u64, + splice_tracker: Arc, lnurl_auth: Arc, is_running: Arc>, node_metrics: Arc, @@ -714,6 +718,7 @@ impl Node { Arc::clone(&self.onion_messenger), self.om_mailbox.clone(), self.prober.clone(), + Arc::clone(&self.splice_tracker), Arc::clone(&self.runtime), Arc::clone(&self.logger), Arc::clone(&self.config), @@ -1740,6 +1745,14 @@ impl Node { if let Some(channel_details) = open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) { + // The channel's current funding outpoint anchors the persisted splice intent, and a + // channel without one is not ready to splice: check before any contribution is + // built, so nothing is reserved for a splice that cannot be submitted. + let pre_splice_funding_txo = channel_details.funding_txo.ok_or_else(|| { + log_error!(self.logger, "Failed to splice channel: channel not yet ready"); + Error::ChannelSplicingFailed + })?; + let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); let max_feerate = max_funding_feerate(min_feerate); @@ -1753,18 +1766,13 @@ impl Node { const EMPTY_SCRIPT_SIG_WEIGHT: u64 = 1 /* empty script_sig */ * bitcoin::constants::WITNESS_SCALE_FACTOR as u64; - let funding_txo = channel_details.funding_txo.ok_or_else(|| { - log_error!(self.logger, "Failed to splice channel: channel not yet ready",); - Error::ChannelSplicingFailed - })?; - let funding_output = channel_details.get_funding_output().ok_or_else(|| { log_error!(self.logger, "Failed to splice channel: channel not yet ready"); Error::ChannelSplicingFailed })?; let shared_input = Input { - outpoint: funding_txo.into_bitcoin_outpoint(), + outpoint: pre_splice_funding_txo.into_bitcoin_outpoint(), previous_utxo: funding_output.clone(), satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, @@ -1826,6 +1834,10 @@ impl Node { _ => min_feerate, }; + // TODO(#1037): the inputs are locked from coin selection on, and a failure of the + // build after it — LDK validating the selected inputs — returns here with nothing + // releasing them; `submit`'s own failure paths release them or leave them to + // `DiscardFunding`. let contribution = self .runtime .block_on(funding_template.splice_in( @@ -1839,16 +1851,18 @@ impl Node { Error::ChannelSplicingFailed })?; - self.channel_manager - .funding_contributed( - &channel_details.channel_id, - &counterparty_node_id, + self.runtime + .block_on(self.splice_tracker.submit( + counterparty_node_id, + channel_details.channel_id, + pre_splice_funding_txo, contribution, + SpliceKind::In { amount_sats: splice_amount_sats }, None, - ) + )) .map_err(|e| { log_error!(self.logger, "Failed to splice channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( @@ -1867,6 +1881,10 @@ impl Node { /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. /// + /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice + /// may be initiated once the cause of the failure is addressed. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but @@ -1891,6 +1909,10 @@ impl Node { /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. /// + /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice + /// may be initiated once the cause of the failure is addressed. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but @@ -1907,6 +1929,10 @@ impl Node { /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. /// + /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice + /// may be initiated once the cause of the failure is addressed. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-out will be marked as an inbound payment if @@ -1921,6 +1947,14 @@ impl Node { if let Some(channel_details) = open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) { + // The channel's current funding outpoint anchors the persisted splice intent, and a + // channel without one is not ready to splice: check before any contribution is + // built, so nothing is reserved for a splice that cannot be submitted. + let pre_splice_funding_txo = channel_details.funding_txo.ok_or_else(|| { + log_error!(self.logger, "Failed to splice channel: channel not yet ready"); + Error::ChannelSplicingFailed + })?; + let splice_amount_msat = splice_amount_sats.checked_mul(1_000).ok_or(Error::ChannelSplicingFailed)?; if splice_amount_msat > channel_details.outbound_capacity_msat { @@ -1963,22 +1997,25 @@ impl Node { value: Amount::from_sat(splice_amount_sats), script_pubkey: address.script_pubkey(), }]; - let contribution = - funding_template.splice_out(outputs, feerate, max_feerate).map_err(|e| { - log_error!(self.logger, "Failed to splice channel: {}", e); - Error::ChannelSplicingFailed - })?; + let contribution = funding_template + .splice_out(outputs.clone(), feerate, max_feerate) + .map_err(|e| { + log_error!(self.logger, "Failed to splice channel: {}", e); + Error::ChannelSplicingFailed + })?; - self.channel_manager - .funding_contributed( - &channel_details.channel_id, - &counterparty_node_id, + self.runtime + .block_on(self.splice_tracker.submit( + counterparty_node_id, + channel_details.channel_id, + pre_splice_funding_txo, contribution, + SpliceKind::Out { outputs }, None, - ) + )) .map_err(|e| { log_error!(self.logger, "Failed to splice channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( @@ -1994,6 +2031,10 @@ 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. + /// + /// A fee bump that fails during negotiation (e.g. because the peer disconnected) is reported + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; the fee may + /// be bumped again once the cause of the failure is addressed. pub fn bump_channel_funding_fee( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, ) -> Result<(), Error> { @@ -2002,6 +2043,14 @@ impl Node { if let Some(channel_details) = open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) { + // The channel's current funding outpoint anchors the persisted splice intent, and a + // channel without one is not ready to splice: check before any contribution is + // built, so nothing is reserved for a splice that cannot be submitted. + let pre_splice_funding_txo = channel_details.funding_txo.ok_or_else(|| { + log_error!(self.logger, "Failed to RBF channel: channel not yet ready"); + Error::ChannelSplicingFailed + })?; + let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); @@ -2028,6 +2077,12 @@ impl Node { return Err(Error::ChannelSplicingFailed); }; + // The round the bump replaces: a bump that only adjusts its fee reuses its inputs and + // change address, which a failed submission must not release. + let prior_contribution = funding_template.prior_contribution().cloned(); + // TODO(#1037): a bump that re-selects its inputs locks them from coin selection on, + // and a failure of the build after it returns here with nothing releasing them; + // `submit`'s own failure paths release them or leave them to `DiscardFunding`. let contribution = self .runtime .block_on(funding_template.rbf_prior_contribution( @@ -2040,16 +2095,18 @@ impl Node { Error::ChannelSplicingFailed })?; - self.channel_manager - .funding_contributed( - &channel_details.channel_id, - &counterparty_node_id, + self.runtime + .block_on(self.splice_tracker.submit( + counterparty_node_id, + channel_details.channel_id, + pre_splice_funding_txo, contribution, - None, - ) + SpliceKind::Rbf {}, + prior_contribution, + )) .map_err(|e| { log_error!(self.logger, "Failed to RBF channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 37c02fa14..560985c49 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -128,10 +128,10 @@ pub(crate) enum PendingPaymentDetails { /// Each field is written by a different subsystem: wallet sync records `conflicting_txids` /// for any wallet transaction (splice fundings included), the signing-time recording /// records `candidates` for interactive funding, the `ChannelReady` arm records - /// `locked_rounds`, and `splice_intent` is carried over from a [`PendingSplice`] record when - /// the payment is promoted — nothing persists an intent at splice initiation yet; that lands - /// with the splice tracking built on this. A splice uses all of them; the fields do not - /// partition by payment type. + /// `locked_rounds`, and `splice_intent` is owned by the splice entry points and the splice + /// tracker — persisted at splice initiation, carried over from a [`PendingSplice`] record + /// when the payment is promoted, and cleared once the splice locks or its failure is + /// surfaced. A splice uses all of them; the fields do not partition by payment type. /// /// [`PendingSplice`]: Self::PendingSplice Tracked { @@ -184,6 +184,10 @@ impl PendingPaymentDetails { } } + pub(crate) fn pending_splice(id: PaymentId, intent: SpliceIntent) -> Self { + Self::PendingSplice { id, intent } + } + /// The full payment details, or `None` for a splice not yet broadcast. pub(crate) fn details(&self) -> Option<&PaymentDetails> { match self { @@ -381,12 +385,17 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { } else { Some(conflicting_txids.clone()) }; + // Leave the splice intent unchanged: it is owned by the splice entry points and the + // splice tracker, never by a payment-tracking merge. Emitting the current value + // here would let an `insert_or_update` of a payment record (e.g. from wallet sync, + // built without an intent) clobber a live intent to `None`. + let _ = splice_intent; Self { id: details.id, payment_update: Some(details.to_update()), conflicting_txids, candidates: candidates.clone(), - splice_intent: Some(splice_intent.clone()), + splice_intent: None, } }, } @@ -728,6 +737,44 @@ mod tests { assert_eq!(merged_details.fee_paid_msat, Some(100)); } + fn test_intent() -> SpliceIntent { + use std::str::FromStr; + + SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution: test_funding_contribution(), + kind: SpliceKind::In { amount_sats: 500_000 }, + } + } + + #[test] + fn payment_tracking_merge_preserves_a_live_splice_intent() { + let payment_id = PaymentId([7u8; 32]); + let txid = test_txid(8); + let intent = test_intent(); + let mut record = PendingPaymentDetails::tracked( + pending_onchain_payment(payment_id, txid), + Vec::new(), + Vec::new(), + Some(intent.clone()), + ); + + // Wallet sync merges its view of a transaction through `to_update()` of a fresh record, + // which is built without an intent; the merge must leave the live intent in place. + let fresh = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, txid), + vec![test_txid(9)], + Vec::new(), + ); + assert!(record.update(fresh.to_update())); + assert_eq!(record.splice_intent(), Some(&intent)); + } + #[test] fn splice_kind_round_trips() { for kind in [ diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1875c97b8..18fa20a2c 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -56,13 +56,14 @@ use lightning::util::wallet_utils::{ use lightning_invoice::RawBolt11Invoice; use persist::KVStoreWalletPersister; +use crate::channel::is_same_splice; use crate::config::{Config, ADDRESS_POOL_SIZE}; #[cfg(test)] use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed}; use crate::data_store::{StorableObject, UpdatableObject}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; use crate::logger::{log_debug, log_error, log_info, log_trace, log_warn, LdkLogger, Logger}; -use crate::payment::pending_payment_store::PendingPaymentDetailsUpdate; +use crate::payment::pending_payment_store::{PendingPaymentDetailsUpdate, SpliceIntent}; use crate::payment::store::{ConfirmationStatus, PaymentDetailsUpdate}; use crate::payment::{ FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, @@ -423,7 +424,19 @@ impl Wallet { let mut unconfirmed_outbound_txids: Vec = Vec::new(); for payment in pending_payments { - // The filter admits only Tracked funding payments. + // The filter admits only Tracked funding payments. A splice intent such a + // record carries — there is one record per splice, so only the intent of + // the round it tracks or of a fee bump of it — goes with the entry when + // the payment graduates: the lock and the graduation both follow the + // confirmation of the round the record tracks, so a lock handled after + // the graduation finds no intent to settle, and one handled before it + // leaves a record whose intent is already cleared. + // TODO(#1037): once inputs are locked, the graduated round's locks sit on + // spent outpoints: #1037 releases nothing for a splice at broadcast, since + // it prepares only `Funding`-typed packages, until the `InteractiveFunding` + // broadcast arm applies the round and unlocks its inputs. A bump's extra + // inputs return through the `DiscardFunding` LDK queues at the lock, once + // that handler passes the inputs to `cancel_tx`. let PendingPaymentDetails::Tracked { ref details, .. } = payment else { continue; }; @@ -826,10 +839,12 @@ impl Wallet { } /// Fails the funding payment `payment_id` while its record still waits on the unconfirmed - /// funding transaction `record_txid`, and removes its pending entry, reporting what it did. As - /// with graduation, the decision is made from the live record and only the status is written. - /// A record already `Failed` — a prior pass whose entry removal was lost to a crash — still - /// matches, no-ops the update, and gets its lingering entry removed. + /// funding transaction `record_txid`, and removes its pending entry — keeping a splice intent + /// it carries as a bare intent under an id of its own, which the splice tracker settles — + /// reporting what it did. As with graduation, the decision is made from the live record and + /// only the status is written. A record already `Failed` — a prior pass whose entry removal + /// was lost to a crash — still matches, no-ops the update, and gets its lingering entry + /// removed, its intent kept unless the prior pass already did. async fn fail_unconfirmed_funding_payment_locked( &self, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, record_txid: Txid, ) -> Result { @@ -863,6 +878,44 @@ impl Wallet { }) .await?; if outcome != FundingPaymentFailure::MovedOn { + // A splice intent the entry carries outlives the record as a bare intent, for the + // splice tracker to settle at the lock or the close that failed the payment, or to + // re-anchor when LDK carries a queued fee bump the promoted round does not overlap + // across the lock and begins a fresh splice from it. The intent moves to an id of its + // own: the fresh round adopts the id of the bare intent carrying its contribution + // (`find_splice_payment_id`), and under this record's id it would take a `Failed` + // record and go untracked. The drop pass (`drop_abandoned_splice_rounds_locked`) keeps + // the intent under the record's id instead, having removed the record. Keeping the + // intent before removing the entry loses nothing to a crash in between: the replay + // finds the intent kept and adds no second copy. A settlement the splice tracker has + // under way, one that read the intent before this routine ran or that lands between the + // read above and the insert, leaves a bare copy of a settled intent behind; the + // channel's next lock, close or startup reconciliation settles the copy. + let intent = match self.pending_payment_store.get(&payment_id).await? { + Some(PendingPaymentDetails::Tracked { splice_intent: Some(intent), .. }) => { + Some(intent) + }, + _ => None, + }; + if let Some(intent) = intent { + let kept_already = self + .pending_payment_store + .list_filter(|p| p.details().is_none() && p.splice_intent() == Some(&intent)) + .await; + if kept_already.is_empty() { + let kept_id = random_payment_id(); + self.pending_payment_store + .insert(PendingPaymentDetails::pending_splice(kept_id, intent)) + .await?; + log_debug!( + self.logger, + "Kept the splice intent of failed funding payment {} as bare intent {} for \ + the splice tracker to settle", + payment_id, + kept_id, + ); + } + } self.pending_payment_store.remove(&payment_id).await?; } Ok(outcome) @@ -922,13 +975,14 @@ impl Wallet { /// Fails every funding payment of `channel_id` still waiting on an unconfirmed splice round /// while no round of ours in its record is among `held_rounds` or was promoted to the channel's - /// funding (see [`Self::resolve_promoted_splice_round`]), removing its pending entry; a payment - /// with such a round is left as it is. The rounds of ours are the candidates recorded with a - /// stake, and the record's own transaction only when no candidate records it, as for a record - /// from before candidates were tracked: a recorded candidate counts by its stake alone, - /// whichever round the record names. A payment that moved on — its round confirmed, or it was - /// failed already — is not touched beyond the entry a failure cut short left behind. - /// `resolution` names the occasion in what is logged. + /// funding (see [`Self::resolve_promoted_splice_round`]), removing its pending entry and + /// keeping a splice intent it carries as a bare intent of its own; a payment with such a round + /// is left as it is. The rounds of ours are the candidates recorded with a stake, and the + /// record's own transaction only when no candidate records it, as for a record from before + /// candidates were tracked: a recorded candidate counts by its stake alone, whichever round + /// the record names. A payment that moved on — its round confirmed, or it was failed already — + /// is not touched beyond the entry a failure cut short left behind. `resolution` names the + /// occasion in what is logged. async fn fail_funding_payments_without_held_round_locked( &self, guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, held_rounds: &[Txid], resolution: FundingResolution, @@ -1383,6 +1437,37 @@ impl Wallet { } } + /// Flushes any staged wallet changes to the persister, providing an explicit durability point + /// for state that was staged rather than persisted where it was written. + pub(crate) async fn persist_staged(&self) -> Result<(), Error> { + let mut locked_persister = self.persister.lock().await; + let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + }) + } + + /// Releases the given outpoints from the wallet's locked set — making them available to coin + /// selection again — and persists the change. Outpoints that are not locked are left alone. + pub(crate) async fn unlock_outpoints(&self, outpoints: &[OutPoint]) -> Result<(), Error> { + if outpoints.is_empty() { + return Ok(()); + } + let mut locked_persister = self.persister.lock().await; + let change_set = { + let mut locked_wallet = self.inner.lock().expect("lock"); + for outpoint in outpoints { + locked_wallet.unlock_outpoint(*outpoint); + } + locked_wallet.take_staged().unwrap_or_default() + }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + }) + } + pub(crate) fn get_balances( &self, total_anchor_channels_reserve_sats: u64, ) -> Result<(u64, u64), Error> { @@ -2143,25 +2228,41 @@ impl Wallet { Ok(()) } - /// Returns the `PaymentId` of a user-initiated splice intent for one of the channels in - /// `candidate`, if any, so the first recorded round of a splice adopts the id chosen at splice - /// time rather than a fresh one. The intent identifies the channel, not the round, so it - /// decides the id only for a history no record tracks yet - /// ([`Self::resolve_interactive_funding_id`]). A fee bump reuses the channel's existing intent, - /// so at most one in-flight intent matches and the first is unambiguous. + /// Returns the `PaymentId` of the user-initiated splice intent the round `candidate` belongs + /// to, if any, so the first recorded round of a splice adopts the id chosen at splice time + /// rather than a fresh one. Only a history no record tracks yet gets here + /// ([`Self::resolve_interactive_funding_id`]), so only the channel's bare intent records — + /// those of its splices with no round on record — can be the round's: a tracked record already + /// has its rounds, and a channel carries one record per splice in flight. Among the bare + /// records, the round's is the one whose intent carries the round's contribution + /// ([`is_same_splice`]; LDK may adjust a contribution's fee fields, not its inputs or outputs) + /// or, when none does, the channel's only bare record. Several bare records and no match + /// identify nothing, and the round gets a fresh id. async fn find_splice_payment_id(&self, candidate: &FundingCandidate) -> Option { - self.pending_payment_store + let channel_of = |intent: &SpliceIntent| { + candidate.channels.iter().find(|channel| { + channel.channel_id == intent.channel_id + && channel.counterparty_node_id == intent.counterparty_node_id + }) + }; + let bare = self + .pending_payment_store .list_filter(|p| { - p.splice_intent().is_some_and(|intent| { - candidate.channels.iter().any(|channel| { - channel.channel_id == intent.channel_id - && channel.counterparty_node_id == intent.counterparty_node_id - }) - }) + p.details().is_none() && p.splice_intent().is_some_and(|i| channel_of(i).is_some()) }) - .await - .first() - .map(|p| p.id()) + .await; + let carries_contribution = |p: &&PendingPaymentDetails| { + p.splice_intent().is_some_and(|intent| { + channel_of(intent) + .and_then(|channel| channel.contribution.as_ref()) + .is_some_and(|contribution| is_same_splice(contribution, &intent.contribution)) + }) + }; + match (bare.iter().find(carries_contribution), bare.as_slice()) { + (Some(record), _) => Some(record.id()), + (None, [only]) => Some(only.id()), + (None, _) => None, + } } /// Resolves the id under which the `active` round of the interactive funding with negotiated @@ -2172,17 +2273,18 @@ impl Wallet { /// whose round lost to a conflicting spend confirmed while the channel stays open, LDK still /// holds the round and a fee bump of it is signed with the round among its candidates, and /// nothing revisits a failed record's status, so the bump filed under it would go untracked. - /// Only a history no live record tracks falls back to the channel's splice intent: a + /// Only a history no live record tracks falls back to the channel's splice intents: a /// user-initiated splice adopts the `PaymentId` generated when it was initiated, so its intent, - /// funding payment and candidate history share one record. The intent identifies the channel, - /// not the round, which is why it must not decide the id of a round already on record: a fee - /// bump this node signs of a round wallet sync recorded first must converge on the record sync - /// created, not be filed under the bump's intent as a second record. Otherwise a fresh id is - /// generated — an id derived from a txid would tie the record's identity to one round of a - /// replaceable transaction, and resolution through the record's txid history is what keeps its - /// identity stable across RBF replacements. The caller holds the cross-store lock: resolved - /// outside it, the id could go stale against a record wallet sync creates for the same - /// transaction before the caller's write. + /// funding payment and candidate history share one record. A channel carries one intent per + /// splice in flight, and only a bare one — of a splice with no round on record — can be a first + /// round's ([`Self::find_splice_payment_id`]), which is why the intents must not decide the id + /// of a round already on record: a fee bump this node signs of a round wallet sync recorded + /// first must converge on the record sync created, not be filed under the bump's intent as a + /// second record. Otherwise a fresh id is generated — an id derived from a txid would tie the + /// record's identity to one round of a replaceable transaction, and resolution through the + /// record's txid history is what keeps its identity stable across RBF replacements. The caller + /// holds the cross-store lock: resolved outside it, the id could go stale against a record + /// wallet sync creates for the same transaction before the caller's write. async fn resolve_interactive_funding_id( &self, _guard: &tokio::sync::MutexGuard<'_, ()>, candidates: &[FundingCandidate], active: &FundingCandidate, @@ -2294,7 +2396,10 @@ impl Wallet { /// Nothing is recorded for a round missing from the history (reset between the event's /// emission and its handling, so LDK will refuse the signed transaction), already recorded (a /// replayed event), or without a local contribution or wallet-level activity. A failed write - /// leaves no half-written record behind for the replayed event to build on. + /// leaves no half-written record behind for the replayed event to build on; one whose rollback + /// failed as well is dropped by the replayed event once the round is gone + /// ([`Self::drop_unindexed_signing_record`]), or along with the settled intent of its splice + /// ([`Self::drop_unindexed_record_of_settled_intent`]). /// /// [`ChannelManager::funding_transaction_signed`]: lightning::ln::channelmanager::ChannelManager::funding_transaction_signed pub(crate) async fn record_signed_funding( @@ -2497,9 +2602,11 @@ impl Wallet { /// or not its `SpliceNegotiated` event has cleared the mark yet. Dropping the record's current /// round hands the record back to the last remaining round this node contributed to, figures /// included; dropping the last such round removes the record, as whatever rounds remain are not - /// this node's payment (LDK keeps this node's contributions to a suffix of the rounds). A record - /// that no longer waits on the dropped round — wallet sync moved it on, or an earlier drop was - /// cut short after moving it — keeps its state and only loses the round from its history. + /// this node's payment (LDK keeps this node's contributions to a suffix of the rounds), while a + /// splice intent the record carried stays behind as a bare intent, for the failure LDK reports + /// to be described from and for its settlement to remove. A record that no longer waits on the + /// dropped round — wallet sync moved it on, or an earlier drop was cut short after moving it — + /// keeps its state and only loses the round from its history. pub(crate) async fn drop_abandoned_splice_rounds( &self, channel_id: ChannelId, held_rounds: &[Txid], ) -> Result<(), Error> { @@ -2596,16 +2703,49 @@ impl Wallet { // Nothing of this node's was ever broadcast under the record, so it goes // rather than fail a payment for a transaction that never existed. The // payment record goes first: the entry keeps resolving the rounds' txids, - // so a removal that fails midway is finished by the replayed event. + // so a removal that fails midway is finished by the replayed event. A + // splice intent the entry carries outlives the record as a bare intent: + // the failure LDK reports for the round is described from it, and its + // settlement removes it (`SpliceTracker::on_negotiation_failed`); one left + // behind by a node that stopped in between is found and settled by + // whatever next concerns the channel's splice. The intent is read from the + // entry as it stands, not as listed above: a fee bump submitted since may + // have replaced it, and that intent must stay just the same. self.payment_store.remove(&payment_id).await?; - self.pending_payment_store.remove(&payment_id).await?; - log_debug!( - self.logger, - "Dropped abandoned splice round(s) {:?} and removed funding payment {}: nothing of ours \ - was broadcast under it", - abandoned_txids, - payment_id, - ); + let kept_intent = self + .pending_payment_store + .mutate(&payment_id, |existing| match existing { + Some(PendingPaymentDetails::Tracked { + splice_intent: Some(intent), + .. + }) => Some(PendingPaymentDetails::pending_splice( + payment_id, + intent.clone(), + )), + _ => None, + }) + .await? + .is_some(); + self.pending_payment_store + .remove_if(&payment_id, |entry| entry.splice_intent().is_none()) + .await?; + if kept_intent { + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} and removed funding payment {}: nothing of ours \ + was broadcast under it; the splice's intent stays until its failure is surfaced", + abandoned_txids, + payment_id, + ); + } else { + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} and removed funding payment {}: nothing of ours \ + was broadcast under it", + abandoned_txids, + payment_id, + ); + } continue; } history_only = true; @@ -2693,34 +2833,80 @@ impl Wallet { /// the history, ends up here; a fully recorded round (its entry in place) is left to /// [`Self::drop_abandoned_splice_rounds`]. Only a first round can be left so: the record of a /// bump keeps the entry of the rounds before it, and wallet sync moves it on as an earlier - /// round confirms or fails. + /// round confirms or fails. A bare splice intent under the record's id — the intent whose id + /// the signing adopted and whose entry the completed write would have promoted — does not + /// index the record, and is left for the splice tracker to settle. async fn drop_unindexed_signing_record(&self, txid: Txid) -> Result<(), Error> { - let _guard = self.funding_payment_update_lock.lock().await; + let guard = self.funding_payment_update_lock.lock().await; let payment_id = match self.find_payment_by_txid(txid).await? { Some(id) => id, None => return Ok(()), }; - if self.pending_payment_store.get(&payment_id).await?.is_some() { + self.drop_unindexed_signing_record_locked(&guard, payment_id, Some(txid)).await + } + + /// Removes the half-written signing record, if any, under the id of a bare splice intent whose + /// splice settled. The signing-time recording ([`Self::record_signed_funding`]) files a payment + /// under a bare intent's id and promotes the intent's entry in the same write, so a payment + /// record found under a bare intent is the first half of a write that never completed. The + /// round's signatures never left the node, nothing can broadcast it, and no entry would ever + /// drive the record. The caller removes the bare entry afterwards; an entry that turns out to + /// be tracked indexes the record, which then stays. + pub(crate) async fn drop_unindexed_record_of_settled_intent( + &self, payment_id: PaymentId, + ) -> Result<(), Error> { + let guard = self.funding_payment_update_lock.lock().await; + self.drop_unindexed_signing_record_locked(&guard, payment_id, None).await + } + + /// Removes the payment record under `payment_id` if it is the half-written record of a signed + /// splice round — pending, unconfirmed, interactive funding, and of `txid` when one is given — + /// that no `Tracked` entry indexes. The caller must hold [`Self::funding_payment_update_lock`] + /// so that the check and the removal cannot interleave with a signing write completing the + /// record; the `_guard` parameter serves as a reminder of that contract. + async fn drop_unindexed_signing_record_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, txid: Option, + ) -> Result<(), Error> { + let indexed = self + .pending_payment_store + .get(&payment_id) + .await? + .is_some_and(|entry| entry.details().is_some()); + if indexed { + log_debug!( + self.logger, + "Keeping the funding record of payment {}: its pending entry indexes it", + payment_id, + ); return Ok(()); } - let unindexed = self.payment_store.get(&payment_id).await?.is_some_and(|record| { - record.status == PaymentStatus::Pending - && matches!( - &record.kind, - PaymentKind::Onchain { - txid: recorded, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - } if *recorded == txid - ) - }); - if unindexed { - self.payment_store.remove(&payment_id).await?; - log_info!( + let half_written = + self.payment_store.get(&payment_id).await?.and_then(|record| match &record.kind { + PaymentKind::Onchain { + txid: recorded, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } if record.status == PaymentStatus::Pending + && txid.map_or(true, |txid| *recorded == txid) => + { + Some(*recorded) + }, + _ => None, + }); + match half_written { + Some(recorded) => { + self.payment_store.remove(&payment_id).await?; + log_info!( + self.logger, + "Dropped the half-written funding record of abandoned splice round {}", + recorded, + ); + }, + None => log_debug!( self.logger, - "Dropped the half-written funding record of abandoned splice round {}", - txid, - ); + "No half-written funding record to drop under payment {}", + payment_id, + ), } Ok(()) } @@ -2841,6 +3027,10 @@ impl Wallet { // is ordered before the removal, which then also deletes anything inserted here. A // status read taken before this write goes stale when graduation lands in between, and // would re-index the graduated payment. + let mut leftover_intent_to_remove = None; + // The `move` closure would capture the `Option` by value, so hand it a reference; the + // borrow ends with the mutate's future, before the leftover is read below. + let leftover = &mut leftover_intent_to_remove; let payment_store = Arc::clone(&self.payment_store); self.pending_payment_store .mutate_async(&id, move |existing| async move { @@ -2861,12 +3051,11 @@ impl Wallet { }), // A user-initiated splice has a pre-broadcast `PendingSplice` intent under // this id; carry its intent into the `Tracked` record so promotion does - // not drop it (nothing persists or consumes intents yet — that arrives - // with the follow-up that makes splice retries survive restarts). If the - // payment already advanced beyond `Pending` (wallet sync confirmed it - // through `ANTI_REORG_DELAY` first), it must not enter the pending store; - // the leftover intent record stays until that follow-up adds its clearing - // path. + // not drop it. If the payment already advanced beyond `Pending` (wallet + // sync confirmed it through `ANTI_REORG_DELAY` first), it must not enter + // the pending store — and the splice behind the intent confirmed, so the + // leftover record is removed below rather than left to look like a splice + // still in flight after a restart. Some(PendingPaymentDetails::PendingSplice { intent, .. }) => { if recorded.status == PaymentStatus::Pending { Some(PendingPaymentDetails::tracked( @@ -2876,6 +3065,7 @@ impl Wallet { Some(intent), )) } else { + *leftover = Some(intent); None } }, @@ -2898,6 +3088,17 @@ impl Wallet { }) .await .map_err(|error| FundingWriteError::Failed { error, prior: prior.clone() })?; + if let Some(intent) = leftover_intent_to_remove { + // Only remove the record while it still is the bare intent the closure saw: a fee bump + // submitted in between joins the bare record and replaces its intent, and that live + // intent must stay. + self.pending_payment_store + .remove_if(&id, |record| { + record.details().is_none() && record.splice_intent() == Some(&intent) + }) + .await + .map_err(|error| FundingWriteError::Failed { error, prior: prior.clone() })?; + } Ok(prior) } @@ -3640,10 +3841,11 @@ enum FundingResolution { /// The outcome of [`Wallet::fail_unconfirmed_funding_payment_locked`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FundingPaymentFailure { - /// The payment was failed and its pending entry removed. + /// The payment was failed and its pending entry removed, a splice intent it carried kept as a + /// bare intent of its own. Failed, /// The payment was failed already — by a pass whose entry removal was lost to a crash — and - /// only the lingering entry was removed. + /// only the lingering entry was removed, its intent kept likewise. EntryRemoved, /// The record no longer waits on the transaction; nothing was touched. MovedOn, @@ -3652,7 +3854,7 @@ enum FundingPaymentFailure { /// Generates a fresh funding-record [`PaymentId`] from the OS entropy source. A funding record's id /// carries no meaning beyond uniqueness: the record is found through its transaction history /// ([`Wallet::find_payment_by_txid`]), never re-derived from a txid. -fn random_payment_id() -> PaymentId { +pub(crate) fn random_payment_id() -> PaymentId { let mut bytes = [0u8; 32]; getrandom::fill(&mut bytes).expect("getrandom failed"); PaymentId(bytes) @@ -4421,6 +4623,111 @@ mod tests { wallet.address_pool.lock().unwrap().available.iter().map(|(index, _)| *index).collect() } + fn test_splice_intent() -> crate::payment::pending_payment_store::SpliceIntent { + use crate::payment::pending_payment_store::{SpliceIntent, SpliceKind}; + + SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([13u8; 32]), + pre_splice_funding_txo: lightning::chain::transaction::OutPoint { + txid: Txid::from_byte_array([3u8; 32]), + index: 0, + }, + contribution: crate::payment::pending_payment_store::test_funding_contribution(), + kind: SpliceKind::In { amount_sats: 10_000 }, + } + } + + fn funding_payment(id: PaymentId, txid: Txid, status: PaymentStatus) -> PaymentDetails { + PaymentDetails::new( + id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels: Vec::new() }), + }, + Some(1_000_000), + Some(500), + PaymentDirection::Outbound, + status, + ) + } + + #[tokio::test] + async fn recording_a_round_promotes_a_pre_broadcast_intent_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([21u8; 32]); + let txid = Txid::from_byte_array([22u8; 32]); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::pending_splice(id, test_splice_intent())) + .await + .unwrap(); + + let candidates = vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + wallet + .persist_funding_payment(funding_payment(id, txid, PaymentStatus::Pending), candidates) + .await + .unwrap(); + + // The pre-broadcast record is promoted into the tracked funding payment, carrying its + // intent until the splice locks. + let record = wallet + .pending_payment_store + .get(&id) + .await + .unwrap() + .expect("the record must be promoted"); + assert!(record.details().is_some()); + assert!(record.splice_intent().is_some()); + } + + #[tokio::test] + async fn recording_a_round_removes_the_intent_record_of_an_advanced_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([23u8; 32]); + let txid = Txid::from_byte_array([24u8; 32]); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::pending_splice(id, test_splice_intent())) + .await + .unwrap(); + // Wallet sync confirmed the payment through `ANTI_REORG_DELAY` before the record was written: + // the payment graduated, so the record must not enter the pending store... + wallet + .payment_store + .insert(funding_payment(id, txid, PaymentStatus::Succeeded)) + .await + .unwrap(); + + let candidates = vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + wallet + .persist_funding_payment(funding_payment(id, txid, PaymentStatus::Pending), candidates) + .await + .unwrap(); + + // ...and the splice behind the intent confirmed, so the leftover intent record is removed + // rather than left to look like a splice still in flight after a restart. + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + #[tokio::test] async fn refill_publishes_addresses_only_after_their_reveal_is_persisted() { let fail_store = FailSwitchStore::new(); @@ -5400,6 +5707,17 @@ mod tests { } } + /// The pending entries carrying `intent` as a bare intent — of a splice with no round on + /// record. + async fn bare_entries_carrying( + wallet: &Wallet, intent: &SpliceIntent, + ) -> Vec { + wallet + .pending_payment_store + .list_filter(|p| p.details().is_none() && p.splice_intent() == Some(intent)) + .await + } + /// A round signed under the channel's splice intent that has since locked with zero /// confirmations — clearing its intent — with a second splice submitted against the locked /// funding before the round's `SpliceNegotiated` event was handled: the channel's intent no @@ -5458,8 +5776,8 @@ mod tests { /// A recorded round is marked broadcast in its own record once the channel carries the intent /// of a newer splice: after a zero-conf lock, the user may submit a second splice before the - /// locked round's `SpliceNegotiated` event is handled, and the event must neither file the - /// round under the new splice as a second record nor touch the new splice's intent. + /// locked round's `SpliceNegotiated` event is handled, and the event must mark the round in + /// its own record without touching the new splice's intent. #[tokio::test] async fn negotiation_marks_a_recorded_round_broadcast_under_a_newer_intent() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); @@ -5470,9 +5788,6 @@ mod tests { let channel_id = setup.candidates[0].channels[0].channel_id; wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - let payments = wallet.payment_store.list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1, "the round must not be filed as a second record"); - assert_eq!(payments[0].id, setup.first_id); let entry = wallet.pending_payment_store.get(&setup.first_id).await.unwrap().expect("entry"); assert!(!entry.candidate(txid).expect("candidate").awaiting_broadcast); @@ -5658,6 +5973,177 @@ mod tests { assert_eq!(entry.splice_intent(), Some(&intent)); } + /// A splice queued behind a pending splice of this node is a splice of its own, negotiating + /// once the pending one locks. Its first round is on no record when it is signed, and the + /// pending round's record — tracked, and still carrying the pending splice's intent — is not + /// its: only a bare intent record can be a first round's. The queued round gets a fresh id and + /// the pending round's record stays as it stands. + #[tokio::test] + async fn signing_a_queued_splice_does_not_join_the_pending_rounds_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + let (queued_tx, queued_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let queued_txid = queued_tx.compute_txid(); + let queued_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(queued_txid, Some(queued_contribution))], + ); + wallet.record_signed_funding(&queued_tx, &queued_candidates).await.unwrap(); + + let queued_id = wallet + .find_payment_by_txid(queued_txid) + .await + .unwrap() + .expect("the queued round must be recorded"); + assert_ne!(queued_id, id, "the queued splice must not join the pending round's record"); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("record"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(entry.splice_intent(), Some(&intent)); + } + + /// A channel carries one intent per splice in flight, each under its own record. Signing the + /// first round of either splice files it under the intent whose contribution it carries, and + /// leaves the other splice's record alone. + #[tokio::test] + async fn signing_the_first_rounds_of_two_splices_files_each_under_its_own_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let (tx_a, contribution_a) = splice_out_round(&wallet, 1, 500_000, 300); + let (tx_b, contribution_b) = splice_out_round(&wallet, 2, 400_000, 700); + let (txid_a, txid_b) = (tx_a.compute_txid(), tx_b.compute_txid()); + let (id_a, id_b) = (PaymentId([31u8; 32]), PaymentId([32u8; 32])); + let intent_a = SpliceIntent { + contribution: contribution_a.clone(), + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + let intent_b = SpliceIntent { + contribution: contribution_b.clone(), + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + for (id, intent) in [(id_a, intent_a.clone()), (id_b, intent_b.clone())] { + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id, intent }) + .await + .unwrap(); + } + + let candidates_a = + splice_candidates(counterparty_node_id, channel_id, &[(txid_a, Some(contribution_a))]); + wallet.record_signed_funding(&tx_a, &candidates_a).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid_a).await.unwrap(), Some(id_a)); + let entry_b = wallet.pending_payment_store.get(&id_b).await.unwrap().expect("entry"); + assert_eq!( + entry_b, + PendingPaymentDetails::PendingSplice { id: id_b, intent: intent_b.clone() } + ); + + let candidates_b = + splice_candidates(counterparty_node_id, channel_id, &[(txid_b, Some(contribution_b))]); + wallet.record_signed_funding(&tx_b, &candidates_b).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid_b).await.unwrap(), Some(id_b)); + let entry_b = wallet.pending_payment_store.get(&id_b).await.unwrap().expect("entry"); + assert_eq!(entry_b.candidates().iter().map(|c| c.txid).collect::>(), vec![txid_b]); + assert_eq!(entry_b.splice_intent(), Some(&intent_b)); + let entry_a = wallet.pending_payment_store.get(&id_a).await.unwrap().expect("entry"); + assert_eq!(entry_a.candidates().iter().map(|c| c.txid).collect::>(), vec![txid_a]); + assert_eq!(entry_a.splice_intent(), Some(&intent_a)); + assert_eq!(wallet.payment_store.list_page(None).await.unwrap().objects.len(), 2); + } + + /// A first round whose contribution is none of the channel's bare intents' — LDK may adjust + /// a contribution's fee fields, not its inputs or outputs — is still the channel's only bare + /// intent's round when there is just one. When there are several, none is known to be its, + /// and the round gets a fresh id while both intents stay. + #[tokio::test] + async fn signing_a_first_round_none_of_several_bare_intents_claims_gets_a_fresh_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let (id_a, id_b) = (PaymentId([31u8; 32]), PaymentId([32u8; 32])); + let intent_a = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let intent_b = SpliceIntent { + contribution: test_funding_contribution_with_outputs(400, 253, &[]), + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + for (id, intent) in [(id_a, intent_a.clone()), (id_b, intent_b.clone())] { + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id, intent }) + .await + .unwrap(); + } + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + let round_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("recorded"); + assert!(round_id != id_a && round_id != id_b, "neither intent is known to be the round's"); + for (id, intent) in [(id_a, intent_a), (id_b, intent_b)] { + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + assert_eq!(entry, PendingPaymentDetails::PendingSplice { id, intent }); + } + } + + /// A splice submitted after the previous splice locked with zero confirmations has an intent + /// of its own: the locked splice's intent was settled before the new one was persisted + /// (`SpliceTracker::submit`). Signing the new splice's first round files it under the new + /// intent's id and leaves the locked round's record as it stands. + #[tokio::test] + async fn signing_a_splice_after_a_zero_conf_lock_gets_its_own_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let first_txid = setup.tx.compute_txid(); + let first_entry = + wallet.pending_payment_store.get(&setup.first_id).await.unwrap().expect("first entry"); + + let (tx, contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(setup.second_id)); + let entry = + wallet.pending_payment_store.get(&setup.second_id).await.unwrap().expect("entry"); + assert_eq!(entry.splice_intent(), Some(&setup.second_intent)); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!( + wallet.pending_payment_store.get(&setup.first_id).await.unwrap(), + Some(first_entry) + ); + assert_eq!(wallet.find_payment_by_txid(first_txid).await.unwrap(), Some(setup.first_id)); + } + /// Signing a splice round records its funding payment with the channel's full pending splice /// history, so a wallet sync that observes the transaction before the broadcast (the /// counterparty may broadcast first) resolves to the funding record through any round of that @@ -6061,8 +6547,110 @@ mod tests { assert_eq!(wallet.find_payment_by_txid(other_txid).await.unwrap(), Some(other_id)); } - /// LDK abandoned a signed fee bump while the round it replaces stays pending: the bump leaves - /// the recorded history and the record tracks the original round again, figures included. + /// The abandoned first round was signed under the channel's splice intent: the record goes, + /// but the intent stays behind as a bare intent, so the failure LDK reports next can still be + /// described in the splice's own terms before its settlement removes the intent. A repeated + /// drop leaves the bare intent alone. + #[tokio::test] + async fn dropping_an_abandoned_first_round_keeps_its_bare_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::PendingSplice { id, intent }; + wallet.pending_payment_store.insert(bare.clone()).await.unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + assert_eq!(wallet.pending_payment_store.get(&id).await.unwrap(), Some(bare.clone())); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + assert_eq!(wallet.pending_payment_store.get(&id).await.unwrap(), Some(bare)); + } + + /// The intent was already settled off the record when the abandoned first round is dropped — + /// a lock or the channel's close settled it first — so nothing is left to keep: the record and + /// its entry both go. + #[tokio::test] + async fn dropping_an_abandoned_first_round_whose_intent_settled_removes_its_entry() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id, intent }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let settled = PendingPaymentDetailsUpdate { + id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + wallet.pending_payment_store.update(settled).await.unwrap(); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + } + + /// LDK abandoned a signed fee bump of a counterparty-initiated round this node did not + /// contribute to: no remaining round is this node's payment, so the record goes as a first + /// round's does, and the bump's intent stays behind as a bare intent. + #[tokio::test] + async fn dropping_an_abandoned_bump_of_a_counterparty_round_keeps_its_bare_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::PendingSplice { id, intent }; + wallet.pending_payment_store.insert(bare.clone()).await.unwrap(); + let prior_txid = Txid::from_byte_array([9u8; 32]); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let bump_txid = bump_tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &candidates).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), Some(id)); + + wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); + + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); + assert_eq!(wallet.pending_payment_store.get(&id).await.unwrap(), Some(bare)); + } + + /// LDK abandoned a signed fee bump while the round it replaces stays pending: the bump leaves + /// the recorded history and the record tracks the original round again, figures included. #[tokio::test] async fn dropping_an_abandoned_bump_restores_the_prior_round() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); @@ -6610,6 +7198,186 @@ mod tests { assert_eq!(reads, 2, "recording a first round re-read the payment store"); } + /// The half-written record of a reset first round sits under the id of the channel's splice + /// intent, which the signing adopted. The bare intent entry under that id does not index the + /// record, so the replayed signing drops the record and leaves the intent for the splice + /// tracker to settle. + #[tokio::test] + async fn a_replayed_signing_drops_the_half_written_record_under_a_bare_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::PendingSplice { id, intent }; + wallet.pending_payment_store.insert(bare.clone()).await.unwrap(); + let (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); + wallet.payment_store.insert_or_update(half_written).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + + wallet.record_signed_funding(&tx, &[]).await.unwrap(); + + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + assert_eq!(wallet.pending_payment_store.get(&id).await.unwrap(), Some(bare)); + } + + /// A half-written fee bump — the payment record moved on to the bump, the entry still lists + /// only the round it replaces — is indexed by that entry: the replayed signing leaves it + /// alone. Wallet sync hands the record back to the replaced round as that round confirms or + /// fails; the negotiation-failure handling cannot, as it only knows the rounds the entry lists. + #[tokio::test] + async fn a_replayed_signing_keeps_the_half_written_record_of_a_reset_bump() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + // The bump's signing write landed in the payment store only. + let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let mut moved_on = PaymentDetailsUpdate::new(id); + moved_on.txid = Some(bump_txid); + wallet.payment_store.update(moved_on).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), Some(id)); + + wallet.record_signed_funding(&bump_tx, &[]).await.unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + } + + /// The signing write of a first round was cut short after the payment store, under the id of + /// the channel's splice intent. Replayed with the round still pending, the signing completes + /// the record: one entry, carrying the intent and the round awaiting broadcast. + #[tokio::test] + async fn a_replayed_signing_completes_the_half_written_record_under_a_bare_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); + wallet.payment_store.insert_or_update(half_written).await.unwrap(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + assert_eq!(payments[0].id, id); + let entries = wallet.pending_payment_store.list_filter(|_| true).await; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].details(), Some(&payments[0])); + assert_eq!(entries[0].splice_intent(), Some(&intent)); + assert!(entries[0].candidate(txid).expect("candidate").awaiting_broadcast); + } + + /// Settling a bare splice intent removes the half-written signing record under its id, if + /// any: it is the first half of a signing write for a round nothing can broadcast, and no + /// entry would ever drive it. The bare entry itself is left to the settlement, and a record + /// a `Tracked` entry indexes stays. + #[tokio::test] + async fn settling_a_bare_intent_drops_its_half_written_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::PendingSplice { id, intent }; + wallet.pending_payment_store.insert(bare.clone()).await.unwrap(); + let (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); + wallet.payment_store.insert_or_update(half_written).await.unwrap(); + + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert_eq!(wallet.pending_payment_store.get(&id).await.unwrap(), Some(bare)); + + // A round recorded in full under the intent's id is indexed by its entry and stays. + let (tx, contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + + assert!(wallet.payment_store.get(&id).await.unwrap().is_some()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + } + + /// Settling a bare splice intent leaves alone a record under its id that is not the + /// half-written record of a signed round: a payment that succeeded, or whose transaction + /// confirmed, was broadcast and driven to that state, and is a payment of its own. + #[tokio::test] + async fn settling_a_bare_intent_leaves_a_settled_record_alone() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::PendingSplice { id, intent }; + wallet.pending_payment_store.insert(bare.clone()).await.unwrap(); + let txid = Txid::from_byte_array([0xBB; 32]); + let settled = [ + (confirmed_status(), PaymentStatus::Succeeded), + (confirmed_status(), PaymentStatus::Pending), + (ConfirmationStatus::Unconfirmed, PaymentStatus::Succeeded), + ]; + for (confirmation, status) in settled { + let kind = PaymentKind::Onchain { + txid, + status: confirmation, + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + let record = PaymentDetails::new( + id, + kind, + Some(500_300_000), + Some(300_000), + PaymentDirection::Outbound, + status, + ); + wallet.payment_store.insert_or_update(record.clone()).await.unwrap(); + + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + + assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(record)); + assert_eq!(wallet.pending_payment_store.get(&id).await.unwrap(), Some(bare.clone())); + wallet.payment_store.remove(&id).await.unwrap(); + } + } + /// The signing write fails between its two stores — the payment record lands, the pending /// entry does not — so the payment store is put back as it was, and the replayed event /// records the round in full once the store recovers instead of building on a half-written @@ -9426,6 +10194,211 @@ mod tests { assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); } + /// A record failed as a round this node did not contribute to locked keeps the splice intent + /// it carries as a bare intent under an id of its own: the intent is the splice tracker's to + /// settle — at this lock, or from the failure LDK reports for the contribution — and the + /// record's removal must not take it along, nor may it stay under the failed record's id, + /// which the fresh round of a fee bump LDK carries across the lock would adopt. + #[tokio::test] + async fn promoting_a_round_not_ours_keeps_the_failed_records_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let counterparty_txid = Txid::from_byte_array([0xBB; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + let kept = bare_entries_carrying(&wallet, &intent).await; + assert_eq!(kept.len(), 1, "one bare entry carries the intent: {kept:?}"); + assert_ne!(kept[0].id(), id); + } + + /// The close keeps the intent of a record it fails the same way, for the splice tracker's + /// settlement of the closed channel's intents to find. + #[tokio::test] + async fn closing_keeps_the_failed_records_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + let kept = bare_entries_carrying(&wallet, &intent).await; + assert_eq!(kept.len(), 1, "one bare entry carries the intent: {kept:?}"); + assert_ne!(kept[0].id(), id); + } + + /// A fee bump LDK carries across the lock of a round it does not overlap begins a fresh + /// splice, and the fresh round adopts the id of the bare intent carrying its contribution. + /// The intent kept from the failed record must therefore sit under an id of its own: the + /// fresh round then gets a `Pending` record whose entry carries the intent, where under the + /// failed record's id it would take that record — left `Failed` with the fresh round's txid — + /// and go untracked. + #[tokio::test] + async fn a_kept_intent_signs_its_fresh_round_under_an_id_of_its_own() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + // The bump's intent joined the record of the round it was to replace. + let id = PaymentId([31u8; 32]); + let intent = SpliceIntent { + contribution: bump_contribution.clone(), + kind: SpliceKind::Rbf {}, + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let counterparty_txid = Txid::from_byte_array([0xBB; 32]); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + // LDK begins a fresh splice from the bump; its round is signed. + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + let bump_id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("a record"); + assert_ne!(bump_id, id); + let payment = wallet.payment_store.get(&bump_id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet.pending_payment_store.get(&bump_id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(bump_txid).is_some()); + let failed = wallet.payment_store.get(&id).await.unwrap().expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// A crash between keeping the intent and removing the failed record's entry leaves both; + /// the replay removes the entry and adds no second copy of the intent. + #[tokio::test] + async fn a_replayed_failure_does_not_duplicate_the_kept_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let counterparty_txid = Txid::from_byte_array([0xBB; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + // The prior pass failed the record and kept the intent, then crashed before removing + // the entry. + wallet + .payment_store + .mutate(&id, |existing| { + let mut update = PaymentDetailsUpdate::new(id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + let kept_id = PaymentId([32u8; 32]); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::PendingSplice { id: kept_id, intent: intent.clone() }) + .await + .unwrap(); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert_eq!( + bare_entries_carrying(&wallet, &intent).await, + vec![PendingPaymentDetails::PendingSplice { id: kept_id, intent }], + ); + } + /// A round of ours nothing had broadcast when the counterparty's round locked — our /// signatures were never exchanged — is dropped with the promotion, and its record with it, /// rather than failed: no transaction of ours ever existed to fail a payment for. From 757af9c4e0f56ee6288075ea6164977e02df40c1 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 10 Sep 2026 23:01:58 -0500 Subject: [PATCH 28/37] f - Persist splice intents until the splice locks Reword what the comments say LDK returns for a synchronously rejected contribution and for a signed round the monitor never watched, so they hold at the pinned LDK and once it carries the fixes for rust-lightning issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no pending splice attempt still uses, except that before 4986 is fixed a refusal for a channel or peer LDK no longer knows names the whole contribution, and once 4967 is fixed a round the monitor never watched is released by the `DiscardFunding` LDK reports at the force-close. Scope the `TODO(#1037)` notes to those fixes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/channel/mod.rs | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 183ef00c3..9fc8638f1 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -116,8 +116,13 @@ impl SpliceTracker { /// contribution and no other round claims ([`Self::release_contribution`]): a fee bump built /// by adjusting the fee of the round it replaces — `prior`, the contribution it was built /// from — reuses that round's inputs and change address, which a refusal must leave to the - /// round that has locked meanwhile. A synchronous rejection leaves the release to the - /// `DiscardFunding` event LDK queues. + /// round that has locked meanwhile. A synchronous rejection leaves the release to LDK, which + /// queues a `DiscardFunding` for the parts of the contribution no pending splice attempt still + /// uses — a negotiated round, one still under negotiation, or a contribution queued behind them + /// — and nothing when there are none. Before + /// is fixed, a + /// refusal for a channel or peer LDK no longer knows names the whole contribution; the handler + /// only unmarks the addresses that names today, as it ignores the event's inputs. /// /// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed pub(crate) async fn submit( @@ -223,10 +228,15 @@ impl SpliceTracker { counterparty_node_id, e, ); - // LDK returns the contribution through a `DiscardFunding` event, whose handling frees - // the addresses the wallet marked for it. + // LDK returns what the contribution reserved and no pending splice attempt still uses + // through a `DiscardFunding` event, or nothing when every part is still in use — before + // https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4986 is fixed, a + // refusal for a channel or peer LDK no longer knows names the whole contribution — and + // the event's handling frees the addresses the wallet marked for it. // TODO(#1037): the handler ignores the event's inputs; once inputs are locked at coin - // selection, it must unlock them as well. + // selection, it must unlock them as well. Unlocking them trusts the event to name only + // inputs no pending splice attempt still spends, which holds once the pinned LDK + // carries that fix. self.discard_persisted_intent(&payment_id, restore).await; return Err(Error::ChannelSplicingFailed); } @@ -577,10 +587,15 @@ impl SpliceTracker { /// Nothing the wallet holds for the intents is released here: a round the channel's monitor /// watches may still confirm, and what LDK reserved for the others it returns through /// `DiscardFunding` once the close matures. A signed round the monitor never watched — the - /// counterparty's `commitment_signed` never arrived — is released by neither. - // TODO(#1037): once inputs are locked at coin selection, such a round's inputs stay locked - // with no record to release them from after its intent is cleared here. Release the parts of - // the contribution no watched round uses before clearing. + /// counterparty's `commitment_signed` never arrived — is released only by the `DiscardFunding` + /// LDK reports for it at the force-close once + /// is fixed, which + /// arrives after `ChannelClosed` and names what no other round of the channel uses; nothing + /// releases it before that fix. + // TODO(#1037): once inputs are locked at coin selection, such a round's inputs stay locked with + // no record to release them from after its intent is cleared here, until the pinned LDK carries + // that fix, which reports a `DiscardFunding` after `ChannelClosed` for what of the round no + // other round of the channel uses. Remove this note at that pin move. pub(crate) async fn on_channel_closed( &self, counterparty_node_id: PublicKey, channel_id: ChannelId, ) { From c02296dc0b723495506c44291058d535b6601bd7 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 21 Sep 2026 16:31:56 -0700 Subject: [PATCH 29/37] f - Persist splice intents until the splice locks The pinned LDK now reports through `DiscardFunding` only the parts a refused or failed contribution reserved for itself, also for a channel or peer it no longer knows (rust-lightning issue 4986), and reports the `DiscardFunding` for a signed round the monitor never watched after `ChannelClosed` at a force-close (issue 4967), so the comments no longer hedge on either, and the note about the inputs such a round would leave locked before that fix is dropped. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/channel/mod.rs | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 9fc8638f1..199206c96 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -119,10 +119,8 @@ impl SpliceTracker { /// round that has locked meanwhile. A synchronous rejection leaves the release to LDK, which /// queues a `DiscardFunding` for the parts of the contribution no pending splice attempt still /// uses — a negotiated round, one still under negotiation, or a contribution queued behind them - /// — and nothing when there are none. Before - /// is fixed, a - /// refusal for a channel or peer LDK no longer knows names the whole contribution; the handler - /// only unmarks the addresses that names today, as it ignores the event's inputs. + /// — and nothing when there are none; the handler only unmarks the addresses that names today, + /// as it ignores the event's inputs. /// /// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed pub(crate) async fn submit( @@ -229,14 +227,11 @@ impl SpliceTracker { e, ); // LDK returns what the contribution reserved and no pending splice attempt still uses - // through a `DiscardFunding` event, or nothing when every part is still in use — before - // https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4986 is fixed, a - // refusal for a channel or peer LDK no longer knows names the whole contribution — and - // the event's handling frees the addresses the wallet marked for it. + // through a `DiscardFunding` event, or nothing when every part is still in use, and the + // event's handling frees the addresses the wallet marked for it. // TODO(#1037): the handler ignores the event's inputs; once inputs are locked at coin // selection, it must unlock them as well. Unlocking them trusts the event to name only - // inputs no pending splice attempt still spends, which holds once the pinned LDK - // carries that fix. + // inputs no pending splice attempt still spends, which the pinned LDK guarantees. self.discard_persisted_intent(&payment_id, restore).await; return Err(Error::ChannelSplicingFailed); } @@ -587,15 +582,9 @@ impl SpliceTracker { /// Nothing the wallet holds for the intents is released here: a round the channel's monitor /// watches may still confirm, and what LDK reserved for the others it returns through /// `DiscardFunding` once the close matures. A signed round the monitor never watched — the - /// counterparty's `commitment_signed` never arrived — is released only by the `DiscardFunding` - /// LDK reports for it at the force-close once - /// is fixed, which - /// arrives after `ChannelClosed` and names what no other round of the channel uses; nothing - /// releases it before that fix. - // TODO(#1037): once inputs are locked at coin selection, such a round's inputs stay locked with - // no record to release them from after its intent is cleared here, until the pinned LDK carries - // that fix, which reports a `DiscardFunding` after `ChannelClosed` for what of the round no - // other round of the channel uses. Remove this note at that pin move. + /// counterparty's `commitment_signed` never arrived — is released by the `DiscardFunding` LDK + /// reports for it at the force-close, which arrives after `ChannelClosed` and names what no + /// other round of the channel uses. pub(crate) async fn on_channel_closed( &self, counterparty_node_id: PublicKey, channel_id: ChannelId, ) { From 27b153da141793817622e9381e9a5345fb043754 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 22 Sep 2026 10:58:46 -0700 Subject: [PATCH 30/37] f - Persist splice intents until the splice locks Decide a removal inside the payment store's own critical section as well, now that remove_if exists: the drop pass no longer reads a record before writing it, and the note saying a removal had no critical section to decide in goes with the read. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 18fa20a2c..3a057e186 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2697,12 +2697,25 @@ impl Wallet { .await?; }, None => { - // A removal has no critical section to decide in, so the record is read first. - let record = self.payment_store.get(&payment_id).await?; - if record.as_ref().map_or(true, waits_on_abandoned) { + // Whether the record still waits on the dropped rounds is decided inside the + // removal's own critical section, from the record found there. A record already + // gone, its removal cut short between the two stores, takes the removal path + // too, for the entry to follow it. + self.payment_store + .remove_if(&payment_id, |current| { + if waits_on_abandoned(current) { + return true; + } + history_only = true; + mirrored = Some(current.clone()) + .filter(|current| current.status == PaymentStatus::Pending); + false + }) + .await?; + if !history_only { // Nothing of this node's was ever broadcast under the record, so it goes // rather than fail a payment for a transaction that never existed. The - // payment record goes first: the entry keeps resolving the rounds' txids, + // payment record went first: the entry keeps resolving the rounds' txids, // so a removal that fails midway is finished by the replayed event. A // splice intent the entry carries outlives the record as a bare intent: // the failure LDK reports for the round is described from it, and its @@ -2748,8 +2761,6 @@ impl Wallet { } continue; } - history_only = true; - mirrored = record.filter(|current| current.status == PaymentStatus::Pending); }, } if history_only { From e700587873aa34812c596d13f70c0749637bf930 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Sat, 5 Sep 2026 01:21:22 -0500 Subject: [PATCH 31/37] Abort a splice when funding signing fails The signing handler previously logged and dropped both failure paths (with TODOs to abort once LDK supported it), leaving the negotiation dangling until a peer disconnect abandons it. Cancel the contributed funding instead. LDK then emits DiscardFunding, releasing whatever the wallet holds for the contribution, and SpliceNegotiationFailed, which surfaces the failure and settles the persisted intent. Cancel errors are only logged: every error case means the splice is already beyond canceling. When LDK refuses the already-signed transaction, the failure report that cancelling produces also takes back the payment recorded at signing time: the round is gone from the channel's history and nothing can ever broadcast it, so left in place the record would wait forever on a payment nothing can confirm. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/event.rs | 54 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/src/event.rs b/src/event.rs index 24dd48fc7..80bc438c3 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2325,7 +2325,6 @@ where } } }, - // TODO(splicing): Revisit error handling once splicing API is settled in LDK 0.3 LdkEvent::FundingTransactionReadyForSigning { channel_id, counterparty_node_id, @@ -2370,22 +2369,59 @@ where ); }, Err(e) => { - // Either the round was reset after its history was read above — LDK - // then reports the failure through `SpliceNegotiationFailed`, whose - // handling takes the record back — or LDK rejected the witnesses, in - // which case the round stays pending in LDK, and the record with it. - // TODO(splicing): cancel the contribution here through - // `ChannelManager::cancel_funding_contributed`; a follow-up wires it. + // The signed transaction never reached LDK, so nothing can ever + // broadcast it: cancel the splice. LDK responds with `DiscardFunding` + // (releasing whatever the wallet holds for the contribution) and + // `SpliceNegotiationFailed` (surfacing the failure, settling the + // persisted intent, and — the round now gone from the channel's + // history — taking back the record written above). If LDK had already + // reset the round when it refused the transaction, that report is on + // its way regardless, and the cancel finds nothing left to cancel. log_error!( self.logger, - "LDK refused the signed funding transaction for channel {}: {:?}", + "LDK refused the signed funding transaction for channel {}, \ + aborting the splice: {:?}", channel_id, e, ); + if let Err(e) = self + .channel_manager + .cancel_funding_contributed(&channel_id, &counterparty_node_id) + { + // Every cancel error means the splice is already beyond canceling + // (e.g. the channel is gone); there is nothing further to unwind. + log_error!( + self.logger, + "Failed to cancel the splice on channel {}: {:?}", + channel_id, + e, + ); + } }, } }, - Err(()) => log_error!(self.logger, "Failed signing funding transaction"), + Err(()) => { + // No record has been written for this transaction yet, so there is nothing to + // unwind: cancel the splice and let LDK's `DiscardFunding` and + // `SpliceNegotiationFailed` events release the contribution and settle the + // persisted intent. + log_error!( + self.logger, + "Failed signing the funding transaction for channel {}, aborting the splice", + channel_id, + ); + if let Err(e) = self + .channel_manager + .cancel_funding_contributed(&channel_id, &counterparty_node_id) + { + log_error!( + self.logger, + "Failed to cancel the splice on channel {}: {:?}", + channel_id, + e, + ); + } + }, }, LdkEvent::SpliceNegotiated { channel_id, From 6f52ca7220f3366cffa1423fcb2ce2aa8e86a7e3 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 1 Sep 2026 19:26:09 -0500 Subject: [PATCH 32/37] Add reason and splice parameters to splice failure events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An application handling SpliceNegotiationFailed had nothing to act on: the event did not say why the splice failed, nor what the failed call had attempted. Both matter for deciding what to do next — a fee bump lost to a disconnect can simply be re-issued, while the splice it meant to bump may still confirm at the prior feerate. Attach a reason, mapped from LDK's NegotiationFailureReason onto an ldk-node-owned enum so the event's serialization and bindings do not change with LDK's, and the parameters of the originating API call, taken from the persisted splice intent when the failure identifies it. Both fields are optional and serialized as odd TLVs: events written by LDK Node v0.7 read back as None, and v0.7 readers ignore the new fields. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 --- src/channel/mod.rs | 5 + src/event.rs | 300 ++++++++++++++++++++++++++++++++++++++++++++- src/lib.rs | 2 +- 3 files changed, 302 insertions(+), 5 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 199206c96..f2e2c4f35 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -632,6 +632,11 @@ pub(crate) struct FailureSettlement<'a> { } impl FailureSettlement<'_> { + /// The parameters of the API call behind the splice the failure identifies, if any. + pub(crate) fn originating_kind(&self) -> Option<&SpliceKind> { + self.matched.as_ref().map(|(_, intent)| &intent.kind) + } + /// Settles the snapshotted intent, if any. Call only once the user-facing failure event is /// durably queued. pub(crate) async fn settle(self) { diff --git a/src/event.rs b/src/event.rs index 80bc438c3..6060e442c 100644 --- a/src/event.rs +++ b/src/event.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, OutPoint, Txid}; +use bitcoin::{Amount, OutPoint, ScriptBuf, Txid}; use lightning::blinded_path::message::NextMessageHop; use lightning::chain::chaininterface::FundingCandidate; use lightning::events::bump_transaction::BumpTransactionEvent; @@ -21,6 +21,7 @@ use lightning::events::bump_transaction::BumpTransactionEvent; use lightning::events::PaidBolt12Invoice; use lightning::events::{ ClosureReason, Event as LdkEvent, FundingInfo, InboundHTLCLocator as LdkInboundHtlcLocator, + NegotiationFailureReason as LdkNegotiationFailureReason, OutboundHTLCLocator as LdkOutboundHtlcLocator, PaymentFailureReason, PaymentPurpose, ReplayEvent, }; @@ -32,9 +33,13 @@ use lightning::util::config::{ChannelConfigOverrides, ChannelConfigUpdate}; use lightning::util::errors::APIError; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, ReadableArgs, Writeable, Writer}; -use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use lightning::{ + impl_writeable_tlv_based, impl_writeable_tlv_based_enum, + impl_writeable_tlv_based_enum_upgradable, +}; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_types::payment::{PaymentHash, PaymentPreimage}; +use lightning_types::string::UntrustedString; use crate::channel::SpliceTracker; use crate::config::{may_announce_channel, Config, PEER_RECONNECTION_INTERVAL}; @@ -52,6 +57,7 @@ use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore; use crate::payment::forwarding_store::{ForwardRecord, ForwardingStore}; +use crate::payment::pending_payment_store::SpliceKind; use crate::payment::store::{ PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; @@ -119,6 +125,155 @@ impl From for HTLCLocator { } } +/// The reason a channel splice failed. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum SpliceFailureReason { + /// The reason was not available. + Unknown, + /// The peer disconnected during negotiation. The splice may be re-initiated once the peer + /// reconnects. + PeerDisconnected, + /// The counterparty explicitly aborted the negotiation. Re-initiating with the same + /// parameters is unlikely to succeed — consider adjusting them or waiting for the + /// counterparty to initiate. + CounterpartyAborted { + /// The counterparty's abort message. + /// + /// This is counterparty-provided data. Use `Display` on [`UntrustedString`] for safe + /// logging. + msg: UntrustedString, + }, + /// An error occurred during interactive transaction negotiation (e.g., the counterparty sent + /// an invalid message). The negotiation was aborted. + NegotiationError { + /// A developer-readable error message. + msg: String, + }, + /// The funding contribution was invalid (e.g., insufficient balance for the splice amount). + /// The splice may be re-initiated with adjusted parameters. + ContributionInvalid, + /// The negotiation was locally canceled. + LocallyCanceled, + /// The channel is closing, so the negotiation cannot continue. See [`Event::ChannelClosed`] + /// for the closure reason. + ChannelClosing, + /// The contribution's feerate was too low to replace the splice's in-flight funding + /// transaction. The fee bump may be re-initiated once feerates allow it. + FeeRateTooLow, + /// A fee bump could not be initiated (e.g., a prior splice funding transaction already + /// confirmed). The channel remains operational. + CannotInitiateRbf, +} + +impl From for SpliceFailureReason { + fn from(reason: LdkNegotiationFailureReason) -> Self { + match reason { + LdkNegotiationFailureReason::Unknown => Self::Unknown, + LdkNegotiationFailureReason::PeerDisconnected => Self::PeerDisconnected, + LdkNegotiationFailureReason::CounterpartyAborted { msg } => { + Self::CounterpartyAborted { msg } + }, + LdkNegotiationFailureReason::NegotiationError { msg } => Self::NegotiationError { msg }, + LdkNegotiationFailureReason::ContributionInvalid => Self::ContributionInvalid, + LdkNegotiationFailureReason::LocallyCanceled => Self::LocallyCanceled, + LdkNegotiationFailureReason::ChannelClosing => Self::ChannelClosing, + LdkNegotiationFailureReason::FeeRateTooLow => Self::FeeRateTooLow, + LdkNegotiationFailureReason::CannotInitiateRbf => Self::CannotInitiateRbf, + } + } +} + +impl_writeable_tlv_based_enum_upgradable!(SpliceFailureReason, + (1, Unknown) => {}, + (3, PeerDisconnected) => {}, + (5, CounterpartyAborted) => { + (1, msg, required), + }, + (7, NegotiationError) => { + (1, msg, required), + }, + (9, ContributionInvalid) => {}, + (11, LocallyCanceled) => {}, + (13, ChannelClosing) => {}, + (15, FeeRateTooLow) => {}, + (17, CannotInitiateRbf) => {}, +); + +/// An output paid from a channel by a splice-out. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct SpliceOutput { + /// The amount paid to the output, in satoshis. + pub amount_sats: u64, + /// The script the output pays to. + pub script_pubkey: ScriptBuf, +} + +impl_writeable_tlv_based!(SpliceOutput, { + (0, amount_sats, required), + (2, script_pubkey, required), +}); + +/// The parameters of the [`Node`] API call that initiated a splice. +/// +/// [`Node`]: crate::Node +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum SpliceParameters { + /// Funds were added to the channel via [`Node::splice_in`] or [`Node::splice_in_with_all`]. + /// + /// [`Node::splice_in`]: crate::Node::splice_in + /// [`Node::splice_in_with_all`]: crate::Node::splice_in_with_all + In { + /// The amount added to the channel, in satoshis. For [`Node::splice_in_with_all`], the + /// amount the available funds resolved to. + /// + /// [`Node::splice_in_with_all`]: crate::Node::splice_in_with_all + amount_sats: u64, + }, + /// Funds were removed from the channel via [`Node::splice_out`]. + /// + /// [`Node::splice_out`]: crate::Node::splice_out + Out { + /// The outputs paid from the channel. + outputs: Vec, + }, + /// The splice's in-flight funding transaction was fee-bumped via + /// [`Node::bump_channel_funding_fee`]. + /// + /// [`Node::bump_channel_funding_fee`]: crate::Node::bump_channel_funding_fee + FeeBump, +} + +impl From<&SpliceKind> for SpliceParameters { + fn from(kind: &SpliceKind) -> Self { + match kind { + SpliceKind::In { amount_sats } => Self::In { amount_sats: *amount_sats }, + SpliceKind::Out { outputs } => Self::Out { + outputs: outputs + .iter() + .map(|o| SpliceOutput { + amount_sats: o.value.to_sat(), + script_pubkey: o.script_pubkey.clone(), + }) + .collect(), + }, + SpliceKind::Rbf {} => Self::FeeBump, + } + } +} + +impl_writeable_tlv_based_enum_upgradable!(SpliceParameters, + (1, In) => { + (1, amount_sats, required), + }, + (3, Out) => { + (1, outputs, required_vec), + }, + (5, FeeBump) => {}, +); + /// An event emitted by [`Node`], which should be handled by the user. /// /// [`Node`]: [`crate::Node`] @@ -312,7 +467,11 @@ pub enum Event { /// The outpoint of the channel's splice funding transaction. new_funding_txo: OutPoint, }, - /// A channel splice negotiation round with local inputs or outputs has failed. + /// A channel splice negotiation round with local inputs or outputs, or a fee bump of a + /// splice's funding transaction, has failed. + /// + /// A failed fee bump leaves the splice it meant to bump unaffected; in particular, the + /// splice's in-flight funding transaction may still confirm. /// /// This event is not emitted when only the counterparty contributes to a splice. SpliceNegotiationFailed { @@ -322,6 +481,18 @@ pub enum Event { user_channel_id: UserChannelId, /// The `node_id` of the channel counterparty. counterparty_node_id: PublicKey, + /// The reason the splice failed. + /// + /// Will be `None` for events serialized by LDK Node v0.7. + reason: Option, + /// The parameters of the [`Node`] API call that initiated the failed splice or fee bump. + /// + /// Will be `None` when the failure does not identify the channel's last locally-initiated + /// splice — e.g. when a fee bump superseded the failed attempt — and for events + /// serialized by LDK Node v0.7. + /// + /// [`Node`]: crate::Node + parameters: Option, }, } @@ -406,6 +577,8 @@ impl_writeable_tlv_based_enum!(Event, (3, counterparty_node_id, required), (5, user_channel_id, required), // TLV 7 (abandoned_funding_txo) may be set for LDK Node v0.7. + (9, reason, upgradable_option), + (11, parameters, upgradable_option), }, ); @@ -2477,8 +2650,8 @@ where channel_id, user_channel_id, counterparty_node_id, + reason, contribution, - .. } => { log_info!( self.logger, @@ -2520,10 +2693,14 @@ where .on_negotiation_failed(counterparty_node_id, channel_id, contribution.as_ref()) .await; + let parameters = settlement.originating_kind().map(SpliceParameters::from); + let event = Event::SpliceNegotiationFailed { channel_id, user_channel_id: UserChannelId(user_channel_id), counterparty_node_id, + reason: Some(reason.into()), + parameters, }; match self.event_queue.add_event(event).await { @@ -2672,6 +2849,11 @@ mod tests { claim_from_onchain_tx: bool, outbound_amount_forwarded_msat: Option, }, + SpliceNegotiationFailed { + channel_id: ChannelId, + user_channel_id: UserChannelId, + counterparty_node_id: PublicKey, + }, } impl_writeable_tlv_based_enum!(LegacyEvent, @@ -2689,6 +2871,11 @@ mod tests { (15, prev_htlcs, (default_value_vec, Vec::new())), (17, next_htlcs, (default_value_vec, Vec::new())), }, + (9, SpliceNegotiationFailed) => { + (1, channel_id, required), + (3, counterparty_node_id, required), + (5, user_channel_id, required), + }, ); fn encode_legacy_event_queue(event: LegacyEvent) -> Vec { @@ -2750,6 +2937,111 @@ mod tests { assert!(res.is_err()); } + #[test] + fn event_queue_reads_legacy_splice_negotiation_failed() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channel_id = ChannelId([42u8; 32]); + let user_channel_id = UserChannelId(4242); + let legacy_event = LegacyEvent::SpliceNegotiationFailed { + channel_id, + user_channel_id, + counterparty_node_id, + }; + let persisted_bytes = encode_legacy_event_queue(legacy_event); + + let event_queue = + EventQueue::read(&mut &persisted_bytes[..], (Arc::clone(&store), logger)).unwrap(); + assert_eq!( + event_queue.next_event(), + Some(Event::SpliceNegotiationFailed { + channel_id, + user_channel_id, + counterparty_node_id, + reason: None, + parameters: None, + }) + ); + } + + #[tokio::test] + async fn splice_negotiation_failed_round_trips_reason_and_parameters() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let event_queue = Arc::new(EventQueue::new(Arc::clone(&store), Arc::clone(&logger))); + + let expected_event = Event::SpliceNegotiationFailed { + channel_id: ChannelId([42u8; 32]), + user_channel_id: UserChannelId(4242), + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + reason: Some(SpliceFailureReason::CounterpartyAborted { + msg: UntrustedString("no thanks".to_string()), + }), + parameters: Some(SpliceParameters::Out { + outputs: vec![SpliceOutput { + amount_sats: 10_000, + script_pubkey: ScriptBuf::new(), + }], + }), + }; + event_queue.add_event(expected_event.clone()).await.unwrap(); + + let persisted_bytes = KVStore::read( + &*store, + EVENT_QUEUE_PERSISTENCE_PRIMARY_NAMESPACE, + EVENT_QUEUE_PERSISTENCE_SECONDARY_NAMESPACE, + EVENT_QUEUE_PERSISTENCE_KEY, + ) + .await + .unwrap(); + let deser_event_queue = + EventQueue::read(&mut &persisted_bytes[..], (Arc::clone(&store), logger)).unwrap(); + assert_eq!(deser_event_queue.next_event(), Some(expected_event)); + } + + #[test] + fn legacy_reader_ignores_splice_failure_reason_and_parameters() { + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channel_id = ChannelId([42u8; 32]); + let user_channel_id = UserChannelId(4242); + let event = Event::SpliceNegotiationFailed { + channel_id, + user_channel_id, + counterparty_node_id, + reason: Some(SpliceFailureReason::PeerDisconnected), + parameters: Some(SpliceParameters::In { amount_sats: 10_000 }), + }; + + // The new fields use odd TLVs, so a reader without them — LDK Node v0.7 — must + // still read the event. + let mut bytes = Vec::new(); + 1u16.write(&mut bytes).unwrap(); + event.write(&mut bytes).unwrap(); + + let mut reader = &bytes[..]; + let num_events: u16 = Readable::read(&mut reader).unwrap(); + assert_eq!(num_events, 1); + let legacy_event: LegacyEvent = Readable::read(&mut reader).unwrap(); + assert_eq!( + legacy_event, + LegacyEvent::SpliceNegotiationFailed { + channel_id, + user_channel_id, + counterparty_node_id, + } + ); + } + #[test] fn event_queue_defaults_legacy_missing_forwarded_amount() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/lib.rs b/src/lib.rs index 711772a2d..3676b16bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -142,7 +142,7 @@ use config::{ use connection::ConnectionManager; pub use error::Error as NodeError; use error::Error; -pub use event::Event; +pub use event::{Event, SpliceFailureReason, SpliceOutput, SpliceParameters}; use event::{EventHandler, EventQueue}; use fee_estimator::{ max_funding_feerate, rbf_splice_feerates, ConfirmationTarget, FeeEstimator, OnchainFeeEstimator, From 5813925450b59685e223fc9b7755f7dbecdff049 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Sun, 6 Sep 2026 21:38:48 -0500 Subject: [PATCH 33/37] Unlock lost splice inputs at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LDK only persists a splice once its negotiation reaches AwaitingSignatures, so a splice in flight when the node stops can leave no trace in LDK's channel state, and no event of LDK's ever returns what the wallet reserved for it — today the addresses its outputs pay; once At startup, reconcile each persisted splice intent against live channel state: release the reservations of a splice LDK no longer holds and drop its record, re-anchor a queued splice whose predecessor locked while the node was down, and keep — minus any inputs no surviving round still claims — those LDK resumes on its own. A splice whose channel closed meanwhile is released only if no round of it reached signing: a signed round is one the channel's monitor watches until the close matures, and what it reserved is spent by it or returned through DiscardFunding then. Reconciliation holds the lock that serializes splice submissions, as the event handlers settling intents do. Recovery fabricates no failure event for a splice lost this way: the initiating call already returned, and the channel simply no longer shows a pending splice. LDK itself reports the loss of a contribution it was still queueing or negotiating when it was last persisted — it fails the contribution as it is written and replays the failure at startup. The replay runs after reconciliation, so that report carries the splice's parameters only where reconciliation kept the intent: for a splice queued behind a pending one of ours, or a fee bump of one, but not for a channel's only splice, whose intent reconciliation settled. Reconciliation runs before background syncing and broadcasting start, so nothing can act on the stale reservations first. Events LDK replays from its last persisted state (e.g. a DiscardFunding for a splice that died before the node stopped) are likewise consumed before the node is running, so they cannot act on state a new user operation set up since. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Fable 5.1 --- src/channel/mod.rs | 272 +++++++++++++++++++++++++-- src/lib.rs | 44 ++++- src/payment/pending_payment_store.rs | 9 + src/wallet/mod.rs | 10 +- 4 files changed, 312 insertions(+), 23 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index f2e2c4f35..1c66d6f67 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -17,13 +17,13 @@ use bitcoin::transaction::Version; use bitcoin::{OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid}; use lightning::chain::chaininterface::FundingCandidate; use lightning::chain::transaction::OutPoint as LdkOutPoint; -use lightning::ln::channel_state::{ChannelDetails, SpliceCandidateDetails}; +use lightning::ln::channel_state::{ChannelDetails, SpliceCandidateDetails, SpliceCandidateStatus}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::funding::FundingContribution; use lightning::ln::types::ChannelId; use crate::data_store::{StorableObject, UpdatableObject}; -use crate::logger::{log_error, LdkLogger, Logger}; +use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::payment::pending_payment_store::{ PendingPaymentDetails, PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind, }; @@ -56,12 +56,13 @@ pub(crate) fn is_same_splice(a: &FundingContribution, b: &FundingContribution) - /// /// The intent is written before the contribution is handed to LDK, undone when LDK rejects the /// hand-off synchronously, and cleared once the splice locks, its failure is surfaced, or its -/// channel closes. The record exists for recovery, not retry: a splice still recorded at the -/// next startup identifies one that was in flight when the node stopped, so anything it reserved -/// can be released, and events about the splice can be described in terms of the original -/// request. Each splice has a record of its own — a channel may carry several, a pending splice -/// and the splices queued behind it — so that each is recognized and described whatever became -/// of the others; only a fee bump joins the record of the round it replaces. +/// channel closes. The record exists for recovery, not retry: a splice still recorded at the next +/// startup identifies one that was in flight when the node stopped, so [`Self::reconcile`] can +/// release what it still reserves where nothing else will, and events about the splice can be +/// described in terms of the original request. Each splice has a record of its own — a channel may +/// carry several, a pending splice and the splices queued behind it — so that each is recognized +/// and described whatever became of the others; only a fee bump joins the record of the round it +/// replaces. pub(crate) struct SpliceTracker { channel_manager: Arc, wallet: Arc, @@ -69,8 +70,9 @@ pub(crate) struct SpliceTracker { /// Serializes everything that reads or settles a channel's intent records against /// [`Self::submit`]'s read-funding, persist and hand-off sequence: the settling of intents by /// [`Self::on_negotiation_failed`], [`Self::on_channel_ready`] and - /// [`Self::on_channel_closed`], and the funding record [`Self::on_funding_ready_for_signing`] - /// files under an intent's id. Without it, the failure event of a synchronously rejected + /// [`Self::on_channel_closed`], the funding record [`Self::on_funding_ready_for_signing`] + /// files under an intent's id, and the startup pass of [`Self::reconcile`]. Without it, the + /// failure event of a synchronously rejected /// hand-off could settle the just-written intent while `submit` is still deciding whether to /// keep it, and a lock event handled between `submit`'s funding read and its persist could /// leave the new intent anchored at a funding the channel has moved past, which nothing would @@ -96,6 +98,127 @@ impl SpliceTracker { } } + /// Reconciles the persisted splice intents against live channel state, releasing whatever the + /// wallet still holds for a splice that did not survive the restart and nothing else will + /// release. LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, so a + /// splice lost earlier leaves no trace in LDK's channel state — the intent record is what + /// recognizes the loss. A round LDK did write is another matter: LDK either still holds it, or + /// failed it as it was last written — the failure is replayed at startup — and returns what it + /// reserved through `DiscardFunding`; a round of a channel that closed meanwhile is watched by + /// the channel's monitor until the close matures. Such rounds are left to those events. Run + /// once at startup, before background chain syncing and event processing start, so nothing can + /// act on the stale reservations first. Holds the submit lock throughout, as the event handlers + /// do. + /// + /// Recovery fabricates no failure event for a splice lost this way: the initiating call + /// already returned and the channel simply shows no pending splice anymore. LDK itself may + /// report the loss — a contribution it was still queueing or negotiating when it was last + /// persisted is failed as it is written, and the failure replayed at startup. That replay + /// runs after this reconciliation, so the report carries the splice's parameters only if + /// `decide_reconcile` kept the intent: a splice queued behind a pending one of ours, or a fee + /// bump of one, is reported with its parameters; a channel's only splice, whose intent + /// settled here, without them. + pub(crate) async fn reconcile(&self) { + let guard = self.submit_lock.lock().await; + let records = self.pending_payment_store.list_filter(|p| p.splice_intent().is_some()).await; + for record in records { + let payment_id = record.id(); + let Some(intent) = record.splice_intent().cloned() else { + continue; + }; + + let channel = self.channel(intent.counterparty_node_id, intent.channel_id); + let Some(channel) = channel else { + // The channel is gone; there is nothing to splice anymore. What the wallet holds + // for the intent is released only while no recorded round exists: a round the + // closed channel's monitor watches is either spent by the close or returned through + // the `DiscardFunding` event the monitor queues once the close matures, and a + // recorded round the monitor never watched — the counterparty's `commitment_signed` + // never arrived before the node stopped — is released by neither, as at + // `ChannelClosed`. A bare intent has no such round — LDK never wrote the splice — + // so nothing else would release it. + log_info!( + self.logger, + "Dropping the recorded splice of closed channel {} with counterparty {}", + intent.channel_id, + intent.counterparty_node_id, + ); + if record.candidates().is_empty() { + self.release_contribution(intent.channel_id, &intent.contribution, &[], None) + .await; + } + // TODO(#1037): once inputs are locked at coin selection, the parts of the + // contribution no recorded round uses stay locked with no record to release them + // from after the intent is cleared here: release them before clearing. And + // `release_contribution` swallows a failed release, which then leaves locks no + // record names either: keep the intent when the release fails. The same holds for a + // recorded round the monitor never watched: nothing releases its inputs once the + // intent is cleared here. + self.clear_persisted_intent(payment_id, |i| *i == intent).await; + continue; + }; + + if channel.funding_txo != Some(intent.pre_splice_funding_txo) { + // The funding moved on while the node was down: the recorded splice, a + // replacement, or a counterparty splice locked — the same situation a live lock + // event resolves, so resolve it the same way. + if let Some(funding_txo) = channel.funding_txo { + self.settle_superseded_intents_locked( + &guard, + intent.counterparty_node_id, + intent.channel_id, + funding_txo.into_bitcoin_outpoint(), + Some(&channel), + ) + .await; + } + continue; + } + + let candidates = channel + .splice_details + .as_ref() + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]); + match decide_reconcile(candidates) { + ReconcileDecision::Keep => { + // A kept record may still reserve more than LDK's surviving rounds use — + // extras a fee bump lost with the restart had reserved. Release the + // difference. + let extras = unclaimed_inputs(&intent.contribution, candidates); + if let Err(e) = self.wallet.unlock_outpoints(&extras).await { + log_error!( + self.logger, + "Failed to release unused splice inputs on channel {}: {}", + intent.channel_id, + e, + ); + } + }, + ReconcileDecision::Lost => { + log_info!( + self.logger, + "Dropping a splice on channel {} with counterparty {} that did not survive \ + the restart", + intent.channel_id, + intent.counterparty_node_id, + ); + self.release_contribution( + intent.channel_id, + &intent.contribution, + candidates, + None, + ) + .await; + // TODO(#1037): `release_contribution` swallows a failed release. Once inputs + // are locked at coin selection, a failure here leaves locks no record names + // after the intent is cleared: keep the intent when the release fails. + self.clear_persisted_intent(payment_id, |i| *i == intent).await; + }, + } + } + } + /// Persists a user-initiated splice as an intent and hands its contribution to /// [`ChannelManager::funding_contributed`]. The intent — and any wallet state staged on the /// splice's behalf — is durable before the hand-off, so no splice is ever in flight without a @@ -483,8 +606,10 @@ impl SpliceTracker { } /// Settles any persisted intent made obsolete by the channel's funding having moved on to - /// `funding_txo`: the funding a `ChannelReady` event reports as locked, or the one a new - /// splice builds on ([`Self::submit`]). Each of the channel's intents is decided on its own + /// `funding_txo`: the funding a `ChannelReady` event reports as locked, the one a new splice + /// builds on ([`Self::submit`]), or the one [`Self::reconcile`] finds the channel at after a + /// funding moved while the node was down — the same situation, minus the event. Each of the + /// channel's intents is decided on its own /// ([`decide_on_lock`]), against the splice candidates LDK holds for the channel (`channel`, /// as the caller listed it): one whose pre-splice outpoint is that funding was created after /// the lock and stays; one LDK still holds as a queued splice candidate is re-anchored to the @@ -894,6 +1019,52 @@ fn record_with_intent_cleared(existing: &PendingPaymentDetails) -> Option ReconcileDecision { + // A round short of `Negotiated` is one LDK still drives on its own: only `AwaitingSignatures` + // survives a restart, and LDK resumes the signature exchange itself on reconnect. + let in_flight = candidates + .iter() + .any(|candidate| !matches!(candidate.status, SpliceCandidateStatus::Negotiated { .. })); + if in_flight { + return ReconcileDecision::Keep; + } + + // LDK persists a splice once negotiated, so a negotiated candidate carrying a local + // contribution is a splice of ours LDK sees through to lock — even one negotiated at a + // different feerate than a recorded fee bump asked for. Without one, only counterparty + // rounds (or nothing) survived: the recorded splice is gone. + if candidates.iter().any(|candidate| candidate.contribution.is_some()) { + ReconcileDecision::Keep + } else { + ReconcileDecision::Lost + } +} + +/// The inputs `contribution` reserved that no candidate's own contribution still claims — extras +/// a splice attempt lost with the restart had reserved. A counterparty-only round carries no +/// contribution and claims nothing. +fn unclaimed_inputs( + contribution: &FundingContribution, candidates: &[SpliceCandidateDetails], +) -> Vec { + let claimants = candidates.iter().filter_map(|candidate| candidate.contribution.as_ref()); + unclaimed_parts(contribution, claimants).0 +} + #[cfg(test)] mod tests { use std::str::FromStr; @@ -904,8 +1075,8 @@ mod tests { use super::*; use crate::payment::pending_payment_store::{ test_funding_contribution, test_funding_contribution_with_feerate, - test_funding_contribution_with_outputs, test_funding_contribution_with_parts, - FundingTxCandidate, + test_funding_contribution_with_inputs, test_funding_contribution_with_outputs, + test_funding_contribution_with_parts, FundingTxCandidate, }; use crate::payment::store::{ConfirmationStatus, PaymentDetails, PaymentKind}; use crate::payment::{PaymentDirection, PaymentStatus}; @@ -1337,4 +1508,77 @@ mod tests { (prevtxs.iter().map(outpoint).collect(), vec![splice_out, change(20_000)]) ); } + + /// While any round is short of `Negotiated`, LDK drives the splice itself; the intent stays + /// in place until the splice settles. + #[test] + fn reconcile_keeps_the_intent_while_ldk_drives_a_round() { + let in_flight = SpliceCandidateDetails { + contribution: Some(test_funding_contribution()), + status: SpliceCandidateStatus::AwaitingSignatures { + is_initiator: true, + funding_feerate_sat_per_1000_weight: 253, + new_channel_value_satoshis: 100_000, + txid: Txid::from_byte_array([9u8; 32]), + }, + }; + assert_eq!(decide_reconcile(&[in_flight]), ReconcileDecision::Keep); + } + + /// A negotiated candidate carrying a local contribution is a splice LDK sees through to lock; + /// nothing was lost. This holds on zero-conf channels too, where the pre-splice funding + /// outpoint has not moved on yet. + #[test] + fn reconcile_trusts_a_negotiated_contribution() { + let negotiated = [negotiated_candidate(Some(test_funding_contribution()))]; + assert_eq!(decide_reconcile(&negotiated), ReconcileDecision::Keep); + } + + /// A fee bump that only survives as a candidate negotiated at a lower feerate than requested + /// is not lost: the recorded bump is moot, but the splice lives on and locks. The old + /// higher-feerate attempt's extra reservations are released through the input difference, not + /// by dropping the record. + #[test] + fn reconcile_keeps_a_bump_negotiated_at_a_lower_feerate() { + let lower = [negotiated_candidate(Some(test_funding_contribution_with_feerate(253)))]; + assert_eq!(decide_reconcile(&lower), ReconcileDecision::Keep); + } + + /// With no contribution of ours in LDK — no splice at all, or only a counterparty round — the + /// recorded splice died with the restart. + #[test] + fn reconcile_finds_the_splice_lost_when_ldk_holds_no_contribution() { + assert_eq!(decide_reconcile(&[]), ReconcileDecision::Lost); + let counterparty_only = [negotiated_candidate(None)]; + assert_eq!(decide_reconcile(&counterparty_only), ReconcileDecision::Lost); + } + + /// The inputs a kept record reserves beyond what LDK's candidates still claim are identified + /// for release; a counterparty-only round claims nothing and must not suppress the + /// difference. + #[test] + fn unclaimed_inputs_are_those_no_candidate_contribution_uses() { + let prevtxs: Vec = (1u8..=3).map(test_prevtx).collect(); + let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 }; + let recorded = test_funding_contribution_with_inputs(253, &prevtxs); + + // Every input still claimed by a surviving candidate: nothing to release. + let all = + [negotiated_candidate(Some(test_funding_contribution_with_inputs(253, &prevtxs)))]; + assert!(unclaimed_inputs(&recorded, &all).is_empty()); + + // A candidate claiming two of the three inputs: the third is released, even with a + // counterparty-only round alongside. + let partial = [ + negotiated_candidate(None), + negotiated_candidate(Some(test_funding_contribution_with_inputs(253, &prevtxs[..2]))), + ]; + assert_eq!(unclaimed_inputs(&recorded, &partial), vec![outpoint(&prevtxs[2])]); + + // No candidates at all: everything is released. + assert_eq!( + unclaimed_inputs(&recorded, &[]), + prevtxs.iter().map(outpoint).collect::>() + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 3676b16bd..cdf58a404 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -370,6 +370,11 @@ impl Node { ) })?; + // Release whatever the wallet still holds for splices that did not survive the restart — + // before background syncing and broadcasting start below, so nothing can act on the stale + // reservations first. + self.runtime.block_on(self.splice_tracker.reconcile()); + // A splice round recorded when this node signed it is taken back once LDK reports the // negotiation failed or the channel closed. LDK reports the loss of a negotiation its last // channel manager write carried mid-way, but a round committed, negotiated and signed @@ -731,6 +736,15 @@ impl Node { }); } + // Consume any events LDK replays from its last persisted state (e.g. a `DiscardFunding` + // for a splice that died before the node stopped) before the node is running: a replayed + // event describes pre-restart state and must act before new user operations build on it. + let replay_handler = &event_handler; + self.runtime.block_on( + self.channel_manager + .process_pending_events_async(|event| replay_handler.handle_event(event)), + ); + // Setup background processing let background_persister = Arc::clone(&self.kv_store); let background_event_handler = Arc::clone(&event_handler); @@ -1883,7 +1897,12 @@ impl Node { /// /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice - /// may be initiated once the cause of the failure is addressed. + /// may be initiated once the cause of the failure is addressed. A splice still pending when the + /// node stops is resumed by LDK when possible; otherwise it is dropped at the next startup, + /// releasing anything reserved for it. A splice LDK was still queueing or negotiating when the + /// node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if a splice this node contributed to is still pending on the channel; one + /// lost earlier is dropped without a failure event. /// /// # Experimental API /// @@ -1911,7 +1930,12 @@ impl Node { /// /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice - /// may be initiated once the cause of the failure is addressed. + /// may be initiated once the cause of the failure is addressed. A splice still pending when the + /// node stops is resumed by LDK when possible; otherwise it is dropped at the next startup, + /// releasing anything reserved for it. A splice LDK was still queueing or negotiating when the + /// node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if a splice this node contributed to is still pending on the channel; one + /// lost earlier is dropped without a failure event. /// /// # Experimental API /// @@ -1931,7 +1955,12 @@ impl Node { /// /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice - /// may be initiated once the cause of the failure is addressed. + /// may be initiated once the cause of the failure is addressed. A splice still pending when the + /// node stops is resumed by LDK when possible; otherwise it is dropped at the next startup, + /// releasing anything reserved for it. A splice LDK was still queueing or negotiating when the + /// node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if a splice this node contributed to is still pending on the channel; one + /// lost earlier is dropped without a failure event. /// /// # Experimental API /// @@ -2033,8 +2062,13 @@ impl Node { /// Errors if the channel has no pending splice to bump. /// /// A fee bump that fails during negotiation (e.g. because the peer disconnected) is reported - /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; the fee may - /// be bumped again once the cause of the failure is addressed. + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; the fee may be + /// bumped again once the cause of the failure is addressed. A fee bump still pending when the + /// node stops is resumed by LDK when possible; otherwise it is dropped at the next startup, + /// releasing anything reserved for it. A fee bump LDK was still queueing or negotiating when + /// the node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if this node contributed to the splice it bumps; one lost earlier is dropped + /// without a failure event. pub fn bump_channel_funding_fee( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, ) -> Result<(), Error> { diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 560985c49..6662e7c9c 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -550,6 +550,15 @@ pub(crate) fn test_funding_contribution_with_feerate( test_funding_contribution_with_outputs(0, feerate, &[]) } +/// Like [`test_funding_contribution`], but with the given input-selection feerate in sat/kwu and +/// an input spending output 0 — which must be P2WPKH — of each given previous transaction. +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_inputs( + feerate: u64, prevtxs: &[bitcoin::Transaction], +) -> FundingContribution { + test_funding_contribution_with_parts(0, feerate, prevtxs, &[], None) +} + #[cfg(test)] mod tests { use bitcoin::hashes::Hash; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3a057e186..2722c1e79 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2720,10 +2720,12 @@ impl Wallet { // splice intent the entry carries outlives the record as a bare intent: // the failure LDK reports for the round is described from it, and its // settlement removes it (`SpliceTracker::on_negotiation_failed`); one left - // behind by a node that stopped in between is found and settled by - // whatever next concerns the channel's splice. The intent is read from the - // entry as it stands, not as listed above: a fee bump submitted since may - // have replaced it, and that intent must stay just the same. + // behind by a node that stopped in between is found by + // `SpliceTracker::reconcile` at the next startup, which settles it once LDK + // holds no round of ours, or by whatever next concerns the channel's + // splice. The intent is read from the entry as it stands, not as listed + // above: a fee bump submitted since may have replaced it, and that intent + // must stay just the same. self.payment_store.remove(&payment_id).await?; let kept_intent = self .pending_payment_store From 43bc45d910c7b3d2337d87c5eb886fc5bbea652d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 10 Sep 2026 23:02:01 -0500 Subject: [PATCH 34/37] f - Unlock lost splice inputs at startup Reword what the reconciliation comments say about rounds LDK wrote, so they hold once the pinned LDK carries the fix for rust-lightning issue 4967: LDK then reports a `DiscardFunding` at a force-close for a recorded round the monitor never watched, which nothing released before. Split the `TODO(#1037)` note into the part that fix removes and the case that stays: a hand-off LDK never wrote, whose channel is force-closed as stale at startup, gets no event and must be released here. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/channel/mod.rs | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 1c66d6f67..7d9964ee0 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -103,9 +103,9 @@ impl SpliceTracker { /// release. LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, so a /// splice lost earlier leaves no trace in LDK's channel state — the intent record is what /// recognizes the loss. A round LDK did write is another matter: LDK either still holds it, or - /// failed it as it was last written — the failure is replayed at startup — and returns what it - /// reserved through `DiscardFunding`; a round of a channel that closed meanwhile is watched by - /// the channel's monitor until the close matures. Such rounds are left to those events. Run + /// reports its failure at startup and returns what it reserved and no other round of the + /// channel uses through `DiscardFunding`; a round of a channel that closed meanwhile is watched + /// by the channel's monitor until the close matures. Such rounds are left to those events. Run /// once at startup, before background chain syncing and event processing start, so nothing can /// act on the stale reservations first. Holds the submit lock throughout, as the event handlers /// do. @@ -134,9 +134,11 @@ impl SpliceTracker { // closed channel's monitor watches is either spent by the close or returned through // the `DiscardFunding` event the monitor queues once the close matures, and a // recorded round the monitor never watched — the counterparty's `commitment_signed` - // never arrived before the node stopped — is released by neither, as at - // `ChannelClosed`. A bare intent has no such round — LDK never wrote the splice — - // so nothing else would release it. + // never arrived before the node stopped — is released only by the `DiscardFunding` + // LDK reports for it at a force-close once + // https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4967 is fixed, + // as at `ChannelClosed`; nothing releases it before that fix. A bare intent has no + // such round — LDK never wrote the splice — so nothing else would release it. log_info!( self.logger, "Dropping the recorded splice of closed channel {} with counterparty {}", @@ -151,9 +153,15 @@ impl SpliceTracker { // contribution no recorded round uses stay locked with no record to release them // from after the intent is cleared here: release them before clearing. And // `release_contribution` swallows a failed release, which then leaves locks no - // record names either: keep the intent when the release fails. The same holds for a - // recorded round the monitor never watched: nothing releases its inputs once the - // intent is cleared here. + // record names either: keep the intent when the release fails. A recorded round the + // monitor never watched is released only by the `DiscardFunding` LDK reports for it + // at a force-close once the fix above is in the pinned LDK; nothing releases its + // inputs before that. One case stays after the fix: a hand-off LDK never wrote — + // the node stopped before the manager's next write — whose channel is force-closed + // as stale at this start and whose round the monitor never watched. LDK knows + // nothing of that round and reports no event for it; release its contribution here, + // which takes the monitor's watched transactions to tell such a round from a + // watched one, as `closed_channel_held_rounds` does at `ChannelClosed`. self.clear_persisted_intent(payment_id, |i| *i == intent).await; continue; }; From 6b83504779071ee0d7d361849c4c0d2dfde17d17 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 21 Sep 2026 16:32:23 -0700 Subject: [PATCH 35/37] f - Unlock lost splice inputs at startup The pinned LDK now reports the `DiscardFunding` for a signed round the monitor never watched at the force-close (rust-lightning issue 4967), so the comments on a gone channel's intent no longer hedge on it, and the TODO on releasing that round's inputs keeps only the case LDK never learns of: a hand-off it never wrote. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/channel/mod.rs | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 7d9964ee0..ad5451125 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -134,11 +134,9 @@ impl SpliceTracker { // closed channel's monitor watches is either spent by the close or returned through // the `DiscardFunding` event the monitor queues once the close matures, and a // recorded round the monitor never watched — the counterparty's `commitment_signed` - // never arrived before the node stopped — is released only by the `DiscardFunding` - // LDK reports for it at a force-close once - // https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4967 is fixed, - // as at `ChannelClosed`; nothing releases it before that fix. A bare intent has no - // such round — LDK never wrote the splice — so nothing else would release it. + // never arrived before the node stopped — by the `DiscardFunding` LDK reports for + // it at the force-close, as at `ChannelClosed`. A bare intent has no such round — + // LDK never wrote the splice — so nothing else would release it. log_info!( self.logger, "Dropping the recorded splice of closed channel {} with counterparty {}", @@ -153,15 +151,13 @@ impl SpliceTracker { // contribution no recorded round uses stay locked with no record to release them // from after the intent is cleared here: release them before clearing. And // `release_contribution` swallows a failed release, which then leaves locks no - // record names either: keep the intent when the release fails. A recorded round the - // monitor never watched is released only by the `DiscardFunding` LDK reports for it - // at a force-close once the fix above is in the pinned LDK; nothing releases its - // inputs before that. One case stays after the fix: a hand-off LDK never wrote — - // the node stopped before the manager's next write — whose channel is force-closed - // as stale at this start and whose round the monitor never watched. LDK knows - // nothing of that round and reports no event for it; release its contribution here, - // which takes the monitor's watched transactions to tell such a round from a - // watched one, as `closed_channel_held_rounds` does at `ChannelClosed`. + // record names either: keep the intent when the release fails. One case stays: a + // hand-off LDK never wrote — the node stopped before the manager's next write — + // whose channel is force-closed as stale at this start and whose round the monitor + // never watched. LDK knows nothing of that round and reports no event for it; + // release its contribution here, which takes the monitor's watched transactions to + // tell such a round from a watched one, as `closed_channel_held_rounds` does at + // `ChannelClosed`. self.clear_persisted_intent(payment_id, |i| *i == intent).await; continue; }; From b81c2b5807d3102a0419cf87eef2d45dc28964a8 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 1 Sep 2026 19:53:45 -0500 Subject: [PATCH 36/37] Test splice failure surfacing and recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A disconnect during the interactive negotiation fails the splice with PeerDisconnected. The first test asserts that exactly one SpliceNegotiationFailed reaches the user — carrying the reason and the originating request's parameters — and that a new splice initiated afterwards completes with a single funding payment. The window only exists mid-negotiation: a contribution still queued at disconnect is resumed by LDK itself on reconnect, and one awaiting signatures survives re-establishment. The test therefore synchronizes on the counterparty's splice_ack — logged by LDK's peer handler — and stretches the negotiation by funding the splice from many small UTXOs, each of which adds an interactive-tx round trip. A splice dropped by a restart is recovered silently: startup reconciliation releases what the wallet reserved and drops the record without fabricating a failure event. What does reach the user is the failure LDK persisted at shutdown and replays at startup — once, with parameters only when it still matches a kept record. The restart tests cover both cases: a dropped splice-out surfaces without parameters and a further restart stays silent, while a dropped fee bump — whose record reconciliation keeps, since LDK still holds the negotiated splice — surfaces with the bump's parameters. In both, the application re-initiates and the splice completes. A splice confirmed while its node was offline keeps exactly one payment record under its splice-time id regardless of whether wallet sync or classification sees the confirmation first. Three more cases: a second splice submitted right after a zero-conf lock gets a record of its own rather than being folded into the record of the splice that just locked; a queued splice the node stopped on, which LDK fails as it shuts down, is reported at startup with its parameters — its record, an intent that never became a payment, outlives the pending splice's graduation, and reconciliation keeps it while LDK still holds that splice; and a funding record left half-written by a stop between the signing write's two stores is dropped at the next startup instead of lingering as a payment nothing indexes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Fable 5.1 --- tests/common/logging.rs | 43 +- tests/integration_tests_rust.rs | 686 +++++++++++++++++++++++++++++++- 2 files changed, 721 insertions(+), 8 deletions(-) diff --git a/tests/common/logging.rs b/tests/common/logging.rs index 5e2f2e5dc..1f667aae4 100644 --- a/tests/common/logging.rs +++ b/tests/common/logging.rs @@ -197,17 +197,21 @@ impl CollectingLogWriter { self.logs.lock().unwrap().clone() } - /// Waits up to ten seconds for a logged message containing `text`, returning whether one - /// arrived. Polling beats a fixed sleep: it returns as soon as the line lands and only pays - /// the full timeout when the line never comes. + /// Waits up to [`INTEROP_TIMEOUT_SECS`] for a logged message containing `text`, returning + /// whether one arrived. Polling beats a fixed sleep: it returns as soon as the line lands and + /// only pays the full timeout when the line never comes. + /// + /// [`INTEROP_TIMEOUT_SECS`]: super::INTEROP_TIMEOUT_SECS pub(crate) async fn wait_for(&self, text: &str) -> bool { self.wait_for_count(text, 1).await } - /// Waits up to ten seconds for `occurrences` logged messages containing `text`, returning - /// whether they arrived. + /// Waits up to [`INTEROP_TIMEOUT_SECS`] for `occurrences` logged messages containing `text`, + /// returning whether they arrived. + /// + /// [`INTEROP_TIMEOUT_SECS`]: super::INTEROP_TIMEOUT_SECS pub(crate) async fn wait_for_count(&self, text: &str, occurrences: usize) -> bool { - for _ in 0..100 { + for _ in 0..(super::INTEROP_TIMEOUT_SECS * 10) { if self.count(text) >= occurrences { return true; } @@ -222,3 +226,30 @@ impl LogWriter for CollectingLogWriter { self.logs.lock().unwrap().push(record.args.to_string()); } } + +/// Forwards every record to an inner [`CollectingLogWriter`] and signals `seen` when a record +/// contains `marker`. The signal fires from inside the logging call, so a test can react within +/// the emitting code path's timing — where the collector's polling `wait_for` (100ms granularity) +/// is too coarse. +pub(crate) struct MarkerLogWriter { + inner: Arc, + marker: &'static str, + seen: Arc, +} + +impl MarkerLogWriter { + pub(crate) fn new( + inner: Arc, marker: &'static str, seen: Arc, + ) -> Self { + Self { inner, marker, seen } + } +} + +impl LogWriter for MarkerLogWriter { + fn log(&self, record: LogRecord) { + if record.args.to_string().contains(self.marker) { + self.seen.notify_one(); + } + LogWriter::log(&*self.inner, record); + } +} diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 108ac5a55..e7f3f6bc8 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -20,7 +20,8 @@ use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; use bitcoin::{Address, Amount, ScriptBuf, Transaction, Txid}; use common::logging::{ - init_log_logger, validate_log_entry, CollectingLogWriter, MultiNodeLogger, TestLogWriter, + init_log_logger, validate_log_entry, CollectingLogWriter, MarkerLogWriter, MultiNodeLogger, + TestLogWriter, }; use common::{ bump_fee_and_broadcast, configure_chain_source, distribute_funds_unconfirmed, @@ -46,7 +47,10 @@ use ldk_node::payment::{ ConfirmationStatus, ForwardedPaymentId, PayerProofOptions, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType, UserChannelId}; +use ldk_node::{ + BuildError, Builder, Event, Node, NodeError, ReserveType, SpliceFailureReason, + SpliceParameters, UserChannelId, +}; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::ln::channelmanager::{PaymentId, BREAKDOWN_TIMEOUT}; use lightning::routing::gossip::{NodeAlias, NodeId}; @@ -57,6 +61,39 @@ use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; use serde_json::json; +/// Pops the next event, panicking unless it is a `SpliceNegotiationFailed` from the given +/// counterparty, and returns its reason and parameters. +macro_rules! expect_splice_negotiation_failed_event { + ($node:expr, $counterparty_node_id:expr) => {{ + let event = tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + $node.next_event_async(), + ) + .await + .unwrap_or_else(|_| { + panic!("{} timed out waiting for SpliceNegotiationFailed event", $node.node_id()) + }); + match event { + ref e @ Event::SpliceNegotiationFailed { + counterparty_node_id, + ref reason, + ref parameters, + .. + } => { + println!("{} got event {:?}", $node.node_id(), e); + assert_eq!(counterparty_node_id, $counterparty_node_id); + let reason = reason.clone(); + let parameters = parameters.clone(); + $node.event_handled().unwrap(); + (reason, parameters) + }, + ref e => { + panic!("{} got unexpected event!: {:?}", std::stringify!($node), e); + }, + } + }}; +} + /// Waits until `node` has recorded the funding broadcast `funding_txid` (a channel open or splice /// candidate) as a payment carrying a `tx_type`. A splice contributor records the payment when it /// signs the funding transaction, before the transaction can even be broadcast, so for splices @@ -2548,6 +2585,68 @@ async fn zero_conf_splice_in_funding_rebroadcast_canary() { )); } +/// Two splices of this node in flight on a zero-conf channel — the second submitted right after +/// the first locked — are two payments: the second splice takes an intent record of its own +/// rather than the first splice's, whose record keeps the first splice's transaction. The lock +/// handler settles the first splice's intent before the second is submitted, so this guards +/// behavior in place before one record per splice rather than failing without it. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn zero_conf_queued_splice_is_recorded_as_its_own_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let node_a = setup_node(&chain_source, random_config()); + let mut config_b = random_config(); + config_b.node_config.trusted_peers_0conf.push(node_a.node_id()); + let node_b = setup_node(&chain_source, config_b); + + // Two coins: the second splice-in below cannot spend the first one's unconfirmed change. + let address_a = node_a.onchain_payment().new_address().unwrap(); + let second_address_a = node_a.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, second_address_a], + Amount::from_sat(5_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 2_000_000, false, &electrsd).await; + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000).unwrap(); + let first = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, first.txid).await; + // The zero-conf splice locks without confirmations, re-signaled as `ChannelReady`. + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + node_a.sync_wallets().unwrap(); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000).unwrap(); + let second = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, second.txid).await; + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let first_payment = funding_payment(&node_a, first.txid); + let second_payment = funding_payment(&node_a, second.txid); + assert_ne!(first_payment.id, second_payment.id, "each splice must have a record of its own"); + for payment in [&first_payment, &second_payment] { + assert!(matches!( + payment.kind, + PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } + )); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn rbf_splice_channel() { run_rbf_splice_channel_test(false).await; @@ -3871,6 +3970,589 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { node_a.stop().unwrap(); } +/// A mid-negotiation failure is surfaced to the user exactly once: the initiator disconnects +/// while the interactive negotiation is in flight, LDK fails the splice with `PeerDisconnected`, +/// and one `SpliceNegotiationFailed` — carrying the reason and the originating request's +/// parameters — reports it. The splice is not retried automatically; the application initiates a +/// new one, which completes. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_failure_surfaced_after_disconnect_mid_negotiation() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // The negotiation is synchronized through a log marker: LDK's peer handler logs every received + // message, and the counterparty's `splice_ack` is the earliest point where a disconnect fails + // the splice — any sooner and the contribution is still queued, which LDK resumes on reconnect + // by itself and no failure occurs. + let logger_a = Arc::new(CollectingLogWriter::new()); + let splice_ack_seen = Arc::new(tokio::sync::Notify::new()); + let mut config_a = random_config(); + config_a.log_writer = TestLogWriter::Custom(Arc::new(MarkerLogWriter::new( + logger_a.clone(), + "Received message SpliceAck", + splice_ack_seen.clone(), + ))); + // `Node::disconnect` persists a peer-store removal before severing the connection, and the + // negotiation keeps running during that write. The default composite test store turns it into + // several fsyncs plus a cross-store comparison, wide enough to lose the race below; a plain + // SQLite store keeps it to a single quick write. + config_a.store_type = TestStoreType::Sqlite; + let node_a = setup_node(&chain_source, config_a); + let node_b = setup_node(&chain_source, random_config()); + + // Fund Node A with many small UTXOs: every input the splice contributes adds an interactive-tx + // round trip, stretching the negotiation so the disconnect below reliably lands inside it. + let addresses_a: Vec
= + (0..40).map(|_| node_a.onchain_payment().new_address().unwrap()).collect(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + addresses_a, + Amount::from_sat(125_000), + ) + .await; + node_a.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 1_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()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // The 3M target forces roughly 25 of the 125k-sat UTXOs into the contribution. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 3_000_000).unwrap(); + + // Disconnect as soon as the negotiation is in flight. The negotiation keeps running while the + // disconnect is processed, so in principle it could still complete first — the disconnect + // would then fail nothing and the failure-event assert below would trip. The ~25 remaining + // per-input round trips make that window practically unlosable; if this ever flakes, widen + // the contribution further. + tokio::time::timeout(std::time::Duration::from_secs(10), splice_ack_seen.notified()) + .await + .expect("node A never received splice_ack"); + node_a.disconnect(node_b.node_id()).unwrap(); + + // ... which fails it with `PeerDisconnected`. The failure is surfaced with the reason and the + // originating request's parameters, and is not retried automatically. + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, Some(SpliceParameters::In { amount_sats: 3_000_000 })); + + let node_addr_b = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_addr_b, false).unwrap(); + + // The failed splice's inputs were released; the application initiates a new splice, which + // completes. A second copy of the failure event would pop here instead and panic: the failure + // is reported exactly once. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 3_000_000).unwrap(); + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + + wait_for_classified_funding_payment(&node_a, txo.txid).await; + wait_for_tx(&electrsd.client, txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let payment = funding_payment(&node_a, txo.txid); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } + )); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice LDK dropped without ever persisting it — initiated while disconnected, then the node +/// restarts — is recovered silently by startup reconciliation: the persisted intent's +/// reservations are released and its record dropped, with no fabricated failure event. What the +/// user does see, once, is the failure LDK itself persisted at shutdown and replays at startup — +/// with `PeerDisconnected` and no parameters, since the record is already gone. A further restart +/// stays silent, and a new splice initiated by the application completes. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_loss_surfaced_after_restart() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let config_b = random_config(); + let node_b = setup_node(&chain_source, config_b); + + let (onchain_balance_before_sat, splice_out_address, user_channel_id_a) = { + let node_a = setup_node(&chain_source, config_a.clone()); + + 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()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Initiate a splice-out while disconnected: LDK accepts the contribution but cannot make + // progress before the restart below drops it, having neither negotiated nor persisted + // the splice itself — only the failure event it queues for it at shutdown. + node_a.disconnect(node_b.node_id()).unwrap(); + let address = node_a.onchain_payment().new_address().unwrap(); + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 500_000).unwrap(); + + let onchain_balance_before_sat = node_a.list_balances().total_onchain_balance_sats; + node_a.stop().unwrap(); + (onchain_balance_before_sat, address, user_channel_id_a) + }; + + // A signing write cut short after its payment-store half leaves a payment record under the + // splice's intent that no pending entry indexes. Plant one while the node is down: the + // intent's settlement at startup must take it along rather than leave a payment nothing + // would ever drive. + let half_written_txid = { + use bitcoin::hashes::hex::FromHex; + use ldk_node::io::sqlite_store::{SqliteStore, KV_TABLE_NAME, SQLITE_DB_FILE_NAME}; + use lightning::util::ser::Writeable; + + let store = SqliteStore::new( + config_a.node_config.storage_dir_path.clone().into(), + Some(SQLITE_DB_FILE_NAME.to_string()), + Some(KV_TABLE_NAME.to_string()), + ) + .unwrap(); + let payment_keys: HashSet = + store.list("payments", "").await.unwrap().into_iter().collect(); + let bare_intent_keys: Vec = store + .list("pending_payments", "") + .await + .unwrap() + .into_iter() + .filter(|key| !payment_keys.contains(key)) + .collect(); + assert_eq!(bare_intent_keys.len(), 1, "the dropped splice must have left one bare intent"); + let key = &bare_intent_keys[0]; + let id = PaymentId(<[u8; 32]>::from_hex(key).unwrap()); + let txid = Txid::from_byte_array([0xEE; 32]); + let half_written = PaymentDetails { + id, + kind: PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels: Vec::new() }), + }, + amount_msat: Some(500_000_000), + fee_paid_msat: Some(300_000), + direction: PaymentDirection::Outbound, + status: PaymentStatus::Pending, + latest_update_timestamp: 0, + }; + store.write("payments", "", key, half_written.encode()).await.unwrap(); + txid + }; + + // On restart, reconciliation finds nothing behind the intent in LDK, releases whatever the + // wallet still reserved for it, and drops the record — with the half-written payment under + // its id — without an event of its own. The one failure surfaced is LDK's replay of the + // event it persisted at shutdown for the dropped contribution — carrying no parameters, + // since the record it would match is already gone. + let node_a = setup_node(&chain_source, config_a.clone()); + node_a.sync_wallets().unwrap(); + + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, None); + assert!( + node_a + .list_payments_matching( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == half_written_txid) + ) + .is_empty(), + "the half-written record under the dropped splice's intent must go with it", + ); + + // The replayed failure was consumed, so another restart must not report it again. + node_a.stop().unwrap(); + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a consumed splice failure must not be reported again"); + + // The application initiates a new splice-out, which completes. + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &splice_out_address, 500_000).unwrap(); + + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + + wait_for_tx(&electrsd.client, txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + assert!( + node_a.list_balances().total_onchain_balance_sats > onchain_balance_before_sat + 400_000, + "the new splice-out should have moved ~500k sats to the on-chain balance", + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A fee bump initiated while disconnected and dropped by a restart leaves LDK holding the +/// negotiated splice at the original feerate, so startup reconciliation keeps the recorded +/// intent. The failure LDK persisted at shutdown for the dropped bump is replayed at startup, +/// matches the kept intent, and surfaces with the intent's parameters. A new bump initiated by +/// the application replaces the funding transaction, and once the negotiated splice carries the +/// bump, further restarts stay silent. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_rbf_loss_surfaced_after_restart() { + // 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); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let config_b = random_config(); + let node_b = setup_node(&chain_source, config_b); + + let (original_txo, user_channel_id_a) = { + let node_a = setup_node(&chain_source, config_a.clone()); + + 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()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Negotiate a splice but leave its transaction unconfirmed so it can be fee-bumped. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap(); + let original_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_tx(&electrsd.client, original_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // Bump the fee while disconnected and restart before anything could be negotiated: LDK + // drops the queued bump, keeping the negotiated splice at the original feerate, while + // the persisted intent records the bump. + node_a.disconnect(node_b.node_id()).unwrap(); + node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id()).unwrap(); + node_a.stop().unwrap(); + (original_txo, user_channel_id_a) + }; + + // On restart, reconciliation keeps the record — LDK still holds the negotiated splice, so + // the wallet's reservations may yet be claimed. The failure LDK persisted at shutdown for + // the dropped bump is replayed, matches the kept intent, and surfaces with its parameters. + let node_a = setup_node(&chain_source, config_a.clone()); + node_a.sync_wallets().unwrap(); + + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, Some(SpliceParameters::FeeBump)); + + // The application initiates a new fee bump, which replaces the funding transaction. + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_b_addr.clone(), false).unwrap(); + node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id()).unwrap(); + + let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + assert_ne!(original_txo, rbf_txo, "the new fee bump should produce a different funding txo"); + + // Restarting again must stay silent: the negotiated splice now carries the bump at the + // intended feerate. + node_a.stop().unwrap(); + let node_a = setup_node(&chain_source, config_a.clone()); + node_a.sync_wallets().unwrap(); + node_a.connect(node_b.node_id(), node_b_addr.clone(), false).unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a carried fee bump must not be reported as lost"); + + wait_for_tx(&electrsd.client, rbf_txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // The locked fee bump cleared its intent, so a further restart must stay silent. + node_a.stop().unwrap(); + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a locked fee bump must produce no events"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice queued behind a pending splice of this node on a confirmed channel — accepted once +/// the pending round has a confirmation — is a splice of its own, with an intent record of its +/// own. Graduating the pending splice's payment removes that splice's record, and the queued +/// splice's survives it: a restart fails the queued contribution LDK never got to negotiate, and +/// the replayed failure is described from the queued splice's own intent. Before one record per +/// splice, the queued intent rode on the pending splice's record and was lost with it, so the +/// failure carried no parameters. +/// +/// Pinned to Esplora so the nodes sync only when told to: the pending splice's lock needs the +/// counterparty's `splice_locked`, which it sends only once it has seen the confirmations. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn queued_splice_failure_surfaced_after_restart() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let node_b = setup_node(&chain_source, random_config()); + + let (pending_txid, pending_payment_id, node_b_addr) = { + let node_a = setup_node(&chain_source, config_a.clone()); + + // Two coins for node_a: the queued splice-in cannot spend the pending one's unconfirmed + // change. + let address_a = node_a.onchain_payment().new_address().unwrap(); + let second_address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, second_address_a, address_b], + Amount::from_sat(5_000_000), + ) + .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()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap(); + let pending = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, pending.txid).await; + + // With one confirmation, seen by node_a alone, LDK takes a further splice-in as a splice + // of its own, queued until the pending one locks. Queueing it starts a quiescence + // handshake LDK breaks off with a warning until then, disconnecting the peers. + wait_for_tx(&electrsd.client, pending.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 300_000).unwrap(); + + // Five more confirmations graduate the pending splice's payment on node_a, removing its + // record, while its lock still waits on node_b. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + node_a.sync_wallets().unwrap(); + let pending_payment = funding_payment(&node_a, pending.txid); + assert_eq!(pending_payment.status, PaymentStatus::Succeeded); + + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.stop().unwrap(); + (pending.txid, pending_payment.id, node_b_addr) + }; + + // LDK failed the queued contribution when it was last persisted and replays the failure at + // startup. The queued splice's own record survived the pending splice's graduation, so the + // failure is described from it. + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, Some(SpliceParameters::In { amount_sats: 300_000 })); + + // The pending splice locks once node_b catches up, under its one record: the one that + // graduated before the restart, not a second one the lock or the sync created. + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + node_b.sync_wallets().unwrap(); + node_a.sync_wallets().unwrap(); + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + let pending_payments = node_a.list_payments_matching( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == pending_txid), + ); + assert_eq!(pending_payments.len(), 1); + assert_eq!(pending_payments[0].id, pending_payment_id); + assert_eq!(pending_payments[0].status, PaymentStatus::Succeeded); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice confirmed while its node was offline keeps exactly one payment record under its +/// splice-time id across the restart, no matter whether wallet sync or classification sees the +/// confirmed transaction first. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_payment_tracked_across_restart_before_lock() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let config_b = random_config(); + let node_b = setup_node(&chain_source, config_b); + + let splice_txid = { + let node_a = setup_node(&chain_source, config_a.clone()); + + 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()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap(); + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + + // Stop node_a as soon as the splice is negotiated. node_b broadcasts the transaction + // either way, so it reaches the chain while node_a is offline. node_a recorded the + // payment when it signed the funding transaction; depending on timing, its own broadcast + // classification may or may not also have run before stopping — the assertions below + // must hold in both cases. + node_a.stop().unwrap(); + txo.txid + }; + + // Confirm the splice while node_a is offline, but keep it short of the depth at which it + // locks, so node_a restarts with its splice intent still live. + wait_for_tx(&electrsd.client, splice_txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + + // After the restart, wallet sync and classification must agree on the splice-time + // `PaymentId` no matter which of them sees the confirmed transaction first: exactly one + // payment record, and not one keyed by a txid-derived id. + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + + let splice_payments = |node: &Node| { + node.list_payments_matching( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == splice_txid), + ) + }; + let payments = splice_payments(&node_a); + assert_eq!( + payments.len(), + 1, + "expected exactly one payment record for the splice, got {}: {:#?}", + payments.len(), + payments, + ); + assert_ne!( + payments[0].id, + PaymentId(splice_txid.to_byte_array()), + "the splice payment must keep its splice-time id, not a txid-derived fallback", + ); + assert_eq!(payments[0].status, PaymentStatus::Pending); + + // Reconnect and let the splice lock: the single record graduates instead of gaining a + // duplicate. + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let payments = splice_payments(&node_a); + assert_eq!( + payments.len(), + 1, + "expected exactly one payment record after the splice locked, got {}: {:#?}", + payments.len(), + payments, + ); + assert_eq!(payments[0].status, PaymentStatus::Succeeded); + + 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 5a6d825d13a7fc1e0f9ffd062bfcbd69a0ba0531 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 21 Sep 2026 16:35:51 -0700 Subject: [PATCH 37/37] f - Test splice failure surfacing and recovery The pinned LDK now reports `SpliceNegotiationFailed` after `ChannelClosed` for a signed round the monitor never watched at a force-close (rust-lightning issue 4967), so the test of that round asserts the reason the node surfaces, `ChannelClosing`, and that no parameters come with it, the intent having been cleared at the close. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- tests/integration_tests_rust.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index e7f3f6bc8..3466fe33a 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3679,7 +3679,8 @@ async fn splice_round_superseded_on_an_open_channel_fails_its_payment() { /// /// LDK reports the round itself after `ChannelClosed`: a `DiscardFunding` for node A's /// contribution, whose handling reclaims its addresses, and a `SpliceNegotiationFailed` the node -/// passes on. +/// reports with reason `ChannelClosing` and no parameters, its intent having been cleared at the +/// close. #[cfg(feature = "chain-esplora")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { @@ -3709,7 +3710,9 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { node_a.disconnect(node_b.node_id()).unwrap(); node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); expect_event!(node_a, ChannelClosed); - expect_event!(node_a, SpliceNegotiationFailed); + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::ChannelClosing)); + assert_eq!(parameters, None, "the intent outlived the close"); assert!( logs_a.wait_for_count(RECLAIMED_ADDRESSES, reclaimed_a + 1).await, "node A's contribution to the discarded round was not reclaimed"