From c7e81230c6edc84fedc76d530f24a476047ee3e7 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 18 Aug 2026 14:27:39 -0500 Subject: [PATCH 1/5] 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 b9c12b4a7f..f8dd13db1c 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 = { @@ -1938,9 +1956,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 @@ -1949,38 +1969,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 }; @@ -1998,7 +2031,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 @@ -2008,7 +2041,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)] @@ -2311,6 +2344,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, @@ -3960,6 +4007,98 @@ mod tests { assert_eq!(wallet.find_payment_by_txid(txid2).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. + /// 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 0a9f121c36fc73b067817f4b73a5fa693ea82df5 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 18 Aug 2026 17:07:12 -0500 Subject: [PATCH 2/5] 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 f01c1c8cb8..2d53cf9d65 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 782112dadb..3e5b846da3 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 f8dd13db1c..9790e4f4c3 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2734,7 +2734,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, ConfirmationBlockTime}; @@ -2764,11 +2764,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 { @@ -2776,6 +2778,7 @@ mod tests { Self { inner: Arc::new(InMemoryStore::new()), fail_writes: Arc::new(AtomicBool::new(false)), + failed_writes: Arc::new(AtomicUsize::new(0)), } } } @@ -2792,11 +2795,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 @@ -4241,6 +4246,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 b762c703423e42110e4efc424aa184214e11291d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 2 Sep 2026 13:53:47 -0500 Subject: [PATCH 3/5] 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 9790e4f4c3..f4a5b15d05 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, @@ -3765,6 +3965,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]); @@ -4104,6 +4355,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 1a8d4bea93a9f29e500cb97d39257c1e8660b2a4 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 4 Sep 2026 21:56:15 -0500 Subject: [PATCH 4/5] 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 | 157 ++- src/lib.rs | 17 + src/payment/pending_payment_store.rs | 58 +- src/tx_broadcaster.rs | 34 +- src/wallet/mod.rs | 1632 +++++++++++++++++++++++++- tests/integration_tests_rust.rs | 271 ++++- 7 files changed, 2066 insertions(+), 109 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 2d53cf9d65..92f7b1e758 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 846117ea71..1ff48874d3 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; @@ -56,8 +57,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, @@ -558,6 +561,7 @@ where wallet: Arc, bump_tx_event_handler: Arc, channel_manager: Arc, + chain_monitor: Arc, connection_manager: Arc>, output_sweeper: Arc, network_graph: Arc, @@ -581,19 +585,20 @@ 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, - 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, 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, @@ -730,6 +735,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 { @@ -1892,10 +1922,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"); @@ -2151,6 +2211,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, @@ -2165,9 +2245,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, + ); }, } }, @@ -2188,6 +2277,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), @@ -2216,6 +2325,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 821304a532..a79573438d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -362,6 +362,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); @@ -673,6 +689,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 30a1135374..f091988110 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 @@ -105,8 +113,10 @@ impl StorableObject for PendingPaymentDetails { updated |= self.conflicting_txids.len() != conflicts_len; } - // Each classify passes the complete candidate history, so a non-empty update replaces the - // stored list. An empty update (e.g. a non-funding payment) leaves it untouched. + // 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; @@ -142,6 +152,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; @@ -160,16 +204,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, }, ]; @@ -279,6 +330,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 3e5b846da3..ff486a156c 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -24,9 +24,10 @@ 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. +/// the store recovers. Packages no timer re-broadcasts — fundings and cooperative closes — +/// don't count against the bound: they are finite — one per funding LDK hands over under the +/// `Funding` type (splice rounds have nothing to classify and are never queued) 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` @@ -68,11 +69,12 @@ impl BroadcastPackage { /// 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. + /// 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( @@ -141,11 +143,10 @@ impl RetryQueue { 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. + // 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 + // is never queued. return ScheduleOutcome::AlreadyQueued(package); } @@ -153,10 +154,9 @@ impl RetryQueue { 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. + // 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. match self.0.iter().position(|(_, _, waiting)| waiting.is_droppable()) { Some(oldest) => dropped = self.0.remove(oldest).map(|(_, _, package)| package), None => return ScheduleOutcome::Refused(package), diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index f4a5b15d05..bdb181542c 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::StorableObject; #[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::{ @@ -709,8 +711,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 { @@ -730,12 +733,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), @@ -1745,9 +1748,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 { .. } @@ -1781,10 +1786,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). @@ -1809,8 +1814,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, @@ -1849,26 +1853,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 { @@ -1876,10 +1869,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; @@ -1893,10 +1886,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 @@ -1915,6 +1908,7 @@ impl Wallet { txid: candidate.txid, amount_msat: aggregate.amount_msat, fee_paid_msat: aggregate.fee_paid_msat, + awaiting_broadcast: false, } }) .collect(); @@ -1931,17 +1925,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( @@ -1983,8 +2365,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 @@ -2028,8 +2418,9 @@ impl Wallet { let payment_store = Arc::clone(&self.payment_store); self.pending_payment_store .mutate_async(&id, move |existing| async move { - // The record was written above and payment records are never removed, so absence - // means the write failed out; fall back to the fresh details. + // The record was written above and a failed write has already returned, so it is + // absent only if the user removed the payment meanwhile; fall back to the fresh + // details. 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 @@ -2041,10 +2432,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, @@ -2544,6 +2935,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 @@ -2942,6 +3410,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::*; @@ -2958,6 +3427,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}; @@ -2971,6 +3441,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 { @@ -2979,8 +3451,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 { @@ -2996,11 +3474,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")); } @@ -4016,6 +4496,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]); @@ -4025,11 +5526,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); @@ -4048,6 +5551,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)); @@ -4063,6 +5567,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); @@ -4240,16 +5745,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)); @@ -4303,6 +5811,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)); @@ -4373,6 +5882,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)); @@ -4436,11 +5946,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 = @@ -4492,6 +6004,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)); @@ -4543,11 +6056,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 = @@ -4609,6 +6124,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(); @@ -4659,6 +6175,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(); @@ -4753,6 +6270,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)); @@ -4786,7 +6304,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] @@ -4882,6 +6400,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(); @@ -4926,11 +6445,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; @@ -5119,11 +6638,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)); @@ -5209,6 +6730,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 5f4a95b7eb..7366354515 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -30,7 +30,7 @@ use common::{ open_channel_push_amt, open_channel_with_all, premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, InMemoryStore, - NodePaymentExt, TestChainSource, TestConfig, TestStoreType, TestSyncStore, + NodePaymentExt, TestChainSource, TestConfig, TestNode, TestStoreType, TestSyncStore, }; use electrsd::corepc_node::{self, Node as BitcoinD}; use electrsd::ElectrsD; @@ -43,7 +43,7 @@ use ldk_node::payment::{ ConfirmationStatus, 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; @@ -53,12 +53,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 { @@ -87,6 +88,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 { @@ -103,6 +106,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(); @@ -110,7 +115,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 } } @@ -160,6 +165,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()) @@ -2071,8 +2077,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; @@ -2131,8 +2135,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; @@ -2317,6 +2319,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()); @@ -2440,8 +2448,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(); @@ -2477,8 +2483,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(); @@ -2678,8 +2682,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 @@ -2770,6 +2774,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 4fd10b862f41a26eff34798025be25046f8a1b07 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 7 Sep 2026 13:11:34 -0500 Subject: [PATCH 5/5] 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 | 112 +++- src/payment/pending_payment_store.rs | 94 ++- src/wallet/mod.rs | 899 +++++++++++++++++++++++++-- tests/common/logging.rs | 5 + tests/integration_tests_rust.rs | 729 +++++++++++++++++++++- 5 files changed, 1770 insertions(+), 69 deletions(-) diff --git a/src/event.rs b/src/event.rs index 1ff48874d3..42725494d5 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1898,6 +1898,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) @@ -1930,11 +1963,16 @@ where // 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. + // 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. let watched_txids: Vec = self .chain_monitor .get_monitor(channel_id) @@ -1944,12 +1982,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, ); @@ -2013,6 +2050,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 f091988110..292e95438c 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)] @@ -162,25 +169,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") @@ -189,6 +236,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; @@ -369,4 +417,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 bdb181542c..7c764de243 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -786,11 +786,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?; @@ -804,26 +830,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)] @@ -2059,14 +2319,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; @@ -2111,30 +2364,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; @@ -2152,6 +2408,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() }) }; @@ -2935,6 +3192,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 @@ -2990,16 +3258,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 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. +/// 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 { @@ -3012,6 +3283,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 @@ -3427,7 +3720,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}; @@ -6783,4 +7078,528 @@ mod tests { PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } )); } + /// 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 3b231b3cd0..5e2f2e5dcf 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 7366354515..a1a7cf874b 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, }; @@ -44,7 +45,8 @@ use ldk_node::payment::{ 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}; @@ -88,8 +90,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 { @@ -106,8 +126,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(); @@ -115,8 +137,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 } } @@ -165,7 +197,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()) @@ -2775,10 +2808,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()); @@ -2787,7 +2821,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); @@ -2858,6 +2894,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 @@ -2869,6 +3088,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 @@ -2885,13 +3128,18 @@ const RECEIVED_COMMITMENT_SIGNED: &str = "Received message CommitmentSigned"; /// `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; @@ -2923,6 +3171,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(); @@ -2943,8 +3197,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, @@ -2954,6 +3234,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 @@ -3003,6 +3458,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();