From 5172d160f99c478921f96ad497f52f0466fcf758 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 18 Aug 2026 14:27:39 -0500 Subject: [PATCH 01/49] DROP ME: 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 (cherry picked from commit 129005abe9a5b28dbbed73202f7919aa8d73d580) --- 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 13a8ef4e00..12b9374b9d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -346,12 +346,12 @@ impl Wallet { // duplicating) the record classification just wrote. let guard = self.funding_payment_update_lock.lock().await; - let payment_id = self + let mut payment_id = self .find_payment_by_txid(txid) .await? .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self + match self .apply_funding_status_update_locked( &guard, payment_id, @@ -360,7 +360,13 @@ impl Wallet { ) .await? { - continue; + FundingStatusUpdate::Applied => continue, + FundingStatusUpdate::NotFunding => {}, + // Not part of the funding payment's history (e.g. a close spending the + // funding outpoint): record it under its own id below instead. + FundingStatusUpdate::Foreign => { + payment_id = PaymentId(txid.to_byte_array()); + }, } let payment = { @@ -487,12 +493,12 @@ impl Wallet { // with classification. let guard = self.funding_payment_update_lock.lock().await; - let payment_id = self + let mut payment_id = self .find_payment_by_txid(txid) .await? .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self + match self .apply_funding_status_update_locked( &guard, payment_id, @@ -501,7 +507,13 @@ impl Wallet { ) .await? { - continue; + FundingStatusUpdate::Applied => continue, + FundingStatusUpdate::NotFunding => {}, + // Not part of the funding payment's history (e.g. a close spending the + // funding outpoint): record it under its own id below instead. + FundingStatusUpdate::Foreign => { + payment_id = PaymentId(txid.to_byte_array()); + }, } let payment = { @@ -563,12 +575,12 @@ impl Wallet { // with classification. let guard = self.funding_payment_update_lock.lock().await; - let payment_id = self + let mut payment_id = self .find_payment_by_txid(txid) .await? .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self + match self .apply_funding_status_update_locked( &guard, payment_id, @@ -577,7 +589,13 @@ impl Wallet { ) .await? { - continue; + FundingStatusUpdate::Applied => continue, + FundingStatusUpdate::NotFunding => {}, + // Not part of the funding payment's history (e.g. a close spending the + // funding outpoint): record it under its own id below instead. + FundingStatusUpdate::Foreign => { + payment_id = PaymentId(txid.to_byte_array()); + }, } let payment = { @@ -1949,9 +1967,11 @@ impl Wallet { /// If `payment_id` refers to a classified funding payment, refreshes its confirmation status /// and the candidate txid the event refers to, while preserving the contribution-derived /// amount/fee and `tx_type` that wallet sync must not recompute from its own view: the wallet's - /// `sent`/`received` don't capture our contribution to a shared funding output. Returns `true` - /// when it handled the payment, so the caller skips the default on-chain path. Graduation to - /// `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. + /// `sent`/`received` don't capture our contribution to a shared funding output. Returns + /// [`FundingStatusUpdate::Applied`] when it handled the payment, so the caller skips the + /// default on-chain path — or [`FundingStatusUpdate::Foreign`] when the transaction is not + /// part of the payment's funding history, so the caller records it under its own id. + /// Graduation to `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. /// /// The caller must hold [`Self::funding_payment_update_lock`] — from resolving `payment_id` /// through its own last write, not just across this call — so that classification's two-store @@ -1960,38 +1980,51 @@ impl Wallet { async fn apply_funding_status_update_locked( &self, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, - ) -> Result { + ) -> Result { // The caller's wallet-level lock keeps the candidate history stable while we await its - // read. The funding-type gate and write then share the payment store's mutation lock: - // against a separate payment `get`, a classification merging in between would have its - // `tx_type` and contribution figures clobbered by this stale snapshot. + // read. The funding-type gate, the candidate lookup, and the write then share the payment + // store's mutation lock: against a separate payment `get`, a classification merging in + // between would have its `tx_type` and contribution figures clobbered by this stale + // snapshot. let pending_payment = self.pending_payment_store.get(&payment_id).await?; + let mut outcome = FundingStatusUpdate::NotFunding; let mut handled = None; self.payment_store .mutate(&payment_id, |existing| { let payment = existing?; - let tx_type = match &payment.kind { + let (current_txid, tx_type) = match &payment.kind { PaymentKind::Onchain { + txid, tx_type: tx_type @ Some( TransactionType::Funding { .. } | TransactionType::InteractiveFunding { .. }, ), .. - } => tx_type.clone(), + } => (*txid, tx_type.clone()), _ => return None, }; + // Adopt the event's txid only when the transaction is part of this payment's + // funding history: its current txid or a classified candidate. A conflicting + // transaction that is neither — a close also spends the funding outpoint — must + // not overwrite the record. + let owns_event_tx = event_txid == current_txid + || pending_payment.as_ref().is_some_and(|p| p.candidate(event_txid).is_some()); + if !owns_event_tx { + outcome = FundingStatusUpdate::Foreign; + return None; + } // Report the figures of the candidate that actually confirmed, which need not be // the last one broadcast (an earlier, lower-fee candidate may win) and may carry // no figures at all (`None`) for a round we didn't contribute to. (`direction` is // invariant across a splice's candidates and cannot be changed through the store // anyway.) let mut target = payment.clone(); - if let Some(pending) = pending_payment.as_ref() { - if let Some(candidate) = pending.candidate(event_txid) { - target.amount_msat = candidate.amount_msat; - target.fee_paid_msat = candidate.fee_paid_msat; - } + if let Some(candidate) = + pending_payment.as_ref().and_then(|p| p.candidate(event_txid)) + { + target.amount_msat = candidate.amount_msat; + target.fee_paid_msat = candidate.fee_paid_msat; } target.kind = PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; @@ -2009,7 +2042,7 @@ impl Wallet { }) .await?; let Some(payment) = handled else { - return Ok(false); + return Ok(outcome); }; // Mirror the refreshed confirmation status onto the pending entry: `ChainTipChanged` // graduates by reading the pending entry's details, so it must see the new status. This is @@ -2019,7 +2052,7 @@ impl Wallet { let pending = self.create_pending_payment_from_tx(payment, Vec::new()); self.pending_payment_store.insert_or_update(pending).await?; } - Ok(true) + Ok(FundingStatusUpdate::Applied) } #[allow(deprecated)] @@ -2322,6 +2355,20 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { } } +/// The outcome of [`Wallet::apply_funding_status_update_locked`]. +enum FundingStatusUpdate { + /// The event's transaction belongs to the funding payment; its refreshed confirmation status + /// was applied (or was already current). + Applied, + /// The resolved payment is not a classified funding payment; the caller's default on-chain + /// handling applies under the resolved id. + NotFunding, + /// The event's transaction is not part of the funding payment's history — e.g. a close + /// spending the same funding outpoint — so the funding record must not adopt it; the caller + /// should record the transaction under its own txid-derived id. + Foreign, +} + impl Listen for Wallet { fn filtered_block_connected( &self, _header: &bitcoin::block::Header, @@ -4048,6 +4095,98 @@ mod tests { wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap(); } + /// A cooperative close conflicts with a pending splice's funding transaction — both spend the + /// pre-splice funding outpoint — so sync records the close among the splice record's + /// conflicting txids, and the close's confirmation then resolves to the splice's PaymentId. + /// The funding record must not adopt the close's txid and confirmation as its own: the close + /// is not a round of the splice. It must land on a record keyed by the close's own id. + #[tokio::test] + async fn funding_record_does_not_adopt_a_conflicting_close() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let funding_outpoint = + bitcoin::OutPoint { txid: Txid::from_byte_array([3u8; 32]), vout: 0 }; + + // The close pays the shutdown script, which is a wallet address. + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let close_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: funding_outpoint, + script_sig: bitcoin::ScriptBuf::new(), + sequence: bitcoin::Sequence::MAX, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + }; + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + + // Sync saw the close double-spend the splice's funding transaction. + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + }) + .await + .unwrap(); + + let event = WalletEvent::TxConfirmed { + txid: close_txid, + tx: Arc::new(close_tx), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let funding = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + match &funding.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid, "the record must not adopt the close's txid"); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(funding.amount_msat, Some(1_000_000)); + assert_eq!(funding.fee_paid_msat, Some(500)); + + let close = wallet + .payment_store + .get(&PaymentId(close_txid.to_byte_array())) + .await + .unwrap() + .unwrap(); + match &close.kind { + PaymentKind::Onchain { txid, status, .. } => { + assert_eq!(*txid, close_txid); + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + kind => panic!("unexpected kind {:?}", kind), + } + } + /// A funding-typed broadcast that doesn't touch the on-chain wallet must not be recorded. /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding /// path, so a splice the interactive-funding classification deliberately declined — no local From 696c7c49758175bd1e76c3604d18f9cce30a4760 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 18 Aug 2026 17:07:12 -0500 Subject: [PATCH 02/49] DROP ME: 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 fresh packages are classified ahead of due retries, so packages arriving during a store outage are not held behind the outage's retries. 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 queue belongs to the broadcaster and outlives the task draining it: a package still waiting when the node stops, fresh or awaiting a retry, is classified and broadcast after the next start, as a package not yet attempted always was; a funding package has no other way back. The queue is 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 recognized as it is queued and not queued again. At the bound, an incoming package that LDK would re-broadcast anyway makes room by dropping the oldest such queued package, whose transactions return with the next rebroadcast; if every queued 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 (cherry picked from commit 6dfa047ad5084b620c392c8d63dec376cc32391e) --- src/chain/mod.rs | 87 +++--- src/tx_broadcaster.rs | 698 ++++++++++++++++++++++++++++++++++++++++-- src/wallet/mod.rs | 408 +++++++++++++++++++++++- 3 files changed, 1122 insertions(+), 71 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index f01c1c8cb8..e34d067b11 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; 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. +pub(crate) const FAILED_CLASSIFY_RETRY_DELAY: Duration = Duration::from_secs(2); + /// We use this parent-child TRUC package to make sure the configured chain source supports /// broadcasting packages via the `submitpackage` Bitcoin Core RPC. const PARENT_TXID: &str = "9a015f93fac6cb203c2b994e18b85176eb0354a22a468255516f3c6002d3f696"; @@ -562,51 +568,62 @@ 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; loop { - let tx_bcast_logger = Arc::clone(&self.logger); - tokio::select! { + let package = tokio::select! { + // A stop request is polled first, so a queue that always has a package ready + // cannot starve it. Which package comes next — a fresh one before a due retry — + // is decided in `BroadcastQueue::next`. + biased; _ = stop_tx_bcast_receiver.changed() => { log_debug!( - 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 - }, - } - } + package = self.tx_broadcaster.next_package() => package, + }; + if let Err(package) = self.classify_and_broadcast(package).await { + let retry_at = tokio::time::Instant::now() + FAILED_CLASSIFY_RETRY_DELAY; + self.tx_broadcaster.retry_package(package, retry_at); } } } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 782112dadb..46af8ba2e9 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -5,20 +5,28 @@ // 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::{BTreeSet, 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::sync::Notify; +use tokio::time::Instant; -use crate::logger::{log_error, LdkLogger}; +use crate::logger::{log_debug, log_error, LdkLogger}; use crate::types::Wallet; use crate::Error; -const BCAST_PACKAGE_QUEUE_SIZE: usize = 256; +/// The most packages [`BroadcastQueue`] holds, fresh and awaiting a retry together. Claims and +/// sweeps re-enter the queue on LDK's periodic rebroadcast timers, so one dropped at the bound +/// resurfaces on its own once the store recovers. Packages nothing re-broadcasts — fundings and +/// cooperative closes — are never dropped or refused for the bound, though they count toward it: +/// what LDK hands over of them is finite — one per negotiated funding candidate and one per +/// closing channel — and a copy of a package awaiting a retry is never queued twice. +const MAX_QUEUED_PACKAGES: usize = 256; /// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions` /// call, along with each transaction's type. Queued until the background task classifies and @@ -47,6 +55,191 @@ impl BroadcastPackage { let txs = self.0.into_iter().map(|(tx, _)| tx).collect(); SortedTransactions::sort_parents_child_package_topologically(txs) } + + /// The txids of the packaged transactions, identifying the package's effect on chain: two + /// packages with the same txids broadcast the same transactions. + pub(crate) fn txids(&self) -> BTreeSet { + self.0.iter().map(|(tx, _)| tx.compute_txid()).collect() + } + + /// Whether the package may be dropped to keep [`BroadcastQueue`] within its bound: every + /// transaction in it is re-broadcast by its originator, so a dropped package resurfaces on + /// its own. LDK re-hands claims, anchor bumps, and force-close commitments to the + /// broadcaster periodically, and the sweeper regenerates sweeps once per block. Nothing + /// re-broadcasts a funding transaction (a channel open or splice, whose classification + /// writes the payment record tracking the funding) or a cooperative close (whose channel is + /// gone from the `ChannelManager` by broadcast time), so a package containing either is + /// never dropped. + 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: the wallet re-submits its unconfirmed transactions on each chain + // tip change. Classification of an untyped package is a no-op that can't fail, so one + // never awaits a retry. + None => true, + }) + } +} + +/// What [`BroadcastQueue`] did with a package, so the caller can log the cases in which the +/// package won't be classified and broadcast as-is. +pub(crate) enum QueueOutcome { + /// The package is queued. When the bound was reached, the oldest droppable package was + /// dropped to make room and is returned — its transactions resurface with LDK's next + /// periodic rebroadcast. + Queued { dropped: Option }, + /// A package broadcasting the same transactions already awaits a classification retry, and + /// that retry covers this one: the incoming package is dropped and returned. + AlreadyQueued(BroadcastPackage), + /// The bound was reached and every queued package is one that must not be dropped (a + /// funding or a cooperative close): the incoming package is refused and returned. + Refused(BroadcastPackage), +} + +/// The packages handed to the broadcaster, waiting for the background task to classify and +/// broadcast them: fresh packages in arrival order, and packages whose classification failed, +/// each waiting out a retry delay. One queue holds both, so one bound and one rule for what may +/// be dropped at it cover fresh packages and retries alike, and a re-broadcast of a package +/// awaiting a retry is recognized as it is queued rather than after one more failed attempt. +/// +/// Deduplicated and bounded: LDK re-broadcasts pending claims every 30 seconds (and sweeps once +/// per block) until they confirm, so while the store is unavailable, copies would otherwise +/// accumulate without bound and replay as a burst on recovery. An identical copy is never queued +/// while one awaits a retry — the waiting entry and its deadline stand; fee-bumped rebroadcast +/// variants carry new txids, so the bound — not the dedup — is what limits their accumulation. +/// +/// The queue belongs to the broadcaster and outlives the task draining it: what is queued when +/// the node stops, fresh or awaiting a retry, is classified and broadcast after the next start. +pub(crate) struct BroadcastQueue { + state: StdMutex, + /// Wakes the draining task when a package is queued. + notify: Notify, +} + +struct QueueState { + /// Packages not yet attempted, in arrival order. + fresh: VecDeque, + /// Packages whose classification failed, with their txids and retry deadlines. Retries are + /// scheduled with a fixed delay, so the front entry is always the next to fall due. + retries: VecDeque<(Instant, BTreeSet, BroadcastPackage)>, +} + +impl BroadcastQueue { + pub(crate) fn new() -> Self { + let state = QueueState { fresh: VecDeque::new(), retries: VecDeque::new() }; + Self { state: StdMutex::new(state), notify: Notify::new() } + } + + /// Queues a fresh package, unless a package with the same transactions already awaits a + /// retry or accepting it would exceed [`MAX_QUEUED_PACKAGES`] with no droppable package to + /// make room with; see [`QueueOutcome`]. + pub(crate) fn push(&self, package: BroadcastPackage) -> QueueOutcome { + self.admit(package, None) + } + + /// Queues a package whose classification failed, to be attempted again at `retry_at`, under + /// the same conditions as [`Self::push`]. + pub(crate) fn retry(&self, package: BroadcastPackage, retry_at: Instant) -> QueueOutcome { + self.admit(package, Some(retry_at)) + } + + fn admit(&self, package: BroadcastPackage, retry_at: Option) -> QueueOutcome { + let outcome = self.state.lock().expect("lock").admit(package, retry_at); + if matches!(outcome, QueueOutcome::Queued { .. }) { + self.notify.notify_one(); + } + outcome + } + + /// The next package to classify and broadcast: a fresh package if any is queued, otherwise + /// the retry whose deadline has passed, waiting for one or the other when neither is ready. + /// Fresh packages go first so broadcasts arriving during a store outage are never held back + /// by the outage's retries. A retry keeps its delay regardless: without it, an otherwise idle + /// queue would retry a fast-failing store back to back, logging an error each time. + /// + /// Safe to drop before completion: a package leaves the queue only as the future completes. + pub(crate) async fn next(&self) -> BroadcastPackage { + loop { + let next_deadline = { + let mut state = self.state.lock().expect("lock"); + if let Some(package) = state.fresh.pop_front() { + return package; + } + match state.retries.front() { + Some((deadline, _, _)) if *deadline <= Instant::now() => { + let (_, _, package) = state.retries.pop_front().expect("front entry"); + return package; + }, + Some((deadline, _, _)) => Some(*deadline), + None => None, + } + }; + // A package queued between the check above and the wait below is not missed: with + // no task waiting, `notify_one` stores a permit that completes the next `notified`. + match next_deadline { + Some(deadline) => { + tokio::select! { + _ = self.notify.notified() => {}, + _ = tokio::time::sleep_until(deadline) => {}, + } + }, + None => self.notify.notified().await, + } + } + } +} + +impl QueueState { + fn admit(&mut self, package: BroadcastPackage, retry_at: Option) -> QueueOutcome { + let txids = package.txids(); + if self.retries.iter().any(|(_, waiting, _)| *waiting == txids) { + // Same transactions, same classification outcome: keep the waiting entry and its + // earlier deadline. The one same-txid package with a *different* type is LDK's + // re-typed generic-funding rebroadcast of a promoted 0conf splice, which always + // arrives after the interactive-funding original (the zero-conf rebroadcast canary + // tests assert that ordering), so the entry kept is the richer of the two — and its + // classification declines the downgrade anyway. + return QueueOutcome::AlreadyQueued(package); + } + + let mut dropped = None; + if package.is_droppable() && self.fresh.len() + self.retries.len() >= MAX_QUEUED_PACKAGES { + // Drop the oldest droppable package, a waiting retry before a fresh package: its + // transactions are re-broadcast periodically, while the incoming package may carry + // a fresher fee-bumped variant. A funding package is never dropped — nothing would + // re-broadcast it, and losing it leaves its transaction confirming without a + // recorded candidate. Neither is a cooperative close, whose queued package may hold + // the only copy of the signed closing transaction. + dropped = self.drop_oldest_droppable(); + if dropped.is_none() { + return QueueOutcome::Refused(package); + } + } + match retry_at { + Some(retry_at) => self.retries.push_back((retry_at, txids, package)), + None => self.fresh.push_back(package), + } + QueueOutcome::Queued { dropped } + } + + fn drop_oldest_droppable(&mut self) -> Option { + if let Some(oldest) = self.retries.iter().position(|(_, _, waiting)| waiting.is_droppable()) + { + return self.retries.remove(oldest).map(|(_, _, package)| package); + } + let oldest = self.fresh.iter().position(|waiting| waiting.is_droppable())?; + self.fresh.remove(oldest) + } } pub(crate) struct SortedTransactions(Vec); @@ -96,11 +289,10 @@ pub(crate) struct TransactionBroadcaster where L::Target: LdkLogger, { - queue_sender: mpsc::Sender, - queue_receiver: Mutex>, + queue: BroadcastQueue, /// Weak handle to the [`Wallet`] that classifies funding broadcasts (channel opens and /// splices) into payment records. Remains `None` while the builder is wiring the node up, - /// during which broadcasts are forwarded to the queue but no payment record is written. + /// during which broadcasts are queued but no payment record is written. /// [`Self::set_wallet`] installs the handle once the [`Wallet`] exists. wallet: StdMutex>>, logger: L, @@ -111,13 +303,7 @@ where L::Target: LdkLogger, { pub(crate) fn new(logger: L) -> Self { - let (queue_sender, queue_receiver) = mpsc::channel(BCAST_PACKAGE_QUEUE_SIZE); - Self { - queue_sender, - queue_receiver: Mutex::new(queue_receiver), - wallet: StdMutex::new(None), - logger, - } + Self { queue: BroadcastQueue::new(), wallet: StdMutex::new(None), logger } } /// Installs the [`Wallet`] handle used to classify funding broadcasts (channel opens and @@ -127,18 +313,52 @@ where *self.wallet.lock().expect("lock") = Some(wallet); } - pub(crate) async fn get_broadcast_queue( - &self, - ) -> MutexGuard<'_, mpsc::Receiver> { - self.queue_receiver.lock().await + /// The next queued package to classify and broadcast, waiting for one when none is ready; + /// see [`BroadcastQueue::next`]. + pub(crate) async fn next_package(&self) -> BroadcastPackage { + self.queue.next().await + } + + /// Queues a package whose classification failed, to be attempted again at `retry_at`. + pub(crate) fn retry_package(&self, package: BroadcastPackage, retry_at: Instant) { + self.log_dropped(self.queue.retry(package, retry_at)); + } + + fn queue_package(&self, package: BroadcastPackage) { + self.log_dropped(self.queue.push(package)); + } + + fn log_dropped(&self, outcome: QueueOutcome) { + match outcome { + QueueOutcome::Queued { dropped: None } => {}, + QueueOutcome::Queued { dropped: Some(dropped) } => { + log_error!( + self.logger, + "Dropped the oldest queued package to make room; its transactions are re-broadcast periodically: {:?}", + dropped.txids(), + ); + }, + QueueOutcome::AlreadyQueued(duplicate) => { + log_debug!( + self.logger, + "Dropped a re-broadcast package; an identical one already awaits a classification retry: {:?}", + duplicate.txids(), + ); + }, + QueueOutcome::Refused(package) => { + log_error!( + self.logger, + "Dropped a package; too many packages await classification and broadcast: {:?}", + package.txids(), + ); + }, + } } - /// Classifies a queued package into payment records 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,13 +367,11 @@ where } } } - Ok(package) + Ok(()) } pub(crate) fn broadcast_unclassified_transaction(&self, tx: Transaction) { - self.queue_sender.try_send(BroadcastPackage::unclassified(tx)).unwrap_or_else(|e| { - log_error!(self.logger, "Failed to broadcast transactions: {}", e); - }); + self.queue_package(BroadcastPackage::unclassified(tx)); } } @@ -162,18 +380,21 @@ where L::Target: LdkLogger, { fn broadcast_transactions(&self, txs: &[(&Transaction, LdkTransactionType)]) { - self.queue_sender.try_send(BroadcastPackage::new(txs)).unwrap_or_else(|e| { - log_error!(self.logger, "Failed to broadcast transactions: {}", e); - }); + self.queue_package(BroadcastPackage::new(txs)); } } #[cfg(test)] mod tests { + use std::collections::BTreeSet; + use bitcoin::hashes::Hash; use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; - use super::SortedTransactions; + use super::{ + BroadcastPackage, BroadcastQueue, LdkTransactionType, QueueOutcome, SortedTransactions, + MAX_QUEUED_PACKAGES, + }; fn txin(txid: Txid, vout: u32) -> TxIn { TxIn { @@ -314,4 +535,417 @@ 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 due retry: `next` hands out a retry once its deadline has passed. + fn due() -> tokio::time::Instant { + tokio::time::Instant::now() + } + + /// Everything `next` hands out before the queue goes quiet, in order. + async fn drain(queue: &BroadcastQueue) -> Vec { + let mut txids = Vec::new(); + while let Ok(package) = + tokio::time::timeout(std::time::Duration::from_millis(200), queue.next()).await + { + txids.extend(package.txids()); + } + txids + } + + /// While a package awaits a retry, another with the same transactions is not queued, whether + /// it arrives as a retry or fresh: the waiting entry keeps its deadline and its package. + #[tokio::test] + async fn identical_transactions_are_queued_once_while_a_retry_waits() { + let tx = parent_tx(1); + let queue = BroadcastQueue::new(); + + assert!(matches!( + queue.retry(funding_package(&tx), due()), + QueueOutcome::Queued { dropped: None } + )); + assert!(matches!( + queue.retry(BroadcastPackage::unclassified(tx.clone()), deadline(4)), + QueueOutcome::AlreadyQueued(_) + )); + assert!(matches!( + queue.push(BroadcastPackage::unclassified(tx.clone())), + QueueOutcome::AlreadyQueued(_) + )); + + // The kept entry is due now; the duplicate's later deadline must not have replaced it. + let kept = tokio::time::timeout(std::time::Duration::from_secs(1), queue.next()) + .await + .expect("the waiting entry keeps its earlier deadline"); + assert!( + matches!(kept.transactions()[0].1, Some(LdkTransactionType::Funding { .. })), + "the first-scheduled package must be kept" + ); + assert!(drain(&queue).await.is_empty()); + } + + /// Fresh packages are not deduplicated against each other: two arrivals of the same + /// transactions before either is attempted are both classified, as with the channel before. + #[tokio::test] + async fn fresh_packages_are_not_deduplicated_against_each_other() { + let tx = parent_tx(1); + let queue = BroadcastQueue::new(); + + assert!(matches!(queue.push(funding_package(&tx)), QueueOutcome::Queued { dropped: None })); + assert!(matches!( + queue.push(BroadcastPackage::unclassified(tx.clone())), + QueueOutcome::Queued { dropped: None } + )); + assert_eq!(drain(&queue).await, vec![tx.compute_txid(), tx.compute_txid()]); + } + + /// Fresh packages go before due retries, each group in arrival order. + #[tokio::test] + async fn fresh_packages_come_before_due_retries() { + let (tx_a, tx_b, tx_c, tx_d) = (parent_tx(1), parent_tx(2), parent_tx(3), parent_tx(4)); + let queue = BroadcastQueue::new(); + + assert!(matches!( + queue.retry(BroadcastPackage::unclassified(tx_a.clone()), due()), + QueueOutcome::Queued { dropped: None } + )); + assert!(matches!( + queue.retry(BroadcastPackage::unclassified(tx_b.clone()), due()), + QueueOutcome::Queued { dropped: None } + )); + assert!(matches!( + queue.push(BroadcastPackage::unclassified(tx_c.clone())), + QueueOutcome::Queued { dropped: None } + )); + assert!(matches!( + queue.push(BroadcastPackage::unclassified(tx_d.clone())), + QueueOutcome::Queued { dropped: None } + )); + + assert_eq!( + drain(&queue).await, + vec![ + tx_c.compute_txid(), + tx_d.compute_txid(), + tx_a.compute_txid(), + tx_b.compute_txid() + ] + ); + } + + /// `next` waits for a package when none is queued and wakes when one is pushed. + #[tokio::test] + async fn next_wakes_on_a_push() { + let tx = parent_tx(1); + let queue = BroadcastQueue::new(); + + assert!(tokio::time::timeout(std::time::Duration::from_millis(100), queue.next()) + .await + .is_err()); + + let (pushed, next) = tokio::join!( + async { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + queue.push(BroadcastPackage::unclassified(tx.clone())) + }, + tokio::time::timeout(std::time::Duration::from_secs(5), queue.next()), + ); + assert!(matches!(pushed, QueueOutcome::Queued { dropped: None })); + assert_eq!(next.expect("woken by the push").txids(), BTreeSet::from([tx.compute_txid()])); + } + + /// A fresh package pushed while `next` waits out a retry's delay is handed out at once; the + /// retry keeps waiting. + #[tokio::test] + async fn next_wakes_on_a_push_while_a_retry_waits() { + let (retry_tx, fresh_tx) = (parent_tx(1), parent_tx(2)); + let queue = BroadcastQueue::new(); + + assert!(matches!( + queue.retry(BroadcastPackage::unclassified(retry_tx.clone()), deadline(5)), + QueueOutcome::Queued { dropped: None } + )); + let (pushed, next) = tokio::join!( + async { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + queue.push(BroadcastPackage::unclassified(fresh_tx.clone())) + }, + tokio::time::timeout(std::time::Duration::from_secs(2), queue.next()), + ); + assert!(matches!(pushed, QueueOutcome::Queued { dropped: None })); + assert_eq!( + next.expect("woken by the push before the retry falls due").txids(), + BTreeSet::from([fresh_tx.compute_txid()]) + ); + assert!(drain(&queue).await.is_empty(), "the retry was handed out before its deadline"); + } + + /// `next` holds a retry back until its deadline, then hands it out on its own. + #[tokio::test] + async fn next_waits_for_a_retry_deadline() { + let tx = parent_tx(1); + let queue = BroadcastQueue::new(); + + let retry_at = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + assert!(matches!( + queue.retry(BroadcastPackage::unclassified(tx.clone()), retry_at), + QueueOutcome::Queued { dropped: None } + )); + assert!(tokio::time::timeout(std::time::Duration::from_millis(500), queue.next()) + .await + .is_err()); + + let next = tokio::time::timeout(std::time::Duration::from_secs(5), queue.next()).await; + assert!(tokio::time::Instant::now() >= retry_at, "the retry was handed out early"); + assert_eq!(next.expect("due retry").txids(), BTreeSet::from([tx.compute_txid()])); + } + + /// Distinct transactions (e.g. fee-bumped claim variants during a store outage) are held to + /// the bound across fresh and waiting packages: the oldest droppable package is dropped for + /// an incoming one, a waiting retry before a fresh package and never a funding package. + #[tokio::test] + async fn bound_drops_the_oldest_droppable_retry_before_a_fresh_package() { + fn numbered_tx(n: u32) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([7u8; 32]), n)], + output: vec![txout(1_000)], + } + } + + let queue = BroadcastQueue::new(); + let funding_tx = numbered_tx(0); + assert!(matches!( + queue.retry(funding_package(&funding_tx), due()), + QueueOutcome::Queued { dropped: None } + )); + let oldest_claim = numbered_tx(1); + let retried = MAX_QUEUED_PACKAGES as u32 / 2; + for n in 1..retried { + assert!(matches!( + queue.retry(BroadcastPackage::unclassified(numbered_tx(n)), due()), + QueueOutcome::Queued { dropped: None } + )); + } + let oldest_fresh = numbered_tx(retried); + for n in retried..(MAX_QUEUED_PACKAGES as u32) { + assert!(matches!( + queue.push(BroadcastPackage::unclassified(numbered_tx(n))), + QueueOutcome::Queued { dropped: None } + )); + } + + // At the bound, an incoming droppable package drops the oldest waiting retry — not the + // older funding package, and not a fresh package. + let new_claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); + match queue.push(BroadcastPackage::unclassified(new_claim.clone())) { + QueueOutcome::Queued { dropped: Some(dropped) } => { + assert_eq!(dropped.txids(), BTreeSet::from([oldest_claim.compute_txid()])); + }, + _ => panic!("the incoming claim must be queued by dropping the oldest one"), + } + + // An incoming funding package is never dropped for the bound. + let new_funding_tx = numbered_tx(MAX_QUEUED_PACKAGES as u32 + 1); + assert!(matches!( + queue.push(funding_package(&new_funding_tx)), + QueueOutcome::Queued { dropped: None } + )); + + let remaining = drain(&queue).await; + assert_eq!(remaining.len(), MAX_QUEUED_PACKAGES + 1); + assert!(remaining.contains(&funding_tx.compute_txid()), "funding is never dropped"); + assert!(remaining.contains(&oldest_fresh.compute_txid()), "a retry is dropped first"); + assert!(remaining.contains(&new_claim.compute_txid())); + assert!(remaining.contains(&new_funding_tx.compute_txid())); + assert!(!remaining.contains(&oldest_claim.compute_txid())); + } + + /// With no retry waiting, the bound falls on the fresh packages: the oldest droppable one + /// is dropped for an incoming one. + #[tokio::test] + async fn bound_drops_the_oldest_droppable_fresh_package() { + fn numbered_tx(n: u32) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([11u8; 32]), n)], + output: vec![txout(1_000)], + } + } + + let queue = BroadcastQueue::new(); + let oldest = numbered_tx(0); + for n in 0..(MAX_QUEUED_PACKAGES as u32) { + assert!(matches!( + queue.push(claim_package(&numbered_tx(n))), + QueueOutcome::Queued { dropped: None } + )); + } + + let new_claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); + match queue.push(claim_package(&new_claim)) { + QueueOutcome::Queued { dropped: Some(dropped) } => { + assert_eq!(dropped.txids(), BTreeSet::from([oldest.compute_txid()])); + }, + _ => panic!("the incoming claim must be queued by dropping the oldest one"), + } + + let remaining = drain(&queue).await; + assert_eq!(remaining.len(), MAX_QUEUED_PACKAGES); + assert!(!remaining.contains(&oldest.compute_txid())); + assert_eq!(remaining.last(), Some(&new_claim.compute_txid())); + } + + /// When only funding packages are queued at the bound, an incoming droppable package is + /// refused, fresh or retried: LDK re-broadcasts claims and sweeps periodically, while a + /// dropped funding package would leave its transaction confirming without a recorded + /// candidate. + #[tokio::test] + async fn bound_refuses_a_droppable_package_over_queued_funding_packages() { + fn numbered_tx(n: u32) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([8u8; 32]), n)], + output: vec![txout(1_000)], + } + } + + let queue = BroadcastQueue::new(); + for n in 0..(MAX_QUEUED_PACKAGES as u32) { + assert!(matches!( + queue.retry(funding_package(&numbered_tx(n)), deadline(60)), + QueueOutcome::Queued { dropped: None } + )); + } + + let claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); + assert!(matches!( + queue.push(BroadcastPackage::unclassified(claim.clone())), + QueueOutcome::Refused(_) + )); + assert!(matches!( + queue.retry(BroadcastPackage::unclassified(claim), deadline(60)), + QueueOutcome::Refused(_) + )); + } + + /// A cooperative close is never dropped at the bound: nothing re-broadcasts it, and the + /// queued package may hold the only copy of the signed closing transaction. + #[tokio::test] + async fn bound_never_drops_a_cooperative_close() { + 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 queue = BroadcastQueue::new(); + let coop_close_tx = numbered_tx(0); + assert!(matches!( + queue.retry(coop_close_package(&coop_close_tx), due()), + QueueOutcome::Queued { dropped: None } + )); + let oldest_claim = numbered_tx(1); + for n in 1..(MAX_QUEUED_PACKAGES as u32) { + assert!(matches!( + queue.retry(claim_package(&numbered_tx(n)), due()), + QueueOutcome::Queued { dropped: None } + )); + } + + // At the bound, an incoming claim drops the oldest waiting claim — not the older + // cooperative close. + let new_claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); + match queue.retry(claim_package(&new_claim), due()) { + QueueOutcome::Queued { dropped: Some(dropped) } => { + assert_eq!(dropped.txids(), BTreeSet::from([oldest_claim.compute_txid()])); + }, + _ => panic!("the incoming claim must be queued by dropping the oldest one"), + } + + // An incoming cooperative close is never dropped for the bound either. + let new_coop_close_tx = numbered_tx(MAX_QUEUED_PACKAGES as u32 + 1); + assert!(matches!( + queue.push(coop_close_package(&new_coop_close_tx)), + QueueOutcome::Queued { dropped: None } + )); + + let remaining = drain(&queue).await; + 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 are queued at the bound, an incoming claim is refused: LDK + /// re-broadcasts the claim periodically, while a dropped close would lose the only copy of + /// its signed closing transaction. + #[tokio::test] + async fn bound_refuses_a_claim_over_queued_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 queue = BroadcastQueue::new(); + for n in 0..(MAX_QUEUED_PACKAGES as u32) { + assert!(matches!( + queue.push(coop_close_package(&numbered_tx(n))), + QueueOutcome::Queued { dropped: None } + )); + } + + let claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); + assert!(matches!( + queue.retry(claim_package(&claim), deadline(60)), + QueueOutcome::Refused(_) + )); + } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 12b9374b9d..0bfb017763 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2745,7 +2745,7 @@ fn funding_reclassification_update( #[cfg(all(test, any(feature = "chain-esplora", feature = "chain-electrum")))] mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::Duration; use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate}; @@ -2775,11 +2775,13 @@ mod tests { const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; - /// An in-memory store whose writes can be made to fail on demand. + /// An in-memory store whose writes can be made to fail on demand, counting the failures so + /// tests can wait for a write to have actually failed rather than guessing with a sleep. #[derive(Clone)] struct FailSwitchStore { inner: Arc, fail_writes: Arc, + failed_writes: Arc, } impl FailSwitchStore { @@ -2787,6 +2789,7 @@ mod tests { Self { inner: Arc::new(InMemoryStore::new()), fail_writes: Arc::new(AtomicBool::new(false)), + failed_writes: Arc::new(AtomicUsize::new(0)), } } } @@ -2803,11 +2806,13 @@ mod tests { ) -> impl Future> + 'static + Send { let inner = Arc::clone(&self.inner); let fail_writes = Arc::clone(&self.fail_writes); + let failed_writes = Arc::clone(&self.failed_writes); let primary_namespace = primary_namespace.to_string(); let secondary_namespace = secondary_namespace.to_string(); let key = key.to_string(); async move { if fail_writes.load(Ordering::Acquire) { + failed_writes.fetch_add(1, Ordering::AcqRel); return Err(io::Error::new(io::ErrorKind::Other, "writes disabled")); } KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await @@ -3170,14 +3175,17 @@ mod tests { } /// An in-memory store whose writes can be made to park until aborted or released, - /// signalling when a write has entered the gate, and whose writes can be made to fail. + /// signalling when a write has entered the gate, and whose writes can be made to fail, + /// counting the failures. Records the keys it wrote, in order. #[derive(Clone)] struct GatedStore { inner: Arc, gate_writes: Arc, fail_writes: Arc, + failed_writes: Arc, write_entered: Arc, release: Arc, + writes: Arc>>, } impl GatedStore { @@ -3186,10 +3194,23 @@ mod tests { inner: Arc::new(InMemoryStore::new()), gate_writes: Arc::new(AtomicBool::new(false)), fail_writes: Arc::new(AtomicBool::new(false)), + failed_writes: Arc::new(AtomicUsize::new(0)), write_entered: Arc::new(tokio::sync::Notify::new()), release: Arc::new(tokio::sync::Notify::new()), + writes: Arc::new(Mutex::new(Vec::new())), } } + + /// The keys written to `primary_namespace`, in write order. + fn written_keys(&self, primary_namespace: &str) -> Vec { + self.writes + .lock() + .unwrap() + .iter() + .filter(|(namespace, _)| namespace == primary_namespace) + .map(|(_, key)| key.clone()) + .collect() + } } impl KVStore for GatedStore { @@ -3205,8 +3226,10 @@ mod tests { let inner = Arc::clone(&self.inner); let gate_writes = Arc::clone(&self.gate_writes); let fail_writes = Arc::clone(&self.fail_writes); + let failed_writes = Arc::clone(&self.failed_writes); let write_entered = Arc::clone(&self.write_entered); let release = Arc::clone(&self.release); + let writes = Arc::clone(&self.writes); let primary_namespace = primary_namespace.to_string(); let secondary_namespace = secondary_namespace.to_string(); let key = key.to_string(); @@ -3216,9 +3239,13 @@ mod tests { release.notified().await; } if fail_writes.load(Ordering::Acquire) { + failed_writes.fetch_add(1, Ordering::AcqRel); return Err(io::Error::new(io::ErrorKind::Other, "write failed")); } - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf) + .await?; + writes.lock().unwrap().push((primary_namespace, key)); + Ok(()) } } @@ -4329,6 +4356,379 @@ 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 survives a stop, as a package the loop has not + /// reached yet always has: the queue belongs to the broadcaster, not to the loop, so the next + /// `start()` classifies and broadcasts whatever was queued when the node stopped. A funding + /// package in particular has no other way back: no timer re-broadcasts it. + #[tokio::test] + async fn packages_awaiting_retry_survive_a_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 + }); + + // The restarted loop classifies the package from before the stop once its retry falls + // due. + let mut payments = Vec::new(); + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + payments = wallet.payment_store.list_page(None).await.unwrap().objects; + if !payments.is_empty() { + break; + } + } + assert_eq!( + payments.len(), + 1, + "the package from before stop() was not classified after restart" + ); + assert!( + matches!(&payments[0].kind, PaymentKind::Onchain { txid, .. } if *txid == tx.compute_txid()), + "the record does not track the package's transaction" + ); + + stop_sender.send(()).unwrap(); + loop_task.await.unwrap(); + } + + /// A re-broadcast of a package awaiting a classification retry is dropped as it is queued, + /// without another classification attempt: LDK re-hands pending claims every 30 seconds, so + /// over a store outage each copy would otherwise cost a failed write and an error log before + /// the queue recognized it. + #[tokio::test] + async fn rebroadcast_of_a_waiting_package_is_not_classified_again() { + use lightning::chain::chaininterface::BroadcasterInterface; + + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.broadcaster.set_wallet(Arc::downgrade(&wallet)); + + let (stop_sender, stop_receiver) = tokio::sync::watch::channel(()); + let chain_source = Arc::clone(&wallet.chain_source); + let loop_task = tokio::spawn(async move { + chain_source.continuously_process_broadcast_queue(stop_receiver).await + }); + + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let funding_type = LdkTransactionType::Funding { + channels: vec![(counterparty_node_id, ChannelId([7u8; 32]))], + }; + + // The first copy fails classification and waits for its retry. + fail_store.fail_writes.store(true, Ordering::Release); + wallet.broadcaster.broadcast_transactions(&[(&tx, funding_type.clone())]); + let mut failed_writes = 0; + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + failed_writes = fail_store.failed_writes.load(Ordering::Acquire); + if failed_writes > 0 { + break; + } + } + assert_eq!(failed_writes, 1, "classification never attempted a payment-store write"); + + // A second copy arrives well within the retry delay. It must not reach the store. + wallet.broadcaster.broadcast_transactions(&[(&tx, funding_type)]); + tokio::time::sleep(Duration::from_millis(500)).await; + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 1, + "a re-broadcast of a package awaiting a retry was classified again" + ); + + // Once the store recovers, the waiting package is classified once. + fail_store.fail_writes.store(false, Ordering::Release); + let mut payments = Vec::new(); + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + payments = wallet.payment_store.list_page(None).await.unwrap().objects; + if !payments.is_empty() { + break; + } + } + assert_eq!(payments.len(), 1, "the waiting package was not classified after recovery"); + + stop_sender.send(()).unwrap(); + loop_task.await.unwrap(); + } + + /// Fresh packages go before a due retry. Both are ready at once when the loop returns from a + /// slow classification with fresh packages queued and a retry past its deadline; the queue + /// hands out every fresh package first, so broadcasts arriving during a store outage are never + /// held back by the outage's retries. + #[tokio::test] + async fn fresh_package_is_classified_before_a_due_retry() { + use lightning::chain::chaininterface::BroadcasterInterface; + + use crate::data_store::StorableObjectId; + + let gated_store = GatedStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(gated_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.broadcaster.set_wallet(Arc::downgrade(&wallet)); + + let (stop_sender, stop_receiver) = tokio::sync::watch::channel(()); + let chain_source = Arc::clone(&wallet.chain_source); + let loop_task = tokio::spawn(async move { + chain_source.continuously_process_broadcast_queue(stop_receiver).await + }); + + // Three funding transactions paying the wallet, so each classification reaches the + // payment-store write. + let funding_tx = |input_byte: u8| { + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: Txid::from_byte_array([input_byte; 32]), + vout: 0, + }, + ..Default::default() + }], + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + } + }; + let retried_tx = funding_tx(1); + let parked_tx = funding_tx(2); + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let funding_type = LdkTransactionType::Funding { + channels: vec![(counterparty_node_id, ChannelId([7u8; 32]))], + }; + + // The first package fails classification and is scheduled to retry after the delay. + gated_store.fail_writes.store(true, Ordering::Release); + wallet.broadcaster.broadcast_transactions(&[(&retried_tx, funding_type.clone())]); + let mut failed_writes = 0; + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + failed_writes = gated_store.failed_writes.load(Ordering::Acquire); + if failed_writes > 0 { + break; + } + } + assert!(failed_writes > 0, "classification never attempted a payment-store write"); + + // The second package parks in its payment-store write, holding the loop past the retry's + // deadline. + gated_store.fail_writes.store(false, Ordering::Release); + gated_store.gate_writes.store(true, Ordering::Release); + wallet.broadcaster.broadcast_transactions(&[(&parked_tx, funding_type.clone())]); + gated_store.write_entered.notified().await; + + // Fresh packages queue while the retry falls due: several, so a queue that only sometimes + // hands out a fresh package ahead of a due retry cannot pass the ordering assertion below + // by luck. + let fresh_txs: Vec = + (3..11).map(|input_byte| funding_tx(input_byte)).collect(); + for fresh_tx in &fresh_txs { + wallet.broadcaster.broadcast_transactions(&[(fresh_tx, funding_type.clone())]); + } + tokio::time::sleep(crate::chain::FAILED_CLASSIFY_RETRY_DELAY + Duration::from_millis(500)) + .await; + + // Once the parked package completes, the fresh packages and the due retry are all ready. + gated_store.gate_writes.store(false, Ordering::Release); + gated_store.release.notify_one(); + let expected_writes = fresh_txs.len() + 2; + let mut payment_writes = Vec::new(); + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + payment_writes = gated_store.written_keys(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + if payment_writes.len() >= expected_writes { + break; + } + } + assert_eq!(payment_writes.len(), expected_writes, "not every package was classified"); + let position = |tx: &Transaction| { + let key = PaymentId(tx.compute_txid().to_byte_array()).encode_to_hex_str(); + payment_writes.iter().position(|written| *written == key).expect("classified") + }; + let retried_position = position(&retried_tx); + assert!( + fresh_txs.iter().all(|fresh_tx| position(fresh_tx) < retried_position), + "the due retry was classified before a fresh package" + ); + + stop_sender.send(()).unwrap(); + loop_task.await.unwrap(); + } + /// Barrier test, classification-first ordering: wallet sync's confirmation handling must /// wait for classification's two-store write pair. Classification is parked between its /// payment-store and pending-store writes (the torn window) and only then is the From 0ba0e63d984dfc41feb2d8c5353667149e00c055 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 25 Sep 2026 10:42:16 -0700 Subject: [PATCH 03/49] DROP ME: f - Retry funding-broadcast classification instead of dropping it Log a re-broadcast dropped for an identical queued package at trace level: it repeats for every LDK re-broadcast while the store is down. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit c41265831cc413a5e34ec4962af6bf1033b03125) --- src/tx_broadcaster.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 46af8ba2e9..edf894f92a 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -16,7 +16,7 @@ use lightning::chain::chaininterface::{ use tokio::sync::Notify; use tokio::time::Instant; -use crate::logger::{log_debug, log_error, LdkLogger}; +use crate::logger::{log_error, log_trace, LdkLogger}; use crate::types::Wallet; use crate::Error; @@ -339,7 +339,7 @@ where ); }, QueueOutcome::AlreadyQueued(duplicate) => { - log_debug!( + log_trace!( self.logger, "Dropped a re-broadcast package; an identical one already awaits a classification retry: {:?}", duplicate.txids(), From 706ee38a3c058c1087121de27663dc183e074855 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 2 Sep 2026 13:53:47 -0500 Subject: [PATCH 04/49] DROP ME: 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 (cherry picked from commit 767855c80bf7506b22c19506e17ce089ae9a1856) --- src/wallet/mod.rs | 834 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 826 insertions(+), 8 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 0bfb017763..d485bd787d 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)] @@ -363,9 +364,20 @@ impl Wallet { FundingStatusUpdate::Applied => continue, FundingStatusUpdate::NotFunding => {}, // Not part of the funding payment's history (e.g. a close spending the - // funding outpoint): record it under its own id below instead. + // funding outpoint): record it under its own id below instead, unless a + // settled funding payment sits there already. FundingStatusUpdate::Foreign => { - payment_id = PaymentId(txid.to_byte_array()); + match self.foreign_transaction_payment_id(payment_id, txid).await? { + Some(fallback_id) => payment_id = fallback_id, + None => { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); + continue; + }, + } }, } @@ -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); + } }, _ => {}, } @@ -510,9 +530,20 @@ impl Wallet { FundingStatusUpdate::Applied => continue, FundingStatusUpdate::NotFunding => {}, // Not part of the funding payment's history (e.g. a close spending the - // funding outpoint): record it under its own id below instead. + // funding outpoint): record it under its own id below instead, unless a + // settled funding payment sits there already. FundingStatusUpdate::Foreign => { - payment_id = PaymentId(txid.to_byte_array()); + match self.foreign_transaction_payment_id(payment_id, txid).await? { + Some(fallback_id) => payment_id = fallback_id, + None => { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); + continue; + }, + } }, } @@ -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()); @@ -592,9 +635,20 @@ impl Wallet { FundingStatusUpdate::Applied => continue, FundingStatusUpdate::NotFunding => {}, // Not part of the funding payment's history (e.g. a close spending the - // funding outpoint): record it under its own id below instead. + // funding outpoint): record it under its own id below instead, unless a + // settled funding payment sits there already. FundingStatusUpdate::Foreign => { - payment_id = PaymentId(txid.to_byte_array()); + match self.foreign_transaction_payment_id(payment_id, txid).await? { + Some(fallback_id) => payment_id = fallback_id, + None => { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); + continue; + }, + } }, } @@ -623,6 +677,164 @@ impl Wallet { Ok(()) } + /// The id to record a transaction under that the funding-status check found foreign to the + /// funding record resolved for it as `resolved_id`: its own txid-derived id, or `None` when a + /// funding record sits there already. A funding record's id is anchored to its first + /// candidate's txid, so a wallet event for that transaction falls back to this id whenever the + /// pending entry no longer maps it — which only happens once the negotiation settled and the + /// entry was removed. The generic event handling must then skip its write: merging a + /// wallet-view `Pending` payment into the settled record would resurrect it with figures no + /// classification derived. When `resolved_id` is the txid-derived id already, the + /// funding-status check has read that record, and finding the transaction foreign to it is + /// this very case; only a fallback from a different id needs a read. + async fn foreign_transaction_payment_id( + &self, resolved_id: PaymentId, txid: Txid, + ) -> Result, Error> { + let fallback_id = PaymentId(txid.to_byte_array()); + if resolved_id == fallback_id { + return Ok(None); + } + let has_funding_record = + self.payment_store.get(&fallback_id).await?.is_some_and(|payment| { + matches!( + payment.kind, + PaymentKind::Onchain { + tx_type: Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. } + ), + .. + } + ) + }); + Ok(if has_funding_record { None } else { Some(fallback_id) }) + } + + /// Fails a funding payment whose transaction has irrevocably lost a conflict: a transaction + /// 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, @@ -2775,6 +2987,71 @@ mod tests { const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; + /// An in-memory store counting the reads it serves, by primary namespace, so tests can pin + /// how many backend reads an operation costs. + #[derive(Clone)] + struct ReadCountingStore { + inner: Arc, + reads: Arc>>, + } + + impl ReadCountingStore { + fn new() -> Self { + Self { inner: Arc::new(InMemoryStore::new()), reads: Arc::new(Mutex::new(Vec::new())) } + } + + /// The number of reads served from `primary_namespace` so far. + fn reads(&self, primary_namespace: &str) -> usize { + self.reads + .lock() + .unwrap() + .iter() + .filter(|namespace| *namespace == primary_namespace) + .count() + } + } + + impl KVStore for ReadCountingStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + self.reads.lock().unwrap().push(primary_namespace.to_string()); + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + KVStore::write(&*self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for ReadCountingStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + /// An in-memory store whose writes can be made to fail on demand, counting the failures so /// tests can wait for a write to have actually failed rather than guessing with a sleep. #[derive(Clone)] @@ -3799,6 +4076,57 @@ mod tests { } } + /// Inserts `tx` into the BDK wallet as canonically confirmed at `height`, extending the + /// local chain to that height. + fn insert_confirmed_tx(wallet: &Wallet, tx: Transaction, height: u32) { + let txid = tx.compute_txid(); + let mut locked = wallet.inner.lock().unwrap(); + let block = + BlockId { height, hash: bitcoin::BlockHash::from_byte_array([height as u8; 32]) }; + let chain = locked.latest_checkpoint().insert(block); + let mut tx_update = bdk_chain::TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.anchors = + [(ConfirmationBlockTime { block_id: block, confirmation_time: 100 }, txid)].into(); + locked + .apply_update(Update { tx_update, chain: Some(chain), ..Default::default() }) + .unwrap(); + } + + /// Inserts `tx` into the BDK wallet as canonically unconfirmed (seen in the mempool). + fn insert_unconfirmed_tx(wallet: &Wallet, tx: Transaction) { + let txid = tx.compute_txid(); + let mut locked = wallet.inner.lock().unwrap(); + let mut tx_update = bdk_chain::TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.seen_ats = [(txid, 100)].into(); + locked.apply_update(Update { tx_update, ..Default::default() }).unwrap(); + } + + /// Builds a transaction paying a wallet address, spending an outpoint derived from + /// `input_byte` (distinct bytes yield non-conflicting transactions). + fn wallet_paying_tx(wallet: &Wallet, input_byte: u8) -> Transaction { + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([input_byte; 32]), + vout: 0, + }, + ..Default::default() + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + } + } + #[test] fn funding_reclassification_update_substitutes_the_confirmed_candidate() { let confirmed_txid = Txid::from_byte_array([1u8; 32]); @@ -4214,6 +4542,496 @@ 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" + ); + } + + /// Recording a transaction the payment store does not know costs two reads of it: the + /// funding-status check looks the resolved id up, and the generic write merges against the + /// store. Nothing in between re-reads what the funding-status check has already seen. + #[tokio::test] + async fn unknown_transaction_is_recorded_after_two_payment_store_reads() { + let counting_store = ReadCountingStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let tx = wallet_paying_tx(&wallet, 1); + let txid = tx.compute_txid(); + let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let event = WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; + + let payment_id = PaymentId(txid.to_byte_array()); + assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_some()); + assert_eq!(reads, 2, "recording an unknown transaction re-read the payment store"); + } + + /// A funding record's id is anchored to its first candidate's txid. Once the payment settles + /// and its entry is removed, a wallet event for that candidate no longer resolves through the + /// candidate history — the fallback keys it by its own txid, colliding with the record's id. + /// 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 same collision through a conflict list: a pending entry naming a settled funding + /// record's transaction as a conflict of its own round resolves an event for that transaction + /// to the entry's record, which finds it foreign, and the fallback to the transaction's own id + /// lands on the settled record. That id is read before anything is written under it. + #[tokio::test] + async fn conflict_listed_event_does_not_resurrect_a_settled_funding_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // A settled funding record under the txid-derived id of r1, its pending entry gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let settled_id = PaymentId(r1.to_byte_array()); + let mut settled = interactive_funding_details(settled_id, r1, Some(1_000_000), Some(600)); + settled.status = PaymentStatus::Failed; + settled.latest_update_timestamp = 7; + wallet.payment_store.insert_or_update(settled).await.unwrap(); + + // A live funding record whose entry lists r1 as a conflict of its round r2. + let r2 = Txid::from_byte_array([4u8; 32]); + let live_id = PaymentId(r2.to_byte_array()); + let live = interactive_funding_details(live_id, r2, Some(2_000_000), Some(700)); + wallet.payment_store.insert_or_update(live.clone()).await.unwrap(); + wallet + .pending_payment_store + .insert_or_update(PendingPaymentDetails::new(live.clone(), vec![r1], Vec::new())) + .await + .unwrap(); + assert_eq!(wallet.find_payment_by_txid(r1).await.unwrap(), Some(live_id)); + + let event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&settled_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the settled record must not resurrect"); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet.pending_payment_store.get(&settled_id).await.unwrap().is_none()); + assert_eq!(wallet.payment_store.get(&live_id).await.unwrap(), Some(live)); + } + + /// The failure transition must apply regardless of the payment's direction: a splice-out + /// records as `Inbound` (funds return to the wallet) and dies to a conflicting close the + /// same way an outbound one does. + #[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 061f4a99a71117a85cc06eada1c57b87db7e26fb Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 4 Sep 2026 21:56:15 -0500 Subject: [PATCH 05/49] DROP ME: 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 once our tx_signatures were ready to send, normally as it hands the fully signed round to the broadcaster: from then on the counterparty may hold our signatures and 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 is reported failed only after ChannelClosed, and that report is resolved by what it 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 (cherry picked from commit 161a9332a24b01d796166beb2b3b748544b0037b) --- src/chain/mod.rs | 6 +- src/event.rs | 159 ++- src/lib.rs | 17 + src/payment/pending_payment_store.rs | 58 +- src/tx_broadcaster.rs | 36 +- src/wallet/mod.rs | 1739 +++++++++++++++++++++++++- tests/integration_tests_rust.rs | 281 ++++- 7 files changed, 2182 insertions(+), 114 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index e34d067b11..cf310f0bb4 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 e700873cdc..c68b6d94ba 100644 --- a/src/event.rs +++ b/src/event.rs @@ -13,8 +13,9 @@ use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, OutPoint}; +use bitcoin::{Amount, OutPoint, Txid}; use lightning::blinded_path::message::NextMessageHop; +use lightning::chain::chaininterface::FundingCandidate; use lightning::events::bump_transaction::BumpTransactionEvent; #[cfg(not(feature = "uniffi"))] use lightning::events::PaidBolt12Invoice; @@ -57,8 +58,10 @@ use crate::payment::PaymentMetadata; use crate::probing::Prober; use crate::runtime::Runtime; use crate::types::{ - CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, + ChainMonitor, CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, + Wallet, }; +use crate::wallet::{closed_channel_held_rounds, funding_candidates, held_splice_rounds}; use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, UserChannelId, @@ -559,6 +562,7 @@ where wallet: Arc, bump_tx_event_handler: Arc, channel_manager: Arc, + chain_monitor: Arc, connection_manager: Arc>, output_sweeper: Arc, network_graph: Arc, @@ -583,19 +587,21 @@ where pub fn new( event_queue: Arc>, wallet: Arc, bump_tx_event_handler: Arc, - channel_manager: Arc, connection_manager: Arc>, - output_sweeper: Arc, network_graph: Arc, - liquidity_source: Arc>>, payment_store: Arc, - forwarding_store: Arc, peer_store: Arc>, - keys_manager: Arc, static_invoice_store: Option, - onion_messenger: Arc, om_mailbox: Option>, - prober: Option>, runtime: Arc, logger: L, config: Arc, + channel_manager: Arc, chain_monitor: Arc, + connection_manager: Arc>, output_sweeper: Arc, + network_graph: Arc, liquidity_source: Arc>>, + payment_store: Arc, forwarding_store: Arc, + peer_store: Arc>, keys_manager: Arc, + static_invoice_store: Option, onion_messenger: Arc, + om_mailbox: Option>, prober: Option>, + runtime: Arc, logger: L, config: Arc, ) -> Self { Self { event_queue, wallet, bump_tx_event_handler, channel_manager, + chain_monitor, connection_manager, output_sweeper, network_graph, @@ -733,6 +739,31 @@ where Ok((payment_id, None)) } + /// The channel's pending splice rounds that have a transaction, as LDK currently holds them. + fn pending_splice_rounds( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Vec { + let splice_details = self + .channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .and_then(|channel| channel.splice_details); + funding_candidates(splice_details.as_ref(), counterparty_node_id, channel_id) + } + + /// The splice rounds LDK holds for the channel, as [`held_splice_rounds`] lists them, or + /// `None` once the channel is gone. + fn held_splice_rounds( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Option> { + self.channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .map(|channel| held_splice_rounds(channel.splice_details.as_ref(), channel.funding_txo)) + } + pub async fn handle_event(&self, event: LdkEvent) -> Result<(), ReplayEvent> { match event { LdkEvent::FundingGenerationReady { @@ -1931,10 +1962,41 @@ 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. Whatever the channel manager reports for a round + // still awaiting the counterparty's signatures when the channel closes is queued + // after this event, so its record is taken back here. 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"); @@ -2190,6 +2252,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, @@ -2204,9 +2286,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, + ); }, } }, @@ -2227,6 +2318,26 @@ where new_funding_txo, ); + // LDK emits this event only once our `tx_signatures` for the round are ready to + // send, so the counterparty may already hold them and may broadcast the round + // without us. The round's funding payment, recorded when the round was signed, + // therefore no longer awaits broadcast. On a failed write, replay: LDK re-offers + // the event in-session and persists it across restarts. + if let Err(e) = self + .wallet + .record_broadcast_splice_round(channel_id, new_funding_txo.txid) + .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), @@ -2255,6 +2366,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 9b0700b967..43cf6d5d66 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -366,6 +366,22 @@ impl Node { ) })?; + // A splice round recorded when this node signed it is taken back once LDK reports the + // negotiation failed or the channel closed. LDK reports the loss of a negotiation its last + // channel manager write carried mid-way, but a round committed, negotiated and signed + // since that write gets no report if the node stopped before the next one, so drop what + // LDK's persisted state does not hold before anything runs on the records: no background + // task has started yet, so a failure here fails the start cleanly. A channel LDK no + // longer lists is left to its `ChannelClosed` event. + let channels = self.channel_manager.list_channels(); + self.runtime.block_on(self.wallet.drop_splice_rounds_lost_across_restart( + |channel_id| { + channels.iter().find(|channel| channel.channel_id == channel_id).map(|channel| { + wallet::held_splice_rounds(channel.splice_details.as_ref(), channel.funding_txo) + }) + }, + ))?; + // Spawn background task continuously syncing onchain, lightning, and fee rate cache. let stop_sync_receiver = self.stop_sender.subscribe(); let chain_source = Arc::clone(&self.chain_source); @@ -685,6 +701,7 @@ impl Node { Arc::clone(&self.wallet), bump_tx_event_handler, Arc::clone(&self.channel_manager), + Arc::clone(&self.chain_monitor), Arc::clone(&self.connection_manager), Arc::clone(&self.output_sweeper), Arc::clone(&self.network_graph), diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index e14f64c380..9a1d6da116 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 LDK has yet to report the round negotiated. Set + /// when the round is recorded at signing time, cleared when LDK reports the splice negotiated + /// (`SpliceNegotiated`, emitted only once our `tx_signatures` for the round are ready to send). + /// Such a round may be abandoned without a trace — the counterparty aborts, or the channel + /// closes, before the signatures are exchanged — so only such a round may be dropped from the + /// history, and only once LDK no longer holds it. + pub awaiting_broadcast: bool, } impl_writeable_tlv_based!(FundingTxCandidate, { (0, txid, required), (2, amount_msat, option), (4, fee_paid_msat, option), + (6, awaiting_broadcast, required), }); /// Represents a pending payment @@ -108,8 +116,10 @@ impl UpdatableObject for PendingPaymentDetails { updated |= self.conflicting_txids.len() != conflicts_len; } - // Each classify passes the complete candidate history, so a non-empty update replaces the - // stored list. An empty update (e.g. a non-funding payment) leaves it untouched. + // Each funding-record write passes the candidate history as of its own round, so a + // non-empty update replaces the stored list. An empty update (e.g. a non-funding payment) + // leaves it untouched. Dropping an abandoned round, the only writer that shrinks it, goes + // through the store's `mutate` instead. if !update.candidates.is_empty() && self.candidates != update.candidates { self.candidates = update.candidates; updated = true; @@ -145,6 +155,40 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { } } +/// Builds a [`FundingContribution`] for tests through its `Readable` impl — the only path open +/// outside `rust-lightning`, which keeps its builder private. The length-prefixed stream holds +/// the required TLV records (the given estimated fee in satoshis, feerate, max feerate, and the +/// is-splice flag) plus the given contributed outputs. +/// +/// [`FundingContribution`]: lightning::ln::funding::FundingContribution +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_outputs( + estimated_fee_sat: u64, feerate: u64, outputs: &[bitcoin::TxOut], +) -> lightning::ln::funding::FundingContribution { + use lightning::util::ser::Writeable; + let mut records = vec![1, 8]; // (1, estimated_fee) + records.extend_from_slice(&estimated_fee_sat.to_be_bytes()); + if !outputs.is_empty() { + let mut output_bytes = Vec::new(); + for output in outputs { + output.write(&mut output_bytes).expect("in-memory write must succeed"); + } + records.push(5); // (5, outputs) + records.push(u8::try_from(output_bytes.len()).expect("test outputs must stay small")); + records.extend_from_slice(&output_bytes); + } + records.extend_from_slice(&[9, 8]); // (9, feerate) + records.extend_from_slice(&feerate.to_be_bytes()); + records.extend_from_slice(&[11, 8]); // (11, max_feerate) + records.extend_from_slice(&feerate.to_be_bytes()); + records.extend_from_slice(&[13, 1, 1]); // (13, is_splice: true) + // BigSize length prefix over the TLV records above; single-byte as long as they stay short. + let mut tlv_bytes = vec![u8::try_from(records.len()).expect("test TLV stream must stay small")]; + tlv_bytes.extend(records); + lightning::util::ser::Readable::read(&mut &tlv_bytes[..]) + .expect("hand-built TLV stream must decode") +} + #[cfg(test)] mod tests { use bitcoin::hashes::Hash; @@ -163,16 +207,23 @@ mod tests { // original and RBF candidates. let counterparty_txid = Txid::from_byte_array([4u8; 32]); let candidates = vec![ - FundingTxCandidate { txid: counterparty_txid, amount_msat: None, fee_paid_msat: None }, + FundingTxCandidate { + txid: counterparty_txid, + amount_msat: None, + fee_paid_msat: None, + awaiting_broadcast: false, + }, FundingTxCandidate { txid: first_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(1_000), + awaiting_broadcast: false, }, FundingTxCandidate { txid: rbf_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(5_000), + awaiting_broadcast: false, }, ]; @@ -282,6 +333,7 @@ mod tests { txid, amount_msat: fresh.amount_msat, fee_paid_msat: fresh.fee_paid_msat, + awaiting_broadcast: false, }]; // The old fresh-insert path merged the full fresh record, downgrading the mirrored diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index edf894f92a..8b6dbeddef 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -22,10 +22,12 @@ use crate::Error; /// The most packages [`BroadcastQueue`] holds, fresh and awaiting a retry together. Claims and /// sweeps re-enter the queue on LDK's periodic rebroadcast timers, so one dropped at the bound -/// resurfaces on its own once the store recovers. Packages nothing re-broadcasts — fundings and +/// resurfaces on its own once the store recovers. Packages no timer re-broadcasts — fundings and /// cooperative closes — are never dropped or refused for the bound, though they count toward it: -/// what LDK hands over of them is finite — one per negotiated funding candidate and one per -/// closing channel — and a copy of a package awaiting a retry is never queued twice. +/// what LDK hands over of them is finite — one per closing channel, and one per funding under the +/// `Funding` type each time its channel resumes while it is unconfirmed (splice rounds have +/// nothing to classify, so none ever awaits a retry) — and a copy of a package awaiting a retry +/// is never queued twice. const MAX_QUEUED_PACKAGES: usize = 256; /// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions` @@ -65,11 +67,12 @@ impl BroadcastPackage { /// Whether the package may be dropped to keep [`BroadcastQueue`] within its bound: every /// transaction in it is re-broadcast by its originator, so a dropped package resurfaces on /// its own. LDK re-hands claims, anchor bumps, and force-close commitments to the - /// broadcaster periodically, and the sweeper regenerates sweeps once per block. Nothing - /// re-broadcasts a funding transaction (a channel open or splice, whose classification - /// writes the payment record tracking the funding) or a cooperative close (whose channel is - /// gone from the `ChannelManager` by broadcast time), so a package containing either is - /// never dropped. + /// broadcaster periodically, and the sweeper regenerates sweeps once per block. No timer + /// re-broadcasts a funding transaction: LDK re-hands an unconfirmed funding only when its + /// channel resumes, and the wallet's tip-change re-broadcast covers recorded transactions + /// only, which a funding whose classification failed is not. Nothing re-broadcasts a + /// cooperative close, whose channel is gone from the `ChannelManager` by broadcast time. A + /// package containing either is never dropped. fn is_droppable(&self) -> bool { self.0.iter().all(|(_, tx_type)| match tx_type { Some( @@ -204,11 +207,10 @@ impl QueueState { let txids = package.txids(); if self.retries.iter().any(|(_, waiting, _)| *waiting == txids) { // Same transactions, same classification outcome: keep the waiting entry and its - // earlier deadline. The one same-txid package with a *different* type is LDK's - // re-typed generic-funding rebroadcast of a promoted 0conf splice, which always - // arrives after the interactive-funding original (the zero-conf rebroadcast canary - // tests assert that ordering), so the entry kept is the richer of the two — and its - // classification declines the downgrade anyway. + // earlier deadline. The one same-txid package LDK hands over under a different type, + // its re-typed generic-funding rebroadcast of a promoted 0conf splice, never meets + // the original here: an interactive-funding broadcast has nothing to classify, so it + // never awaits a retry. return QueueOutcome::AlreadyQueued(package); } @@ -216,10 +218,10 @@ impl QueueState { if package.is_droppable() && self.fresh.len() + self.retries.len() >= MAX_QUEUED_PACKAGES { // Drop the oldest droppable package, a waiting retry before a fresh package: its // transactions are re-broadcast periodically, while the incoming package may carry - // a fresher fee-bumped variant. A funding package is never dropped — nothing would - // re-broadcast it, and losing it leaves its transaction confirming without a - // recorded candidate. Neither is a cooperative close, whose queued package may hold - // the only copy of the signed closing transaction. + // a fresher fee-bumped variant. A funding package is never dropped — no timer would + // re-broadcast it, and it must be recorded before it is broadcast. Neither is a + // cooperative close, whose queued package may hold the only copy of the signed + // closing transaction. dropped = self.drop_oldest_droppable(); if dropped.is_none() { return QueueOutcome::Refused(package); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d485bd787d..83b4742354 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -5,7 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::future::Future; use std::ops::Deref; use std::str::FromStr; @@ -33,11 +33,13 @@ use bitcoin::{ WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::{ - FundingCandidate, TransactionType as LdkTransactionType, + ChannelFunding, FundingCandidate, FundingPurpose, TransactionType as LdkTransactionType, INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::chain::{BlockLocator, ClaimId, Listen}; +use lightning::ln::channel_state::{SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; @@ -59,7 +61,7 @@ use crate::data_store::UpdatableObject; #[cfg(test)] use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; -use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::logger::{log_debug, log_error, log_info, log_trace, log_warn, LdkLogger, Logger}; use crate::payment::pending_payment_store::PendingPaymentDetailsUpdate; use crate::payment::store::{ConfirmationStatus, PaymentDetailsUpdate}; use crate::payment::{ @@ -721,8 +723,9 @@ impl Wallet { /// Only funding-classified records are considered: nothing re-submits a replaced funding /// transaction under the same record (an RBF round is a new candidate), so a buried foreign /// conflict is final for them. The liveness check guards the case where the conflict - /// double-spent only one round of the negotiation: as long as some candidate — including one - /// classification hasn't recorded yet — can still confirm, the record must stay pending. + /// double-spent only one round of the negotiation: as long as some candidate — any recorded + /// round, or the record's own transaction should wallet sync have rotated it to an + /// unrecorded one — can still confirm, the record must stay pending. async fn fail_funding_payment_lost_to_conflict( &self, payment: &PendingPaymentDetails, tip_height: u32, ) -> Result { @@ -742,12 +745,12 @@ impl Wallet { return Ok(false); } - // Serialize with classification, whose retries extend the candidate history: the + // Serialize with the funding-record writers, which extend the candidate history: the // decision below must see that history in its settled form, and holding the lock keeps a // concurrent write from resurrecting the entry removed at the end. let _guard = self.funding_payment_update_lock.lock().await; - // Re-read the entry under the lock; the listing snapshot may predate a classification. + // Re-read the entry under the lock; the listing snapshot may predate a record write. let entry = match self.pending_payment_store.get(&payment.details.id).await? { Some(entry) => entry, None => return Ok(false), @@ -1754,9 +1757,11 @@ impl Wallet { LdkTransactionType::Funding { channels } => { self.classify_funding(tx, channels, tx_type.clone().into()).await }, - LdkTransactionType::InteractiveFunding { candidates } => { - self.classify_interactive_funding(tx, candidates, tx_type.clone().into()).await - }, + // A splice round this node contributed to is recorded when it is signed + // ([`Self::record_signed_funding`]) and marked as broadcast once LDK reports the splice + // negotiated ([`Self::record_broadcast_splice_round`]), so its broadcast has nothing + // left to record; a round without a contribution of ours is left for wallet sync. + LdkTransactionType::InteractiveFunding { .. } => Ok(()), LdkTransactionType::UnilateralClose { .. } => Ok(()), LdkTransactionType::CooperativeClose { .. } | LdkTransactionType::AnchorBump { .. } @@ -1790,10 +1795,10 @@ impl Wallet { // A funding transaction that moves no wallet funds carries nothing to record — e.g. LDK // re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding path, - // including splices the interactive-funding classification deliberately declined (no - // local contribution, or a splice-out moving no wallet funds). Recording it here would + // including splices the signing-time recording deliberately declined (no local + // contribution, or a splice-out moving no wallet funds). Recording it here would // mint a zero-amount payment that nothing ever confirms. Skip on the wallet-derived - // amount alone — the condition `classify_interactive_funding` declines on; anything + // amount alone — the condition `interactive_funding_record` declines on; anything // declined there must be skipped here, or its re-broadcast resurrects the record. The fee // is no participation signal: the wallet resolves a splice's shared input whenever the // previous funding transaction touched it (e.g. it funded the original channel open). @@ -1818,8 +1823,7 @@ impl Wallet { // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed // and carrying wallet-view figures; `funding_reclassification_update` declines the // downgrade, leaving no trace that a re-broadcast arrived. Log the arrival so tests can - // observe the traffic. The read cannot go stale: only the broadcast loop writes - // interactive-funding classifications, and it runs this classification too. + // observe the traffic; the read serves the log line alone, so a stale read costs no more. if let Some(current) = self.payment_store.get(&payment_id).await? { if matches!( current.kind, @@ -1858,26 +1862,15 @@ impl Wallet { Ok(()) } - /// Records an interactive-funding broadcast (splice, or a V2 dual-funded open) as a pending - /// on-chain payment, tagged with its transaction type. Amount and fee are this node's share, - /// derived from the active candidate's contributions; broadcasts we didn't contribute to, or - /// that don't move wallet funds, are left for wallet sync. - async fn classify_interactive_funding( - &self, tx: &Transaction, candidates: &[FundingCandidate], tx_type: TransactionType, - ) -> Result<(), Error> { - // `InteractiveFunding` carries the full negotiated history; the currently-broadcast - // candidate is the last entry, earlier entries are RBF predecessors. - let active = match candidates.last() { - Some(c) => c, - None => return Ok(()), - }; - let first = match candidates.first() { - Some(c) => c, - None => return Ok(()), - }; - - let txid = tx.compute_txid(); - debug_assert_eq!(active.txid, txid, "broadcast tx must match the active candidate"); + /// Builds the payment record and the per-candidate figures for recording the `active` round + /// of an interactive funding whose negotiated history is `candidates`. Returns `None` when + /// there is nothing to record: no local contribution to the round, or no wallet-level activity. + fn interactive_funding_record( + &self, candidates: &[FundingCandidate], active: &FundingCandidate, tx: &Transaction, + tx_type: TransactionType, + ) -> Option<(PaymentDetails, Vec)> { + let first = candidates.first()?; + let txid = active.txid; let aggregate = aggregate_local_stakes(active); let amount_msat = match aggregate.amount_msat { @@ -1885,10 +1878,10 @@ impl Wallet { None => { log_trace!( self.logger, - "Not recording interactive-funding broadcast {} as a payment: no local contribution", + "Not recording signed funding {} as a payment: no local contribution", txid, ); - return Ok(()); + return None; }, }; let fee_paid_msat = aggregate.fee_paid_msat; @@ -1902,10 +1895,10 @@ impl Wallet { if wallet_amount_msat == Some(0) { log_trace!( self.logger, - "Not recording interactive-funding broadcast {} as a payment: no wallet-level activity", + "Not recording signed funding {} as a payment: no wallet-level activity", txid, ); - return Ok(()); + return None; } // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable @@ -1924,6 +1917,7 @@ impl Wallet { txid: candidate.txid, amount_msat: aggregate.amount_msat, fee_paid_msat: aggregate.fee_paid_msat, + awaiting_broadcast: false, } }) .collect(); @@ -1940,17 +1934,418 @@ 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. The write hands back + // what it found in the payment store, read inside its own critical section. + if let Err(failure) = self.persist_funding_payment_locked(&guard, details, recorded).await { + let (e, prior_details) = match failure { + // The write pair failed before its first write, so there is nothing to put back. + FundingWriteError::Unread(e) => return Err(e), + FundingWriteError::Failed { error, prior } => (error, prior), + }; + let rollback = match &prior_details { + Some(prior) => self + .payment_store + .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 only once our + /// `tx_signatures` for the round are ready to send, so the counterparty may hold them by then + /// and may broadcast the round, which is therefore no longer dropped as abandoned. Nothing is + /// written for a round no funding payment of `channel_id` tracks (no local contribution, or no + /// wallet-level activity) or one already marked (a replayed event). + pub(crate) async fn record_broadcast_splice_round( + &self, channel_id: ChannelId, txid: Txid, + ) -> Result<(), Error> { + // 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) + ) + }; + // 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()); + + let mut mirrored = None; + let mut history_only = false; + match handed_back { + Some(active) => { + // Whether the record still waits on the dropped rounds is decided inside the + // write's critical section, from the record found there. + self.payment_store + .mutate(&payment_id, |existing| { + let current = existing?; + if !waits_on_abandoned(current) { + history_only = true; + mirrored = Some(current.clone()) + .filter(|current| current.status == PaymentStatus::Pending); + 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?; + }, + None => { + // A removal has no critical section to decide in, so the record is read first. + let record = self.payment_store.get(&payment_id).await?; + if record.as_ref().map_or(true, waits_on_abandoned) { + // Nothing of this node's was ever broadcast under the record, so it goes + // rather than fail a payment for a transaction that never existed. The + // payment record goes first: the entry keeps resolving the rounds' txids, + // so a removal that fails midway is finished by the replayed event. + self.payment_store.remove(&payment_id).await?; + self.pending_payment_store.remove(&payment_id).await?; + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} and removed funding payment {}: nothing of ours \ + was broadcast under it", + abandoned_txids, + payment_id, + ); + continue; + } + history_only = true; + mirrored = record.filter(|current| current.status == PaymentStatus::Pending); + }, + } + if history_only { + // The record does not wait on the dropped rounds: wallet sync moved it on, or an + // earlier drop was cut short between the two stores. Only its history shrinks, and + // the entry's copy of the record catches up with the record while the record is + // still pending. + log_warn!( + self.logger, + "Funding payment {} does not wait on abandoned splice round(s) {:?}: \ + dropping them from its history only", + payment_id, + abandoned_txids, + ); + } + self.pending_payment_store + .mutate(&payment_id, |existing| { + let mut entry = existing?.clone(); + entry.candidates.retain(|c| !abandoned_txids.contains(&c.txid)); + if let Some(mirrored) = mirrored { + entry.details = mirrored; + } + Some(entry) + }) + .await?; + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} from funding payment {}", + abandoned_txids, + payment_id, + ); + } + Ok(()) + } + + /// Drops the splice rounds recorded when signing that LDK does not hold once the node restarts. + /// LDK reports the loss of a negotiation its last channel manager write carried mid-way, but a + /// round committed, negotiated and signed since that write is gone without a report if the + /// node stopped before the next one. `held_rounds` yields the rounds LDK holds for a channel, + /// as [`held_splice_rounds`] lists them, or `None` for a channel LDK no longer lists, which is + /// left to its `ChannelClosed` event: LDK queues one for every channel it drops, and handling + /// it takes back what neither the closed channel's funding nor its monitor holds. Runs before + /// events are processed again, so no round is recorded while LDK's view is being read. + pub(crate) async fn drop_splice_rounds_lost_across_restart( + &self, held_rounds: impl Fn(ChannelId) -> Option>, + ) -> Result<(), Error> { + let channels: HashSet = self + .pending_payment_store + .list_filter(|entry| { + entry.candidates.iter().any(|candidate| candidate.awaiting_broadcast) + }) + .await + .iter() + .flat_map(|entry| match &entry.details.kind { + PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + } => channels.iter().map(|channel| channel.channel_id).collect(), + _ => Vec::new(), + }) + .collect(); + for channel_id in channels { + let Some(held) = held_rounds(channel_id) else { + log_debug!( + self.logger, + "Leaving the signed splice rounds of channel {} to its ChannelClosed event", + channel_id, + ); + continue; + }; + self.drop_abandoned_splice_rounds(channel_id, &held).await?; + } + Ok(()) + } + + /// Removes the half-written record of a signed round LDK has since abandoned: its write failed + /// between the two stores and the rollback failed as well, leaving the payment record without + /// the pending entry that indexes it. The replayed signing event, finding the round gone from + /// the history, ends up here; a fully recorded round (its entry in place) is left to + /// [`Self::drop_abandoned_splice_rounds`]. Only a first round is recorded under its own txid: + /// the record of a bump lives under an earlier round's id and keeps its entry, and wallet sync + /// moves it on as that earlier round confirms or fails. + async fn drop_unindexed_signing_record(&self, txid: Txid) -> Result<(), Error> { + let _guard = self.funding_payment_update_lock.lock().await; + let payment_id = PaymentId(txid.to_byte_array()); + if self.pending_payment_store.get(&payment_id).await?.is_some() { + return Ok(()); + } + let unindexed = self.payment_store.get(&payment_id).await?.is_some_and(|record| { + record.status == PaymentStatus::Pending + && matches!( + &record.kind, + PaymentKind::Onchain { + txid: recorded, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } if *recorded == txid + ) + }); + if unindexed { + self.payment_store.remove(&payment_id).await?; + log_info!( + self.logger, + "Dropped the half-written funding record of abandoned splice round {}", + txid, + ); + } + Ok(()) + } + /// Records a non-funding LDK broadcast as an on-chain payment, tagged with its transaction type. /// Wallet sync later refreshes confirmation status while preserving the type. async fn classify_regular_broadcast( @@ -1992,8 +2387,21 @@ 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 + .map(|_| ()) + .map_err(Error::from) + } + + /// [`Self::persist_funding_payment`] for a caller already holding the cross-store lock, whose + /// reads the write must not be separated from. Returns the payment store's record as it was + /// before the write, read inside the write's own critical section, so a caller needs no read of + /// its own to know what the write merged into. + async fn persist_funding_payment_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, details: PaymentDetails, + candidates: Vec, + ) -> Result, FundingWriteError> { // Everything this write does depends on the record's current state, so all of it must be // decided inside the store's critical section. When a record exists — no matter when it // appeared — only the classification (`tx_type`) and the figures of whichever candidate @@ -2005,12 +2413,13 @@ impl Wallet { // the update still names the actively-broadcast candidate, the confirmed-figures guard // then rightly refuses it, and the record is left with figures no classification derived. let id = details.id; - let mut update = None; - self.payment_store + let mut seen = None; + let written = self + .payment_store .mutate(&id, |existing| { let reclassification = funding_reclassification_update(details.clone(), &candidates, existing); - update = Some(reclassification.clone()); + seen = Some((existing.cloned(), reclassification.clone())); match existing { None => Some(details.clone()), Some(current) => { @@ -2019,8 +2428,14 @@ impl Wallet { }, } }) - .await?; - let update = update.expect("the mutate closure always runs"); + .await; + // The closure runs only once the record has been read, so a write that failed before it ran + // wrote nothing. + written.map_err(|error| match &seen { + Some((prior, _)) => FundingWriteError::Failed { error, prior: prior.clone() }, + None => FundingWriteError::Unread(error), + })?; + let (prior, update) = seen.expect("the mutate closure always runs"); // The pending index must exist exactly while the authoritative record is Pending: // graduation and rebroadcast read it, and a graduated payment must not be re-indexed. @@ -2050,10 +2465,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, @@ -2065,8 +2480,9 @@ impl Wallet { }, }) }) - .await?; - Ok(()) + .await + .map_err(|error| FundingWriteError::Failed { error, prior: prior.clone() })?; + Ok(prior) } /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. @@ -2567,6 +2983,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 what it reports for one awaiting the counterparty's signatures is queued after +/// `ChannelClosed`, but the monitor keeps watching every round the counterparty's +/// `commitment_signed` reached, and our signatures cannot have left the node before that message: +/// such a round may yet confirm and is left to wallet sync or `DiscardFunding` to resolve, while a +/// round the monitor never watched never had our signatures released. The watched transactions also +/// include the funding and whatever spent it on chain, which no recorded round is. +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 @@ -2900,6 +3393,23 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { ) } +/// How a funding-record write pair ([`Wallet::persist_funding_payment_locked`]) failed. +enum FundingWriteError { + /// The payment store could not be read, so nothing was written. + Unread(Error), + /// A write failed after the payment store's record was read; `prior` is that record, for a + /// caller to put the store back to. + Failed { error: Error, prior: Option }, +} + +impl From for Error { + fn from(failure: FundingWriteError) -> Self { + match failure { + FundingWriteError::Unread(error) | FundingWriteError::Failed { error, .. } => error, + } + } +} + /// Builds the payment-store update for a freshly classified funding payment. `details` describes /// the actively broadcast candidate, but when the record already confirmed a *different* /// candidate — wallet sync saw it win before this classification ran — the update instead carries @@ -2965,6 +3475,7 @@ mod tests { use bitcoin::hashes::Hash; use bitcoin::Network; use lightning::io; + use lightning::ln::funding::FundingContribution; use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; use super::*; @@ -2981,6 +3492,7 @@ mod tests { PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; + use crate::payment::pending_payment_store::test_funding_contribution_with_outputs; use crate::types::{DynStore, DynStoreWrapper}; use crate::{NodeMetrics, PersistedNodeMetrics}; @@ -3059,6 +3571,8 @@ mod tests { inner: Arc, fail_writes: Arc, failed_writes: Arc, + /// When set, only writes to this primary namespace fail while `fail_writes` is on. + failing_namespace: Option, } impl FailSwitchStore { @@ -3067,8 +3581,14 @@ mod tests { inner: Arc::new(InMemoryStore::new()), fail_writes: Arc::new(AtomicBool::new(false)), failed_writes: Arc::new(AtomicUsize::new(0)), + failing_namespace: None, } } + + /// Like [`Self::new`], but only writes to `primary_namespace` fail. + fn failing_only(primary_namespace: &str) -> Self { + Self { failing_namespace: Some(primary_namespace.to_string()), ..Self::new() } + } } impl KVStore for FailSwitchStore { @@ -3084,11 +3604,13 @@ mod tests { let inner = Arc::clone(&self.inner); let fail_writes = Arc::clone(&self.fail_writes); let failed_writes = Arc::clone(&self.failed_writes); + let may_fail = + self.failing_namespace.as_deref().map_or(true, |ns| ns == primary_namespace); let primary_namespace = primary_namespace.to_string(); let secondary_namespace = secondary_namespace.to_string(); let key = key.to_string(); async move { - if fail_writes.load(Ordering::Acquire) { + if may_fail && fail_writes.load(Ordering::Acquire) { failed_writes.fetch_add(1, Ordering::AcqRel); return Err(io::Error::new(io::ErrorKind::Other, "writes disabled")); } @@ -4127,6 +4649,1080 @@ 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); + } + + /// The record moved on to a bump whose signing write was cut short after the payment store, + /// so the entry still lists only the original round. Abandoning that round, with no round of + /// ours remaining, leaves the record as it stands and only shrinks the entry's history, its + /// copy of the record catching up. + #[tokio::test] + async fn dropping_the_last_round_leaves_a_record_that_moved_on() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + // The bump's signing write landed in the payment store only. + let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let mut moved_on = PaymentDetailsUpdate::new(id); + moved_on.txid = Some(bump_txid); + wallet.payment_store.update(moved_on).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(payment.clone())); + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert!(record.candidates.is_empty()); + assert_eq!(record.details, payment); + } + + /// A removal that was cut short between the two stores — the payment record went, the pending + /// entry stayed — is finished by the replayed drop: the entry alone still resolves the round's + /// txid, so it is what the replayed event finds and removes. + #[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()); + } + + /// Recording a first splice round costs one read of the payment store: the write pair reads + /// the record it merges into, and that read also serves the rollback of a failed write. + #[tokio::test] + async fn a_first_round_signing_reads_the_payment_store_once() { + let counting_store = ReadCountingStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + + let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; + assert_eq!(reads, 1, "recording a first round re-read the payment store"); + } + + /// The signing write fails between its two stores — the payment record lands, the pending + /// entry does not — so the payment store is put back as it was, and the replayed event + /// records the round in full once the store recovers instead of building on a half-written + /// record. + #[tokio::test] + async fn a_failed_first_round_signing_write_leaves_no_half_written_record() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + let id = PaymentId(txid.to_byte_array()); + + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.record_signed_funding(&tx, &candidates).await.is_err()); + assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 1); + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + + fail_store.fail_writes.store(false, Ordering::Release); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + } + + /// The same failure while signing a fee bump: the record is put back to the original round, + /// figures included, rather than left pointing at a bump the pending entry knows nothing of. + #[tokio::test] + async fn a_failed_bump_signing_write_restores_the_prior_round() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = PaymentId(txid.to_byte_array()); + let prior = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.record_signed_funding(&bump_tx, &bump_candidates).await.is_err()); + assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 1); + + assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(prior)); + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); + } + + /// The candidates handed to the signing-time recording are the channel's pending splice + /// rounds that have a transaction — negotiated predecessors and the round awaiting + /// signatures, in LDK's order, each with this node's contribution to it. A contribution + /// still queued behind the pending rounds has no transaction and is left out. + #[test] + fn funding_candidates_list_the_rounds_with_a_transaction() { + use lightning::chain::chaininterface::FundingPurpose; + use lightning::ln::channel_state::{ + SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails, + }; + + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let prior_txid = Txid::from_byte_array([9u8; 32]); + let signing_txid = Txid::from_byte_array([10u8; 32]); + let contribution = test_funding_contribution_with_outputs(0, 253, &[]); + let details = SpliceDetails { + candidates: vec![ + SpliceCandidateDetails { + contribution: None, + status: SpliceCandidateStatus::Negotiated { + txid: prior_txid, + new_channel_value_satoshis: 100_000, + }, + }, + SpliceCandidateDetails { + contribution: Some(contribution.clone()), + status: SpliceCandidateStatus::AwaitingSignatures { + is_initiator: true, + funding_feerate_sat_per_1000_weight: 253, + new_channel_value_satoshis: 110_000, + txid: signing_txid, + }, + }, + SpliceCandidateDetails { + contribution: Some(test_funding_contribution_with_outputs(0, 500, &[])), + status: SpliceCandidateStatus::WaitingOnLock, + }, + ], + confirmed_candidate: None, + received_splice_locked_txid: None, + }; + + let candidates = funding_candidates(Some(&details), counterparty_node_id, channel_id); + + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].txid, prior_txid); + assert_eq!(candidates[0].channels.len(), 1); + assert_eq!(candidates[0].channels[0].contribution, None); + assert_eq!(candidates[1].txid, signing_txid); + assert_eq!(candidates[1].channels.len(), 1); + assert_eq!(candidates[1].channels[0].counterparty_node_id, counterparty_node_id); + assert_eq!(candidates[1].channels[0].channel_id, channel_id); + assert_eq!(candidates[1].channels[0].purpose, FundingPurpose::Splice); + assert_eq!(candidates[1].channels[0].contribution, Some(contribution)); + + assert!(funding_candidates(None, counterparty_node_id, channel_id).is_empty()); + } + #[test] fn funding_reclassification_update_substitutes_the_confirmed_candidate() { let confirmed_txid = Txid::from_byte_array([1u8; 32]); @@ -4136,11 +5732,13 @@ mod tests { txid: confirmed_txid, amount_msat: Some(2_000_000), fee_paid_msat: Some(999), + awaiting_broadcast: false, }, FundingTxCandidate { txid: active_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }, ]; let details = onchain_details(active_txid, ConfirmationStatus::Unconfirmed); @@ -4159,6 +5757,7 @@ mod tests { txid: confirmed_txid, amount_msat: None, fee_paid_msat: None, + awaiting_broadcast: false, }]; let update = funding_reclassification_update(details.clone(), &uncontributed, Some(¤t)); @@ -4174,6 +5773,7 @@ mod tests { txid: active_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let details = onchain_details(active_txid, ConfirmationStatus::Unconfirmed); @@ -4351,16 +5951,19 @@ mod tests { txid: txid1, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }, FundingTxCandidate { txid: txid2, amount_msat: Some(1_000_000), fee_paid_msat: Some(600), + awaiting_broadcast: false, }, FundingTxCandidate { txid: txid3, amount_msat: Some(1_000_000), fee_paid_msat: Some(700), + awaiting_broadcast: false, }, ]; let details = interactive_funding_details(payment_id, txid3, Some(1_000_000), Some(700)); @@ -4490,6 +6093,7 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); @@ -4560,6 +6164,7 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); @@ -4623,11 +6228,13 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }, FundingTxCandidate { txid: bumped_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(600), + awaiting_broadcast: false, }, ]; let details = @@ -4679,6 +6286,7 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); @@ -4730,11 +6338,13 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }, FundingTxCandidate { txid: live_candidate_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(600), + awaiting_broadcast: false, }, ]; let details = @@ -4796,6 +6406,7 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); @@ -4846,6 +6457,7 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); @@ -5001,6 +6613,7 @@ mod tests { txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let mut details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); @@ -5034,7 +6647,7 @@ mod tests { /// A funding-typed broadcast that doesn't touch the on-chain wallet must not be recorded. /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding - /// path, so a splice the interactive-funding classification deliberately declined — no local + /// path, so a splice the signing-time recording deliberately declined — no local /// contribution, or none of the moved funds are the wallet's — would otherwise come back as /// a spurious zero-amount record that nothing ever confirms. #[tokio::test] @@ -5130,6 +6743,7 @@ mod tests { txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }]; let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); wallet.persist_funding_payment(details, candidates).await.unwrap(); @@ -5174,11 +6788,11 @@ mod tests { assert_unchanged(&wallet, payment_id, true).await; } - /// A funding broadcast whose classification fails must be retried, not dropped: for - /// interactive funding the counterparty broadcasts the same transaction regardless of - /// whether we do, so dropping the package permanently leaves the confirming transaction - /// unrecorded as a candidate — and the funding-status ownership gate then routes its - /// confirmation to a stray duplicate record instead of the funding record. + /// A funding broadcast whose classification fails must be retried, not dropped: no timer + /// re-broadcasts a funding transaction, so a dropped package would keep the funding off-chain + /// until LDK re-hands it when the channel next resumes. The record is written before the + /// broadcast so that the confirmation refreshes it rather than minting an untyped record that + /// the retried classification types only once it lands. #[tokio::test] async fn failed_funding_classification_is_retried_not_dropped() { use lightning::chain::chaininterface::BroadcasterInterface; @@ -5567,11 +7181,13 @@ mod tests { txid: txid1, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), + awaiting_broadcast: false, }, FundingTxCandidate { txid: txid2, amount_msat: Some(2_000_000), fee_paid_msat: Some(999), + awaiting_broadcast: false, }, ]; let details = interactive_funding_details(payment_id, txid2, Some(2_000_000), Some(999)); @@ -5657,6 +7273,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 3da60800c6..e4895dafdd 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -45,7 +45,7 @@ use ldk_node::payment::{ ConfirmationStatus, ForwardedPaymentId, PayerProofOptions, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType}; +use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType, UserChannelId}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -55,12 +55,13 @@ use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; use serde_json::json; -/// Waits until `node` has classified the funding broadcast `funding_txid` (a channel open or splice -/// candidate) into a payment record carrying a `tx_type`. Classification runs off the broadcaster's -/// queue, which can lag a `sync_wallets` call under load — and for a splice the counterparty also -/// broadcasts the same tx, so a racing sync can see it before this node classifies. Waiting here -/// keeps the next sync on the funding short-circuit instead of recording a generic on-chain payment -/// that clobbers the classification. +/// Waits until `node` has recorded the funding broadcast `funding_txid` (a channel open or splice +/// candidate) as a payment carrying a `tx_type`. A splice contributor records the payment when it +/// signs the funding transaction, before the transaction can even be broadcast, so for splices +/// this settles immediately and only stabilizes assertion timing. A channel open is classified off +/// the broadcaster's queue, which can lag a `sync_wallets` call under load; waiting keeps the next +/// sync on the funding short-circuit instead of recording a generic on-chain payment that clobbers +/// the classification. async fn wait_for_classified_funding_payment(node: &Node, funding_txid: Txid) { let poll = async { loop { @@ -89,6 +90,8 @@ struct ContendedStore { serializer: Arc>, block_writes: Arc, wallet_write_started: Arc, + /// When set, only writes to this primary namespace go through `serializer`; the rest bypass it. + serialized_namespace: Option, } impl KVStore for ContendedStore { @@ -105,6 +108,8 @@ impl KVStore for ContendedStore { let serializer = Arc::clone(&self.serializer); let block_writes = Arc::clone(&self.block_writes); let wallet_write_started = Arc::clone(&self.wallet_write_started); + let serialized = + self.serialized_namespace.as_deref().map_or(true, |ns| ns == primary_namespace); let primary_namespace = primary_namespace.to_string(); let secondary_namespace = secondary_namespace.to_string(); let key = key.to_string(); @@ -112,7 +117,7 @@ impl KVStore for ContendedStore { if block_writes.load(Ordering::Acquire) { wallet_write_started.notify_one(); } - let _guard = serializer.read().await; + let _guard = if serialized { Some(serializer.read().await) } else { None }; KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await } } @@ -162,6 +167,7 @@ fn wallet_store_contention_does_not_stall_runtime() { serializer: Arc::new(tokio::sync::RwLock::new(())), block_writes: Arc::new(AtomicBool::new(false)), wallet_write_started: Arc::new(tokio::sync::Notify::new()), + serialized_namespace: None, }; let node = builder .build_with_store(test_config.node_entropy.into(), store.clone()) @@ -2282,8 +2288,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; @@ -2342,8 +2346,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; @@ -2528,6 +2530,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()); @@ -2651,8 +2659,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(); @@ -2688,8 +2694,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(); @@ -2889,8 +2893,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 @@ -2981,6 +2985,247 @@ 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"; +/// Logged by a node as it returns the addresses of a contribution LDK discarded to the wallet. +const RECLAIMED_ADDRESSES: &str = "Reclaiming unused addresses from channel"; + +/// A splice round this node signed stays recorded when the channel closes before the +/// counterparty's `tx_signatures` arrive, if the channel's monitor watches the round. The monitor +/// 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. +/// +/// LDK reports the round itself after `ChannelClosed`: a `DiscardFunding` for node A's +/// contribution, whose handling reclaims its addresses, and a `SpliceNegotiationFailed` the node +/// passes on. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { + 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 reclaimed_a = logs_a.count(RECLAIMED_ADDRESSES); + + let hold_b = Arc::clone(&store_b.serializer).write_owned().await; + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000).unwrap(); + 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); + expect_event!(node_a, SpliceNegotiationFailed); + assert!( + logs_a.wait_for_count(RECLAIMED_ADDRESSES, reclaimed_a + 1).await, + "node A's contribution to the discarded round was not reclaimed" + ); + + assert!( + node_a + .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 c63c1d6d95049458baf6acfbdfcb02a320d78018 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 7 Sep 2026 13:11:34 -0500 Subject: [PATCH 06/49] DROP ME: 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 (cherry picked from commit b69ea698e9b80bba33a5b794ff9f4785e8c4cc2c) --- src/event.rs | 110 ++- src/payment/pending_payment_store.rs | 94 ++- src/wallet/mod.rs | 993 +++++++++++++++++++++++++-- tests/common/logging.rs | 5 + tests/integration_tests_rust.rs | 739 +++++++++++++++++++- 5 files changed, 1873 insertions(+), 68 deletions(-) diff --git a/src/event.rs b/src/event.rs index c68b6d94ba..29b122b113 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1938,6 +1938,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) @@ -1972,10 +2005,15 @@ where // still awaiting the counterparty's signatures when the channel closes is queued // after this event, so its record is taken back here. The channel manager holds // only the closed channel's last funding, but the channel's monitor still watches - // every round the counterparty committed to, and our signatures may have left the - // node for such a round, so it is kept (see `closed_channel_held_rounds`). The - // monitor's guard is not `Send`, so its watched transactions are collected before - // anything is awaited. + // every pending round the counterparty committed to and the background processor + // has flushed to it, and our signatures may have left the node for such a round, so + // it is kept (see `closed_channel_held_rounds`). A payment left with no round of + // ours the monitor watches, and none LDK promoted to the funding before, is failed: + // the monitor's `DiscardFunding` events settle such payments once the close + // matures, but reach the handler ahead of this event when one sync delivers the + // close and its maturity, and then find the channel still listed with every round + // held. The monitor's guard is not `Send`, so its watched transactions are + // collected before anything is awaited. let watched_txids: Vec = self .chain_monitor .get_monitor(channel_id) @@ -1985,12 +2023,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, ); @@ -2054,6 +2091,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 9a1d6da116..11f09d53fe 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -55,13 +55,19 @@ pub struct PendingPaymentDetails { /// RBF history, keyed by each candidate's txid. Empty for non-funding payments and for /// records written before per-candidate tracking existed. pub(crate) candidates: Vec, + /// The candidates LDK promoted to the channel's funding, as `ChannelReady` reported them. A + /// zero-conf splice locks before its transaction confirms, and every later splice builds on + /// it, so such a round can still confirm once the channel's funding has moved on from it and + /// once the channel has closed, when LDK holds it no longer. Kept apart from the candidates, + /// which each funding-record write replaces as a whole. + pub(crate) locked_rounds: Vec, } impl PendingPaymentDetails { pub(crate) fn new( details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, ) -> Self { - Self { details, conflicting_txids, candidates } + Self { details, conflicting_txids, candidates, locked_rounds: Vec::new() } } /// Returns this node's recorded funding figures for the candidate with the given txid, if any. @@ -74,6 +80,7 @@ impl_writeable_tlv_based!(PendingPaymentDetails, { (0, details, required), (2, conflicting_txids, optional_vec), (4, candidates, optional_vec), + (6, locked_rounds, optional_vec), }); #[derive(Clone, Debug, PartialEq, Eq)] @@ -165,25 +172,65 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { pub(crate) fn test_funding_contribution_with_outputs( estimated_fee_sat: u64, feerate: u64, outputs: &[bitcoin::TxOut], ) -> lightning::ln::funding::FundingContribution { - use lightning::util::ser::Writeable; + test_funding_contribution_with_parts(estimated_fee_sat, feerate, &[], outputs, None) +} + +/// Builds a [`FundingContribution`] for tests from its parts: the given estimated fee, an input +/// spending output 0 — which must be P2WPKH — of each given previous transaction, the given +/// contributed outputs and change output, and the given input-selection feerate (also used as +/// the maximum), with the is-splice flag set. +/// +/// [`FundingContribution`]: lightning::ln::funding::FundingContribution +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_parts( + estimated_fee_sat: u64, feerate: u64, prevtxs: &[bitcoin::Transaction], + outputs: &[bitcoin::TxOut], change_output: Option<&bitcoin::TxOut>, +) -> lightning::ln::funding::FundingContribution { + use lightning::util::ser::{BigSize, Writeable}; + use lightning::util::wallet_utils::ConfirmedUtxo; let mut records = vec![1, 8]; // (1, estimated_fee) records.extend_from_slice(&estimated_fee_sat.to_be_bytes()); + if !prevtxs.is_empty() { + let mut input_bytes = Vec::new(); + for prevtx in prevtxs { + ConfirmedUtxo::new_p2wpkh(prevtx.clone(), 0) + .expect("test prevtx output 0 must be P2WPKH") + .write(&mut input_bytes) + .expect("in-memory write must succeed"); + } + records.push(3); // (3, inputs) + BigSize(input_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); + records.extend_from_slice(&input_bytes); + } if !outputs.is_empty() { let mut output_bytes = Vec::new(); for output in outputs { output.write(&mut output_bytes).expect("in-memory write must succeed"); } records.push(5); // (5, outputs) - records.push(u8::try_from(output_bytes.len()).expect("test outputs must stay small")); + BigSize(output_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); records.extend_from_slice(&output_bytes); } + if let Some(change_output) = change_output { + let change_bytes = change_output.encode(); + records.push(7); // (7, change_output) + BigSize(change_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); + records.extend_from_slice(&change_bytes); + } records.extend_from_slice(&[9, 8]); // (9, feerate) records.extend_from_slice(&feerate.to_be_bytes()); records.extend_from_slice(&[11, 8]); // (11, max_feerate) records.extend_from_slice(&feerate.to_be_bytes()); records.extend_from_slice(&[13, 1, 1]); // (13, is_splice: true) - // BigSize length prefix over the TLV records above; single-byte as long as they stay short. - let mut tlv_bytes = vec![u8::try_from(records.len()).expect("test TLV stream must stay small")]; + let mut tlv_bytes = Vec::new(); + // BigSize length prefix over the TLV records above. + BigSize(records.len() as u64).write(&mut tlv_bytes).expect("in-memory write must succeed"); tlv_bytes.extend(records); lightning::util::ser::Readable::read(&mut &tlv_bytes[..]) .expect("hand-built TLV stream must decode") @@ -192,6 +239,7 @@ pub(crate) fn test_funding_contribution_with_outputs( #[cfg(test)] mod tests { use bitcoin::hashes::Hash; + use lightning::util::ser::{Readable, Writeable}; use super::*; use crate::payment::store::ConfirmationStatus; @@ -372,4 +420,40 @@ mod tests { assert_eq!(merged.details.amount_msat, Some(1_000)); assert_eq!(merged.details.fee_paid_msat, Some(100)); } + + /// A candidate with the given txid byte, with a stake of ours in it if `ours`. + fn candidate(txid_byte: u8, ours: bool) -> FundingTxCandidate { + FundingTxCandidate { + txid: test_txid(txid_byte), + amount_msat: ours.then_some(1_000), + fee_paid_msat: ours.then_some(100), + awaiting_broadcast: false, + } + } + + fn entry(candidates: Vec) -> PendingPaymentDetails { + let payment_id = PaymentId([1u8; 32]); + let txid = candidates.last().expect("at least one candidate").txid; + PendingPaymentDetails::new(pending_onchain_payment(payment_id, txid), vec![], candidates) + } + + /// The rounds LDK promoted round-trip with the entry, absent or present, and the merge of a + /// record's full update, as wallet sync writes it, leaves them. + #[test] + fn locked_rounds_round_trip_and_survive_a_merge() { + let mut stored = entry(vec![candidate(2, false)]); + let decoded: PendingPaymentDetails = + Readable::read(&mut &stored.encode()[..]).expect("encoding must round-trip"); + assert_eq!(decoded.locked_rounds, Vec::::new()); + + stored.locked_rounds.push(test_txid(2)); + let decoded: PendingPaymentDetails = + Readable::read(&mut &stored.encode()[..]).expect("encoding must round-trip"); + assert_eq!(decoded, stored); + + let synced = entry(vec![candidate(2, false), candidate(3, false)]); + assert!(stored.update(synced.to_update())); + assert_eq!(stored.candidates.len(), 2); + assert_eq!(stored.locked_rounds, vec![test_txid(2)]); + } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 83b4742354..a72af2885f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -798,11 +798,37 @@ impl Wallet { return Ok(false); } - // As with graduation, decide from the live record and write only the status. A record - // already `Failed` — a prior pass whose entry removal below was lost to a crash — still - // matches, no-ops the update, and gets its lingering entry removed. let payment_id = entry.details.id; - let mut failed = false; + let outcome = + self.fail_unconfirmed_funding_payment_locked(&_guard, payment_id, record_txid).await?; + match outcome { + FundingPaymentFailure::Failed => log_info!( + self.logger, + "Failed funding payment {}: transaction {} lost to a conflicting transaction confirmed beyond the reorg depth", + payment_id, + record_txid, + ), + FundingPaymentFailure::EntryRemoved => log_info!( + self.logger, + "Removed the lingering entry of failed funding payment {}: transaction {} lost to \ + a conflicting transaction confirmed beyond the reorg depth", + payment_id, + record_txid, + ), + FundingPaymentFailure::MovedOn => {}, + } + Ok(outcome != FundingPaymentFailure::MovedOn) + } + + /// Fails the funding payment `payment_id` while its record still waits on the unconfirmed + /// funding transaction `record_txid`, and removes its pending entry, reporting what it did. As + /// with graduation, the decision is made from the live record and only the status is written. + /// A record already `Failed` — a prior pass whose entry removal was lost to a crash — still + /// matches, no-ops the update, and gets its lingering entry removed. + async fn fail_unconfirmed_funding_payment_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, record_txid: Txid, + ) -> Result { + let mut outcome = FundingPaymentFailure::MovedOn; self.payment_store .mutate(&payment_id, |existing| { let current = existing?; @@ -816,26 +842,266 @@ 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 only when no candidate records it, as for a record + /// from before candidates were tracked: a recorded candidate counts by its stake alone, + /// whichever round the record names. A payment that moved on — its round confirmed, or it was + /// failed already — is not touched beyond the entry a failure cut short left behind. + /// `resolution` names the occasion in what is logged. + async fn fail_funding_payments_without_held_round_locked( + &self, guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, + held_rounds: &[Txid], resolution: FundingResolution, + ) -> 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; + }, + }; + // Wallet sync moves the record onto whichever of its candidates it sees, ours or not, + // so a recorded candidate counts by its stake alone; the record's transaction counts + // only where no candidate records it. + let recorded_round = entry.candidate(record_txid).is_none().then_some(record_txid); + let mut rounds_of_ours = entry + .candidates + .iter() + .filter(|candidate| candidate.amount_msat.is_some()) + .map(|candidate| candidate.txid) + .chain(recorded_round); + if let Some(kept) = rounds_of_ours + .find(|txid| held_rounds.contains(txid) || entry.locked_rounds.contains(txid)) + { + log_info!( + self.logger, + "Splice round {} of ours can still confirm: keeping funding payment {} {}", + kept, + payment_id, + occasion, + ); + continue; + } + match self + .fail_unconfirmed_funding_payment_locked(guard, payment_id, record_txid) + .await? + { + FundingPaymentFailure::Failed => log_info!( + self.logger, + "Failed funding payment {} {}: no round of ours can confirm", + payment_id, + occasion, + ), + FundingPaymentFailure::EntryRemoved => log_info!( + self.logger, + "Removed the lingering entry of failed funding payment {} {}", + payment_id, + occasion, + ), + FundingPaymentFailure::MovedOn => log_warn!( + self.logger, + "Funding payment {} {} moved on from transaction {}: leaving it as it is", + payment_id, + occasion, + record_txid, + ), + } + } + Ok(()) + } + + /// Resolves what LDK's promotion of the splice round `promoted` to the funding of `channel_id`, + /// as its `ChannelReady` reports, means for the channel's funding payments. `held_rounds` lists + /// the rounds LDK holds for the channel once promoted, as [`held_splice_rounds`] does — the + /// promoted round alone, unless a contribution queued behind it was negotiated already — or is + /// `None` for a channel the manager no longer lists, whose close settles its payments. + /// + /// The promotion is recorded first, in the funding payment whose record holds the round. A + /// zero-conf splice is promoted as soon as `splice_locked` is exchanged, before its transaction + /// confirms, and every later splice builds on it, so the round can still confirm once the + /// channel's funding has moved on from it and once the channel has closed — when neither the + /// channel manager nor the monitor holds it anymore — and its payment is kept then. Nothing is + /// recorded for a round no funding payment holds — this node did not contribute to it, or its + /// record graduated already — or recorded as promoted already (a replayed event). + /// + /// LDK discards the round's siblings as it promotes the round, queuing a `DiscardFunding` for + /// each contribution of ours it returns — one naming the contribution, not the round — so the + /// channel's other payments are resolved here, from the rounds LDK holds: a round nothing ever + /// broadcast is dropped from its record, as [`Self::drop_abandoned_splice_rounds`] does, and + /// every payment left waiting on an unconfirmed round with no round of ours among `held_rounds` + /// and none promoted before is failed: no round of ours can confirm anymore, a round this node + /// did not contribute to having locked. A replayed event finds the promoted round recorded and + /// keeps its payment whatever LDK holds by then. + pub(crate) async fn resolve_promoted_splice_round( + &self, channel_id: ChannelId, promoted: Txid, held_rounds: Option<&[Txid]>, + ) -> Result<(), Error> { + // Serialize with the other funding-record writers, which all hold this lock from their + // reads through their last write. + let guard = self.funding_payment_update_lock.lock().await; + self.record_locked_splice_round_locked(&guard, channel_id, promoted).await?; + let held_rounds = match held_rounds { + Some(held_rounds) => held_rounds, + None => { + log_debug!( + self.logger, + "Channel {} is no longer listed as splice round {} locks: leaving its funding \ + payments to its close", + channel_id, + promoted, + ); + return Ok(()); + }, + }; + // The drop goes first: a round nothing broadcast is taken back rather than failed, and + // the payment recorded for it alone goes with it. + self.drop_abandoned_splice_rounds_locked(&guard, channel_id, held_rounds).await?; + self.fail_funding_payments_without_held_round_locked( + &guard, + channel_id, + held_rounds, + FundingResolution::Promotion(promoted), + ) + .await?; + log_debug!( + self.logger, + "Resolved the funding payments of channel {} as splice round {} locked, by the {} \ + round(s) LDK holds", + channel_id, + promoted, + held_rounds.len(), + ); + Ok(()) + } + + /// Records that LDK promoted the splice round `txid` to the funding of `channel_id` in the + /// funding payment whose record holds the round, for a caller holding the funding-record + /// writers' lock (see [`Self::resolve_promoted_splice_round`]). + async fn record_locked_splice_round_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, txid: Txid, + ) -> Result<(), Error> { + let entries = self + .pending_payment_store + .list_filter(|entry| { + tracks_channel(entry, channel_id) + && entry.candidate(txid).is_some() + && !entry.locked_rounds.contains(&txid) + }) + .await; + for entry in entries { + let payment_id = entry.details.id; + self.pending_payment_store + .mutate(&payment_id, |existing| { + let mut entry = existing?.clone(); + if entry.locked_rounds.contains(&txid) { + return None; + } + entry.locked_rounds.push(txid); + Some(entry) + }) + .await?; log_info!( self.logger, - "Failed funding payment {}: transaction {} lost to a conflicting transaction confirmed beyond the reorg depth", + "Splice round {} of funding payment {} locked as the funding of channel {}", + txid, payment_id, - record_txid, + channel_id, ); } - Ok(failed) + Ok(()) } #[allow(deprecated)] @@ -2073,14 +2339,7 @@ impl Wallet { let entries = self .pending_payment_store .list_filter(|entry| { - let tracks_channel = match &entry.details.kind { - PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { channels }), - .. - } => channels.iter().any(|channel| channel.channel_id == channel_id), - _ => false, - }; - tracks_channel + tracks_channel(entry, channel_id) && entry.candidate(txid).is_some_and(|candidate| candidate.awaiting_broadcast) }) .await; @@ -2125,30 +2384,33 @@ impl Wallet { /// event has cleared the mark, whether wallet sync has seen it yet or not; one whose event is /// still unhandled when the channel closes is listed in `held_rounds` because the channel's /// monitor, which saw the counterparty commit to it, still watches it, and so keeps its place - /// as well. Dropping the record's current round hands the record back to the last remaining - /// round this node contributed to, figures included; dropping the last such round removes the - /// record, as whatever rounds remain are not this node's payment (LDK keeps this node's - /// contributions to a suffix of the rounds). A record that no longer waits on the dropped round - /// — wallet sync moved it on, or an earlier drop was cut short after moving it — keeps its - /// state and only loses the round from its history. + /// as well, as does a round LDK promoted to the channel's funding (recorded by + /// [`Self::resolve_promoted_splice_round`]), broadcast with its signatures exchanged whether + /// or not its `SpliceNegotiated` event has cleared the mark yet. Dropping the record's current + /// round hands the record back to the last remaining round this node contributed to, figures + /// included; dropping the last such round removes the record, as whatever rounds remain are not + /// this node's payment (LDK keeps this node's contributions to a suffix of the rounds). A record + /// that no longer waits on the dropped round — wallet sync moved it on, or an earlier drop was + /// cut short after moving it — keeps its state and only loses the round from its history. pub(crate) async fn drop_abandoned_splice_rounds( &self, channel_id: ChannelId, held_rounds: &[Txid], ) -> Result<(), Error> { // Serialize with the other funding-record writers, which all hold this lock from their // reads through their last write. - let _guard = self.funding_payment_update_lock.lock().await; + let guard = self.funding_payment_update_lock.lock().await; + self.drop_abandoned_splice_rounds_locked(&guard, channel_id, held_rounds).await + } + /// [`Self::drop_abandoned_splice_rounds`] for a caller already holding the funding-record + /// writers' lock. + async fn drop_abandoned_splice_rounds_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, + held_rounds: &[Txid], + ) -> Result<(), Error> { let entries = self .pending_payment_store .list_filter(|entry| { - let tracks_channel = match &entry.details.kind { - PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { channels }), - .. - } => channels.iter().any(|channel| channel.channel_id == channel_id), - _ => false, - }; - tracks_channel + tracks_channel(entry, channel_id) && entry.candidates.iter().any(|candidate| candidate.awaiting_broadcast) }) .await; @@ -2166,6 +2428,7 @@ impl Wallet { entry.candidates.iter().cloned().partition(|candidate| { candidate.awaiting_broadcast && !held_rounds.contains(&candidate.txid) + && !entry.locked_rounds.contains(&candidate.txid) && locked_wallet.tx_graph().get_tx(candidate.txid).is_none() }) }; @@ -2983,6 +3246,17 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { } } +/// Whether `entry` is the funding payment of a splice into `channel_id`. +fn tracks_channel(entry: &PendingPaymentDetails, channel_id: ChannelId) -> bool { + match &entry.details.kind { + PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + } => channels.iter().any(|channel| channel.channel_id == channel_id), + _ => false, + } +} + /// Lists a channel's pending splice rounds that have a transaction — the negotiated predecessors /// and the round awaiting signatures, in LDK's order, each with this node's contribution to it — /// as the [`FundingCandidate`]s LDK hands the broadcaster for the round, for recording the round @@ -3038,16 +3312,26 @@ pub(crate) fn held_splice_rounds( held } -/// The splice rounds a closed channel may still see confirm, as +/// The splice rounds LDK still holds for a closed channel, as /// [`Wallet::drop_abandoned_splice_rounds`] takes them: the channel's last funding — which a /// zero-conf splice may have become before its transaction confirmed — and every transaction the /// channel's monitor still watches. The channel manager forgets a pending round with the channel, /// and what it reports for one awaiting the counterparty's signatures is queued after -/// `ChannelClosed`, but the monitor keeps watching every round the counterparty's -/// `commitment_signed` reached, and our signatures cannot have left the node before that message: -/// such a round may yet confirm and is left to wallet sync or `DiscardFunding` to resolve, while a -/// round the monitor never watched never had our signatures released. The watched transactions also -/// include the funding and whatever spent it on chain, which no recorded round is. +/// `ChannelClosed`, but the monitor keeps watching every pending round the counterparty's +/// `commitment_signed` reached and the background processor has flushed to it — the monitor's +/// updates land after the manager's, deferred to that flush — until a sibling locks or the close +/// matures, and our signatures cannot have left the node before that update was persisted: such a +/// round may yet confirm and is left to wallet sync or `DiscardFunding` to resolve, while a round +/// the monitor never watched never had our signatures released. A round whose `commitment_signed` +/// the manager processed since the last flush therefore still looks unwatched here, and is dropped +/// from its record as one nothing broadcast. That is the right outcome for the record: our +/// `tx_signatures` for a splice round are released only once the monitor update its +/// `commitment_signed` produced has been persisted, whichever side sends first, so the counterparty +/// holds nothing it could broadcast. The watched transactions also include the funding and whatever +/// spent it on chain, which no recorded round is. A funding the channel moved on from before it +/// confirmed — a zero-conf splice a later splice built on — is held by neither and can confirm +/// still; the funding payments keep such rounds themselves (see +/// [`Wallet::resolve_promoted_splice_round`]). pub(crate) fn closed_channel_held_rounds( funding_txo: Option, watched_txids: impl IntoIterator, ) -> Vec { @@ -3060,6 +3344,28 @@ pub(crate) fn closed_channel_held_rounds( held } +/// The occasion on which [`Wallet::fail_funding_payments_without_held_round_locked`] resolves a +/// channel's funding payments by the rounds LDK holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FundingResolution { + /// The channel closed. + Close, + /// LDK promoted the given splice round to the channel's funding. + Promotion(Txid), +} + +/// The outcome of [`Wallet::fail_unconfirmed_funding_payment_locked`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FundingPaymentFailure { + /// The payment was failed and its pending entry removed. + Failed, + /// The payment was failed already — by a pass whose entry removal was lost to a crash — and + /// only the lingering entry was removed. + EntryRemoved, + /// The record no longer waits on the transaction; nothing was touched. + MovedOn, +} + /// The outcome of [`Wallet::apply_funding_status_update_locked`]. enum FundingStatusUpdate { /// The event's transaction belongs to the funding payment; its refreshed confirmation status @@ -3492,7 +3798,9 @@ mod tests { PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; - use crate::payment::pending_payment_store::test_funding_contribution_with_outputs; + use crate::payment::pending_payment_store::{ + test_funding_contribution_with_outputs, test_funding_contribution_with_parts, + }; use crate::types::{DynStore, DynStoreWrapper}; use crate::{NodeMetrics, PersistedNodeMetrics}; @@ -7388,4 +7696,609 @@ mod tests { ); assert_ne!(locked_wallet.next_unused_address(KeychainKind::Internal).index, 0); } + + /// A previous transaction with a P2WPKH output at index 0 for a contribution input to spend; + /// `seed` varies the output script, and with it the txid. + fn test_prevtx(seed: u8) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + } + } + + /// Records `rounds` as their signing did — the last round signed, the others negotiated + /// before — then marks the signed round as broadcast, as its `SpliceNegotiated` event would. + /// Returns the record's id. + async fn record_broadcast_rounds( + wallet: &Wallet, tx: &Transaction, rounds: &[(Txid, Option)], + ) -> PaymentId { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let candidates = splice_candidates(counterparty_node_id, channel_id, rounds); + wallet.record_signed_funding(tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, tx.compute_txid()).await.unwrap(); + PaymentId(rounds[0].0.to_byte_array()) + } + + /// The close finds no round of ours held — the channel closed on a commitment transaction and + /// the monitor watches the round no longer — so the only round's payment is failed and its + /// entry removed. The record keeps describing the round. + #[tokio::test] + async fn closing_without_a_round_of_ours_held_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let id = record_broadcast_rounds(&wallet, &tx, &[(txid, Some(contribution))]).await; + + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: recorded, status: ConfirmationStatus::Unconfirmed, .. } + if recorded == txid + )); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// LDK promoted a round of ours and discarded the counterparty's round it replaced with the + /// promotion, so the payment stays as it is, the promotion recorded and the discarded round + /// still in its history. + #[tokio::test] + async fn promoting_a_round_of_ours_keeps_its_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + + wallet.resolve_promoted_splice_round(channel_id, txid, Some(&[txid])).await.unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + assert_eq!(entry.candidates.len(), 2); + assert_eq!(entry.locked_rounds, vec![txid]); + } + + /// LDK promoted a sibling this node did not contribute to — the counterparty's round locked on + /// a channel that stays open, and the channel manager holds it as the funding and no pending + /// round by the time the event is handled — so no round of ours can confirm anymore and the + /// payment is failed, although the channel holds a round of the splice. The channel's monitor, + /// updated only later, may still watch our round; it is not consulted. The record keeps + /// describing our round. + #[tokio::test] + async fn promoting_a_round_not_ours_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: recorded, status: ConfirmationStatus::Unconfirmed, .. } + if recorded == txid + )); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// Wallet sync moved the record onto the counterparty's round before LDK promoted it, so the + /// promoted round is the record's own transaction. It is recorded without a stake all the + /// same, so it is no round of ours, and the payment is failed as it is when the record still + /// names our round. + #[tokio::test] + async fn promoting_a_round_not_ours_the_record_adopted_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let rounds = [(counterparty_txid, None), (tx.compute_txid(), Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + let event = WalletEvent::TxUnconfirmed { + txid: counterparty_txid, + tx: Arc::new(dummy_tx()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().unwrap(); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) + ); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } + if txid == counterparty_txid + )); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// The same at a close whose monitor holds the counterparty's round the record moved onto: + /// the record naming a round recorded without a stake does not make it a round of ours. + #[tokio::test] + async fn closing_with_a_held_round_not_ours_the_record_adopted_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let rounds = [(counterparty_txid, None), (tx.compute_txid(), Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + let event = WalletEvent::TxUnconfirmed { + txid: counterparty_txid, + tx: Arc::new(dummy_tx()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().unwrap(); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) + ); + + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) + .await + .unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } + if txid == counterparty_txid + )); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// A round of ours nothing had broadcast when the counterparty's round locked — our + /// signatures were never exchanged — is dropped with the promotion, and its record with it, + /// rather than failed: no transaction of ours ever existed to fail a payment for. + #[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 e4895dafdd..e97a118a46 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -15,9 +15,10 @@ use std::sync::{mpsc, Arc}; use std::time::Duration; use bitcoin::address::NetworkUnchecked; +use bitcoin::hashes::hex::FromHex; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; -use bitcoin::{Address, Amount, ScriptBuf, Txid}; +use bitcoin::{Address, Amount, ScriptBuf, Transaction, Txid}; use common::logging::{ init_log_logger, validate_log_entry, CollectingLogWriter, MultiNodeLogger, TestLogWriter, }; @@ -46,7 +47,8 @@ use ldk_node::payment::{ PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType, UserChannelId}; -use lightning::ln::channelmanager::PaymentId; +use lightning::chain::channelmonitor::ANTI_REORG_DELAY; +use lightning::ln::channelmanager::{PaymentId, BREAKDOWN_TIMEOUT}; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; @@ -90,8 +92,26 @@ struct ContendedStore { serializer: Arc>, block_writes: Arc, wallet_write_started: Arc, - /// When set, only writes to this primary namespace go through `serializer`; the rest bypass it. - serialized_namespace: Option, + /// When set, only writes to this primary namespace — and, when one is named, to this key — go + /// through `serializer`; the rest bypass it. + serialized: Option<(String, Option)>, + /// The writes going through `serializer` that have not returned yet, those held back included. + serialized_in_flight: Arc, +} + +impl ContendedStore { + /// Waits for a write going through `serializer` to start — one a test holds back by holding + /// the write lock, or one on its way through. + async fn wait_for_serialized_write(&self) { + let poll = async { + while self.serialized_in_flight.load(Ordering::Acquire) == 0 { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }; + tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), poll) + .await + .expect("timed out waiting for a serialized write to start"); + } } impl KVStore for ContendedStore { @@ -108,8 +128,10 @@ impl KVStore for ContendedStore { let serializer = Arc::clone(&self.serializer); let block_writes = Arc::clone(&self.block_writes); let wallet_write_started = Arc::clone(&self.wallet_write_started); - let serialized = - self.serialized_namespace.as_deref().map_or(true, |ns| ns == primary_namespace); + let serialized_in_flight = Arc::clone(&self.serialized_in_flight); + let serialized = self.serialized.as_ref().map_or(true, |(namespace, only_key)| { + namespace == primary_namespace && only_key.as_deref().map_or(true, |k| k == key) + }); let primary_namespace = primary_namespace.to_string(); let secondary_namespace = secondary_namespace.to_string(); let key = key.to_string(); @@ -117,8 +139,18 @@ impl KVStore for ContendedStore { if block_writes.load(Ordering::Acquire) { wallet_write_started.notify_one(); } - let _guard = if serialized { Some(serializer.read().await) } else { None }; - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + let _guard = if serialized { + serialized_in_flight.fetch_add(1, Ordering::AcqRel); + Some(serializer.read().await) + } else { + None + }; + let result = + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await; + if serialized { + serialized_in_flight.fetch_sub(1, Ordering::AcqRel); + } + result } } @@ -167,7 +199,8 @@ fn wallet_store_contention_does_not_stall_runtime() { serializer: Arc::new(tokio::sync::RwLock::new(())), block_writes: Arc::new(AtomicBool::new(false)), wallet_write_started: Arc::new(tokio::sync::Notify::new()), - serialized_namespace: None, + serialized: None, + serialized_in_flight: Arc::new(AtomicUsize::new(0)), }; let node = builder .build_with_store(test_config.node_entropy.into(), store.clone()) @@ -2986,10 +3019,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()); @@ -2998,7 +3032,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); @@ -3069,6 +3105,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 @@ -3080,8 +3299,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 @@ -3098,13 +3339,21 @@ const RECLAIMED_ADDRESSES: &str = "Reclaiming unused addresses from channel"; /// `tx_signatures` on receiving node A's. Node A sends its `tx_signatures` first, see /// [`open_and_splice_from_counterparty`]. Pinned to Esplora so node A's wallet syncs only on /// demand. +/// +/// The kept record is resolved once the close settles: node A's commitment transaction confirms +/// and its `to_self_delay` passes, the monitor stops watching the round and reports it discarded, +/// and the payment fails, no round of ours being left that can confirm. LDK reports +/// `SpliceNegotiated` for this round after `ChannelClosed`, node A having sent its `tx_signatures`, +/// so the node clears the round's awaiting-broadcast mark and the record is not dropped at maturity +/// as one nothing broadcast. The test's own tail shows node B does broadcast the round, which is +/// why `Failed` is the right end state. #[cfg(feature = "chain-esplora")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { 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; @@ -3115,6 +3364,7 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { let received_a = logs_a.count(RECEIVED_TX_SIGNATURES); let received_b = logs_b.count(RECEIVED_TX_SIGNATURES); let broadcast_b = logs_b.count(BROADCAST_FUNDING); + let marked_a = logs_a.count(ROUND_MARKED_BROADCAST); node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000).unwrap(); // Recording the round writes the payment store before the round is signed, so node A does not @@ -3136,10 +3386,22 @@ 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(); expect_event!(node_a, ChannelClosed); + let new_funding_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + assert_eq!(new_funding_txo.txid, rbf_txid, "LDK reported a different round negotiated"); + assert!( + logs_a.wait_for_count(ROUND_MARKED_BROADCAST, marked_a + 1).await, + "the round's awaiting-broadcast mark was not cleared" + ); let payment = node_a .list_all_payments() @@ -3156,8 +3418,36 @@ 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 no round of ours is left that can confirm, so the + // payment fails. + let commitment = + wait_for_broadcast(&bitcoind, &logs_a, |tx| is_commitment(tx, funding_txo), "commitment") + .await; + 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 discarded round's payment was not failed" + ); + let payment = node_a + .list_all_payments() + .into_iter() + .find(|p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == rbf_txid)) + .expect("the record of a round node B could broadcast was taken back"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!( + !logs_a.contains(DROPPED_ABANDONED_ROUND), + "a round node B could broadcast was dropped" + ); + + // 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, @@ -3167,6 +3457,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 @@ -3226,6 +3691,248 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { node_b.stop().unwrap(); } +/// A zero-conf splice round of ours stays recorded when the channel closes after a later splice +/// built on it. LDK promoted the round to the funding as `splice_locked` was exchanged, before its +/// transaction confirmed, and moved on again as the later splice locked, so at the close neither +/// the channel manager nor the monitor holds the round — although it can still confirm, the later +/// round and the commitment transaction both descending from it. Node A splices into its zero-conf +/// channel with node B, then splices out of it, and force-closes before either round confirms; the +/// first round's payment is kept, and both graduate once the rounds confirm. Pinned to Esplora so +/// the wallet syncs only on demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn superseded_zero_conf_splice_round_keeps_its_payment_at_close() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, logs_a) = setup_logged_node(&chain_source, random_config()); + let mut config_b = random_config(); + config_b.node_config.trusted_peers_0conf.push(node_a.node_id()); + let node_b = setup_node(&chain_source, config_b); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(5_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 2_000_000, false, &electrsd).await; + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + // Confirm the original funding so the splices below are the only unconfirmed rounds and node + // A's change from the open is spendable for the splice-in. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000).unwrap(); + let first = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, first.txid).await; + // The zero-conf splice locks without confirmations, re-signaled as `ChannelReady`, and node A + // records the promotion as it handles it. + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + assert_eq!(logs_a.count(ROUND_LOCKED), 1, "the promotion of the first round was not recorded"); + + let address = node_a.onchain_payment().new_address().unwrap(); + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 500_000).unwrap(); + let second = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, second.txid).await; + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + assert_eq!(logs_a.count(ROUND_LOCKED), 2, "the promotion of the second round was not recorded"); + assert_eq!(funding_payment(&node_a, first.txid).status, PaymentStatus::Pending); + assert_eq!(funding_payment(&node_a, second.txid).status, PaymentStatus::Pending); + + node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + expect_event!(node_a, ChannelClosed); + assert!( + logs_a.wait_for_count(CLOSED_CHANNEL_PAYMENT_RESOLVED, 2).await, + "the close did not resolve both funding payments" + ); + assert!(!logs_a.contains(NO_ROUND_CAN_CONFIRM), "the superseded round's payment was failed"); + assert_eq!(funding_payment(&node_a, first.txid).status, PaymentStatus::Pending); + assert_eq!(funding_payment(&node_a, second.txid).status, PaymentStatus::Pending); + + // Both rounds confirm, the second spending the first, and the payments graduate. Six blocks are + // the exact minimum, so wait for the rounds to reach the chain source before mining them. + wait_for_tx(&electrsd.client, second.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + for txid in [first.txid, second.txid] { + let payment = funding_payment(&node_a, txid); + assert_eq!(payment.status, PaymentStatus::Succeeded, "round {} did not graduate", txid); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// The monitor's `DiscardFunding` events for the rounds of a closed channel's splice reach the +/// handler ahead of the channel's `ChannelClosed` when one sync delivers the close and its +/// maturity: the channel manager polls the monitor's report of the close at the start of each event +/// pass and on peer traffic, and the monitor's own events are handled right after the manager's. +/// Each event then finds the channel listed and, both rounds having been broadcast, only returns +/// the round's contribution, leaving the payment to the `ChannelClosed` that follows, which fails +/// it, no round of ours being watched anymore. Node A splices into its channel with node B and +/// bumps the round's fee from another coin, so the two rounds are contributions of their own; node +/// B closes while node A's event handler sits in a held event-queue write — for a channel node C +/// opened to it — until the close and its maturity are synced. Pinned to Esplora so the wallet +/// syncs only on demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_b, logs_b) = setup_logged_node(&chain_source, random_config()); + // Keeping no anchor reserve back from node B, node A's splice-in takes its whole balance and + // leaves no change for a fee bump to draw on. + let mut config_a = random_config(); + config_a.node_config.anchor_channels_config.trusted_peers_no_reserve.push(node_b.node_id()); + let (node_a, store_a, logs_a) = + setup_contended_node(&chain_source, config_a, Some(("", Some("events")))); + let node_c = setup_node(&chain_source, random_config()); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let address_c = node_c.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b, address_c], + Amount::from_sat(1_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + let funding_txo = open_channel(&node_a, &node_b, 600_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + // Node B contributes nothing to either round, so only node A hears of them. + node_a.splice_in_with_all(&user_channel_id_a, node_b.node_id()).unwrap(); + let first_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_tx(&electrsd.client, first_txo.txid).await; + wait_for_classified_funding_payment(&node_a, first_txo.txid).await; + let first_round = + wait_for_broadcast(&bitcoind, &logs_a, |tx| tx.compute_txid() == first_txo.txid, "round") + .await; + assert_eq!(first_round.output.len(), 1, "the splice-in left change"); + // The wallet learns the round from the sync and gets a fresh coin for the bump, which then + // spends nothing of the first round's but the funding. + node_a.sync_wallets().unwrap(); + let coin_address = node_a.onchain_payment().new_address().unwrap(); + let coin_txid = distribute_funds_unconfirmed( + &bitcoind.client, + &electrsd.client, + vec![coin_address], + Amount::from_sat(3_000_000), + ) + .await; + mine_block_with(&bitcoind, &electrsd, &[raw_transaction_hex(&bitcoind, coin_txid)]).await; + node_a.sync_wallets().unwrap(); + + node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id()).unwrap(); + let bump_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + assert_ne!(first_txo, bump_txo, "the bump produced the same funding"); + // The mempool may refuse the bump, which pays little more than the first round; the round + // counts either way. + wait_for_classified_funding_payment(&node_a, bump_txo.txid).await; + let bump_round = + wait_for_broadcast(&bitcoind, &logs_a, |tx| tx.compute_txid() == bump_txo.txid, "bump") + .await; + let shared: Vec<_> = bump_round + .input + .iter() + .map(|input| input.previous_output) + .filter(|outpoint| spends(&first_round, *outpoint)) + .collect(); + assert_eq!(shared, vec![funding_txo], "the bump reused an input of the first round"); + let payment_id = PaymentId(first_txo.txid.to_byte_array()); + let payment = node_a.payment(&payment_id).unwrap().expect("the splice has a payment"); + assert_eq!(payment.status, PaymentStatus::Pending); + + // Neither node reconnects to the other: node B closes on its own and node A learns of the + // close from the chain alone. The commitment conflicts with the round in the mempool, so it is + // refused and mined directly, below. + node_a.disconnect(node_b.node_id()).unwrap(); + node_b.disconnect(node_a.node_id()).unwrap(); + node_b.force_close_channel(&user_channel_id_b, node_a.node_id(), None).unwrap(); + expect_event!(node_b, ChannelClosed); + let commitment = + wait_for_broadcast(&bitcoind, &logs_b, |tx| is_commitment(tx, funding_txo), "commitment") + .await; + node_b.stop().unwrap(); + + // Node A's event handler is held in the write queueing node C's channel for the user, so + // nothing polls the monitor's report of the close until it is released. Node C leaves before + // the close is mined: a peer's messages, or its leaving, would have node A poll too. + let hold_a = Arc::clone(&store_a.serializer).write_owned().await; + let listening_address = node_a.listening_addresses().unwrap().first().unwrap().clone(); + node_c.open_channel(node_a.node_id(), listening_address, 500_000, None, None).unwrap(); + expect_channel_pending_event!(node_c, node_a.node_id()); + store_a.wait_for_serialized_write().await; + node_c.stop().unwrap(); + wait_for_no_peers(&node_a).await; + let kept_before = logs_a.count(ROUND_CAN_STILL_CONFIRM); + let commitment_hex = bitcoin::consensus::encode::serialize_hex(&commitment); + mine_block_with(&bitcoind, &electrsd, &[commitment_hex]).await; + for _ in 1..ANTI_REORG_DELAY { + mine_block_with(&bitcoind, &electrsd, &[]).await; + } + node_a.sync_wallets().unwrap(); + drop(hold_a); + + expect_channel_pending_event!(node_a, node_c.node_id()); + expect_event!(node_a, ChannelClosed); + assert!(logs_a.wait_for(NO_ROUND_CAN_CONFIRM).await, "the payment was not failed"); + assert!( + logs_a.lines().iter().any(|line| line.contains(NO_ROUND_CAN_CONFIRM) + && line.contains(CLOSED_CHANNEL_PAYMENT_RESOLVED)), + "the close did not fail the payment" + ); + assert_eq!( + logs_a.count(ROUND_CAN_STILL_CONFIRM), + kept_before, + "a discard while the channel was listed resolved the payment" + ); + assert_eq!( + logs_a.count(RECLAIMED_ADDRESSES), + 2, + "the monitor's events did not each return the round's contribution" + ); + // The record names the round the wallet last heard of: the sync that delivered the close + // saw the mempool drop the first round, and moved the record from the bump to it. + let payment = node_a.payment(&payment_id).unwrap().expect("the splice has a payment"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!( + matches!( + payment.kind, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } if txid == first_txo.txid || txid == bump_txo.txid + ), + "unexpected kind {:?} for rounds {} and {}", + payment.kind, + first_txo.txid, + bump_txo.txid + ); + node_a.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 9371f0451cbb47e8874bb2670fffb5a9d6e6d4d8 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 25 Sep 2026 10:42:55 -0700 Subject: [PATCH 07/49] DROP ME: f - Resolve funding payments when LDK discards a splice round Drop the log line for a discard on an open channel. It took a sentence to say what the branch does not do, and the wallet logs what it drops right after. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 8e0741e0e15b4285300e8b8c85c3abc6edc6b75a) --- src/event.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/event.rs b/src/event.rs index 29b122b113..47ca7233b9 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2115,13 +2115,6 @@ where 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 => { From 2fe57c3cd9202725f51ecfa45ca2a96e7e20af2e Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 25 Sep 2026 10:59:10 -0700 Subject: [PATCH 08/49] DROP ME: f - Resolve funding payments when LDK discards a splice round Take every transaction the integration tests need from bitcoind's mempool instead of decoding the bytes a node logs at trace level, which tied the tests to a log line. Where bitcoind refused the transaction before, a commitment while a splice round spending the same funding sits in the mempool or a fee bump paying little more than the round it joins, the tests first deprioritise the mempool round so that the replacement passes bitcoind's replacement checks, which compare modified fees. The fee bump joining a counterparty's round is now accepted every run instead of sometimes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 605aae447b45774b4a9f745727e26f4dbbe90ded) --- tests/integration_tests_rust.rs | 141 +++++++++++++++++--------------- 1 file changed, 73 insertions(+), 68 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index e97a118a46..9f6e14b79b 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3051,10 +3051,10 @@ fn setup_contended_node( /// 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. +/// `tx_signatures` first. Returns `node_a`'s id for the channel and the txid of `node_b`'s round. async fn open_and_splice_from_counterparty( bitcoind: &BitcoinD, electrsd: &ElectrsD, node_a: &TestNode, node_b: &TestNode, -) -> UserChannelId { +) -> (UserChannelId, Txid) { let address_a = node_a.onchain_payment().new_address().unwrap(); premine_and_distribute_funds( &bitcoind.client, @@ -3087,7 +3087,7 @@ async fn open_and_splice_from_counterparty( wait_for_tx(&electrsd.client, counterparty_txo.txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); - user_channel_id_a + (user_channel_id_a, counterparty_txo.txid) } /// The transaction of `node`'s only payment typed as interactive funding. @@ -3113,19 +3113,24 @@ fn funding_payment(node: &TestNode, funding_txid: Txid) -> PaymentDetails { .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()) - }; +/// The transaction `txid` once bitcoind accepted it and electrs serves it. +async fn wait_for_transaction(bitcoind: &BitcoinD, electrsd: &ElectrsD, txid: Txid) -> Transaction { + wait_for_tx(&electrsd.client, txid).await; + decode_transaction(&raw_transaction_hex(bitcoind, txid)) + .expect("bitcoind served bytes that do not encode a transaction") +} + +/// The transaction `hex` encodes, if it encodes one. +fn decode_transaction(hex: &str) -> Option { + Vec::::from_hex(hex) + .ok() + .and_then(|bytes| bitcoin::consensus::encode::deserialize::(&bytes).ok()) +} + +/// A commitment transaction of the channel funded by `funding_txo`, once bitcoind holds it in its +/// mempool. A splice round spending the same funding may sit there, which the commitment replaces +/// only once that round is deprioritised, see [`deprioritise_transaction`]. +async fn wait_for_commitment(bitcoind: &BitcoinD, funding_txo: bitcoin::OutPoint) -> Transaction { let poll = async { loop { let mempool: Vec = @@ -3134,21 +3139,32 @@ async fn wait_for_broadcast( // 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) { + if let Some(tx) = hex + .ok() + .and_then(|hex| decode_transaction(&hex)) + .filter(|tx| is_commitment(tx, funding_txo)) + { 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)) + .unwrap_or_else(|_| panic!("timed out waiting for the commitment to be broadcast")) +} + +/// Has bitcoind count the fee of the mempool transaction `txid` as far below zero, so that a +/// transaction conflicting with it replaces it however little it pays: the replacement checks +/// compare against the modified fee. Lets a test take a transaction from the mempool that bitcoind +/// would otherwise refuse — a commitment transaction while a splice round spending the same funding +/// sits there, or a splice round paying little more than the round it joins. +fn deprioritise_transaction(bitcoind: &BitcoinD, txid: Txid) { + let _: bool = bitcoind + .client + .call("prioritisetransaction", &[json!(txid.to_string()), json!(0), json!(-100_000_000i64)]) + .expect("failed to deprioritise the transaction"); } /// Whether `tx` spends `outpoint`. @@ -3224,12 +3240,10 @@ struct TwoRoundSplice { /// 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. +/// `node_a`'s record of the splice, and both are returned in full so either can be mined: the first +/// round is deprioritised so that the mempool takes the joining round, which pays little more. async fn open_and_join_counterparty_splice( - bitcoind: &BitcoinD, electrsd: &ElectrsD, node_a: &TestNode, logs_a: &CollectingLogWriter, - node_b: &TestNode, + bitcoind: &BitcoinD, electrsd: &ElectrsD, node_a: &TestNode, node_b: &TestNode, ) -> TwoRoundSplice { let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -3252,19 +3266,17 @@ async fn open_and_join_counterparty_splice( 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; + let first_tx = wait_for_transaction(bitcoind, electrsd, first_txo.txid).await; wait_for_classified_funding_payment(node_b, first_txo.txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + deprioritise_transaction(bitcoind, first_txo.txid); 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; + let rbf_tx = wait_for_transaction(bitcoind, electrsd, rbf_txo.txid).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(); @@ -3355,7 +3367,7 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { let (node_a, store_a, logs_a) = 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 = + let (user_channel_id_a, counterparty_round) = 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. @@ -3393,6 +3405,9 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { .and_then(|channel| channel.funding_txo) .expect("the channel has a funding"); + // Node B's round, which spends the same funding, sits in the mempool: let the commitment + // replace it rather than be refused. + deprioritise_transaction(&bitcoind, counterparty_round); 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); @@ -3418,15 +3433,12 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { } )); - // 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 no round of ours is left that can confirm, so the - // payment fails. - let commitment = - wait_for_broadcast(&bitcoind, &logs_a, |tx| is_commitment(tx, funding_txo), "commitment") - .await; + // The close settles first: node A's commitment transaction, which replaced node B's first + // round in the mempool, is mined. 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 no round of ours is + // left that can confirm, so the payment fails. + let commitment = wait_for_commitment(&bitcoind, funding_txo).await; mine_transaction(&bitcoind, &commitment); generate_blocks_and_wait(&bitcoind.client, &electrsd.client, BREAKDOWN_TIMEOUT as usize).await; node_a.sync_wallets().unwrap(); @@ -3499,13 +3511,13 @@ async fn broadcast_splice_round_lost_to_a_close_fails_its_payment() { node_a.sync_wallets().unwrap(); assert_eq!(funding_payment(&node_a, splice_txo.txid).status, PaymentStatus::Pending); + // The splice round spends the funding too and sits in the mempool: let the commitment replace + // it rather than be refused. The close settles once the `to_self_delay` on node A's balance + // passes. + deprioritise_transaction(&bitcoind, splice_txo.txid); 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; + let commitment = wait_for_commitment(&bitcoind, funding_txo).await; mine_transaction(&bitcoind, &commitment); generate_blocks_and_wait(&bitcoind.client, &electrsd.client, BREAKDOWN_TIMEOUT as usize).await; node_a.sync_wallets().unwrap(); @@ -3538,8 +3550,7 @@ async fn splice_round_confirmed_after_a_close_keeps_its_payment() { 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; + let splice = open_and_join_counterparty_splice(&bitcoind, &electrsd, &node_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(); @@ -3588,8 +3599,7 @@ async fn splice_round_superseded_on_an_open_channel_fails_its_payment() { 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; + let splice = open_and_join_counterparty_splice(&bitcoind, &electrsd, &node_a, &node_b).await; mine_transaction(&bitcoind, &splice.first_tx); generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; @@ -3650,7 +3660,7 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { 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 = + 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); @@ -3791,7 +3801,7 @@ async fn superseded_zero_conf_splice_round_keeps_its_payment_at_close() { 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()); + let node_b = setup_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(); @@ -3823,11 +3833,8 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { // 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; + let first_round = wait_for_transaction(&bitcoind, &electrsd, 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. @@ -3843,15 +3850,14 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { mine_block_with(&bitcoind, &electrsd, &[raw_transaction_hex(&bitcoind, coin_txid)]).await; node_a.sync_wallets().unwrap(); + // The bump pays little more than the first round, which bitcoind may refuse to replace for it: + // have it replaced, so the mempool serves the bump. + deprioritise_transaction(&bitcoind, first_txo.txid); 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 bump_round = wait_for_transaction(&bitcoind, &electrsd, bump_txo.txid).await; let shared: Vec<_> = bump_round .input .iter() @@ -3864,15 +3870,14 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { 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. + // close from the chain alone. The commitment conflicts with the bump in the mempool: let it + // replace the bump rather than be refused; it is mined in a block of the test's own, below. + deprioritise_transaction(&bitcoind, bump_txo.txid); 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; + let commitment = wait_for_commitment(&bitcoind, funding_txo).await; node_b.stop().unwrap(); // Node A's event handler is held in the write queueing node C's channel for the user, so From 81a62f07d0c32cdf22d1c7fb6db2e12e8fdafdb0 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 21 Sep 2026 23:22:41 -0700 Subject: [PATCH 09/49] DROP ME: Resolve a transaction to the record that owns it before one listing it as a conflict A pending-store entry lists the transactions that replaced its own, so a cooperative close (or any other wallet transaction) that a splice round replaces lists the round among its conflicting txids. The round's events then matched two entries, its own record's and the close's, and the pending cache's iteration order decided which one won. About one time in five the round's confirmation landed on the close's record, which took the round's txid, figures and confirmation and graduated, while the splice's payment never learned of the confirmation and stayed pending for good. Prefer the entry that records the transaction as its own, whether as its current transaction or as a negotiated candidate, and fall back to an entry that only lists it as a conflict when no entry owns it. The conflict listing stays: it is how a replaced round of a record without candidates, an ordinary payment's RBF history or the replacement of an inbound transaction, maps back to its record. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 8315d74169050e2556b02da756c37c1d8dbc3200) --- src/wallet/mod.rs | 161 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 148 insertions(+), 13 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index a72af2885f..bc84e46e9c 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2836,20 +2836,23 @@ impl Wallet { return Ok(Some(direct_payment_id)); } - if let Some(replaced_details) = self + let owns = |p: &PendingPaymentDetails| { + matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid) + // A middle RBF round is not the record's current txid and may never have + // received a `TxReplaced` event of its own, so map any of its candidate + // txids (an earlier RBF round may confirm) back to the record. + || p.candidate(target_txid).is_some() + }; + let matches = self .pending_payment_store - .list_filter(|p| { - matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid) - || p.conflicting_txids.contains(&target_txid) - // A middle RBF round is not the record's current txid and may never have - // received a `TxReplaced` event of its own, so map any of its candidate - // txids (an earlier RBF round may confirm) back to the record. - || p.candidate(target_txid).is_some() - }) - .await - .first() - { - return Ok(Some(replaced_details.details.id)); + .list_filter(|p| owns(p) || p.conflicting_txids.contains(&target_txid)) + .await; + // An entry lists the transactions that replaced its own, so a transaction another entry + // records as its own (a splice round that replaced a close, say) matches both. The entry + // that owns it is its record; the conflict listing is only how a replaced round of a + // record with no candidates (an ordinary payment's RBF history) maps back to its record. + if let Some(entry) = matches.iter().find(|p| owns(p)).or(matches.first()) { + return Ok(Some(entry.details.id)); } Ok(None) @@ -6454,6 +6457,138 @@ mod tests { } } + /// The mirror image of [`funding_record_does_not_adopt_a_conflicting_close`]: a cooperative + /// close that a splice round replaces lists the round among its conflicting txids, so the + /// round's confirmation resolves to the close's entry as readily as to the splice's, which + /// records the round as its own. It must land on the splice's record whichever entry the + /// pending cache lists first: the close's record is not the round's, and merging the round + /// into it leaves the splice's payment pending for good. Several closes and several fresh + /// wallets, each with its own cache order, make the splice's entry unlikely to come first + /// every time. + #[tokio::test] + async fn close_record_does_not_adopt_a_conflicting_splice_round() { + let secp = bitcoin::secp256k1::Secp256k1::new(); + let counterparty_node_id = bitcoin::secp256k1::PublicKey::from_secret_key( + &secp, + &bitcoin::secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(), + ); + let channel_id = lightning::ln::types::ChannelId::from_bytes([4u8; 32]); + + for _ in 0..12 { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // The round pays a wallet address, so the wallet's view of it carries figures of its own. + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let splice_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: Txid::from_byte_array([3u8; 32]), + vout: 0, + }, + script_sig: bitcoin::ScriptBuf::new(), + sequence: bitcoin::Sequence::MAX, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + }; + let splice_txid = splice_tx.compute_txid(); + // Keyed away from the round's txid, as a later round of a splice is. + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + + // Each close was recorded at broadcast, seen unconfirmed, then replaced by the round: + // what the `TxReplaced` arm leaves behind. + let close_txids: Vec = + (7u8..11).map(|byte| Txid::from_byte_array([byte; 32])).collect(); + for close_txid in &close_txids { + let close_details = PaymentDetails::new( + PaymentId(close_txid.to_byte_array()), + PaymentKind::Onchain { + txid: *close_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::CooperativeClose { + counterparty_node_id, + channel_id, + }), + }, + Some(50_000_000), + Some(1_000), + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + wallet.payment_store.insert_or_update(close_details.clone()).await.unwrap(); + let entry = + PendingPaymentDetails::new(close_details, vec![splice_txid], Vec::new()); + wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + } + + assert_eq!( + wallet.find_payment_by_txid(splice_txid).await.unwrap(), + Some(payment_id), + "the round resolved to a record that only lists it as a conflict" + ); + + let event = WalletEvent::TxConfirmed { + txid: splice_txid, + tx: Arc::new(splice_tx), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let funding = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + match &funding.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid); + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(funding.amount_msat, Some(1_000_000)); + assert_eq!(funding.fee_paid_msat, Some(500)); + + for close_txid in &close_txids { + let close = wallet + .payment_store + .get(&PaymentId(close_txid.to_byte_array())) + .await + .unwrap() + .unwrap(); + match &close.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!( + *txid, *close_txid, + "the close's record adopted the round's txid" + ); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::CooperativeClose { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(close.amount_msat, Some(50_000_000)); + assert_eq!(close.fee_paid_msat, Some(1_000)); + } + } + } + /// Continues the story above: once the conflicting close confirms through the anti-reorg /// depth, the splice's funding transaction can never confirm — its shared input is spent for /// good. The record must fail rather than stay `Pending` forever, and removing the pending From 1fded27822ddad3b694d98e856933bf5b2a8e360 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 22 Sep 2026 10:13:27 -0700 Subject: [PATCH 10/49] DROP ME: Take the funding re-broadcast trace from the write's own read Classifying a funding-typed broadcast read the payment store only to log that a re-broadcast of a promoted splice had met its interactive-funding record, then read it again inside the write. The write hands back what it found, so the log comes from that read and a channel-open funding costs one read fewer. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 5d4a518b28ec241432bd53d66cb08eb7c28887a2) --- src/wallet/mod.rs | 74 +++++++++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 28 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index bc84e46e9c..ad2b78cfb4 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2085,27 +2085,6 @@ impl Wallet { } let payment_id = PaymentId(txid.to_byte_array()); - - // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed - // and carrying wallet-view figures; `funding_reclassification_update` declines the - // downgrade, leaving no trace that a re-broadcast arrived. Log the arrival so tests can - // observe the traffic; the read serves the log line alone, so a stale read costs no more. - if let Some(current) = self.payment_store.get(&payment_id).await? { - if matches!( - current.kind, - PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } - ) { - log_trace!( - self.logger, - "Keeping interactive-funding classification over funding-typed rebroadcast {}", - txid, - ); - } - } - let details = PaymentDetails::new( payment_id, PaymentKind::Onchain { @@ -2118,7 +2097,26 @@ impl Wallet { direction, PaymentStatus::Pending, ); - self.persist_funding_payment(details, Vec::new()).await?; + let prior = self.persist_funding_payment(details, Vec::new()).await?; + // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed + // and carrying wallet-view figures; `funding_reclassification_update` declines the + // downgrade, leaving no trace that a re-broadcast arrived. Log the arrival so tests can + // observe the traffic, from the record the write found rather than a read of our own. + if prior.is_some_and(|prior| { + matches!( + prior.kind, + PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } + ) + }) { + log_trace!( + self.logger, + "Keeping interactive-funding classification over funding-typed rebroadcast {}", + txid, + ); + } log_debug!( self.logger, "Recorded channel-funding broadcast {} for channel {}", @@ -2644,17 +2642,15 @@ impl Wallet { } /// Writes a freshly-classified funding payment to the authoritative payment store and adds a - /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. + /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. Returns the + /// payment store's record as it was before the write. async fn persist_funding_payment( &self, details: PaymentDetails, candidates: Vec, - ) -> Result<(), Error> { + ) -> Result, Error> { // Hold the cross-store lock across both writes so a funding confirmation never observes // the record classified but the candidate history it needs still missing. let guard = self.funding_payment_update_lock.lock().await; - self.persist_funding_payment_locked(&guard, details, candidates) - .await - .map(|_| ()) - .map_err(Error::from) + Ok(self.persist_funding_payment_locked(&guard, details, candidates).await?) } /// [`Self::persist_funding_payment`] for a caller already holding the cross-store lock, whose @@ -6931,6 +6927,28 @@ mod tests { ); } + /// Classifying a channel-open funding the payment store does not know costs one read of it: + /// the write merges against the record it reads, and whether a promoted splice's re-broadcast + /// met its record is told from that same read. + #[tokio::test] + async fn unknown_funding_broadcast_is_recorded_after_one_payment_store_read() { + let counting_store = ReadCountingStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let tx = wallet_paying_tx(&wallet, 1); + let tx_type = + LdkTransactionType::Funding { channels: vec![(counterparty_node_id, channel_id)] }; + let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.classify_broadcast(&tx, &tx_type).await.unwrap(); + let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; + + let payment_id = PaymentId(tx.compute_txid().to_byte_array()); + assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_some()); + assert_eq!(reads, 1, "classifying an unknown funding re-read the payment store"); + } + /// Recording a transaction the payment store does not know costs two reads of it: the /// funding-status check looks the resolved id up, and the generic write merges against the /// store. Nothing in between re-reads what the funding-status check has already seen. From 8d7b7f4d32576863127909cccedb80f5cc82b1df Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 28 Sep 2026 09:36:46 -0500 Subject: [PATCH 11/49] DROP ME: Have funding-record writers take a guard only the funding lock issues The writers of funding payment records take a lock guard so that a caller has to hold the funding lock to reach them. The parameter took a guard of any `Mutex<()>`, though, and the wallet has another one, for refilling the address pool, so a caller holding the wrong lock compiled. Wrap the lock in a type whose guard only it can produce and have the writers take that guard, so holding this lock is the only way to call them. Whether the caller's reads before the write happened under the same acquisition is still up to the caller. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit c6cda2dded775b47cbe37d42c5dc014a877f8093) --- src/wallet/mod.rs | 52 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index ad2b78cfb4..bd620531b7 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -147,6 +147,32 @@ impl AddressPool { } } +/// The lock serializing the writers of funding payment records, see +/// [`Wallet::funding_payment_update_lock`]. Locking it hands out a guard of a type only this +/// module constructs, so a function taking one can be called only by a holder of this lock, not of +/// any other `Mutex<()>` the wallet has. +mod funding_payment_update_lock { + pub(super) struct FundingPaymentUpdateLock(tokio::sync::Mutex<()>); + + /// Held by a holder of the [`FundingPaymentUpdateLock`], and by no one else. + #[must_use = "dropping the guard releases the funding lock at once"] + pub(super) struct FundingPaymentUpdateGuard<'a> { + _guard: tokio::sync::MutexGuard<'a, ()>, + } + + impl FundingPaymentUpdateLock { + pub(super) fn new() -> Self { + Self(tokio::sync::Mutex::new(())) + } + + pub(super) async fn lock(&self) -> FundingPaymentUpdateGuard<'_> { + FundingPaymentUpdateGuard { _guard: self.0.lock().await } + } + } +} + +use funding_payment_update_lock::{FundingPaymentUpdateGuard, FundingPaymentUpdateLock}; + pub(crate) struct Wallet { // A BDK on-chain wallet. inner: Mutex>, @@ -171,8 +197,9 @@ pub(crate) struct Wallet { // classification landing inside an arm's decision sequence gets overwritten by the arm's // stale generic fallback. Graduation stays off this lock: it decides from the live record // under the payment store's mutation lock and writes only the status, so it carries nothing - // a concurrent classification could lose. - funding_payment_update_lock: tokio::sync::Mutex<()>, + // a concurrent classification could lose. The functions that need it held take its guard, + // which only this lock hands out. + funding_payment_update_lock: FundingPaymentUpdateLock, } impl Wallet { @@ -200,7 +227,7 @@ impl Wallet { config, logger, pending_payment_store, - funding_payment_update_lock: tokio::sync::Mutex::new(()), + funding_payment_update_lock: FundingPaymentUpdateLock::new(), } } @@ -826,7 +853,7 @@ impl Wallet { /// 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, + &self, _guard: &FundingPaymentUpdateGuard<'_>, payment_id: PaymentId, record_txid: Txid, ) -> Result { let mut outcome = FundingPaymentFailure::MovedOn; self.payment_store @@ -893,7 +920,7 @@ impl Wallet { /// [`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], + &self, guard: &FundingPaymentUpdateGuard<'_>, 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( @@ -925,8 +952,8 @@ impl Wallet { /// 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, + &self, guard: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], + resolution: FundingResolution, ) -> Result<(), Error> { let occasion = match resolution { FundingResolution::Close => format!("of closed channel {}", channel_id), @@ -1071,7 +1098,7 @@ impl Wallet { /// 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, + &self, _guard: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, txid: Txid, ) -> Result<(), Error> { let entries = self .pending_payment_store @@ -2402,8 +2429,7 @@ impl Wallet { /// [`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], + &self, _guard: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], ) -> Result<(), Error> { let entries = self .pending_payment_store @@ -2658,7 +2684,7 @@ impl Wallet { /// before the write, read inside the write's own critical section, so a caller needs no read of /// its own to know what the write merged into. async fn persist_funding_payment_locked( - &self, _guard: &tokio::sync::MutexGuard<'_, ()>, details: PaymentDetails, + &self, _guard: &FundingPaymentUpdateGuard<'_>, details: PaymentDetails, candidates: Vec, ) -> Result, FundingWriteError> { // Everything this write does depends on the record's current state, so all of it must be @@ -2866,9 +2892,9 @@ impl Wallet { /// 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 /// write pair cannot interleave with the caller's decision sequence. The `_guard` parameter - /// serves as a reminder of that contract. + /// proves the lock is held across this call; the rest of that contract is the caller's. async fn apply_funding_status_update_locked( - &self, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, event_txid: Txid, + &self, _guard: &FundingPaymentUpdateGuard<'_>, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, ) -> Result { // The caller's wallet-level lock keeps the candidate history stable while we await its From 233d110a420de21f1c46bb0acbc0025f64fcb404 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 28 Sep 2026 10:05:07 -0500 Subject: [PATCH 12/49] DROP ME: Put the wallet's payment stores behind an API that locks their writers The writers of funding payment records take the funding lock's guard, but the stores are fields of the wallet and a writer can still call them directly. Three did, with no lock: on-chain payment graduation, the classification of non-funding broadcasts, and the fee bump. Move both stores and the lock into one type. Writes are methods of the guard the lock hands out, so a write compiles only for a holder of the lock; reads take no lock. The three writers take the lock too. Graduation was kept off it on purpose, since its status-only write could clobber nothing a concurrent writer wrote; it locks now so that the API needs no unlocked write, per payment, because the conflict check in the same loop takes the lock itself. The fee bump locks after the wallet persister, the order wallet sync takes the two locks in. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit b9d64f02f6c43b504928fc3837961ffa8ca8bc8b) --- src/wallet/mod.rs | 1040 +++++++++++++++++++++++----------- src/wallet/payment_stores.rs | 170 ++++++ 2 files changed, 869 insertions(+), 341 deletions(-) create mode 100644 src/wallet/payment_stores.rs diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index bd620531b7..3f79c1aebe 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -54,6 +54,7 @@ use lightning::util::wallet_utils::{ CoinSelection, CoinSelectionSource, ConfirmedUtxo, Input, Utxo, WalletSource, }; use lightning_invoice::RawBolt11Invoice; +use payment_stores::{PaymentStores, PaymentStoresGuard}; use persist::KVStoreWalletPersister; use crate::config::{Config, ADDRESS_POOL_SIZE}; @@ -83,6 +84,7 @@ pub(crate) enum FundingAmount { Max, } +mod payment_stores; pub(crate) mod persist; pub(crate) mod ser; @@ -147,32 +149,6 @@ impl AddressPool { } } -/// The lock serializing the writers of funding payment records, see -/// [`Wallet::funding_payment_update_lock`]. Locking it hands out a guard of a type only this -/// module constructs, so a function taking one can be called only by a holder of this lock, not of -/// any other `Mutex<()>` the wallet has. -mod funding_payment_update_lock { - pub(super) struct FundingPaymentUpdateLock(tokio::sync::Mutex<()>); - - /// Held by a holder of the [`FundingPaymentUpdateLock`], and by no one else. - #[must_use = "dropping the guard releases the funding lock at once"] - pub(super) struct FundingPaymentUpdateGuard<'a> { - _guard: tokio::sync::MutexGuard<'a, ()>, - } - - impl FundingPaymentUpdateLock { - pub(super) fn new() -> Self { - Self(tokio::sync::Mutex::new(())) - } - - pub(super) async fn lock(&self) -> FundingPaymentUpdateGuard<'_> { - FundingPaymentUpdateGuard { _guard: self.0.lock().await } - } - } -} - -use funding_payment_update_lock::{FundingPaymentUpdateGuard, FundingPaymentUpdateLock}; - pub(crate) struct Wallet { // A BDK on-chain wallet. inner: Mutex>, @@ -183,23 +159,11 @@ pub(crate) struct Wallet { broadcaster: Arc, fee_estimator: Arc, chain_source: Arc, - payment_store: Arc, runtime: Arc, config: Arc, logger: Arc, - pending_payment_store: Arc, - // Serializes the writers that must observe the payment record and its pending-store entry - // (candidate history included) as one consistent unit: classification holds it across its - // two-store write pair, and wallet sync's event arms hold it from payment-id resolution - // through their last write. Without it, a confirmation landing between classification's two - // writes sees the record classified but the candidate history absent — resolving the wrong - // payment id or stamping the confirmed candidate with another candidate's figures — and a - // classification landing inside an arm's decision sequence gets overwritten by the arm's - // stale generic fallback. Graduation stays off this lock: it decides from the live record - // under the payment store's mutation lock and writes only the status, so it carries nothing - // a concurrent classification could lose. The functions that need it held take its guard, - // which only this lock hands out. - funding_payment_update_lock: FundingPaymentUpdateLock, + // The wallet's payment stores; see the type for the lock serializing their writers. + payment_stores: PaymentStores, } impl Wallet { @@ -222,12 +186,10 @@ impl Wallet { broadcaster, fee_estimator, chain_source, - payment_store, runtime, config, logger, - pending_payment_store, - funding_payment_update_lock: FundingPaymentUpdateLock::new(), + payment_stores: PaymentStores::new(payment_store, pending_payment_store), } } @@ -374,7 +336,7 @@ impl Wallet { // a classification landing in between would leave the id resolved against a // torn candidate index and the generic fallback below overwriting (or // duplicating) the record classification just wrote. - let guard = self.funding_payment_update_lock.lock().await; + let stores = self.payment_stores.lock().await; let mut payment_id = self .find_payment_by_txid(txid) @@ -383,7 +345,7 @@ impl Wallet { match self .apply_funding_status_update_locked( - &guard, + &stores, payment_id, txid, confirmation_status, @@ -422,19 +384,19 @@ impl Wallet { ) }; - self.payment_store.insert_or_update(payment.clone()).await?; + stores.insert_or_update_payment(payment.clone()).await?; if payment_status == PaymentStatus::Pending { let pending_payment = self.create_pending_payment_from_tx(payment, Vec::new()); - self.pending_payment_store.insert_or_update(pending_payment).await?; + stores.insert_or_update_pending_payment(pending_payment).await?; } }, WalletEvent::ChainTipChanged { new_tip, .. } => { let pending_payments: Vec = self - .pending_payment_store - .list_filter(|p| { + .payment_stores + .pending_payments(|p| { debug_assert!( p.details.status == PaymentStatus::Pending, "Non-pending payment {:?} found in pending store", @@ -465,8 +427,11 @@ impl Wallet { // snapshot (or was removed) declines, leaving future // events to drive it. let mut graduated = false; - self.payment_store - .mutate(&payment_id, |existing| { + // Taken per payment: the conflict check on unconfirmed payments below + // takes the lock itself. + let stores = self.payment_stores.lock().await; + stores + .mutate_payment(&payment_id, |existing| { let current = existing?; match current.kind { PaymentKind::Onchain { @@ -488,7 +453,7 @@ impl Wallet { }) .await?; if graduated { - self.pending_payment_store.remove(&payment_id).await?; + stores.remove_pending_payment(&payment_id).await?; } } }, @@ -540,7 +505,7 @@ impl Wallet { WalletEvent::TxUnconfirmed { txid, tx, .. } => { // See `TxConfirmed`: id resolution and the writes below must not interleave // with classification. - let guard = self.funding_payment_update_lock.lock().await; + let stores = self.payment_stores.lock().await; let mut payment_id = self .find_payment_by_txid(txid) @@ -549,7 +514,7 @@ impl Wallet { match self .apply_funding_status_update_locked( - &guard, + &stores, payment_id, txid, ConfirmationStatus::Unconfirmed, @@ -589,15 +554,15 @@ impl Wallet { }; let pending_payment = self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.payment_store.insert_or_update(payment).await?; - self.pending_payment_store.insert_or_update(pending_payment).await?; + stores.insert_or_update_payment(payment).await?; + stores.insert_or_update_pending_payment(pending_payment).await?; }, WalletEvent::TxReplaced { txid, conflicts, .. } => { // See `TxConfirmed`: id resolution and the writes below must not interleave // with classification. The pending entry written below embeds a read of the // payment record, which must not go stale against a concurrent // classification either. - let _guard = self.funding_payment_update_lock.lock().await; + let stores = self.payment_stores.lock().await; let Some(payment_id) = self.find_payment_by_txid(txid).await? else { log_error!( @@ -618,7 +583,7 @@ impl Wallet { // cycle, and an id resolved through the candidate history comes from a // classification whose payment-store write strictly precedes the candidate // history it was resolved from. So we can safely fetch it here. - let stored_payment = self.payment_store.get(&payment_id).await?; + let stored_payment = stores.payment(&payment_id).await?; debug_assert!( stored_payment.is_some(), "Payment {:?} expected in store during WalletEvent::TxReplaced but not found", @@ -633,19 +598,19 @@ impl Wallet { // pending listing that repairs such leftovers; finish the interrupted removal // instead. if payment.status != PaymentStatus::Pending { - self.pending_payment_store.remove(&payment_id).await?; + stores.remove_pending_payment(&payment_id).await?; continue; } let pending_payment_details = self.create_pending_payment_from_tx(payment, conflict_txids.clone()); - self.pending_payment_store.insert_or_update(pending_payment_details).await?; + stores.insert_or_update_pending_payment(pending_payment_details).await?; }, WalletEvent::TxDropped { txid, tx } => { // See `TxConfirmed`: id resolution and the writes below must not interleave // with classification. - let guard = self.funding_payment_update_lock.lock().await; + let stores = self.payment_stores.lock().await; let mut payment_id = self .find_payment_by_txid(txid) @@ -654,7 +619,7 @@ impl Wallet { match self .apply_funding_status_update_locked( - &guard, + &stores, payment_id, txid, ConfirmationStatus::Unconfirmed, @@ -694,8 +659,8 @@ impl Wallet { }; let pending_payment = self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.payment_store.insert_or_update(payment).await?; - self.pending_payment_store.insert_or_update(pending_payment).await?; + stores.insert_or_update_payment(payment).await?; + stores.insert_or_update_pending_payment(pending_payment).await?; }, _ => { continue; @@ -724,7 +689,7 @@ impl Wallet { return Ok(None); } let has_funding_record = - self.payment_store.get(&fallback_id).await?.is_some_and(|payment| { + self.payment_stores.payment(&fallback_id).await?.is_some_and(|payment| { matches!( payment.kind, PaymentKind::Onchain { @@ -775,10 +740,10 @@ impl Wallet { // 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; + let stores = self.payment_stores.lock().await; // Re-read the entry under the lock; the listing snapshot may predate a record write. - let entry = match self.pending_payment_store.get(&payment.details.id).await? { + let entry = match stores.pending_payment(&payment.details.id).await? { Some(entry) => entry, None => return Ok(false), }; @@ -827,7 +792,7 @@ impl Wallet { let payment_id = entry.details.id; let outcome = - self.fail_unconfirmed_funding_payment_locked(&_guard, payment_id, record_txid).await?; + self.fail_unconfirmed_funding_payment_locked(&stores, payment_id, record_txid).await?; match outcome { FundingPaymentFailure::Failed => log_info!( self.logger, @@ -853,11 +818,11 @@ impl Wallet { /// 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: &FundingPaymentUpdateGuard<'_>, payment_id: PaymentId, record_txid: Txid, + &self, stores: &PaymentStoresGuard<'_>, payment_id: PaymentId, record_txid: Txid, ) -> Result { let mut outcome = FundingPaymentFailure::MovedOn; - self.payment_store - .mutate(&payment_id, |existing| { + stores + .mutate_payment(&payment_id, |existing| { let current = existing?; match current.kind { PaymentKind::Onchain { @@ -885,7 +850,7 @@ impl Wallet { }) .await?; if outcome != FundingPaymentFailure::MovedOn { - self.pending_payment_store.remove(&payment_id).await?; + stores.remove_pending_payment(&payment_id).await?; } Ok(outcome) } @@ -913,18 +878,18 @@ impl Wallet { ) -> 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 + let stores = self.payment_stores.lock().await; + self.resolve_closed_channel_splice_rounds_locked(&stores, 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: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], + &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], ) -> Result<(), Error> { - self.drop_abandoned_splice_rounds_locked(guard, channel_id, held_rounds).await?; + self.drop_abandoned_splice_rounds_locked(stores, channel_id, held_rounds).await?; self.fail_funding_payments_without_held_round_locked( - guard, + stores, channel_id, held_rounds, FundingResolution::Close, @@ -952,7 +917,7 @@ impl Wallet { /// 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: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], + &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], resolution: FundingResolution, ) -> Result<(), Error> { let occasion = match resolution { @@ -961,8 +926,7 @@ impl Wallet { 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; + let entries = stores.pending_payments(|entry| tracks_channel(entry, channel_id)).await; for entry in entries { let payment_id = entry.details.id; let record_txid = match &entry.details.kind { @@ -1004,7 +968,7 @@ impl Wallet { continue; } match self - .fail_unconfirmed_funding_payment_locked(guard, payment_id, record_txid) + .fail_unconfirmed_funding_payment_locked(stores, payment_id, record_txid) .await? { FundingPaymentFailure::Failed => log_info!( @@ -1058,8 +1022,8 @@ impl Wallet { ) -> 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 stores = self.payment_stores.lock().await; + self.record_locked_splice_round_locked(&stores, channel_id, promoted).await?; let held_rounds = match held_rounds { Some(held_rounds) => held_rounds, None => { @@ -1075,9 +1039,9 @@ impl Wallet { }; // 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.drop_abandoned_splice_rounds_locked(&stores, channel_id, held_rounds).await?; self.fail_funding_payments_without_held_round_locked( - &guard, + &stores, channel_id, held_rounds, FundingResolution::Promotion(promoted), @@ -1098,11 +1062,10 @@ impl Wallet { /// 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: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, txid: Txid, + &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, txid: Txid, ) -> Result<(), Error> { - let entries = self - .pending_payment_store - .list_filter(|entry| { + let entries = stores + .pending_payments(|entry| { tracks_channel(entry, channel_id) && entry.candidate(txid).is_some() && !entry.locked_rounds.contains(&txid) @@ -1110,8 +1073,8 @@ impl Wallet { .await; for entry in entries { let payment_id = entry.details.id; - self.pending_payment_store - .mutate(&payment_id, |existing| { + stores + .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); if entry.locked_rounds.contains(&txid) { return None; @@ -2280,9 +2243,9 @@ impl Wallet { // 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 stores = self.payment_stores.lock().await; - let prior_pending = self.pending_payment_store.get(&payment_id).await?; + let prior_pending = stores.pending_payment(&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(()); @@ -2311,23 +2274,23 @@ impl Wallet { // payment store back as it was while the record is still pending, or the replayed event // would find the half-written record and take it for prior state. The write hands back // what it found in the payment store, read inside its own critical section. - if let Err(failure) = self.persist_funding_payment_locked(&guard, details, recorded).await { + if let Err(failure) = self.persist_funding_payment_locked(&stores, details, recorded).await + { let (e, prior_details) = match failure { // The write pair failed before its first write, so there is nothing to put back. FundingWriteError::Unread(e) => return Err(e), FundingWriteError::Failed { error, prior } => (error, prior), }; let rollback = match &prior_details { - Some(prior) => self - .payment_store - .mutate(&payment_id, |existing| { + Some(prior) => stores + .mutate_payment(&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, + None => stores.remove_payment(&payment_id).await, }; if let Err(rollback_error) = rollback { log_error!( @@ -2359,19 +2322,18 @@ impl Wallet { ) -> 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 stores = self.payment_stores.lock().await; - let entries = self - .pending_payment_store - .list_filter(|entry| { + let entries = stores + .pending_payments(|entry| { tracks_channel(entry, channel_id) && 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| { + stores + .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); let round = entry .candidates @@ -2422,18 +2384,17 @@ impl Wallet { ) -> 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.drop_abandoned_splice_rounds_locked(&guard, channel_id, held_rounds).await + let stores = self.payment_stores.lock().await; + self.drop_abandoned_splice_rounds_locked(&stores, 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: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], + &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], ) -> Result<(), Error> { - let entries = self - .pending_payment_store - .list_filter(|entry| { + let entries = stores + .pending_payments(|entry| { tracks_channel(entry, channel_id) && entry.candidates.iter().any(|candidate| candidate.awaiting_broadcast) }) @@ -2481,8 +2442,8 @@ impl Wallet { Some(active) => { // Whether the record still waits on the dropped rounds is decided inside the // write's critical section, from the record found there. - self.payment_store - .mutate(&payment_id, |existing| { + stores + .mutate_payment(&payment_id, |existing| { let current = existing?; if !waits_on_abandoned(current) { history_only = true; @@ -2504,14 +2465,14 @@ impl Wallet { }, None => { // A removal has no critical section to decide in, so the record is read first. - let record = self.payment_store.get(&payment_id).await?; + let record = stores.payment(&payment_id).await?; if record.as_ref().map_or(true, waits_on_abandoned) { // Nothing of this node's was ever broadcast under the record, so it goes // rather than fail a payment for a transaction that never existed. The // payment record goes first: the entry keeps resolving the rounds' txids, // so a removal that fails midway is finished by the replayed event. - self.payment_store.remove(&payment_id).await?; - self.pending_payment_store.remove(&payment_id).await?; + stores.remove_payment(&payment_id).await?; + stores.remove_pending_payment(&payment_id).await?; log_debug!( self.logger, "Dropped abandoned splice round(s) {:?} and removed funding payment {}: nothing of ours \ @@ -2538,8 +2499,8 @@ impl Wallet { abandoned_txids, ); } - self.pending_payment_store - .mutate(&payment_id, |existing| { + stores + .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); entry.candidates.retain(|c| !abandoned_txids.contains(&c.txid)); if let Some(mirrored) = mirrored { @@ -2570,8 +2531,8 @@ impl Wallet { &self, held_rounds: impl Fn(ChannelId) -> Option>, ) -> Result<(), Error> { let channels: HashSet = self - .pending_payment_store - .list_filter(|entry| { + .payment_stores + .pending_payments(|entry| { entry.candidates.iter().any(|candidate| candidate.awaiting_broadcast) }) .await @@ -2606,12 +2567,12 @@ impl Wallet { /// 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 stores = self.payment_stores.lock().await; let payment_id = PaymentId(txid.to_byte_array()); - if self.pending_payment_store.get(&payment_id).await?.is_some() { + if stores.pending_payment(&payment_id).await?.is_some() { return Ok(()); } - let unindexed = self.payment_store.get(&payment_id).await?.is_some_and(|record| { + let unindexed = stores.payment(&payment_id).await?.is_some_and(|record| { record.status == PaymentStatus::Pending && matches!( &record.kind, @@ -2623,7 +2584,7 @@ impl Wallet { ) }); if unindexed { - self.payment_store.remove(&payment_id).await?; + stores.remove_payment(&payment_id).await?; log_info!( self.logger, "Dropped the half-written funding record of abandoned splice round {}", @@ -2662,7 +2623,7 @@ impl Wallet { direction, PaymentStatus::Pending, ); - self.payment_store.insert_or_update(details).await?; + self.payment_stores.lock().await.insert_or_update_payment(details).await?; log_debug!(self.logger, "Recorded classified on-chain broadcast {}", txid); Ok(()) } @@ -2675,8 +2636,8 @@ 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; - Ok(self.persist_funding_payment_locked(&guard, details, candidates).await?) + let stores = self.payment_stores.lock().await; + Ok(self.persist_funding_payment_locked(&stores, details, candidates).await?) } /// [`Self::persist_funding_payment`] for a caller already holding the cross-store lock, whose @@ -2684,7 +2645,7 @@ impl Wallet { /// before the write, read inside the write's own critical section, so a caller needs no read of /// its own to know what the write merged into. async fn persist_funding_payment_locked( - &self, _guard: &FundingPaymentUpdateGuard<'_>, details: PaymentDetails, + &self, stores: &PaymentStoresGuard<'_>, details: PaymentDetails, candidates: Vec, ) -> Result, FundingWriteError> { // Everything this write does depends on the record's current state, so all of it must be @@ -2699,9 +2660,8 @@ impl Wallet { // then rightly refuses it, and the record is left with figures no classification derived. let id = details.id; let mut seen = None; - let written = self - .payment_store - .mutate(&id, |existing| { + let written = stores + .mutate_payment(&id, |existing| { let reclassification = funding_reclassification_update(details.clone(), &candidates, existing); seen = Some((existing.cloned(), reclassification.clone())); @@ -2734,12 +2694,11 @@ impl Wallet { // is ordered before the removal, which then also deletes anything inserted here. A // status read taken before this write goes stale when graduation lands in between, and // would re-index the graduated payment. - let payment_store = Arc::clone(&self.payment_store); - self.pending_payment_store - .mutate_async(&id, move |existing| async move { + stores + .mutate_pending_payment_async(&id, move |existing| async move { // The record was written above and removal serializes on the cross-store lock held // here, so absence means the write failed out; fall back to the fresh details. - let recorded = payment_store.get(&id).await?.unwrap_or(details); + let recorded = stores.payment(&id).await?.unwrap_or(details); Ok(match existing { // The inserted entry embeds the post-write record rather than the fresh // details, so a confirmation wallet sync already recorded keeps driving @@ -2847,14 +2806,14 @@ impl Wallet { // or classification's resolve-then-write sequence. The pending entry goes first: a failure // in between then leaves an unindexed record (benign, and the retry removes it) rather // than an entry indexing a removed record. - let _guard = self.funding_payment_update_lock.lock().await; - self.pending_payment_store.remove(payment_id).await?; - self.payment_store.remove(payment_id).await + let stores = self.payment_stores.lock().await; + stores.remove_pending_payment(payment_id).await?; + stores.remove_payment(payment_id).await } async fn find_payment_by_txid(&self, target_txid: Txid) -> Result, Error> { let direct_payment_id = PaymentId(target_txid.to_byte_array()); - if self.pending_payment_store.contains_key(&direct_payment_id).await? { + if self.payment_stores.has_pending_payment(&direct_payment_id).await? { return Ok(Some(direct_payment_id)); } @@ -2866,8 +2825,8 @@ impl Wallet { || p.candidate(target_txid).is_some() }; let matches = self - .pending_payment_store - .list_filter(|p| owns(p) || p.conflicting_txids.contains(&target_txid)) + .payment_stores + .pending_payments(|p| owns(p) || p.conflicting_txids.contains(&target_txid)) .await; // An entry lists the transactions that replaced its own, so a transaction another entry // records as its own (a splice round that replaced a close, say) matches both. The entry @@ -2889,12 +2848,12 @@ impl Wallet { /// 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` + /// The caller must hold the [`PaymentStores`] lock — from resolving `payment_id` /// through its own last write, not just across this call — so that classification's two-store - /// write pair cannot interleave with the caller's decision sequence. The `_guard` parameter + /// write pair cannot interleave with the caller's decision sequence. The `stores` guard /// proves the lock is held across this call; the rest of that contract is the caller's. async fn apply_funding_status_update_locked( - &self, _guard: &FundingPaymentUpdateGuard<'_>, payment_id: PaymentId, event_txid: Txid, + &self, stores: &PaymentStoresGuard<'_>, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, ) -> Result { // The caller's wallet-level lock keeps the candidate history stable while we await its @@ -2902,11 +2861,11 @@ impl Wallet { // 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 pending_payment = stores.pending_payment(&payment_id).await?; let mut outcome = FundingStatusUpdate::NotFunding; let mut handled = None; - self.payment_store - .mutate(&payment_id, |existing| { + stores + .mutate_payment(&payment_id, |existing| { let payment = existing?; let (current_txid, tx_type) = match &payment.kind { PaymentKind::Onchain { @@ -2966,7 +2925,7 @@ impl Wallet { // list leaves any stored conflicts intact (the update treats absent as "unchanged"). if payment.status == PaymentStatus::Pending { let pending = self.create_pending_payment_from_tx(payment, Vec::new()); - self.pending_payment_store.insert_or_update(pending).await?; + stores.insert_or_update_pending_payment(pending).await?; } Ok(FundingStatusUpdate::Applied) } @@ -2975,7 +2934,7 @@ impl Wallet { pub(crate) async fn bump_fee_rbf( &self, payment_id: PaymentId, fee_rate: Option, cur_anchor_reserve_sats: u64, ) -> Result { - let payment = self.payment_store.get(&payment_id).await?.ok_or_else(|| { + let payment = self.payment_stores.payment(&payment_id).await?.ok_or_else(|| { log_error!(self.logger, "Payment {} not found in payment store", payment_id); Error::InvalidPaymentId })?; @@ -3218,8 +3177,10 @@ impl Wallet { Error::PersistenceFailed })?; - self.payment_store.insert_or_update(new_payment).await?; - self.pending_payment_store.insert_or_update(pending_payment_store).await?; + // Taken after the persister, the order wallet sync takes the two locks in. + let stores = self.payment_stores.lock().await; + stores.insert_or_update_payment(new_payment).await?; + stores.insert_or_update_pending_payment(pending_payment_store).await?; self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx); @@ -5063,7 +5024,7 @@ mod tests { wallet.record_signed_funding(&tx, &candidates).await.unwrap(); let id = PaymentId(prior_txid.to_byte_array()); - let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1); let payment = &payments[0]; assert_eq!(payment.id, id); @@ -5084,7 +5045,8 @@ mod tests { }, kind => panic!("unexpected kind {:?}", kind), } - let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); assert_eq!( record.candidates.iter().map(|c| c.txid).collect::>(), vec![prior_txid, txid] @@ -5119,14 +5081,17 @@ mod tests { ); 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"); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + let record = + wallet.payment_stores.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!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(payment)); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); assert_eq!( record.candidates.iter().map(|c| c.txid).collect::>(), vec![prior_txid, txid] @@ -5151,8 +5116,10 @@ mod tests { 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"); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); assert!(record.candidate(txid).unwrap().awaiting_broadcast); fail_store.fail_writes.store(true, Ordering::Release); @@ -5163,8 +5130,11 @@ mod tests { 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)); + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(payment)); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(record) + ); } /// A replayed `SpliceNegotiated` event names a round already marked broadcast; nothing is @@ -5205,7 +5175,14 @@ mod tests { 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()); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); } /// A replayed signing event re-offers a transaction already recorded; nothing is written. @@ -5251,8 +5228,22 @@ mod tests { 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()); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .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 @@ -5268,8 +5259,22 @@ mod tests { 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()); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .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 @@ -5300,8 +5305,22 @@ mod tests { ); 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()); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .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 @@ -5333,14 +5352,15 @@ mod tests { wallet.record_signed_funding(&next_tx, &next_candidates).await.unwrap(); let id = PaymentId(prior_txid.to_byte_array()); - let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + let payments = wallet.payment_stores.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"); + let record = + wallet.payment_stores.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] @@ -5376,11 +5396,11 @@ mod tests { 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!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.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!(wallet.payment_stores.payment_store().get(&other_id).await.unwrap().is_some()); assert_eq!(wallet.find_payment_by_txid(other_txid).await.unwrap(), Some(other_id)); } @@ -5410,14 +5430,17 @@ mod tests { &[(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"); + let payment = + wallet.payment_stores.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"); + let record = + wallet.payment_stores.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"); + let payment = + wallet.payment_stores.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" @@ -5451,9 +5474,11 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + let record = + wallet.payment_stores.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"); + let payment = + wallet.payment_stores.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); } @@ -5490,9 +5515,11 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + let record = + wallet.payment_stores.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"); + let payment = + wallet.payment_stores.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)); @@ -5518,12 +5545,12 @@ mod tests { ); 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()); + assert!(wallet.payment_stores.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!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); } @@ -5563,7 +5590,8 @@ mod tests { old_block_time: None, }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); assert!(matches!( payment.kind, PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } @@ -5572,8 +5600,12 @@ mod tests { 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!( + wallet.payment_stores.payment_store().get(&id).await.unwrap(), + Some(payment.clone()) + ); + let record = + wallet.payment_stores.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); @@ -5601,13 +5633,18 @@ mod tests { let bump_txid = bump_tx.compute_txid(); let mut moved_on = PaymentDetailsUpdate::new(id); moved_on.txid = Some(bump_txid); - wallet.payment_store.update(moved_on).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + wallet.payment_stores.payment_store().update(moved_on).await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(payment.clone())); - let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!( + wallet.payment_stores.payment_store().get(&id).await.unwrap(), + Some(payment.clone()) + ); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); assert!(record.candidates.is_empty()); assert_eq!(record.details, payment); } @@ -5627,12 +5664,12 @@ mod tests { 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.payment_stores.payment_store().remove(&id).await.unwrap(); + assert!(wallet.payment_stores.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!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); } @@ -5670,17 +5707,20 @@ mod tests { 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"); + wallet.payment_stores.payment_store().update(update).await.unwrap(); + let entry = + wallet.payment_stores.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"); + let entry = + wallet.payment_stores.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"); + let payment = + wallet.payment_stores.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); @@ -5700,7 +5740,7 @@ mod tests { 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(); + wallet.payment_stores.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(); @@ -5717,13 +5757,16 @@ mod tests { PaymentDirection::Inbound, PaymentStatus::Pending, ); - wallet.payment_store.insert_or_update(untyped.clone()).await.unwrap(); + wallet.payment_stores.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)); + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(graduated)); + assert_eq!( + wallet.payment_stores.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 @@ -5829,14 +5872,26 @@ mod tests { .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!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.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()); + assert!(wallet.payment_stores.payment_store().get(&other_id).await.unwrap().is_some()); + assert!(wallet + .payment_stores + .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()); + assert!(wallet.payment_stores.payment_store().get(&closed_id).await.unwrap().is_some()); + assert!(wallet + .payment_stores + .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 @@ -5869,14 +5924,16 @@ mod tests { let mut update = PaymentDetailsUpdate::new(id); update.status = Some(PaymentStatus::Succeeded); - wallet.payment_store.update(update).await.unwrap(); + wallet.payment_stores.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"); + let payment = + wallet.payment_stores.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"); + let entry = + wallet.payment_stores.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); } @@ -5895,17 +5952,17 @@ mod tests { 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.payment_stores.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()); + assert!(wallet.payment_stores.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()); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); } /// Recording a first splice round costs one read of the payment store: the write pair reads @@ -5949,14 +6006,16 @@ mod tests { fail_store.fail_writes.store(true, Ordering::Release); assert!(wallet.record_signed_funding(&tx, &candidates).await.is_err()); assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 1); - assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.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"); + let payment = + wallet.payment_stores.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"); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); } @@ -5979,7 +6038,7 @@ mod tests { ); 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 prior = wallet.payment_stores.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(); @@ -5992,8 +6051,9 @@ mod tests { 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!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(prior)); + let record = + wallet.payment_stores.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); } @@ -6187,21 +6247,22 @@ mod tests { interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); recorded.kind = PaymentKind::Onchain { txid, status: confirmed, tx_type: tx_type.clone() }; recorded.latest_update_timestamp = 0; - wallet.payment_store.insert_or_update(recorded).await.unwrap(); + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); // The pending entry embeds a stale snapshot: wallet-derived figures recorded before the // classification above landed. let mut stale = interactive_funding_details(payment_id, txid, Some(0), Some(0)); stale.kind = PaymentKind::Onchain { txid, status: confirmed, tx_type }; let entry = PendingPaymentDetails::new(stale, Vec::new(), Vec::new()); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); 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(10) }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.status, PaymentStatus::Succeeded); assert_eq!( payment.amount_msat, @@ -6210,7 +6271,13 @@ mod tests { ); assert_eq!(payment.fee_paid_msat, Some(999)); assert!(payment.latest_update_timestamp > 0, "the graduation write must timestamp"); - assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); } /// When the live record has diverged from the pending-store snapshot — here the snapshot @@ -6233,7 +6300,7 @@ mod tests { // The live record is Unconfirmed... let recorded = interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); - wallet.payment_store.insert_or_update(recorded).await.unwrap(); + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); // ...while the pending entry's snapshot claims a graduation-deep confirmation. let mut snapshot = @@ -6244,14 +6311,15 @@ mod tests { tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), }; let entry = PendingPaymentDetails::new(snapshot, Vec::new(), Vec::new()); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); 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(10) }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!( payment.status, PaymentStatus::Pending, @@ -6262,7 +6330,7 @@ mod tests { PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } )); assert!( - wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some(), + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), "the entry must survive for future events to drive" ); } @@ -6301,7 +6369,7 @@ mod tests { ]; let details = interactive_funding_details(payment_id, txid3, Some(1_000_000), Some(700)); let entry = PendingPaymentDetails::new(details, Vec::new(), candidates); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); // The first candidate resolves via the txid-derived id and the active candidate via the // record's current txid; the middle one must resolve through the candidate history. @@ -6333,14 +6401,20 @@ mod tests { PaymentDirection::Outbound, PaymentStatus::Pending, ); - wallet.payment_store.insert_or_update(details.clone()).await.unwrap(); + wallet.payment_stores.payment_store().insert_or_update(details.clone()).await.unwrap(); let entry = PendingPaymentDetails::new(details, vec![conflicting_txid], Vec::new()); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); wallet.remove_payment(&payment_id).await.unwrap(); - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none()); - assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); assert_eq!(wallet.find_payment_by_txid(conflicting_txid).await.unwrap(), None); @@ -6352,7 +6426,7 @@ mod tests { conflicts: vec![(0, conflicting_txid)], }; wallet.update_payment_store(vec![event]).await.unwrap(); - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none()); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); } /// Payments without a pending-store entry — lightning payments, and on-chain payments that @@ -6377,10 +6451,10 @@ mod tests { PaymentDirection::Outbound, PaymentStatus::Succeeded, ); - wallet.payment_store.insert_or_update(details).await.unwrap(); + wallet.payment_stores.payment_store().insert_or_update(details).await.unwrap(); wallet.remove_payment(&payment_id).await.unwrap(); - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none()); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); // Removing an id known to neither store is also a no-op rather than an error. wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap(); @@ -6434,7 +6508,8 @@ mod tests { // Sync saw the close double-spend the splice's funding transaction. wallet - .pending_payment_store + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -6452,7 +6527,8 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let funding = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let funding = + wallet.payment_stores.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"); @@ -6465,7 +6541,8 @@ mod tests { assert_eq!(funding.fee_paid_msat, Some(500)); let close = wallet - .payment_store + .payment_stores + .payment_store() .get(&PaymentId(close_txid.to_byte_array())) .await .unwrap() @@ -6555,10 +6632,20 @@ mod tests { PaymentDirection::Inbound, PaymentStatus::Pending, ); - wallet.payment_store.insert_or_update(close_details.clone()).await.unwrap(); + wallet + .payment_stores + .payment_store() + .insert_or_update(close_details.clone()) + .await + .unwrap(); let entry = PendingPaymentDetails::new(close_details, vec![splice_txid], Vec::new()); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(entry) + .await + .unwrap(); } assert_eq!( @@ -6575,7 +6662,8 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let funding = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let funding = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); match &funding.kind { PaymentKind::Onchain { txid, status, tx_type } => { assert_eq!(*txid, splice_txid); @@ -6589,7 +6677,8 @@ mod tests { for close_txid in &close_txids { let close = wallet - .payment_store + .payment_stores + .payment_store() .get(&PaymentId(close_txid.to_byte_array())) .await .unwrap() @@ -6635,7 +6724,8 @@ mod tests { 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 + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -6657,7 +6747,8 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.status, PaymentStatus::Failed); match &payment.kind { PaymentKind::Onchain { txid, status, tx_type } => { @@ -6670,7 +6761,7 @@ mod tests { 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(), + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), "the entry must go so the dead transaction stops being rebroadcast" ); } @@ -6706,7 +6797,8 @@ mod tests { 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 + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -6726,10 +6818,11 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.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(), + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), "the entry must survive for classification to adopt the confirmed candidate" ); } @@ -6757,7 +6850,8 @@ mod tests { 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 + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -6777,9 +6871,16 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.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()); + assert!(wallet + .payment_stores + .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 @@ -6816,7 +6917,8 @@ mod tests { 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 + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -6837,10 +6939,11 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.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(), + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), "a candidate can still confirm, so the record must stay pending" ); } @@ -6862,7 +6965,7 @@ mod tests { 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(); + wallet.payment_stores.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 = @@ -6874,7 +6977,7 @@ mod tests { awaiting_broadcast: false, }]; let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); insert_confirmed_tx(&wallet, close_tx, 5); @@ -6886,11 +6989,12 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.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(), + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), "the lingering entry must be removed" ); } @@ -6914,7 +7018,7 @@ mod tests { 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(); + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); let snapshot = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); @@ -6925,7 +7029,7 @@ mod tests { awaiting_broadcast: false, }]; let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); insert_confirmed_tx(&wallet, close_tx, 5); @@ -6944,11 +7048,12 @@ mod tests { ]; wallet.update_payment_store(events).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.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(), + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), "the replay must finish the interrupted entry removal" ); } @@ -6971,7 +7076,7 @@ mod tests { let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; let payment_id = PaymentId(tx.compute_txid().to_byte_array()); - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_some()); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); assert_eq!(reads, 1, "classifying an unknown funding re-read the payment store"); } @@ -6992,7 +7097,7 @@ mod tests { let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; let payment_id = PaymentId(txid.to_byte_array()); - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_some()); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); assert_eq!(reads, 2, "recording an unknown transaction re-read the payment store"); } @@ -7014,18 +7119,25 @@ mod tests { 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(); + wallet.payment_stores.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(); + let payment = + wallet.payment_stores.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()); + assert!(wallet + .payment_stores + .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 { @@ -7036,11 +7148,18 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.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()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); } /// The same collision through a conflict list: a pending entry naming a settled funding @@ -7058,15 +7177,16 @@ mod tests { let mut settled = interactive_funding_details(settled_id, r1, Some(1_000_000), Some(600)); settled.status = PaymentStatus::Failed; settled.latest_update_timestamp = 7; - wallet.payment_store.insert_or_update(settled).await.unwrap(); + wallet.payment_stores.payment_store().insert_or_update(settled).await.unwrap(); // A live funding record whose entry lists r1 as a conflict of its round r2. let r2 = Txid::from_byte_array([4u8; 32]); let live_id = PaymentId(r2.to_byte_array()); let live = interactive_funding_details(live_id, r2, Some(2_000_000), Some(700)); - wallet.payment_store.insert_or_update(live.clone()).await.unwrap(); + wallet.payment_stores.payment_store().insert_or_update(live.clone()).await.unwrap(); wallet - .pending_payment_store + .payment_stores + .pending_payment_store() .insert_or_update(PendingPaymentDetails::new(live.clone(), vec![r1], Vec::new())) .await .unwrap(); @@ -7076,11 +7196,18 @@ mod tests { WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&settled_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&settled_id).await.unwrap().unwrap(); assert_eq!(payment.status, PaymentStatus::Failed, "the settled record must not resurrect"); assert_eq!(payment.latest_update_timestamp, 7); - assert!(wallet.pending_payment_store.get(&settled_id).await.unwrap().is_none()); - assert_eq!(wallet.payment_store.get(&live_id).await.unwrap(), Some(live)); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&settled_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.payment_stores.payment_store().get(&live_id).await.unwrap(), Some(live)); } /// The failure transition must apply regardless of the payment's direction: a splice-out @@ -7107,7 +7234,8 @@ mod tests { details.direction = PaymentDirection::Inbound; wallet.persist_funding_payment(details, candidates).await.unwrap(); wallet - .pending_payment_store + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -7127,9 +7255,16 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.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()); + assert!(wallet + .payment_stores + .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. @@ -7151,8 +7286,20 @@ mod tests { // No inputs or outputs involve the wallet: nothing to record. wallet.classify_funding(&dummy_tx(), &channels, tx_type.clone()).await.unwrap(); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); - assert!(wallet.pending_payment_store.list_filter(|_| true).await.is_empty()); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_filter(|_| true) + .await + .is_empty()); // A computable fee is not wallet participation. The wallet can resolve a splice's shared // input whenever the previous funding transaction touched it (e.g. it funded the original @@ -7175,7 +7322,14 @@ mod tests { }], }; wallet.classify_funding(&splice_tx, &channels, tx_type.clone()).await.unwrap(); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); // Control: a funding transaction the wallet participates in is still recorded. let script_pubkey = wallet @@ -7192,7 +7346,7 @@ mod tests { output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], }; wallet.classify_funding(&funded_tx, &channels, tx_type).await.unwrap(); - let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1); assert_eq!(payments[0].id, PaymentId(funded_tx.compute_txid().to_byte_array())); } @@ -7243,7 +7397,8 @@ mod tests { let tx_type = TransactionType::Funding { channels: vec![] }; async fn assert_unchanged(wallet: &Wallet, payment_id: PaymentId, confirmed: bool) { - let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + let payments = + wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1, "the rebroadcast must not mint a second record"); let payment = &payments[0]; assert_eq!(payment.id, payment_id); @@ -7340,14 +7495,21 @@ mod tests { } } assert!(failed_writes > 0, "classification never attempted a payment-store write"); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); + assert!(wallet + .payment_stores + .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; + recorded = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; if !recorded.is_empty() { break; } @@ -7439,7 +7601,7 @@ mod tests { let mut payments = Vec::new(); for _ in 0..100 { tokio::time::sleep(Duration::from_millis(100)).await; - payments = wallet.payment_store.list_page(None).await.unwrap().objects; + payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; if !payments.is_empty() { break; } @@ -7525,7 +7687,7 @@ mod tests { let mut payments = Vec::new(); for _ in 0..100 { tokio::time::sleep(Duration::from_millis(100)).await; - payments = wallet.payment_store.list_page(None).await.unwrap().objects; + payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; if !payments.is_empty() { break; } @@ -7729,7 +7891,8 @@ mod tests { .await .unwrap(); assert_eq!(payment_keys.len(), 1, "the confirmation must not mint a duplicate record"); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.id, payment_id); assert_eq!(payment.amount_msat, Some(2_000_000)); assert_eq!(payment.fee_paid_msat, Some(999)); @@ -7800,7 +7963,8 @@ mod tests { .await .unwrap(); assert_eq!(payment_keys.len(), 1); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.id, payment_id); assert_eq!( payment.amount_msat, @@ -7917,14 +8081,20 @@ mod tests { wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .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()); + assert!(wallet.payment_stores.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 @@ -7943,9 +8113,21 @@ mod tests { 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"); + let payment = wallet + .payment_stores + .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"); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); assert_eq!(entry.candidates.len(), 2); assert_eq!(entry.locked_rounds, vec![txid]); } @@ -7976,14 +8158,20 @@ mod tests { .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .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()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// Wallet sync moved the record onto the counterparty's round before LDK promoted it, so the @@ -8005,7 +8193,7 @@ mod tests { old_block_time: None, }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().unwrap(); + let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().unwrap(); assert!( matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) ); @@ -8019,14 +8207,20 @@ mod tests { .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Failed); assert!(matches!( payment.kind, PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } if txid == counterparty_txid )); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// The same at a close whose monitor holds the counterparty's round the record moved onto: @@ -8046,7 +8240,7 @@ mod tests { old_block_time: None, }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().unwrap(); + let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().unwrap(); assert!( matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) ); @@ -8056,14 +8250,20 @@ mod tests { .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Failed); assert!(matches!( payment.kind, PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } if txid == counterparty_txid )); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// A round of ours nothing had broadcast when the counterparty's round locked — our @@ -8084,7 +8284,10 @@ mod tests { ); 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"); + assert!( + wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some(), + "the round was recorded" + ); wallet .resolve_promoted_splice_round( @@ -8095,8 +8298,8 @@ mod tests { .await .unwrap(); - assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// Failing the payment writes the record before it removes the entry; a replay after the @@ -8112,7 +8315,8 @@ mod tests { let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; wallet - .payment_store + .payment_stores + .payment_store() .mutate(&id, |existing| { let mut update = PaymentDetailsUpdate::new(id); update.status = Some(PaymentStatus::Failed); @@ -8121,7 +8325,7 @@ mod tests { }) .await .unwrap(); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); wallet .resolve_promoted_splice_round( @@ -8132,9 +8336,15 @@ mod tests { .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .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()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// A promotion reported for a channel the manager no longer lists — the channel closed before @@ -8154,18 +8364,36 @@ mod tests { 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"); + let payment = wallet + .payment_stores + .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"); + let entry = wallet + .payment_stores + .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"); + let payment = wallet + .payment_stores + .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()); + assert!(wallet.payment_stores.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 @@ -8195,18 +8423,41 @@ mod tests { .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"); + let payment = wallet + .payment_stores + .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"); + let entry = wallet + .payment_stores + .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"); + let payment = wallet + .payment_stores + .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()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .is_some()); } } @@ -8223,7 +8474,13 @@ mod tests { 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 payment = wallet + .payment_stores + .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, @@ -8231,7 +8488,13 @@ mod tests { &[(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"); + let payment = wallet + .payment_stores + .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); @@ -8240,11 +8503,23 @@ mod tests { .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .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"); + let entry = wallet + .payment_stores + .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]); } @@ -8270,16 +8545,31 @@ mod tests { .await .unwrap(); } - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + let entry = wallet + .payment_stores + .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"); + let payment = wallet + .payment_stores + .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"); + assert!( + wallet.payment_stores.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 @@ -8303,9 +8593,21 @@ mod tests { .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"); + let payment = wallet + .payment_stores + .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"); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); assert!(entry.candidate(txid).is_some_and(|round| round.awaiting_broadcast)); } @@ -8345,14 +8647,32 @@ mod tests { 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"); + let payment = wallet + .payment_stores + .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"); + let entry = wallet + .payment_stores + .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"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Failed); assert!(matches!( payment.kind, @@ -8362,7 +8682,7 @@ mod tests { tx_type: Some(TransactionType::InteractiveFunding { .. }), } if txid == bump_txid )); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.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: @@ -8382,9 +8702,21 @@ mod tests { .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"); + let payment = wallet + .payment_stores + .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"); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); assert_eq!(entry.candidates.len(), 2); } @@ -8405,7 +8737,8 @@ mod tests { timestamp: 1_700_000_000, }; wallet - .payment_store + .payment_stores + .payment_store() .mutate(&id, |existing| { let mut updated = existing?.clone(); if let PaymentKind::Onchain { status, .. } = &mut updated.kind { @@ -8417,13 +8750,19 @@ mod tests { .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"); + let payment = wallet + .payment_stores + .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()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); } /// Failing the payment writes the record before it removes the entry; the close replayed after @@ -8437,7 +8776,8 @@ mod tests { let txid = tx.compute_txid(); let id = record_broadcast_rounds(&wallet, &tx, &[(txid, Some(contribution))]).await; wallet - .payment_store + .payment_stores + .payment_store() .mutate(&id, |existing| { let mut update = PaymentDetailsUpdate::new(id); update.status = Some(PaymentStatus::Failed); @@ -8447,9 +8787,15 @@ mod tests { .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"); + let payment = wallet + .payment_stores + .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()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// The close resolves every record of the channel — two splices signed under different @@ -8475,9 +8821,21 @@ mod tests { 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"); + let payment = wallet + .payment_stores + .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()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .is_none()); } } } diff --git a/src/wallet/payment_stores.rs b/src/wallet/payment_stores.rs new file mode 100644 index 0000000000..5da2c64a85 --- /dev/null +++ b/src/wallet/payment_stores.rs @@ -0,0 +1,170 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! The wallet's payment stores behind one API, so that every write the wallet makes to them +//! happens under the lock that keeps a payment record and its pending-store entry consistent. + +use std::future::Future; +use std::ops::Deref; +use std::sync::Arc; + +use lightning::ln::channelmanager::PaymentId; + +use crate::payment::{PaymentDetails, PendingPaymentDetails}; +use crate::types::{PaymentStore, PendingPaymentStore}; +use crate::Error; + +/// The wallet's payment store and pending payment store, with the lock serializing their writers. +/// +/// The writers must observe the payment record and its pending-store entry (candidate history +/// included) as one consistent unit: classification writes both stores for one payment, and +/// wallet sync's event arms decide from payment-id resolution through their last write. Without +/// the lock, a confirmation landing between classification's two writes sees the record +/// classified but the candidate history absent — resolving the wrong payment id or stamping the +/// confirmed candidate with another candidate's figures — and a classification landing inside an +/// arm's decision sequence gets overwritten by the arm's stale generic fallback. +/// +/// The writes are methods of [`PaymentStoresGuard`], which only [`Self::lock`] hands out, so a +/// write compiles only for a holder of the lock. The reads are methods of this type and take no +/// lock; a caller whose write depends on what it read takes the lock first and reads through the +/// guard. +pub(super) struct PaymentStores { + payment_store: Arc, + pending_payment_store: Arc, + update_lock: tokio::sync::Mutex<()>, +} + +/// Exclusive access to the writers of a [`PaymentStores`], held by the holder of its lock and by +/// no one else. It dereferences to the stores, so their reads are available under the lock too. +#[must_use = "dropping the guard releases the lock at once"] +pub(super) struct PaymentStoresGuard<'a> { + stores: &'a PaymentStores, + _guard: tokio::sync::MutexGuard<'a, ()>, +} + +impl PaymentStores { + pub(super) fn new( + payment_store: Arc, pending_payment_store: Arc, + ) -> Self { + Self { payment_store, pending_payment_store, update_lock: tokio::sync::Mutex::new(()) } + } + + /// Takes the lock for as long as the returned guard lives. + pub(super) async fn lock(&self) -> PaymentStoresGuard<'_> { + PaymentStoresGuard { stores: self, _guard: self.update_lock.lock().await } + } + + /// The payment record stored under `id`, if any. + pub(super) async fn payment(&self, id: &PaymentId) -> Result, Error> { + self.payment_store.get(id).await + } + + /// The pending-store entry stored under `id`, if any. + pub(super) async fn pending_payment( + &self, id: &PaymentId, + ) -> Result, Error> { + self.pending_payment_store.get(id).await + } + + /// Whether the pending store has an entry under `id`. + pub(super) async fn has_pending_payment(&self, id: &PaymentId) -> Result { + self.pending_payment_store.contains_key(id).await + } + + /// The pending-store entries matching `f`. + pub(super) async fn pending_payments bool>( + &self, f: F, + ) -> Vec { + self.pending_payment_store.list_filter(f).await + } +} + +#[cfg(test)] +impl PaymentStores { + /// The payment store itself, for tests to set up and inspect records around the wallet's API. + pub(super) fn payment_store(&self) -> &PaymentStore { + &self.payment_store + } + + /// The pending payment store itself, for tests to set up and inspect entries around the + /// wallet's API. + pub(super) fn pending_payment_store(&self) -> &PendingPaymentStore { + &self.pending_payment_store + } +} + +impl Deref for PaymentStoresGuard<'_> { + type Target = PaymentStores; + + fn deref(&self) -> &Self::Target { + self.stores + } +} + +impl PaymentStoresGuard<'_> { + /// Stores `details`, merging its update into the record already stored under its id, if any. + /// Returns whether anything was written. + pub(super) async fn insert_or_update_payment( + &self, details: PaymentDetails, + ) -> Result { + self.stores.payment_store.insert_or_update(details).await + } + + /// Removes the payment record stored under `id`, if any. + pub(super) async fn remove_payment(&self, id: &PaymentId) -> Result<(), Error> { + self.stores.payment_store.remove(id).await + } + + /// Transforms the payment record stored under `id` through `f` and persists the result, all + /// in one critical section of the store; see + /// [`DataStore::mutate`](crate::data_store::DataStore::mutate). + pub(super) async fn mutate_payment( + &self, id: &PaymentId, f: F, + ) -> Result, Error> + where + F: FnOnce(Option<&PaymentDetails>) -> Option, + { + self.stores.payment_store.mutate(id, f).await + } + + /// Stores `entry`, merging its update into the entry already stored under its id, if any. + /// Returns whether anything was written. + pub(super) async fn insert_or_update_pending_payment( + &self, entry: PendingPaymentDetails, + ) -> Result { + self.stores.pending_payment_store.insert_or_update(entry).await + } + + /// Removes the pending-store entry stored under `id`, if any. + pub(super) async fn remove_pending_payment(&self, id: &PaymentId) -> Result<(), Error> { + self.stores.pending_payment_store.remove(id).await + } + + /// Transforms the pending-store entry stored under `id` through `f` and persists the result, + /// all in one critical section of the store; see + /// [`DataStore::mutate`](crate::data_store::DataStore::mutate). + pub(super) async fn mutate_pending_payment( + &self, id: &PaymentId, f: F, + ) -> Result, Error> + where + F: FnOnce(Option<&PendingPaymentDetails>) -> Option, + { + self.stores.pending_payment_store.mutate(id, f).await + } + + /// [`Self::mutate_pending_payment`] with a transformation that awaits fallible reads; see + /// [`DataStore::mutate_async`](crate::data_store::DataStore::mutate_async). + pub(super) async fn mutate_pending_payment_async( + &self, id: &PaymentId, f: F, + ) -> Result, Error> + where + F: FnOnce(Option) -> Fut, + Fut: Future, Error>>, + { + self.stores.pending_payment_store.mutate_async(id, f).await + } +} From dc6b42a566a2e4fbfc934e4c598df23f7a228dc7 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 18 Aug 2026 14:39:41 -0500 Subject: [PATCH 13/49] DROP ME: Assign random PaymentIds to funding records Funding records were keyed by a PaymentId derived from a funding txid: the broadcast txid in the generic classification path, the first negotiated candidate's txid in the interactive path. A txid is no identity for a replaceable transaction -- the record deliberately outlives RBF rounds of its funding, so its key carried the txid of whichever round happened to come first, and code could be tempted to re-derive the id from a txid instead of resolving it. Generate the id from the OS entropy source when the record is created, and resolve existing records through their transaction history (find_payment_by_txid) everywhere. RBF stability now comes from resolution instead of derivation. Resolution must share one lock acquisition with the record writes: resolved outside it, the id could go stale against a record wallet sync creates for the same transaction, producing a divergent record -- so both the classification path and the interactive path resolve the id under the lock they write under. Resolution also reaches records that have graduated out of the pending store. Without that, a funding classified again after graduation -- LDK re-broadcasting a 0conf splice whose confirmation landed while the node was offline -- would get a duplicate record under a fresh id, and a reorg after graduation would never reach the record. A record already failed is passed over when a newly signed round resolves its id. Wallet sync fails a funding payment whose round lost to a conflicting spend confirmed while the channel stays open, but LDK still holds the round, so a fee bump of it is signed with the failed round among its candidates. Filed under the failed record, the bump would stay failed and untracked, so nothing would graduate it once it confirmed. The bump gets a record of its own instead. The funding-record surface (classification, candidates, stable ids) debuts in the upcoming release -- v0.7.0 shipped splice_in with no record machinery -- so changing the scheme now costs nothing, while one release later it would break payment(&PaymentId(funding_txid)) lookups for new records. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 (cherry picked from commit ece60704a39d20c874e3e1d472066ec56fa20926) --- src/wallet/mod.rs | 454 ++++++++++++++++++++++++++------ src/wallet/payment_stores.rs | 22 +- tests/integration_tests_rust.rs | 45 ++-- 3 files changed, 417 insertions(+), 104 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3f79c1aebe..25b2b161ca 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2074,7 +2074,16 @@ impl Wallet { return Ok(()); } - let payment_id = PaymentId(txid.to_byte_array()); + // Resolution and the writes below must share one lock acquisition: resolved outside it, + // the id could go stale against a record wallet sync creates for the same transaction, + // and the write below would create a divergent record. + let stores = self.payment_stores.lock().await; + + // Adopt the id of a record that already tracks this transaction — e.g. a 0conf splice + // re-broadcast through LDK's generic funding path resolves back to its + // interactive-funding record here — otherwise generate a fresh id. + let payment_id = self.find_payment_by_txid(txid).await?.unwrap_or_else(random_payment_id); + let details = PaymentDetails::new( payment_id, PaymentKind::Onchain { @@ -2087,7 +2096,7 @@ impl Wallet { direction, PaymentStatus::Pending, ); - let prior = self.persist_funding_payment(details, Vec::new()).await?; + let prior = self.persist_funding_payment_locked(&stores, details, Vec::new()).await?; // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed // and carrying wallet-view figures; `funding_reclassification_update` declines the // downgrade, leaving no trace that a re-broadcast arrived. Log the arrival so tests can @@ -2116,14 +2125,42 @@ impl Wallet { Ok(()) } - /// Builds the payment record and the per-candidate figures for recording the `active` round - /// of an interactive funding whose negotiated history is `candidates`. Returns `None` when - /// there is nothing to record: no local contribution to the round, or no wallet-level activity. + /// Resolves the id under which the interactive funding with negotiated history `candidates` is + /// recorded: that of a record already tracking any of its rounds (wallet sync may record a + /// round before this node does), else a fresh one. A record already failed is passed over: + /// wallet sync fails a payment whose round lost to a conflicting spend confirmed while the + /// channel stays open, LDK still holds the round and a fee bump of it is signed with the round + /// among its candidates, and nothing revisits a failed record's status, so the bump filed under + /// it would go untracked. An id derived from a txid would tie the record's identity to one + /// round of a replaceable transaction — resolution through the record's txid history is what + /// keeps its identity stable across RBF replacements. The caller holds the cross-store lock: + /// resolved outside it, the id could go stale against a record wallet sync creates for the same + /// transaction before the caller's write. + async fn resolve_interactive_funding_id( + &self, stores: &PaymentStoresGuard<'_>, candidates: &[FundingCandidate], + ) -> Result { + for candidate in candidates.iter() { + if let Some(id) = self.find_payment_by_txid(candidate.txid).await? { + let failed = stores + .payment(&id) + .await? + .is_some_and(|payment| payment.status == PaymentStatus::Failed); + if !failed { + return Ok(id); + } + } + } + Ok(random_payment_id()) + } + + /// Builds the payment record, under the resolved `payment_id`, and the per-candidate figures + /// for recording the `active` round of an interactive funding whose negotiated history is + /// `candidates`. Returns `None` when there is nothing to record: no local contribution to the + /// round, or no wallet-level activity. fn interactive_funding_record( - &self, candidates: &[FundingCandidate], active: &FundingCandidate, tx: &Transaction, - tx_type: TransactionType, + &self, payment_id: PaymentId, candidates: &[FundingCandidate], active: &FundingCandidate, + tx: &Transaction, tx_type: TransactionType, ) -> Option<(PaymentDetails, Vec)> { - let first = candidates.first()?; let txid = active.txid; let aggregate = aggregate_local_stakes(active); @@ -2155,10 +2192,6 @@ impl Wallet { return None; } - // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable - // across RBF replacements. - let payment_id = PaymentId(first.txid.to_byte_array()); - // Record every candidate's figures (`None` for any round we didn't contribute to, e.g. a // counterparty-initiated splice our `splice_in` later joined via RBF) so the confirmed // candidate's amount/fee can be applied on confirmation, even if it isn't the last one @@ -2198,11 +2231,12 @@ impl Wallet { /// as broadcast ([`Self::record_broadcast_splice_round`]). /// /// `candidates` is the channel's pending splice history as [`funding_candidates`] lists it from - /// the channel's [`SpliceDetails`], so the record is written in full, under the first - /// candidate's txid as id. The signed round is marked as awaiting broadcast until LDK reports - /// the splice negotiated and [`Self::record_broadcast_splice_round`] clears the mark: only such - /// a round can be abandoned without a trace, and [`Self::drop_abandoned_splice_rounds`] takes - /// it back once LDK no longer holds it. + /// the channel's [`SpliceDetails`], so the record is written in full, under the id + /// [`Self::resolve_interactive_funding_id`] resolves (that of a record already tracking any + /// round of the history, else a fresh one). The signed round is marked as awaiting broadcast + /// until LDK reports the splice negotiated and [`Self::record_broadcast_splice_round`] clears + /// the mark: only such a round can be abandoned without a trace, and + /// [`Self::drop_abandoned_splice_rounds`] takes it back once LDK no longer holds it. /// /// Nothing is recorded for a round missing from the history (reset between the event's /// emission and its handling, so LDK will refuse the signed transaction), already recorded (a @@ -2229,22 +2263,27 @@ impl Wallet { }; let tx_type = LdkTransactionType::InteractiveFunding { candidates: candidates.to_vec() }.into(); - let (details, mut history) = - match self.interactive_funding_record(candidates, signed_round, tx, tx_type) { - Some(record) => record, - None => return Ok(()), - }; - let payment_id = details.id; + + // Resolution, the reads and the writes below must share one lock acquisition, as in every + // funding-record write: done outside it, the record could change under us before the write. + let stores = self.payment_stores.lock().await; + let payment_id = self.resolve_interactive_funding_id(&stores, candidates).await?; + let (details, mut history) = match self.interactive_funding_record( + payment_id, + candidates, + signed_round, + tx, + tx_type, + ) { + Some(record) => record, + None => return Ok(()), + }; // Only the signed round awaits broadcast: LDK broadcast the others once their signatures // were exchanged. if let Some(signed) = history.iter_mut().find(|candidate| candidate.txid == txid) { signed.awaiting_broadcast = true; } - // The reads and the write below must share one lock acquisition, as in every funding-record - // write: read outside it, the record could change under us before the write. - let stores = self.payment_stores.lock().await; - let prior_pending = stores.pending_payment(&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()) { @@ -2563,12 +2602,15 @@ impl Wallet { /// between the two stores and the rollback failed as well, leaving the payment record without /// the pending entry that indexes it. The replayed signing event, finding the round gone from /// the history, ends up here; a fully recorded round (its entry in place) is left to - /// [`Self::drop_abandoned_splice_rounds`]. Only a first round is recorded under its own txid: - /// the record of a bump lives under an earlier round's id and keeps its entry, and wallet sync - /// moves it on as that earlier round confirms or fails. + /// [`Self::drop_abandoned_splice_rounds`]. Only a first round can be left so: the record of a + /// bump keeps the entry of the rounds before it, and wallet sync moves it on as an earlier + /// round confirms or fails. async fn drop_unindexed_signing_record(&self, txid: Txid) -> Result<(), Error> { let stores = self.payment_stores.lock().await; - let payment_id = PaymentId(txid.to_byte_array()); + let payment_id = match self.find_payment_by_txid(txid).await? { + Some(id) => id, + None => return Ok(()), + }; if stores.pending_payment(&payment_id).await?.is_some() { return Ok(()); } @@ -2631,6 +2673,11 @@ impl Wallet { /// Writes a freshly-classified funding payment to the authoritative payment store and adds a /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. Returns the /// payment store's record as it was before the write. + /// + /// Production callers go through [`Self::persist_funding_payment_locked`] because they resolve + /// the record's id under the same lock acquisition; this wrapper models that acquisition for + /// tests writing a record mid-flow. + #[cfg(test)] async fn persist_funding_payment( &self, details: PaymentDetails, candidates: Vec, ) -> Result, Error> { @@ -2640,10 +2687,14 @@ impl Wallet { Ok(self.persist_funding_payment_locked(&stores, details, candidates).await?) } - /// [`Self::persist_funding_payment`] for a caller already holding the cross-store lock, whose - /// reads the write must not be separated from. Returns the payment store's record as it was - /// before the write, read inside the write's own critical section, so a caller needs no read of - /// its own to know what the write merged into. + /// Writes a freshly recorded funding payment to the authoritative payment store and adds a + /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. The + /// caller holds the cross-store lock, resolving the record's id and performing both store + /// writes under one acquisition, so a funding confirmation never observes the record written + /// but the candidate history it needs still missing, and the resolved id never goes stale + /// against a concurrent sync write. Returns the payment store's record as it was before the + /// write, read inside the write's own critical section, so a caller needs no read of its own to + /// know what the write merged into. async fn persist_funding_payment_locked( &self, stores: &PaymentStoresGuard<'_>, details: PaymentDetails, candidates: Vec, @@ -2836,6 +2887,25 @@ impl Wallet { return Ok(Some(entry.details.id)); } + // The pending store only indexes in-flight records — graduation removes the entry — so a + // graduated record's transaction resolves through the payment store itself. Without this, a + // funding-typed broadcast classified after graduation (e.g. LDK re-broadcasting a promoted + // 0conf splice whose confirmation landed while the node was offline) would create a + // duplicate record, and a post-graduation reorg's events would never reach the record. + let mut page_token = None; + loop { + let page = self.payment_stores.payments_page(page_token).await?; + if let Some(payment) = page.objects.iter().find( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), + ) { + return Ok(Some(payment.id)); + } + match page.next_page_token { + Some(token) => page_token = Some(token), + None => break, + } + } + Ok(None) } @@ -3352,6 +3422,15 @@ enum FundingPaymentFailure { MovedOn, } +/// Generates a fresh funding-record [`PaymentId`] from the OS entropy source. A funding record's id +/// carries no meaning beyond uniqueness: the record is found through its transaction history +/// ([`Wallet::find_payment_by_txid`]), never re-derived from a txid. +fn random_payment_id() -> PaymentId { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("getrandom failed"); + PaymentId(bytes) +} + /// The outcome of [`Wallet::apply_funding_status_update_locked`]. enum FundingStatusUpdate { /// The event's transaction belongs to the funding payment; its refreshed confirmation status @@ -3710,9 +3789,9 @@ impl From for Error { /// classification. /// /// `current` is the record as observed inside the payment store's `mutate` critical section — its -/// sole caller, [`Wallet::persist_funding_payment`], builds and applies the update within one -/// closure — so the candidate choice cannot go stale against a concurrent confirmation before the -/// update lands. [`PaymentDetails::update`]'s confirmed-figures rule still arbitrates which +/// sole caller, [`Wallet::persist_funding_payment_locked`], builds and applies the update within +/// one closure — so the candidate choice cannot go stale against a concurrent confirmation before +/// the update lands. [`PaymentDetails::update`]'s confirmed-figures rule still arbitrates which /// figures may land on the record. fn funding_reclassification_update( details: PaymentDetails, candidates: &[FundingTxCandidate], current: Option<&PaymentDetails>, @@ -4999,13 +5078,13 @@ mod tests { (tx, contribution) } - /// Signing a splice round records its funding payment under the first candidate's txid as id, - /// with the channel's full pending splice history, so a wallet sync that observes the - /// transaction before the broadcast (the counterparty may broadcast first) resolves to the - /// funding record instead of filing the round as a foreign duplicate. Only the signed round - /// awaits broadcast; LDK broadcast the negotiated predecessor already. + /// Signing a splice round records its funding payment with the channel's full pending splice + /// history, so a wallet sync that observes the transaction before the broadcast (the + /// counterparty may broadcast first) resolves to the funding record through any round of that + /// history instead of filing the round as a foreign duplicate. Only the signed round awaits + /// broadcast; LDK broadcast the negotiated predecessor already. #[tokio::test] - async fn signing_records_the_round_under_the_first_candidate_id() { + async fn signing_records_the_round_with_the_full_splice_history() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); @@ -5023,11 +5102,12 @@ mod tests { wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(prior_txid.to_byte_array()); let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1); let payment = &payments[0]; - assert_eq!(payment.id, id); + let id = payment.id; + assert_ne!(id, PaymentId(prior_txid.to_byte_array())); + assert_ne!(id, PaymentId(txid.to_byte_array())); assert_eq!(payment.amount_msat, Some(500_300_000)); assert_eq!(payment.fee_paid_msat, Some(300_000)); assert_eq!(payment.direction, PaymentDirection::Inbound); @@ -5062,6 +5142,81 @@ mod tests { assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); } + /// A fee bump of a round whose payment wallet sync failed — the round lost to a conflicting + /// spend confirmed while the channel stayed open, so LDK still holds it and offers the bump — + /// is signed with the failed round among its candidates. The failed record takes no round: + /// nothing revisits its status, so the bump would go untracked under it. The bump gets a + /// record of its own. + #[tokio::test] + async fn signing_a_bump_of_a_failed_round_gets_a_record_of_its_own() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_stores + .payment_store() + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + let bump_id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("a record"); + assert_ne!(bump_id, failed_id); + let payment = + wallet.payment_stores.payment_store().get(&bump_id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&bump_id) + .await + .unwrap() + .expect("entry"); + assert_eq!(entry.details, payment); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let failed = wallet + .payment_stores + .payment_store() + .get(&failed_id) + .await + .unwrap() + .expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&failed_id) + .await + .unwrap() + .is_none()); + } + /// Once LDK reports a round recorded at signing negotiated, there is nothing to add but the /// broadcast itself: the round's awaiting-broadcast mark is cleared and the record left as /// written. @@ -5080,7 +5235,7 @@ mod tests { &[(prior_txid, None), (txid, Some(contribution))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(prior_txid.to_byte_array()); + let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); let record = @@ -5115,7 +5270,7 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); let record = @@ -5351,10 +5506,10 @@ mod tests { ); wallet.record_signed_funding(&next_tx, &next_candidates).await.unwrap(); - let id = PaymentId(prior_txid.to_byte_array()); let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1); - assert_eq!(payments[0].id, id); + let id = payments[0].id; + assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); assert!( matches!(&payments[0].kind, PaymentKind::Onchain { txid: t, .. } if *t == next_txid) ); @@ -5392,14 +5547,14 @@ mod tests { &[(other_txid, Some(other_contribution))], ); wallet.record_signed_funding(&other_tx, &other_candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let other_id = wallet.find_payment_by_txid(other_txid).await.unwrap().expect("other id"); wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); assert!(wallet.payment_stores.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_stores.payment_store().get(&other_id).await.unwrap().is_some()); assert_eq!(wallet.find_payment_by_txid(other_txid).await.unwrap(), Some(other_id)); } @@ -5420,7 +5575,7 @@ mod tests { &[(txid, Some(contribution.clone()))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); @@ -5470,7 +5625,7 @@ mod tests { insert_unconfirmed_tx(&wallet, tx); evict_tx(&wallet, txid); assert!(wallet.inner.lock().unwrap().get_tx(txid).is_none(), "evicted: not canonical"); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); @@ -5502,7 +5657,7 @@ mod tests { ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); @@ -5544,7 +5699,7 @@ mod tests { &[(prior_txid, None), (txid, Some(contribution))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(prior_txid.to_byte_array()); + let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); @@ -5571,7 +5726,7 @@ mod tests { &[(txid, Some(contribution.clone()))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); @@ -5663,7 +5818,7 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); wallet.payment_stores.payment_store().remove(&id).await.unwrap(); assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); @@ -5691,7 +5846,7 @@ mod tests { &[(txid, Some(contribution.clone()))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); let bump_candidates = splice_candidates( @@ -5737,14 +5892,14 @@ mod tests { let (tx, _) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); - let id = PaymentId(txid.to_byte_array()); + let id = PaymentId([11u8; 32]); let mut graduated = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); graduated.status = PaymentStatus::Succeeded; wallet.payment_stores.payment_store().insert_or_update(graduated.clone()).await.unwrap(); let (other_tx, _) = splice_out_round(&wallet, 2, 400_000, 700); let other_txid = other_tx.compute_txid(); - let other_id = PaymentId(other_txid.to_byte_array()); + let other_id = PaymentId([12u8; 32]); let untyped = PaymentDetails::new( other_id, PaymentKind::Onchain { @@ -5857,6 +6012,9 @@ mod tests { &[(closed_txid, Some(closed_contribution))], ); wallet.record_signed_funding(&closed_tx, &closed_candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let other_id = wallet.find_payment_by_txid(other_txid).await.unwrap().expect("other id"); + let closed_id = wallet.find_payment_by_txid(closed_txid).await.unwrap().expect("closed id"); wallet .drop_splice_rounds_lost_across_restart(|channel| { @@ -5871,10 +6029,8 @@ mod tests { .await .unwrap(); - let id = PaymentId(txid.to_byte_array()); assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); - let other_id = PaymentId(other_txid.to_byte_array()); assert!(wallet.payment_stores.payment_store().get(&other_id).await.unwrap().is_some()); assert!(wallet .payment_stores @@ -5883,7 +6039,6 @@ mod tests { .await .unwrap() .is_some()); - let closed_id = PaymentId(closed_txid.to_byte_array()); assert!(wallet.payment_stores.payment_store().get(&closed_id).await.unwrap().is_some()); assert!(wallet .payment_stores @@ -5912,7 +6067,7 @@ mod tests { &[(txid, Some(contribution.clone()))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); let bump_candidates = splice_candidates( @@ -5950,16 +6105,19 @@ mod tests { let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); - let id = PaymentId(txid.to_byte_array()); + let id = PaymentId([9u8; 32]); let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); wallet.payment_stores.payment_store().insert_or_update(half_written).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); wallet.record_signed_funding(&tx, &[]).await.unwrap(); assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); wallet.record_signed_funding(&tx, &[]).await.unwrap(); assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); @@ -6001,16 +6159,23 @@ mod tests { let txid = tx.compute_txid(); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - let id = PaymentId(txid.to_byte_array()); fail_store.fail_writes.store(true, Ordering::Release); assert!(wallet.record_signed_funding(&tx, &candidates).await.is_err()); assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 1); - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); fail_store.fail_writes.store(false, Ordering::Release); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); @@ -6037,7 +6202,7 @@ mod tests { &[(txid, Some(contribution.clone()))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let prior = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); @@ -6460,6 +6625,31 @@ mod tests { wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap(); } + /// A graduated funding record has no pending entry — graduation removes it — so its txid must + /// resolve through the payment store itself. Without that fallback, a funding-typed broadcast + /// classified after graduation (e.g. LDK re-broadcasting a promoted 0conf splice whose + /// confirmation landed while the node was offline) would miss the record and create a duplicate + /// under a fresh id. + #[tokio::test] + async fn find_payment_by_txid_resolves_graduated_records() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let txid = Txid::from_byte_array([6u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut graduated = + interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); + graduated.kind = PaymentKind::Onchain { + txid, + status: confirmed_status(), + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + graduated.status = PaymentStatus::Succeeded; + wallet.payment_stores.payment_store().insert_or_update(graduated).await.unwrap(); + + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(payment_id)); + } + /// A cooperative close conflicts with a pending splice's funding transaction — both spend the /// pre-splice funding outpoint — so sync records the close among the splice record's /// conflicting txids, and the close's confirmation then resolves to the splice's PaymentId. @@ -7075,8 +7265,7 @@ mod tests { wallet.classify_broadcast(&tx, &tx_type).await.unwrap(); let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; - let payment_id = PaymentId(tx.compute_txid().to_byte_array()); - assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); + assert!(wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap().is_some()); assert_eq!(reads, 1, "classifying an unknown funding re-read the payment store"); } @@ -7348,7 +7537,120 @@ mod tests { wallet.classify_funding(&funded_tx, &channels, tx_type).await.unwrap(); let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1); - assert_eq!(payments[0].id, PaymentId(funded_tx.compute_txid().to_byte_array())); + match &payments[0].kind { + PaymentKind::Onchain { txid, .. } => assert_eq!(*txid, funded_tx.compute_txid()), + kind => panic!("unexpected kind {:?}", kind), + } + } + + /// A funding record's PaymentId is generated at record creation instead of being derived from a + /// txid: a replaceable transaction's txid is no stable identity for the record. Every lookup + /// resolves the record through its txid history (current txid, candidates, conflicts) rather + /// than re-deriving the id, so nothing may rely on the id and the txid coinciding. + #[tokio::test] + async fn funding_record_is_keyed_by_a_generated_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; + let tx_type = TransactionType::Funding { channels: vec![] }; + + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let funded_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let txid = funded_tx.compute_txid(); + wallet.classify_funding(&funded_tx, &channels, tx_type).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let record = &payments[0]; + assert_ne!(record.id, PaymentId(txid.to_byte_array()), "the id must not be the txid"); + match &record.kind { + PaymentKind::Onchain { txid: kind_txid, .. } => assert_eq!(*kind_txid, txid), + kind => panic!("unexpected kind {:?}", kind), + } + // The pending entry shares the id, and txid lookups resolve to the record. + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&record.id) + .await + .unwrap() + .is_some()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(record.id)); + } + + /// A funding transaction classified again — e.g. a 0conf splice re-broadcast through LDK's + /// generic funding path after a restart — must resolve to the record's generated id rather + /// than create a second record for the same transaction. + #[tokio::test] + async fn funding_rebroadcast_resolves_to_the_generated_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; + + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let funded_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let txid = funded_tx.compute_txid(); + + // The interactive-funding record written when the round was signed, keyed by a generated + // id. + let payment_id = PaymentId([42u8; 32]); + let candidates = vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + + // The re-typed rebroadcast comes back through the generic funding path. + wallet + .classify_funding(&funded_tx, &channels, TransactionType::Funding { channels: vec![] }) + .await + .unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the rebroadcast must not create a second record"); + assert_eq!(payments[0].id, payment_id); + // The interactive classification and contribution figures survive the generic + // wallet-view update (`funding_reclassification_update` declines the downgrade). + assert!(matches!( + payments[0].kind, + PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } + )); + assert_eq!(payments[0].amount_msat, Some(1_000_000)); } /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding @@ -8064,7 +8366,7 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, rounds); wallet.record_signed_funding(tx, &candidates).await.unwrap(); wallet.record_broadcast_splice_round(channel_id, tx.compute_txid()).await.unwrap(); - PaymentId(rounds[0].0.to_byte_array()) + wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap().expect("recorded") } /// The close finds no round of ours held — the channel closed on a commitment transaction and @@ -8283,7 +8585,7 @@ mod tests { &[(counterparty_txid, None), (txid, Some(contribution))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(counterparty_txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); assert!( wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some(), "the round was recorded" @@ -8585,7 +8887,7 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(txid.to_byte_array()); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); wallet.resolve_promoted_splice_round(channel_id, txid, Some(&[txid])).await.unwrap(); let later_funding_txid = Txid::from_byte_array([0xF1; 32]); diff --git a/src/wallet/payment_stores.rs b/src/wallet/payment_stores.rs index 5da2c64a85..f076a0ab94 100644 --- a/src/wallet/payment_stores.rs +++ b/src/wallet/payment_stores.rs @@ -13,7 +13,9 @@ use std::ops::Deref; use std::sync::Arc; use lightning::ln::channelmanager::PaymentId; +use lightning::util::persist::PageToken; +use crate::data_store::DataStorePage; use crate::payment::{PaymentDetails, PendingPaymentDetails}; use crate::types::{PaymentStore, PendingPaymentStore}; use crate::Error; @@ -21,12 +23,12 @@ use crate::Error; /// The wallet's payment store and pending payment store, with the lock serializing their writers. /// /// The writers must observe the payment record and its pending-store entry (candidate history -/// included) as one consistent unit: classification writes both stores for one payment, and -/// wallet sync's event arms decide from payment-id resolution through their last write. Without -/// the lock, a confirmation landing between classification's two writes sees the record -/// classified but the candidate history absent — resolving the wrong payment id or stamping the -/// confirmed candidate with another candidate's figures — and a classification landing inside an -/// arm's decision sequence gets overwritten by the arm's stale generic fallback. +/// included) as one consistent unit: classification and wallet sync's event arms each hold the +/// lock from payment-id resolution through their last write (classification's being its two-store +/// write pair). Without the lock, a confirmation landing between classification's two writes sees +/// the record classified but the candidate history absent — resolving the wrong payment id or +/// stamping the confirmed candidate with another candidate's figures — and a classification +/// landing inside an arm's decision sequence gets overwritten by the arm's stale generic fallback. /// /// The writes are methods of [`PaymentStoresGuard`], which only [`Self::lock`] hands out, so a /// write compiles only for a holder of the lock. The reads are methods of this type and take no @@ -70,6 +72,14 @@ impl PaymentStores { self.pending_payment_store.get(id).await } + /// A page of payment records, ordered from most recently created to least recently created; + /// see [`DataStore::list_page`](crate::data_store::DataStore::list_page). + pub(super) async fn payments_page( + &self, page_token: Option, + ) -> Result, Error> { + self.payment_store.list_page(page_token).await + } + /// Whether the pending store has an entry under `id`. pub(super) async fn has_pending_payment(&self, id: &PaymentId) -> Result { self.pending_payment_store.contains_key(id).await diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 9f6e14b79b..3780ae6cbf 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2341,9 +2341,7 @@ async fn splice_channel() { // them to the channel balance since there may not be a change output. let expected_splice_in_lightning_balance_sat = 4_000_002; - let payments = node_b.list_all_payments(); - let payment = - payments.into_iter().find(|p| p.id == PaymentId(txo.txid.to_byte_array())).unwrap(); + let payment = funding_payment(&node_b, txo.txid); assert_eq!(payment.fee_paid_msat, Some(expected_splice_in_fee_sat * 1_000)); assert_eq!( @@ -2392,9 +2390,7 @@ async fn splice_channel() { let expected_splice_out_fee_sat = 183; - let payments = node_a.list_all_payments(); - let payment = - payments.into_iter().find(|p| p.id == PaymentId(txo.txid.to_byte_array())).unwrap(); + let payment = funding_payment(&node_a, txo.txid); assert_eq!(payment.fee_paid_msat, Some(expected_splice_out_fee_sat * 1_000)); // The splice-out graduated to a confirmed interactive-funding payment. Its `direction` is left // unasserted on purpose: the destination is our own address, so it is a self-transfer (channel @@ -2693,15 +2689,20 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // classification — the `tx_type` assertion below catches a regression deterministically. wait_for_tx(&electrsd.client, original_txo.txid).await; wait_for_classified_funding_payment(&node_b, original_txo.txid).await; + // The record's random id is fixed at creation; capture it while the original candidate is + // current so its stability can be asserted across the RBF rounds below. + let splice_payment_id = funding_payment(&node_b, original_txo.txid).id; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); // For `confirm_original`, capture the original candidate's fee and raw transaction now, before // the RBF replaces it, so it can be force-confirmed (instead of the RBF) further below. let original_candidate: Option<(Option, String)> = if confirm_original { - let payment_id = PaymentId(original_txo.txid.to_byte_array()); - let fee = - node_b.payment(&payment_id).unwrap().expect("splice payment exists").fee_paid_msat; + let fee = node_b + .payment(&splice_payment_id) + .unwrap() + .expect("splice payment exists") + .fee_paid_msat; let raw_tx: String = bitcoind .client .call("getrawtransaction", &[json!(original_txo.txid.to_string())]) @@ -2732,12 +2733,11 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { node_b.sync_wallets().unwrap(); // After RBF but before confirmation, node_b (the initiator) should have a single on-chain - // payment covering both candidates: id anchored to the first broadcast, `kind.txid` pointing - // at the latest (RBF) candidate, and the durable interactive-funding `tx_type` preserved across - // the replacement. + // payment covering both candidates: still under the id it was created with, `kind.txid` + // pointing at the latest (RBF) candidate, and the durable interactive-funding `tx_type` + // preserved across the replacement. let rbf_candidate_fee = { - let payment_id = PaymentId(original_txo.txid.to_byte_array()); - let payment = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + let payment = node_b.payment(&splice_payment_id).unwrap().expect("splice payment exists"); match payment.kind { PaymentKind::Onchain { txid, @@ -2811,8 +2811,8 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // channel-lifecycle signal, not what drives payment status. Its `kind.txid` reflects the // winning RBF candidate, and `fee_paid_msat` carries this node's `FundingContribution` fee. { - let payment_id = PaymentId(original_txo.txid.to_byte_array()); - let payment = node_b.payment(&payment_id).unwrap().expect("splice payment graduated"); + let payment = + node_b.payment(&splice_payment_id).unwrap().expect("splice payment graduated"); assert_eq!(payment.status, PaymentStatus::Succeeded); match payment.kind { PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } => { @@ -2872,8 +2872,7 @@ async fn funding_payment_graduates_without_channel_ready() { // The funding payment is `Succeeded` purely from wallet sync reaching `ANTI_REORG_DELAY` // confirmations, asserted before draining any LDK event — so graduation is not driven by the // Lightning `ChannelReady` signal. - let payment_id = PaymentId(funding_txo.txid.to_byte_array()); - let payment = node_a.payment(&payment_id).unwrap().expect("funding payment exists"); + let payment = funding_payment(&node_a, funding_txo.txid); assert_eq!(payment.status, PaymentStatus::Succeeded); match payment.kind { PaymentKind::Onchain { @@ -2935,8 +2934,8 @@ async fn splice_payment_reorged_to_unconfirmed() { generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; node_b.sync_wallets().unwrap(); - let payment_id = PaymentId(splice_txo.txid.to_byte_array()); - let payment = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + let payment = funding_payment(&node_b, splice_txo.txid); + let payment_id = payment.id; assert_eq!(payment.status, PaymentStatus::Pending); assert!(matches!( payment.kind, @@ -3106,6 +3105,8 @@ fn only_interactive_funding_txid(node: &TestNode) -> Txid { } /// `node`'s payment for the funding transaction `funding_txid`, which it must have recorded. +/// Funding records are keyed by a random id generated at creation, so they are found through their +/// transaction history rather than by deriving an id from a txid. fn funding_payment(node: &TestNode, funding_txid: Txid) -> PaymentDetails { node.list_all_payments() .into_iter() @@ -3865,9 +3866,9 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { .filter(|outpoint| spends(&first_round, *outpoint)) .collect(); assert_eq!(shared, vec![funding_txo], "the bump reused an input of the first round"); - let payment_id = PaymentId(first_txo.txid.to_byte_array()); - let payment = node_a.payment(&payment_id).unwrap().expect("the splice has a payment"); + let payment = funding_payment(&node_a, bump_txo.txid); assert_eq!(payment.status, PaymentStatus::Pending); + let payment_id = payment.id; // Neither node reconnects to the other: node B closes on its own and node A learns of the // close from the chain alone. The commitment conflicts with the bump in the mempool: let it From d2e35a432783e0296a4a9ad1c0531ac022fb158a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 17 Sep 2026 11:35:53 -0500 Subject: [PATCH 14/49] DROP ME: f - Assign random PaymentIds to funding records Find the record of each classified transaction instead of deriving its payment-store key from the txid, which no longer names a funding record's id. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit f587672e9a7bab2a85b8610a8721fd26f8521feb) --- src/wallet/mod.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 25b2b161ca..3aa640926a 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -8090,16 +8090,27 @@ mod tests { gated_store.release.notify_one(); let expected_writes = fresh_txs.len() + 2; let mut payment_writes = Vec::new(); + let mut payments = Vec::new(); for _ in 0..100 { tokio::time::sleep(Duration::from_millis(100)).await; payment_writes = gated_store.written_keys(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); - if payment_writes.len() >= expected_writes { + payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + if payment_writes.len() >= expected_writes && payments.len() >= expected_writes { break; } } assert_eq!(payment_writes.len(), expected_writes, "not every package was classified"); + // A funding record's id is its own, so find each transaction's record to learn the key + // its write went under. let position = |tx: &Transaction| { - let key = PaymentId(tx.compute_txid().to_byte_array()).encode_to_hex_str(); + let txid = tx.compute_txid(); + let record = payments + .iter() + .find(|payment| { + matches!(payment.kind, PaymentKind::Onchain { txid: recorded, .. } if recorded == txid) + }) + .expect("classified"); + let key = record.id.encode_to_hex_str(); payment_writes.iter().position(|written| *written == key).expect("classified") }; let retried_position = position(&retried_tx); From 7547e8cebf96b894dc4771e736efc622b19f3e5c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 17 Sep 2026 12:00:53 -0500 Subject: [PATCH 15/49] DROP ME: f - Assign random PaymentIds to funding records Say where a funding record still carries a txid-derived id now that classification generates ids: only a record wallet sync created before classification keeps the id of that transaction, and the settled-record collision the wallet-sync fallback guards against arises for those records alone. Three docs still described the derived id as the rule. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit f9b6f8fb9369a400b8850184cadad9353258ad05) --- src/wallet/mod.rs | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3aa640926a..c81270aadf 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -673,14 +673,14 @@ impl Wallet { /// The id to record a transaction under that the funding-status check found foreign to the /// funding record resolved for it as `resolved_id`: its own txid-derived id, or `None` when a - /// funding record sits there already. A funding record's id is anchored to its first - /// candidate's txid, so a wallet event for that transaction falls back to this id whenever the - /// pending entry no longer maps it — which only happens once the negotiation settled and the - /// entry was removed. The generic event handling must then skip its write: merging a - /// wallet-view `Pending` payment into the settled record would resurrect it with figures no - /// classification derived. When `resolved_id` is the txid-derived id already, the - /// funding-status check has read that record, and finding the transaction foreign to it is - /// this very case; only a fallback from a different id needs a read. + /// funding record sits there already. A funding record wallet sync created before + /// classification keeps the txid-derived id of that transaction, so a wallet event for it + /// falls back to this id whenever the pending entry no longer maps it — which only happens + /// once the negotiation settled and the entry was removed. The generic event handling must + /// then skip its write: merging a wallet-view `Pending` payment into the settled record would + /// resurrect it with figures no classification derived. When `resolved_id` is the txid-derived + /// id already, the funding-status check has read that record, and finding the transaction + /// foreign to it is this very case; only a fallback from a different id needs a read. async fn foreign_transaction_payment_id( &self, resolved_id: PaymentId, txid: Txid, ) -> Result, Error> { @@ -6501,8 +6501,8 @@ mod tests { } /// A middle RBF candidate must map back to the funding record: it is neither the record's - /// id (derived from the first candidate), nor its current txid (the active candidate), nor - /// in `conflicting_txids` (it never got a `TxReplaced` event of its own). + /// id (here the txid-derived id of the first candidate), nor its current txid (the active + /// candidate), nor in `conflicting_txids` (it never got a `TxReplaced` event of its own). #[tokio::test] async fn find_payment_by_txid_maps_candidate_txids() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); @@ -7290,18 +7290,19 @@ mod tests { assert_eq!(reads, 2, "recording an unknown transaction re-read the payment store"); } - /// A funding record's id is anchored to its first candidate's txid. Once the payment settles - /// and its entry is removed, a wallet event for that candidate no longer resolves through the - /// candidate history — the fallback keys it by its own txid, colliding with the record's id. - /// Recording the event there would merge a fresh wallet-view `Pending` payment into the - /// terminal record; such events must be skipped. + /// A funding record wallet sync created before classification keeps the txid-derived id of + /// its first candidate. Once the payment settles and its entry is removed, a wallet event for + /// that candidate no longer resolves through the candidate history — the fallback keys it by + /// its own txid, colliding with the record's id. Recording the event there would merge a fresh + /// wallet-view `Pending` payment into the terminal record; such events must be skipped. #[tokio::test] async fn candidate_event_does_not_resurrect_a_settled_funding_payment() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(store, false).await; - // The record's id derives from the first candidate r1; its txid rotated to the RBF round - // r2. The payment failed and its pending entry is gone. + // The record keeps the txid-derived id of its first candidate r1, as one wallet sync + // created first does; its txid rotated to the RBF round r2. The payment failed and its + // pending entry is gone. let r1 = Txid::from_byte_array([2u8; 32]); let r2 = Txid::from_byte_array([4u8; 32]); let payment_id = PaymentId(r1.to_byte_array()); From 8a5db4a8eaa9d1a4d89c293fd2c2a31301511e21 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 3 Aug 2026 10:33:13 -0500 Subject: [PATCH 16/49] DROP ME: Model pending payments as an enum for pre-broadcast splices A user-initiated splice dropped before LDK persists it leaves no trace in LDK. Recovering whatever the splice reserved and describing later events about it in terms of the original request both require persisting the splice intent before handing it to LDK, which happens before negotiation and therefore before any funding transaction exists. The pending-payment record was built around an on-chain PaymentDetails carrying a txid, which cannot represent a splice that has not been broadcast yet. Reshape PendingPaymentDetails into an enum: a PendingSplice variant that holds only the generated PaymentId and the splice intent, and a Tracked variant that is the previous record plus an optional intent retained until the splice locks. Add the SpliceIntent and SpliceKind types that record what was handed to LDK and the API call that produced it. The wallet's pending-store writes that depend on a payment's status now make that check and the write atomically, replacing racy read-then-write pairs. They share one helper whose closure re-reads the payment's status inside the critical section -- only Pending payments belong in the pending store, and a status read taken outside it can go stale against graduation -- and promotes a bare PendingSplice to a Tracked record once a payment exists under its id: a plain payment-tracking merge would silently no-op against the variant, leaving the splice invisible to txid lookups. This is groundwork; nothing constructs a PendingSplice yet. A later commit adds the classification that reads the variant; the entry points that persist splice intents land with the splice tracking built on this. Generated with assistance from Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Claude Fable 5 (cherry picked from commit 8a6bb71ba2681850e36339eeea7b6e4868496ec3) --- src/payment/pending_payment_store.rs | 461 ++++++++++++++++++++++----- src/wallet/mod.rs | 296 ++++++++++------- 2 files changed, 565 insertions(+), 192 deletions(-) diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 11f09d53fe..fe3f1f2cb1 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -5,9 +5,13 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use bitcoin::Txid; -use lightning::impl_writeable_tlv_based; +use bitcoin::secp256k1::PublicKey; +use bitcoin::{TxOut, Txid}; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::FundingContribution; +use lightning::ln::types::ChannelId; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use crate::data_store::{StorableObject, StorableObjectUpdate, UpdatableObject}; use crate::payment::store::PaymentDetailsUpdate; @@ -44,44 +48,216 @@ impl_writeable_tlv_based!(FundingTxCandidate, { (6, awaiting_broadcast, required), }); -/// Represents a pending payment +/// The parameters of the API call that initiated a splice, recording what was attempted +/// independently of the contribution built from them. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PendingPaymentDetails { - /// The full payment details - pub details: PaymentDetails, - /// Transaction IDs that have replaced or conflict with this payment. - pub conflicting_txids: Vec, - /// For interactive funding (splices), this node's per-candidate funding figures across the - /// RBF history, keyed by each candidate's txid. Empty for non-funding payments and for - /// records written before per-candidate tracking existed. - pub(crate) candidates: Vec, - /// The candidates LDK promoted to the channel's funding, as `ChannelReady` reported them. A - /// zero-conf splice locks before its transaction confirms, and every later splice builds on - /// it, so such a round can still confirm once the channel's funding has moved on from it and - /// once the channel has closed, when LDK holds it no longer. Kept apart from the candidates, - /// which each funding-record write replaces as a whole. - pub(crate) locked_rounds: Vec, +pub(crate) enum SpliceKind { + /// [`Node::splice_in`] with a resolved amount. + /// + /// [`Node::splice_in`]: crate::Node::splice_in + In { amount_sats: u64 }, + /// [`Node::splice_out`] to the given outputs. + /// + /// [`Node::splice_out`]: crate::Node::splice_out + Out { outputs: Vec }, + /// [`Node::bump_channel_funding_fee`] of a pending splice. + /// + /// [`Node::bump_channel_funding_fee`]: crate::Node::bump_channel_funding_fee + Rbf {}, +} + +impl_writeable_tlv_based_enum!(SpliceKind, + (0, In) => { + (0, amount_sats, required), + }, + (2, Out) => { + (0, outputs, required_vec), + }, + (4, Rbf) => {}, +); + +/// A user-initiated splice that has been handed to LDK but is not yet guaranteed to survive a +/// restart. LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, and it +/// abandons an in-progress negotiation whenever the peer disconnects (which includes stopping the +/// node). Until the new funding transaction locks we keep enough state to recognize a splice LDK +/// no longer knows about and to describe events about it in terms of the original request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SpliceIntent { + /// The channel counterparty. + pub counterparty_node_id: PublicKey, + /// The channel being spliced. + pub channel_id: ChannelId, + /// The channel's funding outpoint when the splice was initiated. It only changes once a splice + /// locks, so a mismatch with the channel's current funding outpoint means the splice (or a + /// replacement) completed and the intent is stale. + pub pre_splice_funding_txo: LdkOutPoint, + /// The contribution handed to [`ChannelManager::funding_contributed`], kept to match later + /// events about the splice back to this intent. + /// + /// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed + pub contribution: FundingContribution, + /// The parameters of the originating API call. + pub kind: SpliceKind, +} + +impl_writeable_tlv_based!(SpliceIntent, { + (0, counterparty_node_id, required), + (2, channel_id, required), + (4, pre_splice_funding_txo, required), + (6, contribution, required), + (8, kind, required), +}); + +/// A pending payment tracked by LDK Node, keyed by [`PaymentId`]. +/// +/// A user-initiated splice is persisted as a [`PendingSplice`] before its contribution is handed +/// to LDK — at which point no funding transaction, and therefore no [`PaymentDetails`], exists yet. +/// Once the splice is recorded as a funding payment it becomes a [`Tracked`] payment carrying the +/// real [`PaymentDetails`], while retaining its [`SpliceIntent`] until the splice locks. +/// +/// [`PendingSplice`]: Self::PendingSplice +/// [`Tracked`]: Self::Tracked +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum PendingPaymentDetails { + /// A user-initiated splice persisted before hand-off to LDK; no funding transaction exists yet. + /// Keyed by the generated [`PaymentId`]; never mirrored into the payment store. + PendingSplice { id: PaymentId, intent: SpliceIntent }, + /// A pending payment tracked toward confirmation, optionally still carrying a live splice + /// intent until the splice locks. + /// + /// Each field is written by a different subsystem: wallet sync records `conflicting_txids` + /// for any wallet transaction (splice fundings included), the signing-time recording + /// records `candidates` for interactive funding, the `ChannelReady` arm records + /// `locked_rounds`, and `splice_intent` is carried over from a [`PendingSplice`] record when + /// the payment is promoted — nothing persists an intent at splice initiation yet; that lands + /// with the splice tracking built on this. A splice uses all of them; the fields do not + /// partition by payment type. + /// + /// [`PendingSplice`]: Self::PendingSplice + Tracked { + /// The full payment details. + details: PaymentDetails, + /// Transaction IDs wallet sync observed to have replaced or to conflict with this + /// payment, used to map later events about those txids back to this record. This is + /// BDK's view, distinct from `candidates`: it can hold conflicts that were never + /// negotiated candidates, while a candidate replaced between wallet syncs may never + /// appear here (it gets no `TxReplaced` event of its own). + conflicting_txids: Vec, + /// For interactive funding (splices), this node's per-candidate funding figures across the + /// RBF history, keyed by each candidate's txid and recorded as each round is signed. + /// Empty for non-funding payments. + candidates: Vec, + /// The live splice intent, or `None` for a non-splice payment or a splice that has + /// locked. It lives here as well as on + /// [`PendingSplice`] because a fee bump — a fresh negotiation LDK likewise abandons if the + /// peer disconnects before signing — would share the broadcast splice's record rather than + /// get one of its own. + /// + /// [`PendingSplice`]: Self::PendingSplice + splice_intent: Option, + /// The candidates LDK promoted to the channel's funding, as `ChannelReady` reported them. + /// A zero-conf splice locks before its transaction confirms, and every later splice builds + /// on it, so such a round can still confirm once the channel's funding has moved on from + /// it and once the channel has closed, when LDK holds it no longer. Kept apart from the + /// candidates, which each funding-record write replaces as a whole. + locked_rounds: Vec, + }, } impl PendingPaymentDetails { pub(crate) fn new( details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, ) -> Self { - Self { details, conflicting_txids, candidates, locked_rounds: Vec::new() } + Self::tracked(details, conflicting_txids, candidates, None) + } + + pub(crate) fn tracked( + details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, + splice_intent: Option, + ) -> Self { + Self::Tracked { + details, + conflicting_txids, + candidates, + splice_intent, + locked_rounds: Vec::new(), + } + } + + /// The full payment details, or `None` for a splice not yet broadcast. + pub(crate) fn details(&self) -> Option<&PaymentDetails> { + match self { + Self::PendingSplice { .. } => None, + Self::Tracked { details, .. } => Some(details), + } + } + + /// Transaction IDs that have replaced or conflict with this payment. + pub(crate) fn conflicting_txids(&self) -> &[Txid] { + match self { + Self::PendingSplice { .. } => &[], + Self::Tracked { conflicting_txids, .. } => conflicting_txids, + } + } + + /// The rounds LDK promoted to the channel's funding, as `ChannelReady` reported them; empty + /// for a splice without a funding transaction yet. + pub(crate) fn locked_rounds(&self) -> &[Txid] { + match self { + Self::PendingSplice { .. } => &[], + Self::Tracked { locked_rounds, .. } => locked_rounds, + } + } + + /// Records that LDK promoted the round with the given txid to the channel's funding. Returns + /// whether the record changed: a round recorded as promoted already, or a splice without a + /// funding transaction yet, leaves it as it is. + pub(crate) fn record_locked_round(&mut self, txid: Txid) -> bool { + match self { + Self::PendingSplice { .. } => false, + Self::Tracked { locked_rounds, .. } => { + if locked_rounds.contains(&txid) { + return false; + } + locked_rounds.push(txid); + true + }, + } } /// Returns this node's recorded funding figures for the candidate with the given txid, if any. pub(crate) fn candidate(&self, txid: Txid) -> Option<&FundingTxCandidate> { - self.candidates.iter().find(|candidate| candidate.txid == txid) + match self { + Self::PendingSplice { .. } => None, + Self::Tracked { candidates, .. } => { + candidates.iter().find(|candidate| candidate.txid == txid) + }, + } + } + + /// This node's recorded funding figures across the candidate history, in LDK's order; empty for + /// a splice without a funding transaction yet and for non-funding payments. + pub(crate) fn candidates(&self) -> &[FundingTxCandidate] { + match self { + Self::PendingSplice { .. } => &[], + Self::Tracked { candidates, .. } => candidates, + } } } -impl_writeable_tlv_based!(PendingPaymentDetails, { - (0, details, required), - (2, conflicting_txids, optional_vec), - (4, candidates, optional_vec), - (6, locked_rounds, optional_vec), -}); +impl_writeable_tlv_based_enum!(PendingPaymentDetails, + (0, PendingSplice) => { + (0, id, required), + (2, intent, required), + }, + (2, Tracked) => { + (0, details, required), + (2, conflicting_txids, optional_vec), + (4, candidates, optional_vec), + (6, splice_intent, option), + (8, locked_rounds, optional_vec), + }, +); #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingPaymentDetailsUpdate { @@ -89,13 +265,20 @@ pub(crate) struct PendingPaymentDetailsUpdate { pub payment_update: Option, pub conflicting_txids: Option>, pub candidates: Vec, + /// The splice intent to set (`Some(Some(..))`) or clear (`Some(None)`), or `None` to leave it + /// unchanged. Setting it on a [`PendingPaymentDetails::PendingSplice`] replaces the intent; + /// clearing a pre-broadcast splice is done by removing the record, not through this field. + pub splice_intent: Option>, } impl StorableObject for PendingPaymentDetails { type Id = PaymentId; fn id(&self) -> Self::Id { - self.details.id + match self { + Self::PendingSplice { id, .. } => *id, + Self::Tracked { details, .. } => details.id, + } } } @@ -103,36 +286,58 @@ impl UpdatableObject for PendingPaymentDetails { type Update = PendingPaymentDetailsUpdate; fn update(&mut self, update: Self::Update) -> bool { - let mut updated = false; - - // Update the underlying payment details if present - if let Some(payment_update) = update.payment_update { - updated |= self.details.update(payment_update); - } - - if let Some(new_conflicting_txids) = update.conflicting_txids { - if self.conflicting_txids != new_conflicting_txids { - self.conflicting_txids = new_conflicting_txids; - updated = true; - } - } - - if let PaymentKind::Onchain { txid, .. } = &self.details.kind { - let conflicts_len = self.conflicting_txids.len(); - self.conflicting_txids.retain(|conflicting_txid| conflicting_txid != txid); - updated |= self.conflicting_txids.len() != conflicts_len; - } - - // Each funding-record write passes the candidate history as of its own round, so a - // non-empty update replaces the stored list. An empty update (e.g. a non-funding payment) - // leaves it untouched. Dropping an abandoned round, the only writer that shrinks it, goes - // through the store's `mutate` instead. - if !update.candidates.is_empty() && self.candidates != update.candidates { - self.candidates = update.candidates; - updated = true; + match self { + Self::PendingSplice { intent, .. } => { + // A pre-broadcast record only carries a splice intent; the only meaningful update + // is replacing that intent. Clearing it is done by removing the record. + if let Some(Some(new_intent)) = update.splice_intent { + if *intent != new_intent { + *intent = new_intent; + return true; + } + } + false + }, + Self::Tracked { details, conflicting_txids, candidates, splice_intent, .. } => { + let mut updated = false; + + // Update the underlying payment details if present + if let Some(payment_update) = update.payment_update { + updated |= details.update(payment_update); + } + + if let Some(new_conflicting_txids) = update.conflicting_txids { + if *conflicting_txids != new_conflicting_txids { + *conflicting_txids = new_conflicting_txids; + updated = true; + } + } + + if let PaymentKind::Onchain { txid, .. } = &details.kind { + let conflicts_len = conflicting_txids.len(); + conflicting_txids.retain(|conflicting_txid| conflicting_txid != txid); + updated |= conflicting_txids.len() != conflicts_len; + } + + // Each funding-record write passes the candidate history as of its own round, so a + // non-empty update replaces the stored list. An empty update (e.g. a non-funding + // payment) leaves it untouched. Dropping an abandoned round, the only writer that + // shrinks it, goes through the store's `mutate` instead. + if !update.candidates.is_empty() && *candidates != update.candidates { + *candidates = update.candidates; + updated = true; + } + + if let Some(new_splice_intent) = update.splice_intent { + if *splice_intent != new_splice_intent { + *splice_intent = new_splice_intent; + updated = true; + } + } + + updated + }, } - - updated } fn to_update(&self) -> Self::Update { @@ -148,16 +353,34 @@ impl StorableObjectUpdate for PendingPaymentDetailsUpdate impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { fn from(value: &PendingPaymentDetails) -> Self { - let conflicting_txids = if value.conflicting_txids.is_empty() { - None - } else { - Some(value.conflicting_txids.clone()) - }; - Self { - id: value.id(), - payment_update: Some(value.details.to_update()), - conflicting_txids, - candidates: value.candidates.clone(), + match value { + PendingPaymentDetails::PendingSplice { id, intent } => Self { + id: *id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(Some(intent.clone())), + }, + PendingPaymentDetails::Tracked { + details, + conflicting_txids, + candidates, + splice_intent, + .. + } => { + let conflicting_txids = if conflicting_txids.is_empty() { + None + } else { + Some(conflicting_txids.clone()) + }; + Self { + id: details.id, + payment_update: Some(details.to_update()), + conflicting_txids, + candidates: candidates.clone(), + splice_intent: Some(splice_intent.clone()), + } + }, } } } @@ -236,6 +459,23 @@ pub(crate) fn test_funding_contribution_with_parts( .expect("hand-built TLV stream must decode") } +/// Builds a [`FundingContribution`] for tests carrying just the required TLV records: a zero +/// estimated fee, the default feerate, and no contributed outputs. +/// +/// [`FundingContribution`]: lightning::ln::funding::FundingContribution +#[cfg(test)] +pub(crate) fn test_funding_contribution() -> lightning::ln::funding::FundingContribution { + test_funding_contribution_with_feerate(253) +} + +/// Like [`test_funding_contribution`], but with the given input-selection feerate in sat/kwu. +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_feerate( + feerate: u64, +) -> lightning::ln::funding::FundingContribution { + test_funding_contribution_with_outputs(0, feerate, &[]) +} + #[cfg(test)] mod tests { use bitcoin::hashes::Hash; @@ -339,7 +579,7 @@ mod tests { assert!(pending_payment.update(update)); assert_eq!( - pending_payment.conflicting_txids, + pending_payment.conflicting_txids(), Vec::::new(), "current txid must not remain in its own conflict list" ); @@ -392,7 +632,7 @@ mod tests { assert!(downgraded.update(full_update)); assert!( matches!( - downgraded.details.kind, + downgraded.details().expect("tracked").kind, PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } ), "a full merge of a fresh classification downgrades a mirrored confirmation", @@ -407,18 +647,86 @@ mod tests { payment_update: Some(PaymentDetailsUpdate::funding_reclassification(fresh)), conflicting_txids: None, candidates: candidates.clone(), + splice_intent: None, }; assert!(merged.update(narrow_update)); + let merged_details = merged.details().expect("tracked"); assert!( matches!( - merged.details.kind, + merged_details.kind, PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } ), "a narrow classification update must not downgrade a mirrored confirmation", ); - assert_eq!(merged.candidates, candidates); - assert_eq!(merged.details.amount_msat, Some(1_000)); - assert_eq!(merged.details.fee_paid_msat, Some(100)); + assert_eq!(merged.candidate(txid), Some(&candidates[0])); + assert_eq!(merged_details.amount_msat, Some(1_000)); + assert_eq!(merged_details.fee_paid_msat, Some(100)); + } + + #[test] + fn splice_kind_round_trips() { + for kind in [ + SpliceKind::In { amount_sats: 500_000 }, + SpliceKind::Out { + outputs: vec![TxOut { + value: bitcoin::Amount::from_sat(400_000), + script_pubkey: bitcoin::ScriptBuf::new(), + }], + }, + SpliceKind::Rbf {}, + ] { + let encoded = kind.encode(); + let decoded = SpliceKind::read(&mut &encoded[..]).unwrap(); + assert_eq!(kind, decoded); + } + } + + #[test] + fn pending_splice_round_trips() { + use std::str::FromStr; + + let id = PaymentId([10u8; 32]); + let intent = SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution: test_funding_contribution(), + kind: SpliceKind::In { amount_sats: 500_000 }, + }; + let record = PendingPaymentDetails::PendingSplice { id, intent }; + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + assert_eq!(decoded.id(), id); + assert!(decoded.details().is_none()); + } + + #[test] + fn tracked_payment_round_trips() { + // The `PendingSplice` variant round-trips in `pending_splice_round_trips`; here we cover + // the `Tracked` variant and its enum discriminant. + let payment_id = PaymentId([7u8; 32]); + let txid = Txid::from_byte_array([8u8; 32]); + let record = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, txid), + vec![Txid::from_byte_array([9u8; 32])], + vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000), + fee_paid_msat: Some(100), + awaiting_broadcast: false, + }], + ); + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + assert_eq!(decoded.id(), payment_id); + assert!(decoded.details().is_some()); } /// A candidate with the given txid byte, with a stake of ours in it if `ours`. @@ -444,16 +752,17 @@ mod tests { let mut stored = entry(vec![candidate(2, false)]); let decoded: PendingPaymentDetails = Readable::read(&mut &stored.encode()[..]).expect("encoding must round-trip"); - assert_eq!(decoded.locked_rounds, Vec::::new()); + assert!(decoded.locked_rounds().is_empty()); - stored.locked_rounds.push(test_txid(2)); + assert!(stored.record_locked_round(test_txid(2))); + assert!(!stored.record_locked_round(test_txid(2))); let decoded: PendingPaymentDetails = Readable::read(&mut &stored.encode()[..]).expect("encoding must round-trip"); assert_eq!(decoded, stored); let synced = entry(vec![candidate(2, false), candidate(3, false)]); assert!(stored.update(synced.to_update())); - assert_eq!(stored.candidates.len(), 2); - assert_eq!(stored.locked_rounds, vec![test_txid(2)]); + assert_eq!(stored.candidates().len(), 2); + assert_eq!(stored.locked_rounds(), &[test_txid(2)]); } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index c81270aadf..3085739132 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -58,9 +58,9 @@ use payment_stores::{PaymentStores, PaymentStoresGuard}; use persist::KVStoreWalletPersister; use crate::config::{Config, ADDRESS_POOL_SIZE}; -use crate::data_store::UpdatableObject; #[cfg(test)] use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed}; +use crate::data_store::{StorableObject, UpdatableObject}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; use crate::logger::{log_debug, log_error, log_info, log_trace, log_warn, LdkLogger, Logger}; use crate::payment::pending_payment_store::PendingPaymentDetailsUpdate; @@ -387,35 +387,41 @@ impl Wallet { stores.insert_or_update_payment(payment.clone()).await?; if payment_status == PaymentStatus::Pending { - let pending_payment = - self.create_pending_payment_from_tx(payment, Vec::new()); - - stores.insert_or_update_pending_payment(pending_payment).await?; + self.upsert_pending_payment(&stores, payment, Vec::new()).await?; } }, WalletEvent::ChainTipChanged { new_tip, .. } => { let pending_payments: Vec = self .payment_stores - .pending_payments(|p| { - debug_assert!( - p.details.status == PaymentStatus::Pending, - "Non-pending payment {:?} found in pending store", - p.details.id, - ); - p.details.status == PaymentStatus::Pending - && matches!(p.details.kind, PaymentKind::Onchain { .. }) + .pending_payments(|p| match p.details() { + // A pre-broadcast splice intent carries no payment yet and cannot + // graduate. + None => false, + Some(details) => { + debug_assert!( + details.status == PaymentStatus::Pending, + "Non-pending payment {:?} found in pending store", + details.id, + ); + details.status == PaymentStatus::Pending + && matches!(details.kind, PaymentKind::Onchain { .. }) + }, }) .await; let mut unconfirmed_outbound_txids: Vec = Vec::new(); for payment in pending_payments { - match payment.details.kind { + // The filter admits only Tracked funding payments. + let PendingPaymentDetails::Tracked { ref details, .. } = payment else { + continue; + }; + match details.kind { PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { height, .. }, .. } => { - let payment_id = payment.details.id; + let payment_id = details.id; if new_tip.height >= height + ANTI_REORG_DELAY - 1 { // Graduate from the live record, not the snapshot listed // above: a classification landing since then must not have @@ -468,7 +474,7 @@ impl Wallet { { continue; } - if payment.details.direction == PaymentDirection::Outbound { + if details.direction == PaymentDirection::Outbound { unconfirmed_outbound_txids.push(txid); } }, @@ -552,10 +558,8 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ) }; - let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - stores.insert_or_update_payment(payment).await?; - stores.insert_or_update_pending_payment(pending_payment).await?; + stores.insert_or_update_payment(payment.clone()).await?; + self.upsert_pending_payment(&stores, payment, Vec::new()).await?; }, WalletEvent::TxReplaced { txid, conflicts, .. } => { // See `TxConfirmed`: id resolution and the writes below must not interleave @@ -602,10 +606,7 @@ impl Wallet { continue; } - let pending_payment_details = - self.create_pending_payment_from_tx(payment, conflict_txids.clone()); - - stores.insert_or_update_pending_payment(pending_payment_details).await?; + self.upsert_pending_payment(&stores, payment, conflict_txids).await?; }, WalletEvent::TxDropped { txid, tx } => { // See `TxConfirmed`: id resolution and the writes below must not interleave @@ -657,10 +658,8 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ) }; - let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - stores.insert_or_update_payment(payment).await?; - stores.insert_or_update_pending_payment(pending_payment).await?; + stores.insert_or_update_payment(payment.clone()).await?; + self.upsert_pending_payment(&stores, payment, Vec::new()).await?; }, _ => { continue; @@ -721,19 +720,22 @@ impl Wallet { async fn fail_funding_payment_lost_to_conflict( &self, payment: &PendingPaymentDetails, tip_height: u32, ) -> Result { - match payment.details.kind { - PaymentKind::Onchain { - status: ConfirmationStatus::Unconfirmed, - tx_type: - Some( - TransactionType::Funding { .. } - | TransactionType::InteractiveFunding { .. }, - ), - .. - } => {}, - _ => return Ok(false), - } - if payment.conflicting_txids.is_empty() { + let payment_id = match payment.details() { + Some(details) => match details.kind { + PaymentKind::Onchain { + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + .. + } => details.id, + _ => return Ok(false), + }, + None => return Ok(false), + }; + if payment.conflicting_txids().is_empty() { return Ok(false); } @@ -743,11 +745,15 @@ impl Wallet { let stores = self.payment_stores.lock().await; // Re-read the entry under the lock; the listing snapshot may predate a record write. - let entry = match stores.pending_payment(&payment.details.id).await? { + let entry = match stores.pending_payment(&payment_id).await? { Some(entry) => entry, None => return Ok(false), }; - let record_txid = match entry.details.kind { + let PendingPaymentDetails::Tracked { details, conflicting_txids, candidates, .. } = &entry + else { + return Ok(false); + }; + let record_txid = match details.kind { PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, @@ -760,8 +766,7 @@ impl Wallet { _ => return Ok(false), }; - let foreign_conflicts: Vec = entry - .conflicting_txids + let foreign_conflicts: Vec = conflicting_txids .iter() .copied() .filter(|conflict| *conflict != record_txid && entry.candidate(*conflict).is_none()) @@ -775,7 +780,7 @@ impl Wallet { // `get_tx` is canonical-only: a transaction that lost to a confirmed conflict // returns `None`, while one that can still confirm is `Some`. let a_candidate_is_live = locked_wallet.get_tx(record_txid).is_some() - || entry.candidates.iter().any(|c| locked_wallet.get_tx(c.txid).is_some()); + || candidates.iter().any(|c| locked_wallet.get_tx(c.txid).is_some()); !a_candidate_is_live && foreign_conflicts.iter().any(|conflict| { match locked_wallet.get_tx(*conflict).map(|tx| tx.chain_position) { @@ -790,7 +795,7 @@ impl Wallet { return Ok(false); } - let payment_id = entry.details.id; + let payment_id = entry.id(); let outcome = self.fail_unconfirmed_funding_payment_locked(&stores, payment_id, record_txid).await?; match outcome { @@ -928,8 +933,12 @@ impl Wallet { }; let entries = stores.pending_payments(|entry| tracks_channel(entry, channel_id)).await; for entry in entries { - let payment_id = entry.details.id; - let record_txid = match &entry.details.kind { + let details = match entry.details() { + Some(details) => details, + None => continue, + }; + let payment_id = details.id; + let record_txid = match &details.kind { PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, @@ -950,13 +959,13 @@ impl Wallet { // only where no candidate records it. let recorded_round = entry.candidate(record_txid).is_none().then_some(record_txid); let mut rounds_of_ours = entry - .candidates + .candidates() .iter() .filter(|candidate| candidate.amount_msat.is_some()) .map(|candidate| candidate.txid) .chain(recorded_round); if let Some(kept) = rounds_of_ours - .find(|txid| held_rounds.contains(txid) || entry.locked_rounds.contains(txid)) + .find(|txid| held_rounds.contains(txid) || entry.locked_rounds().contains(txid)) { log_info!( self.logger, @@ -1068,18 +1077,17 @@ impl Wallet { .pending_payments(|entry| { tracks_channel(entry, channel_id) && entry.candidate(txid).is_some() - && !entry.locked_rounds.contains(&txid) + && !entry.locked_rounds().contains(&txid) }) .await; for entry in entries { - let payment_id = entry.details.id; + let payment_id = entry.id(); stores .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); - if entry.locked_rounds.contains(&txid) { + if !entry.record_locked_round(txid) { return None; } - entry.locked_rounds.push(txid); Some(entry) }) .await?; @@ -2300,7 +2308,7 @@ impl Wallet { // pushed before this signing event and has been handled by now. Should LDK ever reorder // them, this would clear the mark of a round whose event has not been handled yet. let mut recorded = - prior_pending.as_ref().map(|entry| entry.candidates.clone()).unwrap_or_default(); + prior_pending.as_ref().map(|entry| entry.candidates().to_vec()).unwrap_or_default(); for candidate in history { match recorded.iter_mut().find(|stored| stored.txid == candidate.txid) { Some(stored) => *stored = candidate, @@ -2370,12 +2378,14 @@ impl Wallet { }) .await; for entry in entries { - let payment_id = entry.details.id; + let payment_id = entry.id(); stores .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); - let round = entry - .candidates + let PendingPaymentDetails::Tracked { candidates, .. } = &mut entry else { + return None; + }; + let round = candidates .iter_mut() .find(|candidate| candidate.txid == txid && candidate.awaiting_broadcast)?; round.awaiting_broadcast = false; @@ -2435,12 +2445,15 @@ impl Wallet { let entries = stores .pending_payments(|entry| { tracks_channel(entry, channel_id) - && entry.candidates.iter().any(|candidate| candidate.awaiting_broadcast) + && entry.candidates().iter().any(|candidate| candidate.awaiting_broadcast) }) .await; for entry in entries { - let payment_id = entry.details.id; + let payment_id = match entry.details() { + Some(details) => details.id, + None => continue, + }; let (abandoned, remaining): (Vec, Vec) = { let locked_wallet = self.inner.lock().expect("lock"); // TODO(#1037): the graph learns a round LDK broadcast from wallet sync alone @@ -2449,10 +2462,10 @@ impl Wallet { // sweep or a live event — runs the drop, only once the `InteractiveFunding` // broadcast arm applies the round to the graph, which #1037 does not do: it // prepares only `Funding`-typed packages. - entry.candidates.iter().cloned().partition(|candidate| { + entry.candidates().iter().cloned().partition(|candidate| { candidate.awaiting_broadcast && !held_rounds.contains(&candidate.txid) - && !entry.locked_rounds.contains(&candidate.txid) + && !entry.locked_rounds().contains(&candidate.txid) && locked_wallet.tx_graph().get_tx(candidate.txid).is_none() }) }; @@ -2541,9 +2554,11 @@ impl Wallet { stores .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); - entry.candidates.retain(|c| !abandoned_txids.contains(&c.txid)); - if let Some(mirrored) = mirrored { - entry.details = mirrored; + if let PendingPaymentDetails::Tracked { details, candidates, .. } = &mut entry { + candidates.retain(|c| !abandoned_txids.contains(&c.txid)); + if let Some(mirrored) = mirrored { + *details = mirrored; + } } Some(entry) }) @@ -2572,15 +2587,15 @@ impl Wallet { let channels: HashSet = self .payment_stores .pending_payments(|entry| { - entry.candidates.iter().any(|candidate| candidate.awaiting_broadcast) + entry.candidates().iter().any(|candidate| candidate.awaiting_broadcast) }) .await .iter() - .flat_map(|entry| match &entry.details.kind { - PaymentKind::Onchain { + .flat_map(|entry| match entry.details().map(|details| &details.kind) { + Some(PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { channels }), .. - } => channels.iter().map(|channel| channel.channel_id).collect(), + }) => channels.iter().map(|channel| channel.channel_id).collect(), _ => Vec::new(), }) .collect(); @@ -2770,6 +2785,7 @@ impl Wallet { payment_update: Some(update), conflicting_txids: None, candidates, + splice_intent: None, }; entry.update(pending_update).then_some(entry) }, @@ -2842,10 +2858,52 @@ impl Wallet { PaymentDetails::new(payment_id, kind, amount_msat, fee_paid_msat, direction, payment_status) } - fn create_pending_payment_from_tx( - &self, payment: PaymentDetails, conflicting_txids: Vec, - ) -> PendingPaymentDetails { - PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()) + /// Inserts or refreshes the pending-store entry tracking `payment` toward graduation, + /// atomically with reading the entry's current state. + async fn upsert_pending_payment( + &self, stores: &PaymentStoresGuard<'_>, payment: PaymentDetails, + conflicting_txids: Vec, + ) -> Result<(), Error> { + let id = payment.id; + stores + .mutate_pending_payment_async(&id, move |existing| async move { + // Only `Pending` payments belong in the pending store. Like in + // [`Self::persist_funding_payment`], the authoritative status is re-read inside + // the store's critical section, where it cannot go stale against graduation. + let is_pending = stores + .payment(&id) + .await? + .map_or(payment.status == PaymentStatus::Pending, |recorded| { + recorded.status == PaymentStatus::Pending + }); + if !is_pending { + return Ok(None); + } + Ok(match existing { + None => { + Some(PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())) + }, + // Promote a pre-broadcast splice intent: wallet sync saw the splice + // transaction before this node recorded it as a funding payment. Carrying the + // intent into the `Tracked` record makes the entry visible to txid lookups + // while the retrier keeps the intent until the splice locks. + Some(PendingPaymentDetails::PendingSplice { intent, .. }) => { + Some(PendingPaymentDetails::tracked( + payment, + conflicting_txids, + Vec::new(), + Some(intent), + )) + }, + Some(mut tracked @ PendingPaymentDetails::Tracked { .. }) => { + let fresh = + PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()); + tracked.update(fresh.to_update()).then_some(tracked) + }, + }) + }) + .await?; + Ok(()) } /// Removes the payment with the given id from the payment store, along with any pending-store @@ -2869,7 +2927,9 @@ impl Wallet { } let owns = |p: &PendingPaymentDetails| { - matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid) + p.details().is_some_and( + |d| matches!(d.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), + ) // A middle RBF round is not the record's current txid and may never have // received a `TxReplaced` event of its own, so map any of its candidate // txids (an earlier RBF round may confirm) back to the record. @@ -2877,14 +2937,14 @@ impl Wallet { }; let matches = self .payment_stores - .pending_payments(|p| owns(p) || p.conflicting_txids.contains(&target_txid)) + .pending_payments(|p| owns(p) || p.conflicting_txids().contains(&target_txid)) .await; // An entry lists the transactions that replaced its own, so a transaction another entry // records as its own (a splice round that replaced a close, say) matches both. The entry // that owns it is its record; the conflict listing is only how a replaced round of a // record with no candidates (an ordinary payment's RBF history) maps back to its record. if let Some(entry) = matches.iter().find(|p| owns(p)).or(matches.first()) { - return Ok(Some(entry.details.id)); + return Ok(Some(entry.id())); } // The pending store only indexes in-flight records — graduation removes the entry — so a @@ -2994,8 +3054,7 @@ impl Wallet { // the same dual-write the default `TxConfirmed` path performs; an empty conflicting-txids // list leaves any stored conflicts intact (the update treats absent as "unchanged"). if payment.status == PaymentStatus::Pending { - let pending = self.create_pending_payment_from_tx(payment, Vec::new()); - stores.insert_or_update_pending_payment(pending).await?; + self.upsert_pending_payment(stores, payment, Vec::new()).await?; } Ok(FundingStatusUpdate::Applied) } @@ -3238,8 +3297,6 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ); - let pending_payment_store = - self.create_pending_payment_from_tx(new_payment.clone(), Vec::new()); let change_set = locked_wallet.take_staged().unwrap_or_default(); drop(locked_wallet); locked_persister.persist_changeset(change_set).await.map_err(|e| { @@ -3249,8 +3306,8 @@ impl Wallet { // Taken after the persister, the order wallet sync takes the two locks in. let stores = self.payment_stores.lock().await; - stores.insert_or_update_payment(new_payment).await?; - stores.insert_or_update_pending_payment(pending_payment_store).await?; + stores.insert_or_update_payment(new_payment.clone()).await?; + self.upsert_pending_payment(&stores, new_payment, Vec::new()).await?; self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx); @@ -3304,11 +3361,11 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { /// Whether `entry` is the funding payment of a splice into `channel_id`. fn tracks_channel(entry: &PendingPaymentDetails, channel_id: ChannelId) -> bool { - match &entry.details.kind { - PaymentKind::Onchain { + match entry.details().map(|details| &details.kind) { + Some(PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { channels }), .. - } => channels.iter().any(|channel| channel.channel_id == channel_id), + }) => channels.iter().any(|channel| channel.channel_id == channel_id), _ => false, } } @@ -5128,7 +5185,7 @@ mod tests { let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); assert_eq!( - record.candidates.iter().map(|c| c.txid).collect::>(), + record.candidates().iter().map(|c| c.txid).collect::>(), vec![prior_txid, txid] ); let prior = record.candidate(prior_txid).unwrap(); @@ -5197,7 +5254,7 @@ mod tests { .await .unwrap() .expect("entry"); - assert_eq!(entry.details, payment); + assert_eq!(entry.details(), Some(&payment)); assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); let failed = wallet .payment_stores @@ -5248,7 +5305,7 @@ mod tests { let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); assert_eq!( - record.candidates.iter().map(|c| c.txid).collect::>(), + record.candidates().iter().map(|c| c.txid).collect::>(), vec![prior_txid, txid] ); assert!(!record.candidate(txid).unwrap().awaiting_broadcast); @@ -5517,7 +5574,7 @@ mod tests { let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); assert_eq!( - record.candidates.iter().map(|c| c.txid).collect::>(), + record.candidates().iter().map(|c| c.txid).collect::>(), vec![prior_txid, txid, next_txid] ); assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); @@ -5593,7 +5650,7 @@ mod tests { let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); assert!( @@ -5602,7 +5659,7 @@ mod tests { ); assert_eq!(payment.amount_msat, Some(500_300_000)); assert_eq!(payment.fee_paid_msat, Some(300_000)); - assert_eq!(record.details, payment); + assert_eq!(record.details(), Some(&payment)); assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); } @@ -5631,7 +5688,7 @@ mod tests { let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); @@ -5672,7 +5729,7 @@ mod tests { let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); @@ -5761,8 +5818,8 @@ mod tests { ); let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); - assert_eq!(record.details, payment); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.details(), Some(&payment)); assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); } @@ -5800,8 +5857,8 @@ mod tests { ); let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert!(record.candidates.is_empty()); - assert_eq!(record.details, payment); + assert!(record.candidates().is_empty()); + assert_eq!(record.details(), Some(&payment)); } /// A removal that was cut short between the two stores — the payment record went, the pending @@ -5865,20 +5922,21 @@ mod tests { wallet.payment_stores.payment_store().update(update).await.unwrap(); let entry = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); - assert!( - matches!(entry.details.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid) - ); + assert!(matches!( + entry.details().map(|details| &details.kind), + Some(PaymentKind::Onchain { txid: t, .. }) if *t == bump_txid + )); wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); let entry = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); - assert_eq!(entry.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); assert_eq!(payment.amount_msat, Some(500_300_000)); - assert_eq!(entry.details, payment); + assert_eq!(entry.details(), Some(&payment)); assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); } @@ -6089,8 +6147,8 @@ mod tests { assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); let entry = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); - assert_eq!(entry.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); - assert_eq!(entry.details.status, PaymentStatus::Pending); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(entry.details().map(|details| details.status), Some(PaymentStatus::Pending)); } /// The signing write failed between its two stores and the rollback failed as well, leaving @@ -6181,7 +6239,7 @@ mod tests { assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); } /// The same failure while signing a fee bump: the record is put back to the original round, @@ -6219,7 +6277,7 @@ mod tests { assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(prior)); let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates.iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); } @@ -6705,6 +6763,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -6921,6 +6980,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -6994,6 +7054,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![bumped_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7047,6 +7108,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7114,6 +7176,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![conflict_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7431,6 +7494,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -8442,8 +8506,8 @@ mod tests { .await .unwrap() .expect("the entry stays"); - assert_eq!(entry.candidates.len(), 2); - assert_eq!(entry.locked_rounds, vec![txid]); + assert_eq!(entry.candidates().len(), 2); + assert_eq!(entry.locked_rounds(), &[txid]); } /// LDK promoted a sibling this node did not contribute to — the counterparty's round locked on @@ -8693,7 +8757,7 @@ mod tests { .await .unwrap() .expect("the entry stays"); - assert_eq!(entry.locked_rounds, vec![counterparty_txid]); + assert_eq!(entry.locked_rounds(), &[counterparty_txid]); wallet .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) @@ -8752,7 +8816,7 @@ mod tests { .await .unwrap() .expect("the entry stays"); - assert_eq!(entry.locked_rounds, vec![locked]); + assert_eq!(entry.locked_rounds(), &[locked]); } wallet.resolve_closed_channel_splice_rounds(channel_id, &[second_txid]).await.unwrap(); @@ -8834,8 +8898,8 @@ mod tests { .await .unwrap() .expect("the entry stays"); - assert_eq!(entry.candidates.iter().map(|c| c.txid).collect::>(), vec![first_txid]); - assert_eq!(entry.locked_rounds, vec![first_txid]); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![first_txid]); + assert_eq!(entry.locked_rounds(), &[first_txid]); } /// A zero-conf splice round of ours locked before its transaction confirmed and a later splice @@ -8866,7 +8930,7 @@ mod tests { .await .unwrap() .expect("the entry stays"); - assert_eq!(entry.locked_rounds, vec![txid]); + assert_eq!(entry.locked_rounds(), &[txid]); wallet .resolve_closed_channel_splice_rounds(channel_id, &[later_funding_txid]) @@ -8976,7 +9040,7 @@ mod tests { .await .unwrap() .expect("the entry stays"); - assert_eq!(entry.candidates.len(), 2); + assert_eq!(entry.candidates().len(), 2); // At the close the monitor has settled on the funding and watches neither round. wallet.resolve_closed_channel_splice_rounds(channel_id, &[funding_txid]).await.unwrap(); @@ -9031,7 +9095,7 @@ mod tests { .await .unwrap() .expect("the entry stays"); - assert_eq!(entry.candidates.len(), 2); + assert_eq!(entry.candidates().len(), 2); } /// The close does not touch a payment that no longer waits on an unconfirmed round: one whose From e83a13e3b6d7f6d17dbf6fcd25f078a3450b1dd7 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 21 Sep 2026 16:41:36 -0700 Subject: [PATCH 17/49] DROP ME: f - Model pending payments as an enum for pre-broadcast splices Check that a persisted splice intent keeps the parts its contribution inherited from the round it replaces, which the pinned LDK records in the contribution so that `reserved_inputs` and `reserved_outputs` leave them out. The contribution's equality ignores that record, so the existing round trip could lose it unnoticed. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 68d98228471eeeb6e857022a94cde95cfa63f72b) --- src/payment/pending_payment_store.rs | 137 +++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index fe3f1f2cb1..c65c5432fe 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -408,6 +408,27 @@ pub(crate) fn test_funding_contribution_with_outputs( pub(crate) fn test_funding_contribution_with_parts( estimated_fee_sat: u64, feerate: u64, prevtxs: &[bitcoin::Transaction], outputs: &[bitcoin::TxOut], change_output: Option<&bitcoin::TxOut>, +) -> lightning::ln::funding::FundingContribution { + test_funding_contribution_inheriting( + estimated_fee_sat, + feerate, + prevtxs, + outputs, + change_output, + &[], + &[], + ) +} + +/// Like [`test_funding_contribution_with_parts`], but recording `inherited_inputs` and +/// `inherited_output_scripts` as parts a still-pending splice attempt reserved before this +/// contribution, as LDK records them at the hand-off of a fee bump built from the round it +/// replaces: the contribution's `reserved_inputs` and `reserved_outputs` leave them out. +#[cfg(test)] +pub(crate) fn test_funding_contribution_inheriting( + estimated_fee_sat: u64, feerate: u64, prevtxs: &[bitcoin::Transaction], + outputs: &[bitcoin::TxOut], change_output: Option<&bitcoin::TxOut>, + inherited_inputs: &[bitcoin::OutPoint], inherited_output_scripts: &[bitcoin::ScriptBuf], ) -> lightning::ln::funding::FundingContribution { use lightning::util::ser::{BigSize, Writeable}; use lightning::util::wallet_utils::ConfirmedUtxo; @@ -451,6 +472,42 @@ pub(crate) fn test_funding_contribution_with_parts( records.extend_from_slice(&[11, 8]); // (11, max_feerate) records.extend_from_slice(&feerate.to_be_bytes()); records.extend_from_slice(&[13, 1, 1]); // (13, is_splice: true) + if !inherited_inputs.is_empty() || !inherited_output_scripts.is_empty() { + // (17, pending_components): a length-prefixed TLV stream of its own. + let mut components = Vec::new(); + if !inherited_inputs.is_empty() { + let mut bytes = Vec::new(); + for outpoint in inherited_inputs { + outpoint.write(&mut bytes).expect("in-memory write must succeed"); + } + components.push(1); // (1, inputs) + BigSize(bytes.len() as u64) + .write(&mut components) + .expect("in-memory write must succeed"); + components.extend(bytes); + } + if !inherited_output_scripts.is_empty() { + let mut bytes = Vec::new(); + for script in inherited_output_scripts { + script.write(&mut bytes).expect("in-memory write must succeed"); + } + components.push(3); // (3, output_scripts) + BigSize(bytes.len() as u64) + .write(&mut components) + .expect("in-memory write must succeed"); + components.extend(bytes); + } + let mut component_bytes = Vec::new(); + BigSize(components.len() as u64) + .write(&mut component_bytes) + .expect("in-memory write must succeed"); + component_bytes.extend(components); + records.push(17); + BigSize(component_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); + records.extend(component_bytes); + } let mut tlv_bytes = Vec::new(); // BigSize length prefix over the TLV records above. BigSize(records.len() as u64).write(&mut tlv_bytes).expect("in-memory write must succeed"); @@ -705,6 +762,86 @@ mod tests { assert!(decoded.details().is_none()); } + /// A fee bump's contribution inherits the inputs and change of the round it replaces, which + /// LDK records in the contribution at the hand-off so that `reserved_inputs` and + /// `reserved_outputs` leave them out: what a failure of the bump releases, and what a retry + /// must reserve again. That record is a private field the contribution's `PartialEq` ignores, + /// so a persisted intent's round trip is checked through those accessors. + #[test] + fn pending_splice_keeps_the_contribution_reserved_parts() { + use std::str::FromStr; + + use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, WPubkeyHash}; + + let prevtx = |seed: u8| Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + }; + let prevtxs = [prevtx(1), prevtx(2)]; + let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 }; + let script = |seed: u8| ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])); + let change = TxOut { value: Amount::from_sat(21_000), script_pubkey: script(9) }; + let splice_out = TxOut { value: Amount::from_sat(50_000), script_pubkey: script(8) }; + let reserved = |contribution: &FundingContribution| { + ( + contribution.reserved_inputs().map(|input| input.outpoint()).collect::>(), + contribution.reserved_outputs().cloned().collect::>(), + ) + }; + + // Without the record, every part counts as reserved. + let plain = test_funding_contribution_with_parts( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change), + ); + assert_eq!( + reserved(&plain), + (prevtxs.iter().map(outpoint).collect(), vec![splice_out.clone(), change.clone()]) + ); + + // The bump reuses the first input and the change address of the round it replaces; the + // second input and the splice-out output are its own. + let contribution = test_funding_contribution_inheriting( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change), + &[outpoint(&prevtxs[0])], + &[change.script_pubkey.clone()], + ); + let expected = (vec![outpoint(&prevtxs[1])], vec![splice_out]); + assert_eq!(reserved(&contribution), expected); + + let intent = SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution, + kind: SpliceKind::Rbf {}, + }; + let record = PendingPaymentDetails::PendingSplice { id: PaymentId([10u8; 32]), intent }; + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + let PendingPaymentDetails::PendingSplice { intent, .. } = decoded else { + panic!("a pending splice decoded as something else"); + }; + assert_eq!(reserved(&intent.contribution), expected); + } + #[test] fn tracked_payment_round_trips() { // The `PendingSplice` variant round-trips in `pending_splice_round_trips`; here we cover From c13a390ced3084951b27d6834a8083c8d65eabec Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 3 Aug 2026 10:34:53 -0500 Subject: [PATCH 18/49] DROP ME: Adopt the splice-time PaymentId when recording a splice A user-initiated splice will be keyed by a PaymentId generated at splice time rather than derived from a candidate's txid, so its splice intent, funding payment, and candidate history all share one record. Teach the signing-time recording to find a pre-broadcast splice intent by its channel and reuse that id for a splice no live record tracks yet, promoting the intent record to a tracked funding payment while preserving the intent until the splice locks. A round already on record keeps its record, whatever id it is under: the id of the first round of the history any record tracks is adopted before the channel's intent is consulted, and a fresh id is generated only when neither yields one. A record wallet sync has already failed does not count: nothing revisits a failed record, so a fee bump signed with its lost round in the history adopts the channel's intent instead, and its entry carries the intent. The intent identifies the channel, not a round, and must not decide the id of a round already on record: a splice this node joins as a fee bump of a round wallet sync recorded first converges on the record sync created, and consulting the intent first would file the bump under the intent as a second record, with wallet sync then graduating whichever of the two it finds first. Every splice round this node contributes to that the wallet records is recorded when it is signed, before our signatures are released, so the intent only ever decides the id of a splice's first signed round, or of a bump signed after wallet sync has failed every round on record before it. Splices we did not originate (counterparty-initiated or V2 dual-funded opens) have no intent. An intent submitted for a channel whose history is already on a record under another id that has not failed is never promoted and stays bare until the splice locks or fails. A splice under a generated id is no longer found by the txid-derived lookup, so it leans on find_payment_by_txid's candidate probe to map its txids back to the record. The generic funding classification already resolves an existing record the same way before generating a fresh id: LDK re-broadcasts a promoted-but-unconfirmed 0conf funding transaction through that path, and a test added here covers the rebroadcast merging into the record the signing created rather than creating a duplicate. Promotion of a pre-broadcast intent in persist_funding_payment_locked is gated on the payment still being Pending, read inside the pending store's critical section like the rest of the write's decision: a payment that confirmed through ANTI_REORG_DELAY before the write must not re-enter the pending store, which graduation and rebroadcast assume holds only Pending payments. No splice intents are created yet; the splice entry points that persist them land in a follow-up -- on this branch the intent probe stays dormant. Generated with assistance from Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 1c45324b0b49de40a13489512ea553135783fec4) --- src/payment/pending_payment_store.rs | 8 + src/wallet/mod.rs | 479 +++++++++++++++++++++++++-- 2 files changed, 454 insertions(+), 33 deletions(-) diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index c65c5432fe..37c02fa147 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -225,6 +225,14 @@ impl PendingPaymentDetails { } } + /// The splice intent this record carries, if it is a splice that has not yet locked. + pub(crate) fn splice_intent(&self) -> Option<&SpliceIntent> { + match self { + Self::PendingSplice { intent, .. } => Some(intent), + Self::Tracked { splice_intent, .. } => splice_intent.as_ref(), + } + } + /// Returns this node's recorded funding figures for the candidate with the given txid, if any. pub(crate) fn candidate(&self, txid: Txid) -> Option<&FundingTxCandidate> { match self { diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3085739132..c03596fd2b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2133,19 +2133,49 @@ impl Wallet { Ok(()) } - /// Resolves the id under which the interactive funding with negotiated history `candidates` is - /// recorded: that of a record already tracking any of its rounds (wallet sync may record a - /// round before this node does), else a fresh one. A record already failed is passed over: - /// wallet sync fails a payment whose round lost to a conflicting spend confirmed while the - /// channel stays open, LDK still holds the round and a fee bump of it is signed with the round - /// among its candidates, and nothing revisits a failed record's status, so the bump filed under - /// it would go untracked. An id derived from a txid would tie the record's identity to one - /// round of a replaceable transaction — resolution through the record's txid history is what - /// keeps its identity stable across RBF replacements. The caller holds the cross-store lock: - /// resolved outside it, the id could go stale against a record wallet sync creates for the same + /// Returns the `PaymentId` of a user-initiated splice intent for one of the channels in + /// `candidate`, if any, so the first recorded round of a splice adopts the id chosen at splice + /// time rather than a fresh one. The intent identifies the channel, not the round, so it + /// decides the id only for a history no record tracks yet + /// ([`Self::resolve_interactive_funding_id`]). A fee bump reuses the channel's existing intent, + /// so at most one in-flight intent matches and the first is unambiguous. + async fn find_splice_payment_id(&self, candidate: &FundingCandidate) -> Option { + self.payment_stores + .pending_payments(|p| { + p.splice_intent().is_some_and(|intent| { + candidate.channels.iter().any(|channel| { + channel.channel_id == intent.channel_id + && channel.counterparty_node_id == intent.counterparty_node_id + }) + }) + }) + .await + .first() + .map(|p| p.id()) + } + + /// Resolves the id under which the `active` round of the interactive funding with negotiated + /// history `candidates` is recorded. A round already on record keeps its record: the id of the + /// first round of the history any record tracks is adopted (wallet sync may record a round + /// before this node does), so a replacement, a replayed signing and a sync-created record + /// converge on one record. A record already failed is passed over: wallet sync fails a payment + /// whose round lost to a conflicting spend confirmed while the channel stays open, LDK still + /// holds the round and a fee bump of it is signed with the round among its candidates, and + /// nothing revisits a failed record's status, so the bump filed under it would go untracked. + /// Only a history no live record tracks falls back to the channel's splice intent: a + /// user-initiated splice adopts the `PaymentId` generated when it was initiated, so its intent, + /// funding payment and candidate history share one record. The intent identifies the channel, + /// not the round, which is why it must not decide the id of a round already on record: a fee + /// bump this node signs of a round wallet sync recorded first must converge on the record sync + /// created, not be filed under the bump's intent as a second record. Otherwise a fresh id is + /// generated — an id derived from a txid would tie the record's identity to one round of a + /// replaceable transaction, and resolution through the record's txid history is what keeps its + /// identity stable across RBF replacements. The caller holds the cross-store lock: resolved + /// outside it, the id could go stale against a record wallet sync creates for the same /// transaction before the caller's write. async fn resolve_interactive_funding_id( &self, stores: &PaymentStoresGuard<'_>, candidates: &[FundingCandidate], + active: &FundingCandidate, ) -> Result { for candidate in candidates.iter() { if let Some(id) = self.find_payment_by_txid(candidate.txid).await? { @@ -2158,6 +2188,9 @@ impl Wallet { } } } + if let Some(id) = self.find_splice_payment_id(active).await { + return Ok(id); + } Ok(random_payment_id()) } @@ -2241,9 +2274,10 @@ impl Wallet { /// `candidates` is the channel's pending splice history as [`funding_candidates`] lists it from /// the channel's [`SpliceDetails`], so the record is written in full, under the id /// [`Self::resolve_interactive_funding_id`] resolves (that of a record already tracking any - /// round of the history, else a fresh one). The signed round is marked as awaiting broadcast - /// until LDK reports the splice negotiated and [`Self::record_broadcast_splice_round`] clears - /// the mark: only such a round can be abandoned without a trace, and + /// round of the history, else the channel's splice intent, else a fresh one). The signed round + /// is marked as awaiting broadcast until LDK reports the splice negotiated and + /// [`Self::record_broadcast_splice_round`] clears the mark: only such a round can be abandoned + /// without a trace, and /// [`Self::drop_abandoned_splice_rounds`] takes it back once LDK no longer holds it. /// /// Nothing is recorded for a round missing from the history (reset between the event's @@ -2275,7 +2309,8 @@ impl Wallet { // Resolution, the reads and the writes below must share one lock acquisition, as in every // funding-record write: done outside it, the record could change under us before the write. let stores = self.payment_stores.lock().await; - let payment_id = self.resolve_interactive_funding_id(&stores, candidates).await?; + let payment_id = + self.resolve_interactive_funding_id(&stores, candidates, signed_round).await?; let (details, mut history) = match self.interactive_funding_record( payment_id, candidates, @@ -2763,23 +2798,46 @@ impl Wallet { stores .mutate_pending_payment_async(&id, move |existing| async move { // The record was written above and removal serializes on the cross-store lock held - // here, so absence means the write failed out; fall back to the fresh details. + // here, so absence means the write failed out; fall back to the fresh details. A + // promoted or (re)created entry embeds this post-write record rather than the + // fresh Unconfirmed details, so a confirmation wallet sync already recorded keeps + // driving graduation. let recorded = stores.payment(&id).await?.unwrap_or(details); Ok(match existing { - // The inserted entry embeds the post-write record rather than the fresh - // details, so a confirmation wallet sync already recorded keeps driving - // graduation. - None if recorded.status == PaymentStatus::Pending => { - Some(PendingPaymentDetails::new(recorded, Vec::new(), candidates)) + // First time we record this funding payment — or a crash between the two + // store writes left a Pending record with no index entry: (re)create it so + // the payment can graduate and its candidate txids stay mapped. A graduated + // payment is never `Pending`, so absence with an advanced record means the + // graduation path removed the entry and it must not be re-indexed. + None => (recorded.status == PaymentStatus::Pending).then(|| { + PendingPaymentDetails::tracked(recorded, Vec::new(), candidates, None) + }), + // A user-initiated splice has a pre-broadcast `PendingSplice` intent under + // this id; carry its intent into the `Tracked` record so promotion does + // not drop it (nothing persists or consumes intents yet — that arrives + // with the follow-up that makes splice retries survive restarts). If the + // payment already advanced beyond `Pending` (wallet sync confirmed it + // through `ANTI_REORG_DELAY` first), it must not enter the pending store; + // the leftover intent record stays until that follow-up adds its clearing + // path. + Some(PendingPaymentDetails::PendingSplice { intent, .. }) => { + if recorded.status == PaymentStatus::Pending { + Some(PendingPaymentDetails::tracked( + recorded, + Vec::new(), + candidates, + Some(intent), + )) + } else { + None + } }, - // The payment already advanced beyond Pending: the graduation path removed - // the entry and it must not be re-created. - None => None, - // The entry predates this write — wallet sync recorded the transaction - // before it was recorded as a funding (its arms and this write pair - // serialize on the cross-store lock, so nothing lands in between): merge - // only the funding classification into the existing entry. - Some(mut entry) => { + // The entry predates this write — an earlier round's recording or wallet sync + // recorded the transaction before this write (sync's arms and this write pair + // serialize on the cross-store lock, so nothing lands in between): merge only + // the funding classification (`tx_type`, candidate history and the figures of + // whichever candidate the record's state makes authoritative) into it. + Some(mut tracked @ PendingPaymentDetails::Tracked { .. }) => { let pending_update = PendingPaymentDetailsUpdate { id, payment_update: Some(update), @@ -2787,7 +2845,7 @@ impl Wallet { candidates, splice_intent: None, }; - entry.update(pending_update).then_some(entry) + tracked.update(pending_update).then_some(tracked) }, }) }) @@ -2886,7 +2944,7 @@ impl Wallet { // Promote a pre-broadcast splice intent: wallet sync saw the splice // transaction before this node recorded it as a funding payment. Carrying the // intent into the `Tracked` record makes the entry visible to txid lookups - // while the retrier keeps the intent until the splice locks. + // while preserving the intent. Some(PendingPaymentDetails::PendingSplice { intent, .. }) => { Some(PendingPaymentDetails::tracked( payment, @@ -2931,8 +2989,9 @@ impl Wallet { |d| matches!(d.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), ) // A middle RBF round is not the record's current txid and may never have - // received a `TxReplaced` event of its own, so map any of its candidate - // txids (an earlier RBF round may confirm) back to the record. + // received a `TxReplaced` event of its own, and a splice keyed by a generated + // PaymentId is not found by the txid-derived id above: map any of the + // candidate txids (an earlier RBF round may confirm) back to the record. || p.candidate(target_txid).is_some() }; let matches = self @@ -3921,7 +3980,8 @@ mod tests { PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; use crate::payment::pending_payment_store::{ - test_funding_contribution_with_outputs, test_funding_contribution_with_parts, + test_funding_contribution_with_outputs, test_funding_contribution_with_parts, SpliceIntent, + SpliceKind, }; use crate::types::{DynStore, DynStoreWrapper}; use crate::{NodeMetrics, PersistedNodeMetrics}; @@ -5135,6 +5195,311 @@ mod tests { (tx, contribution) } + /// The intent of a user-initiated splice of `channel_id` with `counterparty_node_id`, anchored + /// at the channel's funding `pre_splice_funding` when the splice was submitted. + fn splice_intent_for( + counterparty_node_id: PublicKey, channel_id: ChannelId, pre_splice_funding: LdkOutPoint, + ) -> SpliceIntent { + SpliceIntent { + counterparty_node_id, + channel_id, + pre_splice_funding_txo: pre_splice_funding, + contribution: test_funding_contribution_with_outputs(300, 253, &[]), + kind: SpliceKind::Out { outputs: Vec::new() }, + } + } + + /// A round signed under the channel's splice intent that has since locked with zero + /// confirmations — clearing its intent — with a second splice submitted against the locked + /// funding before the round's `SpliceNegotiated` event was handled: the channel's intent no + /// longer belongs to the recorded round. + struct LockedRoundWithNewerIntent { + first_id: PaymentId, + tx: Transaction, + candidates: Vec, + second_id: PaymentId, + second_intent: SpliceIntent, + } + + async fn lock_a_signed_round_and_submit_another_splice( + wallet: &Wallet, + ) -> LockedRoundWithNewerIntent { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let first_id = PaymentId([31u8; 32]); + let first_intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id: first_id, intent: first_intent }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + // The round locks with zero confirmations, which clears its intent... + let cleared = PendingPaymentDetailsUpdate { + id: first_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + wallet.payment_stores.pending_payment_store().update(cleared).await.unwrap(); + // ...and a second splice of the channel is submitted against the new funding. + let second_id = PaymentId([32u8; 32]); + let second_intent = + splice_intent_for(counterparty_node_id, channel_id, LdkOutPoint { txid, index: 0 }); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { + id: second_id, + intent: second_intent.clone(), + }) + .await + .unwrap(); + + LockedRoundWithNewerIntent { first_id, tx, candidates, second_id, second_intent } + } + + /// A recorded round is marked broadcast in its own record once the channel carries the intent + /// of a newer splice: after a zero-conf lock, the user may submit a second splice before the + /// locked round's `SpliceNegotiated` event is handled, and the event must neither file the + /// round under the new splice as a second record nor touch the new splice's intent. + #[tokio::test] + async fn negotiation_marks_a_recorded_round_broadcast_under_a_newer_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + let txid = setup.tx.compute_txid(); + + let channel_id = setup.candidates[0].channels[0].channel_id; + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the round must not be filed as a second record"); + assert_eq!(payments[0].id, setup.first_id); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&setup.first_id) + .await + .unwrap() + .expect("entry"); + assert!(!entry.candidate(txid).expect("candidate").awaiting_broadcast); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&setup.second_id).await.unwrap(), + Some(PendingPaymentDetails::PendingSplice { + id: setup.second_id, + intent: setup.second_intent, + }), + "the newer splice's intent must be left untouched" + ); + } + + /// The signing event of a recorded round, replayed once the channel carries the intent of a + /// newer splice, writes nothing: the round is on record, so the newer intent is not consulted. + #[tokio::test] + async fn a_replayed_signing_writes_nothing_under_a_newer_intent() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + + fail_store.fail_writes.store(true, Ordering::Release); + wallet.record_signed_funding(&setup.tx, &setup.candidates).await.unwrap(); + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 0, + "a replayed signing must produce no new write" + ); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&setup.second_id).await.unwrap(), + Some(PendingPaymentDetails::PendingSplice { + id: setup.second_id, + intent: setup.second_intent, + }), + ); + } + + /// The first round of a user-initiated splice is on no record when it is signed, so it adopts + /// the id of the channel's splice intent: the bare intent entry becomes the round's record and + /// keeps carrying the intent. + #[tokio::test] + async fn signing_a_first_round_adopts_the_intent_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + let txid_derived_id = PaymentId(txid.to_byte_array()); + assert!(wallet + .payment_stores + .payment_store() + .get(&txid_derived_id) + .await + .unwrap() + .is_none()); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); + } + + /// A fee bump of a round whose payment wallet sync failed — the round lost to a conflicting + /// spend confirmed while the channel stayed open — adopts the channel's splice intent rather + /// than the failed record: the failed round in its history decides nothing, so the bump is + /// recorded under the intent's id, its entry carrying the intent. + #[tokio::test] + async fn signing_a_bump_of_a_failed_round_adopts_the_channels_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_stores + .payment_store() + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); + + // The bump's intent, recorded at submission with no record left to join. + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let id = PaymentId([31u8; 32]); + let intent = SpliceIntent { + contribution: bump_contribution.clone(), + kind: SpliceKind::Rbf {}, + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let failed = wallet + .payment_stores + .payment_store() + .get(&failed_id) + .await + .unwrap() + .expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&failed_id) + .await + .unwrap() + .is_none()); + } + + /// A fee bump signed while the channel's intent is still live joins the record of the round + /// it replaces: that round is on record, so the history decides the id, and the intent the + /// bump shares with the first round stays on the record. + #[tokio::test] + async fn signing_a_bump_joins_the_replaced_rounds_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the bump must join the first round's record"); + assert_eq!(payments[0].id, id); + assert!(matches!(payments[0].kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!( + entry.candidates().iter().map(|c| c.txid).collect::>(), + vec![txid, bump_txid] + ); + assert_eq!(entry.splice_intent(), Some(&intent)); + } + /// Signing a splice round records its funding payment with the channel's full pending splice /// history, so a wallet sync that observes the transaction before the broadcast (the /// counterparty may broadcast first) resolves to the funding record through any round of that @@ -7797,6 +8162,54 @@ mod tests { assert_unchanged(&wallet, payment_id, true).await; } + /// A user-initiated splice's record is keyed by the PaymentId chosen at splice time, not by + /// its funding txid. The generic funding path must resolve a rebroadcast of that funding tx + /// back to the existing record rather than creating a duplicate under the txid-derived id. + #[tokio::test] + async fn classify_funding_resolves_the_splice_time_payment_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let txid = tx.compute_txid(); + + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; + let tx_type = TransactionType::Funding { channels: vec![] }; + wallet.classify_funding(&tx, &channels, tx_type).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the rebroadcast must not create a second record"); + assert_eq!(payments[0].id, payment_id); + assert_eq!(payments[0].amount_msat, Some(1_000_000)); + assert_eq!(payments[0].fee_paid_msat, Some(500)); + } + /// A funding broadcast whose classification fails must be retried, not dropped: no timer /// re-broadcasts a funding transaction, so a dropped package would keep the funding off-chain /// until LDK re-hands it when the channel next resumes. The record is written before the From d1d868c8774717a50bc533ed3dbc4efb22748a64 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 18 Aug 2026 17:13:08 -0500 Subject: [PATCH 19/49] DROP ME: Merge sync-created duplicates when recording a funding round Wallet sync can observe a funding round before it is recorded as a candidate: the counterparty broadcasts a round this node did not contribute to, which nothing records until this node signs a later round of the same splice and records the channel's history with it. The funding-status gate rightly reports such a round foreign, and sync re-keys the event to the round's txid-derived id, creating an untyped duplicate record whose pending entry from then on shadows the funding record in txid resolution: even after the round is recorded as a candidate, every later event routes to the duplicate, the confirmation strands there, and the funding record never confirms or graduates. Fold the duplicate back in when its round becomes a recorded candidate: adopt its confirmation onto the funding record -- through the same status-update path wallet sync uses, so the confirmed candidate's figures land -- and remove the duplicate along with its pending entry. A duplicate for a round that never confirmed is dropped without adopting anything; the actively-broadcast candidate stays the record's current txid. The merge runs when this node signs a round and records the channel's history with it, and again when LDK reports the round negotiated, under the writer's cross-store lock acquisition, so sync cannot interleave, and is idempotent, so a replayed SpliceNegotiated event can re-run it after a partial failure. At signing time the merge is a courtesy and a failure is only logged: the signed round can have no duplicate yet, as our signatures have not left the node, the round's SpliceNegotiated event re-runs the merge and replays on failure, and failing the signing would replay it against a record whose two-store write already completed, which the write's rollback does not cover. The pending entry is removed before the payment record: a replay rediscovers the duplicate through the record, so a failure between the two removals can still be cleaned up, instead of orphaning a pending entry that would shadow txid resolution all over again. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 85fee29a09ae5e58d011b22f9e393b74d01793eb) --- src/wallet/mod.rs | 769 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 747 insertions(+), 22 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index c03596fd2b..4fb2ee30ec 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2356,7 +2356,8 @@ impl Wallet { // payment store back as it was while the record is still pending, or the replayed event // would find the half-written record and take it for prior state. The write hands back // what it found in the payment store, read inside its own critical section. - if let Err(failure) = self.persist_funding_payment_locked(&stores, details, recorded).await + if let Err(failure) = + self.persist_funding_payment_locked(&stores, details, recorded.clone()).await { let (e, prior_details) = match failure { // The write pair failed before its first write, so there is nothing to put back. @@ -2390,15 +2391,32 @@ impl Wallet { txid, candidates.len(), ); + + // The record is complete; merging the duplicates wallet sync created for earlier rounds + // is a courtesy. The signed round can have no duplicate yet, as our signatures have not + // left the node, and the round's `SpliceNegotiated` event re-runs the merge, replaying on + // failure, so a failure here is logged rather than replaying the signing. + if let Err(e) = self.merge_duplicate_candidate_records(&stores, payment_id, &recorded).await + { + log_error!( + self.logger, + "Failed to merge duplicate records into funding payment {}: {}", + payment_id, + e, + ); + } Ok(()) } /// Marks a splice round recorded when signing ([`Self::record_signed_funding`]) as broadcast /// once LDK reports the splice negotiated: `SpliceNegotiated` is emitted only once our /// `tx_signatures` for the round are ready to send, so the counterparty may hold them by then - /// and may broadcast the round, which is therefore no longer dropped as abandoned. Nothing is - /// written for a round no funding payment of `channel_id` tracks (no local contribution, or no - /// wallet-level activity) or one already marked (a replayed event). + /// and may broadcast the round, which is therefore no longer dropped as abandoned. Then merges + /// the duplicate records wallet sync created for the record's candidates + /// ([`Self::merge_duplicate_candidate_records`]), completing a merge the signing left + /// unfinished. Nothing is written for a round no funding payment of `channel_id` tracks (no + /// local contribution, or no wallet-level activity); a replayed event finds the round marked + /// already and only re-runs the merge. pub(crate) async fn record_broadcast_splice_round( &self, channel_id: ChannelId, txid: Txid, ) -> Result<(), Error> { @@ -2408,13 +2426,12 @@ impl Wallet { let entries = stores .pending_payments(|entry| { - tracks_channel(entry, channel_id) - && entry.candidate(txid).is_some_and(|candidate| candidate.awaiting_broadcast) + tracks_channel(entry, channel_id) && entry.candidate(txid).is_some() }) .await; for entry in entries { let payment_id = entry.id(); - stores + let marked = stores .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); let PendingPaymentDetails::Tracked { candidates, .. } = &mut entry else { @@ -2427,13 +2444,19 @@ impl Wallet { Some(entry) }) .await?; - log_debug!( - self.logger, - "Marked splice round {} of channel {} as broadcast in funding payment {}", - txid, - channel_id, - payment_id, - ); + if marked.is_some() { + log_debug!( + self.logger, + "Marked splice round {} of channel {} as broadcast in funding payment {}", + txid, + channel_id, + payment_id, + ); + } + // The round's record is complete, so the duplicates wallet sync created for earlier + // rounds can be folded in. A failure replays the event, which re-runs the merge + // idempotently. + self.merge_duplicate_candidate_records(&stores, payment_id, entry.candidates()).await?; } Ok(()) } @@ -2720,9 +2743,11 @@ impl Wallet { Ok(()) } - /// Writes a freshly-classified funding payment to the authoritative payment store and adds a - /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. Returns the - /// payment store's record as it was before the write. + /// Writes a freshly-classified funding payment to the authoritative payment store, adds a + /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`, and + /// merges the duplicate records wallet sync created for its candidates, as + /// [`Self::merge_duplicate_candidate_records`] describes. Returns the payment store's record as + /// it was before the write. /// /// Production callers go through [`Self::persist_funding_payment_locked`] because they resolve /// the record's id under the same lock acquisition; this wrapper models that acquisition for @@ -2734,7 +2759,11 @@ impl Wallet { // Hold the cross-store lock across both writes so a funding confirmation never observes // the record classified but the candidate history it needs still missing. let stores = self.payment_stores.lock().await; - Ok(self.persist_funding_payment_locked(&stores, details, candidates).await?) + let id = details.id; + let prior = + self.persist_funding_payment_locked(&stores, details, candidates.clone()).await?; + self.merge_duplicate_candidate_records(&stores, id, &candidates).await?; + Ok(prior) } /// Writes a freshly recorded funding payment to the authoritative payment store and adds a @@ -2854,6 +2883,70 @@ impl Wallet { Ok(prior) } + /// Merges duplicate records wallet sync created for this funding payment's candidates before + /// they were recorded as such. Sync re-keys an event for a round it cannot attribute to the + /// funding record — not yet a candidate, so the funding-status gate reports it foreign — to + /// the round's txid-derived id, creating an untyped duplicate whose pending entry then + /// shadows the funding record in [`Self::find_payment_by_txid`]'s direct probe. Once the + /// round is a recorded candidate, the duplicate's confirmation (if any) belongs on the + /// funding record: adopt it, then remove the duplicate and its pending entry. + /// + /// Runs once a record's candidate history is written, so the funding-status gate accepts the + /// candidates it adopts, and under the writer's lock acquisition, so sync cannot interleave. + /// It is idempotent: a failure at signing time ([`Self::record_signed_funding`]) is left to the + /// signed round's `SpliceNegotiated` event ([`Self::record_broadcast_splice_round`]), which + /// re-runs the merge and replays on failure. The caller must hold the [`PaymentStores`] lock, + /// per [`Self::apply_funding_status_update_locked`]'s contract. + async fn merge_duplicate_candidate_records( + &self, stores: &PaymentStoresGuard<'_>, id: PaymentId, candidates: &[FundingTxCandidate], + ) -> Result<(), Error> { + for candidate in candidates { + let duplicate_id = PaymentId(candidate.txid.to_byte_array()); + if duplicate_id == id { + continue; + } + let duplicate = match stores.payment(&duplicate_id).await? { + Some(duplicate) => duplicate, + None => continue, + }; + // Only a duplicate view of this candidate's transaction qualifies: an untyped record + // wallet sync created, or one a funding-typed rebroadcast classified onto it. Anything + // else keyed by the txid-derived id is left alone. + let status = match &duplicate.kind { + PaymentKind::Onchain { + txid, + status, + tx_type: None | Some(TransactionType::Funding { .. }), + } if *txid == candidate.txid => status.clone(), + _ => continue, + }; + // Only a confirmation is worth adopting; an unconfirmed duplicate carries nothing the + // record needs — the actively-broadcast candidate stays the record's current txid. + if matches!(status, ConfirmationStatus::Confirmed { .. }) { + let outcome = self + .apply_funding_status_update_locked(stores, id, candidate.txid, status) + .await?; + debug_assert!(matches!(outcome, FundingStatusUpdate::Applied)); + if !matches!(outcome, FundingStatusUpdate::Applied) { + // Adoption declined; keep the duplicate rather than discard its confirmation. + continue; + } + } + log_debug!( + self.logger, + "Merging duplicate payment record for funding transaction {}", + candidate.txid, + ); + // Pending entry first: the retry of a failure between these two removals rediscovers + // the duplicate through its payment record. Removed the other way around, the + // leftover pending entry would be unreachable to the retry yet keep shadowing the + // funding record in `find_payment_by_txid`'s direct probe. + stores.remove_pending_payment(&duplicate_id).await?; + stores.remove_payment(&duplicate_id).await?; + } + Ok(()) + } + /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. pub(crate) fn onchain_payment_fields( &self, tx: &Transaction, @@ -4135,6 +4228,86 @@ mod tests { } } + /// An in-memory store that fails the next remove issued against an armed namespace, for + /// exercising cleanup paths that must survive a failure between two removals. + #[derive(Clone)] + struct FailRemoveStore { + inner: Arc, + fail_remove_in: Arc>>, + } + + impl FailRemoveStore { + fn new() -> Self { + Self { + inner: Arc::new(InMemoryStore::new()), + fail_remove_in: Arc::new(std::sync::Mutex::new(None)), + } + } + + fn fail_next_remove_in(&self, primary_namespace: &str) { + *self.fail_remove_in.lock().unwrap() = Some(primary_namespace.to_string()); + } + } + + impl KVStore for FailRemoveStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + KVStore::write(&*self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + let inner = Arc::clone(&self.inner); + let armed = Arc::clone(&self.fail_remove_in); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + async move { + let fail = { + let mut armed = armed.lock().unwrap(); + if armed.as_deref() == Some(primary_namespace.as_str()) { + *armed = None; + true + } else { + false + } + }; + if fail { + return Err(io::Error::new(io::ErrorKind::Other, "removes disabled")); + } + KVStore::remove(&*inner, &primary_namespace, &secondary_namespace, &key, lazy).await + } + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for FailRemoveStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + /// Constructs a `Wallet` around the given store, either creating a fresh BDK wallet or /// loading the one the store already holds. async fn new_test_wallet(store: Arc, load_existing: bool) -> Arc { @@ -6546,10 +6719,11 @@ mod tests { assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); } - /// Recording a first splice round costs one read of the payment store: the write pair reads - /// the record it merges into, and that read also serves the rollback of a failed write. + /// Recording a first splice round costs two reads of the payment store: the write pair reads + /// the record it merges into, which also serves the rollback of a failed write, and the + /// duplicate merge probes for a record wallet sync may have keyed by the round's own txid. #[tokio::test] - async fn a_first_round_signing_reads_the_payment_store_once() { + async fn a_first_round_signing_reads_the_payment_store_twice() { let counting_store = ReadCountingStore::new(); let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); let wallet = new_test_wallet(Arc::clone(&store), false).await; @@ -6563,7 +6737,7 @@ mod tests { let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; - assert_eq!(reads, 1, "recording a first round re-read the payment store"); + assert_eq!(reads, 2, "recording a first round re-read the payment store"); } /// The signing write fails between its two stores — the payment record lands, the pending @@ -8601,6 +8775,557 @@ mod tests { loop_task.await.unwrap(); } + /// Wallet sync can record a genuine replacement round before it is recorded as a candidate: + /// the counterparty broadcast a round this node did not contribute to, which is recorded only + /// when this node signs a later round of the splice. The funding-status gate then routes the + /// round's confirmation to a duplicate record keyed by the round's txid, whose pending entry + /// shadows the funding record in `find_payment_by_txid`'s direct probe. Once the round is + /// recorded as a candidate, the write must merge the duplicate — adopt its confirmation and + /// remove it — so a single record tracks the splice. + #[tokio::test] + async fn recording_a_round_merges_duplicate_records_for_its_candidates() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); + + // Round 1 recorded normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate: a + // duplicate untyped record under the txid-derived id, plus its pending entry. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(duplicate_id)); + + // Round 2 is recorded as a candidate, with the history of a later round this node signs. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.persist_funding_payment(details, rounds).await.unwrap(); + + // One record: the funding record carries the duplicate's confirmation and the confirmed + // candidate's figures; the duplicate and its pending entry are gone, so the round's txid + // resolves to the funding record again. + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + assert_eq!(payment.amount_msat, Some(1_000_000)); + assert_eq!(payment.fee_paid_msat, Some(400)); + match &payment.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } => assert_eq!(*txid, txid2), + kind => panic!("unexpected kind {:?}", kind), + } + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); + } + + /// A duplicate for an *unconfirmed* round carries no state the funding record needs: the + /// merge removes it without touching the record's active txid or figures, and the round's + /// txid maps back to the funding record through its candidate history. + #[tokio::test] + async fn recording_drops_unconfirmed_duplicates_without_adopting_their_txid() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); + + // Wallet sync saw round 1 — still unconfirmed — before any round was recorded. + let duplicate_id = PaymentId(txid1.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: txid1, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + + // Round 2 is the active broadcast; its record lists both rounds. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.persist_funding_payment(details, rounds).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + // The record keeps tracking the actively-broadcast round; a duplicate that never confirmed + // has nothing to adopt. + match &payment.kind { + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } => { + assert_eq!(*txid, txid2) + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(payment.fee_paid_msat, Some(400)); + assert_eq!(wallet.find_payment_by_txid(txid1).await.unwrap(), Some(funding_id)); + } + + /// Removing the duplicate is two store writes, and the failure between them must leave a + /// state a re-run of the merge (a replayed `SpliceNegotiated` event) can finish cleaning up. + /// If the payment record went first, a failure on the pending-entry removal would orphan that + /// entry where the re-run can no longer discover it (the record lookup misses), and it would + /// keep shadowing the funding record in `find_payment_by_txid`'s direct probe — re-creating + /// the duplicate problem with no further merge coming to fix it. + #[tokio::test] + async fn a_rerun_merge_completes_a_partially_failed_duplicate_removal() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); + + // Round 1 recorded normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + + // Round 2 is recorded as a candidate, but one of the duplicate's two removals fails. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let res = wallet.persist_funding_payment(details.clone(), rounds.clone()).await; + assert!(res.is_err(), "the injected remove failure must surface"); + + // The merge re-runs with the record's next write; it must finish the cleanup. + wallet.persist_funding_payment(details, rounds).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, funding_id); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); + } + + /// Signing a later round merges the duplicates of earlier rounds as a courtesy: the signed + /// round itself can have no duplicate yet, as our signatures have not left the node, and the + /// round's own `SpliceNegotiated` event re-runs the merge, replaying on failure. A merge + /// failure must therefore not fail the signing, whose record is complete once both stores are + /// written, and must not leave the record half rolled back. + #[tokio::test] + async fn signing_survives_a_failed_duplicate_merge() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // Round 1 is recorded at signing; round 2 is a counterparty-initiated replacement the + // wallet observed before it was recorded as a candidate, filed as an untyped duplicate. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new())) + .await + .unwrap(); + + // This node signs round 3, a bump of the replacement, but the duplicate's removal fails. + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + // The signing is recorded in full and the duplicate is left as it was. + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!( + entry.candidates().iter().map(|c| c.txid).collect::>(), + vec![txid, replacement_txid, bump_txid] + ); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_some()); + + // The bump's `SpliceNegotiated` event merges the duplicate away. + wallet.record_broadcast_splice_round(channel_id, bump_txid).await.unwrap(); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); + } + + /// A duplicate merge failing under the `SpliceNegotiated` write must fail that write: the + /// replay it triggers is the merge's only re-run. The mark, cleared before the merge, stays + /// cleared and the duplicate is left as it was; the replayed write finds the round marked + /// already and merges the duplicate away. + #[tokio::test] + async fn a_failed_duplicate_merge_fails_the_negotiation_write_until_its_replay() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // Round 1 is recorded at signing; round 2 is a counterparty-initiated replacement the + // wallet observed before it was recorded as a candidate, filed as an untyped duplicate; + // round 3, a bump this node signs, records round 2 as a candidate, but the signing's + // merge of the duplicate fails and is left to the bump's `SpliceNegotiated` event. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new())) + .await + .unwrap(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + // The event's write meets the same failure: it must surface, so the event is replayed, + // with the mark cleared and the duplicate untouched. + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let res = wallet.record_broadcast_splice_round(channel_id, bump_txid).await; + assert!(res.is_err(), "a failed merge must fail the write"); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(!entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_some()); + + // The replayed write finds the round marked already and merges the duplicate away. + wallet.record_broadcast_splice_round(channel_id, bump_txid).await.unwrap(); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); + } + + /// A merge cut short between adopting a confirmed duplicate's confirmation and removing the + /// duplicate leaves the funding record confirmed on the duplicate's transaction, the pending + /// entry at its prior status and the duplicate untouched, and a re-run completes the removal: + /// the merge is idempotent, so the record's next write or a replayed `SpliceNegotiated` event + /// can finish what a failure cut short. The failure injected is the pending store's, which the + /// adoption writes after the payment store. + #[tokio::test] + async fn a_torn_duplicate_merge_is_completed_by_a_rerun() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // Round 1 is recorded at signing, round 2 is a counterparty-initiated replacement, and + // round 3 is this node's bump of it, recorded with the channel's history when signed. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + // Wallet sync filed the replacement's confirmation under an untyped record of its own, a + // duplicate of the funding record that already lists the replacement as a candidate. + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: confirmed_status(), + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + let duplicate_entry = PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new()); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(duplicate_entry.clone()) + .await + .unwrap(); + let entry_before = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + let rounds = entry_before.candidates().to_vec(); + + // The merge adopts the confirmation onto the payment record, then fails to mirror it onto + // the pending entry and stops short of removing the duplicate. + fail_store.fail_writes.store(true, Ordering::Release); + { + let guard = wallet.payment_stores.lock().await; + let res = wallet.merge_duplicate_candidate_records(&guard, id, &rounds).await; + assert!(res.is_err(), "the injected pending-store failure must surface"); + } + fail_store.fail_writes.store(false, Ordering::Release); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } + if t == replacement_txid + )); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(entry_before) + ); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate_entry) + ); + + // A re-run finds the confirmation adopted, mirrors it, and removes the duplicate. + { + let guard = wallet.payment_stores.lock().await; + wallet.merge_duplicate_candidate_records(&guard, id, &rounds).await.unwrap(); + } + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); + } + /// Barrier test, classification-first ordering: wallet sync's confirmation handling must /// wait for classification's two-store write pair. Classification is parked between its /// payment-store and pending-store writes (the torn window) and only then is the From 5f5701446798d233fc76601cb7cda8ff408c7548 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 1 Sep 2026 19:12:33 -0500 Subject: [PATCH 20/49] DROP ME: Persist splice intents until the splice locks LDK only persists a splice once its negotiation reaches AwaitingSignatures, so a splice in flight when the node stops can leave no trace in LDK. Persist each user-initiated splice as an intent record before its contribution is handed to LDK, so such a splice can be recognized at the next startup -- releasing whatever the wallet still holds for it, which a later commit adds -- and so events about the splice can be described in terms of the original request. Each splice gets a record of its own, so that its failure is described from its own intent and a restart recognizes it whatever became of the channel's other splices: a splice queued behind a pending one negotiates as a splice of its own once the pending one locks, and its rounds must not be filed under the pending splice's payment. Only a fee bump joins an existing record, that of the round it replaces. A splice is refused while the channel carries an intent anchored at another funding -- one the lock that superseded it failed to settle or to re-anchor -- rather than recorded beside it. A submission reads the channel's funding under the lock that serializes splice submissions and anchors its intent there, not at the funding the caller read before building the contribution: a splice locking in between moves the funding, and an intent anchored at the old one would never be settled by the lock that superseded it. A funding that moved refuses a fee bump, whose round has locked, and a splice-in, whose inputs the locked round may have spent; a splice-out carries no wallet inputs and proceeds. A splice submitted after the previous one locked with zero confirmations settles that splice's intent first, as the lock's event would have: LDK promotes the funding as soon as splice_locked is exchanged but only queues the event. The lock and close event handlers settle intents under the same lock, so a lock handled mid-submission cannot settle the new intent before its contribution reaches LDK. The record is undone when LDK rejects the hand-off synchronously and settled once the splice locks, its failure is surfaced, or its channel closes. A failure event settles the intent only after the event is durably queued -- a crash in between leaves the intent for the replayed event to settle, erring toward a duplicate report over a lost one -- and only when the event's contribution identifies the recorded splice: a mismatch means the failure concerns an older, superseded attempt with no record of its own. Taking back the funding record of a signed round the failure abandoned leaves its intent behind as a bare intent, so the report can still describe the splice. A splice queued behind another pending splice survives the pending splice's lock, so its intent is re-anchored to the new funding rather than settled. Failing a funding payment -- when a round other than its own locks, when its channel closes, or when wallet sync finds its round lost to a confirmed conflicting spend -- likewise keeps the intent its entry carried, as a bare intent under an id of its own. LDK carries a fee bump queued behind a round it does not overlap across that round's lock and begins a fresh splice from it, so the intent is still needed: to re-anchor it at the new funding, to file the fresh round under it when signed, and to describe the failure LDK reports if the fresh negotiation fails instead. Under the failed record's id, the fresh round would take that record and go untracked. The lock and close handlers settle the kept intent right after it is kept, unless LDK still holds its splice and the lock re-anchors it instead; one kept from wallet sync stays anchored at the channel's unchanged funding, where a later fee bump joins it and no submission is refused on its account. Wallet state staged on a splice's behalf is flushed only after the intent record persists, so nothing the wallet reserves for a splice can outlive the record through which a later startup would release it. A splice that fails before the hand-off immediately releases what the wallet holds for it and no other round uses -- a fee bump built by adjusting the fee of the round it replaces shares that round's inputs and change address, which stay reserved while the round can confirm; one LDK rejects has it returned through the DiscardFunding event instead. A lock settles an intent without releasing anything: what the locked round did not spend, LDK returns through the DiscardFunding events it queues at the promotion. Once a splice funding payment is classified, the intent is carried on the payment's record until the splice locks or the payment fails; a payment that already graduated instead removes the leftover intent record. The funding payment recorded when this node signs a splice round is filed under the record of the intent carrying the round's contribution, written while holding the lock that serializes splice submissions, so neither a fee bump replacing the intent nor a failure settling it can interleave with the write. A signing write cut short after the payment store leaves that payment under a bare intent; it records a round whose signatures never left the node, so it is dropped -- when the replayed signing finds the round gone, or with the intent once the splice settles -- rather than promoted into a record nothing could ever drive. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit b9b0d24100cfb0ad1d522c27ae2e75b47455d70e) --- src/builder.rs | 9 + src/channel/mod.rs | 1331 ++++++++++++++++++++++++ src/data_store.rs | 68 ++ src/event.rs | 55 +- src/lib.rs | 117 ++- src/payment/pending_payment_store.rs | 57 +- src/wallet/mod.rs | 1417 ++++++++++++++++++++++---- src/wallet/payment_stores.rs | 17 + 8 files changed, 2845 insertions(+), 226 deletions(-) create mode 100644 src/channel/mod.rs diff --git a/src/builder.rs b/src/builder.rs index 1158044e47..a9ba3d0d3c 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -53,6 +53,7 @@ use lightning_dns_resolver::OMDomainResolver; use vss_client::headers::VssHeaderProvider; use crate::chain::ChainSource; +use crate::channel::SpliceTracker; #[cfg(feature = "chain-bitcoind")] use crate::config::BitcoindRestClientConfig; use crate::config::{ @@ -2500,6 +2501,13 @@ fn build_with_store_internal( }) }); + let splice_tracker = Arc::new(SpliceTracker::new( + Arc::clone(&channel_manager), + Arc::clone(&wallet), + Arc::clone(&pending_payment_store), + Arc::clone(&logger), + )); + #[cfg(cycle_tests)] let mut _leak_checker = crate::LeakChecker(Vec::new()); #[cfg(cycle_tests)] @@ -2551,6 +2559,7 @@ fn build_with_store_internal( payment_store, forwarding_store, forwarded_payment_aggregation_retention_secs, + splice_tracker, lnurl_auth, is_running, node_metrics, diff --git a/src/channel/mod.rs b/src/channel/mod.rs new file mode 100644 index 0000000000..183ef00c3b --- /dev/null +++ b/src/channel/mod.rs @@ -0,0 +1,1331 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Persistence of in-flight user-initiated splices, so a splice LDK has not durably learned of +//! yet can be recognized — and whatever it reserved recovered — after a restart. + +use std::fmt; +use std::sync::Arc; + +use bitcoin::absolute::LockTime; +use bitcoin::secp256k1::PublicKey; +use bitcoin::transaction::Version; +use bitcoin::{OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid}; +use lightning::chain::chaininterface::FundingCandidate; +use lightning::chain::transaction::OutPoint as LdkOutPoint; +use lightning::ln::channel_state::{ChannelDetails, SpliceCandidateDetails}; +use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::FundingContribution; +use lightning::ln::types::ChannelId; + +use crate::data_store::{StorableObject, UpdatableObject}; +use crate::logger::{log_error, LdkLogger, Logger}; +use crate::payment::pending_payment_store::{ + PendingPaymentDetails, PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind, +}; +use crate::payment::{PaymentKind, TransactionType}; +use crate::types::{ChannelManager, PendingPaymentStore}; +use crate::wallet::{funding_candidates, random_payment_id, Wallet}; +use crate::Error; + +/// Whether two contributions describe the same splice attempt. LDK may adjust a contribution +/// during negotiation — the quiescence tie-breaker rebuilds the acceptor's copy at a fresh +/// feerate, touching only its fee fields and change value — so fees and feerates do not identify +/// an attempt. Its inputs and outputs do: they are what the user asked to move. Contributions +/// carrying neither (channel-balance-only attempts) fall back to full equality. +pub(crate) fn is_same_splice(a: &FundingContribution, b: &FundingContribution) -> bool { + if a.inputs().is_empty() + && a.outputs().is_empty() + && b.inputs().is_empty() + && b.outputs().is_empty() + { + return a == b; + } + a.inputs().iter().map(|i| i.outpoint()).eq(b.inputs().iter().map(|i| i.outpoint())) + && a.outputs() == b.outputs() +} + +/// Tracks each user-initiated splice through a persisted [`SpliceIntent`] for as long as LDK is +/// not guaranteed to remember the splice itself: LDK only persists a splice once its negotiation +/// reaches `AwaitingSignatures`, and it abandons an in-progress negotiation whenever the peer +/// disconnects — which includes stopping the node. +/// +/// The intent is written before the contribution is handed to LDK, undone when LDK rejects the +/// hand-off synchronously, and cleared once the splice locks, its failure is surfaced, or its +/// channel closes. The record exists for recovery, not retry: a splice still recorded at the +/// next startup identifies one that was in flight when the node stopped, so anything it reserved +/// can be released, and events about the splice can be described in terms of the original +/// request. Each splice has a record of its own — a channel may carry several, a pending splice +/// and the splices queued behind it — so that each is recognized and described whatever became +/// of the others; only a fee bump joins the record of the round it replaces. +pub(crate) struct SpliceTracker { + channel_manager: Arc, + wallet: Arc, + pending_payment_store: Arc, + /// Serializes everything that reads or settles a channel's intent records against + /// [`Self::submit`]'s read-funding, persist and hand-off sequence: the settling of intents by + /// [`Self::on_negotiation_failed`], [`Self::on_channel_ready`] and + /// [`Self::on_channel_closed`], and the funding record [`Self::on_funding_ready_for_signing`] + /// files under an intent's id. Without it, the failure event of a synchronously rejected + /// hand-off could settle the just-written intent while `submit` is still deciding whether to + /// keep it, and a lock event handled between `submit`'s funding read and its persist could + /// leave the new intent anchored at a funding the channel has moved past, which nothing would + /// settle. Every public entry point takes it; the `_locked` variants assume it is held and + /// must not take it again (tokio's mutex is not reentrant). It nests outward of the wallet's + /// locks and the stores', which are taken while it is held and never hold it. An event + /// handler waiting on it waits for a `submit` to finish its bounded sequence, nothing more. + submit_lock: tokio::sync::Mutex<()>, + logger: Arc, +} + +impl SpliceTracker { + pub(crate) fn new( + channel_manager: Arc, wallet: Arc, + pending_payment_store: Arc, logger: Arc, + ) -> Self { + Self { + channel_manager, + wallet, + pending_payment_store, + submit_lock: tokio::sync::Mutex::new(()), + logger, + } + } + + /// Persists a user-initiated splice as an intent and hands its contribution to + /// [`ChannelManager::funding_contributed`]. The intent — and any wallet state staged on the + /// splice's behalf — is durable before the hand-off, so no splice is ever in flight without a + /// persisted record of it. Each splice gets a record of its own; only a fee bump joins the + /// record of the round it replaces ([`Self::persist_intent`]). + /// + /// The intent is anchored at the channel's funding as it stands under the submit lock, not at + /// `pre_splice_funding_txo`, the funding the caller read before building the contribution: a + /// splice locking in between moves the funding, and an intent anchored at the old one would + /// never be settled by the lock that superseded it. A funding that moved refuses a fee bump — + /// the round it was built to replace has locked — and a splice-in, whose inputs the locked + /// round may have spent; a splice-out carries no wallet inputs and proceeds, as LDK + /// re-validates its amount against the live balance ([`check_submission`]). Intents anchored + /// at a funding the channel has moved past are settled first, as their lock event would. + /// + /// On any failure the persisted intent is undone and the error returned for the caller to + /// surface. A failure before the hand-off also releases what the wallet holds for the + /// contribution and no other round claims ([`Self::release_contribution`]): a fee bump built + /// by adjusting the fee of the round it replaces — `prior`, the contribution it was built + /// from — reuses that round's inputs and change address, which a refusal must leave to the + /// round that has locked meanwhile. A synchronous rejection leaves the release to the + /// `DiscardFunding` event LDK queues. + /// + /// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed + pub(crate) async fn submit( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + pre_splice_funding_txo: LdkOutPoint, contribution: FundingContribution, kind: SpliceKind, + prior: Option, + ) -> Result<(), Error> { + let guard = self.submit_lock.lock().await; + let channel = self.channel(counterparty_node_id, channel_id); + let live_funding_txo = channel.as_ref().and_then(|channel| channel.funding_txo); + let candidates = channel + .as_ref() + .and_then(|channel| channel.splice_details.as_ref()) + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]); + let funding_txo = match check_submission(pre_splice_funding_txo, live_funding_txo, &kind) { + Ok(funding_txo) => funding_txo, + Err(refusal) => { + log_error!( + self.logger, + "Refusing to splice channel {} with counterparty {}: {}", + channel_id, + counterparty_node_id, + refusal, + ); + // TODO(#1037): `release_contribution` swallows a failed release. Once inputs are + // locked at coin selection, a failure here leaves locks no record names: surface + // it, or persist an intent for `reconcile` to release from. + self.release_contribution(channel_id, &contribution, candidates, prior.as_ref()) + .await; + return Err(Error::ChannelSplicingFailed); + }, + }; + // LDK promotes a zero-conf splice as soon as `splice_locked` is exchanged and only queues + // the `ChannelReady` event whose handling settles the locked splice's intent. A splice + // submitted in between builds on the new funding while the channel still carries that + // intent: settle it here as the event would. + self.settle_superseded_intents_locked( + &guard, + counterparty_node_id, + channel_id, + funding_txo.into_bitcoin_outpoint(), + channel.as_ref(), + ) + .await; + let intent = SpliceIntent { + counterparty_node_id, + channel_id, + pre_splice_funding_txo: funding_txo, + contribution: contribution.clone(), + kind, + }; + // A splice whose intent cannot be persisted is not attempted at all, rather than + // attempted without restart coverage. + let (payment_id, restore) = match self.persist_intent(intent, channel.as_ref()).await { + Ok(persisted) => persisted, + Err(e) => { + log_error!( + self.logger, + "Failed to persist the splice intent for channel {} with counterparty {}: {:?}", + channel_id, + counterparty_node_id, + e, + ); + // TODO(#1037): as at the refusal above, a failed release here leaves locks no + // record names. + self.release_contribution(channel_id, &contribution, candidates, prior.as_ref()) + .await; + return Err(e); + }, + }; + // Flush wallet state staged on the splice's behalf (e.g. input locks) only now that the + // intent record is durable: whatever the wallet holds for a splice must never outlive the + // record through which a later startup would release it. + // TODO(#1037): nothing is staged yet, and #1037 persists its input locks at coin + // selection, ahead of the intent. Stage them instead, so that this flush is what makes + // them durable. + if let Err(e) = self.wallet.persist_staged().await { + log_error!( + self.logger, + "Failed to persist staged wallet state for splicing channel {} with counterparty \ + {}: {:?}", + channel_id, + counterparty_node_id, + e, + ); + // TODO(#1037): the intent is discarded before the release; a release that fails + // leaves locks no record names. Keep the intent instead when the release fails. + self.discard_persisted_intent(&payment_id, restore).await; + self.release_contribution(channel_id, &contribution, candidates, prior.as_ref()).await; + return Err(e); + } + if let Err(e) = self.channel_manager.funding_contributed( + &channel_id, + &counterparty_node_id, + contribution, + None, + ) { + log_error!( + self.logger, + "LDK rejected the splice contribution for channel {} with counterparty {}: {:?}", + channel_id, + counterparty_node_id, + e, + ); + // LDK returns the contribution through a `DiscardFunding` event, whose handling frees + // the addresses the wallet marked for it. + // TODO(#1037): the handler ignores the event's inputs; once inputs are locked at coin + // selection, it must unlock them as well. + self.discard_persisted_intent(&payment_id, restore).await; + return Err(Error::ChannelSplicingFailed); + } + Ok(()) + } + + /// Releases what the wallet may still hold for a contribution that is going nowhere, short of + /// what another contribution claims as well — a splice candidate LDK holds for the channel, or + /// the round a fee bump was built from (`prior`): the remaining inputs are unlocked and a + /// transaction paying the remaining outputs is canceled, freeing the addresses of its change + /// and splice-out outputs ([`unclaimed_parts`]). A fee bump built by adjusting the fee of the + /// round it replaces reuses that round's inputs and change address; released along with the + /// bump, they would be free for other spends while the round can still confirm. + async fn release_contribution( + &self, channel_id: ChannelId, contribution: &FundingContribution, + candidates: &[SpliceCandidateDetails], prior: Option<&FundingContribution>, + ) { + let claimants = candidates.iter().filter_map(|c| c.contribution.as_ref()).chain(prior); + let (inputs, outputs) = unclaimed_parts(contribution, claimants); + if inputs.is_empty() && outputs.is_empty() { + return; + } + // TODO(#1037): `cancel_tx` unlocks the transaction's inputs itself once inputs are locked + // at coin selection, making this unlock redundant. + if let Err(e) = self.wallet.unlock_outpoints(&inputs).await { + log_error!( + self.logger, + "Failed to release the inputs of a splice contribution on channel {}: {}", + channel_id, + e, + ); + } + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs + .into_iter() + .map(|previous_output| TxIn { previous_output, ..TxIn::default() }) + .collect(), + output: outputs, + }; + if let Err(e) = self.wallet.cancel_tx(tx).await { + log_error!( + self.logger, + "Failed to release the outputs of a splice contribution on channel {}: {}", + channel_id, + e, + ); + } + } + + /// Persists `intent` before its contribution is handed to LDK, outliving a restart that — + /// until the negotiation reaches `AwaitingSignatures` — LDK's own state does not. + /// + /// Each splice gets a record of its own, so a channel may carry several: a splice queued + /// behind a pending one negotiates as a splice of its own once the pending one locks. Only a + /// fee bump joins an existing record, that of the round it replaces ([`place_intent`]), decided + /// from the channel's pending records and the splice rounds LDK holds for the channel + /// (`channel`, as the caller listed it). A record still anchored at another funding is one + /// [`Self::submit`] just failed to settle or to re-anchor; the new splice is refused rather + /// than recorded beside it. Returns the id and, for restoring on a rejected hand-off, `None` + /// when a fresh record was created or `Some(prior)` when an existing record's intent was + /// replaced (`prior` being `None` for a record that carried no intent). + async fn persist_intent( + &self, intent: SpliceIntent, channel: Option<&ChannelDetails>, + ) -> Result<(PaymentId, Option>), Error> { + let records = self + .pending_payment_store + .list_filter(|p| concerns_channel(p, intent.counterparty_node_id, intent.channel_id)) + .await; + let held_rounds: Vec = channel + .map(|channel| { + funding_candidates( + channel.splice_details.as_ref(), + intent.counterparty_node_id, + intent.channel_id, + ) + }) + .unwrap_or_default() + .into_iter() + .map(|candidate| candidate.txid) + .collect(); + match place_intent(&intent, &records, &held_rounds) { + IntentPlacement::Refused => { + log_error!( + self.logger, + "Refusing to splice channel {} with counterparty {}: the channel carries a \ + splice intent anchored at another funding", + intent.channel_id, + intent.counterparty_node_id, + ); + Err(Error::ChannelSplicingFailed) + }, + IntentPlacement::Reuse(payment_id) => { + let prior = records + .iter() + .find(|record| record.id() == payment_id) + .and_then(|record| record.splice_intent().cloned()); + self.pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(Some(intent)), + }) + .await?; + Ok((payment_id, Some(prior))) + }, + IntentPlacement::Fresh => { + let payment_id = random_payment_id(); + self.pending_payment_store + .insert(PendingPaymentDetails::pending_splice(payment_id, intent)) + .await?; + Ok((payment_id, None)) + }, + } + } + + /// Undoes a splice intent persisted for a hand-off that then failed before LDK took the + /// splice: restores an existing record's prior intent, or removes a freshly created record. + async fn discard_persisted_intent( + &self, payment_id: &PaymentId, restore: Option>, + ) { + let result = match restore { + Some(prior) => self + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: *payment_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(prior), + }) + .await + .map(|_| ()), + None => self.pending_payment_store.remove(payment_id).await, + }; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to undo the intent of rejected splice payment {}: a stale intent record \ + may be left behind: {}", + payment_id, + e, + ); + } + } + + /// Clears the persisted intent behind a splice that settled — it locked, its failure was + /// surfaced, or its channel closed — but only while `still_applies` holds for the stored + /// intent: a mismatch means a fee bump took over the record in the meantime, and its intent + /// must stay. A tracked record stays, with the intent cleared, so its payment keeps + /// graduating. A bare intent record is removed, along with any payment record under its id: + /// the signing-time recording of a splice round files a payment under an intent's id and + /// promotes the entry in the same write, so a payment record found under a bare intent is the + /// first half of a write that never completed, of a round whose signatures never left the + /// node, so nothing can broadcast it and no entry would ever drive the record + /// ([`Wallet::drop_unindexed_record_of_settled_intent`]). The record goes first: a bare intent + /// left behind is found and settled again, an orphaned record would not be. + async fn clear_persisted_intent bool>( + &self, payment_id: PaymentId, still_applies: F, + ) { + let still_applies = &still_applies; + let result: Result<(), Error> = async { + let mut remove_bare_record = false; + // The `move` closure would capture a plain `bool` by copy, so hand it a reference; the + // borrow ends with the mutate's future, before the flag is read below. + let removal_flag = &mut remove_bare_record; + self.pending_payment_store + .mutate(&payment_id, move |existing| { + let record = existing?; + match record.splice_intent() { + Some(intent) if still_applies(intent) => {}, + _ => return None, + } + let replacement = record_with_intent_cleared(record); + // A bare intent record cannot be cleared in place; it is removed below. + *removal_flag = replacement.is_none(); + replacement + }) + .await?; + if remove_bare_record { + self.wallet.drop_unindexed_record_of_settled_intent(payment_id).await?; + self.pending_payment_store + .remove_if(&payment_id, |record| { + record.details().is_none() + && record.splice_intent().is_some_and(still_applies) + }) + .await?; + } + Ok(()) + } + .await; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to clear the persisted intent of splice payment {}: a stale intent record \ + may be left behind: {}", + payment_id, + e, + ); + } + } + + /// Begins settling the recorded splice a failure event concerns, snapshotting the intent + /// `contribution` identifies among the channel's ([`record_of_failed_splice`]) — if any; a + /// failure of some other attempt (e.g. one superseded by a fee bump, whose failure LDK + /// reports separately) identifies nothing and settles nothing. The returned + /// [`FailureSettlement`] holds the submit lock until it is settled or dropped, so no new + /// splice can take the record in between: without it, a failure event could settle the intent + /// of an identical splice submitted while the event was being reported, or race `submit`'s + /// undo of a synchronously rejected hand-off. + /// + /// Settle only once the user-facing event is durably queued, and drop the settlement when + /// queueing fails: LDK then replays the failure event, and a cleared intent must mean the + /// failure was reported. + pub(crate) async fn on_negotiation_failed( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + contribution: Option<&FundingContribution>, + ) -> FailureSettlement<'_> { + let guard = self.submit_lock.lock().await; + let records = self.intent_records_for_channel(counterparty_node_id, channel_id).await; + let matched = record_of_failed_splice(&records, contribution); + FailureSettlement { tracker: self, _guard: guard, matched } + } + + /// Settles the persisted intents made obsolete by the channel's funding having moved on to + /// `funding_txo`, the funding a `ChannelReady` event reports as locked + /// ([`Self::settle_superseded_intents_locked`]). Takes the submit lock, so the settlement + /// cannot interleave with a splice being submitted. + pub(crate) async fn on_channel_ready( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + funding_txo: Option, + ) { + let Some(funding_txo) = funding_txo else { + return; + }; + let guard = self.submit_lock.lock().await; + let channel = self.channel(counterparty_node_id, channel_id); + self.settle_superseded_intents_locked( + &guard, + counterparty_node_id, + channel_id, + funding_txo, + channel.as_ref(), + ) + .await; + } + + /// Settles any persisted intent made obsolete by the channel's funding having moved on to + /// `funding_txo`: the funding a `ChannelReady` event reports as locked, or the one a new + /// splice builds on ([`Self::submit`]). Each of the channel's intents is decided on its own + /// ([`decide_on_lock`]), against the splice candidates LDK holds for the channel (`channel`, + /// as the caller listed it): one whose pre-splice outpoint is that funding was created after + /// the lock and stays; one LDK still holds as a queued splice candidate is re-anchored to the + /// funding it now builds on rather than settled; any other is settled, and what the wallet + /// holds for it is either spent by the locked round or returned by LDK through + /// `DiscardFunding`. The caller holds the submit lock. + async fn settle_superseded_intents_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, counterparty_node_id: PublicKey, + channel_id: ChannelId, funding_txo: OutPoint, channel: Option<&ChannelDetails>, + ) { + let records = self.intent_records_for_channel(counterparty_node_id, channel_id).await; + let candidates = channel + .and_then(|channel| channel.splice_details.as_ref()) + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]); + for record in records { + let payment_id = record.id(); + let Some(intent) = record.splice_intent().cloned() else { + continue; + }; + match decide_on_lock(&intent, funding_txo, candidates) { + LockDecision::Keep => {}, + LockDecision::Refresh => { + if let Some(new_funding_txo) = channel.and_then(|channel| channel.funding_txo) { + self.refresh_intent_funding(payment_id, &intent, new_funding_txo).await; + } + }, + LockDecision::Settle => { + // Nothing the wallet holds for the intent is released here. The inputs the + // locked round spent are gone with it, and whatever a superseded round reserved + // beyond them, LDK returns through the `DiscardFunding` events it queues at the + // promotion. A guard on the wallet's transaction graph could not tell the two + // apart: today the graph learns an interactive funding from sync alone. Once + // inputs are locked at coin selection (#1037), releasing them here would free + // the promoted round's inputs for a conflicting spend: #1037 prepares and + // unlocks only `Funding`-typed broadcasts, and a splice round is broadcast as + // `InteractiveFunding`. + self.clear_persisted_intent(payment_id, |i| *i == intent).await; + }, + } + } + } + + /// Re-anchors a still-live intent to the funding outpoint it now builds on, but only while + /// the record still carries the intent this decision was made for. + async fn refresh_intent_funding( + &self, payment_id: PaymentId, intent: &SpliceIntent, new_funding_txo: LdkOutPoint, + ) { + let refreshed = SpliceIntent { pre_splice_funding_txo: new_funding_txo, ..intent.clone() }; + let result = self + .pending_payment_store + .mutate(&payment_id, |existing| { + let mut record = existing?.clone(); + if record.splice_intent() != Some(intent) { + return None; + } + let update = PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(Some(refreshed)), + }; + record.update(update).then_some(record) + }) + .await; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to re-anchor the intent of queued splice payment {}: {}", + payment_id, + e, + ); + } + } + + /// Records the funding payment of a splice round this node has just signed but not yet handed + /// back to LDK, through [`Wallet::record_signed_funding`], so the record precedes any + /// broadcast: the counterparty cannot broadcast before receiving our `tx_signatures`, which + /// only [`ChannelManager::funding_transaction_signed`] releases. Holding the submit lock keeps + /// the channel's intent records — one of which the funding record adopts — from changing + /// mid-write: a concurrent [`Self::submit`] adding or replacing an intent, or a lock or + /// failure event settling one. + /// + /// [`ChannelManager::funding_transaction_signed`]: lightning::ln::channelmanager::ChannelManager::funding_transaction_signed + pub(crate) async fn on_funding_ready_for_signing( + &self, tx: &Transaction, candidates: &[FundingCandidate], + ) -> Result<(), Error> { + let _guard = self.submit_lock.lock().await; + self.wallet.record_signed_funding(tx, candidates).await + } + + /// Settles every persisted intent of a closed channel, as there is nothing left to splice. + /// Takes the submit lock, so the settlement cannot interleave with a splice being submitted. + /// Nothing the wallet holds for the intents is released here: a round the channel's monitor + /// watches may still confirm, and what LDK reserved for the others it returns through + /// `DiscardFunding` once the close matures. A signed round the monitor never watched — the + /// counterparty's `commitment_signed` never arrived — is released by neither. + // TODO(#1037): once inputs are locked at coin selection, such a round's inputs stay locked + // with no record to release them from after its intent is cleared here. Release the parts of + // the contribution no watched round uses before clearing. + pub(crate) async fn on_channel_closed( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) { + let _guard = self.submit_lock.lock().await; + for record in self.intent_records_for_channel(counterparty_node_id, channel_id).await { + self.clear_persisted_intent(record.id(), |_| true).await; + } + } + + /// Returns the pending records carrying a splice intent for the given channel: one per + /// splice of the channel still in flight, a fee bump sharing the record of the round it + /// replaces. + async fn intent_records_for_channel( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Vec { + self.pending_payment_store + .list_filter(|p| { + p.splice_intent().is_some_and(|i| { + i.channel_id == channel_id && i.counterparty_node_id == counterparty_node_id + }) + }) + .await + } + + /// The channel as LDK lists it, if it still does. + fn channel( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Option { + self.channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .into_iter() + .find(|channel| channel.channel_id == channel_id) + } +} + +/// The in-progress settlement of a splice failure, returned by +/// [`SpliceTracker::on_negotiation_failed`]. It snapshots the recorded intent the failure +/// identifies and holds the submit lock, so the record cannot change between the snapshot and +/// [`Self::settle`]. +pub(crate) struct FailureSettlement<'a> { + tracker: &'a SpliceTracker, + _guard: tokio::sync::MutexGuard<'a, ()>, + /// The record and intent the failure identifies, if any. + matched: Option<(PaymentId, SpliceIntent)>, +} + +impl FailureSettlement<'_> { + /// Settles the snapshotted intent, if any. Call only once the user-facing failure event is + /// durably queued. + pub(crate) async fn settle(self) { + let FailureSettlement { tracker, _guard, matched } = self; + if let Some((payment_id, intent)) = matched { + tracker.clear_persisted_intent(payment_id, move |i| *i == intent).await; + } + } +} + +/// Why a submission is refused once the channel's funding turns out to differ from the one the +/// caller built the contribution against, decided by [`check_submission`]. +#[derive(Debug, PartialEq, Eq)] +enum SubmissionRefusal { + /// LDK no longer lists the channel, or lists it without a funding. + ChannelGone, + /// The round a fee bump was built to replace has locked; there is nothing left to bump. + BumpedRoundLocked, + /// A splice locked while the splice-in's inputs were being selected, and may have spent + /// them. + InputsMayBeSpent, +} + +impl fmt::Display for SubmissionRefusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ChannelGone => write!(f, "the channel is gone or has no funding"), + Self::BumpedRoundLocked => { + write!(f, "the funding moved since the bump was built; its round has locked") + }, + Self::InputsMayBeSpent => write!( + f, + "the funding moved since the splice was built; the locked round may have spent \ + its inputs" + ), + } + } +} + +/// Whether a submission built against `requested_funding` may proceed now that the channel's +/// funding is `live_funding`, and at which funding to anchor its intent. A funding that has not +/// moved proceeds. One that has means a splice locked while the contribution was being built, +/// and only a splice-out proceeds, anchored at the live funding: it carries no wallet inputs, +/// and LDK re-validates its amount against the live balance. A fee bump was built to replace +/// that very round — a bump template is only offered for an unconfirmed, unlocked pending round +/// — and is refused rather than handed to LDK as a fresh splice reusing the locked round's +/// inputs. A splice-in is refused because its inputs were selected before the lock and may be +/// among those the locked round spent, which the wallet only learns from a sync: handed to LDK, +/// such a contribution would negotiate a splice whose transaction can never confirm, and neither +/// LDK nor this node would ever fail it. Refusing costs the caller one retry of a rare race. +// TODO(#1037): once inputs are locked from coin selection until the round is broadcast, and the +// round is applied to the wallet's transaction graph as it is broadcast, a wallet-selected input +// cannot be one a promoted round spent, and `InputsMayBeSpent` can go with its refusal. +// `BumpedRoundLocked` stays: a bump reuses the locked round's inputs. This needs the unlock and +// the graph insertion to happen together, as #1037's broadcast preparation does. +fn check_submission( + requested_funding: LdkOutPoint, live_funding: Option, kind: &SpliceKind, +) -> Result { + let live_funding = live_funding.ok_or(SubmissionRefusal::ChannelGone)?; + if live_funding == requested_funding { + return Ok(live_funding); + } + match kind { + SpliceKind::Rbf {} => Err(SubmissionRefusal::BumpedRoundLocked), + SpliceKind::In { .. } => Err(SubmissionRefusal::InputsMayBeSpent), + SpliceKind::Out { .. } => Ok(live_funding), + } +} + +/// The parts of `contribution` none of `claimants` uses: the inputs none of them spends, and the +/// outputs — change included — paying a script none of them pays. Outputs are matched by script +/// rather than as a whole, as LDK's `DiscardFunding` matches them: a fee-adjusted bump pays its +/// change to the same address as the round it replaces, at a different amount. +fn unclaimed_parts<'a>( + contribution: &FundingContribution, + claimants: impl IntoIterator, +) -> (Vec, Vec) { + let mut claimed_inputs: Vec = Vec::new(); + let mut claimed_scripts: Vec<&ScriptBuf> = Vec::new(); + for claimant in claimants { + claimed_inputs.extend(claimant.inputs().iter().map(|input| input.outpoint())); + claimed_scripts.extend( + claimant + .outputs() + .iter() + .chain(claimant.change_output()) + .map(|output| &output.script_pubkey), + ); + } + let inputs = contribution + .inputs() + .iter() + .map(|input| input.outpoint()) + .filter(|outpoint| !claimed_inputs.contains(outpoint)) + .collect(); + let outputs = contribution + .outputs() + .iter() + .chain(contribution.change_output()) + .filter(|output| !claimed_scripts.contains(&&output.script_pubkey)) + .cloned() + .collect(); + (inputs, outputs) +} + +/// Whether a pending record concerns the given channel's splices: it carries a splice intent for +/// the channel, or tracks an interactive funding payment of it. +fn concerns_channel( + record: &PendingPaymentDetails, counterparty_node_id: PublicKey, channel_id: ChannelId, +) -> bool { + if let Some(intent) = record.splice_intent() { + return intent.channel_id == channel_id + && intent.counterparty_node_id == counterparty_node_id; + } + match record.details().map(|details| &details.kind) { + Some(PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + }) => channels.iter().any(|channel| { + channel.channel_id == channel_id && channel.counterparty_node_id == counterparty_node_id + }), + _ => false, + } +} + +/// Where the intent of a new submission is recorded, decided by [`place_intent`]. +#[derive(Debug, PartialEq, Eq)] +enum IntentPlacement { + /// The channel carries an intent anchored at another funding, one [`SpliceTracker::submit`] + /// just failed to settle or to re-anchor; the submission is refused. + Refused, + /// The submission joins the given record. + Reuse(PaymentId), + /// The submission gets a record of its own. + Fresh, +} + +/// Decides where the intent of a new submission is recorded, given the channel's pending records +/// (`records`: those carrying an intent for the channel or tracking a funding payment of it) and +/// the txids of the splice rounds LDK holds for the channel (`held_rounds`, the pending rounds +/// with a transaction; not the funding). +/// +/// Every splice gets a record of its own, so that its failure is described from its own intent +/// and a restart recognizes it whatever became of the channel's other splices. A splice-in or +/// splice-out therefore always starts fresh: while a round of ours is pending and bumpable the +/// entry points refuse a new one, and a contribution LDK takes beside a pending splice — queued +/// behind it, or joining a round the counterparty is negotiating — is a splice of its own. Only +/// a fee bump joins an existing record, that of the round it replaces: the record tracking a +/// round LDK still holds — intent-less when an earlier bump failed and its settlement cleared +/// the intent — or else the channel's bare intent record, whose round negotiated but recorded +/// nothing (a splice-out to an external address). A bump joining a bare record shares its fate: +/// the bump's failure removes the record, so a later bump starts fresh. +fn place_intent( + intent: &SpliceIntent, records: &[PendingPaymentDetails], held_rounds: &[Txid], +) -> IntentPlacement { + let anchored_elsewhere = records.iter().any(|record| { + record + .splice_intent() + .is_some_and(|i| i.pre_splice_funding_txo != intent.pre_splice_funding_txo) + }); + if anchored_elsewhere { + return IntentPlacement::Refused; + } + match intent.kind { + SpliceKind::Rbf {} => { + let tracks_held_round = |record: &&PendingPaymentDetails| { + record.candidates().iter().any(|candidate| held_rounds.contains(&candidate.txid)) + }; + records + .iter() + .find(tracks_held_round) + .or_else(|| records.iter().find(|record| record.splice_intent().is_some())) + .map_or(IntentPlacement::Fresh, |record| IntentPlacement::Reuse(record.id())) + }, + SpliceKind::In { .. } | SpliceKind::Out { .. } => IntentPlacement::Fresh, + } +} + +/// The record, and its intent, of the splice a failure event identifies by `contribution` among +/// the channel's intent records: the one whose intent's contribution is the same attempt +/// ([`is_same_splice`]). A failure that reports no contribution identifies nothing, as does one +/// whose contribution matches no recorded intent — an attempt superseded by a fee bump, whose +/// failure LDK reports separately. +fn record_of_failed_splice( + records: &[PendingPaymentDetails], contribution: Option<&FundingContribution>, +) -> Option<(PaymentId, SpliceIntent)> { + let contribution = contribution?; + records.iter().find_map(|record| { + let intent = record.splice_intent()?; + is_same_splice(&intent.contribution, contribution).then(|| (record.id(), intent.clone())) + }) +} + +/// What a lock of the funding a channel has moved on to — or a new splice building on it — +/// means for one of the channel's recorded intents, decided by [`decide_on_lock`]. +#[derive(Debug, PartialEq, Eq)] +enum LockDecision { + /// The intent is anchored at that funding: its splice was submitted after the lock. + Keep, + /// LDK still holds the intent's contribution as a splice candidate — a splice queued behind + /// the one that locked, carried across the lock — so the intent is re-anchored to the new + /// funding. + Refresh, + /// The lock superseded the intent's splice: the splice locked, a replacement or a + /// counterparty splice locked instead, or the queued splice was failed at the lock. The + /// intent is settled. + /// + /// A queued splice fails at the lock when its contribution overlaps the promoted transaction. + /// LDK takes it out of the queue and reports the failure after the `ChannelReady` of the + /// lock, so the intent is settled here first and the failure surfaces without the splice's + /// parameters. The overlap check at queue time — against this node's own contributions to the + /// pending rounds — lets only a contribution naming an input or output the counterparty + /// contributed to the promoted round get this far, which this node's wallet does not produce. + Settle, +} + +/// Decides what the channel's funding having moved on to `funding_txo` means for `intent`, given +/// the splice candidates LDK holds for the channel. +fn decide_on_lock( + intent: &SpliceIntent, funding_txo: OutPoint, candidates: &[SpliceCandidateDetails], +) -> LockDecision { + if intent.pre_splice_funding_txo.into_bitcoin_outpoint() == funding_txo { + return LockDecision::Keep; + } + let still_held = candidates.iter().any(|candidate| { + candidate.contribution.as_ref().is_some_and(|c| is_same_splice(c, &intent.contribution)) + }); + if still_held { + LockDecision::Refresh + } else { + LockDecision::Settle + } +} + +/// The replacement for a pending record whose splice intent is being dropped. A tracked record +/// keeps its payment details with just the intent cleared. A bare intent record has nothing to +/// keep and is left for the caller to remove — never promoted over a payment record found under +/// its id, which is the first half of a write — for a round of ours, the signing write — that +/// never completed rather than a payment to keep graduating (see +/// [`SpliceTracker::clear_persisted_intent`]). +fn record_with_intent_cleared(existing: &PendingPaymentDetails) -> Option { + match existing { + PendingPaymentDetails::PendingSplice { .. } => None, + PendingPaymentDetails::Tracked { .. } => { + let mut tracked = existing.clone(); + let update = PendingPaymentDetailsUpdate { + id: tracked.id(), + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + tracked.update(update).then_some(tracked) + }, + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bitcoin::hashes::Hash; + use bitcoin::{Amount, Txid}; + + use super::*; + use crate::payment::pending_payment_store::{ + test_funding_contribution, test_funding_contribution_with_feerate, + test_funding_contribution_with_outputs, test_funding_contribution_with_parts, + FundingTxCandidate, + }; + use crate::payment::store::{ConfirmationStatus, PaymentDetails, PaymentKind}; + use crate::payment::{PaymentDirection, PaymentStatus}; + use lightning::ln::channel_state::SpliceCandidateStatus; + + fn test_intent() -> SpliceIntent { + SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([7u8; 32]), + pre_splice_funding_txo: LdkOutPoint { + txid: Txid::from_byte_array([3u8; 32]), + index: 0, + }, + contribution: test_funding_contribution(), + kind: SpliceKind::Rbf {}, + } + } + + fn payment_details(id: PaymentId, status: PaymentStatus) -> PaymentDetails { + PaymentDetails::new( + id, + PaymentKind::Onchain { + txid: Txid::from_byte_array([1u8; 32]), + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(1_000_000), + Some(500), + PaymentDirection::Outbound, + status, + ) + } + + /// A bare intent entry has nothing to keep once its intent is cleared: it is removed rather + /// than promoted, whatever the payment store holds under its id — a payment record there is + /// the first half of a signing write that never completed, which the caller removes as well. + #[test] + fn intent_clearing_removes_a_bare_intent_entry() { + let id = PaymentId([9u8; 32]); + let existing = PendingPaymentDetails::pending_splice(id, test_intent()); + assert!(record_with_intent_cleared(&existing).is_none()); + } + + /// A tracked record keeps its payment details; only the intent is cleared. + #[test] + fn intent_clearing_keeps_a_tracked_record() { + let id = PaymentId([9u8; 32]); + let details = payment_details(id, PaymentStatus::Pending); + let existing = PendingPaymentDetails::tracked( + details.clone(), + Vec::new(), + Vec::new(), + Some(test_intent()), + ); + + let replacement = record_with_intent_cleared(&existing); + let replacement = replacement.expect("the entry must survive with its intent cleared"); + assert_eq!(replacement.details(), Some(&details)); + assert!(replacement.splice_intent().is_none()); + } + + #[test] + fn contributions_match_by_inputs_and_outputs() { + use bitcoin::{ScriptBuf, TxOut}; + + let outputs = + vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: ScriptBuf::new() }]; + // Fee fields differ, inputs and outputs agree: the same attempt. LDK may adjust a + // contribution during negotiation — the quiescence tie-breaker rebuilds the acceptor's + // copy at a fresh feerate — and events then carry the adjusted copy, which must still + // identify the recorded splice. + let a = test_funding_contribution_with_outputs(0, 253, &outputs); + let b = test_funding_contribution_with_outputs(0, 500, &outputs); + assert!(is_same_splice(&a, &b)); + + // Different outputs are a different attempt. + let other = vec![TxOut { value: Amount::from_sat(2_000), script_pubkey: ScriptBuf::new() }]; + assert!(!is_same_splice(&a, &test_funding_contribution_with_outputs(0, 253, &other))); + + // Contributions moving nothing (no inputs, no outputs) only match themselves exactly. + assert!(is_same_splice(&test_funding_contribution(), &test_funding_contribution())); + assert!(!is_same_splice( + &test_funding_contribution(), + &test_funding_contribution_with_feerate(500) + )); + } + + fn intent_with( + kind: SpliceKind, funding_byte: u8, contribution: FundingContribution, + ) -> SpliceIntent { + SpliceIntent { + pre_splice_funding_txo: LdkOutPoint { + txid: Txid::from_byte_array([funding_byte; 32]), + index: 0, + }, + contribution, + kind, + ..test_intent() + } + } + + fn splice_out_contribution(value_sat: u64) -> FundingContribution { + use bitcoin::{ScriptBuf, TxOut}; + let outputs = + vec![TxOut { value: Amount::from_sat(value_sat), script_pubkey: ScriptBuf::new() }]; + test_funding_contribution_with_outputs(300, 253, &outputs) + } + + /// A tracked record of the test channel whose funding payment names `txid` and whose history + /// lists `candidates`, carrying `intent` if any. + fn tracked_record( + id: PaymentId, txid: Txid, candidates: &[Txid], intent: Option, + ) -> PendingPaymentDetails { + use crate::payment::store::Channel; + let base = test_intent(); + let details = PaymentDetails::new( + id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { + channels: vec![Channel { + counterparty_node_id: base.counterparty_node_id, + channel_id: base.channel_id, + }], + }), + }, + Some(1_000_000), + Some(500), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + let candidates = candidates + .iter() + .map(|txid| FundingTxCandidate { + txid: *txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }) + .collect(); + PendingPaymentDetails::tracked(details, Vec::new(), candidates, intent) + } + + fn txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + /// A splice-in or splice-out is a splice of its own, whatever the channel already carries: + /// a pending splice's bare intent, or the tracked record of its rounds. + #[test] + fn a_splice_in_or_out_gets_a_record_of_its_own() { + let pending = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(1_000)); + let records = vec![ + PendingPaymentDetails::pending_splice(PaymentId([1u8; 32]), pending.clone()), + tracked_record(PaymentId([2u8; 32]), txid(0x10), &[txid(0x10)], Some(pending)), + ]; + let held = [txid(0x10)]; + + let splice_in = + intent_with(SpliceKind::In { amount_sats: 5_000 }, 3, test_funding_contribution()); + assert_eq!(place_intent(&splice_in, &records, &held), IntentPlacement::Fresh); + let splice_out = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(2_000)); + assert_eq!(place_intent(&splice_out, &records, &held), IntentPlacement::Fresh); + assert_eq!(place_intent(&splice_out, &[], &[]), IntentPlacement::Fresh); + } + + /// A fee bump joins the record of the round it replaces: the one tracking a round LDK still + /// holds, even when that record carries no intent any more and the channel also carries a + /// bare intent. + #[test] + fn a_bump_joins_the_record_tracking_a_held_round() { + let bump = intent_with(SpliceKind::Rbf {}, 3, test_funding_contribution()); + let bare_id = PaymentId([1u8; 32]); + let tracked_id = PaymentId([2u8; 32]); + let records = vec![ + PendingPaymentDetails::pending_splice( + bare_id, + intent_with( + SpliceKind::Out { outputs: Vec::new() }, + 3, + splice_out_contribution(1_000), + ), + ), + tracked_record(tracked_id, txid(0x11), &[txid(0x10), txid(0x11)], None), + ]; + assert_eq!( + place_intent(&bump, &records, &[txid(0x11)]), + IntentPlacement::Reuse(tracked_id) + ); + + // The tracked record of a splice that already locked — its funding is no held round — is + // not the bump's; the bare intent of the round LDK negotiated but the wallet did not record + // is. + assert_eq!(place_intent(&bump, &records, &[]), IntentPlacement::Reuse(bare_id)); + + // With neither, the bump starts fresh. + let locked_only = vec![tracked_record(tracked_id, txid(0x11), &[txid(0x11)], None)]; + assert_eq!(place_intent(&bump, &locked_only, &[]), IntentPlacement::Fresh); + } + + /// An intent anchored at another funding is one the lock handling failed to settle or to + /// re-anchor; nothing is recorded beside it, whatever the new splice's kind and whatever + /// else the channel carries. + #[test] + fn an_intent_anchored_elsewhere_refuses_every_kind() { + let stale = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 4, splice_out_contribution(1_000)); + let current = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(2_000)); + let records = vec![ + PendingPaymentDetails::pending_splice(PaymentId([1u8; 32]), current), + PendingPaymentDetails::pending_splice(PaymentId([2u8; 32]), stale), + ]; + for kind in [ + SpliceKind::In { amount_sats: 5_000 }, + SpliceKind::Out { outputs: Vec::new() }, + SpliceKind::Rbf {}, + ] { + let intent = intent_with(kind, 3, test_funding_contribution()); + assert_eq!(place_intent(&intent, &records, &[]), IntentPlacement::Refused); + } + } + + /// A failure identifies the record whose intent carries the failed contribution — fee fields + /// aside — among the channel's; one reporting no contribution, or a contribution of no + /// recorded intent, identifies nothing. + #[test] + fn a_failure_identifies_the_record_carrying_its_contribution() { + let first = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(1_000)); + let second = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(2_000)); + let (first_id, second_id) = (PaymentId([1u8; 32]), PaymentId([2u8; 32])); + let records = vec![ + PendingPaymentDetails::pending_splice(first_id, first.clone()), + tracked_record(second_id, txid(0x10), &[txid(0x10)], Some(second.clone())), + ]; + + let adjusted = { + use bitcoin::{ScriptBuf, TxOut}; + let outputs = + vec![TxOut { value: Amount::from_sat(2_000), script_pubkey: ScriptBuf::new() }]; + test_funding_contribution_with_outputs(900, 1_000, &outputs) + }; + assert_eq!(record_of_failed_splice(&records, Some(&adjusted)), Some((second_id, second))); + assert_eq!( + record_of_failed_splice(&records, Some(&first.contribution)), + Some((first_id, first)) + ); + assert_eq!(record_of_failed_splice(&records, Some(&splice_out_contribution(3_000))), None); + assert_eq!(record_of_failed_splice(&records, None), None); + } + + /// A lock keeps an intent anchored at the locked funding, re-anchors one LDK still holds as a + /// candidate, and settles any other. + #[test] + fn a_lock_keeps_refreshes_or_settles_an_intent() { + let intent = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(1_000)); + let same_funding = intent.pre_splice_funding_txo.into_bitcoin_outpoint(); + let new_funding = OutPoint { txid: txid(0x20), vout: 0 }; + let held = [SpliceCandidateDetails { + contribution: Some(splice_out_contribution(1_000)), + status: SpliceCandidateStatus::WaitingOnLock, + }]; + let other = [ + SpliceCandidateDetails { + contribution: Some(splice_out_contribution(2_000)), + status: SpliceCandidateStatus::WaitingOnLock, + }, + SpliceCandidateDetails { + contribution: None, + status: SpliceCandidateStatus::WaitingOnLock, + }, + ]; + + assert_eq!(decide_on_lock(&intent, same_funding, &other), LockDecision::Keep); + assert_eq!(decide_on_lock(&intent, new_funding, &held), LockDecision::Refresh); + assert_eq!(decide_on_lock(&intent, new_funding, &other), LockDecision::Settle); + assert_eq!(decide_on_lock(&intent, new_funding, &[]), LockDecision::Settle); + } + + /// A submission proceeds at the funding it was built against while that is still the + /// channel's. Once the funding moved, only a splice-out proceeds, anchored at the live + /// funding; a bump and a splice-in are refused, as is any submission for a channel LDK no + /// longer lists with a funding. + #[test] + fn a_submission_is_checked_against_the_live_funding() { + let requested = LdkOutPoint { txid: txid(0x30), index: 0 }; + let moved = LdkOutPoint { txid: txid(0x31), index: 0 }; + let kinds = [ + SpliceKind::In { amount_sats: 5_000 }, + SpliceKind::Out { outputs: Vec::new() }, + SpliceKind::Rbf {}, + ]; + for kind in &kinds { + assert_eq!(check_submission(requested, Some(requested), kind), Ok(requested)); + assert_eq!( + check_submission(requested, None, kind), + Err(SubmissionRefusal::ChannelGone) + ); + } + assert_eq!( + check_submission(requested, Some(moved), &SpliceKind::Rbf {}), + Err(SubmissionRefusal::BumpedRoundLocked) + ); + assert_eq!( + check_submission(requested, Some(moved), &SpliceKind::In { amount_sats: 5_000 }), + Err(SubmissionRefusal::InputsMayBeSpent) + ); + assert_eq!( + check_submission(requested, Some(moved), &SpliceKind::Out { outputs: Vec::new() }), + Ok(moved) + ); + } + + /// The records concerning a channel's splices are those carrying an intent for it and those + /// tracking an interactive funding of it; records of other channels and of other payments are + /// not. + #[test] + fn records_concerning_a_channel() { + let base = test_intent(); + let (cp, channel_id) = (base.counterparty_node_id, base.channel_id); + let id = PaymentId([1u8; 32]); + assert!(concerns_channel( + &PendingPaymentDetails::pending_splice(id, base.clone()), + cp, + channel_id + )); + assert!(concerns_channel( + &tracked_record(id, txid(0x10), &[txid(0x10)], None), + cp, + channel_id + )); + + let other_channel = SpliceIntent { channel_id: ChannelId([8u8; 32]), ..base }; + assert!(!concerns_channel( + &PendingPaymentDetails::pending_splice(id, other_channel), + cp, + channel_id + )); + assert!(!concerns_channel( + &tracked_record(id, txid(0x10), &[], None), + cp, + ChannelId([8u8; 32]) + )); + let plain = PendingPaymentDetails::new( + payment_details(id, PaymentStatus::Pending), + Vec::new(), + Vec::new(), + ); + assert!(!concerns_channel(&plain, cp, channel_id)); + } + + fn negotiated_candidate(contribution: Option) -> SpliceCandidateDetails { + SpliceCandidateDetails { + contribution, + status: SpliceCandidateStatus::Negotiated { + txid: Txid::from_byte_array([9u8; 32]), + new_channel_value_satoshis: 100_000, + }, + } + } + + /// A previous transaction with a P2WPKH output at index 0 for a contribution input to spend; + /// `seed` varies the output script, and with it the txid. + fn test_prevtx(seed: u8) -> Transaction { + use bitcoin::WPubkeyHash; + + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + } + } + + /// Releasing a contribution spares the parts another contribution uses as well: a fee bump + /// built by adjusting the fee of the round it replaces shares that round's inputs and change + /// address — the change differing in amount only — so against that round nothing is released; + /// against a candidate using only some of the parts, the rest is, a counterparty-only round + /// alongside claiming nothing; against no other contribution, everything is. + #[test] + fn unclaimed_parts_spare_what_other_contributions_use() { + use bitcoin::WPubkeyHash; + + let prevtxs: Vec = (1u8..=3).map(test_prevtx).collect(); + let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 }; + let script = |seed: u8| ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])); + let change = |sats: u64| TxOut { value: Amount::from_sat(sats), script_pubkey: script(9) }; + let splice_out = TxOut { value: Amount::from_sat(50_000), script_pubkey: script(8) }; + let bump = test_funding_contribution_with_parts( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change(20_000)), + ); + + let prior = test_funding_contribution_with_parts( + 0, + 253, + &prevtxs, + &[splice_out.clone()], + Some(&change(21_000)), + ); + assert_eq!(unclaimed_parts(&bump, [&prior]), (Vec::new(), Vec::new())); + + let partial = + test_funding_contribution_with_parts(0, 253, &prevtxs[..2], &[], Some(&change(21_000))); + let candidates = [negotiated_candidate(None), negotiated_candidate(Some(partial))]; + let claimants = candidates.iter().filter_map(|candidate| candidate.contribution.as_ref()); + assert_eq!( + unclaimed_parts(&bump, claimants), + (vec![outpoint(&prevtxs[2])], vec![splice_out.clone()]) + ); + + assert_eq!( + unclaimed_parts(&bump, []), + (prevtxs.iter().map(outpoint).collect(), vec![splice_out, change(20_000)]) + ); + } +} diff --git a/src/data_store.rs b/src/data_store.rs index bdd190621d..747e0b7ed6 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -389,6 +389,44 @@ where Ok(()) } + /// Removes the object stored under `id` only while `predicate` holds for it. The read, the + /// predicate, and the removal share one critical section of the mutation lock, so a + /// concurrent write cannot land in between and be deleted by mistake — unlike a separate + /// [`Self::get`] followed by [`Self::remove`]. Returns whether the object was removed. + pub(crate) async fn remove_if bool>( + &self, id: &SO::Id, predicate: F, + ) -> Result { + let _guard = self.mutation_lock.write().await; + + match self.lookup(id).await? { + Some(object) if predicate(&object) => {}, + _ => return Ok(false), + } + + let store_key = id.encode_to_hex_str(); + KVStore::remove( + &*self.kv_store, + &self.primary_namespace, + &self.secondary_namespace, + &store_key, + false, + ) + .await + .map_err(|e| { + log_error!( + self.logger, + "Removing object data for key {}/{}/{} failed due to: {}", + &self.primary_namespace, + &self.secondary_namespace, + store_key, + e + ); + Error::PersistenceFailed + })?; + self.cache.lock().expect("lock").remove(id); + Ok(true) + } + /// Returns the object stored under `id`, if any. pub(crate) async fn get(&self, id: &SO::Id) -> Result, Error> { let _guard = self.mutation_lock.read().await; @@ -1163,6 +1201,36 @@ mod tests { .is_ok()); } + #[tokio::test] + async fn remove_if_only_removes_while_the_predicate_holds() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let id = TestObjectId { id: [42u8; 4] }; + let existing_object = TestObject::new(id, [23u8; 3]); + let data_store: DataStore> = DataStore::new( + vec![existing_object], + KeepAllEntries, + TEST_PRIMARY_NAMESPACE.to_string(), + TEST_SECONDARY_NAMESPACE.to_string(), + store, + logger, + ); + + // A failed predicate — the entry no longer looks like what the caller decided to delete — + // must leave the entry in place. + let result = data_store.remove_if(&id, |object| object.data != existing_object.data).await; + assert_eq!(Ok(false), result); + assert_eq!(Some(existing_object), data_store.get(&id).await.unwrap()); + + let result = data_store.remove_if(&id, |object| object.data == existing_object.data).await; + assert_eq!(Ok(true), result); + assert!(data_store.get(&id).await.unwrap().is_none()); + + // An absent entry is not an error; there is just nothing to remove. + let result = data_store.remove_if(&id, |_| true).await; + assert_eq!(Ok(false), result); + } + #[tokio::test] async fn mutate_transforms_existing_entry() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/event.rs b/src/event.rs index 47ca7233b9..694d17eb54 100644 --- a/src/event.rs +++ b/src/event.rs @@ -36,6 +36,7 @@ use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_types::payment::{PaymentHash, PaymentPreimage}; +use crate::channel::SpliceTracker; use crate::config::{may_announce_channel, Config, PEER_RECONNECTION_INTERVAL}; use crate::connection::ConnectionManager; use crate::data_store::{DataStoreUpdateResult, UpdatableObject}; @@ -575,6 +576,7 @@ where onion_messenger: Arc, om_mailbox: Option>, prober: Option>, + splice_tracker: Arc, runtime: Arc, logger: L, config: Arc, @@ -594,7 +596,7 @@ where peer_store: Arc>, keys_manager: Arc, static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, prober: Option>, - runtime: Arc, logger: L, config: Arc, + splice_tracker: Arc, runtime: Arc, logger: L, config: Arc, ) -> Self { Self { event_queue, @@ -614,6 +616,7 @@ where onion_messenger, om_mailbox, prober, + splice_tracker, runtime, logger, config, @@ -1976,6 +1979,10 @@ where .handle_channel_ready(user_channel_id, &channel_id, &counterparty_node_id) .await; + self.splice_tracker + .on_channel_ready(counterparty_node_id, channel_id, funding_txo) + .await; + let event = Event::ChannelReady { channel_id, user_channel_id: UserChannelId(user_channel_id), @@ -2038,6 +2045,8 @@ where let counterparty_node_id = counterparty_node_id .expect("counterparty_node_id is always set since LDK 0.0.117"); + self.splice_tracker.on_channel_closed(counterparty_node_id, channel_id).await; + // Drop the peer once its last channel with us has reached a terminal state. // For `HolderForceClosed`, retain it through one recovery reconnect so that // `channel_reestablish` can retransmit the force-close error before cleanup. @@ -2345,13 +2354,16 @@ where // `funding_transaction_signed` releases them to the counterparty, after which // either party may broadcast — and wallet sync could observe the transaction // before this node has recorded it. The record is written from the channel's - // pending splice history, and the round's broadcast adds nothing to it. On a - // failed write, replay rather than proceed unrecorded: LDK re-offers the event - // in-session and regenerates it across restarts while the transaction is - // unsigned. + // pending splice history through the splice tracker, whose lock keeps the + // channel's intent record from changing hands mid-write, and the round's + // broadcast adds nothing to it. On a failed write, replay rather than proceed + // unrecorded: LDK re-offers the event in-session and regenerates it across + // restarts while the transaction is unsigned. let candidates = self.pending_splice_rounds(counterparty_node_id, channel_id); - if let Err(e) = - self.wallet.record_signed_funding(&partially_signed_tx, &candidates).await + if let Err(e) = self + .splice_tracker + .on_funding_ready_for_signing(&partially_signed_tx, &candidates) + .await { log_error!( self.logger, @@ -2446,6 +2458,7 @@ where channel_id, user_channel_id, counterparty_node_id, + contribution, .. } => { log_info!( @@ -2457,12 +2470,13 @@ where // A round this node signed was recorded when signing; if the failed round was // among them, nothing can broadcast it anymore, so take its record back. The - // rounds LDK still holds tell which recorded ones it abandoned (a contribution - // can fail while an earlier signed round still awaits its signatures). A closed - // channel is left to its `ChannelClosed` event: LDK queues one for every channel it - // removes — before the failures a force-close reports, after the one a cooperative - // close reports — and that event carries the channel's last funding, which this - // handler can no longer read from the channel. + // splice intent the record carried stays behind as a bare intent for the report + // below. The rounds LDK still holds tell which recorded ones it abandoned (a + // contribution can fail while an earlier signed round still awaits its + // signatures). A closed channel is left to its `ChannelClosed` event: LDK queues + // one for every channel it removes — before the failures a force-close reports, + // after the one a cooperative close reports — and that event carries the + // channel's last funding, which this handler can no longer read from the channel. if let Some(held_rounds) = self.held_splice_rounds(counterparty_node_id, channel_id) { if let Err(e) = @@ -2479,6 +2493,14 @@ where } } + // Snapshot the recorded splice this failure concerns; the settlement keeps the + // channel's record from changing hands until the report is settled below. + let contribution = contribution.map(|c| c.into_contribution()); + let settlement = self + .splice_tracker + .on_negotiation_failed(counterparty_node_id, channel_id, contribution.as_ref()) + .await; + let event = Event::SpliceNegotiationFailed { channel_id, user_channel_id: UserChannelId(user_channel_id), @@ -2488,10 +2510,17 @@ where match self.event_queue.add_event(event).await { Ok(_) => {}, Err(e) => { + // Dropping the settlement leaves the intent in place for the replayed + // event to settle. log_error!(self.logger, "Failed to push to event queue: {}", e); return Err(ReplayEvent()); }, }; + + // Settle the failed splice's persisted intent only now that the report is + // durably queued: a crash in between replays this event, which must still find + // the intent to settle. + settlement.settle().await; }, } Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 43cf6d5d66..711772a2da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -91,6 +91,7 @@ compile_error!("at least one chain source feature must be enabled"); mod balance; mod builder; mod chain; +mod channel; pub mod config; mod connection; mod data_store; @@ -132,6 +133,7 @@ pub use bitcoin::FeeRate; use bitcoin::{Address, Amount, BlockHash, Network}; pub use builder::{BuildError, Builder}; use chain::ChainSource; +use channel::SpliceTracker; use config::{ default_user_config, may_announce_channel, AsyncPaymentsRole, ChannelConfig, Config, LNURL_AUTH_TIMEOUT_SECS, NODE_ANN_BCAST_INTERVAL, PEER_RECONNECTION_INTERVAL, @@ -177,6 +179,7 @@ use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; pub use payment::forwarding_store::aggregate_channel_pair_stats; use payment::forwarding_store::{run_forwarded_payment_aggregation, ForwardingStore}; +use payment::pending_payment_store::SpliceKind; use payment::{ Bolt11Payment, Bolt12Payment, ForwardingAnalytics, OnchainPayment, PaymentDetails, PaymentDetailsPage, SpontaneousPayment, @@ -275,6 +278,7 @@ pub struct Node { payment_store: Arc, forwarding_store: Arc, forwarded_payment_aggregation_retention_secs: u64, + splice_tracker: Arc, lnurl_auth: Arc, is_running: Arc>, node_metrics: Arc, @@ -714,6 +718,7 @@ impl Node { Arc::clone(&self.onion_messenger), self.om_mailbox.clone(), self.prober.clone(), + Arc::clone(&self.splice_tracker), Arc::clone(&self.runtime), Arc::clone(&self.logger), Arc::clone(&self.config), @@ -1740,6 +1745,14 @@ impl Node { if let Some(channel_details) = open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) { + // The channel's current funding outpoint anchors the persisted splice intent, and a + // channel without one is not ready to splice: check before any contribution is + // built, so nothing is reserved for a splice that cannot be submitted. + let pre_splice_funding_txo = channel_details.funding_txo.ok_or_else(|| { + log_error!(self.logger, "Failed to splice channel: channel not yet ready"); + Error::ChannelSplicingFailed + })?; + let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); let max_feerate = max_funding_feerate(min_feerate); @@ -1753,18 +1766,13 @@ impl Node { const EMPTY_SCRIPT_SIG_WEIGHT: u64 = 1 /* empty script_sig */ * bitcoin::constants::WITNESS_SCALE_FACTOR as u64; - let funding_txo = channel_details.funding_txo.ok_or_else(|| { - log_error!(self.logger, "Failed to splice channel: channel not yet ready",); - Error::ChannelSplicingFailed - })?; - let funding_output = channel_details.get_funding_output().ok_or_else(|| { log_error!(self.logger, "Failed to splice channel: channel not yet ready"); Error::ChannelSplicingFailed })?; let shared_input = Input { - outpoint: funding_txo.into_bitcoin_outpoint(), + outpoint: pre_splice_funding_txo.into_bitcoin_outpoint(), previous_utxo: funding_output.clone(), satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, @@ -1826,6 +1834,10 @@ impl Node { _ => min_feerate, }; + // TODO(#1037): the inputs are locked from coin selection on, and a failure of the + // build after it — LDK validating the selected inputs — returns here with nothing + // releasing them; `submit`'s own failure paths release them or leave them to + // `DiscardFunding`. let contribution = self .runtime .block_on(funding_template.splice_in( @@ -1839,16 +1851,18 @@ impl Node { Error::ChannelSplicingFailed })?; - self.channel_manager - .funding_contributed( - &channel_details.channel_id, - &counterparty_node_id, + self.runtime + .block_on(self.splice_tracker.submit( + counterparty_node_id, + channel_details.channel_id, + pre_splice_funding_txo, contribution, + SpliceKind::In { amount_sats: splice_amount_sats }, None, - ) + )) .map_err(|e| { log_error!(self.logger, "Failed to splice channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( @@ -1867,6 +1881,10 @@ impl Node { /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. /// + /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice + /// may be initiated once the cause of the failure is addressed. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but @@ -1891,6 +1909,10 @@ impl Node { /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. /// + /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice + /// may be initiated once the cause of the failure is addressed. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but @@ -1907,6 +1929,10 @@ impl Node { /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. /// + /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice + /// may be initiated once the cause of the failure is addressed. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-out will be marked as an inbound payment if @@ -1921,6 +1947,14 @@ impl Node { if let Some(channel_details) = open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) { + // The channel's current funding outpoint anchors the persisted splice intent, and a + // channel without one is not ready to splice: check before any contribution is + // built, so nothing is reserved for a splice that cannot be submitted. + let pre_splice_funding_txo = channel_details.funding_txo.ok_or_else(|| { + log_error!(self.logger, "Failed to splice channel: channel not yet ready"); + Error::ChannelSplicingFailed + })?; + let splice_amount_msat = splice_amount_sats.checked_mul(1_000).ok_or(Error::ChannelSplicingFailed)?; if splice_amount_msat > channel_details.outbound_capacity_msat { @@ -1963,22 +1997,25 @@ impl Node { value: Amount::from_sat(splice_amount_sats), script_pubkey: address.script_pubkey(), }]; - let contribution = - funding_template.splice_out(outputs, feerate, max_feerate).map_err(|e| { - log_error!(self.logger, "Failed to splice channel: {}", e); - Error::ChannelSplicingFailed - })?; + let contribution = funding_template + .splice_out(outputs.clone(), feerate, max_feerate) + .map_err(|e| { + log_error!(self.logger, "Failed to splice channel: {}", e); + Error::ChannelSplicingFailed + })?; - self.channel_manager - .funding_contributed( - &channel_details.channel_id, - &counterparty_node_id, + self.runtime + .block_on(self.splice_tracker.submit( + counterparty_node_id, + channel_details.channel_id, + pre_splice_funding_txo, contribution, + SpliceKind::Out { outputs }, None, - ) + )) .map_err(|e| { log_error!(self.logger, "Failed to splice channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( @@ -1994,6 +2031,10 @@ impl Node { /// Fee-bumps the pending splice on a channel by replacing its in-flight funding transaction /// (RBF). The splice's amount and destination are preserved; only the fee rate is raised. /// Errors if the channel has no pending splice to bump. + /// + /// A fee bump that fails during negotiation (e.g. because the peer disconnected) is reported + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; the fee may + /// be bumped again once the cause of the failure is addressed. pub fn bump_channel_funding_fee( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, ) -> Result<(), Error> { @@ -2002,6 +2043,14 @@ impl Node { if let Some(channel_details) = open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) { + // The channel's current funding outpoint anchors the persisted splice intent, and a + // channel without one is not ready to splice: check before any contribution is + // built, so nothing is reserved for a splice that cannot be submitted. + let pre_splice_funding_txo = channel_details.funding_txo.ok_or_else(|| { + log_error!(self.logger, "Failed to RBF channel: channel not yet ready"); + Error::ChannelSplicingFailed + })?; + let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); @@ -2028,6 +2077,12 @@ impl Node { return Err(Error::ChannelSplicingFailed); }; + // The round the bump replaces: a bump that only adjusts its fee reuses its inputs and + // change address, which a failed submission must not release. + let prior_contribution = funding_template.prior_contribution().cloned(); + // TODO(#1037): a bump that re-selects its inputs locks them from coin selection on, + // and a failure of the build after it returns here with nothing releasing them; + // `submit`'s own failure paths release them or leave them to `DiscardFunding`. let contribution = self .runtime .block_on(funding_template.rbf_prior_contribution( @@ -2040,16 +2095,18 @@ impl Node { Error::ChannelSplicingFailed })?; - self.channel_manager - .funding_contributed( - &channel_details.channel_id, - &counterparty_node_id, + self.runtime + .block_on(self.splice_tracker.submit( + counterparty_node_id, + channel_details.channel_id, + pre_splice_funding_txo, contribution, - None, - ) + SpliceKind::Rbf {}, + prior_contribution, + )) .map_err(|e| { log_error!(self.logger, "Failed to RBF channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 37c02fa147..560985c494 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -128,10 +128,10 @@ pub(crate) enum PendingPaymentDetails { /// Each field is written by a different subsystem: wallet sync records `conflicting_txids` /// for any wallet transaction (splice fundings included), the signing-time recording /// records `candidates` for interactive funding, the `ChannelReady` arm records - /// `locked_rounds`, and `splice_intent` is carried over from a [`PendingSplice`] record when - /// the payment is promoted — nothing persists an intent at splice initiation yet; that lands - /// with the splice tracking built on this. A splice uses all of them; the fields do not - /// partition by payment type. + /// `locked_rounds`, and `splice_intent` is owned by the splice entry points and the splice + /// tracker — persisted at splice initiation, carried over from a [`PendingSplice`] record + /// when the payment is promoted, and cleared once the splice locks or its failure is + /// surfaced. A splice uses all of them; the fields do not partition by payment type. /// /// [`PendingSplice`]: Self::PendingSplice Tracked { @@ -184,6 +184,10 @@ impl PendingPaymentDetails { } } + pub(crate) fn pending_splice(id: PaymentId, intent: SpliceIntent) -> Self { + Self::PendingSplice { id, intent } + } + /// The full payment details, or `None` for a splice not yet broadcast. pub(crate) fn details(&self) -> Option<&PaymentDetails> { match self { @@ -381,12 +385,17 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { } else { Some(conflicting_txids.clone()) }; + // Leave the splice intent unchanged: it is owned by the splice entry points and the + // splice tracker, never by a payment-tracking merge. Emitting the current value + // here would let an `insert_or_update` of a payment record (e.g. from wallet sync, + // built without an intent) clobber a live intent to `None`. + let _ = splice_intent; Self { id: details.id, payment_update: Some(details.to_update()), conflicting_txids, candidates: candidates.clone(), - splice_intent: Some(splice_intent.clone()), + splice_intent: None, } }, } @@ -728,6 +737,44 @@ mod tests { assert_eq!(merged_details.fee_paid_msat, Some(100)); } + fn test_intent() -> SpliceIntent { + use std::str::FromStr; + + SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution: test_funding_contribution(), + kind: SpliceKind::In { amount_sats: 500_000 }, + } + } + + #[test] + fn payment_tracking_merge_preserves_a_live_splice_intent() { + let payment_id = PaymentId([7u8; 32]); + let txid = test_txid(8); + let intent = test_intent(); + let mut record = PendingPaymentDetails::tracked( + pending_onchain_payment(payment_id, txid), + Vec::new(), + Vec::new(), + Some(intent.clone()), + ); + + // Wallet sync merges its view of a transaction through `to_update()` of a fresh record, + // which is built without an intent; the merge must leave the live intent in place. + let fresh = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, txid), + vec![test_txid(9)], + Vec::new(), + ); + assert!(record.update(fresh.to_update())); + assert_eq!(record.splice_intent(), Some(&intent)); + } + #[test] fn splice_kind_round_trips() { for kind in [ diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 4fb2ee30ec..c8648685fa 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -57,13 +57,14 @@ use lightning_invoice::RawBolt11Invoice; use payment_stores::{PaymentStores, PaymentStoresGuard}; use persist::KVStoreWalletPersister; +use crate::channel::is_same_splice; use crate::config::{Config, ADDRESS_POOL_SIZE}; #[cfg(test)] use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed}; use crate::data_store::{StorableObject, UpdatableObject}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; use crate::logger::{log_debug, log_error, log_info, log_trace, log_warn, LdkLogger, Logger}; -use crate::payment::pending_payment_store::PendingPaymentDetailsUpdate; +use crate::payment::pending_payment_store::{PendingPaymentDetailsUpdate, SpliceIntent}; use crate::payment::store::{ConfirmationStatus, PaymentDetailsUpdate}; use crate::payment::{ FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, @@ -412,7 +413,19 @@ impl Wallet { let mut unconfirmed_outbound_txids: Vec = Vec::new(); for payment in pending_payments { - // The filter admits only Tracked funding payments. + // The filter admits only Tracked funding payments. A splice intent such a + // record carries — there is one record per splice, so only the intent of + // the round it tracks or of a fee bump of it — goes with the entry when + // the payment graduates: the lock and the graduation both follow the + // confirmation of the round the record tracks, so a lock handled after + // the graduation finds no intent to settle, and one handled before it + // leaves a record whose intent is already cleared. + // TODO(#1037): once inputs are locked, the graduated round's locks sit on + // spent outpoints: #1037 releases nothing for a splice at broadcast, since + // it prepares only `Funding`-typed packages, until the `InteractiveFunding` + // broadcast arm applies the round and unlocks its inputs. A bump's extra + // inputs return through the `DiscardFunding` LDK queues at the lock, once + // that handler passes the inputs to `cancel_tx`. let PendingPaymentDetails::Tracked { ref details, .. } = payment else { continue; }; @@ -818,10 +831,12 @@ impl Wallet { } /// Fails the funding payment `payment_id` while its record still waits on the unconfirmed - /// funding transaction `record_txid`, and removes its pending entry, reporting what it did. As - /// with graduation, the decision is made from the live record and only the status is written. - /// A record already `Failed` — a prior pass whose entry removal was lost to a crash — still - /// matches, no-ops the update, and gets its lingering entry removed. + /// funding transaction `record_txid`, and removes its pending entry — keeping a splice intent + /// it carries as a bare intent under an id of its own, which the splice tracker settles — + /// reporting what it did. As with graduation, the decision is made from the live record and + /// only the status is written. A record already `Failed` — a prior pass whose entry removal + /// was lost to a crash — still matches, no-ops the update, and gets its lingering entry + /// removed, its intent kept unless the prior pass already did. async fn fail_unconfirmed_funding_payment_locked( &self, stores: &PaymentStoresGuard<'_>, payment_id: PaymentId, record_txid: Txid, ) -> Result { @@ -855,6 +870,47 @@ impl Wallet { }) .await?; if outcome != FundingPaymentFailure::MovedOn { + // A splice intent the entry carries outlives the record as a bare intent, for the + // splice tracker to settle at the lock or the close that failed the payment, or to + // re-anchor when LDK carries a queued fee bump the promoted round does not overlap + // across the lock and begins a fresh splice from it. The intent moves to an id of its + // own: the fresh round adopts the id of the bare intent carrying its contribution + // (`find_splice_payment_id`), and under this record's id it would take a `Failed` + // record and go untracked. The drop pass (`drop_abandoned_splice_rounds_locked`) keeps + // the intent under the record's id instead, having removed the record. Keeping the + // intent before removing the entry loses nothing to a crash in between: the replay + // finds the intent kept and adds no second copy. A settlement the splice tracker has + // under way, one that read the intent before this routine ran or that lands between the + // read above and the insert, leaves a bare copy of a settled intent behind; the + // channel's next lock, close or startup reconciliation settles the copy. + let intent = match stores.pending_payment(&payment_id).await? { + Some(PendingPaymentDetails::Tracked { splice_intent: Some(intent), .. }) => { + Some(intent) + }, + _ => None, + }; + if let Some(intent) = intent { + let kept_already = stores + .pending_payments(|p| { + p.details().is_none() && p.splice_intent() == Some(&intent) + }) + .await; + if kept_already.is_empty() { + let kept_id = random_payment_id(); + stores + .insert_pending_payment(PendingPaymentDetails::pending_splice( + kept_id, intent, + )) + .await?; + log_debug!( + self.logger, + "Kept the splice intent of failed funding payment {} as bare intent {} for \ + the splice tracker to settle", + payment_id, + kept_id, + ); + } + } stores.remove_pending_payment(&payment_id).await?; } Ok(outcome) @@ -914,13 +970,14 @@ impl Wallet { /// Fails every funding payment of `channel_id` still waiting on an unconfirmed splice round /// while no round of ours in its record is among `held_rounds` or was promoted to the channel's - /// funding (see [`Self::resolve_promoted_splice_round`]), removing its pending entry; a payment - /// with such a round is left as it is. The rounds of ours are the candidates recorded with a - /// stake, and the record's own transaction only when no candidate records it, as for a record - /// from before candidates were tracked: a recorded candidate counts by its stake alone, - /// whichever round the record names. A payment that moved on — its round confirmed, or it was - /// failed already — is not touched beyond the entry a failure cut short left behind. - /// `resolution` names the occasion in what is logged. + /// funding (see [`Self::resolve_promoted_splice_round`]), removing its pending entry and + /// keeping a splice intent it carries as a bare intent of its own; a payment with such a round + /// is left as it is. The rounds of ours are the candidates recorded with a stake, and the + /// record's own transaction only when no candidate records it, as for a record from before + /// candidates were tracked: a recorded candidate counts by its stake alone, whichever round + /// the record names. A payment that moved on — its round confirmed, or it was failed already — + /// is not touched beyond the entry a failure cut short left behind. `resolution` names the + /// occasion in what is logged. async fn fail_funding_payments_without_held_round_locked( &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], resolution: FundingResolution, @@ -1373,6 +1430,37 @@ impl Wallet { } } + /// Flushes any staged wallet changes to the persister, providing an explicit durability point + /// for state that was staged rather than persisted where it was written. + pub(crate) async fn persist_staged(&self) -> Result<(), Error> { + let mut locked_persister = self.persister.lock().await; + let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + }) + } + + /// Releases the given outpoints from the wallet's locked set — making them available to coin + /// selection again — and persists the change. Outpoints that are not locked are left alone. + pub(crate) async fn unlock_outpoints(&self, outpoints: &[OutPoint]) -> Result<(), Error> { + if outpoints.is_empty() { + return Ok(()); + } + let mut locked_persister = self.persister.lock().await; + let change_set = { + let mut locked_wallet = self.inner.lock().expect("lock"); + for outpoint in outpoints { + locked_wallet.unlock_outpoint(*outpoint); + } + locked_wallet.take_staged().unwrap_or_default() + }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + }) + } + pub(crate) fn get_balances( &self, total_anchor_channels_reserve_sats: u64, ) -> Result<(u64, u64), Error> { @@ -2133,25 +2221,41 @@ impl Wallet { Ok(()) } - /// Returns the `PaymentId` of a user-initiated splice intent for one of the channels in - /// `candidate`, if any, so the first recorded round of a splice adopts the id chosen at splice - /// time rather than a fresh one. The intent identifies the channel, not the round, so it - /// decides the id only for a history no record tracks yet - /// ([`Self::resolve_interactive_funding_id`]). A fee bump reuses the channel's existing intent, - /// so at most one in-flight intent matches and the first is unambiguous. + /// Returns the `PaymentId` of the user-initiated splice intent the round `candidate` belongs + /// to, if any, so the first recorded round of a splice adopts the id chosen at splice time + /// rather than a fresh one. Only a history no record tracks yet gets here + /// ([`Self::resolve_interactive_funding_id`]), so only the channel's bare intent records — + /// those of its splices with no round on record — can be the round's: a tracked record already + /// has its rounds, and a channel carries one record per splice in flight. Among the bare + /// records, the round's is the one whose intent carries the round's contribution + /// ([`is_same_splice`]; LDK may adjust a contribution's fee fields, not its inputs or outputs) + /// or, when none does, the channel's only bare record. Several bare records and no match + /// identify nothing, and the round gets a fresh id. async fn find_splice_payment_id(&self, candidate: &FundingCandidate) -> Option { - self.payment_stores + let channel_of = |intent: &SpliceIntent| { + candidate.channels.iter().find(|channel| { + channel.channel_id == intent.channel_id + && channel.counterparty_node_id == intent.counterparty_node_id + }) + }; + let bare = self + .payment_stores .pending_payments(|p| { - p.splice_intent().is_some_and(|intent| { - candidate.channels.iter().any(|channel| { - channel.channel_id == intent.channel_id - && channel.counterparty_node_id == intent.counterparty_node_id - }) - }) + p.details().is_none() && p.splice_intent().is_some_and(|i| channel_of(i).is_some()) }) - .await - .first() - .map(|p| p.id()) + .await; + let carries_contribution = |p: &&PendingPaymentDetails| { + p.splice_intent().is_some_and(|intent| { + channel_of(intent) + .and_then(|channel| channel.contribution.as_ref()) + .is_some_and(|contribution| is_same_splice(contribution, &intent.contribution)) + }) + }; + match (bare.iter().find(carries_contribution), bare.as_slice()) { + (Some(record), _) => Some(record.id()), + (None, [only]) => Some(only.id()), + (None, _) => None, + } } /// Resolves the id under which the `active` round of the interactive funding with negotiated @@ -2162,17 +2266,18 @@ impl Wallet { /// whose round lost to a conflicting spend confirmed while the channel stays open, LDK still /// holds the round and a fee bump of it is signed with the round among its candidates, and /// nothing revisits a failed record's status, so the bump filed under it would go untracked. - /// Only a history no live record tracks falls back to the channel's splice intent: a + /// Only a history no live record tracks falls back to the channel's splice intents: a /// user-initiated splice adopts the `PaymentId` generated when it was initiated, so its intent, - /// funding payment and candidate history share one record. The intent identifies the channel, - /// not the round, which is why it must not decide the id of a round already on record: a fee - /// bump this node signs of a round wallet sync recorded first must converge on the record sync - /// created, not be filed under the bump's intent as a second record. Otherwise a fresh id is - /// generated — an id derived from a txid would tie the record's identity to one round of a - /// replaceable transaction, and resolution through the record's txid history is what keeps its - /// identity stable across RBF replacements. The caller holds the cross-store lock: resolved - /// outside it, the id could go stale against a record wallet sync creates for the same - /// transaction before the caller's write. + /// funding payment and candidate history share one record. A channel carries one intent per + /// splice in flight, and only a bare one — of a splice with no round on record — can be a first + /// round's ([`Self::find_splice_payment_id`]), which is why the intents must not decide the id + /// of a round already on record: a fee bump this node signs of a round wallet sync recorded + /// first must converge on the record sync created, not be filed under the bump's intent as a + /// second record. Otherwise a fresh id is generated — an id derived from a txid would tie the + /// record's identity to one round of a replaceable transaction, and resolution through the + /// record's txid history is what keeps its identity stable across RBF replacements. The caller + /// holds the cross-store lock: resolved outside it, the id could go stale against a record + /// wallet sync creates for the same transaction before the caller's write. async fn resolve_interactive_funding_id( &self, stores: &PaymentStoresGuard<'_>, candidates: &[FundingCandidate], active: &FundingCandidate, @@ -2283,7 +2388,10 @@ impl Wallet { /// Nothing is recorded for a round missing from the history (reset between the event's /// emission and its handling, so LDK will refuse the signed transaction), already recorded (a /// replayed event), or without a local contribution or wallet-level activity. A failed write - /// leaves no half-written record behind for the replayed event to build on. + /// leaves no half-written record behind for the replayed event to build on; one whose rollback + /// failed as well is dropped by the replayed event once the round is gone + /// ([`Self::drop_unindexed_signing_record`]), or along with the settled intent of its splice + /// ([`Self::drop_unindexed_record_of_settled_intent`]). /// /// [`ChannelManager::funding_transaction_signed`]: lightning::ln::channelmanager::ChannelManager::funding_transaction_signed pub(crate) async fn record_signed_funding( @@ -2483,9 +2591,11 @@ impl Wallet { /// or not its `SpliceNegotiated` event has cleared the mark yet. Dropping the record's current /// round hands the record back to the last remaining round this node contributed to, figures /// included; dropping the last such round removes the record, as whatever rounds remain are not - /// this node's payment (LDK keeps this node's contributions to a suffix of the rounds). A record - /// that no longer waits on the dropped round — wallet sync moved it on, or an earlier drop was - /// cut short after moving it — keeps its state and only loses the round from its history. + /// this node's payment (LDK keeps this node's contributions to a suffix of the rounds), while a + /// splice intent the record carried stays behind as a bare intent, for the failure LDK reports + /// to be described from and for its settlement to remove. A record that no longer waits on the + /// dropped round — wallet sync moved it on, or an earlier drop was cut short after moving it — + /// keeps its state and only loses the round from its history. pub(crate) async fn drop_abandoned_splice_rounds( &self, channel_id: ChannelId, held_rounds: &[Txid], ) -> Result<(), Error> { @@ -2580,16 +2690,50 @@ impl Wallet { // Nothing of this node's was ever broadcast under the record, so it goes // rather than fail a payment for a transaction that never existed. The // payment record goes first: the entry keeps resolving the rounds' txids, - // so a removal that fails midway is finished by the replayed event. + // so a removal that fails midway is finished by the replayed event. A + // splice intent the entry carries outlives the record as a bare intent: + // the failure LDK reports for the round is described from it, and its + // settlement removes it (`SpliceTracker::on_negotiation_failed`); one left + // behind by a node that stopped in between is found and settled by + // whatever next concerns the channel's splice. The intent is read from the + // entry as it stands, not as listed above: a fee bump submitted since may + // have replaced it, and that intent must stay just the same. stores.remove_payment(&payment_id).await?; - stores.remove_pending_payment(&payment_id).await?; - log_debug!( - self.logger, - "Dropped abandoned splice round(s) {:?} and removed funding payment {}: nothing of ours \ - was broadcast under it", - abandoned_txids, - payment_id, - ); + let kept_intent = stores + .mutate_pending_payment(&payment_id, |existing| match existing { + Some(PendingPaymentDetails::Tracked { + splice_intent: Some(intent), + .. + }) => Some(PendingPaymentDetails::pending_splice( + payment_id, + intent.clone(), + )), + _ => None, + }) + .await? + .is_some(); + stores + .remove_pending_payment_if(&payment_id, |entry| { + entry.splice_intent().is_none() + }) + .await?; + if kept_intent { + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} and removed funding payment {}: nothing of ours \ + was broadcast under it; the splice's intent stays until its failure is surfaced", + abandoned_txids, + payment_id, + ); + } else { + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} and removed funding payment {}: nothing of ours \ + was broadcast under it", + abandoned_txids, + payment_id, + ); + } continue; } history_only = true; @@ -2677,34 +2821,79 @@ impl Wallet { /// the history, ends up here; a fully recorded round (its entry in place) is left to /// [`Self::drop_abandoned_splice_rounds`]. Only a first round can be left so: the record of a /// bump keeps the entry of the rounds before it, and wallet sync moves it on as an earlier - /// round confirms or fails. + /// round confirms or fails. A bare splice intent under the record's id — the intent whose id + /// the signing adopted and whose entry the completed write would have promoted — does not + /// index the record, and is left for the splice tracker to settle. async fn drop_unindexed_signing_record(&self, txid: Txid) -> Result<(), Error> { let stores = self.payment_stores.lock().await; let payment_id = match self.find_payment_by_txid(txid).await? { Some(id) => id, None => return Ok(()), }; - if stores.pending_payment(&payment_id).await?.is_some() { + self.drop_unindexed_signing_record_locked(&stores, payment_id, Some(txid)).await + } + + /// Removes the half-written signing record, if any, under the id of a bare splice intent whose + /// splice settled. The signing-time recording ([`Self::record_signed_funding`]) files a payment + /// under a bare intent's id and promotes the intent's entry in the same write, so a payment + /// record found under a bare intent is the first half of a write that never completed. The + /// round's signatures never left the node, nothing can broadcast it, and no entry would ever + /// drive the record. The caller removes the bare entry afterwards; an entry that turns out to + /// be tracked indexes the record, which then stays. + pub(crate) async fn drop_unindexed_record_of_settled_intent( + &self, payment_id: PaymentId, + ) -> Result<(), Error> { + let stores = self.payment_stores.lock().await; + self.drop_unindexed_signing_record_locked(&stores, payment_id, None).await + } + + /// Removes the payment record under `payment_id` if it is the half-written record of a signed + /// splice round — pending, unconfirmed, interactive funding, and of `txid` when one is given — + /// that no `Tracked` entry indexes. The caller must hold the [`PaymentStores`] lock so that + /// the check and the removal cannot interleave with a signing write completing the record; + /// the `stores` guard proves the lock is held across this call. + async fn drop_unindexed_signing_record_locked( + &self, stores: &PaymentStoresGuard<'_>, payment_id: PaymentId, txid: Option, + ) -> Result<(), Error> { + let indexed = stores + .pending_payment(&payment_id) + .await? + .is_some_and(|entry| entry.details().is_some()); + if indexed { + log_debug!( + self.logger, + "Keeping the funding record of payment {}: its pending entry indexes it", + payment_id, + ); return Ok(()); } - let unindexed = stores.payment(&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 { - stores.remove_payment(&payment_id).await?; - log_info!( + let half_written = + stores.payment(&payment_id).await?.and_then(|record| match &record.kind { + PaymentKind::Onchain { + txid: recorded, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } if record.status == PaymentStatus::Pending + && txid.map_or(true, |txid| *recorded == txid) => + { + Some(*recorded) + }, + _ => None, + }); + match half_written { + Some(recorded) => { + stores.remove_payment(&payment_id).await?; + log_info!( + self.logger, + "Dropped the half-written funding record of abandoned splice round {}", + recorded, + ); + }, + None => log_debug!( self.logger, - "Dropped the half-written funding record of abandoned splice round {}", - txid, - ); + "No half-written funding record to drop under payment {}", + payment_id, + ), } Ok(()) } @@ -2824,6 +3013,10 @@ impl Wallet { // is ordered before the removal, which then also deletes anything inserted here. A // status read taken before this write goes stale when graduation lands in between, and // would re-index the graduated payment. + let mut leftover_intent_to_remove = None; + // The `move` closure would capture the `Option` by value, so hand it a reference; the + // borrow ends with the mutate's future, before the leftover is read below. + let leftover = &mut leftover_intent_to_remove; stores .mutate_pending_payment_async(&id, move |existing| async move { // The record was written above and removal serializes on the cross-store lock held @@ -2843,12 +3036,11 @@ impl Wallet { }), // A user-initiated splice has a pre-broadcast `PendingSplice` intent under // this id; carry its intent into the `Tracked` record so promotion does - // not drop it (nothing persists or consumes intents yet — that arrives - // with the follow-up that makes splice retries survive restarts). If the - // payment already advanced beyond `Pending` (wallet sync confirmed it - // through `ANTI_REORG_DELAY` first), it must not enter the pending store; - // the leftover intent record stays until that follow-up adds its clearing - // path. + // not drop it. If the payment already advanced beyond `Pending` (wallet + // sync confirmed it through `ANTI_REORG_DELAY` first), it must not enter + // the pending store — and the splice behind the intent confirmed, so the + // leftover record is removed below rather than left to look like a splice + // still in flight after a restart. Some(PendingPaymentDetails::PendingSplice { intent, .. }) => { if recorded.status == PaymentStatus::Pending { Some(PendingPaymentDetails::tracked( @@ -2858,6 +3050,7 @@ impl Wallet { Some(intent), )) } else { + *leftover = Some(intent); None } }, @@ -2880,6 +3073,17 @@ impl Wallet { }) .await .map_err(|error| FundingWriteError::Failed { error, prior: prior.clone() })?; + if let Some(intent) = leftover_intent_to_remove { + // Only remove the record while it still is the bare intent the closure saw: a fee bump + // submitted in between joins the bare record and replaces its intent, and that live + // intent must stay. + stores + .remove_pending_payment_if(&id, |record| { + record.details().is_none() && record.splice_intent() == Some(&intent) + }) + .await + .map_err(|error| FundingWriteError::Failed { error, prior: prior.clone() })?; + } Ok(prior) } @@ -3622,10 +3826,11 @@ enum FundingResolution { /// The outcome of [`Wallet::fail_unconfirmed_funding_payment_locked`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FundingPaymentFailure { - /// The payment was failed and its pending entry removed. + /// The payment was failed and its pending entry removed, a splice intent it carried kept as a + /// bare intent of its own. Failed, /// The payment was failed already — by a pass whose entry removal was lost to a crash — and - /// only the lingering entry was removed. + /// only the lingering entry was removed, its intent kept likewise. EntryRemoved, /// The record no longer waits on the transaction; nothing was touched. MovedOn, @@ -3634,7 +3839,7 @@ enum FundingPaymentFailure { /// Generates a fresh funding-record [`PaymentId`] from the OS entropy source. A funding record's id /// carries no meaning beyond uniqueness: the record is found through its transaction history /// ([`Wallet::find_payment_by_txid`]), never re-derived from a txid. -fn random_payment_id() -> PaymentId { +pub(crate) fn random_payment_id() -> PaymentId { let mut bytes = [0u8; 32]; getrandom::fill(&mut bytes).expect("getrandom failed"); PaymentId(bytes) @@ -4403,6 +4608,115 @@ mod tests { wallet.address_pool.lock().unwrap().available.iter().map(|(index, _)| *index).collect() } + fn test_splice_intent() -> crate::payment::pending_payment_store::SpliceIntent { + use crate::payment::pending_payment_store::{SpliceIntent, SpliceKind}; + + SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([13u8; 32]), + pre_splice_funding_txo: lightning::chain::transaction::OutPoint { + txid: Txid::from_byte_array([3u8; 32]), + index: 0, + }, + contribution: crate::payment::pending_payment_store::test_funding_contribution(), + kind: SpliceKind::In { amount_sats: 10_000 }, + } + } + + fn funding_payment(id: PaymentId, txid: Txid, status: PaymentStatus) -> PaymentDetails { + PaymentDetails::new( + id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels: Vec::new() }), + }, + Some(1_000_000), + Some(500), + PaymentDirection::Outbound, + status, + ) + } + + #[tokio::test] + async fn recording_a_round_promotes_a_pre_broadcast_intent_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([21u8; 32]); + let txid = Txid::from_byte_array([22u8; 32]); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, test_splice_intent())) + .await + .unwrap(); + + let candidates = vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + wallet + .persist_funding_payment(funding_payment(id, txid, PaymentStatus::Pending), candidates) + .await + .unwrap(); + + // The pre-broadcast record is promoted into the tracked funding payment, carrying its + // intent until the splice locks. + let record = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the record must be promoted"); + assert!(record.details().is_some()); + assert!(record.splice_intent().is_some()); + } + + #[tokio::test] + async fn recording_a_round_removes_the_intent_record_of_an_advanced_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([23u8; 32]); + let txid = Txid::from_byte_array([24u8; 32]); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, test_splice_intent())) + .await + .unwrap(); + // Wallet sync confirmed the payment through `ANTI_REORG_DELAY` before the record was written: + // the payment graduated, so the record must not enter the pending store... + wallet + .payment_stores + .payment_store() + .insert(funding_payment(id, txid, PaymentStatus::Succeeded)) + .await + .unwrap(); + + let candidates = vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + wallet + .persist_funding_payment(funding_payment(id, txid, PaymentStatus::Pending), candidates) + .await + .unwrap(); + + // ...and the splice behind the intent confirmed, so the leftover intent record is removed + // rather than left to look like a splice still in flight after a restart. + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + #[tokio::test] async fn refill_publishes_addresses_only_after_their_reveal_is_persisted() { let fail_store = FailSwitchStore::new(); @@ -5382,6 +5696,18 @@ mod tests { } } + /// The pending entries carrying `intent` as a bare intent — of a splice with no round on + /// record. + async fn bare_entries_carrying( + wallet: &Wallet, intent: &SpliceIntent, + ) -> Vec { + wallet + .payment_stores + .pending_payment_store() + .list_filter(|p| p.details().is_none() && p.splice_intent() == Some(intent)) + .await + } + /// A round signed under the channel's splice intent that has since locked with zero /// confirmations — clearing its intent — with a second splice submitted against the locked /// funding before the round's `SpliceNegotiated` event was handled: the channel's intent no @@ -5442,8 +5768,8 @@ mod tests { /// A recorded round is marked broadcast in its own record once the channel carries the intent /// of a newer splice: after a zero-conf lock, the user may submit a second splice before the - /// locked round's `SpliceNegotiated` event is handled, and the event must neither file the - /// round under the new splice as a second record nor touch the new splice's intent. + /// locked round's `SpliceNegotiated` event is handled, and the event must mark the round in + /// its own record without touching the new splice's intent. #[tokio::test] async fn negotiation_marks_a_recorded_round_broadcast_under_a_newer_intent() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); @@ -5454,9 +5780,6 @@ mod tests { let channel_id = setup.candidates[0].channels[0].channel_id; wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1, "the round must not be filed as a second record"); - assert_eq!(payments[0].id, setup.first_id); let entry = wallet .payment_stores .pending_payment_store() @@ -5673,89 +5996,287 @@ mod tests { assert_eq!(entry.splice_intent(), Some(&intent)); } - /// Signing a splice round records its funding payment with the channel's full pending splice - /// history, so a wallet sync that observes the transaction before the broadcast (the - /// counterparty may broadcast first) resolves to the funding record through any round of that - /// history instead of filing the round as a foreign duplicate. Only the signed round awaits - /// broadcast; LDK broadcast the negotiated predecessor already. + /// A splice queued behind a pending splice of this node is a splice of its own, negotiating + /// once the pending one locks. Its first round is on no record when it is signed, and the + /// pending round's record — tracked, and still carrying the pending splice's intent — is not + /// its: only a bare intent record can be a first round's. The queued round gets a fresh id and + /// the pending round's record stays as it stands. #[tokio::test] - async fn signing_records_the_round_with_the_full_splice_history() { + async fn signing_a_queued_splice_does_not_join_the_pending_rounds_record() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - // 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 id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); - let candidates = splice_candidates( + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + let (queued_tx, queued_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let queued_txid = queued_tx.compute_txid(); + let queued_candidates = splice_candidates( counterparty_node_id, channel_id, - &[(prior_txid, None), (txid, Some(contribution))], + &[(queued_txid, Some(queued_contribution))], ); + wallet.record_signed_funding(&queued_tx, &queued_candidates).await.unwrap(); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - - let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1); - let payment = &payments[0]; - let id = payment.id; - assert_ne!(id, PaymentId(prior_txid.to_byte_array())); - assert_ne!(id, PaymentId(txid.to_byte_array())); - assert_eq!(payment.amount_msat, Some(500_300_000)); - assert_eq!(payment.fee_paid_msat, Some(300_000)); - assert_eq!(payment.direction, PaymentDirection::Inbound); - 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.payment_stores.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)); + let queued_id = wallet + .find_payment_by_txid(queued_txid) + .await + .unwrap() + .expect("the queued round must be recorded"); + assert_ne!(queued_id, id, "the queued splice must not join the pending round's record"); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(entry.splice_intent(), Some(&intent)); } - /// A fee bump of a round whose payment wallet sync failed — the round lost to a conflicting - /// spend confirmed while the channel stayed open, so LDK still holds it and offers the bump — - /// is signed with the failed round among its candidates. The failed record takes no round: - /// nothing revisits its status, so the bump would go untracked under it. The bump gets a - /// record of its own. + /// A channel carries one intent per splice in flight, each under its own record. Signing the + /// first round of either splice files it under the intent whose contribution it carries, and + /// leaves the other splice's record alone. #[tokio::test] - async fn signing_a_bump_of_a_failed_round_gets_a_record_of_its_own() { + async fn signing_the_first_rounds_of_two_splices_files_each_under_its_own_intent() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); - let txid = tx.compute_txid(); - let candidates = splice_candidates( - counterparty_node_id, - channel_id, - &[(txid, Some(contribution.clone()))], - ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let (tx_a, contribution_a) = splice_out_round(&wallet, 1, 500_000, 300); + let (tx_b, contribution_b) = splice_out_round(&wallet, 2, 400_000, 700); + let (txid_a, txid_b) = (tx_a.compute_txid(), tx_b.compute_txid()); + let (id_a, id_b) = (PaymentId([31u8; 32]), PaymentId([32u8; 32])); + let intent_a = SpliceIntent { + contribution: contribution_a.clone(), + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + let intent_b = SpliceIntent { + contribution: contribution_b.clone(), + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + for (id, intent) in [(id_a, intent_a.clone()), (id_b, intent_b.clone())] { + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id, intent }) + .await + .unwrap(); + } + + let candidates_a = + splice_candidates(counterparty_node_id, channel_id, &[(txid_a, Some(contribution_a))]); + wallet.record_signed_funding(&tx_a, &candidates_a).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid_a).await.unwrap(), Some(id_a)); + let entry_b = + wallet.payment_stores.pending_payment_store().get(&id_b).await.unwrap().expect("entry"); + assert_eq!( + entry_b, + PendingPaymentDetails::PendingSplice { id: id_b, intent: intent_b.clone() } + ); + + let candidates_b = + splice_candidates(counterparty_node_id, channel_id, &[(txid_b, Some(contribution_b))]); + wallet.record_signed_funding(&tx_b, &candidates_b).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid_b).await.unwrap(), Some(id_b)); + let entry_b = + wallet.payment_stores.pending_payment_store().get(&id_b).await.unwrap().expect("entry"); + assert_eq!(entry_b.candidates().iter().map(|c| c.txid).collect::>(), vec![txid_b]); + assert_eq!(entry_b.splice_intent(), Some(&intent_b)); + let entry_a = + wallet.payment_stores.pending_payment_store().get(&id_a).await.unwrap().expect("entry"); + assert_eq!(entry_a.candidates().iter().map(|c| c.txid).collect::>(), vec![txid_a]); + assert_eq!(entry_a.splice_intent(), Some(&intent_a)); + assert_eq!( + wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects.len(), + 2 + ); + } + + /// A first round whose contribution is none of the channel's bare intents' — LDK may adjust + /// a contribution's fee fields, not its inputs or outputs — is still the channel's only bare + /// intent's round when there is just one. When there are several, none is known to be its, + /// and the round gets a fresh id while both intents stay. + #[tokio::test] + async fn signing_a_first_round_none_of_several_bare_intents_claims_gets_a_fresh_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let (id_a, id_b) = (PaymentId([31u8; 32]), PaymentId([32u8; 32])); + let intent_a = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let intent_b = SpliceIntent { + contribution: test_funding_contribution_with_outputs(400, 253, &[]), + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + for (id, intent) in [(id_a, intent_a.clone()), (id_b, intent_b.clone())] { + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id, intent }) + .await + .unwrap(); + } + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + let round_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("recorded"); + assert!(round_id != id_a && round_id != id_b, "neither intent is known to be the round's"); + for (id, intent) in [(id_a, intent_a), (id_b, intent_b)] { + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("entry"); + assert_eq!(entry, PendingPaymentDetails::PendingSplice { id, intent }); + } + } + + /// A splice submitted after the previous splice locked with zero confirmations has an intent + /// of its own: the locked splice's intent was settled before the new one was persisted + /// (`SpliceTracker::submit`). Signing the new splice's first round files it under the new + /// intent's id and leaves the locked round's record as it stands. + #[tokio::test] + async fn signing_a_splice_after_a_zero_conf_lock_gets_its_own_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let first_txid = setup.tx.compute_txid(); + let first_entry = wallet + .payment_stores + .pending_payment_store() + .get(&setup.first_id) + .await + .unwrap() + .expect("first entry"); + + let (tx, contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(setup.second_id)); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&setup.second_id) + .await + .unwrap() + .expect("entry"); + assert_eq!(entry.splice_intent(), Some(&setup.second_intent)); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&setup.first_id).await.unwrap(), + Some(first_entry) + ); + assert_eq!(wallet.find_payment_by_txid(first_txid).await.unwrap(), Some(setup.first_id)); + } + + /// Signing a splice round records its funding payment with the channel's full pending splice + /// history, so a wallet sync that observes the transaction before the broadcast (the + /// counterparty may broadcast first) resolves to the funding record through any round of that + /// history instead of filing the round as a foreign duplicate. Only the signed round awaits + /// broadcast; LDK broadcast the negotiated predecessor already. + #[tokio::test] + async fn signing_records_the_round_with_the_full_splice_history() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // 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 payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let payment = &payments[0]; + let id = payment.id; + assert_ne!(id, PaymentId(prior_txid.to_byte_array())); + assert_ne!(id, PaymentId(txid.to_byte_array())); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(payment.fee_paid_msat, Some(300_000)); + assert_eq!(payment.direction, PaymentDirection::Inbound); + 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.payment_stores.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)); + } + + /// A fee bump of a round whose payment wallet sync failed — the round lost to a conflicting + /// spend confirmed while the channel stayed open, so LDK still holds it and offers the bump — + /// is signed with the failed round among its candidates. The failed record takes no round: + /// nothing revisits its status, so the bump would go untracked under it. The bump gets a + /// record of its own. + #[tokio::test] + async fn signing_a_bump_of_a_failed_round_gets_a_record_of_its_own() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); // Wallet sync failed the payment and removed its entry. wallet .payment_stores @@ -6154,6 +6675,118 @@ mod tests { assert_eq!(wallet.find_payment_by_txid(other_txid).await.unwrap(), Some(other_id)); } + /// The abandoned first round was signed under the channel's splice intent: the record goes, + /// but the intent stays behind as a bare intent, so the failure LDK reports next can still be + /// described in the splice's own terms before its settlement removes the intent. A repeated + /// drop leaves the bare intent alone. + #[tokio::test] + async fn dropping_an_abandoned_first_round_keeps_its_bare_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::PendingSplice { id, intent }; + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare.clone()) + ); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare) + ); + } + + /// The intent was already settled off the record when the abandoned first round is dropped — + /// a lock or the channel's close settled it first — so nothing is left to keep: the record and + /// its entry both go. + #[tokio::test] + async fn dropping_an_abandoned_first_round_whose_intent_settled_removes_its_entry() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id, intent }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let settled = PendingPaymentDetailsUpdate { + id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + wallet.payment_stores.pending_payment_store().update(settled).await.unwrap(); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + } + + /// LDK abandoned a signed fee bump of a counterparty-initiated round this node did not + /// contribute to: no remaining round is this node's payment, so the record goes as a first + /// round's does, and the bump's intent stays behind as a bare intent. + #[tokio::test] + async fn dropping_an_abandoned_bump_of_a_counterparty_round_keeps_its_bare_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::PendingSplice { id, intent }; + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let prior_txid = Txid::from_byte_array([9u8; 32]); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let bump_txid = bump_tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &candidates).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), Some(id)); + + wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare) + ); + } + /// LDK abandoned a signed fee bump while the round it replaces stays pending: the bump leaves /// the recorded history and the record tracks the original round again, figures included. #[tokio::test] @@ -6689,55 +7322,247 @@ mod tests { assert_eq!(entry.details().map(|details| details.status), Some(PaymentStatus::Pending)); } - /// The signing write failed between its two stores and the rollback failed as well, leaving - /// 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. + /// 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([9u8; 32]); + let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); + wallet.payment_stores.payment_store().insert_or_update(half_written).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + + wallet.record_signed_funding(&tx, &[]).await.unwrap(); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + wallet.record_signed_funding(&tx, &[]).await.unwrap(); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); + } + + /// Recording a first splice round costs two reads of the payment store: the write pair reads + /// the record it merges into, which also serves the rollback of a failed write, and the + /// duplicate merge probes for a record wallet sync may have keyed by the round's own txid. + #[tokio::test] + async fn a_first_round_signing_reads_the_payment_store_twice() { + let counting_store = ReadCountingStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + + let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; + assert_eq!(reads, 2, "recording a first round re-read the payment store"); + } + + /// The half-written record of a reset first round sits under the id of the channel's splice + /// intent, which the signing adopted. The bare intent entry under that id does not index the + /// record, so the replayed signing drops the record and leaves the intent for the splice + /// tracker to settle. + #[tokio::test] + async fn a_replayed_signing_drops_the_half_written_record_under_a_bare_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::PendingSplice { id, intent }; + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); + wallet.payment_stores.payment_store().insert_or_update(half_written).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + + wallet.record_signed_funding(&tx, &[]).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare) + ); + } + + /// A half-written fee bump — the payment record moved on to the bump, the entry still lists + /// only the round it replaces — is indexed by that entry: the replayed signing leaves it + /// alone. Wallet sync hands the record back to the replaced round as that round confirms or + /// fails; the negotiation-failure handling cannot, as it only knows the rounds the entry lists. + #[tokio::test] + async fn a_replayed_signing_keeps_the_half_written_record_of_a_reset_bump() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + // The bump's signing write landed in the payment store only. + let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let mut moved_on = PaymentDetailsUpdate::new(id); + moved_on.txid = Some(bump_txid); + wallet.payment_stores.payment_store().update(moved_on).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), Some(id)); + + wallet.record_signed_funding(&bump_tx, &[]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + } + + /// The signing write of a first round was cut short after the payment store, under the id of + /// the channel's splice intent. Replayed with the round still pending, the signing completes + /// the record: one entry, carrying the intent and the round awaiting broadcast. + #[tokio::test] + async fn a_replayed_signing_completes_the_half_written_record_under_a_bare_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); + wallet.payment_stores.payment_store().insert_or_update(half_written).await.unwrap(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + assert_eq!(payments[0].id, id); + let entries = wallet.payment_stores.pending_payment_store().list_filter(|_| true).await; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].details(), Some(&payments[0])); + assert_eq!(entries[0].splice_intent(), Some(&intent)); + assert!(entries[0].candidate(txid).expect("candidate").awaiting_broadcast); + } + + /// Settling a bare splice intent removes the half-written signing record under its id, if + /// any: it is the first half of a signing write for a round nothing can broadcast, and no + /// entry would ever drive it. The bare entry itself is left to the settlement, and a record + /// a `Tracked` entry indexes stays. #[tokio::test] - async fn a_replayed_signing_drops_the_half_written_record_of_a_reset_round() { + async fn settling_a_bare_intent_drops_its_half_written_record() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::PendingSplice { id, intent }; + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); - let id = PaymentId([9u8; 32]); let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); wallet.payment_stores.payment_store().insert_or_update(half_written).await.unwrap(); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); - wallet.record_signed_funding(&tx, &[]).await.unwrap(); + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare) + ); + // A round recorded in full under the intent's id is indexed by its entry and stays. + let (tx, contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let txid = tx.compute_txid(); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - wallet.record_signed_funding(&tx, &[]).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); } - /// Recording a first splice round costs two reads of the payment store: the write pair reads - /// the record it merges into, which also serves the rollback of a failed write, and the - /// duplicate merge probes for a record wallet sync may have keyed by the round's own txid. + /// Settling a bare splice intent leaves alone a record under its id that is not the + /// half-written record of a signed round: a payment that succeeded, or whose transaction + /// confirmed, was broadcast and driven to that state, and is a payment of its own. #[tokio::test] - async fn a_first_round_signing_reads_the_payment_store_twice() { - let counting_store = ReadCountingStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); + async fn settling_a_bare_intent_leaves_a_settled_record_alone() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - let (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([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::PendingSplice { id, intent }; + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let txid = Txid::from_byte_array([0xBB; 32]); + let settled = [ + (confirmed_status(), PaymentStatus::Succeeded), + (confirmed_status(), PaymentStatus::Pending), + (ConfirmationStatus::Unconfirmed, PaymentStatus::Succeeded), + ]; + for (confirmation, status) in settled { + let kind = PaymentKind::Onchain { + txid, + status: confirmation, + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + let record = PaymentDetails::new( + id, + kind, + Some(500_300_000), + Some(300_000), + PaymentDirection::Outbound, + status, + ); + wallet.payment_stores.payment_store().insert_or_update(record.clone()).await.unwrap(); - let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; - assert_eq!(reads, 2, "recording a first round re-read the payment store"); + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(record)); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare.clone()) + ); + wallet.payment_stores.payment_store().remove(&id).await.unwrap(); + } } /// The signing write fails between its two stores — the payment record lands, the pending @@ -9782,6 +10607,242 @@ mod tests { assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } + /// A record failed as a round this node did not contribute to locked keeps the splice intent + /// it carries as a bare intent under an id of its own: the intent is the splice tracker's to + /// settle — at this lock, or from the failure LDK reports for the contribution — and the + /// record's removal must not take it along, nor may it stay under the failed record's id, + /// which the fresh round of a fee bump LDK carries across the lock would adopt. + #[tokio::test] + async fn promoting_a_round_not_ours_keeps_the_failed_records_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let counterparty_txid = Txid::from_byte_array([0xBB; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + let kept = bare_entries_carrying(&wallet, &intent).await; + assert_eq!(kept.len(), 1, "one bare entry carries the intent: {kept:?}"); + assert_ne!(kept[0].id(), id); + } + + /// The close keeps the intent of a record it fails the same way, for the splice tracker's + /// settlement of the closed channel's intents to find. + #[tokio::test] + async fn closing_keeps_the_failed_records_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + let kept = bare_entries_carrying(&wallet, &intent).await; + assert_eq!(kept.len(), 1, "one bare entry carries the intent: {kept:?}"); + assert_ne!(kept[0].id(), id); + } + + /// A fee bump LDK carries across the lock of a round it does not overlap begins a fresh + /// splice, and the fresh round adopts the id of the bare intent carrying its contribution. + /// The intent kept from the failed record must therefore sit under an id of its own: the + /// fresh round then gets a `Pending` record whose entry carries the intent, where under the + /// failed record's id it would take that record — left `Failed` with the fresh round's txid — + /// and go untracked. + #[tokio::test] + async fn a_kept_intent_signs_its_fresh_round_under_an_id_of_its_own() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + // The bump's intent joined the record of the round it was to replace. + let id = PaymentId([31u8; 32]); + let intent = SpliceIntent { + contribution: bump_contribution.clone(), + kind: SpliceKind::Rbf {}, + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let counterparty_txid = Txid::from_byte_array([0xBB; 32]); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + // LDK begins a fresh splice from the bump; its round is signed. + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + let bump_id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("a record"); + assert_ne!(bump_id, id); + let payment = + wallet.payment_stores.payment_store().get(&bump_id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&bump_id) + .await + .unwrap() + .expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(bump_txid).is_some()); + let failed = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + /// A crash between keeping the intent and removing the failed record's entry leaves both; + /// the replay removes the entry and adds no second copy of the intent. + #[tokio::test] + async fn a_replayed_failure_does_not_duplicate_the_kept_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .await + .unwrap(); + let counterparty_txid = Txid::from_byte_array([0xBB; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + // The prior pass failed the record and kept the intent, then crashed before removing + // the entry. + wallet + .payment_stores + .payment_store() + .mutate(&id, |existing| { + let mut update = PaymentDetailsUpdate::new(id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + let kept_id = PaymentId([32u8; 32]); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::PendingSplice { id: kept_id, intent: intent.clone() }) + .await + .unwrap(); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + assert_eq!( + bare_entries_carrying(&wallet, &intent).await, + vec![PendingPaymentDetails::PendingSplice { id: kept_id, intent }], + ); + } + /// A round of ours nothing had broadcast when the counterparty's round locked — our /// signatures were never exchanged — is dropped with the promotion, and its record with it, /// rather than failed: no transaction of ours ever existed to fail a payment for. diff --git a/src/wallet/payment_stores.rs b/src/wallet/payment_stores.rs index f076a0ab94..26aaa4e952 100644 --- a/src/wallet/payment_stores.rs +++ b/src/wallet/payment_stores.rs @@ -149,11 +149,28 @@ impl PaymentStoresGuard<'_> { self.stores.pending_payment_store.insert_or_update(entry).await } + /// Stores `entry`, overwriting the entry already stored under its id, if any. + pub(super) async fn insert_pending_payment( + &self, entry: PendingPaymentDetails, + ) -> Result<(), Error> { + self.stores.pending_payment_store.insert(entry).await + } + /// Removes the pending-store entry stored under `id`, if any. pub(super) async fn remove_pending_payment(&self, id: &PaymentId) -> Result<(), Error> { self.stores.pending_payment_store.remove(id).await } + /// Removes the pending-store entry stored under `id` only while `predicate` holds for it, in + /// one critical section of the store; see + /// [`DataStore::remove_if`](crate::data_store::DataStore::remove_if). Returns whether the + /// entry was removed. + pub(super) async fn remove_pending_payment_if bool>( + &self, id: &PaymentId, predicate: F, + ) -> Result { + self.stores.pending_payment_store.remove_if(id, predicate).await + } + /// Transforms the pending-store entry stored under `id` through `f` and persists the result, /// all in one critical section of the store; see /// [`DataStore::mutate`](crate::data_store::DataStore::mutate). From f02dc554585ac37df581d8ba655cc75e8750887a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 10 Sep 2026 23:01:58 -0500 Subject: [PATCH 21/49] DROP ME: f - Persist splice intents until the splice locks Reword what the comments say LDK returns for a synchronously rejected contribution and for a signed round the monitor never watched, so they hold at the pinned LDK and once it carries the fixes for rust-lightning issues 4986 and 4967: a refusal's `DiscardFunding` names the parts no pending splice attempt still uses, except that before 4986 is fixed a refusal for a channel or peer LDK no longer knows names the whole contribution, and once 4967 is fixed a round the monitor never watched is released by the `DiscardFunding` LDK reports at the force-close. Scope the `TODO(#1037)` notes to those fixes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 7ab378d7cc21836153d205ddc31099cf5e77a57d) --- src/channel/mod.rs | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 183ef00c3b..9fc8638f17 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -116,8 +116,13 @@ impl SpliceTracker { /// contribution and no other round claims ([`Self::release_contribution`]): a fee bump built /// by adjusting the fee of the round it replaces — `prior`, the contribution it was built /// from — reuses that round's inputs and change address, which a refusal must leave to the - /// round that has locked meanwhile. A synchronous rejection leaves the release to the - /// `DiscardFunding` event LDK queues. + /// round that has locked meanwhile. A synchronous rejection leaves the release to LDK, which + /// queues a `DiscardFunding` for the parts of the contribution no pending splice attempt still + /// uses — a negotiated round, one still under negotiation, or a contribution queued behind them + /// — and nothing when there are none. Before + /// is fixed, a + /// refusal for a channel or peer LDK no longer knows names the whole contribution; the handler + /// only unmarks the addresses that names today, as it ignores the event's inputs. /// /// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed pub(crate) async fn submit( @@ -223,10 +228,15 @@ impl SpliceTracker { counterparty_node_id, e, ); - // LDK returns the contribution through a `DiscardFunding` event, whose handling frees - // the addresses the wallet marked for it. + // LDK returns what the contribution reserved and no pending splice attempt still uses + // through a `DiscardFunding` event, or nothing when every part is still in use — before + // https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4986 is fixed, a + // refusal for a channel or peer LDK no longer knows names the whole contribution — and + // the event's handling frees the addresses the wallet marked for it. // TODO(#1037): the handler ignores the event's inputs; once inputs are locked at coin - // selection, it must unlock them as well. + // selection, it must unlock them as well. Unlocking them trusts the event to name only + // inputs no pending splice attempt still spends, which holds once the pinned LDK + // carries that fix. self.discard_persisted_intent(&payment_id, restore).await; return Err(Error::ChannelSplicingFailed); } @@ -577,10 +587,15 @@ impl SpliceTracker { /// Nothing the wallet holds for the intents is released here: a round the channel's monitor /// watches may still confirm, and what LDK reserved for the others it returns through /// `DiscardFunding` once the close matures. A signed round the monitor never watched — the - /// counterparty's `commitment_signed` never arrived — is released by neither. - // TODO(#1037): once inputs are locked at coin selection, such a round's inputs stay locked - // with no record to release them from after its intent is cleared here. Release the parts of - // the contribution no watched round uses before clearing. + /// counterparty's `commitment_signed` never arrived — is released only by the `DiscardFunding` + /// LDK reports for it at the force-close once + /// is fixed, which + /// arrives after `ChannelClosed` and names what no other round of the channel uses; nothing + /// releases it before that fix. + // TODO(#1037): once inputs are locked at coin selection, such a round's inputs stay locked with + // no record to release them from after its intent is cleared here, until the pinned LDK carries + // that fix, which reports a `DiscardFunding` after `ChannelClosed` for what of the round no + // other round of the channel uses. Remove this note at that pin move. pub(crate) async fn on_channel_closed( &self, counterparty_node_id: PublicKey, channel_id: ChannelId, ) { From 5c745f207224dcb816da39015f6974e223f35c11 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 21 Sep 2026 16:31:56 -0700 Subject: [PATCH 22/49] DROP ME: f - Persist splice intents until the splice locks The pinned LDK now reports through `DiscardFunding` only the parts a refused or failed contribution reserved for itself, also for a channel or peer it no longer knows (rust-lightning issue 4986), and reports the `DiscardFunding` for a signed round the monitor never watched after `ChannelClosed` at a force-close (issue 4967), so the comments no longer hedge on either, and the note about the inputs such a round would leave locked before that fix is dropped. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 203af6e1b1f00ec0b0d7985b1d5ac5125b21d378) --- src/channel/mod.rs | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 9fc8638f17..199206c96c 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -119,10 +119,8 @@ impl SpliceTracker { /// round that has locked meanwhile. A synchronous rejection leaves the release to LDK, which /// queues a `DiscardFunding` for the parts of the contribution no pending splice attempt still /// uses — a negotiated round, one still under negotiation, or a contribution queued behind them - /// — and nothing when there are none. Before - /// is fixed, a - /// refusal for a channel or peer LDK no longer knows names the whole contribution; the handler - /// only unmarks the addresses that names today, as it ignores the event's inputs. + /// — and nothing when there are none; the handler only unmarks the addresses that names today, + /// as it ignores the event's inputs. /// /// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed pub(crate) async fn submit( @@ -229,14 +227,11 @@ impl SpliceTracker { e, ); // LDK returns what the contribution reserved and no pending splice attempt still uses - // through a `DiscardFunding` event, or nothing when every part is still in use — before - // https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4986 is fixed, a - // refusal for a channel or peer LDK no longer knows names the whole contribution — and - // the event's handling frees the addresses the wallet marked for it. + // through a `DiscardFunding` event, or nothing when every part is still in use, and the + // event's handling frees the addresses the wallet marked for it. // TODO(#1037): the handler ignores the event's inputs; once inputs are locked at coin // selection, it must unlock them as well. Unlocking them trusts the event to name only - // inputs no pending splice attempt still spends, which holds once the pinned LDK - // carries that fix. + // inputs no pending splice attempt still spends, which the pinned LDK guarantees. self.discard_persisted_intent(&payment_id, restore).await; return Err(Error::ChannelSplicingFailed); } @@ -587,15 +582,9 @@ impl SpliceTracker { /// Nothing the wallet holds for the intents is released here: a round the channel's monitor /// watches may still confirm, and what LDK reserved for the others it returns through /// `DiscardFunding` once the close matures. A signed round the monitor never watched — the - /// counterparty's `commitment_signed` never arrived — is released only by the `DiscardFunding` - /// LDK reports for it at the force-close once - /// is fixed, which - /// arrives after `ChannelClosed` and names what no other round of the channel uses; nothing - /// releases it before that fix. - // TODO(#1037): once inputs are locked at coin selection, such a round's inputs stay locked with - // no record to release them from after its intent is cleared here, until the pinned LDK carries - // that fix, which reports a `DiscardFunding` after `ChannelClosed` for what of the round no - // other round of the channel uses. Remove this note at that pin move. + /// counterparty's `commitment_signed` never arrived — is released by the `DiscardFunding` LDK + /// reports for it at the force-close, which arrives after `ChannelClosed` and names what no + /// other round of the channel uses. pub(crate) async fn on_channel_closed( &self, counterparty_node_id: PublicKey, channel_id: ChannelId, ) { From f97e5c53a1e87ecac90a95b86f41b132ef08be3d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 22 Sep 2026 10:58:46 -0700 Subject: [PATCH 23/49] DROP ME: f - Persist splice intents until the splice locks Decide a removal inside the payment store's own critical section as well, now that remove_if exists: the drop pass no longer reads a record before writing it, and the note saying a removal had no critical section to decide in goes with the read. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 62a73cd095979dc4386da3f2437997d4975487db) --- src/wallet/mod.rs | 23 +++++++++++++++++------ src/wallet/payment_stores.rs | 10 ++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index c8648685fa..0ffddb9a01 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2684,12 +2684,25 @@ impl Wallet { .await?; }, None => { - // A removal has no critical section to decide in, so the record is read first. - let record = stores.payment(&payment_id).await?; - if record.as_ref().map_or(true, waits_on_abandoned) { + // Whether the record still waits on the dropped rounds is decided inside the + // removal's own critical section, from the record found there. A record already + // gone, its removal cut short between the two stores, takes the removal path + // too, for the entry to follow it. + stores + .remove_payment_if(&payment_id, |current| { + if waits_on_abandoned(current) { + return true; + } + history_only = true; + mirrored = Some(current.clone()) + .filter(|current| current.status == PaymentStatus::Pending); + false + }) + .await?; + if !history_only { // Nothing of this node's was ever broadcast under the record, so it goes // rather than fail a payment for a transaction that never existed. The - // payment record goes first: the entry keeps resolving the rounds' txids, + // payment record went first: the entry keeps resolving the rounds' txids, // so a removal that fails midway is finished by the replayed event. A // splice intent the entry carries outlives the record as a bare intent: // the failure LDK reports for the round is described from it, and its @@ -2736,8 +2749,6 @@ impl Wallet { } continue; } - history_only = true; - mirrored = record.filter(|current| current.status == PaymentStatus::Pending); }, } if history_only { diff --git a/src/wallet/payment_stores.rs b/src/wallet/payment_stores.rs index 26aaa4e952..f99b1e986b 100644 --- a/src/wallet/payment_stores.rs +++ b/src/wallet/payment_stores.rs @@ -129,6 +129,16 @@ impl PaymentStoresGuard<'_> { self.stores.payment_store.remove(id).await } + /// Removes the payment record stored under `id` only while `predicate` holds for it, in one + /// critical section of the store; see + /// [`DataStore::remove_if`](crate::data_store::DataStore::remove_if). Returns whether the + /// record was removed. + pub(super) async fn remove_payment_if bool>( + &self, id: &PaymentId, predicate: F, + ) -> Result { + self.stores.payment_store.remove_if(id, predicate).await + } + /// Transforms the payment record stored under `id` through `f` and persists the result, all /// in one critical section of the store; see /// [`DataStore::mutate`](crate::data_store::DataStore::mutate). From 10a742b5303e1dee9cce53e4f186b9149a205748 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Sat, 5 Sep 2026 01:21:22 -0500 Subject: [PATCH 24/49] DROP ME: Abort a splice when funding signing fails The signing handler previously logged and dropped both failure paths (with TODOs to abort once LDK supported it), leaving the negotiation dangling until a peer disconnect abandons it. Cancel the contributed funding instead. LDK then emits DiscardFunding, releasing whatever the wallet holds for the contribution, and SpliceNegotiationFailed, which surfaces the failure and settles the persisted intent. Cancel errors are only logged: every error case means the splice is already beyond canceling. When LDK refuses the already-signed transaction, the failure report that cancelling produces also takes back the payment recorded at signing time: the round is gone from the channel's history and nothing can ever broadcast it, so left in place the record would wait forever on a payment nothing can confirm. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit e10d1b049c4e3e4db6d4123b7bda598535c169bd) --- src/event.rs | 54 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/src/event.rs b/src/event.rs index 694d17eb54..e49f4ceb83 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2342,7 +2342,6 @@ where } } }, - // TODO(splicing): Revisit error handling once splicing API is settled in LDK 0.3 LdkEvent::FundingTransactionReadyForSigning { channel_id, counterparty_node_id, @@ -2387,22 +2386,59 @@ where ); }, Err(e) => { - // Either the round was reset after its history was read above — LDK - // then reports the failure through `SpliceNegotiationFailed`, whose - // handling takes the record back — or LDK rejected the witnesses, in - // which case the round stays pending in LDK, and the record with it. - // TODO(splicing): cancel the contribution here through - // `ChannelManager::cancel_funding_contributed`; a follow-up wires it. + // The signed transaction never reached LDK, so nothing can ever + // broadcast it: cancel the splice. LDK responds with `DiscardFunding` + // (releasing whatever the wallet holds for the contribution) and + // `SpliceNegotiationFailed` (surfacing the failure, settling the + // persisted intent, and — the round now gone from the channel's + // history — taking back the record written above). If LDK had already + // reset the round when it refused the transaction, that report is on + // its way regardless, and the cancel finds nothing left to cancel. log_error!( self.logger, - "LDK refused the signed funding transaction for channel {}: {:?}", + "LDK refused the signed funding transaction for channel {}, \ + aborting the splice: {:?}", channel_id, e, ); + if let Err(e) = self + .channel_manager + .cancel_funding_contributed(&channel_id, &counterparty_node_id) + { + // Every cancel error means the splice is already beyond canceling + // (e.g. the channel is gone); there is nothing further to unwind. + log_error!( + self.logger, + "Failed to cancel the splice on channel {}: {:?}", + channel_id, + e, + ); + } }, } }, - Err(()) => log_error!(self.logger, "Failed signing funding transaction"), + Err(()) => { + // No record has been written for this transaction yet, so there is nothing to + // unwind: cancel the splice and let LDK's `DiscardFunding` and + // `SpliceNegotiationFailed` events release the contribution and settle the + // persisted intent. + log_error!( + self.logger, + "Failed signing the funding transaction for channel {}, aborting the splice", + channel_id, + ); + if let Err(e) = self + .channel_manager + .cancel_funding_contributed(&channel_id, &counterparty_node_id) + { + log_error!( + self.logger, + "Failed to cancel the splice on channel {}: {:?}", + channel_id, + e, + ); + } + }, }, LdkEvent::SpliceNegotiated { channel_id, From 2f1a321021281a4fa9530688abc0aeb1a19c1a7a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 1 Sep 2026 19:26:09 -0500 Subject: [PATCH 25/49] DROP ME: Add reason and splice parameters to splice failure events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An application handling SpliceNegotiationFailed had nothing to act on: the event did not say why the splice failed, nor what the failed call had attempted. Both matter for deciding what to do next — a fee bump lost to a disconnect can simply be re-issued, while the splice it meant to bump may still confirm at the prior feerate. Attach a reason, mapped from LDK's NegotiationFailureReason onto an ldk-node-owned enum so the event's serialization and bindings do not change with LDK's, and the parameters of the originating API call, taken from the persisted splice intent when the failure identifies it. Both fields are optional and serialized as odd TLVs: events written by LDK Node v0.7 read back as None, and v0.7 readers ignore the new fields. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 (cherry picked from commit 6fb80a81903c617e5ca6c8f1b1979b5afeca8ccb) --- src/channel/mod.rs | 5 + src/event.rs | 300 ++++++++++++++++++++++++++++++++++++++++++++- src/lib.rs | 2 +- 3 files changed, 302 insertions(+), 5 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 199206c96c..f2e2c4f35c 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -632,6 +632,11 @@ pub(crate) struct FailureSettlement<'a> { } impl FailureSettlement<'_> { + /// The parameters of the API call behind the splice the failure identifies, if any. + pub(crate) fn originating_kind(&self) -> Option<&SpliceKind> { + self.matched.as_ref().map(|(_, intent)| &intent.kind) + } + /// Settles the snapshotted intent, if any. Call only once the user-facing failure event is /// durably queued. pub(crate) async fn settle(self) { diff --git a/src/event.rs b/src/event.rs index e49f4ceb83..3702fbc543 100644 --- a/src/event.rs +++ b/src/event.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, OutPoint, Txid}; +use bitcoin::{Amount, OutPoint, ScriptBuf, Txid}; use lightning::blinded_path::message::NextMessageHop; use lightning::chain::chaininterface::FundingCandidate; use lightning::events::bump_transaction::BumpTransactionEvent; @@ -21,6 +21,7 @@ use lightning::events::bump_transaction::BumpTransactionEvent; use lightning::events::PaidBolt12Invoice; use lightning::events::{ ClosureReason, Event as LdkEvent, FundingInfo, InboundHTLCLocator as LdkInboundHtlcLocator, + NegotiationFailureReason as LdkNegotiationFailureReason, OutboundHTLCLocator as LdkOutboundHtlcLocator, PaymentFailureReason, PaymentPurpose, ReplayEvent, }; @@ -32,9 +33,13 @@ use lightning::util::config::{ChannelConfigOverrides, ChannelConfigUpdate}; use lightning::util::errors::APIError; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, ReadableArgs, Writeable, Writer}; -use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use lightning::{ + impl_writeable_tlv_based, impl_writeable_tlv_based_enum, + impl_writeable_tlv_based_enum_upgradable, +}; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_types::payment::{PaymentHash, PaymentPreimage}; +use lightning_types::string::UntrustedString; use crate::channel::SpliceTracker; use crate::config::{may_announce_channel, Config, PEER_RECONNECTION_INTERVAL}; @@ -52,6 +57,7 @@ use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore; use crate::payment::forwarding_store::{ForwardRecord, ForwardingStore}; +use crate::payment::pending_payment_store::SpliceKind; use crate::payment::store::{ PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; @@ -119,6 +125,155 @@ impl From for HTLCLocator { } } +/// The reason a channel splice failed. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum SpliceFailureReason { + /// The reason was not available. + Unknown, + /// The peer disconnected during negotiation. The splice may be re-initiated once the peer + /// reconnects. + PeerDisconnected, + /// The counterparty explicitly aborted the negotiation. Re-initiating with the same + /// parameters is unlikely to succeed — consider adjusting them or waiting for the + /// counterparty to initiate. + CounterpartyAborted { + /// The counterparty's abort message. + /// + /// This is counterparty-provided data. Use `Display` on [`UntrustedString`] for safe + /// logging. + msg: UntrustedString, + }, + /// An error occurred during interactive transaction negotiation (e.g., the counterparty sent + /// an invalid message). The negotiation was aborted. + NegotiationError { + /// A developer-readable error message. + msg: String, + }, + /// The funding contribution was invalid (e.g., insufficient balance for the splice amount). + /// The splice may be re-initiated with adjusted parameters. + ContributionInvalid, + /// The negotiation was locally canceled. + LocallyCanceled, + /// The channel is closing, so the negotiation cannot continue. See [`Event::ChannelClosed`] + /// for the closure reason. + ChannelClosing, + /// The contribution's feerate was too low to replace the splice's in-flight funding + /// transaction. The fee bump may be re-initiated once feerates allow it. + FeeRateTooLow, + /// A fee bump could not be initiated (e.g., a prior splice funding transaction already + /// confirmed). The channel remains operational. + CannotInitiateRbf, +} + +impl From for SpliceFailureReason { + fn from(reason: LdkNegotiationFailureReason) -> Self { + match reason { + LdkNegotiationFailureReason::Unknown => Self::Unknown, + LdkNegotiationFailureReason::PeerDisconnected => Self::PeerDisconnected, + LdkNegotiationFailureReason::CounterpartyAborted { msg } => { + Self::CounterpartyAborted { msg } + }, + LdkNegotiationFailureReason::NegotiationError { msg } => Self::NegotiationError { msg }, + LdkNegotiationFailureReason::ContributionInvalid => Self::ContributionInvalid, + LdkNegotiationFailureReason::LocallyCanceled => Self::LocallyCanceled, + LdkNegotiationFailureReason::ChannelClosing => Self::ChannelClosing, + LdkNegotiationFailureReason::FeeRateTooLow => Self::FeeRateTooLow, + LdkNegotiationFailureReason::CannotInitiateRbf => Self::CannotInitiateRbf, + } + } +} + +impl_writeable_tlv_based_enum_upgradable!(SpliceFailureReason, + (1, Unknown) => {}, + (3, PeerDisconnected) => {}, + (5, CounterpartyAborted) => { + (1, msg, required), + }, + (7, NegotiationError) => { + (1, msg, required), + }, + (9, ContributionInvalid) => {}, + (11, LocallyCanceled) => {}, + (13, ChannelClosing) => {}, + (15, FeeRateTooLow) => {}, + (17, CannotInitiateRbf) => {}, +); + +/// An output paid from a channel by a splice-out. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct SpliceOutput { + /// The amount paid to the output, in satoshis. + pub amount_sats: u64, + /// The script the output pays to. + pub script_pubkey: ScriptBuf, +} + +impl_writeable_tlv_based!(SpliceOutput, { + (0, amount_sats, required), + (2, script_pubkey, required), +}); + +/// The parameters of the [`Node`] API call that initiated a splice. +/// +/// [`Node`]: crate::Node +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum SpliceParameters { + /// Funds were added to the channel via [`Node::splice_in`] or [`Node::splice_in_with_all`]. + /// + /// [`Node::splice_in`]: crate::Node::splice_in + /// [`Node::splice_in_with_all`]: crate::Node::splice_in_with_all + In { + /// The amount added to the channel, in satoshis. For [`Node::splice_in_with_all`], the + /// amount the available funds resolved to. + /// + /// [`Node::splice_in_with_all`]: crate::Node::splice_in_with_all + amount_sats: u64, + }, + /// Funds were removed from the channel via [`Node::splice_out`]. + /// + /// [`Node::splice_out`]: crate::Node::splice_out + Out { + /// The outputs paid from the channel. + outputs: Vec, + }, + /// The splice's in-flight funding transaction was fee-bumped via + /// [`Node::bump_channel_funding_fee`]. + /// + /// [`Node::bump_channel_funding_fee`]: crate::Node::bump_channel_funding_fee + FeeBump, +} + +impl From<&SpliceKind> for SpliceParameters { + fn from(kind: &SpliceKind) -> Self { + match kind { + SpliceKind::In { amount_sats } => Self::In { amount_sats: *amount_sats }, + SpliceKind::Out { outputs } => Self::Out { + outputs: outputs + .iter() + .map(|o| SpliceOutput { + amount_sats: o.value.to_sat(), + script_pubkey: o.script_pubkey.clone(), + }) + .collect(), + }, + SpliceKind::Rbf {} => Self::FeeBump, + } + } +} + +impl_writeable_tlv_based_enum_upgradable!(SpliceParameters, + (1, In) => { + (1, amount_sats, required), + }, + (3, Out) => { + (1, outputs, required_vec), + }, + (5, FeeBump) => {}, +); + /// An event emitted by [`Node`], which should be handled by the user. /// /// [`Node`]: [`crate::Node`] @@ -312,7 +467,11 @@ pub enum Event { /// The outpoint of the channel's splice funding transaction. new_funding_txo: OutPoint, }, - /// A channel splice negotiation round with local inputs or outputs has failed. + /// A channel splice negotiation round with local inputs or outputs, or a fee bump of a + /// splice's funding transaction, has failed. + /// + /// A failed fee bump leaves the splice it meant to bump unaffected; in particular, the + /// splice's in-flight funding transaction may still confirm. /// /// This event is not emitted when only the counterparty contributes to a splice. SpliceNegotiationFailed { @@ -322,6 +481,18 @@ pub enum Event { user_channel_id: UserChannelId, /// The `node_id` of the channel counterparty. counterparty_node_id: PublicKey, + /// The reason the splice failed. + /// + /// Will be `None` for events serialized by LDK Node v0.7. + reason: Option, + /// The parameters of the [`Node`] API call that initiated the failed splice or fee bump. + /// + /// Will be `None` when the failure does not identify the channel's last locally-initiated + /// splice — e.g. when a fee bump superseded the failed attempt — and for events + /// serialized by LDK Node v0.7. + /// + /// [`Node`]: crate::Node + parameters: Option, }, } @@ -406,6 +577,8 @@ impl_writeable_tlv_based_enum!(Event, (3, counterparty_node_id, required), (5, user_channel_id, required), // TLV 7 (abandoned_funding_txo) may be set for LDK Node v0.7. + (9, reason, upgradable_option), + (11, parameters, upgradable_option), }, ); @@ -2494,8 +2667,8 @@ where channel_id, user_channel_id, counterparty_node_id, + reason, contribution, - .. } => { log_info!( self.logger, @@ -2537,10 +2710,14 @@ where .on_negotiation_failed(counterparty_node_id, channel_id, contribution.as_ref()) .await; + let parameters = settlement.originating_kind().map(SpliceParameters::from); + let event = Event::SpliceNegotiationFailed { channel_id, user_channel_id: UserChannelId(user_channel_id), counterparty_node_id, + reason: Some(reason.into()), + parameters, }; match self.event_queue.add_event(event).await { @@ -2689,6 +2866,11 @@ mod tests { claim_from_onchain_tx: bool, outbound_amount_forwarded_msat: Option, }, + SpliceNegotiationFailed { + channel_id: ChannelId, + user_channel_id: UserChannelId, + counterparty_node_id: PublicKey, + }, } impl_writeable_tlv_based_enum!(LegacyEvent, @@ -2706,6 +2888,11 @@ mod tests { (15, prev_htlcs, (default_value_vec, Vec::new())), (17, next_htlcs, (default_value_vec, Vec::new())), }, + (9, SpliceNegotiationFailed) => { + (1, channel_id, required), + (3, counterparty_node_id, required), + (5, user_channel_id, required), + }, ); fn encode_legacy_event_queue(event: LegacyEvent) -> Vec { @@ -2767,6 +2954,111 @@ mod tests { assert!(res.is_err()); } + #[test] + fn event_queue_reads_legacy_splice_negotiation_failed() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channel_id = ChannelId([42u8; 32]); + let user_channel_id = UserChannelId(4242); + let legacy_event = LegacyEvent::SpliceNegotiationFailed { + channel_id, + user_channel_id, + counterparty_node_id, + }; + let persisted_bytes = encode_legacy_event_queue(legacy_event); + + let event_queue = + EventQueue::read(&mut &persisted_bytes[..], (Arc::clone(&store), logger)).unwrap(); + assert_eq!( + event_queue.next_event(), + Some(Event::SpliceNegotiationFailed { + channel_id, + user_channel_id, + counterparty_node_id, + reason: None, + parameters: None, + }) + ); + } + + #[tokio::test] + async fn splice_negotiation_failed_round_trips_reason_and_parameters() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let event_queue = Arc::new(EventQueue::new(Arc::clone(&store), Arc::clone(&logger))); + + let expected_event = Event::SpliceNegotiationFailed { + channel_id: ChannelId([42u8; 32]), + user_channel_id: UserChannelId(4242), + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + reason: Some(SpliceFailureReason::CounterpartyAborted { + msg: UntrustedString("no thanks".to_string()), + }), + parameters: Some(SpliceParameters::Out { + outputs: vec![SpliceOutput { + amount_sats: 10_000, + script_pubkey: ScriptBuf::new(), + }], + }), + }; + event_queue.add_event(expected_event.clone()).await.unwrap(); + + let persisted_bytes = KVStore::read( + &*store, + EVENT_QUEUE_PERSISTENCE_PRIMARY_NAMESPACE, + EVENT_QUEUE_PERSISTENCE_SECONDARY_NAMESPACE, + EVENT_QUEUE_PERSISTENCE_KEY, + ) + .await + .unwrap(); + let deser_event_queue = + EventQueue::read(&mut &persisted_bytes[..], (Arc::clone(&store), logger)).unwrap(); + assert_eq!(deser_event_queue.next_event(), Some(expected_event)); + } + + #[test] + fn legacy_reader_ignores_splice_failure_reason_and_parameters() { + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channel_id = ChannelId([42u8; 32]); + let user_channel_id = UserChannelId(4242); + let event = Event::SpliceNegotiationFailed { + channel_id, + user_channel_id, + counterparty_node_id, + reason: Some(SpliceFailureReason::PeerDisconnected), + parameters: Some(SpliceParameters::In { amount_sats: 10_000 }), + }; + + // The new fields use odd TLVs, so a reader without them — LDK Node v0.7 — must + // still read the event. + let mut bytes = Vec::new(); + 1u16.write(&mut bytes).unwrap(); + event.write(&mut bytes).unwrap(); + + let mut reader = &bytes[..]; + let num_events: u16 = Readable::read(&mut reader).unwrap(); + assert_eq!(num_events, 1); + let legacy_event: LegacyEvent = Readable::read(&mut reader).unwrap(); + assert_eq!( + legacy_event, + LegacyEvent::SpliceNegotiationFailed { + channel_id, + user_channel_id, + counterparty_node_id, + } + ); + } + #[test] fn event_queue_defaults_legacy_missing_forwarded_amount() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/lib.rs b/src/lib.rs index 711772a2da..3676b16bd9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -142,7 +142,7 @@ use config::{ use connection::ConnectionManager; pub use error::Error as NodeError; use error::Error; -pub use event::Event; +pub use event::{Event, SpliceFailureReason, SpliceOutput, SpliceParameters}; use event::{EventHandler, EventQueue}; use fee_estimator::{ max_funding_feerate, rbf_splice_feerates, ConfirmationTarget, FeeEstimator, OnchainFeeEstimator, From 0731f257b2f9be8865061015c28bf2b442038d81 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Sun, 6 Sep 2026 21:38:48 -0500 Subject: [PATCH 26/49] DROP ME: Unlock lost splice inputs at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LDK only persists a splice once its negotiation reaches AwaitingSignatures, so a splice in flight when the node stops can leave no trace in LDK's channel state, and no event of LDK's ever returns what the wallet reserved for it — today the addresses its outputs pay; once At startup, reconcile each persisted splice intent against live channel state: release the reservations of a splice LDK no longer holds and drop its record, re-anchor a queued splice whose predecessor locked while the node was down, and keep — minus any inputs no surviving round still claims — those LDK resumes on its own. A splice whose channel closed meanwhile is released only if no round of it reached signing: a signed round is one the channel's monitor watches until the close matures, and what it reserved is spent by it or returned through DiscardFunding then. Reconciliation holds the lock that serializes splice submissions, as the event handlers settling intents do. Recovery fabricates no failure event for a splice lost this way: the initiating call already returned, and the channel simply no longer shows a pending splice. LDK itself reports the loss of a contribution it was still queueing or negotiating when it was last persisted — it fails the contribution as it is written and replays the failure at startup. The replay runs after reconciliation, so that report carries the splice's parameters only where reconciliation kept the intent: for a splice queued behind a pending one of ours, or a fee bump of one, but not for a channel's only splice, whose intent reconciliation settled. Reconciliation runs before background syncing and broadcasting start, so nothing can act on the stale reservations first. Events LDK replays from its last persisted state (e.g. a DiscardFunding for a splice that died before the node stopped) are likewise consumed before the node is running, so they cannot act on state a new user operation set up since. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 3581203283e4737f3e6843ab40b5eb1da8105b48) --- src/channel/mod.rs | 272 +++++++++++++++++++++++++-- src/lib.rs | 44 ++++- src/payment/pending_payment_store.rs | 9 + src/wallet/mod.rs | 10 +- 4 files changed, 312 insertions(+), 23 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index f2e2c4f35c..1c66d6f670 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -17,13 +17,13 @@ use bitcoin::transaction::Version; use bitcoin::{OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid}; use lightning::chain::chaininterface::FundingCandidate; use lightning::chain::transaction::OutPoint as LdkOutPoint; -use lightning::ln::channel_state::{ChannelDetails, SpliceCandidateDetails}; +use lightning::ln::channel_state::{ChannelDetails, SpliceCandidateDetails, SpliceCandidateStatus}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::funding::FundingContribution; use lightning::ln::types::ChannelId; use crate::data_store::{StorableObject, UpdatableObject}; -use crate::logger::{log_error, LdkLogger, Logger}; +use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::payment::pending_payment_store::{ PendingPaymentDetails, PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind, }; @@ -56,12 +56,13 @@ pub(crate) fn is_same_splice(a: &FundingContribution, b: &FundingContribution) - /// /// The intent is written before the contribution is handed to LDK, undone when LDK rejects the /// hand-off synchronously, and cleared once the splice locks, its failure is surfaced, or its -/// channel closes. The record exists for recovery, not retry: a splice still recorded at the -/// next startup identifies one that was in flight when the node stopped, so anything it reserved -/// can be released, and events about the splice can be described in terms of the original -/// request. Each splice has a record of its own — a channel may carry several, a pending splice -/// and the splices queued behind it — so that each is recognized and described whatever became -/// of the others; only a fee bump joins the record of the round it replaces. +/// channel closes. The record exists for recovery, not retry: a splice still recorded at the next +/// startup identifies one that was in flight when the node stopped, so [`Self::reconcile`] can +/// release what it still reserves where nothing else will, and events about the splice can be +/// described in terms of the original request. Each splice has a record of its own — a channel may +/// carry several, a pending splice and the splices queued behind it — so that each is recognized +/// and described whatever became of the others; only a fee bump joins the record of the round it +/// replaces. pub(crate) struct SpliceTracker { channel_manager: Arc, wallet: Arc, @@ -69,8 +70,9 @@ pub(crate) struct SpliceTracker { /// Serializes everything that reads or settles a channel's intent records against /// [`Self::submit`]'s read-funding, persist and hand-off sequence: the settling of intents by /// [`Self::on_negotiation_failed`], [`Self::on_channel_ready`] and - /// [`Self::on_channel_closed`], and the funding record [`Self::on_funding_ready_for_signing`] - /// files under an intent's id. Without it, the failure event of a synchronously rejected + /// [`Self::on_channel_closed`], the funding record [`Self::on_funding_ready_for_signing`] + /// files under an intent's id, and the startup pass of [`Self::reconcile`]. Without it, the + /// failure event of a synchronously rejected /// hand-off could settle the just-written intent while `submit` is still deciding whether to /// keep it, and a lock event handled between `submit`'s funding read and its persist could /// leave the new intent anchored at a funding the channel has moved past, which nothing would @@ -96,6 +98,127 @@ impl SpliceTracker { } } + /// Reconciles the persisted splice intents against live channel state, releasing whatever the + /// wallet still holds for a splice that did not survive the restart and nothing else will + /// release. LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, so a + /// splice lost earlier leaves no trace in LDK's channel state — the intent record is what + /// recognizes the loss. A round LDK did write is another matter: LDK either still holds it, or + /// failed it as it was last written — the failure is replayed at startup — and returns what it + /// reserved through `DiscardFunding`; a round of a channel that closed meanwhile is watched by + /// the channel's monitor until the close matures. Such rounds are left to those events. Run + /// once at startup, before background chain syncing and event processing start, so nothing can + /// act on the stale reservations first. Holds the submit lock throughout, as the event handlers + /// do. + /// + /// Recovery fabricates no failure event for a splice lost this way: the initiating call + /// already returned and the channel simply shows no pending splice anymore. LDK itself may + /// report the loss — a contribution it was still queueing or negotiating when it was last + /// persisted is failed as it is written, and the failure replayed at startup. That replay + /// runs after this reconciliation, so the report carries the splice's parameters only if + /// `decide_reconcile` kept the intent: a splice queued behind a pending one of ours, or a fee + /// bump of one, is reported with its parameters; a channel's only splice, whose intent + /// settled here, without them. + pub(crate) async fn reconcile(&self) { + let guard = self.submit_lock.lock().await; + let records = self.pending_payment_store.list_filter(|p| p.splice_intent().is_some()).await; + for record in records { + let payment_id = record.id(); + let Some(intent) = record.splice_intent().cloned() else { + continue; + }; + + let channel = self.channel(intent.counterparty_node_id, intent.channel_id); + let Some(channel) = channel else { + // The channel is gone; there is nothing to splice anymore. What the wallet holds + // for the intent is released only while no recorded round exists: a round the + // closed channel's monitor watches is either spent by the close or returned through + // the `DiscardFunding` event the monitor queues once the close matures, and a + // recorded round the monitor never watched — the counterparty's `commitment_signed` + // never arrived before the node stopped — is released by neither, as at + // `ChannelClosed`. A bare intent has no such round — LDK never wrote the splice — + // so nothing else would release it. + log_info!( + self.logger, + "Dropping the recorded splice of closed channel {} with counterparty {}", + intent.channel_id, + intent.counterparty_node_id, + ); + if record.candidates().is_empty() { + self.release_contribution(intent.channel_id, &intent.contribution, &[], None) + .await; + } + // TODO(#1037): once inputs are locked at coin selection, the parts of the + // contribution no recorded round uses stay locked with no record to release them + // from after the intent is cleared here: release them before clearing. And + // `release_contribution` swallows a failed release, which then leaves locks no + // record names either: keep the intent when the release fails. The same holds for a + // recorded round the monitor never watched: nothing releases its inputs once the + // intent is cleared here. + self.clear_persisted_intent(payment_id, |i| *i == intent).await; + continue; + }; + + if channel.funding_txo != Some(intent.pre_splice_funding_txo) { + // The funding moved on while the node was down: the recorded splice, a + // replacement, or a counterparty splice locked — the same situation a live lock + // event resolves, so resolve it the same way. + if let Some(funding_txo) = channel.funding_txo { + self.settle_superseded_intents_locked( + &guard, + intent.counterparty_node_id, + intent.channel_id, + funding_txo.into_bitcoin_outpoint(), + Some(&channel), + ) + .await; + } + continue; + } + + let candidates = channel + .splice_details + .as_ref() + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]); + match decide_reconcile(candidates) { + ReconcileDecision::Keep => { + // A kept record may still reserve more than LDK's surviving rounds use — + // extras a fee bump lost with the restart had reserved. Release the + // difference. + let extras = unclaimed_inputs(&intent.contribution, candidates); + if let Err(e) = self.wallet.unlock_outpoints(&extras).await { + log_error!( + self.logger, + "Failed to release unused splice inputs on channel {}: {}", + intent.channel_id, + e, + ); + } + }, + ReconcileDecision::Lost => { + log_info!( + self.logger, + "Dropping a splice on channel {} with counterparty {} that did not survive \ + the restart", + intent.channel_id, + intent.counterparty_node_id, + ); + self.release_contribution( + intent.channel_id, + &intent.contribution, + candidates, + None, + ) + .await; + // TODO(#1037): `release_contribution` swallows a failed release. Once inputs + // are locked at coin selection, a failure here leaves locks no record names + // after the intent is cleared: keep the intent when the release fails. + self.clear_persisted_intent(payment_id, |i| *i == intent).await; + }, + } + } + } + /// Persists a user-initiated splice as an intent and hands its contribution to /// [`ChannelManager::funding_contributed`]. The intent — and any wallet state staged on the /// splice's behalf — is durable before the hand-off, so no splice is ever in flight without a @@ -483,8 +606,10 @@ impl SpliceTracker { } /// Settles any persisted intent made obsolete by the channel's funding having moved on to - /// `funding_txo`: the funding a `ChannelReady` event reports as locked, or the one a new - /// splice builds on ([`Self::submit`]). Each of the channel's intents is decided on its own + /// `funding_txo`: the funding a `ChannelReady` event reports as locked, the one a new splice + /// builds on ([`Self::submit`]), or the one [`Self::reconcile`] finds the channel at after a + /// funding moved while the node was down — the same situation, minus the event. Each of the + /// channel's intents is decided on its own /// ([`decide_on_lock`]), against the splice candidates LDK holds for the channel (`channel`, /// as the caller listed it): one whose pre-splice outpoint is that funding was created after /// the lock and stays; one LDK still holds as a queued splice candidate is re-anchored to the @@ -894,6 +1019,52 @@ fn record_with_intent_cleared(existing: &PendingPaymentDetails) -> Option ReconcileDecision { + // A round short of `Negotiated` is one LDK still drives on its own: only `AwaitingSignatures` + // survives a restart, and LDK resumes the signature exchange itself on reconnect. + let in_flight = candidates + .iter() + .any(|candidate| !matches!(candidate.status, SpliceCandidateStatus::Negotiated { .. })); + if in_flight { + return ReconcileDecision::Keep; + } + + // LDK persists a splice once negotiated, so a negotiated candidate carrying a local + // contribution is a splice of ours LDK sees through to lock — even one negotiated at a + // different feerate than a recorded fee bump asked for. Without one, only counterparty + // rounds (or nothing) survived: the recorded splice is gone. + if candidates.iter().any(|candidate| candidate.contribution.is_some()) { + ReconcileDecision::Keep + } else { + ReconcileDecision::Lost + } +} + +/// The inputs `contribution` reserved that no candidate's own contribution still claims — extras +/// a splice attempt lost with the restart had reserved. A counterparty-only round carries no +/// contribution and claims nothing. +fn unclaimed_inputs( + contribution: &FundingContribution, candidates: &[SpliceCandidateDetails], +) -> Vec { + let claimants = candidates.iter().filter_map(|candidate| candidate.contribution.as_ref()); + unclaimed_parts(contribution, claimants).0 +} + #[cfg(test)] mod tests { use std::str::FromStr; @@ -904,8 +1075,8 @@ mod tests { use super::*; use crate::payment::pending_payment_store::{ test_funding_contribution, test_funding_contribution_with_feerate, - test_funding_contribution_with_outputs, test_funding_contribution_with_parts, - FundingTxCandidate, + test_funding_contribution_with_inputs, test_funding_contribution_with_outputs, + test_funding_contribution_with_parts, FundingTxCandidate, }; use crate::payment::store::{ConfirmationStatus, PaymentDetails, PaymentKind}; use crate::payment::{PaymentDirection, PaymentStatus}; @@ -1337,4 +1508,77 @@ mod tests { (prevtxs.iter().map(outpoint).collect(), vec![splice_out, change(20_000)]) ); } + + /// While any round is short of `Negotiated`, LDK drives the splice itself; the intent stays + /// in place until the splice settles. + #[test] + fn reconcile_keeps_the_intent_while_ldk_drives_a_round() { + let in_flight = SpliceCandidateDetails { + contribution: Some(test_funding_contribution()), + status: SpliceCandidateStatus::AwaitingSignatures { + is_initiator: true, + funding_feerate_sat_per_1000_weight: 253, + new_channel_value_satoshis: 100_000, + txid: Txid::from_byte_array([9u8; 32]), + }, + }; + assert_eq!(decide_reconcile(&[in_flight]), ReconcileDecision::Keep); + } + + /// A negotiated candidate carrying a local contribution is a splice LDK sees through to lock; + /// nothing was lost. This holds on zero-conf channels too, where the pre-splice funding + /// outpoint has not moved on yet. + #[test] + fn reconcile_trusts_a_negotiated_contribution() { + let negotiated = [negotiated_candidate(Some(test_funding_contribution()))]; + assert_eq!(decide_reconcile(&negotiated), ReconcileDecision::Keep); + } + + /// A fee bump that only survives as a candidate negotiated at a lower feerate than requested + /// is not lost: the recorded bump is moot, but the splice lives on and locks. The old + /// higher-feerate attempt's extra reservations are released through the input difference, not + /// by dropping the record. + #[test] + fn reconcile_keeps_a_bump_negotiated_at_a_lower_feerate() { + let lower = [negotiated_candidate(Some(test_funding_contribution_with_feerate(253)))]; + assert_eq!(decide_reconcile(&lower), ReconcileDecision::Keep); + } + + /// With no contribution of ours in LDK — no splice at all, or only a counterparty round — the + /// recorded splice died with the restart. + #[test] + fn reconcile_finds_the_splice_lost_when_ldk_holds_no_contribution() { + assert_eq!(decide_reconcile(&[]), ReconcileDecision::Lost); + let counterparty_only = [negotiated_candidate(None)]; + assert_eq!(decide_reconcile(&counterparty_only), ReconcileDecision::Lost); + } + + /// The inputs a kept record reserves beyond what LDK's candidates still claim are identified + /// for release; a counterparty-only round claims nothing and must not suppress the + /// difference. + #[test] + fn unclaimed_inputs_are_those_no_candidate_contribution_uses() { + let prevtxs: Vec = (1u8..=3).map(test_prevtx).collect(); + let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 }; + let recorded = test_funding_contribution_with_inputs(253, &prevtxs); + + // Every input still claimed by a surviving candidate: nothing to release. + let all = + [negotiated_candidate(Some(test_funding_contribution_with_inputs(253, &prevtxs)))]; + assert!(unclaimed_inputs(&recorded, &all).is_empty()); + + // A candidate claiming two of the three inputs: the third is released, even with a + // counterparty-only round alongside. + let partial = [ + negotiated_candidate(None), + negotiated_candidate(Some(test_funding_contribution_with_inputs(253, &prevtxs[..2]))), + ]; + assert_eq!(unclaimed_inputs(&recorded, &partial), vec![outpoint(&prevtxs[2])]); + + // No candidates at all: everything is released. + assert_eq!( + unclaimed_inputs(&recorded, &[]), + prevtxs.iter().map(outpoint).collect::>() + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 3676b16bd9..cdf58a4044 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -370,6 +370,11 @@ impl Node { ) })?; + // Release whatever the wallet still holds for splices that did not survive the restart — + // before background syncing and broadcasting start below, so nothing can act on the stale + // reservations first. + self.runtime.block_on(self.splice_tracker.reconcile()); + // A splice round recorded when this node signed it is taken back once LDK reports the // negotiation failed or the channel closed. LDK reports the loss of a negotiation its last // channel manager write carried mid-way, but a round committed, negotiated and signed @@ -731,6 +736,15 @@ impl Node { }); } + // Consume any events LDK replays from its last persisted state (e.g. a `DiscardFunding` + // for a splice that died before the node stopped) before the node is running: a replayed + // event describes pre-restart state and must act before new user operations build on it. + let replay_handler = &event_handler; + self.runtime.block_on( + self.channel_manager + .process_pending_events_async(|event| replay_handler.handle_event(event)), + ); + // Setup background processing let background_persister = Arc::clone(&self.kv_store); let background_event_handler = Arc::clone(&event_handler); @@ -1883,7 +1897,12 @@ impl Node { /// /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice - /// may be initiated once the cause of the failure is addressed. + /// may be initiated once the cause of the failure is addressed. A splice still pending when the + /// node stops is resumed by LDK when possible; otherwise it is dropped at the next startup, + /// releasing anything reserved for it. A splice LDK was still queueing or negotiating when the + /// node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if a splice this node contributed to is still pending on the channel; one + /// lost earlier is dropped without a failure event. /// /// # Experimental API /// @@ -1911,7 +1930,12 @@ impl Node { /// /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice - /// may be initiated once the cause of the failure is addressed. + /// may be initiated once the cause of the failure is addressed. A splice still pending when the + /// node stops is resumed by LDK when possible; otherwise it is dropped at the next startup, + /// releasing anything reserved for it. A splice LDK was still queueing or negotiating when the + /// node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if a splice this node contributed to is still pending on the channel; one + /// lost earlier is dropped without a failure event. /// /// # Experimental API /// @@ -1931,7 +1955,12 @@ impl Node { /// /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice - /// may be initiated once the cause of the failure is addressed. + /// may be initiated once the cause of the failure is addressed. A splice still pending when the + /// node stops is resumed by LDK when possible; otherwise it is dropped at the next startup, + /// releasing anything reserved for it. A splice LDK was still queueing or negotiating when the + /// node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if a splice this node contributed to is still pending on the channel; one + /// lost earlier is dropped without a failure event. /// /// # Experimental API /// @@ -2033,8 +2062,13 @@ impl Node { /// Errors if the channel has no pending splice to bump. /// /// A fee bump that fails during negotiation (e.g. because the peer disconnected) is reported - /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; the fee may - /// be bumped again once the cause of the failure is addressed. + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; the fee may be + /// bumped again once the cause of the failure is addressed. A fee bump still pending when the + /// node stops is resumed by LDK when possible; otherwise it is dropped at the next startup, + /// releasing anything reserved for it. A fee bump LDK was still queueing or negotiating when + /// the node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if this node contributed to the splice it bumps; one lost earlier is dropped + /// without a failure event. pub fn bump_channel_funding_fee( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, ) -> Result<(), Error> { diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 560985c494..6662e7c9c1 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -550,6 +550,15 @@ pub(crate) fn test_funding_contribution_with_feerate( test_funding_contribution_with_outputs(0, feerate, &[]) } +/// Like [`test_funding_contribution`], but with the given input-selection feerate in sat/kwu and +/// an input spending output 0 — which must be P2WPKH — of each given previous transaction. +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_inputs( + feerate: u64, prevtxs: &[bitcoin::Transaction], +) -> FundingContribution { + test_funding_contribution_with_parts(0, feerate, prevtxs, &[], None) +} + #[cfg(test)] mod tests { use bitcoin::hashes::Hash; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 0ffddb9a01..45ba8185e9 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2707,10 +2707,12 @@ impl Wallet { // splice intent the entry carries outlives the record as a bare intent: // the failure LDK reports for the round is described from it, and its // settlement removes it (`SpliceTracker::on_negotiation_failed`); one left - // behind by a node that stopped in between is found and settled by - // whatever next concerns the channel's splice. The intent is read from the - // entry as it stands, not as listed above: a fee bump submitted since may - // have replaced it, and that intent must stay just the same. + // behind by a node that stopped in between is found by + // `SpliceTracker::reconcile` at the next startup, which settles it once LDK + // holds no round of ours, or by whatever next concerns the channel's + // splice. The intent is read from the entry as it stands, not as listed + // above: a fee bump submitted since may have replaced it, and that intent + // must stay just the same. stores.remove_payment(&payment_id).await?; let kept_intent = stores .mutate_pending_payment(&payment_id, |existing| match existing { From 54d59e5e0638b830caa6f307173ca7b242e3ec56 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 10 Sep 2026 23:02:01 -0500 Subject: [PATCH 27/49] DROP ME: f - Unlock lost splice inputs at startup Reword what the reconciliation comments say about rounds LDK wrote, so they hold once the pinned LDK carries the fix for rust-lightning issue 4967: LDK then reports a `DiscardFunding` at a force-close for a recorded round the monitor never watched, which nothing released before. Split the `TODO(#1037)` note into the part that fix removes and the case that stays: a hand-off LDK never wrote, whose channel is force-closed as stale at startup, gets no event and must be released here. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 3247cbafcafd82f721e70908414a881d549b74f8) --- src/channel/mod.rs | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 1c66d6f670..7d9964ee07 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -103,9 +103,9 @@ impl SpliceTracker { /// release. LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, so a /// splice lost earlier leaves no trace in LDK's channel state — the intent record is what /// recognizes the loss. A round LDK did write is another matter: LDK either still holds it, or - /// failed it as it was last written — the failure is replayed at startup — and returns what it - /// reserved through `DiscardFunding`; a round of a channel that closed meanwhile is watched by - /// the channel's monitor until the close matures. Such rounds are left to those events. Run + /// reports its failure at startup and returns what it reserved and no other round of the + /// channel uses through `DiscardFunding`; a round of a channel that closed meanwhile is watched + /// by the channel's monitor until the close matures. Such rounds are left to those events. Run /// once at startup, before background chain syncing and event processing start, so nothing can /// act on the stale reservations first. Holds the submit lock throughout, as the event handlers /// do. @@ -134,9 +134,11 @@ impl SpliceTracker { // closed channel's monitor watches is either spent by the close or returned through // the `DiscardFunding` event the monitor queues once the close matures, and a // recorded round the monitor never watched — the counterparty's `commitment_signed` - // never arrived before the node stopped — is released by neither, as at - // `ChannelClosed`. A bare intent has no such round — LDK never wrote the splice — - // so nothing else would release it. + // never arrived before the node stopped — is released only by the `DiscardFunding` + // LDK reports for it at a force-close once + // https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4967 is fixed, + // as at `ChannelClosed`; nothing releases it before that fix. A bare intent has no + // such round — LDK never wrote the splice — so nothing else would release it. log_info!( self.logger, "Dropping the recorded splice of closed channel {} with counterparty {}", @@ -151,9 +153,15 @@ impl SpliceTracker { // contribution no recorded round uses stay locked with no record to release them // from after the intent is cleared here: release them before clearing. And // `release_contribution` swallows a failed release, which then leaves locks no - // record names either: keep the intent when the release fails. The same holds for a - // recorded round the monitor never watched: nothing releases its inputs once the - // intent is cleared here. + // record names either: keep the intent when the release fails. A recorded round the + // monitor never watched is released only by the `DiscardFunding` LDK reports for it + // at a force-close once the fix above is in the pinned LDK; nothing releases its + // inputs before that. One case stays after the fix: a hand-off LDK never wrote — + // the node stopped before the manager's next write — whose channel is force-closed + // as stale at this start and whose round the monitor never watched. LDK knows + // nothing of that round and reports no event for it; release its contribution here, + // which takes the monitor's watched transactions to tell such a round from a + // watched one, as `closed_channel_held_rounds` does at `ChannelClosed`. self.clear_persisted_intent(payment_id, |i| *i == intent).await; continue; }; From 52e83f2bf791f80b443b75c69e9265b3d50fd90e Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 21 Sep 2026 16:32:23 -0700 Subject: [PATCH 28/49] DROP ME: f - Unlock lost splice inputs at startup The pinned LDK now reports the `DiscardFunding` for a signed round the monitor never watched at the force-close (rust-lightning issue 4967), so the comments on a gone channel's intent no longer hedge on it, and the TODO on releasing that round's inputs keeps only the case LDK never learns of: a hand-off it never wrote. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 9bd7f9a56dc9962b61701a88409c56125833f24c) --- src/channel/mod.rs | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 7d9964ee07..ad54511259 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -134,11 +134,9 @@ impl SpliceTracker { // closed channel's monitor watches is either spent by the close or returned through // the `DiscardFunding` event the monitor queues once the close matures, and a // recorded round the monitor never watched — the counterparty's `commitment_signed` - // never arrived before the node stopped — is released only by the `DiscardFunding` - // LDK reports for it at a force-close once - // https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4967 is fixed, - // as at `ChannelClosed`; nothing releases it before that fix. A bare intent has no - // such round — LDK never wrote the splice — so nothing else would release it. + // never arrived before the node stopped — by the `DiscardFunding` LDK reports for + // it at the force-close, as at `ChannelClosed`. A bare intent has no such round — + // LDK never wrote the splice — so nothing else would release it. log_info!( self.logger, "Dropping the recorded splice of closed channel {} with counterparty {}", @@ -153,15 +151,13 @@ impl SpliceTracker { // contribution no recorded round uses stay locked with no record to release them // from after the intent is cleared here: release them before clearing. And // `release_contribution` swallows a failed release, which then leaves locks no - // record names either: keep the intent when the release fails. A recorded round the - // monitor never watched is released only by the `DiscardFunding` LDK reports for it - // at a force-close once the fix above is in the pinned LDK; nothing releases its - // inputs before that. One case stays after the fix: a hand-off LDK never wrote — - // the node stopped before the manager's next write — whose channel is force-closed - // as stale at this start and whose round the monitor never watched. LDK knows - // nothing of that round and reports no event for it; release its contribution here, - // which takes the monitor's watched transactions to tell such a round from a - // watched one, as `closed_channel_held_rounds` does at `ChannelClosed`. + // record names either: keep the intent when the release fails. One case stays: a + // hand-off LDK never wrote — the node stopped before the manager's next write — + // whose channel is force-closed as stale at this start and whose round the monitor + // never watched. LDK knows nothing of that round and reports no event for it; + // release its contribution here, which takes the monitor's watched transactions to + // tell such a round from a watched one, as `closed_channel_held_rounds` does at + // `ChannelClosed`. self.clear_persisted_intent(payment_id, |i| *i == intent).await; continue; }; From 298f84d11fac01a045b115b70126eb954ddab9d0 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 1 Sep 2026 19:53:45 -0500 Subject: [PATCH 29/49] DROP ME: Test splice failure surfacing and recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A disconnect during the interactive negotiation fails the splice with PeerDisconnected. The first test asserts that exactly one SpliceNegotiationFailed reaches the user — carrying the reason and the originating request's parameters — and that a new splice initiated afterwards completes with a single funding payment. The window only exists mid-negotiation: a contribution still queued at disconnect is resumed by LDK itself on reconnect, and one awaiting signatures survives re-establishment. The test therefore synchronizes on the counterparty's splice_ack — logged by LDK's peer handler — and stretches the negotiation by funding the splice from many small UTXOs, each of which adds an interactive-tx round trip. A splice dropped by a restart is recovered silently: startup reconciliation releases what the wallet reserved and drops the record without fabricating a failure event. What does reach the user is the failure LDK persisted at shutdown and replays at startup — once, with parameters only when it still matches a kept record. The restart tests cover both cases: a dropped splice-out surfaces without parameters and a further restart stays silent, while a dropped fee bump — whose record reconciliation keeps, since LDK still holds the negotiated splice — surfaces with the bump's parameters. In both, the application re-initiates and the splice completes. A splice confirmed while its node was offline keeps exactly one payment record under its splice-time id regardless of whether wallet sync or classification sees the confirmation first. Three more cases: a second splice submitted right after a zero-conf lock gets a record of its own rather than being folded into the record of the splice that just locked; a queued splice the node stopped on, which LDK fails as it shuts down, is reported at startup with its parameters — its record, an intent that never became a payment, outlives the pending splice's graduation, and reconciliation keeps it while LDK still holds that splice; and a funding record left half-written by a stop between the signing write's two stores is dropped at the next startup instead of lingering as a payment nothing indexes. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 095ba807bf60a5d60c4cff654613035504409ea9) --- tests/common/logging.rs | 43 +- tests/integration_tests_rust.rs | 686 +++++++++++++++++++++++++++++++- 2 files changed, 721 insertions(+), 8 deletions(-) diff --git a/tests/common/logging.rs b/tests/common/logging.rs index 5e2f2e5dcf..1f667aae4d 100644 --- a/tests/common/logging.rs +++ b/tests/common/logging.rs @@ -197,17 +197,21 @@ impl CollectingLogWriter { self.logs.lock().unwrap().clone() } - /// Waits up to ten seconds for a logged message containing `text`, returning whether one - /// arrived. Polling beats a fixed sleep: it returns as soon as the line lands and only pays - /// the full timeout when the line never comes. + /// Waits up to [`INTEROP_TIMEOUT_SECS`] for a logged message containing `text`, returning + /// whether one arrived. Polling beats a fixed sleep: it returns as soon as the line lands and + /// only pays the full timeout when the line never comes. + /// + /// [`INTEROP_TIMEOUT_SECS`]: super::INTEROP_TIMEOUT_SECS pub(crate) async fn wait_for(&self, text: &str) -> bool { self.wait_for_count(text, 1).await } - /// Waits up to ten seconds for `occurrences` logged messages containing `text`, returning - /// whether they arrived. + /// Waits up to [`INTEROP_TIMEOUT_SECS`] for `occurrences` logged messages containing `text`, + /// returning whether they arrived. + /// + /// [`INTEROP_TIMEOUT_SECS`]: super::INTEROP_TIMEOUT_SECS pub(crate) async fn wait_for_count(&self, text: &str, occurrences: usize) -> bool { - for _ in 0..100 { + for _ in 0..(super::INTEROP_TIMEOUT_SECS * 10) { if self.count(text) >= occurrences { return true; } @@ -222,3 +226,30 @@ impl LogWriter for CollectingLogWriter { self.logs.lock().unwrap().push(record.args.to_string()); } } + +/// Forwards every record to an inner [`CollectingLogWriter`] and signals `seen` when a record +/// contains `marker`. The signal fires from inside the logging call, so a test can react within +/// the emitting code path's timing — where the collector's polling `wait_for` (100ms granularity) +/// is too coarse. +pub(crate) struct MarkerLogWriter { + inner: Arc, + marker: &'static str, + seen: Arc, +} + +impl MarkerLogWriter { + pub(crate) fn new( + inner: Arc, marker: &'static str, seen: Arc, + ) -> Self { + Self { inner, marker, seen } + } +} + +impl LogWriter for MarkerLogWriter { + fn log(&self, record: LogRecord) { + if record.args.to_string().contains(self.marker) { + self.seen.notify_one(); + } + LogWriter::log(&*self.inner, record); + } +} diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 3780ae6cbf..cf0b8e529b 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -20,7 +20,8 @@ use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; use bitcoin::{Address, Amount, ScriptBuf, Transaction, Txid}; use common::logging::{ - init_log_logger, validate_log_entry, CollectingLogWriter, MultiNodeLogger, TestLogWriter, + init_log_logger, validate_log_entry, CollectingLogWriter, MarkerLogWriter, MultiNodeLogger, + TestLogWriter, }; use common::{ bump_fee_and_broadcast, configure_chain_source, distribute_funds_unconfirmed, @@ -46,7 +47,10 @@ use ldk_node::payment::{ ConfirmationStatus, ForwardedPaymentId, PayerProofOptions, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType, UserChannelId}; +use ldk_node::{ + BuildError, Builder, Event, Node, NodeError, ReserveType, SpliceFailureReason, + SpliceParameters, UserChannelId, +}; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::ln::channelmanager::{PaymentId, BREAKDOWN_TIMEOUT}; use lightning::routing::gossip::{NodeAlias, NodeId}; @@ -57,6 +61,39 @@ use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; use serde_json::json; +/// Pops the next event, panicking unless it is a `SpliceNegotiationFailed` from the given +/// counterparty, and returns its reason and parameters. +macro_rules! expect_splice_negotiation_failed_event { + ($node:expr, $counterparty_node_id:expr) => {{ + let event = tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + $node.next_event_async(), + ) + .await + .unwrap_or_else(|_| { + panic!("{} timed out waiting for SpliceNegotiationFailed event", $node.node_id()) + }); + match event { + ref e @ Event::SpliceNegotiationFailed { + counterparty_node_id, + ref reason, + ref parameters, + .. + } => { + println!("{} got event {:?}", $node.node_id(), e); + assert_eq!(counterparty_node_id, $counterparty_node_id); + let reason = reason.clone(); + let parameters = parameters.clone(); + $node.event_handled().unwrap(); + (reason, parameters) + }, + ref e => { + panic!("{} got unexpected event!: {:?}", std::stringify!($node), e); + }, + } + }}; +} + /// Waits until `node` has recorded the funding broadcast `funding_txid` (a channel open or splice /// candidate) as a payment carrying a `tx_type`. A splice contributor records the payment when it /// signs the funding transaction, before the transaction can even be broadcast, so for splices @@ -2611,6 +2648,68 @@ async fn zero_conf_splice_in_funding_rebroadcast_canary() { )); } +/// Two splices of this node in flight on a zero-conf channel — the second submitted right after +/// the first locked — are two payments: the second splice takes an intent record of its own +/// rather than the first splice's, whose record keeps the first splice's transaction. The lock +/// handler settles the first splice's intent before the second is submitted, so this guards +/// behavior in place before one record per splice rather than failing without it. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn zero_conf_queued_splice_is_recorded_as_its_own_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let node_a = setup_node(&chain_source, random_config()); + let mut config_b = random_config(); + config_b.node_config.trusted_peers_0conf.push(node_a.node_id()); + let node_b = setup_node(&chain_source, config_b); + + // Two coins: the second splice-in below cannot spend the first one's unconfirmed change. + let address_a = node_a.onchain_payment().new_address().unwrap(); + let second_address_a = node_a.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, second_address_a], + Amount::from_sat(5_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 2_000_000, false, &electrsd).await; + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000).unwrap(); + let first = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, first.txid).await; + // The zero-conf splice locks without confirmations, re-signaled as `ChannelReady`. + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + node_a.sync_wallets().unwrap(); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000).unwrap(); + let second = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, second.txid).await; + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let first_payment = funding_payment(&node_a, first.txid); + let second_payment = funding_payment(&node_a, second.txid); + assert_ne!(first_payment.id, second_payment.id, "each splice must have a record of its own"); + for payment in [&first_payment, &second_payment] { + assert!(matches!( + payment.kind, + PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } + )); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn rbf_splice_channel() { run_rbf_splice_channel_test(false).await; @@ -3939,6 +4038,589 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { node_a.stop().unwrap(); } +/// A mid-negotiation failure is surfaced to the user exactly once: the initiator disconnects +/// while the interactive negotiation is in flight, LDK fails the splice with `PeerDisconnected`, +/// and one `SpliceNegotiationFailed` — carrying the reason and the originating request's +/// parameters — reports it. The splice is not retried automatically; the application initiates a +/// new one, which completes. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_failure_surfaced_after_disconnect_mid_negotiation() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // The negotiation is synchronized through a log marker: LDK's peer handler logs every received + // message, and the counterparty's `splice_ack` is the earliest point where a disconnect fails + // the splice — any sooner and the contribution is still queued, which LDK resumes on reconnect + // by itself and no failure occurs. + let logger_a = Arc::new(CollectingLogWriter::new()); + let splice_ack_seen = Arc::new(tokio::sync::Notify::new()); + let mut config_a = random_config(); + config_a.log_writer = TestLogWriter::Custom(Arc::new(MarkerLogWriter::new( + logger_a.clone(), + "Received message SpliceAck", + splice_ack_seen.clone(), + ))); + // `Node::disconnect` persists a peer-store removal before severing the connection, and the + // negotiation keeps running during that write. The default composite test store turns it into + // several fsyncs plus a cross-store comparison, wide enough to lose the race below; a plain + // SQLite store keeps it to a single quick write. + config_a.store_type = TestStoreType::Sqlite; + let node_a = setup_node(&chain_source, config_a); + let node_b = setup_node(&chain_source, random_config()); + + // Fund Node A with many small UTXOs: every input the splice contributes adds an interactive-tx + // round trip, stretching the negotiation so the disconnect below reliably lands inside it. + let addresses_a: Vec
= + (0..40).map(|_| node_a.onchain_payment().new_address().unwrap()).collect(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + addresses_a, + Amount::from_sat(125_000), + ) + .await; + node_a.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 1_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // The 3M target forces roughly 25 of the 125k-sat UTXOs into the contribution. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 3_000_000).unwrap(); + + // Disconnect as soon as the negotiation is in flight. The negotiation keeps running while the + // disconnect is processed, so in principle it could still complete first — the disconnect + // would then fail nothing and the failure-event assert below would trip. The ~25 remaining + // per-input round trips make that window practically unlosable; if this ever flakes, widen + // the contribution further. + tokio::time::timeout(std::time::Duration::from_secs(10), splice_ack_seen.notified()) + .await + .expect("node A never received splice_ack"); + node_a.disconnect(node_b.node_id()).unwrap(); + + // ... which fails it with `PeerDisconnected`. The failure is surfaced with the reason and the + // originating request's parameters, and is not retried automatically. + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, Some(SpliceParameters::In { amount_sats: 3_000_000 })); + + let node_addr_b = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_addr_b, false).unwrap(); + + // The failed splice's inputs were released; the application initiates a new splice, which + // completes. A second copy of the failure event would pop here instead and panic: the failure + // is reported exactly once. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 3_000_000).unwrap(); + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + + wait_for_classified_funding_payment(&node_a, txo.txid).await; + wait_for_tx(&electrsd.client, txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let payment = funding_payment(&node_a, txo.txid); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } + )); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice LDK dropped without ever persisting it — initiated while disconnected, then the node +/// restarts — is recovered silently by startup reconciliation: the persisted intent's +/// reservations are released and its record dropped, with no fabricated failure event. What the +/// user does see, once, is the failure LDK itself persisted at shutdown and replays at startup — +/// with `PeerDisconnected` and no parameters, since the record is already gone. A further restart +/// stays silent, and a new splice initiated by the application completes. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_loss_surfaced_after_restart() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let config_b = random_config(); + let node_b = setup_node(&chain_source, config_b); + + let (onchain_balance_before_sat, splice_out_address, user_channel_id_a) = { + let node_a = setup_node(&chain_source, config_a.clone()); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Initiate a splice-out while disconnected: LDK accepts the contribution but cannot make + // progress before the restart below drops it, having neither negotiated nor persisted + // the splice itself — only the failure event it queues for it at shutdown. + node_a.disconnect(node_b.node_id()).unwrap(); + let address = node_a.onchain_payment().new_address().unwrap(); + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 500_000).unwrap(); + + let onchain_balance_before_sat = node_a.list_balances().total_onchain_balance_sats; + node_a.stop().unwrap(); + (onchain_balance_before_sat, address, user_channel_id_a) + }; + + // A signing write cut short after its payment-store half leaves a payment record under the + // splice's intent that no pending entry indexes. Plant one while the node is down: the + // intent's settlement at startup must take it along rather than leave a payment nothing + // would ever drive. + let half_written_txid = { + use bitcoin::hashes::hex::FromHex; + use ldk_node::io::sqlite_store::{SqliteStore, KV_TABLE_NAME, SQLITE_DB_FILE_NAME}; + use lightning::util::ser::Writeable; + + let store = SqliteStore::new( + config_a.node_config.storage_dir_path.clone().into(), + Some(SQLITE_DB_FILE_NAME.to_string()), + Some(KV_TABLE_NAME.to_string()), + ) + .unwrap(); + let payment_keys: HashSet = + store.list("payments", "").await.unwrap().into_iter().collect(); + let bare_intent_keys: Vec = store + .list("pending_payments", "") + .await + .unwrap() + .into_iter() + .filter(|key| !payment_keys.contains(key)) + .collect(); + assert_eq!(bare_intent_keys.len(), 1, "the dropped splice must have left one bare intent"); + let key = &bare_intent_keys[0]; + let id = PaymentId(<[u8; 32]>::from_hex(key).unwrap()); + let txid = Txid::from_byte_array([0xEE; 32]); + let half_written = PaymentDetails { + id, + kind: PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels: Vec::new() }), + }, + amount_msat: Some(500_000_000), + fee_paid_msat: Some(300_000), + direction: PaymentDirection::Outbound, + status: PaymentStatus::Pending, + latest_update_timestamp: 0, + }; + store.write("payments", "", key, half_written.encode()).await.unwrap(); + txid + }; + + // On restart, reconciliation finds nothing behind the intent in LDK, releases whatever the + // wallet still reserved for it, and drops the record — with the half-written payment under + // its id — without an event of its own. The one failure surfaced is LDK's replay of the + // event it persisted at shutdown for the dropped contribution — carrying no parameters, + // since the record it would match is already gone. + let node_a = setup_node(&chain_source, config_a.clone()); + node_a.sync_wallets().unwrap(); + + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, None); + assert!( + node_a + .list_payments_matching( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == half_written_txid) + ) + .is_empty(), + "the half-written record under the dropped splice's intent must go with it", + ); + + // The replayed failure was consumed, so another restart must not report it again. + node_a.stop().unwrap(); + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a consumed splice failure must not be reported again"); + + // The application initiates a new splice-out, which completes. + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &splice_out_address, 500_000).unwrap(); + + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + + wait_for_tx(&electrsd.client, txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + assert!( + node_a.list_balances().total_onchain_balance_sats > onchain_balance_before_sat + 400_000, + "the new splice-out should have moved ~500k sats to the on-chain balance", + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A fee bump initiated while disconnected and dropped by a restart leaves LDK holding the +/// negotiated splice at the original feerate, so startup reconciliation keeps the recorded +/// intent. The failure LDK persisted at shutdown for the dropped bump is replayed at startup, +/// matches the kept intent, and surfaces with the intent's parameters. A new bump initiated by +/// the application replaces the funding transaction, and once the negotiated splice carries the +/// bump, further restarts stay silent. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_rbf_loss_surfaced_after_restart() { + // Use a custom bitcoind config with a lower incrementalrelayfee so that the +25 sat/kwu + // (0.1 sat/vB) RBF feerate bump satisfies BIP125's absolute fee increase requirement. + let bitcoind_exe = std::env::var("BITCOIND_EXE") + .ok() + .or_else(|| corepc_node::downloaded_exe_path().ok()) + .expect( + "you need to provide an env var BITCOIND_EXE or specify a bitcoind version feature", + ); + let mut bitcoind_conf = corepc_node::Conf::default(); + bitcoind_conf.network = "regtest"; + bitcoind_conf.args.push("-rest"); + bitcoind_conf.args.push("-incrementalrelayfee=0.00000100"); + let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap(); + + let electrs_exe = std::env::var("ELECTRS_EXE") + .ok() + .or_else(electrsd::downloaded_exe_path) + .expect("you need to provide env var ELECTRS_EXE or specify an electrsd version feature"); + let mut electrsd_conf = electrsd::Conf::default(); + electrsd_conf.http_enabled = true; + electrsd_conf.network = "regtest"; + let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let config_b = random_config(); + let node_b = setup_node(&chain_source, config_b); + + let (original_txo, user_channel_id_a) = { + let node_a = setup_node(&chain_source, config_a.clone()); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Negotiate a splice but leave its transaction unconfirmed so it can be fee-bumped. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap(); + let original_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_tx(&electrsd.client, original_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // Bump the fee while disconnected and restart before anything could be negotiated: LDK + // drops the queued bump, keeping the negotiated splice at the original feerate, while + // the persisted intent records the bump. + node_a.disconnect(node_b.node_id()).unwrap(); + node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id()).unwrap(); + node_a.stop().unwrap(); + (original_txo, user_channel_id_a) + }; + + // On restart, reconciliation keeps the record — LDK still holds the negotiated splice, so + // the wallet's reservations may yet be claimed. The failure LDK persisted at shutdown for + // the dropped bump is replayed, matches the kept intent, and surfaces with its parameters. + let node_a = setup_node(&chain_source, config_a.clone()); + node_a.sync_wallets().unwrap(); + + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, Some(SpliceParameters::FeeBump)); + + // The application initiates a new fee bump, which replaces the funding transaction. + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_b_addr.clone(), false).unwrap(); + node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id()).unwrap(); + + let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + assert_ne!(original_txo, rbf_txo, "the new fee bump should produce a different funding txo"); + + // Restarting again must stay silent: the negotiated splice now carries the bump at the + // intended feerate. + node_a.stop().unwrap(); + let node_a = setup_node(&chain_source, config_a.clone()); + node_a.sync_wallets().unwrap(); + node_a.connect(node_b.node_id(), node_b_addr.clone(), false).unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a carried fee bump must not be reported as lost"); + + wait_for_tx(&electrsd.client, rbf_txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // The locked fee bump cleared its intent, so a further restart must stay silent. + node_a.stop().unwrap(); + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a locked fee bump must produce no events"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice queued behind a pending splice of this node on a confirmed channel — accepted once +/// the pending round has a confirmation — is a splice of its own, with an intent record of its +/// own. Graduating the pending splice's payment removes that splice's record, and the queued +/// splice's survives it: a restart fails the queued contribution LDK never got to negotiate, and +/// the replayed failure is described from the queued splice's own intent. Before one record per +/// splice, the queued intent rode on the pending splice's record and was lost with it, so the +/// failure carried no parameters. +/// +/// Pinned to Esplora so the nodes sync only when told to: the pending splice's lock needs the +/// counterparty's `splice_locked`, which it sends only once it has seen the confirmations. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn queued_splice_failure_surfaced_after_restart() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let node_b = setup_node(&chain_source, random_config()); + + let (pending_txid, pending_payment_id, node_b_addr) = { + let node_a = setup_node(&chain_source, config_a.clone()); + + // Two coins for node_a: the queued splice-in cannot spend the pending one's unconfirmed + // change. + let address_a = node_a.onchain_payment().new_address().unwrap(); + let second_address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, second_address_a, address_b], + Amount::from_sat(5_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap(); + let pending = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, pending.txid).await; + + // With one confirmation, seen by node_a alone, LDK takes a further splice-in as a splice + // of its own, queued until the pending one locks. Queueing it starts a quiescence + // handshake LDK breaks off with a warning until then, disconnecting the peers. + wait_for_tx(&electrsd.client, pending.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 300_000).unwrap(); + + // Five more confirmations graduate the pending splice's payment on node_a, removing its + // record, while its lock still waits on node_b. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + node_a.sync_wallets().unwrap(); + let pending_payment = funding_payment(&node_a, pending.txid); + assert_eq!(pending_payment.status, PaymentStatus::Succeeded); + + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.stop().unwrap(); + (pending.txid, pending_payment.id, node_b_addr) + }; + + // LDK failed the queued contribution when it was last persisted and replays the failure at + // startup. The queued splice's own record survived the pending splice's graduation, so the + // failure is described from it. + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, Some(SpliceParameters::In { amount_sats: 300_000 })); + + // The pending splice locks once node_b catches up, under its one record: the one that + // graduated before the restart, not a second one the lock or the sync created. + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + node_b.sync_wallets().unwrap(); + node_a.sync_wallets().unwrap(); + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + let pending_payments = node_a.list_payments_matching( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == pending_txid), + ); + assert_eq!(pending_payments.len(), 1); + assert_eq!(pending_payments[0].id, pending_payment_id); + assert_eq!(pending_payments[0].status, PaymentStatus::Succeeded); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice confirmed while its node was offline keeps exactly one payment record under its +/// splice-time id across the restart, no matter whether wallet sync or classification sees the +/// confirmed transaction first. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_payment_tracked_across_restart_before_lock() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let config_b = random_config(); + let node_b = setup_node(&chain_source, config_b); + + let splice_txid = { + let node_a = setup_node(&chain_source, config_a.clone()); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap(); + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + + // Stop node_a as soon as the splice is negotiated. node_b broadcasts the transaction + // either way, so it reaches the chain while node_a is offline. node_a recorded the + // payment when it signed the funding transaction; depending on timing, its own broadcast + // classification may or may not also have run before stopping — the assertions below + // must hold in both cases. + node_a.stop().unwrap(); + txo.txid + }; + + // Confirm the splice while node_a is offline, but keep it short of the depth at which it + // locks, so node_a restarts with its splice intent still live. + wait_for_tx(&electrsd.client, splice_txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + + // After the restart, wallet sync and classification must agree on the splice-time + // `PaymentId` no matter which of them sees the confirmed transaction first: exactly one + // payment record, and not one keyed by a txid-derived id. + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + + let splice_payments = |node: &Node| { + node.list_payments_matching( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == splice_txid), + ) + }; + let payments = splice_payments(&node_a); + assert_eq!( + payments.len(), + 1, + "expected exactly one payment record for the splice, got {}: {:#?}", + payments.len(), + payments, + ); + assert_ne!( + payments[0].id, + PaymentId(splice_txid.to_byte_array()), + "the splice payment must keep its splice-time id, not a txid-derived fallback", + ); + assert_eq!(payments[0].status, PaymentStatus::Pending); + + // Reconnect and let the splice lock: the single record graduates instead of gaining a + // duplicate. + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let payments = splice_payments(&node_a); + assert_eq!( + payments.len(), + 1, + "expected exactly one payment record after the splice locked, got {}: {:#?}", + payments.len(), + payments, + ); + assert_eq!(payments[0].status, PaymentStatus::Succeeded); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From ad3d6290c3fd4e5e1682cade6d3360bc11ae21da Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 21 Sep 2026 16:35:51 -0700 Subject: [PATCH 30/49] DROP ME: f - Test splice failure surfacing and recovery The pinned LDK now reports `SpliceNegotiationFailed` after `ChannelClosed` for a signed round the monitor never watched at a force-close (rust-lightning issue 4967), so the test of that round asserts the reason the node surfaces, `ChannelClosing`, and that no parameters come with it, the intent having been cleared at the close. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 67315bba2f226f41405b2ed001e9b0f101575051) --- tests/integration_tests_rust.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index cf0b8e529b..503dc5167d 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3752,7 +3752,8 @@ async fn splice_round_superseded_on_an_open_channel_fails_its_payment() { /// /// LDK reports the round itself after `ChannelClosed`: a `DiscardFunding` for node A's /// contribution, whose handling reclaims its addresses, and a `SpliceNegotiationFailed` the node -/// passes on. +/// reports with reason `ChannelClosing` and no parameters, its intent having been cleared at the +/// close. #[cfg(feature = "chain-esplora")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { @@ -3782,7 +3783,9 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { node_a.disconnect(node_b.node_id()).unwrap(); node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); expect_event!(node_a, ChannelClosed); - expect_event!(node_a, SpliceNegotiationFailed); + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::ChannelClosing)); + assert_eq!(parameters, None, "the intent outlived the close"); assert!( logs_a.wait_for_count(RECLAIMED_ADDRESSES, reclaimed_a + 1).await, "node A's contribution to the discarded round was not reclaimed" From 641475f1ca741b08815cf92bcdaa9554feede664 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 16:46:50 +0200 Subject: [PATCH 31/49] Add a channel transaction provenance store What a transaction is, is known only to the channel that produced it, and only while the event announcing it is being handled. Record that knowledge durably, keyed by transaction id, so it is still available whenever the transaction is looked at later. Facts are immutable and merged rather than replaced, because several channel events describe the same transaction from different angles: re-recording what is already known writes nothing, so an event handler may replay freely, while a report contradicting a recorded fact is rejected and logged rather than overwriting it. Co-Authored-By: HAL 9000 --- src/builder.rs | 36 ++- src/config.rs | 16 ++ src/io/mod.rs | 4 + src/types.rs | 2 + src/wallet/mod.rs | 64 ++++- src/wallet/provenance.rs | 551 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 666 insertions(+), 7 deletions(-) create mode 100644 src/wallet/provenance.rs diff --git a/src/builder.rs b/src/builder.rs index a9ba3d0d3c..c91dd44a99 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -59,6 +59,7 @@ use crate::config::BitcoindRestClientConfig; use crate::config::{ default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig, TorConfig, + CHANNEL_TX_FACTS_CACHE_CAPACITY, CHANNEL_TX_FACTS_CACHE_WARMUP_COUNT, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL, DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, PAYMENT_CACHE_CAPACITY, PAYMENT_CACHE_WARMUP_COUNT, @@ -84,6 +85,8 @@ use crate::io::utils::{ use crate::io::vss_store::VssStoreBuilder; use crate::io::{ self, CHANNEL_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE, FORWARDED_PAYMENT_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, @@ -105,9 +108,9 @@ use crate::probing::{ use crate::runtime::{Runtime, RuntimeSpawner}; use crate::tx_broadcaster::TransactionBroadcaster; use crate::types::{ - AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreRef, DynStoreWrapper, - GossipSync, Graph, KeysManager, MessageRouter, OnionMessenger, PaymentStore, PeerManager, - PendingPaymentStore, + AsyncPersister, ChainMonitor, ChannelManager, ChannelTxFactsStore, DynStore, DynStoreRef, + DynStoreWrapper, GossipSync, Graph, KeysManager, MessageRouter, OnionMessenger, PaymentStore, + PeerManager, PendingPaymentStore, }; use crate::wallet::persist::{read_address_pool, KVStoreWalletPersister}; use crate::wallet::Wallet; @@ -1553,6 +1556,7 @@ fn build_with_store_internal( channel_forwarding_stats_res, node_metris_res, pending_payment_store_res, + channel_tx_facts_store_res, address_pool_res, ) = runtime.block_on(async move { tokio::join!( @@ -1576,6 +1580,13 @@ fn build_with_store_internal( PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, Arc::clone(&logger_ref), ), + read_n_objects( + &*kv_store_ref, + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_TX_FACTS_CACHE_WARMUP_COUNT, + Arc::clone(&logger_ref), + ), read_address_pool(&*kv_store_ref, &*logger_ref), ) }); @@ -1907,6 +1918,24 @@ fn build_with_store_internal( }, }; + let channel_tx_facts_store = match channel_tx_facts_store_res { + Ok(channel_tx_facts) => Arc::new(ChannelTxFactsStore::new( + // The read hands us the newest records first, while the cache treats the objects it + // is seeded with as increasingly recently used. Reverse them, so that the newest + // record is the last one to be evicted rather than the first. + channel_tx_facts.into_iter().rev().collect(), + KeepLeastRecentlyUsed::new(CHANNEL_TX_FACTS_CACHE_CAPACITY), + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&kv_store), + Arc::clone(&logger), + )), + Err(e) => { + log_error!(logger, "Failed to read channel transaction facts from store: {}", e); + return Err(BuildError::ReadFailed); + }, + }; + let persisted_pool_indices = match address_pool_res { Ok(indices) => indices, Err(e) => { @@ -1927,6 +1956,7 @@ fn build_with_store_internal( Arc::clone(&config), Arc::clone(&logger), Arc::clone(&pending_payment_store), + Arc::clone(&channel_tx_facts_store), )); // Fill the address pool up front so LDK's sync `SignerProvider` callbacks can hand out diff --git a/src/config.rs b/src/config.rs index cb74b55c80..c9c7372ca9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,6 +65,22 @@ pub(crate) const PAYMENT_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000). // may displace those entries. pub(crate) const PAYMENT_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap(); +// The number of channel transaction provenance records we keep in memory. +// +// A record is written when a channel produces a transaction and read back when the wallet meets +// that transaction, so the working set is a node's recent channel activity rather than its whole +// history. Records are small — a handful of outpoints, each with a role and a channel reference +// — so this bounds the store's share of memory well below the payment store's while still +// covering the channels a node is busy with. +pub(crate) const CHANNEL_TX_FACTS_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).unwrap(); + +// The number of channel transaction provenance records we read into the cache when starting up. +// +// This matches the built-in storage backends' page size, so warming the cache costs a single page +// listing and one batch of reads. Later activity may displace those entries, which are then read +// back individually as they are needed. +pub(crate) const CHANNEL_TX_FACTS_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap(); + // The default {Esplora,Electrum} client timeout we're using. const DEFAULT_PER_REQUEST_TIMEOUT_SECS: u8 = 10; diff --git a/src/io/mod.rs b/src/io/mod.rs index b7e4d2131f..4d229e8b0d 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -37,6 +37,10 @@ pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// The channel transaction provenance facts will be persisted under this prefix. +pub(crate) const CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE: &str = "channel_tx_facts"; +pub(crate) const CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + /// Forwarded payment information is persisted under this primary namespace. pub(crate) const FORWARDED_PAYMENT_PERSISTENCE_PRIMARY_NAMESPACE: &str = "forwarded_payments"; pub(crate) const FORWARDED_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = "details"; diff --git a/src/types.rs b/src/types.rs index fd86d1bcd8..26049e8a92 100644 --- a/src/types.rs +++ b/src/types.rs @@ -46,6 +46,7 @@ use crate::payment::{ ChannelPairForwardingStats, ForwardedPaymentDetails, PaymentDetails, PendingPaymentDetails, }; use crate::runtime::RuntimeSpawner; +use crate::wallet::provenance::ChannelTxFacts; #[cfg(feature = "uniffi")] type ChannelTypeFeatures = Arc; @@ -341,6 +342,7 @@ pub(crate) type ChannelForwardingStatsStore = DataStore>; pub(crate) type ChannelPairForwardingStatsStore = DataStore, KeepNoEntries>; +pub(crate) type ChannelTxFactsStore = DataStore, KeepLeastRecentlyUsed>; /// A local, potentially user-provided, identifier of a channel. /// diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 45ba8185e9..0b081e0bcf 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -71,7 +71,8 @@ use crate::payment::{ PendingPaymentDetails, TransactionType, }; use crate::runtime::Runtime; -use crate::types::{Broadcaster, PaymentStore, PendingPaymentStore}; +use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; +use crate::wallet::provenance::ChannelTxFacts; use crate::{ChainSource, Error}; pub(crate) enum OnchainSendAmount { @@ -87,6 +88,7 @@ pub(crate) enum FundingAmount { mod payment_stores; pub(crate) mod persist; +pub(crate) mod provenance; pub(crate) mod ser; const DUST_LIMIT_SATS: u64 = 546; @@ -165,6 +167,9 @@ pub(crate) struct Wallet { logger: Arc, // The wallet's payment stores; see the type for the lock serializing their writers. payment_stores: PaymentStores, + // What this node's channels reported about the transactions they produced, keyed by + // transaction id. + channel_tx_facts_store: Arc, } impl Wallet { @@ -174,6 +179,7 @@ impl Wallet { broadcaster: Arc, fee_estimator: Arc, chain_source: Arc, payment_store: Arc, runtime: Arc, config: Arc, logger: Arc, pending_payment_store: Arc, + channel_tx_facts_store: Arc, ) -> Self { let address_pool = Mutex::new(AddressPool::new(persisted_pool_indices, &wallet, &logger)); let inner = Mutex::new(wallet); @@ -191,6 +197,45 @@ impl Wallet { config, logger, payment_stores: PaymentStores::new(payment_store, pending_payment_store), + channel_tx_facts_store, + } + } + + /// Records what a producer reported about the transaction `facts` describes, merging it into + /// whatever this node already knows about that transaction. + /// + /// Re-recording facts already known writes nothing, so a producer may safely replay its + /// event. Facts that contradict what is recorded are rejected and logged rather than + /// overwriting it: one of the two producers is wrong, and the recorded facts came first. + pub(crate) async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) -> Result<(), Error> { + let txid = facts.txid; + // The rejection is reported out of the closure rather than through it, so that the read, + // the merge and the write stay one critical section of the store's mutation lock. + let mut conflict = None; + self.channel_tx_facts_store + .mutate(&txid, |current| match current { + Some(recorded) => match recorded.clone().merged_with(&facts) { + Ok(merged) => merged, + Err(e) => { + conflict = Some(e); + None + }, + }, + None => Some(facts), + }) + .await?; + + match conflict { + Some(e) => { + log_error!( + self.logger, + "Rejected facts contradicting what is recorded for transaction {}: {}", + txid, + e, + ); + Err(Error::PersistenceFailed) + }, + None => Ok(()), } } @@ -4281,12 +4326,14 @@ mod tests { use crate::config::ElectrumSyncConfig; #[cfg(feature = "chain-esplora")] use crate::config::EsploraSyncConfig; - use crate::config::PAYMENT_CACHE_CAPACITY; + use crate::config::{CHANNEL_TX_FACTS_CACHE_CAPACITY, PAYMENT_CACHE_CAPACITY}; use crate::io::test_utils::InMemoryStore; use crate::io::{ BDK_WALLET_ADDRESS_POOL_KEY, BDK_WALLET_ADDRESS_POOL_PRIMARY_NAMESPACE, - BDK_WALLET_ADDRESS_POOL_SECONDARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + BDK_WALLET_ADDRESS_POOL_SECONDARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; @@ -4598,6 +4645,14 @@ mod tests { Arc::clone(&store), Arc::clone(&logger), )); + let channel_tx_facts_store = Arc::new(ChannelTxFactsStore::new( + Vec::new(), + KeepLeastRecentlyUsed::new(CHANNEL_TX_FACTS_CACHE_CAPACITY), + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&store), + Arc::clone(&logger), + )); let runtime = Arc::new(Runtime::new(Arc::clone(&logger)).unwrap()); let persisted_pool_indices = persist::read_address_pool(&*store, &*logger).await.unwrap(); @@ -4614,6 +4669,7 @@ mod tests { config, logger, pending_payment_store, + channel_tx_facts_store, )) } diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs new file mode 100644 index 0000000000..ac2f90968e --- /dev/null +++ b/src/wallet/provenance.rs @@ -0,0 +1,551 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Durable facts about the transactions a channel produces, as the producers of those +//! transactions reported them. +//! +//! A fact is immutable: it records what one producer knew at the moment it handed a transaction +//! over, keyed by that transaction's id. Several producers may describe the same transaction — +//! a funding transaction is reported when it is built and again when the channel reaches +//! pending — so records are merged rather than replaced, and a producer reporting a different +//! value for something already recorded is rejected instead of overwriting it. + +use std::fmt; + +use bitcoin::hashes::Hash; +use bitcoin::secp256k1::PublicKey; +use bitcoin::Txid; +use lightning::ln::channelmanager::PaymentId; +use lightning::ln::types::ChannelId; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; + +use crate::data_store::{StorableObject, StorableObjectId}; +use crate::hex_utils; +use crate::payment::store::{Channel, TransactionType}; +use crate::payment::PaymentDirection; +use crate::types::UserChannelId; + +/// The part a transaction output plays in a channel. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ChannelOutputRole { + /// The output holding a channel's funds, spendable only by the channel's commitment and + /// closing transactions. + Funding, + /// An anchor output of a commitment transaction, spendable to fee-bump that transaction. + Anchor, + /// An HTLC output of a commitment transaction. + Htlc, + /// An output a channel resolved to this node, spendable by the on-chain wallet. + Spendable, +} + +impl_writeable_tlv_based_enum!(ChannelOutputRole, + (0, Funding) => {}, + (2, Anchor) => {}, + (4, Htlc) => {}, + (6, Spendable) => {}, +); + +/// One output of a transaction that a channel controls, and the channel controlling it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ChannelOutputFact { + /// The index of the output within its transaction. + pub vout: u32, + /// What the output is for. + pub role: ChannelOutputRole, + /// The `node_id` of the channel's counterparty. + pub counterparty_node_id: PublicKey, + /// The channel controlling the output. + pub channel_id: ChannelId, + /// The channel's local identifier, when the producer of this fact knew it. It survives the + /// temporary-to-final `channel_id` transition, unlike `channel_id` itself. + pub user_channel_id: Option, +} + +impl_writeable_tlv_based!(ChannelOutputFact, { + (0, vout, required), + (2, role, required), + (4, counterparty_node_id, required), + (6, channel_id, required), + (8, user_channel_id, option), +}); + +/// This node's share of an interactively negotiated funding transaction, and the funding payment +/// the transaction belongs to. +/// +/// The amount and the fee are `None` for a candidate this node contributed nothing to, e.g. a +/// counterparty-initiated round before one of ours replaced it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocalFundingFigures { + /// The funding payment this transaction is a candidate of. + pub funding_payment_id: PaymentId, + /// This node's share of the funding amount, in millisatoshis. + pub amount_msat: Option, + /// This node's share of the transaction's on-chain fee, in millisatoshis. + pub fee_paid_msat: Option, + /// Whether this node's share moves funds into or out of its on-chain wallet. + pub direction: PaymentDirection, +} + +impl_writeable_tlv_based!(LocalFundingFigures, { + (0, funding_payment_id, required), + (2, amount_msat, option), + (4, fee_paid_msat, option), + (6, direction, required), +}); + +/// What this node's producers reported about one transaction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ChannelTxFacts { + /// The transaction these facts are about. + pub txid: Txid, + /// Outputs of this transaction controlled by a channel rather than by the wallet. + pub outputs: Vec, + /// What this transaction is, when a producer identified it directly. + pub self_role: Option, + /// This node's share of an interactive-funding candidate, and the funding record it belongs + /// to. + pub local_figures: Option, +} + +impl_writeable_tlv_based!(ChannelTxFacts, { + (0, txid, required), + (2, outputs, optional_vec), + (4, self_role, option), + (6, local_figures, option), +}); + +impl ChannelTxFacts { + /// Facts about the transaction `txid`, to be filled in with what a producer reported. + pub(crate) fn new(txid: Txid) -> Self { + Self { txid, outputs: Vec::new(), self_role: None, local_figures: None } + } + + /// Records `vouts` of this transaction as controlled by `channel` in `role`. + pub(crate) fn with_outputs( + mut self, channel: &Channel, user_channel_id: Option, + role: ChannelOutputRole, vouts: impl IntoIterator, + ) -> Self { + self.outputs.extend(vouts.into_iter().map(|vout| ChannelOutputFact { + vout, + role, + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + user_channel_id, + })); + self + } + + /// Records what this transaction is. + pub(crate) fn with_self_role(mut self, self_role: TransactionType) -> Self { + self.self_role = Some(self_role); + self + } + + /// Records this node's share of an interactively negotiated funding transaction. + pub(crate) fn with_local_figures(mut self, local_figures: LocalFundingFigures) -> Self { + self.local_figures = Some(local_figures); + self + } + + /// Merges `incoming` into these facts, returning the result, or `None` when `incoming` adds + /// nothing to what is already recorded. + /// + /// Outputs are unioned by `vout`, while `self_role` and `local_figures` are filled in only + /// where they are still absent. Re-reporting a fact is therefore a no-op, which is what lets + /// a producer replay its event without consequence. Reporting a *different* value for + /// something already recorded is rejected, leaving the recorded facts as they were. + pub(crate) fn merged_with( + mut self, incoming: &ChannelTxFacts, + ) -> Result, ChannelTxFactsConflict> { + if self.txid != incoming.txid { + return Err(ChannelTxFactsConflict::Txid { + recorded: self.txid, + incoming: incoming.txid, + }); + } + + let mut changed = false; + for output in &incoming.outputs { + match self.outputs.iter().find(|recorded| recorded.vout == output.vout) { + Some(recorded) if recorded == output => {}, + Some(recorded) => { + return Err(ChannelTxFactsConflict::Output { + recorded: recorded.clone(), + incoming: output.clone(), + }) + }, + None => { + self.outputs.push(output.clone()); + changed = true; + }, + } + } + + match (&self.self_role, &incoming.self_role) { + (Some(recorded), Some(incoming)) if recorded != incoming => { + return Err(ChannelTxFactsConflict::SelfRole { + recorded: recorded.clone(), + incoming: incoming.clone(), + }) + }, + (None, Some(incoming)) => { + self.self_role = Some(incoming.clone()); + changed = true; + }, + _ => {}, + } + + match (&self.local_figures, &incoming.local_figures) { + (Some(recorded), Some(incoming)) if recorded != incoming => { + return Err(ChannelTxFactsConflict::LocalFigures { + recorded: recorded.clone(), + incoming: incoming.clone(), + }) + }, + (None, Some(incoming)) => { + self.local_figures = Some(incoming.clone()); + changed = true; + }, + _ => {}, + } + + Ok(changed.then_some(self)) + } +} + +impl StorableObjectId for Txid { + fn encode_to_hex_str(&self) -> String { + hex_utils::to_string(self.as_byte_array()) + } + + fn decode_from_hex_str(s: &str) -> Option { + let bytes: [u8; 32] = hex_utils::to_vec(s)?.try_into().ok()?; + Some(Txid::from_byte_array(bytes)) + } +} + +impl StorableObject for ChannelTxFacts { + type Id = Txid; + + fn id(&self) -> Self::Id { + self.txid + } +} + +/// A reported fact that contradicts one already recorded for the same transaction. +/// +/// Facts are immutable, so this means two producers disagree about the same transaction, which +/// they cannot both be right about. The recorded value stands and the reported one is dropped. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ChannelTxFactsConflict { + /// The reported facts are about a different transaction altogether. + Txid { recorded: Txid, incoming: Txid }, + /// The same output is reported with a different role or a different channel. + Output { recorded: ChannelOutputFact, incoming: ChannelOutputFact }, + /// The transaction is reported as being something else than it is recorded as. + SelfRole { recorded: TransactionType, incoming: TransactionType }, + /// This node's share of the transaction is reported differently than it is recorded. + LocalFigures { recorded: LocalFundingFigures, incoming: LocalFundingFigures }, +} + +impl fmt::Display for ChannelTxFactsConflict { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Txid { recorded, incoming } => { + write!(f, "transaction {} reported as {}", recorded, incoming) + }, + Self::Output { recorded, incoming } => { + write!(f, "output {:?} reported as {:?}", recorded, incoming) + }, + Self::SelfRole { recorded, incoming } => { + write!(f, "transaction type {:?} reported as {:?}", recorded, incoming) + }, + Self::LocalFigures { recorded, incoming } => { + write!(f, "local funding figures {:?} reported as {:?}", recorded, incoming) + }, + } + } +} + +#[cfg(test)] +mod tests { + use lightning::util::ser::{Readable, Writeable}; + + use super::*; + + fn test_txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + fn test_channel(byte: u8) -> Channel { + let counterparty_node_id = PublicKey::from_slice(&[ + 0x02, 0xc6, 0x04, 0x7f, 0x94, 0x41, 0xed, 0x7d, 0x6d, 0x30, 0x45, 0x40, 0x6e, 0x95, + 0xc0, 0x7c, 0xd8, 0x5c, 0x77, 0x8e, 0x4b, 0x8c, 0xef, 0x3c, 0xa7, 0xab, 0xac, 0x09, + 0xb9, 0x5c, 0x70, 0x9e, 0xe5, + ]) + .expect("static test key is valid"); + Channel { counterparty_node_id, channel_id: ChannelId([byte; 32]) } + } + + fn other_counterparty() -> PublicKey { + PublicKey::from_slice(&[ + 0x02, 0x4d, 0x4b, 0x6c, 0xd1, 0x36, 0x10, 0x32, 0xca, 0x9b, 0xd2, 0xae, 0xb9, 0xd9, + 0x00, 0xaa, 0x4d, 0x45, 0xd9, 0xea, 0xd8, 0x0a, 0xc9, 0x42, 0x33, 0x74, 0xc4, 0x51, + 0xa7, 0x25, 0x4d, 0x07, 0x66, + ]) + .expect("static test key is valid") + } + + fn round_trip(object: &T) { + let encoded = object.encode(); + let decoded: T = Readable::read(&mut &encoded[..]).expect("round trip"); + assert_eq!(&decoded, object); + } + + fn full_facts() -> ChannelTxFacts { + let channel = test_channel(1); + ChannelTxFacts::new(test_txid(7)) + .with_outputs(&channel, Some(UserChannelId(42)), ChannelOutputRole::Funding, [0]) + .with_outputs(&channel, None, ChannelOutputRole::Anchor, [1]) + .with_outputs(&channel, None, ChannelOutputRole::Htlc, [2, 3]) + .with_outputs(&channel, None, ChannelOutputRole::Spendable, [4]) + .with_self_role(TransactionType::InteractiveFunding { channels: vec![channel.clone()] }) + .with_local_figures(LocalFundingFigures { + funding_payment_id: PaymentId([9u8; 32]), + amount_msat: Some(1_000_000), + fee_paid_msat: Some(2_500), + direction: PaymentDirection::Outbound, + }) + } + + #[test] + fn facts_round_trip_through_tlv() { + let facts = full_facts(); + round_trip(&facts); + for output in &facts.outputs { + round_trip(output); + round_trip(&output.role); + } + round_trip(facts.local_figures.as_ref().expect("figures are set")); + + // A record a producer only partially filled in round-trips as such rather than picking up + // defaults for what it left out. + let sparse = ChannelTxFacts::new(test_txid(8)); + round_trip(&sparse); + let decoded: ChannelTxFacts = + Readable::read(&mut &sparse.encode()[..]).expect("round trip"); + assert!(decoded.outputs.is_empty()); + assert_eq!(decoded.self_role, None); + assert_eq!(decoded.local_figures, None); + } + + #[test] + fn facts_key_round_trips_through_its_hex_encoding() { + let txid = test_txid(3); + let encoded = txid.encode_to_hex_str(); + assert_eq!(encoded.len(), 64); + assert_eq!(Txid::decode_from_hex_str(&encoded), Some(txid)); + assert_eq!(Txid::decode_from_hex_str("not hex"), None); + assert_eq!(Txid::decode_from_hex_str("00"), None); + } + + #[test] + fn replaying_a_fact_changes_nothing() { + let facts = full_facts(); + assert_eq!(facts.clone().merged_with(&facts), Ok(None)); + + // A producer that reports only part of what is already recorded is likewise a no-op, which + // is what a replay of an earlier event looks like once a later one has filled the record + // in. + let channel = test_channel(1); + let partial = ChannelTxFacts::new(test_txid(7)).with_outputs( + &channel, + Some(UserChannelId(42)), + ChannelOutputRole::Funding, + [0], + ); + assert_eq!(facts.clone().merged_with(&partial), Ok(None)); + } + + #[test] + fn outputs_of_two_producers_merge_into_one_record() { + let channel = test_channel(1); + let other = test_channel(2); + let txid = test_txid(7); + + // A batched sweep resolves outputs of two different channels; each producer reports only + // its own. + let first = ChannelTxFacts::new(txid).with_outputs( + &channel, + None, + ChannelOutputRole::Spendable, + [0, 2], + ); + let second = + ChannelTxFacts::new(txid).with_outputs(&other, None, ChannelOutputRole::Spendable, [1]); + + let merged = first.merged_with(&second).expect("disjoint outputs merge").expect("changed"); + assert_eq!(merged.outputs.len(), 3); + let mut vouts: Vec = merged.outputs.iter().map(|output| output.vout).collect(); + vouts.sort_unstable(); + assert_eq!(vouts, vec![0, 1, 2]); + assert_eq!( + merged.outputs.iter().find(|output| output.vout == 1).map(|output| output.channel_id), + Some(other.channel_id) + ); + } + + #[test] + fn a_second_role_for_one_output_is_rejected() { + let channel = test_channel(1); + let txid = test_txid(7); + let recorded = + ChannelTxFacts::new(txid).with_outputs(&channel, None, ChannelOutputRole::Funding, [0]); + let conflicting = + ChannelTxFacts::new(txid).with_outputs(&channel, None, ChannelOutputRole::Anchor, [0]); + + match recorded.clone().merged_with(&conflicting) { + Err(ChannelTxFactsConflict::Output { recorded, incoming }) => { + assert_eq!(recorded.role, ChannelOutputRole::Funding); + assert_eq!(incoming.role, ChannelOutputRole::Anchor); + }, + other => panic!("expected an output conflict, got {:?}", other), + } + + // The same output attributed to a different channel is a conflict too, rather than the + // later producer's channel silently winning. + let other_channel = Channel { + counterparty_node_id: other_counterparty(), + channel_id: ChannelId([2u8; 32]), + }; + let reattributed = ChannelTxFacts::new(txid).with_outputs( + &other_channel, + None, + ChannelOutputRole::Funding, + [0], + ); + assert!(matches!( + recorded.merged_with(&reattributed), + Err(ChannelTxFactsConflict::Output { .. }) + )); + } + + #[test] + fn a_second_transaction_type_is_rejected() { + let channel = test_channel(1); + let txid = test_txid(7); + let recorded = ChannelTxFacts::new(txid) + .with_self_role(TransactionType::Funding { channels: vec![channel.clone()] }); + let conflicting = + ChannelTxFacts::new(txid).with_self_role(TransactionType::InteractiveFunding { + channels: vec![channel.clone()], + }); + + match recorded.clone().merged_with(&conflicting) { + Err(ChannelTxFactsConflict::SelfRole { recorded, incoming }) => { + assert_eq!(recorded, TransactionType::Funding { channels: vec![channel.clone()] }); + assert_eq!( + incoming, + TransactionType::InteractiveFunding { channels: vec![channel] } + ); + }, + other => panic!("expected a transaction type conflict, got {:?}", other), + } + } + + #[test] + fn a_second_set_of_local_figures_is_rejected() { + let txid = test_txid(7); + let figures = LocalFundingFigures { + funding_payment_id: PaymentId([9u8; 32]), + amount_msat: Some(1_000_000), + fee_paid_msat: Some(2_500), + direction: PaymentDirection::Outbound, + }; + let recorded = ChannelTxFacts::new(txid).with_local_figures(figures.clone()); + let conflicting = ChannelTxFacts::new(txid) + .with_local_figures(LocalFundingFigures { fee_paid_msat: Some(5_000), ..figures }); + + assert!(matches!( + recorded.merged_with(&conflicting), + Err(ChannelTxFactsConflict::LocalFigures { .. }) + )); + } + + #[test] + fn facts_about_another_transaction_are_rejected() { + let recorded = ChannelTxFacts::new(test_txid(7)); + let other = ChannelTxFacts::new(test_txid(8)); + assert_eq!( + recorded.merged_with(&other), + Err(ChannelTxFactsConflict::Txid { recorded: test_txid(7), incoming: test_txid(8) }) + ); + } + + #[test] + fn a_rejected_merge_leaves_the_record_untouched() { + let channel = test_channel(1); + let txid = test_txid(7); + let recorded = + ChannelTxFacts::new(txid).with_outputs(&channel, None, ChannelOutputRole::Funding, [0]); + + // The addition the producer got right comes with one it got wrong; neither lands. + let conflicting = ChannelTxFacts::new(txid) + .with_outputs(&channel, None, ChannelOutputRole::Htlc, [1]) + .with_outputs(&channel, None, ChannelOutputRole::Anchor, [0]); + + assert!(recorded.clone().merged_with(&conflicting).is_err()); + assert_eq!(recorded.outputs.len(), 1); + assert_eq!(recorded.outputs[0].role, ChannelOutputRole::Funding); + } + + #[test] + fn a_transaction_type_fills_in_only_while_absent() { + let channel = test_channel(1); + let txid = test_txid(7); + let role = TransactionType::UnilateralClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }; + + let empty = ChannelTxFacts::new(txid); + let filled = empty + .merged_with(&ChannelTxFacts::new(txid).with_self_role(role.clone())) + .expect("fills in") + .expect("changed"); + assert_eq!(filled.self_role, Some(role.clone())); + + // A producer reporting the same type again adds nothing, so nothing is written. + assert_eq!( + filled.clone().merged_with(&ChannelTxFacts::new(txid).with_self_role(role)), + Ok(None) + ); + } + + #[test] + fn local_figures_fill_in_only_while_absent() { + let txid = test_txid(7); + let figures = LocalFundingFigures { + funding_payment_id: PaymentId([9u8; 32]), + amount_msat: None, + fee_paid_msat: None, + direction: PaymentDirection::Inbound, + }; + + let filled = ChannelTxFacts::new(txid) + .merged_with(&ChannelTxFacts::new(txid).with_local_figures(figures.clone())) + .expect("fills in") + .expect("changed"); + assert_eq!(filled.local_figures, Some(figures.clone())); + + assert_eq!( + filled.merged_with(&ChannelTxFacts::new(txid).with_local_figures(figures)), + Ok(None) + ); + } +} From fb501d1c2b2e10929d4b880619de8542857fe414 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 16:53:40 +0200 Subject: [PATCH 32/49] Record channel transaction provenance from events The channel events that hand a transaction over are the only place this node learns what that transaction is; record it there, so the knowledge outlives the handler. A funding transaction this node builds is recorded before LDK is allowed to release it, because the event is regenerated rather than persisted: recording afterwards could lose the outpoint to a crash. Sweeps, anchor bumps and HTLC claims are recorded only once the protective action has succeeded, and a failed write is logged rather than reported, so that bookkeeping can never withhold a claim. The remaining channel events record the same funding outpoints a second time as a backstop, which the merge absorbs. Co-Authored-By: HAL 9000 --- src/event.rs | 202 ++++++++++++++++++++++++++++++++++++++- src/wallet/provenance.rs | 95 ++++++++++++++---- 2 files changed, 275 insertions(+), 22 deletions(-) diff --git a/src/event.rs b/src/event.rs index 3702fbc543..eb0338bb27 100644 --- a/src/event.rs +++ b/src/event.rs @@ -16,6 +16,7 @@ use bitcoin::secp256k1::PublicKey; use bitcoin::{Amount, OutPoint, ScriptBuf, Txid}; use lightning::blinded_path::message::NextMessageHop; use lightning::chain::chaininterface::FundingCandidate; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::events::bump_transaction::BumpTransactionEvent; #[cfg(not(feature = "uniffi"))] use lightning::events::PaidBolt12Invoice; @@ -59,7 +60,8 @@ use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore; use crate::payment::forwarding_store::{ForwardRecord, ForwardingStore}; use crate::payment::pending_payment_store::SpliceKind; use crate::payment::store::{ - PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, + Channel, PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, + TransactionType, }; use crate::payment::PaymentMetadata; use crate::probing::Prober; @@ -68,6 +70,7 @@ use crate::types::{ ChainMonitor, CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, }; +use crate::wallet::provenance::{ChannelOutputRole, ChannelTxFacts}; use crate::wallet::{closed_channel_held_rounds, funding_candidates, held_splice_rounds}; use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, @@ -940,6 +943,18 @@ where .map(|channel| held_splice_rounds(channel.splice_details.as_ref(), channel.funding_txo)) } + /// Records what one of this node's channels reported about a transaction it produced. + /// + /// A failure is logged rather than reported: these facts accompany a transaction this node + /// has already released or a claim it has already made, so there is nothing left to withhold, + /// and the producing event is re-offered until the claim resolves. + async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) { + let txid = facts.txid; + if let Err(e) = self.wallet.record_channel_tx_facts(facts).await { + log_error!(self.logger, "Failed to record what channel transaction {} is: {}", txid, e); + } + } + pub async fn handle_event(&self, event: LdkEvent) -> Result<(), ReplayEvent> { match event { LdkEvent::FundingGenerationReady { @@ -962,7 +977,7 @@ where let funding_transaction = self .wallet .create_funding_transaction( - output_script, + output_script.clone(), channel_amount, confirmation_target, locktime, @@ -970,6 +985,49 @@ where .await; match funding_transaction { Ok(final_tx) => { + // Record what the transaction is before handing it to LDK, which is what + // authorizes either party to broadcast it. LDK identifies the funding + // output by the same script and value, and names the channel after that + // outpoint, so the fact matches the channel LDK will report from here on + // rather than the temporary one this event carries. + let txid = final_tx.compute_txid(); + let funding_vout = final_tx + .output + .iter() + .position(|output| { + output.script_pubkey == output_script + && output.value == channel_amount + }) + .and_then(|index| u16::try_from(index).ok()); + if let Some(vout) = funding_vout { + let funding_txo = LdkOutPoint { txid, index: vout }; + let channel = Channel { + counterparty_node_id, + channel_id: ChannelId::v1_from_funding_outpoint(funding_txo), + }; + let facts = ChannelTxFacts::new(txid).with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [vout as u32], + ); + if let Err(e) = self.wallet.record_channel_tx_facts(facts).await { + log_error!( + self.logger, + "Failed to record the funding transaction of channel {}: {}", + temporary_channel_id, + e, + ); + return Err(ReplayEvent()); + } + } else { + log_error!( + self.logger, + "Failed to locate the funding output of channel {} in the transaction funding it", + temporary_channel_id, + ); + } + let needs_manual_broadcast = self .liquidity_source .lsps2_service() @@ -1041,7 +1099,22 @@ where }, } }, - LdkEvent::FundingTxBroadcastSafe { user_channel_id, counterparty_node_id, .. } => { + LdkEvent::FundingTxBroadcastSafe { + channel_id, + user_channel_id, + counterparty_node_id, + funding_txo, + .. + } => { + let channel = Channel { counterparty_node_id, channel_id }; + let facts = ChannelTxFacts::new(funding_txo.txid).with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [funding_txo.vout], + ); + self.record_channel_tx_facts(facts).await; + self.liquidity_source .lsps2_service() .lsps2_funding_tx_broadcast_safe(user_channel_id, counterparty_node_id); @@ -1752,17 +1825,42 @@ where .await; }, LdkEvent::SpendableOutputs { outputs, channel_id, counterparty_node_id } => { + let spendable_outpoints: Vec<(Txid, u32)> = outputs + .iter() + .map(|output| { + let outpoint = output.spendable_outpoint(); + (outpoint.txid, outpoint.index as u32) + }) + .collect(); + match self .output_sweeper .track_spendable_outputs(outputs, channel_id, counterparty_node_id, true, None) .await { - Ok(_) => return Ok(()), + Ok(_) => {}, Err(_) => { log_error!(self.logger, "Failed to track spendable outputs"); return Err(ReplayEvent()); }, }; + + // Record which channel resolved these outputs only once the sweeper holds them: + // the sweep itself must never wait on bookkeeping, and the sweeper's own record + // is durable, so a failure here costs a label rather than the funds. + if let (Some(counterparty_node_id), Some(channel_id)) = + (counterparty_node_id, channel_id) + { + let channel = Channel { counterparty_node_id, channel_id }; + for facts in ChannelTxFacts::per_transaction( + &channel, + None, + ChannelOutputRole::Spendable, + spendable_outpoints, + ) { + self.record_channel_tx_facts(facts).await; + } + } }, LdkEvent::OpenChannelRequest { temporary_channel_id, @@ -2041,6 +2139,17 @@ where "LDK Node has only ever persisted ChannelPending events from rust-lightning 0.0.115 or later", ); + let channel = Channel { counterparty_node_id, channel_id }; + let facts = ChannelTxFacts::new(funding_txo.txid) + .with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [funding_txo.vout], + ) + .with_self_role(TransactionType::Funding { channels: vec![channel.clone()] }); + self.record_channel_tx_facts(facts).await; + let event = Event::ChannelPending { channel_id, user_channel_id: UserChannelId(user_channel_id), @@ -2114,6 +2223,20 @@ where ); } + // The funding this channel now runs on is either the one it opened with or the + // splice round that just locked, so recording it here also catches a round that + // locked before anything else reported it. + if let Some(funding_txo) = funding_txo { + let channel = Channel { counterparty_node_id, channel_id }; + let facts = ChannelTxFacts::new(funding_txo.txid).with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [funding_txo.vout], + ); + self.record_channel_tx_facts(facts).await; + } + // 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 @@ -2418,6 +2541,64 @@ where } self.bump_tx_event_handler.handle_event(&bte).await; + + // Record what the claim is spending only once it has been made: a claim must + // never wait on bookkeeping, and LDK re-offers the event until the claim + // resolves, so a failure here costs a label rather than the funds. + let facts = match &bte { + BumpTransactionEvent::ChannelClose { + channel_id, + counterparty_node_id, + commitment_tx, + anchor_descriptor, + pending_htlcs, + .. + } => { + let channel = Channel { + counterparty_node_id: *counterparty_node_id, + channel_id: *channel_id, + }; + // An HTLC below the dust limit is paid to fees instead of to an output of + // its own, and so has no output index to record. + let htlc_vouts = + pending_htlcs.iter().filter_map(|htlc| htlc.transaction_output_index); + vec![ChannelTxFacts::new(commitment_tx.compute_txid()) + .with_outputs( + &channel, + None, + ChannelOutputRole::Anchor, + [anchor_descriptor.outpoint.vout], + ) + .with_outputs(&channel, None, ChannelOutputRole::Htlc, htlc_vouts) + .with_self_role(TransactionType::UnilateralClose { + counterparty_node_id: *counterparty_node_id, + channel_id: *channel_id, + })] + }, + BumpTransactionEvent::HTLCResolution { + channel_id, + counterparty_node_id, + htlc_descriptors, + .. + } => { + let channel = Channel { + counterparty_node_id: *counterparty_node_id, + channel_id: *channel_id, + }; + ChannelTxFacts::per_transaction( + &channel, + None, + ChannelOutputRole::Htlc, + htlc_descriptors.iter().map(|descriptor| { + let outpoint = descriptor.outpoint(); + (outpoint.txid, outpoint.vout) + }), + ) + }, + }; + for facts in facts { + self.record_channel_tx_facts(facts).await; + } }, LdkEvent::OnionMessageIntercepted { next_hop, message, .. } => { if let NextMessageHop::NodeId(peer_node_id) = next_hop { @@ -2628,6 +2809,19 @@ where new_funding_txo, ); + let channel = Channel { counterparty_node_id, channel_id }; + let facts = ChannelTxFacts::new(new_funding_txo.txid) + .with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [new_funding_txo.vout], + ) + .with_self_role(TransactionType::InteractiveFunding { + channels: vec![channel.clone()], + }); + self.record_channel_tx_facts(facts).await; + // LDK emits this event only once our `tx_signatures` for the round are ready to // send, so the counterparty may already hold them and may broadcast the round // without us. The round's funding payment, recorded when the round was signed, diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index ac2f90968e..94f9fec5ac 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -140,18 +140,37 @@ impl ChannelTxFacts { self } + /// Facts about `outpoints`, all controlled by `channel` in `role`, as one record per + /// transaction they belong to. + pub(crate) fn per_transaction( + channel: &Channel, user_channel_id: Option, role: ChannelOutputRole, + outpoints: impl IntoIterator, + ) -> Vec { + let mut grouped: Vec<(Txid, Vec)> = Vec::new(); + for (txid, vout) in outpoints { + match grouped.iter_mut().find(|(recorded, _)| *recorded == txid) { + Some((_, vouts)) => { + if !vouts.contains(&vout) { + vouts.push(vout); + } + }, + None => grouped.push((txid, vec![vout])), + } + } + grouped + .into_iter() + .map(|(txid, vouts)| { + Self::new(txid).with_outputs(channel, user_channel_id, role, vouts) + }) + .collect() + } + /// Records what this transaction is. pub(crate) fn with_self_role(mut self, self_role: TransactionType) -> Self { self.self_role = Some(self_role); self } - /// Records this node's share of an interactively negotiated funding transaction. - pub(crate) fn with_local_figures(mut self, local_figures: LocalFundingFigures) -> Self { - self.local_figures = Some(local_figures); - self - } - /// Merges `incoming` into these facts, returning the result, or `None` when `incoming` adds /// nothing to what is already recorded. /// @@ -301,6 +320,10 @@ mod tests { .expect("static test key is valid") } + fn with_local_figures(txid: Txid, local_figures: LocalFundingFigures) -> ChannelTxFacts { + ChannelTxFacts { local_figures: Some(local_figures), ..ChannelTxFacts::new(txid) } + } + fn round_trip(object: &T) { let encoded = object.encode(); let decoded: T = Readable::read(&mut &encoded[..]).expect("round trip"); @@ -309,18 +332,23 @@ mod tests { fn full_facts() -> ChannelTxFacts { let channel = test_channel(1); - ChannelTxFacts::new(test_txid(7)) + let facts = ChannelTxFacts::new(test_txid(7)) .with_outputs(&channel, Some(UserChannelId(42)), ChannelOutputRole::Funding, [0]) .with_outputs(&channel, None, ChannelOutputRole::Anchor, [1]) .with_outputs(&channel, None, ChannelOutputRole::Htlc, [2, 3]) .with_outputs(&channel, None, ChannelOutputRole::Spendable, [4]) - .with_self_role(TransactionType::InteractiveFunding { channels: vec![channel.clone()] }) - .with_local_figures(LocalFundingFigures { + .with_self_role(TransactionType::InteractiveFunding { + channels: vec![channel.clone()], + }); + ChannelTxFacts { + local_figures: Some(LocalFundingFigures { funding_payment_id: PaymentId([9u8; 32]), amount_msat: Some(1_000_000), fee_paid_msat: Some(2_500), direction: PaymentDirection::Outbound, - }) + }), + ..facts + } } #[test] @@ -344,6 +372,40 @@ mod tests { assert_eq!(decoded.local_figures, None); } + #[test] + fn outpoints_group_into_one_record_per_transaction() { + let channel = test_channel(1); + let records = ChannelTxFacts::per_transaction( + &channel, + Some(UserChannelId(7)), + ChannelOutputRole::Spendable, + [ + (test_txid(1), 0), + (test_txid(2), 4), + (test_txid(1), 3), + // A producer reporting the same outpoint twice contributes it once. + (test_txid(2), 4), + ], + ); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].txid, test_txid(1)); + assert_eq!( + records[0].outputs.iter().map(|output| output.vout).collect::>(), + vec![0, 3] + ); + assert_eq!(records[1].txid, test_txid(2)); + assert_eq!( + records[1].outputs.iter().map(|output| output.vout).collect::>(), + vec![4] + ); + assert!(records.iter().flat_map(|facts| &facts.outputs).all(|output| { + output.role == ChannelOutputRole::Spendable + && output.channel_id == channel.channel_id + && output.user_channel_id == Some(UserChannelId(7)) + })); + } + #[test] fn facts_key_round_trips_through_its_hex_encoding() { let txid = test_txid(3); @@ -467,9 +529,9 @@ mod tests { fee_paid_msat: Some(2_500), direction: PaymentDirection::Outbound, }; - let recorded = ChannelTxFacts::new(txid).with_local_figures(figures.clone()); - let conflicting = ChannelTxFacts::new(txid) - .with_local_figures(LocalFundingFigures { fee_paid_msat: Some(5_000), ..figures }); + let recorded = with_local_figures(txid, figures.clone()); + let conflicting = + with_local_figures(txid, LocalFundingFigures { fee_paid_msat: Some(5_000), ..figures }); assert!(matches!( recorded.merged_with(&conflicting), @@ -538,14 +600,11 @@ mod tests { }; let filled = ChannelTxFacts::new(txid) - .merged_with(&ChannelTxFacts::new(txid).with_local_figures(figures.clone())) + .merged_with(&with_local_figures(txid, figures.clone())) .expect("fills in") .expect("changed"); assert_eq!(filled.local_figures, Some(figures.clone())); - assert_eq!( - filled.merged_with(&ChannelTxFacts::new(txid).with_local_figures(figures)), - Ok(None) - ); + assert_eq!(filled.merged_with(&with_local_figures(txid, figures)), Ok(None)); } } From 71f0edc7d46b2f9e3537e1d21dc555cbce017ea8 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 17:12:50 +0200 Subject: [PATCH 33/49] Classify transactions from recorded provenance What a transaction is follows from what this node's channels said about it and about the transactions it spends from, so derive it there rather than from the tag its broadcast carried: a tag describes one broadcast, while the facts describe the transaction and survive re-broadcasts and replacements unchanged. A funding output spent in a shape no channel produces stays unnamed. Guessing would put a classification on a payment record that nothing later corrects, and an unnamed record is the honest answer. Co-Authored-By: HAL 9000 --- src/wallet/provenance.rs | 429 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 428 insertions(+), 1 deletion(-) diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index 94f9fec5ac..1c05d46332 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -14,11 +14,12 @@ //! pending — so records are merged rather than replaced, and a producer reporting a different //! value for something already recorded is rejected instead of overwriting it. +use std::collections::HashMap; use std::fmt; use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; -use bitcoin::Txid; +use bitcoin::{Sequence, Transaction, Txid}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::types::ChannelId; use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; @@ -291,8 +292,165 @@ impl fmt::Display for ChannelTxFactsConflict { } } +/// The recorded facts a transaction's classification rests on: what this node's channels reported +/// about the transaction itself, and what they reported about the transactions its inputs spend. +#[derive(Clone, Debug, Default)] +pub(crate) struct TxProvenance { + /// What was reported about the transaction itself, if anything. + self_facts: Option, + /// What was reported about the transactions the inputs spend, keyed by transaction id. Only + /// the transactions the inputs actually reference are represented. + parent_facts: HashMap, +} + +impl TxProvenance { + /// The provenance assembled from the facts recorded for a transaction and for the + /// transactions its inputs spend. + pub(crate) fn new( + self_facts: Option, parent_facts: HashMap, + ) -> Self { + Self { self_facts, parent_facts } + } + + /// What `tx` is, as far as these facts can tell; see [`classify`]. + pub(crate) fn classify(&self, tx: &Transaction) -> Option { + classify(tx, self.self_facts.as_ref(), &self.parent_facts) + } + + /// This node's share of the transaction, for a candidate of an interactively negotiated + /// funding a producer reported the figures of. + pub(crate) fn local_figures(&self) -> Option<&LocalFundingFigures> { + self.self_facts.as_ref()?.local_figures.as_ref() + } +} + +/// What a transaction is, derived from what this node's channels recorded about it and about the +/// transactions its inputs spend. +/// +/// `self_facts` are the facts recorded for `tx`, `parent_facts` those recorded for the +/// transactions `tx` spends from, keyed by transaction id. Anything these cannot account for is +/// left unclassified rather than guessed: without a channel of this node's laying claim to an +/// output, a transaction is an ordinary on-chain payment. +pub(crate) fn classify( + tx: &Transaction, self_facts: Option<&ChannelTxFacts>, + parent_facts: &HashMap, +) -> Option { + // A producer that named the transaction outright is the most reliable answer there is, and + // the only one that stays put across a re-broadcast or a replacement of the transaction. + if let Some(self_role) = self_facts.and_then(|facts| facts.self_role.as_ref()) { + return Some(self_role.clone()); + } + + let spent: Vec<&ChannelOutputFact> = tx + .input + .iter() + .filter_map(|input| { + parent_facts.get(&input.previous_output.txid).and_then(|parent| { + parent.outputs.iter().find(|output| output.vout == input.previous_output.vout) + }) + }) + .collect(); + let created: &[ChannelOutputFact] = self_facts.map_or(&[], |facts| facts.outputs.as_slice()); + let funds: Vec<&ChannelOutputFact> = + created.iter().filter(|output| output.role == ChannelOutputRole::Funding).collect(); + + let spent_funding = in_role(&spent, ChannelOutputRole::Funding); + if let Some(funding) = spent_funding.first() { + // Moving a channel's funds into a new funding output is what an interactive negotiation + // produces, whichever side of it this node is on. + if !funds.is_empty() { + let channels = channels_of(spent_funding.iter().copied().chain(funds.iter().copied())); + return Some(TransactionType::InteractiveFunding { channels }); + } + if is_cooperative_close(tx) { + return Some(TransactionType::CooperativeClose { + counterparty_node_id: funding.counterparty_node_id, + channel_id: funding.channel_id, + }); + } + if is_commitment(tx) { + return Some(TransactionType::UnilateralClose { + counterparty_node_id: funding.counterparty_node_id, + channel_id: funding.channel_id, + }); + } + // The funding output is gone in a shape none of the transactions a channel produces has. + // Naming it anyway would put a guess on a payment record that nothing later corrects. + return None; + } + + let spent_anchors = in_role(&spent, ChannelOutputRole::Anchor); + if let Some(anchor) = spent_anchors.first() { + return Some(TransactionType::AnchorBump { + counterparty_node_id: anchor.counterparty_node_id, + channel_id: anchor.channel_id, + }); + } + + let spent_htlcs = in_role(&spent, ChannelOutputRole::Htlc); + if let Some(htlc) = spent_htlcs.first() { + return Some(TransactionType::Claim { + counterparty_node_id: htlc.counterparty_node_id, + channel_id: htlc.channel_id, + }); + } + + let spent_spendable = in_role(&spent, ChannelOutputRole::Spendable); + if !spent_spendable.is_empty() { + return Some(TransactionType::Sweep { channels: channels_of(spent_spendable) }); + } + + if !funds.is_empty() { + return Some(TransactionType::Funding { channels: channels_of(funds) }); + } + + None +} + +/// The outputs among `outputs` a channel controls in `role`. +fn in_role<'a>( + outputs: &[&'a ChannelOutputFact], role: ChannelOutputRole, +) -> Vec<&'a ChannelOutputFact> { + outputs.iter().copied().filter(|output| output.role == role).collect() +} + +/// The channels controlling `outputs`, each named once, in the order the outputs name them. +fn channels_of<'a>(outputs: impl IntoIterator) -> Vec { + let mut channels: Vec = Vec::new(); + for output in outputs { + let channel = Channel { + counterparty_node_id: output.counterparty_node_id, + channel_id: output.channel_id, + }; + if !channels.contains(&channel) { + channels.push(channel); + } + } + channels +} + +/// Whether `tx` has the shape BOLT 2 gives a cooperative closing transaction: the sole spend of +/// the funding output, final and valid from the moment it is signed. +fn is_cooperative_close(tx: &Transaction) -> bool { + tx.input.len() == 1 + && tx.input[0].sequence == Sequence::MAX + && tx.lock_time.to_consensus_u32() == 0 +} + +/// Whether `tx` has the shape BOLT 3 gives a commitment transaction: the sole spend of the +/// funding output, with the upper byte of its sequence and of its locktime set to the constants +/// that mark the remainder of both as the obscured commitment number. +fn is_commitment(tx: &Transaction) -> bool { + tx.input.len() == 1 + && (tx.input[0].sequence.0 >> 24) as u8 == 0x80 + && (tx.lock_time.to_consensus_u32() >> 24) as u8 == 0x20 +} + #[cfg(test)] mod tests { + use bitcoin::absolute::LockTime; + use bitcoin::transaction::Version; + use bitcoin::{Amount, OutPoint, ScriptBuf, TxIn, TxOut, Witness}; use lightning::util::ser::{Readable, Writeable}; use super::*; @@ -607,4 +765,273 @@ mod tests { assert_eq!(filled.merged_with(&with_local_figures(txid, figures)), Ok(None)); } + /// The transaction whose outputs the classification cases below spend. + const PARENT: u8 = 0x11; + + /// A transaction spending `inputs`, each input carrying `sequence`. + fn spending_tx(inputs: &[(Txid, u32)], sequence: Sequence, lock_time: u32) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(lock_time), + input: inputs + .iter() + .map(|(txid, vout)| TxIn { + previous_output: OutPoint { txid: *txid, vout: *vout }, + script_sig: ScriptBuf::new(), + sequence, + witness: Witness::new(), + }) + .collect(), + output: vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: ScriptBuf::new() }], + } + } + + /// The single spend of `PARENT`'s first output, in the shape BOLT 2 gives a cooperative + /// closing transaction. + fn cooperative_close_shaped() -> Transaction { + spending_tx(&[(test_txid(PARENT), 0)], Sequence::MAX, 0) + } + + /// The single spend of `PARENT`'s first output, in the shape BOLT 3 gives a commitment + /// transaction: the obscured commitment number split across sequence and locktime. + fn commitment_shaped() -> Transaction { + spending_tx(&[(test_txid(PARENT), 0)], Sequence(0x80_12_34_56), 0x20_ab_cd_ef) + } + + /// The single spend of `PARENT`'s first output in no shape a channel produces: replaceable, + /// and without a commitment number. + fn unrecognised_shaped() -> Transaction { + spending_tx(&[(test_txid(PARENT), 0)], Sequence(0xff_ff_ff_fd), 0) + } + + fn parents(facts: impl IntoIterator) -> HashMap { + facts.into_iter().map(|facts| (facts.txid, facts)).collect() + } + + /// Facts recording `PARENT`'s outputs `vouts` as controlled by `channel` in `role`. + fn parent_outputs( + channel: &Channel, role: ChannelOutputRole, vouts: impl IntoIterator, + ) -> ChannelTxFacts { + ChannelTxFacts::new(test_txid(PARENT)).with_outputs(channel, None, role, vouts) + } + + /// Facts recording `tx`'s first output as `channel`'s funding output. + fn funds(tx: &Transaction, channel: &Channel, vout: u32) -> ChannelTxFacts { + ChannelTxFacts::new(tx.compute_txid()).with_outputs( + channel, + Some(UserChannelId(42)), + ChannelOutputRole::Funding, + [vout], + ) + } + + #[test] + fn a_reported_role_settles_what_a_transaction_is() { + let channel = test_channel(1); + let tx = cooperative_close_shaped(); + let recorded = parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]); + + // Left to its shape alone, the transaction is a cooperative close. + assert_eq!( + classify(&tx, None, &recorded), + Some(TransactionType::CooperativeClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + + // The channel that produced it says otherwise, and it is the one that knows. + let reported = TransactionType::UnilateralClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }; + let self_facts = ChannelTxFacts::new(tx.compute_txid()).with_self_role(reported.clone()); + assert_eq!(classify(&tx, Some(&self_facts), &recorded), Some(reported)); + } + + #[test] + fn spending_and_creating_a_funding_output_is_an_interactive_funding() { + let channel = test_channel(1); + let tx = unrecognised_shaped(); + let self_facts = funds(&tx, &channel, 0); + + assert_eq!( + classify( + &tx, + Some(&self_facts), + &parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]), + ), + Some(TransactionType::InteractiveFunding { channels: vec![channel] }) + ); + } + + #[test] + fn a_final_single_spend_of_a_funding_output_is_a_cooperative_close() { + let channel = test_channel(1); + assert_eq!( + classify( + &cooperative_close_shaped(), + None, + &parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]), + ), + Some(TransactionType::CooperativeClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn a_commitment_shaped_spend_of_a_funding_output_is_a_unilateral_close() { + let channel = test_channel(1); + assert_eq!( + classify( + &commitment_shaped(), + None, + &parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]), + ), + Some(TransactionType::UnilateralClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn an_unrecognised_spend_of_a_funding_output_is_left_unnamed() { + let channel = test_channel(1); + let recorded = parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]); + + // Neither template matches and nothing reported the transaction, so there is no answer + // to give. A close of either kind would be a guess. + assert_eq!(classify(&unrecognised_shaped(), None, &recorded), None); + + // A second input rules both templates out as well, whatever the first input looks like. + let two_inputs = + spending_tx(&[(test_txid(PARENT), 0), (test_txid(PARENT + 1), 0)], Sequence::MAX, 0); + assert_eq!(classify(&two_inputs, None, &recorded), None); + } + + #[test] + fn spending_an_anchor_output_is_an_anchor_bump() { + let channel = test_channel(1); + let tx = spending_tx( + &[(test_txid(PARENT), 1), (test_txid(PARENT + 9), 0)], + Sequence(0xff_ff_ff_fd), + 0, + ); + + assert_eq!( + classify( + &tx, + None, + &parents([parent_outputs(&channel, ChannelOutputRole::Anchor, [1])]), + ), + Some(TransactionType::AnchorBump { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn spending_an_htlc_output_is_a_claim() { + let channel = test_channel(1); + let tx = spending_tx(&[(test_txid(PARENT), 2)], Sequence(0xff_ff_ff_fd), 0); + + assert_eq!( + classify(&tx, None, &parents([parent_outputs(&channel, ChannelOutputRole::Htlc, [2])]),), + Some(TransactionType::Claim { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn spending_resolved_outputs_is_a_sweep_naming_every_channel() { + let channel = test_channel(1); + let other = test_channel(2); + let tx = spending_tx( + &[(test_txid(PARENT), 0), (test_txid(PARENT), 1), (test_txid(PARENT + 1), 0)], + Sequence(0xff_ff_ff_fd), + 0, + ); + + // One sweep resolving outputs of two channels is associated with both of them. + let recorded = parents([ + parent_outputs(&channel, ChannelOutputRole::Spendable, [0, 1]), + ChannelTxFacts::new(test_txid(PARENT + 1)).with_outputs( + &other, + None, + ChannelOutputRole::Spendable, + [0], + ), + ]); + assert_eq!( + classify(&tx, None, &recorded), + Some(TransactionType::Sweep { channels: vec![channel, other] }) + ); + } + + #[test] + fn creating_a_funding_output_alone_is_a_funding_naming_every_channel() { + let channel = test_channel(1); + let other = test_channel(2); + // Nothing channel-controlled is spent: the wallet pays for both funding outputs. + let tx = spending_tx(&[(test_txid(PARENT + 20), 0)], Sequence(0xff_ff_ff_fd), 0); + let self_facts = funds(&tx, &channel, 0).with_outputs( + &other, + Some(UserChannelId(43)), + ChannelOutputRole::Funding, + [1], + ); + + assert_eq!( + classify(&tx, Some(&self_facts), &HashMap::new()), + Some(TransactionType::Funding { channels: vec![channel, other] }) + ); + } + + #[test] + fn an_ordinary_wallet_spend_is_left_unnamed() { + let tx = spending_tx(&[(test_txid(PARENT), 0)], Sequence(0xff_ff_ff_fd), 0); + + // Nothing was ever reported about the transaction or about what it spends. + assert_eq!(classify(&tx, None, &HashMap::new()), None); + + // Nor does spending an output a channel left alone make the transaction a channel's. + let channel = test_channel(1); + let recorded = parents([parent_outputs(&channel, ChannelOutputRole::Spendable, [7])]); + assert_eq!(classify(&tx, None, &recorded), None); + } + + #[test] + fn provenance_answers_from_the_facts_it_holds() { + let channel = test_channel(1); + let tx = cooperative_close_shaped(); + let figures = LocalFundingFigures { + funding_payment_id: PaymentId([9u8; 32]), + amount_msat: Some(1_000_000), + fee_paid_msat: Some(2_500), + direction: PaymentDirection::Outbound, + }; + + let empty = TxProvenance::default(); + assert_eq!(empty.classify(&tx), None); + assert_eq!(empty.local_figures(), None); + + let provenance = TxProvenance::new( + Some(with_local_figures(tx.compute_txid(), figures.clone())), + parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]), + ); + assert_eq!( + provenance.classify(&tx), + Some(TransactionType::CooperativeClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + assert_eq!(provenance.local_figures(), Some(&figures)); + } } From 5a592d2cd12cc3d4a18c1b5683abf850000a6966 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 17:12:55 +0200 Subject: [PATCH 34/49] Classify on-chain payments during wallet sync Wallet sync recorded every on-chain transaction as unclassified, leaving what the transaction is to the classification the broadcast queue wrote separately. Name it from the recorded facts instead, so the record wallet sync creates already says what its transaction is. The facts live behind an async store while the record is built under the wallet lock, so each caller reads them first and passes them in, looking up only the transactions the inputs actually reference. A transaction the wallet sees before its channel reports it is named on a later chain tip, from the same scan that graduates confirmed payments. The retry only names a record that is still unnamed, read inside the payment store's critical section, so it can add a name but never replace one. Where a producer reported this node's share of an interactively negotiated funding, that share describes the payment better than the wallet's view does, which reads a shared funding input as wholly this node's. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 308 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 280 insertions(+), 28 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 0b081e0bcf..61ae5cf0ad 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -72,7 +72,7 @@ use crate::payment::{ }; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; -use crate::wallet::provenance::ChannelTxFacts; +use crate::wallet::provenance::{ChannelTxFacts, TxProvenance}; use crate::{ChainSource, Error}; pub(crate) enum OnchainSendAmount { @@ -239,6 +239,43 @@ impl Wallet { } } + /// Everything this node recorded about `tx` and about the transactions its inputs spend, as + /// classifying `tx` needs it. + /// + /// Facts that cannot be read are logged and left out, leaving the transaction less + /// classifiable rather than failing the caller: a transaction whose record says nothing about + /// what it is remains a correct record of the funds it moved, and is picked up again on a + /// later chain tip. + async fn tx_provenance(&self, txid: Txid, tx: &Transaction) -> TxProvenance { + let self_facts = self.channel_tx_facts(&txid).await; + let parents: HashSet = + tx.input.iter().map(|input| input.previous_output.txid).collect(); + let mut parent_facts = HashMap::new(); + for parent in parents { + if let Some(facts) = self.channel_tx_facts(&parent).await { + parent_facts.insert(parent, facts); + } + } + TxProvenance::new(self_facts, parent_facts) + } + + /// What this node's channels reported about the transaction `txid`, or nothing when they + /// reported nothing or the report cannot be read. + async fn channel_tx_facts(&self, txid: &Txid) -> Option { + match self.channel_tx_facts_store.get(txid).await { + Ok(facts) => facts, + Err(e) => { + log_error!( + self.logger, + "Failed to read what this node recorded about transaction {}: {}", + txid, + e, + ); + None + }, + } + } + pub(crate) fn get_full_scan_request(&self) -> FullScanRequest { self.inner.lock().expect("lock").start_full_scan().build() } @@ -418,6 +455,7 @@ impl Wallet { }, } + let provenance = self.tx_provenance(txid, &tx).await; let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -425,6 +463,7 @@ impl Wallet { txid, payment_id, &tx, + &provenance, payment_status, confirmation_status, ) @@ -456,6 +495,7 @@ impl Wallet { .await; let mut unconfirmed_outbound_txids: Vec = Vec::new(); + let mut unnamed_transactions: Vec<(PaymentId, Txid)> = Vec::new(); for payment in pending_payments { // The filter admits only Tracked funding payments. A splice intent such a @@ -474,6 +514,14 @@ impl Wallet { let PendingPaymentDetails::Tracked { ref details, .. } = payment else { continue; }; + + // A record written before the channel that produced its transaction + // reported what the transaction is says nothing about it yet. The report + // may have arrived since, so try again while the record is in hand. + if let PaymentKind::Onchain { txid, tx_type: None, .. } = details.kind { + unnamed_transactions.push((details.id, txid)); + } + match details.kind { PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { height, .. }, @@ -540,6 +588,8 @@ impl Wallet { } } + self.name_recorded_transactions(unnamed_transactions).await?; + if !unconfirmed_outbound_txids.is_empty() { let txs_to_broadcast: Vec = { let locked_wallet = self.inner.lock().expect("lock"); @@ -605,6 +655,7 @@ impl Wallet { }, } + let provenance = self.tx_provenance(txid, &tx).await; let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -612,6 +663,7 @@ impl Wallet { txid, payment_id, &tx, + &provenance, PaymentStatus::Pending, ConfirmationStatus::Unconfirmed, ) @@ -705,6 +757,7 @@ impl Wallet { }, } + let provenance = self.tx_provenance(txid, &tx).await; let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -712,6 +765,7 @@ impl Wallet { txid, payment_id, &tx, + &provenance, PaymentStatus::Pending, ConfirmationStatus::Unconfirmed, ) @@ -728,6 +782,54 @@ impl Wallet { Ok(()) } + /// Names the transactions of the given payments from the facts this node has recorded about + /// them, for records that do not say what their transaction is. + /// + /// This is how a record written before the producing channel reported its transaction picks + /// that report up: the facts are durable, so a report arriving after the record does reach it + /// on a later chain tip. A transaction the facts still cannot account for leaves its record + /// as it is, and so does a record that names its transaction already: whoever named it knew + /// more than the facts alone say. + async fn name_recorded_transactions( + &self, payments: Vec<(PaymentId, Txid)>, + ) -> Result<(), Error> { + for (payment_id, txid) in payments { + let tx = { + let locked_wallet = self.inner.lock().expect("lock"); + locked_wallet.get_tx(txid).map(|tx| tx.tx_node.tx.as_ref().clone()) + }; + let Some(tx) = tx else { + continue; + }; + let Some(tx_type) = self.tx_provenance(txid, &tx).await.classify(&tx) else { + continue; + }; + + let mut update = PaymentDetailsUpdate::new(payment_id); + update.tx_type = Some(Some(tx_type)); + // Taken per payment, like the graduation above: the write touches one record and + // leaves its pending entry alone. + let stores = self.payment_stores.lock().await; + let named = stores + .mutate_payment(&payment_id, |existing| { + let current = existing?; + // Whether the record is still unnamed is decided inside the store's + // critical section, where the answer cannot go stale against a name + // written since this payment was listed. + if !matches!(current.kind, PaymentKind::Onchain { tx_type: None, .. }) { + return None; + } + let mut updated = current.clone(); + updated.update(update).then_some(updated) + }) + .await?; + if named.is_some() { + log_debug!(self.logger, "Named transaction {} from what is recorded of it", txid); + } + } + Ok(()) + } + /// The id to record a transaction under that the funding-status check found foreign to the /// funding record resolved for it as `resolved_id`: its own txid-derived id, or `None` when a /// funding record sits there already. A funding record wallet sync created before @@ -3246,27 +3348,28 @@ impl Wallet { (amount_msat, Some(fee_sat * 1000), direction) } + /// Builds the payment record for `tx`, naming what the transaction is from `provenance`. + /// + /// The provenance is read by the caller rather than here, because reading it awaits the facts + /// store while this runs under the wallet lock. fn create_payment_from_tx( &self, locked_wallet: &PersistedWallet, txid: Txid, - payment_id: PaymentId, tx: &Transaction, payment_status: PaymentStatus, - confirmation_status: ConfirmationStatus, + payment_id: PaymentId, tx: &Transaction, provenance: &TxProvenance, + payment_status: PaymentStatus, confirmation_status: ConfirmationStatus, ) -> PaymentDetails { - // TODO: It would be great to introduce additional variants for - // `ChannelFunding` and `ChannelClosing`. For the former, we could just - // take a reference to `ChannelManager` here and check against - // `list_channels`. But for the latter the best approach is much less - // clear: for force-closes/HTLC spends we should be good querying - // `OutputSweeper::tracked_spendable_outputs`, but regular channel closes - // (i.e., `SpendableOutputDescriptor::StaticOutput` variants) are directly - // spent to a wallet address. The only solution I can come up with is to - // create and persist a list of 'static pending outputs' that we could use - // here to determine the `PaymentKind`, but that's not really satisfactory, so - // we're punting on it until we can come up with a better solution. - - let kind = PaymentKind::Onchain { txid, status: confirmation_status, tx_type: None }; - - let (amount_msat, fee_paid_msat, direction) = - self.onchain_payment_fields_locked(locked_wallet, tx); + let kind = PaymentKind::Onchain { + txid, + status: confirmation_status, + tx_type: provenance.classify(tx), + }; + + // The figures a producer reported take precedence over the wallet's view: an + // interactively negotiated funding spends an output both parties own, which the wallet + // reads as this node having spent all of it. + let (amount_msat, fee_paid_msat, direction) = match provenance.local_figures() { + Some(figures) => (figures.amount_msat, figures.fee_paid_msat, figures.direction), + None => self.onchain_payment_fields_locked(locked_wallet, tx), + }; PaymentDetails::new(payment_id, kind, amount_msat, fee_paid_msat, direction, payment_status) } @@ -3702,17 +3805,26 @@ impl Wallet { let new_txid = fee_bumped_tx.compute_txid(); - let new_payment = self.create_payment_from_tx( - &locked_wallet, - new_txid, - payment.id, - &fee_bumped_tx, - PaymentStatus::Pending, - ConfirmationStatus::Unconfirmed, - ); - let change_set = locked_wallet.take_staged().unwrap_or_default(); drop(locked_wallet); + + // The replacement's provenance is only readable once the wallet lock is released, and + // only knowable once the replacement exists: its inputs are what decides which facts the + // classification rests on. + let provenance = self.tx_provenance(new_txid, &fee_bumped_tx).await; + let new_payment = { + let locked_wallet = self.inner.lock().expect("lock"); + self.create_payment_from_tx( + &locked_wallet, + new_txid, + payment.id, + &fee_bumped_tx, + &provenance, + PaymentStatus::Pending, + ConfirmationStatus::Unconfirmed, + ) + }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet after fee bump of {}: {}", txid, e); Error::PersistenceFailed @@ -4341,7 +4453,9 @@ mod tests { test_funding_contribution_with_outputs, test_funding_contribution_with_parts, SpliceIntent, SpliceKind, }; + use crate::payment::store::Channel; use crate::types::{DynStore, DynStoreWrapper}; + use crate::wallet::provenance::{ChannelOutputRole, LocalFundingFigures}; use crate::{NodeMetrics, PersistedNodeMetrics}; const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; @@ -11484,4 +11598,142 @@ mod tests { .is_none()); } } + + /// The facts a channel would record for a splice candidate: the pre-splice funding output it + /// spends, the new funding output it creates, and this node's share of it. + fn splice_candidate_facts( + txid: Txid, spends: Txid, channel: &Channel, figures: LocalFundingFigures, + ) -> (ChannelTxFacts, ChannelTxFacts) { + let spent = ChannelTxFacts::new(spends).with_outputs( + channel, + None, + ChannelOutputRole::Funding, + [0], + ); + let created = + ChannelTxFacts::new(txid).with_outputs(channel, None, ChannelOutputRole::Funding, [0]); + (spent, ChannelTxFacts { local_figures: Some(figures), ..created }) + } + + #[tokio::test] + async fn a_reported_share_of_a_transaction_outranks_the_wallets_view() { + 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 channel = Channel { counterparty_node_id, channel_id }; + let tx = wallet_paying_tx(&wallet, 4); + let txid = tx.compute_txid(); + insert_unconfirmed_tx(&wallet, tx.clone()); + + let figures = LocalFundingFigures { + funding_payment_id: PaymentId([31u8; 32]), + amount_msat: Some(77_000), + fee_paid_msat: Some(1_100), + direction: PaymentDirection::Outbound, + }; + // The wallet reads a shared funding input as wholly this node's, so its view of the + // transaction is a different one, which is the point of preferring the reported share. + assert_ne!( + wallet.onchain_payment_fields(&tx), + (figures.amount_msat, figures.fee_paid_msat, figures.direction), + ); + + let (spent, created) = splice_candidate_facts( + txid, + tx.input[0].previous_output.txid, + &channel, + figures.clone(), + ); + wallet.record_channel_tx_facts(spent).await.unwrap(); + wallet.record_channel_tx_facts(created).await.unwrap(); + + let event = + WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx.clone()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet + .payment_stores + .payment_store() + .get(&PaymentId(txid.to_byte_array())) + .await + .unwrap() + .expect("wallet sync records the transaction"); + assert_eq!(payment.amount_msat, figures.amount_msat); + assert_eq!(payment.fee_paid_msat, figures.fee_paid_msat); + assert_eq!(payment.direction, figures.direction); + match payment.kind { + PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + } => { + assert_eq!(channels, vec![channel]); + }, + kind => panic!("unexpected kind {:?}", kind), + } + } + + #[tokio::test] + async fn an_unnamed_transaction_is_named_once_its_facts_arrive() { + 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 channel = Channel { counterparty_node_id, channel_id }; + let sweep = wallet_paying_tx(&wallet, 3); + let sweep_txid = sweep.compute_txid(); + let swept = sweep.input[0].previous_output.txid; + insert_unconfirmed_tx(&wallet, sweep.clone()); + + // Wallet sync sees the sweep before the channel gets to report what it resolved. + let event = WalletEvent::TxUnconfirmed { + txid: sweep_txid, + tx: Arc::new(sweep.clone()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment_id = PaymentId(sweep_txid.to_byte_array()); + let unnamed = wallet + .payment_stores + .payment_store() + .get(&payment_id) + .await + .unwrap() + .expect("wallet sync records the transaction"); + assert!( + matches!(unnamed.kind, PaymentKind::Onchain { tx_type: None, .. }), + "nothing is recorded about the transaction yet, so it cannot be named: {:?}", + unnamed.kind, + ); + + wallet + .record_channel_tx_facts(ChannelTxFacts::new(swept).with_outputs( + &channel, + None, + ChannelOutputRole::Spendable, + [0], + )) + .await + .unwrap(); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { old_tip: block_id(1), new_tip: block_id(2) }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let named = wallet + .payment_stores + .payment_store() + .get(&payment_id) + .await + .unwrap() + .expect("the record stays"); + match named.kind { + PaymentKind::Onchain { tx_type: Some(TransactionType::Sweep { channels }), .. } => { + assert_eq!(channels, vec![channel]); + }, + kind => panic!("unexpected kind {:?}", kind), + } + } } From f73da849d0d4e2a9cc6bc84fcad1f901ec22e891 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 17:52:27 +0200 Subject: [PATCH 35/49] Let wallet sync create funding payment records Signing a splice round wrote a payment record so that a counterparty broadcasting the round first would find one rather than mint a duplicate. Record what the round is instead: an interactive funding of its channels, this node's share of it, and the funding payment it belongs to, all keyed by the round's transaction id. Wallet sync then creates the record when it observes the transaction and resolves its identity through those facts, which leaves sync as the only creator of funding payment records while keeping the guarantee that bought the signing-time write. A pending-store entry therefore has to track a splice before any payment record for it exists, so its two shapes become one: the record, the conflicts wallet sync listed, the channels of the funding it tracks, the signed rounds, the splice intent and the rounds LDK promoted are each present on their own schedule, and an entry left tracking nothing is removed. The channels are recorded on the entry because, with no record to name them, nothing else says which channel's splice a signed round belongs to. With no payment record written at signing there is no half-written record either, so the rollback of the write pair and the cleanup of what a failed rollback left behind both go. The two writes that remain are idempotent, and a replay adopts the figures already on record rather than deriving a second answer the facts would refuse. Co-Authored-By: HAL 9000 --- src/channel/mod.rs | 48 +- src/payment/pending_payment_store.rs | 362 ++++---- src/wallet/mod.rs | 1177 ++++++++++++-------------- src/wallet/provenance.rs | 7 + tests/integration_tests_rust.rs | 14 +- 5 files changed, 746 insertions(+), 862 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index ad54511259..6b0eafa810 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -511,14 +511,9 @@ impl SpliceTracker { /// Clears the persisted intent behind a splice that settled — it locked, its failure was /// surfaced, or its channel closed — but only while `still_applies` holds for the stored /// intent: a mismatch means a fee bump took over the record in the meantime, and its intent - /// must stay. A tracked record stays, with the intent cleared, so its payment keeps - /// graduating. A bare intent record is removed, along with any payment record under its id: - /// the signing-time recording of a splice round files a payment under an intent's id and - /// promotes the entry in the same write, so a payment record found under a bare intent is the - /// first half of a write that never completed, of a round whose signatures never left the - /// node, so nothing can broadcast it and no entry would ever drive the record - /// ([`Wallet::drop_unindexed_record_of_settled_intent`]). The record goes first: a bare intent - /// left behind is found and settled again, an orphaned record would not be. + /// must stay. A record that tracks anything else stays, with the intent cleared, so its + /// payment keeps graduating and the rounds signed under the splice keep their place. A record + /// left with nothing to track is removed. async fn clear_persisted_intent bool>( &self, payment_id: PaymentId, still_applies: F, ) { @@ -542,10 +537,11 @@ impl SpliceTracker { }) .await?; if remove_bare_record { - self.wallet.drop_unindexed_record_of_settled_intent(payment_id).await?; self.pending_payment_store .remove_if(&payment_id, |record| { record.details().is_none() + && record.candidates().is_empty() + && record.locked_rounds().is_empty() && record.splice_intent().is_some_and(still_applies) }) .await?; @@ -1000,27 +996,21 @@ fn decide_on_lock( } } -/// The replacement for a pending record whose splice intent is being dropped. A tracked record -/// keeps its payment details with just the intent cleared. A bare intent record has nothing to -/// keep and is left for the caller to remove — never promoted over a payment record found under -/// its id, which is the first half of a write — for a round of ours, the signing write — that -/// never completed rather than a payment to keep graduating (see -/// [`SpliceTracker::clear_persisted_intent`]). +/// The replacement for a pending record whose splice intent is being dropped. A record that still +/// tracks something else — a payment, the rounds signed under the splice, or a round LDK promoted +/// — keeps it with just the intent cleared. A record left with nothing to track has nothing to +/// keep and is left for the caller to remove (see [`SpliceTracker::clear_persisted_intent`]). fn record_with_intent_cleared(existing: &PendingPaymentDetails) -> Option { - match existing { - PendingPaymentDetails::PendingSplice { .. } => None, - PendingPaymentDetails::Tracked { .. } => { - let mut tracked = existing.clone(); - let update = PendingPaymentDetailsUpdate { - id: tracked.id(), - payment_update: None, - conflicting_txids: None, - candidates: Vec::new(), - splice_intent: Some(None), - }; - tracked.update(update).then_some(tracked) - }, - } + let mut record = existing.clone(); + let update = PendingPaymentDetailsUpdate { + id: record.id(), + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + record.update(update); + (!record.is_empty()).then_some(record) } /// What [`SpliceTracker::reconcile`] should do with a persisted intent whose channel and funding diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 6662e7c9c1..59b98758f3 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -14,7 +14,7 @@ use lightning::ln::types::ChannelId; use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use crate::data_store::{StorableObject, StorableObjectUpdate, UpdatableObject}; -use crate::payment::store::PaymentDetailsUpdate; +use crate::payment::store::{Channel, PaymentDetailsUpdate, TransactionType}; use crate::payment::{PaymentDetails, PaymentKind}; /// One candidate transaction in an interactive-funding (splice) RBF history, holding this node's @@ -110,58 +110,48 @@ impl_writeable_tlv_based!(SpliceIntent, { /// A pending payment tracked by LDK Node, keyed by [`PaymentId`]. /// -/// A user-initiated splice is persisted as a [`PendingSplice`] before its contribution is handed -/// to LDK — at which point no funding transaction, and therefore no [`PaymentDetails`], exists yet. -/// Once the splice is recorded as a funding payment it becomes a [`Tracked`] payment carrying the -/// real [`PaymentDetails`], while retaining its [`SpliceIntent`] until the splice locks. -/// -/// [`PendingSplice`]: Self::PendingSplice -/// [`Tracked`]: Self::Tracked +/// Each part of an entry is written by a different subsystem and is present on its own schedule, +/// so all of them are optional. A user-initiated splice is persisted with nothing but its +/// [`SpliceIntent`] before its contribution is handed to LDK; signing a round of it adds the +/// round to `candidates` and names the `funding_channels` it belongs to, still without a +/// transaction anyone has seen; wallet sync adds `details` once it observes the transaction, and +/// records `conflicting_txids` for any wallet transaction; the `ChannelReady` arm records +/// `locked_rounds`. A splice uses all of them; the fields do not partition by payment type. An +/// entry holding none of them tracks nothing and is removed. #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum PendingPaymentDetails { - /// A user-initiated splice persisted before hand-off to LDK; no funding transaction exists yet. - /// Keyed by the generated [`PaymentId`]; never mirrored into the payment store. - PendingSplice { id: PaymentId, intent: SpliceIntent }, - /// A pending payment tracked toward confirmation, optionally still carrying a live splice - /// intent until the splice locks. - /// - /// Each field is written by a different subsystem: wallet sync records `conflicting_txids` - /// for any wallet transaction (splice fundings included), the signing-time recording - /// records `candidates` for interactive funding, the `ChannelReady` arm records - /// `locked_rounds`, and `splice_intent` is owned by the splice entry points and the splice - /// tracker — persisted at splice initiation, carried over from a [`PendingSplice`] record - /// when the payment is promoted, and cleared once the splice locks or its failure is - /// surfaced. A splice uses all of them; the fields do not partition by payment type. - /// - /// [`PendingSplice`]: Self::PendingSplice - Tracked { - /// The full payment details. - details: PaymentDetails, - /// Transaction IDs wallet sync observed to have replaced or to conflict with this - /// payment, used to map later events about those txids back to this record. This is - /// BDK's view, distinct from `candidates`: it can hold conflicts that were never - /// negotiated candidates, while a candidate replaced between wallet syncs may never - /// appear here (it gets no `TxReplaced` event of its own). - conflicting_txids: Vec, - /// For interactive funding (splices), this node's per-candidate funding figures across the - /// RBF history, keyed by each candidate's txid and recorded as each round is signed. - /// Empty for non-funding payments. - candidates: Vec, - /// The live splice intent, or `None` for a non-splice payment or a splice that has - /// locked. It lives here as well as on - /// [`PendingSplice`] because a fee bump — a fresh negotiation LDK likewise abandons if the - /// peer disconnects before signing — would share the broadcast splice's record rather than - /// get one of its own. - /// - /// [`PendingSplice`]: Self::PendingSplice - splice_intent: Option, - /// The candidates LDK promoted to the channel's funding, as `ChannelReady` reported them. - /// A zero-conf splice locks before its transaction confirms, and every later splice builds - /// on it, so such a round can still confirm once the channel's funding has moved on from - /// it and once the channel has closed, when LDK holds it no longer. Kept apart from the - /// candidates, which each funding-record write replaces as a whole. - locked_rounds: Vec, - }, +pub(crate) struct PendingPaymentDetails { + /// The payment this entry tracks. + pub id: PaymentId, + /// The full payment details, or `None` for a splice whose transaction wallet sync has yet to + /// observe — including one this node has signed but nothing has broadcast. + pub details: Option, + /// Transaction IDs wallet sync observed to have replaced or to conflict with this + /// payment, used to map later events about those txids back to this record. This is + /// BDK's view, distinct from `candidates`: it can hold conflicts that were never + /// negotiated candidates, while a candidate replaced between wallet syncs may never + /// appear here (it gets no `TxReplaced` event of its own). + pub conflicting_txids: Vec, + /// The channels whose interactive funding `candidates` are rounds of, as the signing of a + /// round named them. Empty for a non-funding payment and for a record wallet sync created + /// on its own, whose channels its classification names instead. + pub funding_channels: Vec, + /// For interactive funding (splices), this node's per-candidate funding figures across the + /// RBF history, keyed by each candidate's txid and recorded as each round is signed. + /// Empty for non-funding payments. + pub candidates: Vec, + /// The live splice intent, or `None` for a non-splice payment or a splice that has + /// locked. It is owned by the splice entry points and the splice tracker — persisted at + /// splice initiation and cleared once the splice locks or its failure is surfaced — and + /// outlives the rounds negotiated under it, because a fee bump is a fresh negotiation LDK + /// likewise abandons if the peer disconnects before signing, and shares the bumped round's + /// record rather than getting one of its own. + pub splice_intent: Option, + /// The candidates LDK promoted to the channel's funding, as `ChannelReady` reported them. + /// A zero-conf splice locks before its transaction confirms, and every later splice builds + /// on it, so such a round can still confirm once the channel's funding has moved on from + /// it and once the channel has closed, when LDK holds it no longer. Kept apart from the + /// candidates, which each funding-record write replaces as a whole. + pub locked_rounds: Vec, } impl PendingPaymentDetails { @@ -175,9 +165,11 @@ impl PendingPaymentDetails { details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, splice_intent: Option, ) -> Self { - Self::Tracked { - details, + Self { + id: details.id, + details: Some(details), conflicting_txids, + funding_channels: Vec::new(), candidates, splice_intent, locked_rounds: Vec::new(), @@ -185,91 +177,109 @@ impl PendingPaymentDetails { } pub(crate) fn pending_splice(id: PaymentId, intent: SpliceIntent) -> Self { - Self::PendingSplice { id, intent } + Self { + id, + details: None, + conflicting_txids: Vec::new(), + funding_channels: Vec::new(), + candidates: Vec::new(), + splice_intent: Some(intent), + locked_rounds: Vec::new(), + } } - /// The full payment details, or `None` for a splice not yet broadcast. - pub(crate) fn details(&self) -> Option<&PaymentDetails> { - match self { - Self::PendingSplice { .. } => None, - Self::Tracked { details, .. } => Some(details), + /// An entry for the rounds of an interactive funding of `funding_channels` this node has + /// signed, before any transaction of it has been observed and therefore before a payment + /// record for it exists. + pub(crate) fn signed_rounds( + id: PaymentId, funding_channels: Vec, candidates: Vec, + splice_intent: Option, + ) -> Self { + Self { + id, + details: None, + conflicting_txids: Vec::new(), + funding_channels, + candidates, + splice_intent, + locked_rounds: Vec::new(), } } + /// The full payment details, or `None` for a splice whose transaction has not been observed. + pub(crate) fn details(&self) -> Option<&PaymentDetails> { + self.details.as_ref() + } + /// Transaction IDs that have replaced or conflict with this payment. pub(crate) fn conflicting_txids(&self) -> &[Txid] { - match self { - Self::PendingSplice { .. } => &[], - Self::Tracked { conflicting_txids, .. } => conflicting_txids, - } + &self.conflicting_txids } - /// The rounds LDK promoted to the channel's funding, as `ChannelReady` reported them; empty - /// for a splice without a funding transaction yet. + /// The rounds LDK promoted to the channel's funding, as `ChannelReady` reported them. pub(crate) fn locked_rounds(&self) -> &[Txid] { - match self { - Self::PendingSplice { .. } => &[], - Self::Tracked { locked_rounds, .. } => locked_rounds, - } + &self.locked_rounds } /// Records that LDK promoted the round with the given txid to the channel's funding. Returns - /// whether the record changed: a round recorded as promoted already, or a splice without a - /// funding transaction yet, leaves it as it is. + /// whether the record changed: a round recorded as promoted already leaves it as it is. pub(crate) fn record_locked_round(&mut self, txid: Txid) -> bool { - match self { - Self::PendingSplice { .. } => false, - Self::Tracked { locked_rounds, .. } => { - if locked_rounds.contains(&txid) { - return false; - } - locked_rounds.push(txid); - true - }, + if self.locked_rounds.contains(&txid) { + return false; } + self.locked_rounds.push(txid); + true } /// The splice intent this record carries, if it is a splice that has not yet locked. pub(crate) fn splice_intent(&self) -> Option<&SpliceIntent> { - match self { - Self::PendingSplice { intent, .. } => Some(intent), - Self::Tracked { splice_intent, .. } => splice_intent.as_ref(), - } + self.splice_intent.as_ref() } /// Returns this node's recorded funding figures for the candidate with the given txid, if any. pub(crate) fn candidate(&self, txid: Txid) -> Option<&FundingTxCandidate> { - match self { - Self::PendingSplice { .. } => None, - Self::Tracked { candidates, .. } => { - candidates.iter().find(|candidate| candidate.txid == txid) - }, - } + self.candidates.iter().find(|candidate| candidate.txid == txid) } - /// This node's recorded funding figures across the candidate history, in LDK's order; empty for - /// a splice without a funding transaction yet and for non-funding payments. + /// This node's recorded funding figures across the candidate history, in LDK's order; empty + /// for a splice without a signed round yet and for non-funding payments. pub(crate) fn candidates(&self) -> &[FundingTxCandidate] { - match self { - Self::PendingSplice { .. } => &[], - Self::Tracked { candidates, .. } => candidates, + &self.candidates + } + + /// The channels of the interactive funding this entry tracks: those the signing of a round + /// named, else those its classification names. + pub(crate) fn funding_channels(&self) -> &[Channel] { + if !self.funding_channels.is_empty() { + return &self.funding_channels; + } + match self.details.as_ref().map(|details| &details.kind) { + Some(PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + }) => channels, + _ => &[], } } + + /// Whether this entry tracks nothing anymore and can be dropped. + pub(crate) fn is_empty(&self) -> bool { + self.details.is_none() + && self.splice_intent.is_none() + && self.candidates.is_empty() + && self.locked_rounds.is_empty() + } } -impl_writeable_tlv_based_enum!(PendingPaymentDetails, - (0, PendingSplice) => { - (0, id, required), - (2, intent, required), - }, - (2, Tracked) => { - (0, details, required), - (2, conflicting_txids, optional_vec), - (4, candidates, optional_vec), - (6, splice_intent, option), - (8, locked_rounds, optional_vec), - }, -); +impl_writeable_tlv_based!(PendingPaymentDetails, { + (0, id, required), + (2, details, option), + (4, conflicting_txids, optional_vec), + (6, funding_channels, optional_vec), + (8, candidates, optional_vec), + (10, splice_intent, option), + (12, locked_rounds, optional_vec), +}); #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingPaymentDetailsUpdate { @@ -278,8 +288,8 @@ pub(crate) struct PendingPaymentDetailsUpdate { pub conflicting_txids: Option>, pub candidates: Vec, /// The splice intent to set (`Some(Some(..))`) or clear (`Some(None)`), or `None` to leave it - /// unchanged. Setting it on a [`PendingPaymentDetails::PendingSplice`] replaces the intent; - /// clearing a pre-broadcast splice is done by removing the record, not through this field. + /// unchanged. Clearing the intent of an entry that tracks nothing else is done by removing the + /// entry, not through this field. pub splice_intent: Option>, } @@ -287,10 +297,7 @@ impl StorableObject for PendingPaymentDetails { type Id = PaymentId; fn id(&self) -> Self::Id { - match self { - Self::PendingSplice { id, .. } => *id, - Self::Tracked { details, .. } => details.id, - } + self.id } } @@ -298,58 +305,50 @@ impl UpdatableObject for PendingPaymentDetails { type Update = PendingPaymentDetailsUpdate; fn update(&mut self, update: Self::Update) -> bool { - match self { - Self::PendingSplice { intent, .. } => { - // A pre-broadcast record only carries a splice intent; the only meaningful update - // is replacing that intent. Clearing it is done by removing the record. - if let Some(Some(new_intent)) = update.splice_intent { - if *intent != new_intent { - *intent = new_intent; - return true; - } - } - false - }, - Self::Tracked { details, conflicting_txids, candidates, splice_intent, .. } => { - let mut updated = false; - - // Update the underlying payment details if present - if let Some(payment_update) = update.payment_update { - updated |= details.update(payment_update); - } - - if let Some(new_conflicting_txids) = update.conflicting_txids { - if *conflicting_txids != new_conflicting_txids { - *conflicting_txids = new_conflicting_txids; - updated = true; - } - } + let mut updated = false; + + // Update the underlying payment details if present. An entry with no record yet is not + // given one here: only the writer that observed the transaction knows what the record + // says, and it sets the field directly. + if let (Some(payment_update), Some(details)) = + (update.payment_update, self.details.as_mut()) + { + updated |= details.update(payment_update); + } - if let PaymentKind::Onchain { txid, .. } = &details.kind { - let conflicts_len = conflicting_txids.len(); - conflicting_txids.retain(|conflicting_txid| conflicting_txid != txid); - updated |= conflicting_txids.len() != conflicts_len; - } + if let Some(new_conflicting_txids) = update.conflicting_txids { + if self.conflicting_txids != new_conflicting_txids { + self.conflicting_txids = new_conflicting_txids; + updated = true; + } + } - // Each funding-record write passes the candidate history as of its own round, so a - // non-empty update replaces the stored list. An empty update (e.g. a non-funding - // payment) leaves it untouched. Dropping an abandoned round, the only writer that - // shrinks it, goes through the store's `mutate` instead. - if !update.candidates.is_empty() && *candidates != update.candidates { - *candidates = update.candidates; - updated = true; - } + if let Some(PaymentKind::Onchain { txid, .. }) = + self.details.as_ref().map(|details| &details.kind) + { + let txid = *txid; + let conflicts_len = self.conflicting_txids.len(); + self.conflicting_txids.retain(|conflicting_txid| *conflicting_txid != txid); + updated |= self.conflicting_txids.len() != conflicts_len; + } - if let Some(new_splice_intent) = update.splice_intent { - if *splice_intent != new_splice_intent { - *splice_intent = new_splice_intent; - updated = true; - } - } + // 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; + } - updated - }, + if let Some(new_splice_intent) = update.splice_intent { + if self.splice_intent != new_splice_intent { + self.splice_intent = new_splice_intent; + updated = true; + } } + + updated } fn to_update(&self) -> Self::Update { @@ -365,36 +364,31 @@ impl StorableObjectUpdate for PendingPaymentDetailsUpdate impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { fn from(value: &PendingPaymentDetails) -> Self { - match value { - PendingPaymentDetails::PendingSplice { id, intent } => Self { - id: *id, + match &value.details { + // An entry with no record yet carries nothing a payment-tracking merge could apply + // beyond its intent, which the entry that holds it owns outright. + None => Self { + id: value.id, payment_update: None, conflicting_txids: None, - candidates: Vec::new(), - splice_intent: Some(Some(intent.clone())), + candidates: value.candidates.clone(), + splice_intent: value.splice_intent.clone().map(Some), }, - PendingPaymentDetails::Tracked { - details, - conflicting_txids, - candidates, - splice_intent, - .. - } => { - let conflicting_txids = if conflicting_txids.is_empty() { + Some(details) => { + let conflicting_txids = if value.conflicting_txids.is_empty() { None } else { - Some(conflicting_txids.clone()) + Some(value.conflicting_txids.clone()) }; // Leave the splice intent unchanged: it is owned by the splice entry points and the // splice tracker, never by a payment-tracking merge. Emitting the current value // here would let an `insert_or_update` of a payment record (e.g. from wallet sync, // built without an intent) clobber a live intent to `None`. - let _ = splice_intent; Self { id: details.id, payment_update: Some(details.to_update()), conflicting_txids, - candidates: candidates.clone(), + candidates: value.candidates.clone(), splice_intent: None, } }, @@ -817,7 +811,7 @@ mod tests { contribution: test_funding_contribution(), kind: SpliceKind::In { amount_sats: 500_000 }, }; - let record = PendingPaymentDetails::PendingSplice { id, intent }; + let record = PendingPaymentDetails::pending_splice(id, intent); let encoded = record.encode(); let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); @@ -895,21 +889,19 @@ mod tests { contribution, kind: SpliceKind::Rbf {}, }; - let record = PendingPaymentDetails::PendingSplice { id: PaymentId([10u8; 32]), intent }; + let record = PendingPaymentDetails::pending_splice(PaymentId([10u8; 32]), intent); let encoded = record.encode(); let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); assert_eq!(record, decoded); - let PendingPaymentDetails::PendingSplice { intent, .. } = decoded else { - panic!("a pending splice decoded as something else"); - }; + let intent = decoded.splice_intent.expect("a pending splice decoded without its intent"); assert_eq!(reserved(&intent.contribution), expected); } #[test] fn tracked_payment_round_trips() { - // The `PendingSplice` variant round-trips in `pending_splice_round_trips`; here we cover - // the `Tracked` variant and its enum discriminant. + // An entry without a payment record round-trips in `pending_splice_round_trips`; here we + // cover one carrying the record and its candidate history. let payment_id = PaymentId([7u8; 32]); let txid = Txid::from_byte_array([8u8; 32]); let record = PendingPaymentDetails::new( diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 61ae5cf0ad..de0af448cf 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -65,14 +65,14 @@ use crate::data_store::{StorableObject, UpdatableObject}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; use crate::logger::{log_debug, log_error, log_info, log_trace, log_warn, LdkLogger, Logger}; use crate::payment::pending_payment_store::{PendingPaymentDetailsUpdate, SpliceIntent}; -use crate::payment::store::{ConfirmationStatus, PaymentDetailsUpdate}; +use crate::payment::store::{Channel, ConfirmationStatus, PaymentDetailsUpdate}; use crate::payment::{ FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, PendingPaymentDetails, TransactionType, }; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; -use crate::wallet::provenance::{ChannelTxFacts, TxProvenance}; +use crate::wallet::provenance::{ChannelTxFacts, LocalFundingFigures, TxProvenance}; use crate::{ChainSource, Error}; pub(crate) enum OnchainSendAmount { @@ -511,7 +511,7 @@ impl Wallet { // broadcast arm applies the round and unlocks its inputs. A bump's extra // inputs return through the `DiscardFunding` LDK queues at the lock, once // that handler passes the inputs to `cancel_tx`. - let PendingPaymentDetails::Tracked { ref details, .. } = payment else { + let Some(details) = payment.details() else { continue; }; @@ -909,10 +909,10 @@ impl Wallet { Some(entry) => entry, None => return Ok(false), }; - let PendingPaymentDetails::Tracked { details, conflicting_txids, candidates, .. } = &entry - else { + let Some(details) = entry.details() else { return Ok(false); }; + let (conflicting_txids, candidates) = (&entry.conflicting_txids, &entry.candidates); let record_txid = match details.kind { PaymentKind::Onchain { txid, @@ -1031,9 +1031,7 @@ impl Wallet { // read above and the insert, leaves a bare copy of a settled intent behind; the // channel's next lock, close or startup reconciliation settles the copy. let intent = match stores.pending_payment(&payment_id).await? { - Some(PendingPaymentDetails::Tracked { splice_intent: Some(intent), .. }) => { - Some(intent) - }, + Some(entry) if entry.details().is_some() => entry.splice_intent, _ => None, }; if let Some(intent) = intent { @@ -2388,7 +2386,9 @@ impl Wallet { let bare = self .payment_stores .pending_payments(|p| { - p.details().is_none() && p.splice_intent().is_some_and(|i| channel_of(i).is_some()) + p.details().is_none() + && p.candidates().is_empty() + && p.splice_intent().is_some_and(|i| channel_of(i).is_some()) }) .await; let carries_contribution = |p: &&PendingPaymentDetails| { @@ -2446,14 +2446,14 @@ impl Wallet { Ok(random_payment_id()) } - /// Builds the payment record, under the resolved `payment_id`, and the per-candidate figures - /// for recording the `active` round of an interactive funding whose negotiated history is - /// `candidates`. Returns `None` when there is nothing to record: no local contribution to the - /// round, or no wallet-level activity. - fn interactive_funding_record( + /// Builds this node's share of the `active` round of an interactive funding whose negotiated + /// history is `candidates`, and the per-candidate figures of that history, for recording the + /// round under `payment_id`. Returns `None` when there is nothing to record: no local + /// contribution to the round, or no wallet-level activity. + fn interactive_funding_figures( &self, payment_id: PaymentId, candidates: &[FundingCandidate], active: &FundingCandidate, - tx: &Transaction, tx_type: TransactionType, - ) -> Option<(PaymentDetails, Vec)> { + tx: &Transaction, + ) -> Option<(LocalFundingFigures, Vec)> { let txid = active.txid; let aggregate = aggregate_local_stakes(active); @@ -2468,8 +2468,6 @@ impl Wallet { return None; }, }; - let fee_paid_msat = aggregate.fee_paid_msat; - let direction = aggregate.direction; // A contribution doesn't mean the tx touches our on-chain wallet: a splice-out to an // external address sends channel funds to a third party, which BDK sees as zero wallet @@ -2502,43 +2500,42 @@ impl Wallet { }) .collect(); - let details = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(tx_type), - }, + let figures = LocalFundingFigures { + funding_payment_id: payment_id, amount_msat, - fee_paid_msat, - direction, - PaymentStatus::Pending, - ); - Some((details, candidate_records)) + fee_paid_msat: aggregate.fee_paid_msat, + direction: aggregate.direction, + }; + Some((figures, candidate_records)) } - /// Records the funding payment of a splice round this node is about to sign, before + /// Records what this node knows about a splice round it 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`]). + /// + /// Two things are written. The round's transaction gets a provenance fact naming it an + /// interactive funding of the round's channels and carrying this node's share of it along with + /// the funding payment the round belongs to, so that whoever first observes the transaction — + /// wallet sync, whichever party broadcast it — records it under that payment rather than as a + /// payment of its own, with this node's figures rather than the wallet's view of a funding + /// output both parties own. The pending store gets the round's place in the channel's splice + /// history, 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. No payment record is written here — wallet sync creates it when it observes the + /// transaction, and resolves its identity through the fact. /// /// `candidates` is the channel's pending splice history as [`funding_candidates`] lists it from - /// the channel's [`SpliceDetails`], so the record is written in full, under the id + /// the channel's [`SpliceDetails`], so the history is written in full, under the id /// [`Self::resolve_interactive_funding_id`] resolves (that of a record already tracking any - /// round of the history, else the channel's splice intent, else a fresh one). The signed round - /// is marked as awaiting broadcast until LDK reports the splice negotiated and - /// [`Self::record_broadcast_splice_round`] clears the mark: only such a round can be abandoned - /// without a trace, and - /// [`Self::drop_abandoned_splice_rounds`] takes it back once LDK no longer holds it. + /// round of the history, else the channel's splice intent, else a fresh one). /// /// 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; one whose rollback - /// failed as well is dropped by the replayed event once the round is gone - /// ([`Self::drop_unindexed_signing_record`]), or along with the settled intent of its splice - /// ([`Self::drop_unindexed_record_of_settled_intent`]). + /// leaves the caller to replay: a losing RBF candidate's contribution figures exist only while + /// the candidate is live in the channel's splice details, and both writes are idempotent, so + /// the replay completes whichever of them was lost. /// /// [`ChannelManager::funding_transaction_signed`]: lightning::ln::channelmanager::ChannelManager::funding_transaction_signed pub(crate) async fn record_signed_funding( @@ -2553,29 +2550,38 @@ impl Wallet { "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; + return Ok(()); }, }; - let tx_type = - LdkTransactionType::InteractiveFunding { candidates: candidates.to_vec() }.into(); + let funding_channels: Vec = signed_round + .channels + .iter() + .map(|channel| Channel { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + .collect(); // Resolution, the reads and the writes below must share one lock acquisition, as in every - // funding-record write: done outside it, the record could change under us before the write. + // funding-record write: done outside it, the id could go stale against a record wallet + // sync creates for the same transaction before the write. let stores = self.payment_stores.lock().await; - let payment_id = - self.resolve_interactive_funding_id(&stores, candidates, signed_round).await?; - let (details, mut history) = match self.interactive_funding_record( - payment_id, - candidates, - signed_round, - tx, - tx_type, - ) { - Some(record) => record, - None => return Ok(()), + // A round whose facts are on record already names its payment and this node's share of + // it. Those facts are immutable, so a replay adopts them rather than deriving figures + // afresh: LDK may have adjusted the contribution's fee fields since, and a second answer + // would be refused rather than recorded, leaving the event replaying forever. + let recorded_figures = + self.channel_tx_facts(&txid).await.and_then(|facts| facts.local_figures); + let payment_id = match &recorded_figures { + Some(figures) => figures.funding_payment_id, + None => self.resolve_interactive_funding_id(&stores, candidates, signed_round).await?, }; + let (figures, mut history) = + match self.interactive_funding_figures(payment_id, candidates, signed_round, tx) { + Some(record) => record, + None => return Ok(()), + }; + let figures = recorded_figures.unwrap_or(figures); // 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) { @@ -2606,40 +2612,35 @@ impl Wallet { } } - // The write pair can fail between its two stores. The lock keeps the other writers of this - // record out, bar graduation, which only ever moves a record out of `Pending`: put the - // payment store back as it was while the record is still pending, or the replayed event - // would find the half-written record and take it for prior state. The write hands back - // what it found in the payment store, read inside its own critical section. - if let Err(failure) = - self.persist_funding_payment_locked(&stores, details, recorded.clone()).await - { - let (e, prior_details) = match failure { - // The write pair failed before its first write, so there is nothing to put back. - FundingWriteError::Unread(e) => return Err(e), - FundingWriteError::Failed { error, prior } => (error, prior), - }; - let rollback = match &prior_details { - Some(prior) => stores - .mutate_payment(&payment_id, |existing| { - let current = existing?; - (current.status == PaymentStatus::Pending && current != prior) - .then(|| prior.clone()) - }) - .await - .map(|_| ()), - None => stores.remove_payment(&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); - } + // The fact goes first: it is what ties the transaction to this payment, so a failure + // afterwards leaves the round attributable rather than a history pointing at a payment + // nothing would ever file the transaction under. + self.record_channel_tx_facts( + ChannelTxFacts::new(txid) + .with_self_role(TransactionType::InteractiveFunding { + channels: funding_channels.clone(), + }) + .with_local_figures(figures), + ) + .await?; + + stores + .mutate_pending_payment(&payment_id, |existing| { + let mut changed = existing.is_none(); + let mut entry = existing.cloned().unwrap_or_else(|| { + PendingPaymentDetails::signed_rounds(payment_id, Vec::new(), Vec::new(), None) + }); + if entry.funding_channels.is_empty() && !funding_channels.is_empty() { + entry.funding_channels = funding_channels.clone(); + changed = true; + } + if entry.candidates != recorded { + entry.candidates = recorded.clone(); + changed = true; + } + changed.then_some(entry) + }) + .await?; log_debug!( self.logger, "Recorded signed splice funding {} ({} candidates)", @@ -2647,7 +2648,7 @@ impl Wallet { candidates.len(), ); - // The record is complete; merging the duplicates wallet sync created for earlier rounds + // The history is complete; merging the duplicates wallet sync created for earlier rounds // is a courtesy. The signed round can have no duplicate yet, as our signatures have not // left the node, and the round's `SpliceNegotiated` event re-runs the merge, replaying on // failure, so a failure here is logged rather than replaying the signing. @@ -2689,10 +2690,8 @@ impl Wallet { let marked = stores .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); - let PendingPaymentDetails::Tracked { candidates, .. } = &mut entry else { - return None; - }; - let round = candidates + let round = entry + .candidates .iter_mut() .find(|candidate| candidate.txid == txid && candidate.awaiting_broadcast)?; round.awaiting_broadcast = false; @@ -2765,10 +2764,7 @@ impl Wallet { .await; for entry in entries { - let payment_id = match entry.details() { - Some(details) => details.id, - None => continue, - }; + let payment_id = entry.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 @@ -2803,6 +2799,35 @@ impl Wallet { // one. let handed_back = remaining.last().filter(|round| round.amount_msat.is_some()); + // An entry with no payment record yet — nothing has observed a transaction of this + // splice — has no record to hand back or remove: only its history shrinks, and with + // the last round of ours it loses the rest of the history too, leaving a splice + // intent it carries behind as a bare intent for the failure LDK reports to be + // described from. An entry left tracking nothing goes. + if entry.details().is_none() { + stores + .mutate_pending_payment(&payment_id, |existing| { + let mut entry = existing?.clone(); + if handed_back.is_some() { + entry.candidates.retain(|c| !abandoned_txids.contains(&c.txid)); + } else { + entry.candidates.clear(); + entry.locked_rounds.clear(); + entry.funding_channels.clear(); + } + Some(entry) + }) + .await?; + stores.remove_pending_payment_if(&payment_id, |entry| entry.is_empty()).await?; + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} of unobserved funding payment {}", + abandoned_txids, + payment_id, + ); + continue; + } + let mut mirrored = None; let mut history_only = false; match handed_back { @@ -2863,14 +2888,13 @@ impl Wallet { stores.remove_payment(&payment_id).await?; let kept_intent = stores .mutate_pending_payment(&payment_id, |existing| match existing { - Some(PendingPaymentDetails::Tracked { - splice_intent: Some(intent), - .. - }) => Some(PendingPaymentDetails::pending_splice( - payment_id, - intent.clone(), - )), - _ => None, + Some(entry) => entry.splice_intent().map(|intent| { + PendingPaymentDetails::pending_splice( + payment_id, + intent.clone(), + ) + }), + None => None, }) .await? .is_some(); @@ -2916,11 +2940,9 @@ impl Wallet { stores .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); - if let PendingPaymentDetails::Tracked { details, candidates, .. } = &mut entry { - candidates.retain(|c| !abandoned_txids.contains(&c.txid)); - if let Some(mirrored) = mirrored { - *details = mirrored; - } + entry.candidates.retain(|c| !abandoned_txids.contains(&c.txid)); + if let Some(mirrored) = mirrored { + entry.details = Some(mirrored); } Some(entry) }) @@ -2953,12 +2975,12 @@ impl Wallet { }) .await .iter() - .flat_map(|entry| match entry.details().map(|details| &details.kind) { - Some(PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { channels }), - .. - }) => channels.iter().map(|channel| channel.channel_id).collect(), - _ => Vec::new(), + .flat_map(|entry| { + entry + .funding_channels() + .iter() + .map(|channel| channel.channel_id) + .collect::>() }) .collect(); for channel_id in channels { @@ -2975,89 +2997,6 @@ impl Wallet { 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 can be left so: the record of a - /// bump keeps the entry of the rounds before it, and wallet sync moves it on as an earlier - /// round confirms or fails. A bare splice intent under the record's id — the intent whose id - /// the signing adopted and whose entry the completed write would have promoted — does not - /// index the record, and is left for the splice tracker to settle. - async fn drop_unindexed_signing_record(&self, txid: Txid) -> Result<(), Error> { - let stores = self.payment_stores.lock().await; - let payment_id = match self.find_payment_by_txid(txid).await? { - Some(id) => id, - None => return Ok(()), - }; - self.drop_unindexed_signing_record_locked(&stores, payment_id, Some(txid)).await - } - - /// Removes the half-written signing record, if any, under the id of a bare splice intent whose - /// splice settled. The signing-time recording ([`Self::record_signed_funding`]) files a payment - /// under a bare intent's id and promotes the intent's entry in the same write, so a payment - /// record found under a bare intent is the first half of a write that never completed. The - /// round's signatures never left the node, nothing can broadcast it, and no entry would ever - /// drive the record. The caller removes the bare entry afterwards; an entry that turns out to - /// be tracked indexes the record, which then stays. - pub(crate) async fn drop_unindexed_record_of_settled_intent( - &self, payment_id: PaymentId, - ) -> Result<(), Error> { - let stores = self.payment_stores.lock().await; - self.drop_unindexed_signing_record_locked(&stores, payment_id, None).await - } - - /// Removes the payment record under `payment_id` if it is the half-written record of a signed - /// splice round — pending, unconfirmed, interactive funding, and of `txid` when one is given — - /// that no `Tracked` entry indexes. The caller must hold the [`PaymentStores`] lock so that - /// the check and the removal cannot interleave with a signing write completing the record; - /// the `stores` guard proves the lock is held across this call. - async fn drop_unindexed_signing_record_locked( - &self, stores: &PaymentStoresGuard<'_>, payment_id: PaymentId, txid: Option, - ) -> Result<(), Error> { - let indexed = stores - .pending_payment(&payment_id) - .await? - .is_some_and(|entry| entry.details().is_some()); - if indexed { - log_debug!( - self.logger, - "Keeping the funding record of payment {}: its pending entry indexes it", - payment_id, - ); - return Ok(()); - } - let half_written = - stores.payment(&payment_id).await?.and_then(|record| match &record.kind { - PaymentKind::Onchain { - txid: recorded, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - } if record.status == PaymentStatus::Pending - && txid.map_or(true, |txid| *recorded == txid) => - { - Some(*recorded) - }, - _ => None, - }); - match half_written { - Some(recorded) => { - stores.remove_payment(&payment_id).await?; - log_info!( - self.logger, - "Dropped the half-written funding record of abandoned splice round {}", - recorded, - ); - }, - None => log_debug!( - self.logger, - "No half-written funding record to drop under payment {}", - payment_id, - ), - } - 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( @@ -3126,7 +3065,7 @@ impl Wallet { async fn persist_funding_payment_locked( &self, stores: &PaymentStoresGuard<'_>, details: PaymentDetails, candidates: Vec, - ) -> Result, FundingWriteError> { + ) -> 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 @@ -3155,10 +3094,7 @@ impl Wallet { .await; // The closure runs only once the record has been read, so a write that failed before it ran // wrote nothing. - written.map_err(|error| match &seen { - Some((prior, _)) => FundingWriteError::Failed { error, prior: prior.clone() }, - None => FundingWriteError::Unread(error), - })?; + written?; let (prior, update) = seen.expect("the mutate closure always runs"); // The pending index must exist exactly while the authoritative record is Pending: @@ -3194,23 +3130,22 @@ impl Wallet { None => (recorded.status == PaymentStatus::Pending).then(|| { PendingPaymentDetails::tracked(recorded, Vec::new(), candidates, None) }), - // A user-initiated splice has a pre-broadcast `PendingSplice` intent under - // this id; carry its intent into the `Tracked` record so promotion does - // not drop it. If the payment already advanced beyond `Pending` (wallet - // sync confirmed it through `ANTI_REORG_DELAY` first), it must not enter - // the pending store — and the splice behind the intent confirmed, so the - // leftover record is removed below rather than left to look like a splice - // still in flight after a restart. - Some(PendingPaymentDetails::PendingSplice { intent, .. }) => { + // An entry without a record yet — a pre-broadcast splice intent, the rounds + // an earlier signing recorded, or both — is promoted to carry this record, + // keeping everything it tracks. If the payment already advanced beyond + // `Pending` (wallet sync confirmed it through `ANTI_REORG_DELAY` first), it + // must not enter the pending store — and the splice behind the entry + // confirmed, so the leftover entry is removed below rather than left to look + // like a splice still in flight after a restart. + Some(mut entry) if entry.details().is_none() => { if recorded.status == PaymentStatus::Pending { - Some(PendingPaymentDetails::tracked( - recorded, - Vec::new(), - candidates, - Some(intent), - )) + entry.details = Some(recorded); + if !candidates.is_empty() { + entry.candidates = candidates; + } + Some(entry) } else { - *leftover = Some(intent); + *leftover = entry.splice_intent; None } }, @@ -3219,7 +3154,7 @@ impl Wallet { // serialize on the cross-store lock, so nothing lands in between): merge only // the funding classification (`tx_type`, candidate history and the figures of // whichever candidate the record's state makes authoritative) into it. - Some(mut tracked @ PendingPaymentDetails::Tracked { .. }) => { + Some(mut tracked) => { let pending_update = PendingPaymentDetailsUpdate { id, payment_update: Some(update), @@ -3231,18 +3166,18 @@ impl Wallet { }, }) }) - .await - .map_err(|error| FundingWriteError::Failed { error, prior: prior.clone() })?; + .await?; if let Some(intent) = leftover_intent_to_remove { // Only remove the record while it still is the bare intent the closure saw: a fee bump // submitted in between joins the bare record and replaces its intent, and that live // intent must stay. stores .remove_pending_payment_if(&id, |record| { - record.details().is_none() && record.splice_intent() == Some(&intent) + record.details().is_none() + && record.candidates().is_empty() + && record.splice_intent() == Some(&intent) }) - .await - .map_err(|error| FundingWriteError::Failed { error, prior: prior.clone() })?; + .await?; } Ok(prior) } @@ -3399,19 +3334,15 @@ impl Wallet { None => { Some(PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())) }, - // Promote a pre-broadcast splice intent: wallet sync saw the splice - // transaction before this node recorded it as a funding payment. Carrying the - // intent into the `Tracked` record makes the entry visible to txid lookups - // while preserving the intent. - Some(PendingPaymentDetails::PendingSplice { intent, .. }) => { - Some(PendingPaymentDetails::tracked( - payment, - conflicting_txids, - Vec::new(), - Some(intent), - )) + // Promote an entry that has no record yet: wallet sync saw the splice + // transaction before this node recorded a payment for it. The entry keeps + // the splice intent and the rounds signed under it, and gains the record. + Some(mut entry) if entry.details().is_none() => { + entry.details = Some(payment); + entry.conflicting_txids = conflicting_txids; + Some(entry) }, - Some(mut tracked @ PendingPaymentDetails::Tracked { .. }) => { + Some(mut tracked) => { let fresh = PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()); tracked.update(fresh.to_update()).then_some(tracked) @@ -3436,7 +3367,20 @@ impl Wallet { stores.remove_payment(payment_id).await } + /// The payment the transaction `target_txid` belongs to, as far as anything on record says. + /// + /// A transaction this node signed a round of an interactive funding for names its payment + /// outright, in the facts the signing recorded about it; that is the only answer that holds + /// before the payment record exists. Otherwise the pending store is asked, by the record's + /// own transaction, by its candidate history and by the conflicts wallet sync listed for it, + /// and finally the payment store itself, for a record that graduated out of the pending store. async fn find_payment_by_txid(&self, target_txid: Txid) -> Result, Error> { + if let Some(figures) = + self.channel_tx_facts(&target_txid).await.and_then(|facts| facts.local_figures) + { + return Ok(Some(figures.funding_payment_id)); + } + let direct_payment_id = PaymentId(target_txid.to_byte_array()); if self.payment_stores.has_pending_payment(&direct_payment_id).await? { return Ok(Some(direct_payment_id)); @@ -3885,15 +3829,9 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { } } -/// Whether `entry` is the funding payment of a splice into `channel_id`. +/// Whether `entry` tracks the funding payment of a splice into `channel_id`. fn tracks_channel(entry: &PendingPaymentDetails, channel_id: ChannelId) -> bool { - match entry.details().map(|details| &details.kind) { - Some(PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { channels }), - .. - }) => channels.iter().any(|channel| channel.channel_id == channel_id), - _ => false, - } + entry.funding_channels().iter().any(|channel| channel.channel_id == channel_id) } /// Lists a channel's pending splice rounds that have a transaction — the negotiated predecessors @@ -4348,23 +4286,6 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { ) } -/// How a funding-record write pair ([`Wallet::persist_funding_payment_locked`]) failed. -enum FundingWriteError { - /// The payment store could not be read, so nothing was written. - Unread(Error), - /// A write failed after the payment store's record was read; `prior` is that record, for a - /// caller to put the store back to. - Failed { error: Error, prior: Option }, -} - -impl From for Error { - fn from(failure: FundingWriteError) -> Self { - match failure { - FundingWriteError::Unread(error) | FundingWriteError::Failed { error, .. } => error, - } - } -} - /// Builds the payment-store update for a freshly classified funding payment. `details` describes /// the actively broadcast candidate, but when the record already confirmed a *different* /// candidate — wallet sync saw it win before this classification ran — the update instead carries @@ -4453,7 +4374,7 @@ mod tests { test_funding_contribution_with_outputs, test_funding_contribution_with_parts, SpliceIntent, SpliceKind, }; - use crate::payment::store::Channel; + use crate::types::{DynStore, DynStoreWrapper}; use crate::wallet::provenance::{ChannelOutputRole, LocalFundingFigures}; use crate::{NodeMetrics, PersistedNodeMetrics}; @@ -5839,6 +5760,40 @@ mod tests { .collect() } + /// Lets wallet sync observe `tx` as an unconfirmed wallet transaction: the wallet takes it in + /// and the sync event it yields is handled. + async fn observe_unconfirmed(wallet: &Wallet, tx: &Transaction) { + insert_unconfirmed_tx(wallet, tx.clone()); + let event = WalletEvent::TxUnconfirmed { + txid: tx.compute_txid(), + tx: Arc::new(tx.clone()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + } + + /// Records the payment wallet sync creates for `tx` without the wallet taking the transaction + /// in, for the tests that need the record of a round while the wallet's graph must not hold + /// it: a round the wallet has seen is a round no drop may take back. + async fn record_unseen_round(wallet: &Wallet, tx: &Transaction) { + let event = WalletEvent::TxUnconfirmed { + txid: tx.compute_txid(), + tx: Arc::new(tx.clone()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + } + + /// Signs a splice round and lets wallet sync observe its transaction, as the node does: the + /// signing records what the round is and this node's share of it, and the transaction's + /// arrival is what creates the payment record. + async fn sign_and_observe_round( + wallet: &Wallet, tx: &Transaction, candidates: &[FundingCandidate], + ) { + wallet.record_signed_funding(tx, candidates).await.unwrap(); + observe_unconfirmed(wallet, tx).await; + } + /// 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) { @@ -5914,7 +5869,7 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id: first_id, intent: first_intent }) + .insert(PendingPaymentDetails::pending_splice(first_id, first_intent)) .await .unwrap(); let (tx, contribution) = splice_out_round(wallet, 1, 500_000, 300); @@ -5939,10 +5894,7 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { - id: second_id, - intent: second_intent.clone(), - }) + .insert(PendingPaymentDetails::pending_splice(second_id, second_intent.clone())) .await .unwrap(); @@ -5973,10 +5925,7 @@ mod tests { assert!(!entry.candidate(txid).expect("candidate").awaiting_broadcast); assert_eq!( wallet.payment_stores.pending_payment_store().get(&setup.second_id).await.unwrap(), - Some(PendingPaymentDetails::PendingSplice { - id: setup.second_id, - intent: setup.second_intent, - }), + Some(PendingPaymentDetails::pending_splice(setup.second_id, setup.second_intent)), "the newer splice's intent must be left untouched" ); } @@ -5999,11 +5948,194 @@ mod tests { ); assert_eq!( wallet.payment_stores.pending_payment_store().get(&setup.second_id).await.unwrap(), - Some(PendingPaymentDetails::PendingSplice { - id: setup.second_id, - intent: setup.second_intent, + Some(PendingPaymentDetails::pending_splice(setup.second_id, setup.second_intent)), + ); + } + + /// Signing a round writes no payment record. It records what the round is, this node's share + /// of it and the funding payment it belongs to, and leaves the record itself to whoever first + /// observes the transaction. + #[tokio::test] + async fn signing_a_round_writes_no_payment_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 (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + let id = + wallet.find_payment_by_txid(txid).await.unwrap().expect("the round names its payment"); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(entry.details().is_none()); + assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); + assert_eq!( + entry.funding_channels(), + &[Channel { counterparty_node_id, channel_id }], + "the entry names the channel whose splice it tracks, with no record to name it", + ); + let facts = wallet.channel_tx_facts(&txid).await.expect("the round's facts are on record"); + assert_eq!( + facts.self_role, + Some(TransactionType::InteractiveFunding { + channels: vec![Channel { counterparty_node_id, channel_id }], }), ); + let figures = facts.local_figures.expect("this node's share is on record"); + assert_eq!(figures.funding_payment_id, id); + assert_eq!(figures.amount_msat, Some(500_300_000)); + assert_eq!(figures.fee_paid_msat, Some(300_000)); + assert_eq!( + figures.direction, + PaymentDirection::Inbound, + "a splice-out returns funds to the wallet" + ); + } + + /// A signing event replayed after its pending-store write was lost re-derives the round's + /// figures, from a contribution LDK may have adjusted the fee fields of since. The round's + /// facts are immutable, so the replay adopts what is on record instead of offering a second + /// answer the facts would refuse — which would leave the event replaying forever. + #[tokio::test] + async fn a_replayed_signing_adopts_the_recorded_figures() { + 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))]); + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.record_signed_funding(&tx, &candidates).await.is_err()); + fail_store.fail_writes.store(false, Ordering::Release); + + // LDK re-offers the event with the round's contribution carrying a different estimated + // fee, which would derive a different share of the same transaction. + let splice_out = tx.output.last().expect("the splice-out output").clone(); + let adjusted = test_funding_contribution_with_outputs(900, 253, &[splice_out]); + let adjusted_candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(adjusted))]); + wallet.record_signed_funding(&tx, &adjusted_candidates).await.unwrap(); + + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let figures = wallet + .channel_tx_facts(&txid) + .await + .expect("facts") + .local_figures + .expect("this node\'s share"); + assert_eq!(figures.funding_payment_id, id); + assert_eq!(figures.fee_paid_msat, Some(300_000), "the recorded share stands"); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); + } + + /// The counterparty broadcasts the round it holds our signatures for before this node has any + /// payment record for it — the guarantee the signing-time recording exists for. Wallet sync + /// must file the transaction under the funding payment the signing named, resolved through the + /// round's recorded facts, instead of minting a second record under the transaction's own id. + #[tokio::test] + async fn a_counterparty_broadcast_does_not_duplicate_the_funding_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent)) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + // Our signatures leave the node and the counterparty broadcasts: wallet sync is the first + // to see the transaction. + observe_unconfirmed(&wallet, &tx).await; + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the broadcast must not mint a second record"); + assert_eq!(payments[0].id, id); + assert!(matches!( + payments[0].kind, + PaymentKind::Onchain { + txid: t, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } if t == txid + )); + // This node's share of the round, not the wallet's view of a funding output both parties + // own. + assert_eq!(payments[0].amount_msat, Some(500_300_000)); + assert_eq!(payments[0].fee_paid_msat, Some(300_000)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details().map(|details| details.id), Some(id)); + assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); + } + + /// An event about an earlier round of a funding payment that has graduated out of the pending + /// store still reaches the record. Neither store can say so by then — the entry that held the + /// candidate history is gone, and the record names the round that confirmed — but the round's + /// facts still name the payment it belonged to. + #[tokio::test] + async fn a_graduated_records_earlier_round_still_names_its_payment() { + 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 (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))], + ); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; + let id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("id"); + + // Graduation: the record settles and its pending entry, with the candidate history, goes. + let mut graduated = PaymentDetailsUpdate::new(id); + graduated.status = Some(PaymentStatus::Succeeded); + wallet.payment_stores.payment_store().update(graduated).await.unwrap(); + wallet.payment_stores.pending_payment_store().remove(&id).await.unwrap(); + + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), Some(id)); + assert_eq!( + wallet.find_payment_by_txid(txid).await.unwrap(), + Some(id), + "the replaced round still names the payment it was a candidate of", + ); } /// The first round of a user-initiated splice is on no record when it is signed, so it adopts @@ -6021,14 +6153,14 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) .await .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); @@ -6065,7 +6197,7 @@ mod tests { channel_id, &[(txid, Some(contribution.clone()))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); // Wallet sync failed the payment and removed its entry. wallet @@ -6093,7 +6225,7 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) .await .unwrap(); let bump_candidates = splice_candidates( @@ -6101,7 +6233,7 @@ mod tests { channel_id, &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); - wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); @@ -6145,7 +6277,7 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) .await .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); @@ -6155,7 +6287,7 @@ mod tests { channel_id, &[(txid, Some(contribution.clone()))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); @@ -6164,7 +6296,7 @@ mod tests { channel_id, &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); - wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1, "the bump must join the first round's record"); @@ -6196,14 +6328,14 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) .await .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let (queued_tx, queued_contribution) = splice_out_round(&wallet, 2, 400_000, 700); let queued_txid = queued_tx.compute_txid(); @@ -6212,7 +6344,7 @@ mod tests { channel_id, &[(queued_txid, Some(queued_contribution))], ); - wallet.record_signed_funding(&queued_tx, &queued_candidates).await.unwrap(); + sign_and_observe_round(&wallet, &queued_tx, &queued_candidates).await; let queued_id = wallet .find_payment_by_txid(queued_txid) @@ -6255,25 +6387,22 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id, intent }) + .insert(PendingPaymentDetails::pending_splice(id, intent)) .await .unwrap(); } let candidates_a = splice_candidates(counterparty_node_id, channel_id, &[(txid_a, Some(contribution_a))]); - wallet.record_signed_funding(&tx_a, &candidates_a).await.unwrap(); + sign_and_observe_round(&wallet, &tx_a, &candidates_a).await; assert_eq!(wallet.find_payment_by_txid(txid_a).await.unwrap(), Some(id_a)); let entry_b = wallet.payment_stores.pending_payment_store().get(&id_b).await.unwrap().expect("entry"); - assert_eq!( - entry_b, - PendingPaymentDetails::PendingSplice { id: id_b, intent: intent_b.clone() } - ); + assert_eq!(entry_b, PendingPaymentDetails::pending_splice(id_b, intent_b.clone())); let candidates_b = splice_candidates(counterparty_node_id, channel_id, &[(txid_b, Some(contribution_b))]); - wallet.record_signed_funding(&tx_b, &candidates_b).await.unwrap(); + sign_and_observe_round(&wallet, &tx_b, &candidates_b).await; assert_eq!(wallet.find_payment_by_txid(txid_b).await.unwrap(), Some(id_b)); let entry_b = wallet.payment_stores.pending_payment_store().get(&id_b).await.unwrap().expect("entry"); @@ -6310,7 +6439,7 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id, intent }) + .insert(PendingPaymentDetails::pending_splice(id, intent)) .await .unwrap(); } @@ -6331,7 +6460,7 @@ mod tests { .await .unwrap() .expect("entry"); - assert_eq!(entry, PendingPaymentDetails::PendingSplice { id, intent }); + assert_eq!(entry, PendingPaymentDetails::pending_splice(id, intent)); } } @@ -6399,7 +6528,7 @@ mod tests { &[(prior_txid, None), (txid, Some(contribution))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1); @@ -6458,7 +6587,7 @@ mod tests { channel_id, &[(txid, Some(contribution.clone()))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); // Wallet sync failed the payment and removed its entry. wallet @@ -6481,7 +6610,7 @@ mod tests { channel_id, &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); - wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; let bump_id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("a record"); assert_ne!(bump_id, failed_id); @@ -6533,7 +6662,7 @@ mod tests { channel_id, &[(prior_txid, None), (txid, Some(contribution))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); @@ -6568,7 +6697,7 @@ mod tests { 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(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); @@ -6794,7 +6923,7 @@ mod tests { channel_id, &[(prior_txid, None), (txid, Some(contribution))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let (next_tx, next_contribution) = splice_out_round(&wallet, 2, 400_000, 700); let next_txid = next_tx.compute_txid(); @@ -6803,7 +6932,7 @@ mod tests { channel_id, &[(prior_txid, None), (next_txid, Some(next_contribution))], ); - wallet.record_signed_funding(&next_tx, &next_candidates).await.unwrap(); + sign_and_observe_round(&wallet, &next_tx, &next_candidates).await; let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1); @@ -6824,10 +6953,11 @@ mod tests { } /// 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. + /// exchanged) and reports the failure: nothing was ever broadcast under it, so its entry goes, + /// leaving nothing behind to wait on a transaction that will never exist — while another + /// channel's entry is left alone. #[tokio::test] - async fn dropping_an_abandoned_first_round_removes_its_record() { + async fn dropping_an_abandoned_first_round_removes_its_entry() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); @@ -6853,8 +6983,18 @@ mod tests { assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); - assert!(wallet.payment_stores.payment_store().get(&other_id).await.unwrap().is_some()); + // The round's provenance fact outlives the drop — it says what the transaction would + // have been, which no drop unsays — so the txid still names the payment it belonged to, + // and nothing is recorded under that payment anymore. + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + let other = wallet + .payment_stores + .pending_payment_store() + .get(&other_id) + .await + .unwrap() + .expect("the other channel's entry stays"); + assert!(other.candidate(other_txid).is_some()); assert_eq!(wallet.find_payment_by_txid(other_txid).await.unwrap(), Some(other_id)); } @@ -6871,7 +7011,7 @@ mod tests { let id = PaymentId([31u8; 32]); let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); - let bare = PendingPaymentDetails::PendingSplice { id, intent }; + let bare = PendingPaymentDetails::pending_splice(id, intent); wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); @@ -6883,7 +7023,6 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); assert_eq!( wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), Some(bare.clone()) @@ -6911,7 +7050,7 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id, intent }) + .insert(PendingPaymentDetails::pending_splice(id, intent)) .await .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); @@ -6932,7 +7071,6 @@ mod tests { assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); } /// LDK abandoned a signed fee bump of a counterparty-initiated round this node did not @@ -6947,7 +7085,7 @@ mod tests { let id = PaymentId([31u8; 32]); let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); - let bare = PendingPaymentDetails::PendingSplice { id, intent }; + let bare = PendingPaymentDetails::pending_splice(id, intent); wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); let prior_txid = Txid::from_byte_array([9u8; 32]); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 1, 500_000, 300); @@ -6963,7 +7101,6 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); - assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); assert_eq!( wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), Some(bare) @@ -6985,7 +7122,7 @@ mod tests { channel_id, &[(txid, Some(contribution.clone()))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); @@ -6996,6 +7133,7 @@ mod tests { &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); @@ -7014,7 +7152,6 @@ mod tests { assert_eq!(payment.amount_msat, Some(500_300_000)); assert_eq!(payment.fee_paid_msat, Some(300_000)); assert_eq!(record.details(), Some(&payment)); - assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); } @@ -7032,8 +7169,7 @@ mod tests { 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); + sign_and_observe_round(&wallet, &tx, &candidates).await; evict_tx(&wallet, txid); assert!(wallet.inner.lock().unwrap().get_tx(txid).is_none(), "evicted: not canonical"); let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); @@ -7084,12 +7220,12 @@ mod tests { let record = wallet.payment_stores.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_stores.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); + let kept = record.candidate(txid).expect("the negotiated round keeps its place"); + assert_eq!(kept.amount_msat, Some(500_300_000)); + assert_eq!(kept.fee_paid_msat, Some(300_000)); + assert!(!kept.awaiting_broadcast); + // Wallet sync has not picked the round up, so there is no payment record either way. + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); } /// LDK abandoned the only round this node contributed to, an RBF of a counterparty-initiated @@ -7110,6 +7246,7 @@ mod tests { &[(prior_txid, None), (txid, Some(contribution))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); @@ -7117,7 +7254,6 @@ mod tests { assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); assert!(wallet.payment_stores.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 @@ -7174,13 +7310,12 @@ mod tests { wallet.payment_stores.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(), Some(&payment)); - assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), None); + assert!(record.candidate(bump_txid).is_none()); } - /// The record moved on to a bump whose signing write was cut short after the payment store, - /// so the entry still lists only the original round. Abandoning that round, with no round of - /// ours remaining, leaves the record as it stands and only shrinks the entry's history, its - /// copy of the record catching up. + /// The record moved on to a bump the entry does not list, so the entry still lists only the + /// original round. Abandoning that round, with no round of ours remaining, leaves the record + /// as it stands and only shrinks the entry's history, its copy of the record catching up. #[tokio::test] async fn dropping_the_last_round_leaves_a_record_that_moved_on() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); @@ -7192,9 +7327,10 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - // The bump's signing write landed in the payment store only. + // Wallet sync moved the record onto a bump the entry does not list. let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); let mut moved_on = PaymentDetailsUpdate::new(id); @@ -7229,6 +7365,7 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); wallet.payment_stores.payment_store().remove(&id).await.unwrap(); assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); @@ -7236,7 +7373,6 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); assert!(wallet.payment_stores.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 @@ -7256,7 +7392,7 @@ mod tests { channel_id, &[(txid, Some(contribution.clone()))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); @@ -7266,6 +7402,7 @@ mod tests { &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; // The first half of the hand-back: the payment record alone tracks the original round. let mut update = PaymentDetailsUpdate::new(id); @@ -7291,49 +7428,7 @@ mod tests { assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); assert_eq!(payment.amount_msat, Some(500_300_000)); assert_eq!(entry.details(), Some(&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([11u8; 32]); - let mut graduated = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); - graduated.status = PaymentStatus::Succeeded; - wallet.payment_stores.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([12u8; 32]); - 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_stores.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_stores.payment_store().get(&id).await.unwrap(), Some(graduated)); - assert_eq!( - wallet.payment_stores.payment_store().get(&other_id).await.unwrap(), - Some(untyped) - ); + assert!(entry.candidate(bump_txid).is_none()); } /// The rounds LDK holds for a channel are its pending rounds with a transaction and its current @@ -7415,7 +7510,7 @@ mod tests { other_channel_id, &[(other_txid, Some(other_contribution))], ); - wallet.record_signed_funding(&other_tx, &other_candidates).await.unwrap(); + sign_and_observe_round(&wallet, &other_tx, &other_candidates).await; 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( @@ -7423,7 +7518,7 @@ mod tests { closed_channel_id, &[(closed_txid, Some(closed_contribution))], ); - wallet.record_signed_funding(&closed_tx, &closed_candidates).await.unwrap(); + sign_and_observe_round(&wallet, &closed_tx, &closed_candidates).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let other_id = wallet.find_payment_by_txid(other_txid).await.unwrap().expect("other id"); let closed_id = wallet.find_payment_by_txid(closed_txid).await.unwrap().expect("closed id"); @@ -7488,6 +7583,7 @@ mod tests { &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; let mut update = PaymentDetailsUpdate::new(id); update.status = Some(PaymentStatus::Succeeded); @@ -7505,41 +7601,11 @@ mod tests { assert_eq!(entry.details().map(|details| details.status), Some(PaymentStatus::Pending)); } - /// The signing write failed between its two stores and the rollback failed as well, leaving - /// 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. + /// Recording a first splice round costs one read of the payment store: the signing writes no + /// payment record, so the only read left is the duplicate merge's probe for a record wallet + /// sync may have keyed by the round's own txid. #[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([9u8; 32]); - let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); - wallet.payment_stores.payment_store().insert_or_update(half_written).await.unwrap(); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); - - wallet.record_signed_funding(&tx, &[]).await.unwrap(); - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); - - let candidates = - splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - wallet.record_signed_funding(&tx, &[]).await.unwrap(); - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); - } - - /// Recording a first splice round costs two reads of the payment store: the write pair reads - /// the record it merges into, which also serves the rollback of a failed write, and the - /// duplicate merge probes for a record wallet sync may have keyed by the round's own txid. - #[tokio::test] - async fn a_first_round_signing_reads_the_payment_store_twice() { + async fn a_first_round_signing_reads_the_payment_store_once() { let counting_store = ReadCountingStore::new(); let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); let wallet = new_test_wallet(Arc::clone(&store), false).await; @@ -7553,205 +7619,12 @@ mod tests { let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; - assert_eq!(reads, 2, "recording a first round re-read the payment store"); + assert_eq!(reads, 1, "recording a first round re-read the payment store"); } - /// The half-written record of a reset first round sits under the id of the channel's splice - /// intent, which the signing adopted. The bare intent entry under that id does not index the - /// record, so the replayed signing drops the record and leaves the intent for the splice - /// tracker to settle. - #[tokio::test] - async fn a_replayed_signing_drops_the_half_written_record_under_a_bare_intent() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - - let id = PaymentId([31u8; 32]); - let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); - let bare = PendingPaymentDetails::PendingSplice { id, intent }; - wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); - let (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); - let txid = tx.compute_txid(); - let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); - wallet.payment_stores.payment_store().insert_or_update(half_written).await.unwrap(); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); - - wallet.record_signed_funding(&tx, &[]).await.unwrap(); - - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); - assert_eq!( - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), - Some(bare) - ); - } - - /// A half-written fee bump — the payment record moved on to the bump, the entry still lists - /// only the round it replaces — is indexed by that entry: the replayed signing leaves it - /// alone. Wallet sync hands the record back to the replaced round as that round confirms or - /// fails; the negotiation-failure handling cannot, as it only knows the rounds the entry lists. - #[tokio::test] - async fn a_replayed_signing_keeps_the_half_written_record_of_a_reset_bump() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); - let txid = tx.compute_txid(); - let candidates = - splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - - // The bump's signing write landed in the payment store only. - let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); - let bump_txid = bump_tx.compute_txid(); - let mut moved_on = PaymentDetailsUpdate::new(id); - moved_on.txid = Some(bump_txid); - wallet.payment_stores.payment_store().update(moved_on).await.unwrap(); - assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), Some(id)); - - wallet.record_signed_funding(&bump_tx, &[]).await.unwrap(); - - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); - let entry = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); - assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - } - - /// The signing write of a first round was cut short after the payment store, under the id of - /// the channel's splice intent. Replayed with the round still pending, the signing completes - /// the record: one entry, carrying the intent and the round awaiting broadcast. - #[tokio::test] - async fn a_replayed_signing_completes_the_half_written_record_under_a_bare_intent() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - - let id = PaymentId([31u8; 32]); - let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); - wallet - .payment_stores - .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) - .await - .unwrap(); - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); - let txid = tx.compute_txid(); - let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); - wallet.payment_stores.payment_store().insert_or_update(half_written).await.unwrap(); - let candidates = - splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - - let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1); - assert_eq!(payments[0].id, id); - let entries = wallet.payment_stores.pending_payment_store().list_filter(|_| true).await; - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].details(), Some(&payments[0])); - assert_eq!(entries[0].splice_intent(), Some(&intent)); - assert!(entries[0].candidate(txid).expect("candidate").awaiting_broadcast); - } - - /// Settling a bare splice intent removes the half-written signing record under its id, if - /// any: it is the first half of a signing write for a round nothing can broadcast, and no - /// entry would ever drive it. The bare entry itself is left to the settlement, and a record - /// a `Tracked` entry indexes stays. - #[tokio::test] - async fn settling_a_bare_intent_drops_its_half_written_record() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - - let id = PaymentId([31u8; 32]); - let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); - let bare = PendingPaymentDetails::PendingSplice { id, intent }; - wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); - let (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); - let txid = tx.compute_txid(); - let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); - wallet.payment_stores.payment_store().insert_or_update(half_written).await.unwrap(); - - wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); - - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); - assert_eq!( - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), - Some(bare) - ); - - // A round recorded in full under the intent's id is indexed by its entry and stays. - let (tx, contribution) = splice_out_round(&wallet, 2, 400_000, 700); - let txid = tx.compute_txid(); - let candidates = - splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); - - wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); - - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); - } - - /// Settling a bare splice intent leaves alone a record under its id that is not the - /// half-written record of a signed round: a payment that succeeded, or whose transaction - /// confirmed, was broadcast and driven to that state, and is a payment of its own. - #[tokio::test] - async fn settling_a_bare_intent_leaves_a_settled_record_alone() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - - let id = PaymentId([31u8; 32]); - let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); - let bare = PendingPaymentDetails::PendingSplice { id, intent }; - wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); - let txid = Txid::from_byte_array([0xBB; 32]); - let settled = [ - (confirmed_status(), PaymentStatus::Succeeded), - (confirmed_status(), PaymentStatus::Pending), - (ConfirmationStatus::Unconfirmed, PaymentStatus::Succeeded), - ]; - for (confirmation, status) in settled { - let kind = PaymentKind::Onchain { - txid, - status: confirmation, - tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), - }; - let record = PaymentDetails::new( - id, - kind, - Some(500_300_000), - Some(300_000), - PaymentDirection::Outbound, - status, - ); - wallet.payment_stores.payment_store().insert_or_update(record.clone()).await.unwrap(); - - wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); - - assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(record)); - assert_eq!( - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), - Some(bare.clone()) - ); - wallet.payment_stores.payment_store().remove(&id).await.unwrap(); - } - } - - /// The signing write fails between its two stores — the payment record lands, the pending - /// entry does not — so the payment store is put back as it was, and the replayed event - /// records the round in full once the store recovers instead of building on a half-written - /// record. + /// The signing write fails at the pending store: no payment record is minted for a round + /// nothing may broadcast, no entry is left half-written, and the replayed event records the + /// round in full once the store recovers. #[tokio::test] async fn a_failed_first_round_signing_write_leaves_no_half_written_record() { let fail_store = @@ -7776,23 +7649,29 @@ mod tests { .unwrap() .objects .is_empty()); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + // The round's facts landed before the entry, so the transaction names its payment + // already; nothing tracks it yet. + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + assert!(wallet.payment_stores.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 id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert!(record.candidate(txid).expect("candidate").awaiting_broadcast); + // Wallet sync creates the payment record when it observes the transaction. + observe_unconfirmed(&wallet, &tx).await; + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == 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. + /// The same failure while signing a fee bump: the record of the round it replaces is left + /// exactly as it stands, and the recorded history still ends at that round. #[tokio::test] - async fn a_failed_bump_signing_write_restores_the_prior_round() { + async fn a_failed_bump_signing_write_leaves_the_prior_round_tracked() { let fail_store = FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); @@ -7806,12 +7685,13 @@ mod tests { channel_id, &[(txid, Some(contribution.clone()))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); let prior = wallet.payment_stores.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_id = PaymentId(bump_txid.to_byte_array()); let bump_candidates = splice_candidates( counterparty_node_id, channel_id, @@ -7825,7 +7705,7 @@ mod tests { let record = wallet.payment_stores.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); + assert!(wallet.payment_stores.payment_store().get(&bump_id).await.unwrap().is_none()); } /// The candidates handed to the signing-time recording are the channel's pending splice @@ -10043,7 +9923,7 @@ mod tests { channel_id, &[(txid, Some(contribution.clone()))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); let replacement_txid = replacement_tx.compute_txid(); @@ -10091,9 +9971,11 @@ mod tests { vec![txid, replacement_txid, bump_txid] ); assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + // The signing writes no payment record: the one wallet sync made for the first round + // still describes that round, and the entry still mirrors it. let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); assert_eq!(entry.details(), Some(&payment)); assert_eq!( wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), @@ -10144,7 +10026,7 @@ mod tests { channel_id, &[(txid, Some(contribution.clone()))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); let replacement_txid = replacement_tx.compute_txid(); @@ -10240,7 +10122,7 @@ mod tests { channel_id, &[(txid, Some(contribution.clone()))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); let replacement_txid = replacement_tx.compute_txid(); @@ -10586,7 +10468,7 @@ mod tests { ) -> 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(); + sign_and_observe_round(wallet, tx, &candidates).await; wallet.record_broadcast_splice_round(channel_id, tx.compute_txid()).await.unwrap(); wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap().expect("recorded") } @@ -10806,7 +10688,7 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) .await .unwrap(); let counterparty_txid = Txid::from_byte_array([0xBB; 32]); @@ -10817,7 +10699,7 @@ mod tests { channel_id, &[(counterparty_txid, None), (txid, Some(contribution))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); wallet @@ -10856,14 +10738,14 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) .await .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); @@ -10908,7 +10790,7 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) .await .unwrap(); let counterparty_txid = Txid::from_byte_array([0xBB; 32]); @@ -10917,7 +10799,7 @@ mod tests { channel_id, &[(counterparty_txid, None), (txid, Some(contribution))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); wallet .resolve_promoted_splice_round( @@ -10934,7 +10816,7 @@ mod tests { channel_id, &[(bump_txid, Some(bump_contribution))], ); - wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; let bump_id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("a record"); assert_ne!(bump_id, id); @@ -10976,7 +10858,7 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id, intent: intent.clone() }) + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) .await .unwrap(); let counterparty_txid = Txid::from_byte_array([0xBB; 32]); @@ -10987,7 +10869,7 @@ mod tests { channel_id, &[(counterparty_txid, None), (txid, Some(contribution))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); // The prior pass failed the record and kept the intent, then crashed before removing // the entry. @@ -11006,7 +10888,7 @@ mod tests { wallet .payment_stores .pending_payment_store() - .insert(PendingPaymentDetails::PendingSplice { id: kept_id, intent: intent.clone() }) + .insert(PendingPaymentDetails::pending_splice(kept_id, intent.clone())) .await .unwrap(); @@ -11022,7 +10904,7 @@ mod tests { assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); assert_eq!( bare_entries_carrying(&wallet, &intent).await, - vec![PendingPaymentDetails::PendingSplice { id: kept_id, intent }], + vec![PendingPaymentDetails::pending_splice(kept_id, intent)], ); } @@ -11043,6 +10925,7 @@ mod tests { &[(counterparty_txid, None), (txid, Some(contribution))], ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); assert!( wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some(), @@ -11248,6 +11131,7 @@ mod tests { &[(first_txid, Some(first)), (bump_txid, Some(bump))], ); wallet.record_signed_funding(&bump_tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; let payment = wallet .payment_stores .payment_store() @@ -11344,7 +11228,7 @@ mod tests { 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(); + sign_and_observe_round(&wallet, &tx, &candidates).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); wallet.resolve_promoted_splice_round(channel_id, txid, Some(&[txid])).await.unwrap(); @@ -11652,12 +11536,21 @@ mod tests { WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx.clone()), old_block_time: None }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet + // The reported share names the funding payment the transaction belongs to, so the record + // is filed under that payment rather than under the transaction's own id. + assert!(wallet .payment_stores .payment_store() .get(&PaymentId(txid.to_byte_array())) .await .unwrap() + .is_none()); + let payment = wallet + .payment_stores + .payment_store() + .get(&figures.funding_payment_id) + .await + .unwrap() .expect("wallet sync records the transaction"); assert_eq!(payment.amount_msat, figures.amount_msat); assert_eq!(payment.fee_paid_msat, figures.fee_paid_msat); diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index 1c05d46332..005a21c284 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -172,6 +172,13 @@ impl ChannelTxFacts { self } + /// Records this node's share of an interactively negotiated funding candidate, and the funding + /// payment the candidate belongs to. + pub(crate) fn with_local_figures(mut self, local_figures: LocalFundingFigures) -> Self { + self.local_figures = Some(local_figures); + self + } + /// Merges `incoming` into these facts, returning the result, or `None` when `incoming` adds /// nothing to what is already recorded. /// diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 503dc5167d..cc41d2acbf 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -95,15 +95,17 @@ macro_rules! expect_splice_negotiation_failed_event { } /// 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. +/// candidate) as a payment carrying a `tx_type`, syncing its wallet until it has. A splice +/// candidate's payment is recorded when wallet sync first observes the transaction, so the sync is +/// what settles this; a channel open is classified off the broadcaster's queue, which can lag a +/// `sync_wallets` call under load, and 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 { + // A sync that cannot reach the transaction yet is retried rather than reported: the + // timeout below is what turns a transaction that never arrives into a failure. + let _ = node.sync_wallets(); let classified = node.list_all_payments().into_iter().any(|p| { matches!( p.kind, From 9fc719b05868c5da2bda732418baafc70c1f8ff3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 19:36:27 +0200 Subject: [PATCH 36/49] f Let wallet sync create funding payment records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the funding payment record from signing to wallet sync regressed four of the splice integration tests, two of them through production behaviour. Settling a bare splice intent stopped taking the payment record under its id along. What can leave one there changed — wallet sync now files the record and its pending entry in one write, under the id the round's facts name — but a record found under a bare intent is still the first half of a write that never completed, and still one no entry would ever drive, so restore its removal. LDK re-offers a promoted but unconfirmed zero-conf splice through its generic funding path, re-typed as a plain funding of the channel and carrying the wallet's view of an output both parties own. That re-offer used to find the round's record in place and be declined; with no record until sync has seen the transaction it created one itself, naming a splice a plain funding. Leave a transaction already on record as an interactive funding to sync. Two tests read the transaction of the round just signed out of a payment record that no longer exists at that point, and one of them held back the payment-store writes that used to precede signing to sequence the two nodes. Both now take the round from what the node logs as it records it, and the sequencing holds back the provenance write that precedes signing instead. The kept-at-close test also asserted a record that is only written once a wallet has seen the transaction, which nothing broadcasts there; it now asserts that the close resolves the channel's rounds and leaves this one in the record, against its sibling where the same resolution drops the round the monitor never watched. Two further tests are timing-fragile rather than wrong. The disconnect-mid-negotiation test loses its own race on a loaded machine — the negotiation completes before the disconnect lands, so no failure is ever due — and is widened as its comment prescribes. The zero-conf queued-splice test now needs a chain source that reports an unconfirmed round promptly, the record no longer existing before one has, and is pinned to Esplora as its sibling already is. Co-Authored-By: HAL 9000 --- src/channel/mod.rs | 8 +- src/wallet/mod.rs | 216 ++++++++++++++++++++++++++++++++ tests/integration_tests_rust.rs | 174 +++++++++++++------------ 3 files changed, 318 insertions(+), 80 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 6b0eafa810..a873df7eb5 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -513,7 +513,12 @@ impl SpliceTracker { /// intent: a mismatch means a fee bump took over the record in the meantime, and its intent /// must stay. A record that tracks anything else stays, with the intent cleared, so its /// payment keeps graduating and the rounds signed under the splice keep their place. A record - /// left with nothing to track is removed. + /// left with nothing to track is removed, along with any payment record under its id: wallet + /// sync files a funding payment under the id of the intent its round belongs to and indexes it + /// in the same write, so a payment record found under a bare intent is the first half of a + /// write that never completed, which no entry would ever drive + /// ([`Wallet::drop_unindexed_record_of_settled_intent`]). The record goes first: a bare intent + /// left behind is found and settled again, an orphaned record would not be. async fn clear_persisted_intent bool>( &self, payment_id: PaymentId, still_applies: F, ) { @@ -537,6 +542,7 @@ impl SpliceTracker { }) .await?; if remove_bare_record { + self.wallet.drop_unindexed_record_of_settled_intent(payment_id).await?; self.pending_payment_store .remove_if(&payment_id, |record| { record.details().is_none() diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index de0af448cf..c06eaa528e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2315,6 +2315,26 @@ impl Wallet { return Ok(()); } + // A round this node signed is on record as an interactive funding of its channels, and + // that is what the transaction is however it is re-offered. Recording the re-offer would + // name the round a plain funding of the channel and give it the wallet's view of a funding + // output both parties own — the record wallet sync will create for it says both correctly + // — so leave the transaction to sync. A record the re-offer finds in place comes through + // unchanged either way (`funding_reclassification_update` declines the downgrade); this is + // what keeps a re-offer arriving before sync has seen the transaction from creating the + // record itself. + let named_interactive = self.channel_tx_facts(&txid).await.is_some_and(|facts| { + matches!(facts.self_role, Some(TransactionType::InteractiveFunding { .. })) + }); + if named_interactive { + log_trace!( + self.logger, + "Keeping interactive-funding classification over funding-typed rebroadcast {}", + txid, + ); + return Ok(()); + } + // Resolution and the writes below must share one lock acquisition: resolved outside it, // the id could go stale against a record wallet sync creates for the same transaction, // and the write below would create a divergent record. @@ -2997,6 +3017,62 @@ impl Wallet { Ok(()) } + /// Removes the payment record under `payment_id` — the id of a bare splice intent whose splice + /// settled — when it is the first half of a funding-record write that never completed. + /// + /// Wallet sync files a funding payment under the id the round's recorded facts name, which for + /// a user-initiated splice is the id its intent was created with, and indexes it in the pending + /// store in the same write. A record found under a bare intent therefore lost that index to a + /// write that failed in between, and no entry would ever drive it: it neither graduates nor + /// maps its transaction back to itself. A record an entry does index stays, as does one that is + /// not a pending, unconfirmed interactive funding — a record that confirmed or succeeded was + /// driven to that state and is a payment of its own. The caller removes the bare entry + /// afterwards. + pub(crate) async fn drop_unindexed_record_of_settled_intent( + &self, payment_id: PaymentId, + ) -> Result<(), Error> { + // Serialize with the funding-record writers, so that the check and the removal cannot + // interleave with a write completing the record. + let stores = self.payment_stores.lock().await; + let indexed = stores + .pending_payment(&payment_id) + .await? + .is_some_and(|entry| entry.details().is_some()); + if indexed { + log_debug!( + self.logger, + "Keeping the funding record of payment {}: its pending entry indexes it", + payment_id, + ); + return Ok(()); + } + let half_written = + stores.payment(&payment_id).await?.and_then(|record| match &record.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } if record.status == PaymentStatus::Pending => Some(*txid), + _ => None, + }); + match half_written { + Some(recorded) => { + stores.remove_payment(&payment_id).await?; + log_info!( + self.logger, + "Dropped the half-written funding record of splice round {}", + recorded, + ); + }, + None => log_debug!( + self.logger, + "No half-written funding record to drop under payment {}", + payment_id, + ), + } + 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( @@ -7073,6 +7149,98 @@ mod tests { assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } + /// Settling a bare splice intent removes the unindexed funding record under its id, if any: + /// it is the first half of a write that never completed, and no entry would ever drive it. The + /// bare entry itself is left to the settlement, and a record an entry indexes stays. + #[tokio::test] + async fn settling_a_bare_intent_drops_its_half_written_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::pending_splice(id, intent); + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let txid = Txid::from_byte_array([0xBB; 32]); + let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); + wallet.payment_stores.payment_store().insert_or_update(half_written).await.unwrap(); + + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare) + ); + + // The same record with an entry indexing it is a funding payment wallet sync recorded in + // full, and stays. + let indexed_txid = Txid::from_byte_array([0xCC; 32]); + let record = + interactive_funding_details(id, indexed_txid, Some(400_700_000), Some(700_000)); + wallet.payment_stores.payment_store().insert_or_update(record.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::new(record.clone(), Vec::new(), Vec::new())) + .await + .unwrap(); + + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(record)); + assert_eq!(wallet.find_payment_by_txid(indexed_txid).await.unwrap(), Some(id)); + } + + /// Settling a bare splice intent leaves alone a record under its id that is not the + /// half-written record of a funding round: a payment that succeeded, or whose transaction + /// confirmed, was broadcast and driven to that state, and is a payment of its own. + #[tokio::test] + async fn settling_a_bare_intent_leaves_a_settled_record_alone() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::pending_splice(id, intent); + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let txid = Txid::from_byte_array([0xBB; 32]); + let settled = [ + (confirmed_status(), PaymentStatus::Succeeded), + (confirmed_status(), PaymentStatus::Pending), + (ConfirmationStatus::Unconfirmed, PaymentStatus::Succeeded), + ]; + for (confirmation, status) in settled { + let kind = PaymentKind::Onchain { + txid, + status: confirmation, + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + let record = PaymentDetails::new( + id, + kind, + Some(500_300_000), + Some(300_000), + PaymentDirection::Outbound, + status, + ); + wallet.payment_stores.payment_store().insert_or_update(record.clone()).await.unwrap(); + + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(record)); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare.clone()) + ); + wallet.payment_stores.payment_store().remove(&id).await.unwrap(); + } + } + /// LDK abandoned a signed fee bump of a counterparty-initiated round this node did not /// contribute to: no remaining round is this node's payment, so the record goes as a first /// round's does, and the bump's intent stays behind as a bare intent. @@ -9145,6 +9313,54 @@ mod tests { assert_eq!(payments[0].amount_msat, Some(1_000_000)); } + /// The same re-broadcast arriving before wallet sync has seen the transaction finds no record + /// to be declined against. The round is on record as an interactive funding of its channel, + /// which is what the transaction is whichever path re-offers it, so the re-offer must record + /// nothing and leave the transaction to sync. + #[tokio::test] + async fn a_funding_rebroadcast_of_a_named_round_records_nothing() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channels = vec![(counterparty_node_id, channel_id)]; + + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let funded_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let txid = funded_tx.compute_txid(); + + wallet + .record_channel_tx_facts(ChannelTxFacts::new(txid).with_self_role( + TransactionType::InteractiveFunding { + channels: vec![Channel { counterparty_node_id, channel_id }], + }, + )) + .await + .unwrap(); + + wallet + .classify_funding(&funded_tx, &channels, TransactionType::Funding { channels: vec![] }) + .await + .unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert!( + payments.is_empty(), + "the re-offer recorded the round as a plain funding: {:?}", + payments, + ); + } + /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding /// path: same txid, but typed as a plain funding transaction with wallet-view figures and no /// contribution metadata. The rebroadcast must not overwrite the contribution-derived diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index cc41d2acbf..ae4d523ab6 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2544,16 +2544,17 @@ async fn zero_conf_splice_out_funding_rebroadcast_canary() { ); } -/// Canary for the upstream behavior the funding-over-interactive-funding guard in -/// `funding_reclassification_update` works around: LDK re-broadcasts a promoted-but-unconfirmed -/// 0conf splice through its generic funding path — re-typed as a plain funding transaction with -/// wallet-view figures and no contribution metadata — on every monitor-update completion until it -/// confirms. On the contributing side those re-offers target the interactive-funding record, -/// which must come through unchanged. The re-typing is tracked upstream at +/// Canary for the upstream behavior the funding-over-interactive-funding guards in +/// `classify_funding` and `funding_reclassification_update` work around: LDK re-broadcasts a +/// promoted-but-unconfirmed 0conf splice through its generic funding path — re-typed as a plain +/// funding transaction with wallet-view figures and no contribution metadata — on every +/// monitor-update completion until it confirms. On the contributing side those re-offers name a +/// transaction the node has already recorded as an interactive funding, and must leave both the +/// classification and the figures alone. The re-typing is tracked upstream at /// . /// /// If this test fails, upstream likely stopped re-offering the transaction that way (or now -/// preserves its interactive-funding classification): re-evaluate whether the guard still sees +/// preserves its interactive-funding classification): re-evaluate whether the guards still see /// traffic. #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn zero_conf_splice_in_funding_rebroadcast_canary() { @@ -2634,8 +2635,9 @@ async fn zero_conf_splice_in_funding_rebroadcast_canary() { let rebroadcast = format!("funding-typed rebroadcast {}", txo.txid); assert!( logger_a.wait_for_count(&rebroadcast, 2).await, - "Node A saw no generic-funding re-broadcast targeting the interactive-funding record; if \ - upstream stopped re-offering it, re-evaluate the guard in funding_reclassification_update" + "Node A saw no generic-funding re-broadcast of the round it recorded as an interactive \ + funding; if upstream stopped re-offering it, re-evaluate the guards in classify_funding \ + and funding_reclassification_update" ); // The re-offers must not have disturbed the record's classification or figures. @@ -2655,10 +2657,17 @@ async fn zero_conf_splice_in_funding_rebroadcast_canary() { /// rather than the first splice's, whose record keeps the first splice's transaction. The lock /// handler settles the first splice's intent before the second is submitted, so this guards /// behavior in place before one record per splice rather than failing without it. +/// +/// Pinned to Esplora, as the sibling below is: both wait for a record of a splice round that is +/// still unconfirmed, and wallet sync creates it from what the wallet has seen. A bitcoind chain +/// source learns an unconfirmed transaction from its mempool poll, which offers a mempool entry +/// to the wallet once, so a round that falls outside that one offer reaches the wallet only when +/// it confirms. +#[cfg(feature = "chain-esplora")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn zero_conf_queued_splice_is_recorded_as_its_own_payment() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = random_chain_source(&bitcoind, &electrsd); + let chain_source = TestChainSource::Esplora(&electrsd); let node_a = setup_node(&chain_source, random_config()); let mut config_b = random_config(); @@ -3190,18 +3199,18 @@ async fn open_and_splice_from_counterparty( (user_channel_id_a, counterparty_txo.txid) } -/// 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, +/// The transaction of the only splice round `logs` show the node having recorded at signing. A +/// round is recorded before it is signed, so this names the round while nothing has broadcast it +/// and no wallet has seen it — before there is a payment record to read it from. A round the node +/// contributed nothing to records nothing and is not named here. +fn only_signed_round_txid(logs: &CollectingLogWriter) -> Txid { + let prefix = format!("{} ", RECORDED_SIGNED_ROUND); + let mut txids = logs.lines().into_iter().filter_map(|line| { + let rest = line.strip_prefix(&prefix)?; + Txid::from_str(rest.split(' ').next()?).ok() }); - let txid = txids.next().expect("no interactive funding payment recorded"); - assert_eq!(txids.next(), None, "more than one interactive funding payment recorded"); + let txid = txids.next().expect("no signed splice round recorded"); + assert_eq!(txids.next(), None, "more than one signed splice round recorded"); txid } @@ -3404,6 +3413,9 @@ fn setup_logged_node( /// 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 as it records a splice round it is about to sign, naming the round's +/// transaction. +const RECORDED_SIGNED_ROUND: &str = "Recorded signed splice funding"; /// 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"; @@ -3438,15 +3450,19 @@ const CLOSED_CHANNEL_ROUNDS_RESOLVED: &str = "round(s) its monitor holds"; /// 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 -/// 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. +/// A splice round this node signed keeps its place in the channel's recorded splice history 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 round 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 sibling +/// [`signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close`] shows the same close +/// dropping a round the monitor never watched, so this is a decision the close makes, not one it +/// never reaches. /// /// 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 +/// before it signs: node A's provenance 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 @@ -3454,20 +3470,21 @@ const ROUND_LOCKED: &str = "locked as the funding of channel"; /// [`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 payment fails, no round of ours being left that can confirm. LDK reports -/// `SpliceNegotiated` for this round after `ChannelClosed`, node A having sent its `tx_signatures`, -/// so the node clears the round's awaiting-broadcast mark and the record is not dropped at maturity -/// as one nothing broadcast. The test's own tail shows node B does broadcast the round, which is -/// why `Failed` is the right end state. +/// The round survives the close settling too: node A's commitment transaction confirms and its +/// `to_self_delay` passes, and the monitor stops watching the round and reports it discarded. LDK +/// reports `SpliceNegotiated` for this round after `ChannelClosed`, node A having sent its +/// `tx_signatures`, so the node clears the round's awaiting-broadcast mark and the round is not +/// dropped at maturity as one nothing broadcast. The test's own tail shows node B does broadcast +/// the round, which is why keeping it is right. No payment record is written for a round nothing +/// has broadcast — wallet sync creates one when it observes the transaction — so what is kept is +/// the round's place in the record, which the mark cleared after the close reports. #[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", None))); + setup_contended_node(&chain_source, random_config(), Some(("channel_tx_facts", None))); let (node_b, store_b, logs_b) = setup_contended_node(&chain_source, random_config(), None); let (user_channel_id_a, counterparty_round) = open_and_splice_from_counterparty(&bitcoind, &electrsd, &node_a, &node_b).await; @@ -3478,11 +3495,12 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { let received_a = logs_a.count(RECEIVED_TX_SIGNATURES); let received_b = logs_b.count(RECEIVED_TX_SIGNATURES); let broadcast_b = logs_b.count(BROADCAST_FUNDING); - let marked_a = logs_a.count(ROUND_MARKED_BROADCAST); + let resolved_a = logs_a.count(CLOSED_CHANNEL_ROUNDS_RESOLVED); 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. + // Recording the round writes what the transaction is 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, @@ -3499,7 +3517,7 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { received_a, "node B did not withhold its signatures" ); - let rbf_txid = only_interactive_funding_txid(&node_a); + let rbf_txid = only_signed_round_txid(&logs_a); let funding_txo = node_a .list_channels() .into_iter() @@ -3515,49 +3533,38 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { expect_event!(node_a, ChannelClosed); let new_funding_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); assert_eq!(new_funding_txo.txid, rbf_txid, "LDK reported a different round negotiated"); + // The mark is cleared in the record that holds the round, so clearing it is itself evidence + // that the closed channel's record still holds the round. + let round_marked = format!("{} {} of channel", ROUND_MARKED_BROADCAST, rbf_txid); assert!( - logs_a.wait_for_count(ROUND_MARKED_BROADCAST, marked_a + 1).await, + logs_a.wait_for(&round_marked).await, "the round's awaiting-broadcast mark was not cleared" ); - 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 { .. }), - .. - } - )); + // The close resolves the channel's rounds by the ones its monitor holds, and leaves this one + // where it is: the monitor watches it, so the counterparty can still release it. + let round_dropped = format!("{} [{}]", DROPPED_ABANDONED_ROUND, rbf_txid); + assert!( + logs_a.wait_for_count(CLOSED_CHANNEL_ROUNDS_RESOLVED, resolved_a + 1).await, + "the close did not resolve the channel's splice rounds" + ); + assert!(!logs_a.contains(&round_dropped), "the signed round was taken back with the channel"); - // The close settles first: node A's commitment transaction, which replaced node B's first + // The close settles next: node A's commitment transaction, which replaced node B's first // round in the mempool, is mined. 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 no round of ours is - // left that can confirm, so the payment fails. + // counterparty's; it then reports the rounds it watched as discarded and the channel's rounds + // are resolved once more — and the round stays, its awaiting-broadcast mark having been + // cleared, so it is not one nothing ever broadcast. let commitment = wait_for_commitment(&bitcoind, funding_txo).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 discarded round's payment was not failed" - ); - let payment = node_a - .list_all_payments() - .into_iter() - .find(|p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == rbf_txid)) - .expect("the record of a round node B could broadcast was taken back"); - assert_eq!(payment.status, PaymentStatus::Failed); - assert!( - !logs_a.contains(DROPPED_ABANDONED_ROUND), - "a round node B could broadcast was dropped" + logs_a.wait_for_count(CLOSED_CHANNEL_ROUNDS_RESOLVED, resolved_a + 2).await, + "the matured close did not resolve the channel's splice rounds again" ); + assert!(!logs_a.contains(&round_dropped), "a round node B could broadcast was dropped"); // 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 @@ -3774,7 +3781,7 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { 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); + let rbf_txid = only_signed_round_txid(&logs_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), @@ -3793,12 +3800,19 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { "node A's contribution to the discarded round was not reclaimed" ); + // The round is taken back from the channel's record: the monitor never watched it, so node + // B's `commitment_signed` never arrived, this node's signatures never left it, and nothing + // can broadcast it. + assert!( + logs_a.wait_for(&format!("{} [{}]", DROPPED_ABANDONED_ROUND, rbf_txid)).await, + "the round the monitor never watched was kept" + ); 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" + "a payment was left behind for a round nothing can broadcast" ); drop(hold_b); @@ -4076,12 +4090,12 @@ async fn splice_failure_surfaced_after_disconnect_mid_negotiation() { // Fund Node A with many small UTXOs: every input the splice contributes adds an interactive-tx // round trip, stretching the negotiation so the disconnect below reliably lands inside it. let addresses_a: Vec
= - (0..40).map(|_| node_a.onchain_payment().new_address().unwrap()).collect(); + (0..240).map(|_| node_a.onchain_payment().new_address().unwrap()).collect(); premine_and_distribute_funds( &bitcoind.client, &electrsd.client, addresses_a, - Amount::from_sat(125_000), + Amount::from_sat(32_000), ) .await; node_a.sync_wallets().unwrap(); @@ -4094,14 +4108,16 @@ async fn splice_failure_surfaced_after_disconnect_mid_negotiation() { let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); - // The 3M target forces roughly 25 of the 125k-sat UTXOs into the contribution. + // The 3M target forces roughly 95 of the 32k-sat UTXOs into the contribution. node_a.splice_in(&user_channel_id_a, node_b.node_id(), 3_000_000).unwrap(); // Disconnect as soon as the negotiation is in flight. The negotiation keeps running while the - // disconnect is processed, so in principle it could still complete first — the disconnect - // would then fail nothing and the failure-event assert below would trip. The ~25 remaining - // per-input round trips make that window practically unlosable; if this ever flakes, widen - // the contribution further. + // disconnect is processed — `Node::disconnect` first persists a peer-store removal on the + // node's own runtime, which the negotiation is keeping busy — so in principle the negotiation + // could still complete first, the disconnect would then fail nothing (a signed round is + // resumed on reconnect rather than failed) and the failure-event assert below would trip. The + // ~95 remaining per-input round trips make that window wide enough to survive a heavily + // loaded machine; if this ever flakes, widen the contribution further. tokio::time::timeout(std::time::Duration::from_secs(10), splice_ack_seen.notified()) .await .expect("node A never received splice_ack"); From a9117e6de8a046418b2bb7528b92e8792db677bc Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 20:01:32 +0200 Subject: [PATCH 37/49] Delete broadcast-time payment classification Wallet sync classifies on-chain payments from recorded provenance and owns the payment record. The second classifier, which ran on the broadcaster's queue and had to hold a broadcast back until its record was persisted, is now redundant: it wrote records sync would write anyway, under merge rules that existed only to keep the two writers from clobbering each other. Broadcasting no longer waits on persistence, so the queue needs neither retries nor a bound nor deduplication, and the broadcaster needs no handle on the wallet: it is a plain FIFO that the chain source drains and sends. The LDK-supplied transaction type is ignored on arrival. Tests deleted with their subjects: - zero_conf_splice_{out,in}_funding_rebroadcast_canary, together with the rust-lightning#4878 TODO they pin. They assert log lines emitted by the funding-over-interactive-funding guards, which are gone; with no tag to re-type, the upstream behaviour they watch is unobservable. - funding_reclassification_* and funding_classification_*, plus transaction_type_from_ldk_variants: their subjects are funding_reclassification_update, PaymentDetailsUpdate:: funding_reclassification, the confirmed-figures guard and the LdkTransactionType conversion. - funding_confirmation_waits_for_classification and funding_classification_waits_for_wallet_sync: race tests between classification's two-store write pair and a sync arm. There is no second writer left to race. - The broadcast-queue tests for retries, deduplication and the package bound, and the wallet-level tests driving them. Arrival order and the wake-on-push remain covered. - classify_funding's own tests, including its generated-id and rebroadcast handling. - recording_a_round_removes_the_intent_record_of_an_advanced_payment: the leftover-intent removal it pins lived only in the deleted persist_funding_payment_locked. The writer that creates a funding record now declines to write when the record has advanced past Pending, and leaves a bare intent entry for the splice lifecycle to clean up. Tests re-expressed rather than deleted: the funding-record fixture the conflict, graduation and duplicate-merge tests build on now composes the surviving writers -- the payment store, upsert_pending_payment and merge_duplicate_candidate_records -- instead of calling the deleted classification path. Co-Authored-By: HAL 9000 --- src/builder.rs | 2 - src/chain/mod.rs | 36 +- src/payment/pending_payment_store.rs | 78 -- src/payment/store.rs | 529 +-------- src/tx_broadcaster.rs | 698 +---------- src/wallet/mod.rs | 1647 ++------------------------ src/wallet/payment_stores.rs | 12 +- tests/integration_tests_rust.rs | 198 ---- 8 files changed, 150 insertions(+), 3050 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index c91dd44a99..d8cbce66c2 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1966,8 +1966,6 @@ fn build_with_store_internal( BuildError::WalletSetupFailed })?; - tx_broadcaster.set_wallet(Arc::downgrade(&wallet)); - // Initialize the KeysManager let cur_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_err(|e| { log_error!(logger, "Failed to get current time: {}", e); diff --git a/src/chain/mod.rs b/src/chain/mod.rs index cf310f0bb4..4096890c90 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -35,17 +35,12 @@ use crate::config::ElectrumSyncConfig; use crate::config::EsploraSyncConfig; use crate::config::{BackgroundSyncConfig, Config, WALLET_SYNC_INTERVAL_MINIMUM_SECS}; use crate::fee_estimator::OnchainFeeEstimator; -use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::logger::{log_debug, log_info, log_trace, LdkLogger, Logger}; use crate::runtime::Runtime; use crate::tx_broadcaster::BroadcastPackage; 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. -pub(crate) const FAILED_CLASSIFY_RETRY_DELAY: Duration = Duration::from_secs(2); - /// We use this parent-child TRUC package to make sure the configured chain source supports /// broadcasting packages via the `submitpackage` Bitcoin Core RPC. const PARENT_TXID: &str = "9a015f93fac6cb203c2b994e18b85176eb0354a22a468255516f3c6002d3f696"; @@ -568,23 +563,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 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> { - 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); - } + /// Hands the package to the configured chain source, parents before their child so a CPFP + /// package a chain source submits one transaction at a time is still accepted. + async fn broadcast(&self, package: BroadcastPackage) { let package = package.into_sorted_transactions(); match &self.kind { #[cfg(feature = "chain-esplora")] @@ -600,7 +581,6 @@ impl ChainSource { bitcoind_chain_source.process_transaction_broadcast(package).await }, } - Ok(()) } pub(crate) async fn continuously_process_broadcast_queue( @@ -609,8 +589,7 @@ impl ChainSource { loop { let package = tokio::select! { // A stop request is polled first, so a queue that always has a package ready - // cannot starve it. Which package comes next — a fresh one before a due retry — - // is decided in `BroadcastQueue::next`. + // cannot starve it. biased; _ = stop_tx_bcast_receiver.changed() => { log_debug!( @@ -621,10 +600,7 @@ impl ChainSource { } package = self.tx_broadcaster.next_package() => package, }; - if let Err(package) = self.classify_and_broadcast(package).await { - let retry_at = tokio::time::Instant::now() + FAILED_CLASSIFY_RETRY_DELAY; - self.tx_broadcaster.retry_package(package, retry_at); - } + self.broadcast(package).await; } } } diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 59b98758f3..d0afd90259 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -662,84 +662,6 @@ mod tests { ); } - #[test] - fn funding_classification_pending_update_preserves_mirrored_confirmation() { - use bitcoin::BlockHash; - - use crate::payment::store::PaymentDetailsUpdate; - - let txid = test_txid(7); - let payment_id = PaymentId(txid.to_byte_array()); - - // A pending entry wallet sync has already mirrored a confirmation into (via - // `apply_funding_status_update_locked`) before classification ran. - let confirmed_details = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Confirmed { - block_hash: BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, - }, - tx_type: None, - }, - Some(2_000_000), - Some(999), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - let mirrored = PendingPaymentDetails::new(confirmed_details, Vec::new(), Vec::new()); - - // A fresh classification is always Unconfirmed and carries the candidate history; its - // figures are the active candidate's. - let fresh = pending_onchain_payment(payment_id, txid); - let candidates = vec![FundingTxCandidate { - 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 - // confirmation. - let mut downgraded = mirrored.clone(); - let full_update = - PendingPaymentDetails::new(fresh.clone(), Vec::new(), candidates.clone()).to_update(); - assert!(downgraded.update(full_update)); - assert!( - matches!( - downgraded.details().expect("tracked").kind, - PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } - ), - "a full merge of a fresh classification downgrades a mirrored confirmation", - ); - - // The narrow classification update merges the candidates while preserving the - // confirmation state wallet sync owns. It names the confirmed txid, so its - // contribution-derived figures replace the mirrored wallet-view ones. - let mut merged = mirrored.clone(); - let narrow_update = PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: Some(PaymentDetailsUpdate::funding_reclassification(fresh)), - conflicting_txids: None, - candidates: candidates.clone(), - splice_intent: None, - }; - assert!(merged.update(narrow_update)); - let merged_details = merged.details().expect("tracked"); - assert!( - matches!( - merged_details.kind, - PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } - ), - "a narrow classification update must not downgrade a mirrored confirmation", - ); - assert_eq!(merged.candidate(txid), Some(&candidates[0])); - assert_eq!(merged_details.amount_msat, Some(1_000)); - assert_eq!(merged_details.fee_paid_msat, Some(100)); - } - fn test_intent() -> SpliceIntent { use std::str::FromStr; diff --git a/src/payment/store.rs b/src/payment/store.rs index 46cc57b87b..c760661845 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -9,7 +9,6 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; use bitcoin::{BlockHash, Txid}; -use lightning::chain::chaininterface::TransactionType as LdkTransactionType; use lightning::ln::channelmanager::PaymentId; use lightning::ln::msgs::DecodeError; use lightning::ln::types::ChannelId; @@ -283,28 +282,12 @@ impl UpdatableObject for PaymentDetails { } } - // Once an on-chain record is confirmed, its txid and figures describe the candidate that - // confirmed, which need not be the last one broadcast. An update that doesn't assert the - // confirmation state was built without knowing it — e.g. a late funding classification - // whose candidate lost to the counterparty's broadcast — so it must not move them. The - // exception is an update naming the confirmed txid itself: its figures describe the very - // candidate that confirmed and correct the wallet-view amount/fee a sync-created record - // carries, which cannot represent our contribution to a shared funding output. - let keep_confirmed_figures = update.confirmation_status.is_none() - && matches!( - self.kind, - PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } - if update.txid != Some(txid) - ); - - if !keep_confirmed_figures { - if let Some(amount_opt) = update.amount_msat { - update_if_necessary!(self.amount_msat, amount_opt); - } + if let Some(amount_opt) = update.amount_msat { + update_if_necessary!(self.amount_msat, amount_opt); + } - if let Some(fee_paid_msat_opt) = update.fee_paid_msat { - update_if_necessary!(self.fee_paid_msat, fee_paid_msat_opt); - } + if let Some(fee_paid_msat_opt) = update.fee_paid_msat { + update_if_necessary!(self.fee_paid_msat, fee_paid_msat_opt); } if let Some(skimmed_fee_msat) = update.counterparty_skimmed_fee_msat { @@ -334,7 +317,7 @@ impl UpdatableObject for PaymentDetails { if let Some(tx_id) = update.txid { match self.kind { - PaymentKind::Onchain { ref mut txid, .. } if !keep_confirmed_figures => { + PaymentKind::Onchain { ref mut txid, .. } => { update_if_necessary!(*txid, tx_id); }, _ => {}, @@ -415,12 +398,11 @@ impl_writeable_tlv_based!(Channel, { (2, channel_id, required), }); -/// The classification of a [`PaymentKind::Onchain`] transaction, as reported by LDK when the -/// transaction was broadcast. +/// The classification of a [`PaymentKind::Onchain`] transaction: what the channels of this node +/// that took part in it make the transaction out to be. /// -/// Mirrors [`lightning::chain::chaininterface::TransactionType`], retaining the channel references -/// but dropping the broadcast-time contribution data; a transaction's amount and fee are tracked on -/// the [`PaymentDetails`] itself. +/// Names the channels involved; a transaction's amount and fee are tracked on the +/// [`PaymentDetails`] itself. #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] pub enum TransactionType { @@ -498,58 +480,6 @@ impl_writeable_tlv_based_enum!(TransactionType, } ); -impl From for TransactionType { - fn from(tx_type: LdkTransactionType) -> Self { - let to_channels = |channels: Vec<(PublicKey, ChannelId)>| -> Vec { - channels - .into_iter() - .map(|(counterparty_node_id, channel_id)| Channel { - counterparty_node_id, - channel_id, - }) - .collect() - }; - match tx_type { - LdkTransactionType::Funding { channels } => { - TransactionType::Funding { channels: to_channels(channels) } - }, - LdkTransactionType::CooperativeClose { counterparty_node_id, channel_id } => { - TransactionType::CooperativeClose { counterparty_node_id, channel_id } - }, - LdkTransactionType::UnilateralClose { counterparty_node_id, channel_id } => { - TransactionType::UnilateralClose { counterparty_node_id, channel_id } - }, - LdkTransactionType::AnchorBump { counterparty_node_id, channel_id } => { - TransactionType::AnchorBump { counterparty_node_id, channel_id } - }, - LdkTransactionType::Claim { counterparty_node_id, channel_id } => { - TransactionType::Claim { counterparty_node_id, channel_id } - }, - LdkTransactionType::Sweep { channels } => { - TransactionType::Sweep { channels: to_channels(channels) } - }, - LdkTransactionType::InteractiveFunding { candidates } => { - // Every candidate (the original negotiation plus any RBF replacements) references - // the same channel(s); take the active (last) candidate's channel references. - let channels = candidates - .last() - .map(|candidate| { - candidate - .channels - .iter() - .map(|cf| Channel { - counterparty_node_id: cf.counterparty_node_id, - channel_id: cf.channel_id, - }) - .collect() - }) - .unwrap_or_default(); - TransactionType::InteractiveFunding { channels } - }, - } - } -} - /// Represents the kind of a payment. #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] @@ -787,33 +717,6 @@ impl PaymentDetailsUpdate { tx_type: None, } } - - /// Builds an update that merges a freshly-classified funding payment's classification - /// (`tx_type`), broadcast txid, and our contribution figures (amount/fee) into an existing - /// record, while leaving the top-level [`PaymentStatus`] and the on-chain - /// [`ConfirmationStatus`] untouched. - /// - /// Funding classification runs off the broadcaster queue and can land *after* wallet sync has - /// already advanced a record's confirmation state (e.g. when the counterparty's broadcast of - /// the funding transaction is observed first). Merging only the funding-specific fields keeps - /// such a late classification from downgrading a `Confirmed`/`Succeeded` payment back to - /// `Unconfirmed`/`Pending`; the confirmation state is owned by the wallet-sync events instead. - /// - /// The txid and figures are taken from the freshly broadcast (active) candidate, so they only - /// apply while the record is unconfirmed. Once a candidate confirms, the record's txid and - /// figures describe that candidate — which need not be the one being classified (e.g. the - /// counterparty broadcast an earlier candidate and it won) — and [`PaymentDetails::update`] - /// leaves them in place for updates like this one that don't carry a confirmation state. - pub(crate) fn funding_reclassification(details: PaymentDetails) -> Self { - let mut update = Self::new(details.id); - update.amount_msat = Some(details.amount_msat); - update.fee_paid_msat = Some(details.fee_paid_msat); - if let PaymentKind::Onchain { txid, tx_type, .. } = details.kind { - update.txid = Some(txid); - update.tx_type = Some(tx_type); - } - update - } } impl From<&PaymentDetails> for PaymentDetailsUpdate { @@ -1081,418 +984,6 @@ mod tests { } } - #[test] - fn transaction_type_from_ldk_variants() { - use std::str::FromStr; - - let pubkey = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channel_id = ChannelId([5u8; 32]); - let channel = Channel { counterparty_node_id: pubkey, channel_id }; - - let variants = vec![ - ( - LdkTransactionType::Funding { channels: vec![(pubkey, channel_id)] }, - TransactionType::Funding { channels: vec![channel.clone()] }, - ), - ( - LdkTransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id }, - TransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id }, - ), - ( - LdkTransactionType::UnilateralClose { counterparty_node_id: pubkey, channel_id }, - TransactionType::UnilateralClose { counterparty_node_id: pubkey, channel_id }, - ), - ( - LdkTransactionType::AnchorBump { counterparty_node_id: pubkey, channel_id }, - TransactionType::AnchorBump { counterparty_node_id: pubkey, channel_id }, - ), - ( - LdkTransactionType::Claim { counterparty_node_id: pubkey, channel_id }, - TransactionType::Claim { counterparty_node_id: pubkey, channel_id }, - ), - ( - LdkTransactionType::Sweep { channels: vec![(pubkey, channel_id)] }, - TransactionType::Sweep { channels: vec![channel] }, - ), - ]; - - for (ldk_type, expected_type) in variants { - assert_eq!(TransactionType::from(ldk_type), expected_type); - } - } - - #[test] - fn funding_reclassification_does_not_downgrade_an_advanced_record() { - use std::str::FromStr; - - use bitcoin::hashes::Hash; - - // A splice funding payment wallet sync has already advanced to Succeeded/Confirmed. - let txid = Txid::from_byte_array([7u8; 32]); - let id = PaymentId(txid.to_byte_array()); - let tx_type = Some(TransactionType::InteractiveFunding { - channels: vec![Channel { - counterparty_node_id: PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(), - channel_id: ChannelId([3u8; 32]), - }], - }); - let advanced = PaymentDetails::new( - id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Confirmed { - block_hash: BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, - }, - tx_type: tx_type.clone(), - }, - Some(2_000_000), - Some(999), - PaymentDirection::Outbound, - PaymentStatus::Succeeded, - ); - - // A fresh funding classification for the same payment is always Pending/Unconfirmed. - let fresh = PaymentDetails::new( - id, - PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type }, - Some(1_000_000), - Some(500), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - - // The naive full update `insert_or_update` applied before the fix downgrades both the - // top-level status and the on-chain confirmation status — the bug Codex flagged. - let mut downgraded = advanced.clone(); - downgraded.update((&fresh).into()); - assert_eq!( - downgraded.status, - PaymentStatus::Pending, - "a full update from a fresh classification downgrades the top-level status", - ); - assert!( - matches!( - downgraded.kind, - PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } - ), - "a full update from a fresh classification downgrades the confirmation status", - ); - - // The narrowed reclassification update merges only the funding fields and preserves the - // advanced confirmation state that wallet sync owns. - let mut merged = advanced.clone(); - merged.update(PaymentDetailsUpdate::funding_reclassification(fresh)); - assert_eq!( - merged.status, - PaymentStatus::Succeeded, - "reclassification must not downgrade the top-level status", - ); - assert!( - matches!( - merged.kind, - PaymentKind::Onchain { - status: ConfirmationStatus::Confirmed { .. }, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } - ), - "reclassification must preserve the confirmation status and keep the funding tx_type", - ); - // The late classification names the confirmed txid, so its contribution-derived figures - // replace the record's; only an update for a different candidate leaves them in place - // (covered by `funding_reclassification_keeps_confirmed_candidate_figures`). - assert_eq!(merged.amount_msat, Some(1_000_000)); - assert_eq!(merged.fee_paid_msat, Some(500)); - } - - #[test] - fn funding_reclassification_keeps_confirmed_candidate_figures() { - use std::str::FromStr; - - use bitcoin::hashes::Hash; - - // A funding payment whose first candidate wallet sync has already seen confirm — e.g. the - // counterparty's broadcast of it was picked up before our own later candidate was - // classified. The record is unclassified (created by the sync fallthrough). - let confirmed_txid = Txid::from_byte_array([7u8; 32]); - let id = PaymentId(confirmed_txid.to_byte_array()); - let confirmed = PaymentDetails::new( - id, - PaymentKind::Onchain { - txid: confirmed_txid, - status: ConfirmationStatus::Confirmed { - block_hash: BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, - }, - tx_type: None, - }, - Some(2_000_000), - Some(999), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - - // Our own, different (e.g. fee-bumped) candidate is classified late. - let late_txid = Txid::from_byte_array([9u8; 32]); - let tx_type = Some(TransactionType::InteractiveFunding { - channels: vec![Channel { - counterparty_node_id: PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(), - channel_id: ChannelId([3u8; 32]), - }], - }); - let late = PaymentDetails::new( - id, - PaymentKind::Onchain { - txid: late_txid, - status: ConfirmationStatus::Unconfirmed, - tx_type, - }, - Some(1_000_000), - Some(500), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - - // The confirmed record's txid and figures describe the candidate that confirmed; the late - // classification must not replace them with an unconfirmed candidate's. The - // classification itself (`tx_type`) still lands. - let mut classified = confirmed.clone(); - classified.update(PaymentDetailsUpdate::funding_reclassification(late.clone())); - assert!( - matches!( - classified.kind, - PaymentKind::Onchain { - txid, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } if txid == confirmed_txid - ), - "a late classification must set the tx_type but not replace a confirmed record's txid", - ); - assert_eq!(classified.amount_msat, Some(2_000_000)); - assert_eq!(classified.fee_paid_msat, Some(999)); - - // While the record is still unconfirmed, the freshly broadcast candidate is the active - // one, so its txid and figures do replace the stored ones (RBF rotation). - let mut unconfirmed = confirmed.clone(); - if let PaymentKind::Onchain { ref mut status, .. } = unconfirmed.kind { - *status = ConfirmationStatus::Unconfirmed; - } - unconfirmed.update(PaymentDetailsUpdate::funding_reclassification(late)); - assert!( - matches!(unconfirmed.kind, PaymentKind::Onchain { txid, .. } if txid == late_txid), - "classifying a new candidate of an unconfirmed record rotates the txid", - ); - assert_eq!(unconfirmed.amount_msat, Some(1_000_000)); - assert_eq!(unconfirmed.fee_paid_msat, Some(500)); - } - - #[test] - fn funding_reclassification_merges_figures_for_the_confirmed_candidate() { - use std::str::FromStr; - - use bitcoin::hashes::Hash; - - // Wallet sync confirmed the transaction before classification ran, so the record carries - // the wallet's own view of amount/fee, which cannot represent our contribution to a shared - // funding output. - let confirmed_txid = Txid::from_byte_array([7u8; 32]); - let id = PaymentId(confirmed_txid.to_byte_array()); - let mut record = PaymentDetails::new( - id, - PaymentKind::Onchain { - txid: confirmed_txid, - status: ConfirmationStatus::Confirmed { - block_hash: BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, - }, - tx_type: None, - }, - Some(2_000_000), - Some(999), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - - // The late classification names the candidate that confirmed, so its contribution-derived - // figures are authoritative and must replace the wallet-view ones; only an update for a - // different (losing) candidate leaves a confirmed record's figures in place. - let tx_type = Some(TransactionType::InteractiveFunding { - channels: vec![Channel { - counterparty_node_id: PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(), - channel_id: ChannelId([3u8; 32]), - }], - }); - let classified = PaymentDetails::new( - id, - PaymentKind::Onchain { - txid: confirmed_txid, - status: ConfirmationStatus::Unconfirmed, - tx_type, - }, - Some(1_000_000), - Some(500), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - - assert!(record.update(PaymentDetailsUpdate::funding_reclassification(classified))); - assert!( - matches!( - record.kind, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Confirmed { .. }, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - } if txid == confirmed_txid - ), - "the confirmed txid, confirmation state, and classification must all be in place", - ); - assert_eq!(record.amount_msat, Some(1_000_000)); - assert_eq!(record.fee_paid_msat, Some(500)); - } - - #[tokio::test] - async fn funding_classification_merge_preserves_advanced_record() { - use std::str::FromStr; - use std::sync::Arc; - - use bitcoin::hashes::Hash; - use lightning::util::test_utils::TestLogger; - - use crate::data_store::{DataStore, KeepAllEntries}; - use crate::io::test_utils::InMemoryStore; - use crate::types::{DynStore, DynStoreWrapper}; - - let txid = Txid::from_byte_array([7u8; 32]); - let id = PaymentId(txid.to_byte_array()); - let tx_type = Some(TransactionType::InteractiveFunding { - channels: vec![Channel { - counterparty_node_id: PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(), - channel_id: ChannelId([3u8; 32]), - }], - }); - // A funding payment wallet sync has already recorded (unclassified, via the default - // on-chain path) and advanced to Succeeded/Confirmed. - let advanced = PaymentDetails::new( - id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Confirmed { - block_hash: BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, - }, - tx_type: None, - }, - Some(2_000_000), - Some(999), - PaymentDirection::Outbound, - PaymentStatus::Succeeded, - ); - // A fresh funding classification for the same payment is always Pending/Unconfirmed. - let fresh = PaymentDetails::new( - id, - PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type }, - Some(1_000_000), - Some(500), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - - let new_store = |seed: Vec| { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let logger = Arc::new(TestLogger::new()); - DataStore::>::new( - seed, - KeepAllEntries, - "payment_test_primary".to_string(), - "payment_test_secondary".to_string(), - store, - logger, - ) - }; - - // The pre-fix fresh-insert path — a full `insert_or_update` merge landing after a racing - // wallet sync already advanced the record — downgrades it. - let store = new_store(vec![advanced.clone()]); - store.insert_or_update(fresh.clone()).await.unwrap(); - let downgraded = store.get(&id).await.unwrap().unwrap(); - assert_eq!( - downgraded.status, - PaymentStatus::Pending, - "a full merge of a fresh classification downgrades an advanced record", - ); - - // Classification instead applies only the narrow reclassification when a record exists — - // no matter when it appeared — setting the `tx_type` while preserving the confirmation - // state wallet sync owns. The update names the confirmed txid, so its - // contribution-derived figures replace the record's wallet-view ones. - let store = new_store(vec![advanced.clone()]); - let update = PaymentDetailsUpdate::funding_reclassification(fresh.clone()); - let written = store - .mutate(&id, |existing| match existing { - Some(current) => { - let mut updated = current.clone(); - updated.update(update).then_some(updated) - }, - None => Some(fresh.clone()), - }) - .await; - assert!(matches!(written, Ok(Some(_))), "the reclassification must merge"); - let merged = store.get(&id).await.unwrap().unwrap(); - assert_eq!(merged.status, PaymentStatus::Succeeded); - assert!(matches!( - merged.kind, - PaymentKind::Onchain { - status: ConfirmationStatus::Confirmed { .. }, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } - )); - assert_eq!(merged.amount_msat, Some(1_000_000)); - assert_eq!(merged.fee_paid_msat, Some(500)); - - // And it inserts the fresh details when no record exists yet. - let store = new_store(Vec::new()); - let update = PaymentDetailsUpdate::funding_reclassification(fresh.clone()); - let written = store - .mutate(&id, |existing| match existing { - Some(current) => { - let mut updated = current.clone(); - updated.update(update).then_some(updated) - }, - None => Some(fresh.clone()), - }) - .await; - assert!(matches!(written, Ok(Some(_))), "the fresh details must insert"); - let inserted = store.get(&id).await.unwrap().unwrap(); - assert_eq!(inserted.status, PaymentStatus::Pending); - assert!(matches!( - inserted.kind, - PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } - )); - } - #[derive(Clone, Debug, PartialEq, Eq)] struct LegacyBolt11JitKind { hash: PaymentHash, diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 8b6dbeddef..f544cd13bc 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -5,245 +5,73 @@ // 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::{BTreeSet, VecDeque}; +use std::collections::VecDeque; use std::ops::Deref; -use std::sync::{Mutex as StdMutex, Weak}; +use std::sync::Mutex as StdMutex; use bitcoin::{Transaction, Txid}; use lightning::chain::chaininterface::{ BroadcasterInterface, TransactionType as LdkTransactionType, }; use tokio::sync::Notify; -use tokio::time::Instant; - -use crate::logger::{log_error, log_trace, LdkLogger}; -use crate::types::Wallet; -use crate::Error; - -/// The most packages [`BroadcastQueue`] holds, fresh and awaiting a retry together. Claims and -/// sweeps re-enter the queue on LDK's periodic rebroadcast timers, so one dropped at the bound -/// resurfaces on its own once the store recovers. Packages no timer re-broadcasts — fundings and -/// cooperative closes — are never dropped or refused for the bound, though they count toward it: -/// what LDK hands over of them is finite — one per closing channel, and one per funding under the -/// `Funding` type each time its channel resumes while it is unconfirmed (splice rounds have -/// nothing to classify, so none ever awaits a retry) — and a copy of a package awaiting a retry -/// is never queued twice. -const MAX_QUEUED_PACKAGES: usize = 256; - -/// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions` -/// call, along with each transaction's type. Queued until the background task classifies and -/// broadcasts it. Built only via [`BroadcastPackage::new`] from such a call, so unrelated -/// transactions can't be grouped into one package by accident. -pub(crate) struct BroadcastPackage(Vec<(Transaction, Option)>); -impl BroadcastPackage { - /// Builds a package from the transactions of a single `broadcast_transactions` call. - fn new(txs: &[(&Transaction, LdkTransactionType)]) -> Self { - Self(txs.iter().map(|(tx, tx_type)| ((*tx).clone(), Some(tx_type.clone()))).collect()) - } +use crate::logger::{log_trace, LdkLogger}; - /// Builds a package for wallet-originated broadcasts that have no LDK classification. - fn unclassified(tx: Transaction) -> Self { - Self(vec![(tx, None)]) - } +/// A package of transactions to broadcast together: everything LDK handed over in one +/// `broadcast_transactions` call, or a single transaction the wallet broadcasts itself. Queued +/// until the background task sends it. Built only from one such source, so unrelated transactions +/// can't be grouped into one package by accident. +pub(crate) struct BroadcastPackage(Vec); - /// The packaged transactions and their types, for classification. - fn transactions(&self) -> &[(Transaction, Option)] { - &self.0 +impl BroadcastPackage { + /// The txids of the packaged transactions, identifying the package's effect on chain. + fn txids(&self) -> Vec { + self.0.iter().map(Transaction::compute_txid).collect() } /// Consumes the package into its transactions, ready for the chain client. pub(crate) fn into_sorted_transactions(self) -> SortedTransactions { - let txs = self.0.into_iter().map(|(tx, _)| tx).collect(); - SortedTransactions::sort_parents_child_package_topologically(txs) - } - - /// The txids of the packaged transactions, identifying the package's effect on chain: two - /// packages with the same txids broadcast the same transactions. - pub(crate) fn txids(&self) -> BTreeSet { - self.0.iter().map(|(tx, _)| tx.compute_txid()).collect() - } - - /// Whether the package may be dropped to keep [`BroadcastQueue`] within its bound: every - /// transaction in it is re-broadcast by its originator, so a dropped package resurfaces on - /// its own. LDK re-hands claims, anchor bumps, and force-close commitments to the - /// broadcaster periodically, and the sweeper regenerates sweeps once per block. 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( - LdkTransactionType::Funding { .. } - | LdkTransactionType::InteractiveFunding { .. } - | LdkTransactionType::CooperativeClose { .. }, - ) => false, - Some( - LdkTransactionType::UnilateralClose { .. } - | LdkTransactionType::AnchorBump { .. } - | LdkTransactionType::Claim { .. } - | LdkTransactionType::Sweep { .. }, - ) => true, - // Wallet-originated: the wallet re-submits its unconfirmed transactions on each chain - // tip change. Classification of an untyped package is a no-op that can't fail, so one - // never awaits a retry. - None => true, - }) + SortedTransactions::sort_parents_child_package_topologically(self.0) } } -/// What [`BroadcastQueue`] did with a package, so the caller can log the cases in which the -/// package won't be classified and broadcast as-is. -pub(crate) enum QueueOutcome { - /// The package is queued. When the bound was reached, the oldest droppable package was - /// dropped to make room and is returned — its transactions resurface with LDK's next - /// periodic rebroadcast. - Queued { dropped: Option }, - /// A package broadcasting the same transactions already awaits a classification retry, and - /// that retry covers this one: the incoming package is dropped and returned. - AlreadyQueued(BroadcastPackage), - /// The bound was reached and every queued package is one that must not be dropped (a - /// funding or a cooperative close): the incoming package is refused and returned. - Refused(BroadcastPackage), -} - -/// The packages handed to the broadcaster, waiting for the background task to classify and -/// broadcast them: fresh packages in arrival order, and packages whose classification failed, -/// each waiting out a retry delay. One queue holds both, so one bound and one rule for what may -/// be dropped at it cover fresh packages and retries alike, and a re-broadcast of a package -/// awaiting a retry is recognized as it is queued rather than after one more failed attempt. -/// -/// Deduplicated and bounded: LDK re-broadcasts pending claims every 30 seconds (and sweeps once -/// per block) until they confirm, so while the store is unavailable, copies would otherwise -/// accumulate without bound and replay as a burst on recovery. An identical copy is never queued -/// while one awaits a retry — the waiting entry and its deadline stand; fee-bumped rebroadcast -/// variants carry new txids, so the bound — not the dedup — is what limits their accumulation. +/// The packages handed to the broadcaster, waiting in arrival order for the background task to +/// send them. /// -/// The queue belongs to the broadcaster and outlives the task draining it: what is queued when -/// the node stops, fresh or awaiting a retry, is classified and broadcast after the next start. +/// The queue belongs to the broadcaster and outlives the task draining it: what is queued when the +/// node stops is broadcast after the next start. pub(crate) struct BroadcastQueue { - state: StdMutex, + packages: StdMutex>, /// Wakes the draining task when a package is queued. notify: Notify, } -struct QueueState { - /// Packages not yet attempted, in arrival order. - fresh: VecDeque, - /// Packages whose classification failed, with their txids and retry deadlines. Retries are - /// scheduled with a fixed delay, so the front entry is always the next to fall due. - retries: VecDeque<(Instant, BTreeSet, BroadcastPackage)>, -} - impl BroadcastQueue { pub(crate) fn new() -> Self { - let state = QueueState { fresh: VecDeque::new(), retries: VecDeque::new() }; - Self { state: StdMutex::new(state), notify: Notify::new() } + Self { packages: StdMutex::new(VecDeque::new()), notify: Notify::new() } } - /// Queues a fresh package, unless a package with the same transactions already awaits a - /// retry or accepting it would exceed [`MAX_QUEUED_PACKAGES`] with no droppable package to - /// make room with; see [`QueueOutcome`]. - pub(crate) fn push(&self, package: BroadcastPackage) -> QueueOutcome { - self.admit(package, None) - } - - /// Queues a package whose classification failed, to be attempted again at `retry_at`, under - /// the same conditions as [`Self::push`]. - pub(crate) fn retry(&self, package: BroadcastPackage, retry_at: Instant) -> QueueOutcome { - self.admit(package, Some(retry_at)) - } - - fn admit(&self, package: BroadcastPackage, retry_at: Option) -> QueueOutcome { - let outcome = self.state.lock().expect("lock").admit(package, retry_at); - if matches!(outcome, QueueOutcome::Queued { .. }) { - self.notify.notify_one(); - } - outcome + /// Queues a package to broadcast. + pub(crate) fn push(&self, package: BroadcastPackage) { + self.packages.lock().expect("lock").push_back(package); + self.notify.notify_one(); } - /// The next package to classify and broadcast: a fresh package if any is queued, otherwise - /// the retry whose deadline has passed, waiting for one or the other when neither is ready. - /// Fresh packages go first so broadcasts arriving during a store outage are never held back - /// by the outage's retries. A retry keeps its delay regardless: without it, an otherwise idle - /// queue would retry a fast-failing store back to back, logging an error each time. + /// The next package to broadcast, waiting for one while the queue is empty. /// /// Safe to drop before completion: a package leaves the queue only as the future completes. pub(crate) async fn next(&self) -> BroadcastPackage { loop { - let next_deadline = { - let mut state = self.state.lock().expect("lock"); - if let Some(package) = state.fresh.pop_front() { - return package; - } - match state.retries.front() { - Some((deadline, _, _)) if *deadline <= Instant::now() => { - let (_, _, package) = state.retries.pop_front().expect("front entry"); - return package; - }, - Some((deadline, _, _)) => Some(*deadline), - None => None, - } - }; + if let Some(package) = self.packages.lock().expect("lock").pop_front() { + return package; + } // A package queued between the check above and the wait below is not missed: with // no task waiting, `notify_one` stores a permit that completes the next `notified`. - match next_deadline { - Some(deadline) => { - tokio::select! { - _ = self.notify.notified() => {}, - _ = tokio::time::sleep_until(deadline) => {}, - } - }, - None => self.notify.notified().await, - } + self.notify.notified().await; } } } -impl QueueState { - fn admit(&mut self, package: BroadcastPackage, retry_at: Option) -> QueueOutcome { - let txids = package.txids(); - if self.retries.iter().any(|(_, waiting, _)| *waiting == txids) { - // Same transactions, same classification outcome: keep the waiting entry and its - // earlier deadline. The one same-txid package LDK hands over under a different type, - // its re-typed generic-funding rebroadcast of a promoted 0conf splice, never meets - // the original here: an interactive-funding broadcast has nothing to classify, so it - // never awaits a retry. - return QueueOutcome::AlreadyQueued(package); - } - - let mut dropped = None; - if package.is_droppable() && self.fresh.len() + self.retries.len() >= MAX_QUEUED_PACKAGES { - // Drop the oldest droppable package, a waiting retry before a fresh package: its - // transactions are re-broadcast periodically, while the incoming package may carry - // a fresher fee-bumped variant. A funding package is never dropped — no timer would - // re-broadcast it, and it must be recorded before it is broadcast. Neither is a - // cooperative close, whose queued package may hold the only copy of the signed - // closing transaction. - dropped = self.drop_oldest_droppable(); - if dropped.is_none() { - return QueueOutcome::Refused(package); - } - } - match retry_at { - Some(retry_at) => self.retries.push_back((retry_at, txids, package)), - None => self.fresh.push_back(package), - } - QueueOutcome::Queued { dropped } - } - - fn drop_oldest_droppable(&mut self) -> Option { - if let Some(oldest) = self.retries.iter().position(|(_, _, waiting)| waiting.is_droppable()) - { - return self.retries.remove(oldest).map(|(_, _, package)| package); - } - let oldest = self.fresh.iter().position(|waiting| waiting.is_droppable())?; - self.fresh.remove(oldest) - } -} - pub(crate) struct SortedTransactions(Vec); impl SortedTransactions { @@ -292,11 +120,6 @@ where L::Target: LdkLogger, { queue: BroadcastQueue, - /// Weak handle to the [`Wallet`] that classifies funding broadcasts (channel opens and - /// splices) into payment records. Remains `None` while the builder is wiring the node up, - /// during which broadcasts are queued but no payment record is written. - /// [`Self::set_wallet`] installs the handle once the [`Wallet`] exists. - wallet: StdMutex>>, logger: L, } @@ -305,75 +128,22 @@ where L::Target: LdkLogger, { pub(crate) fn new(logger: L) -> Self { - Self { queue: BroadcastQueue::new(), wallet: StdMutex::new(None), logger } + Self { queue: BroadcastQueue::new(), logger } } - /// Installs the [`Wallet`] handle used to classify funding broadcasts (channel opens and - /// splices) into payment records. Called once the builder has constructed both the - /// broadcaster and the wallet. - pub(crate) fn set_wallet(&self, wallet: Weak) { - *self.wallet.lock().expect("lock") = Some(wallet); - } - - /// The next queued package to classify and broadcast, waiting for one when none is ready; - /// see [`BroadcastQueue::next`]. + /// The next queued package to broadcast, waiting for one when none is queued. pub(crate) async fn next_package(&self) -> BroadcastPackage { self.queue.next().await } - /// Queues a package whose classification failed, to be attempted again at `retry_at`. - pub(crate) fn retry_package(&self, package: BroadcastPackage, retry_at: Instant) { - self.log_dropped(self.queue.retry(package, retry_at)); + /// Queues a transaction the wallet broadcasts on its own behalf. + pub(crate) fn broadcast(&self, tx: Transaction) { + self.queue_package(BroadcastPackage(vec![tx])); } fn queue_package(&self, package: BroadcastPackage) { - self.log_dropped(self.queue.push(package)); - } - - fn log_dropped(&self, outcome: QueueOutcome) { - match outcome { - QueueOutcome::Queued { dropped: None } => {}, - QueueOutcome::Queued { dropped: Some(dropped) } => { - log_error!( - self.logger, - "Dropped the oldest queued package to make room; its transactions are re-broadcast periodically: {:?}", - dropped.txids(), - ); - }, - QueueOutcome::AlreadyQueued(duplicate) => { - log_trace!( - self.logger, - "Dropped a re-broadcast package; an identical one already awaits a classification retry: {:?}", - duplicate.txids(), - ); - }, - QueueOutcome::Refused(package) => { - log_error!( - self.logger, - "Dropped a package; too many packages await classification and broadcast: {:?}", - package.txids(), - ); - }, - } - } - - /// Classifies a queued package into payment records. Returns `Err` if any classification - /// 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() { - if let Some(tx_type) = tx_type { - wallet.classify_broadcast(tx, tx_type).await?; - } - } - } - Ok(()) - } - - pub(crate) fn broadcast_unclassified_transaction(&self, tx: Transaction) { - self.queue_package(BroadcastPackage::unclassified(tx)); + log_trace!(self.logger, "Queuing package for broadcast: {:?}", package.txids()); + self.queue.push(package); } } @@ -382,21 +152,16 @@ where L::Target: LdkLogger, { fn broadcast_transactions(&self, txs: &[(&Transaction, LdkTransactionType)]) { - self.queue_package(BroadcastPackage::new(txs)); + self.queue_package(BroadcastPackage(txs.iter().map(|(tx, _)| (*tx).clone()).collect())); } } #[cfg(test)] mod tests { - use std::collections::BTreeSet; - use bitcoin::hashes::Hash; use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; - use super::{ - BroadcastPackage, BroadcastQueue, LdkTransactionType, QueueOutcome, SortedTransactions, - MAX_QUEUED_PACKAGES, - }; + use super::{BroadcastPackage, BroadcastQueue, SortedTransactions}; fn txin(txid: Txid, vout: u32) -> TxIn { TxIn { @@ -538,135 +303,31 @@ mod tests { 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 due retry: `next` hands out a retry once its deadline has passed. - fn due() -> tokio::time::Instant { - tokio::time::Instant::now() - } - /// Everything `next` hands out before the queue goes quiet, in order. async fn drain(queue: &BroadcastQueue) -> Vec { let mut txids = Vec::new(); while let Ok(package) = tokio::time::timeout(std::time::Duration::from_millis(200), queue.next()).await { - txids.extend(package.txids()); + txids.extend(package.into_sorted_transactions().iter().map(Transaction::compute_txid)); } txids } - /// While a package awaits a retry, another with the same transactions is not queued, whether - /// it arrives as a retry or fresh: the waiting entry keeps its deadline and its package. - #[tokio::test] - async fn identical_transactions_are_queued_once_while_a_retry_waits() { - let tx = parent_tx(1); - let queue = BroadcastQueue::new(); - - assert!(matches!( - queue.retry(funding_package(&tx), due()), - QueueOutcome::Queued { dropped: None } - )); - assert!(matches!( - queue.retry(BroadcastPackage::unclassified(tx.clone()), deadline(4)), - QueueOutcome::AlreadyQueued(_) - )); - assert!(matches!( - queue.push(BroadcastPackage::unclassified(tx.clone())), - QueueOutcome::AlreadyQueued(_) - )); - - // The kept entry is due now; the duplicate's later deadline must not have replaced it. - let kept = tokio::time::timeout(std::time::Duration::from_secs(1), queue.next()) - .await - .expect("the waiting entry keeps its earlier deadline"); - assert!( - matches!(kept.transactions()[0].1, Some(LdkTransactionType::Funding { .. })), - "the first-scheduled package must be kept" - ); - assert!(drain(&queue).await.is_empty()); - } - - /// Fresh packages are not deduplicated against each other: two arrivals of the same - /// transactions before either is attempted are both classified, as with the channel before. - #[tokio::test] - async fn fresh_packages_are_not_deduplicated_against_each_other() { - let tx = parent_tx(1); - let queue = BroadcastQueue::new(); - - assert!(matches!(queue.push(funding_package(&tx)), QueueOutcome::Queued { dropped: None })); - assert!(matches!( - queue.push(BroadcastPackage::unclassified(tx.clone())), - QueueOutcome::Queued { dropped: None } - )); - assert_eq!(drain(&queue).await, vec![tx.compute_txid(), tx.compute_txid()]); - } - - /// Fresh packages go before due retries, each group in arrival order. + /// Every queued package is handed out, in arrival order, however often the same transaction + /// arrives. #[tokio::test] - async fn fresh_packages_come_before_due_retries() { - let (tx_a, tx_b, tx_c, tx_d) = (parent_tx(1), parent_tx(2), parent_tx(3), parent_tx(4)); + async fn packages_are_handed_out_in_arrival_order() { + let (tx_a, tx_b) = (parent_tx(1), parent_tx(2)); let queue = BroadcastQueue::new(); - assert!(matches!( - queue.retry(BroadcastPackage::unclassified(tx_a.clone()), due()), - QueueOutcome::Queued { dropped: None } - )); - assert!(matches!( - queue.retry(BroadcastPackage::unclassified(tx_b.clone()), due()), - QueueOutcome::Queued { dropped: None } - )); - assert!(matches!( - queue.push(BroadcastPackage::unclassified(tx_c.clone())), - QueueOutcome::Queued { dropped: None } - )); - assert!(matches!( - queue.push(BroadcastPackage::unclassified(tx_d.clone())), - QueueOutcome::Queued { dropped: None } - )); + queue.push(BroadcastPackage(vec![tx_a.clone()])); + queue.push(BroadcastPackage(vec![tx_b.clone()])); + queue.push(BroadcastPackage(vec![tx_a.clone()])); assert_eq!( drain(&queue).await, - vec![ - tx_c.compute_txid(), - tx_d.compute_txid(), - tx_a.compute_txid(), - tx_b.compute_txid() - ] + vec![tx_a.compute_txid(), tx_b.compute_txid(), tx_a.compute_txid()] ); } @@ -680,274 +341,17 @@ mod tests { .await .is_err()); - let (pushed, next) = tokio::join!( + let (_, next) = tokio::join!( async { tokio::time::sleep(std::time::Duration::from_millis(50)).await; - queue.push(BroadcastPackage::unclassified(tx.clone())) + queue.push(BroadcastPackage(vec![tx.clone()])); }, tokio::time::timeout(std::time::Duration::from_secs(5), queue.next()), ); - assert!(matches!(pushed, QueueOutcome::Queued { dropped: None })); - assert_eq!(next.expect("woken by the push").txids(), BTreeSet::from([tx.compute_txid()])); - } - - /// A fresh package pushed while `next` waits out a retry's delay is handed out at once; the - /// retry keeps waiting. - #[tokio::test] - async fn next_wakes_on_a_push_while_a_retry_waits() { - let (retry_tx, fresh_tx) = (parent_tx(1), parent_tx(2)); - let queue = BroadcastQueue::new(); - - assert!(matches!( - queue.retry(BroadcastPackage::unclassified(retry_tx.clone()), deadline(5)), - QueueOutcome::Queued { dropped: None } - )); - let (pushed, next) = tokio::join!( - async { - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - queue.push(BroadcastPackage::unclassified(fresh_tx.clone())) - }, - tokio::time::timeout(std::time::Duration::from_secs(2), queue.next()), - ); - assert!(matches!(pushed, QueueOutcome::Queued { dropped: None })); + let handed_out = next.expect("woken by the push").into_sorted_transactions(); assert_eq!( - next.expect("woken by the push before the retry falls due").txids(), - BTreeSet::from([fresh_tx.compute_txid()]) + handed_out.iter().map(Transaction::compute_txid).collect::>(), + vec![tx.compute_txid()], ); - assert!(drain(&queue).await.is_empty(), "the retry was handed out before its deadline"); - } - - /// `next` holds a retry back until its deadline, then hands it out on its own. - #[tokio::test] - async fn next_waits_for_a_retry_deadline() { - let tx = parent_tx(1); - let queue = BroadcastQueue::new(); - - let retry_at = tokio::time::Instant::now() + std::time::Duration::from_secs(2); - assert!(matches!( - queue.retry(BroadcastPackage::unclassified(tx.clone()), retry_at), - QueueOutcome::Queued { dropped: None } - )); - assert!(tokio::time::timeout(std::time::Duration::from_millis(500), queue.next()) - .await - .is_err()); - - let next = tokio::time::timeout(std::time::Duration::from_secs(5), queue.next()).await; - assert!(tokio::time::Instant::now() >= retry_at, "the retry was handed out early"); - assert_eq!(next.expect("due retry").txids(), BTreeSet::from([tx.compute_txid()])); - } - - /// Distinct transactions (e.g. fee-bumped claim variants during a store outage) are held to - /// the bound across fresh and waiting packages: the oldest droppable package is dropped for - /// an incoming one, a waiting retry before a fresh package and never a funding package. - #[tokio::test] - async fn bound_drops_the_oldest_droppable_retry_before_a_fresh_package() { - fn numbered_tx(n: u32) -> Transaction { - Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: bitcoin::absolute::LockTime::ZERO, - input: vec![txin(Txid::from_byte_array([7u8; 32]), n)], - output: vec![txout(1_000)], - } - } - - let queue = BroadcastQueue::new(); - let funding_tx = numbered_tx(0); - assert!(matches!( - queue.retry(funding_package(&funding_tx), due()), - QueueOutcome::Queued { dropped: None } - )); - let oldest_claim = numbered_tx(1); - let retried = MAX_QUEUED_PACKAGES as u32 / 2; - for n in 1..retried { - assert!(matches!( - queue.retry(BroadcastPackage::unclassified(numbered_tx(n)), due()), - QueueOutcome::Queued { dropped: None } - )); - } - let oldest_fresh = numbered_tx(retried); - for n in retried..(MAX_QUEUED_PACKAGES as u32) { - assert!(matches!( - queue.push(BroadcastPackage::unclassified(numbered_tx(n))), - QueueOutcome::Queued { dropped: None } - )); - } - - // At the bound, an incoming droppable package drops the oldest waiting retry — not the - // older funding package, and not a fresh package. - let new_claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); - match queue.push(BroadcastPackage::unclassified(new_claim.clone())) { - QueueOutcome::Queued { dropped: Some(dropped) } => { - assert_eq!(dropped.txids(), BTreeSet::from([oldest_claim.compute_txid()])); - }, - _ => panic!("the incoming claim must be queued by dropping the oldest one"), - } - - // An incoming funding package is never dropped for the bound. - let new_funding_tx = numbered_tx(MAX_QUEUED_PACKAGES as u32 + 1); - assert!(matches!( - queue.push(funding_package(&new_funding_tx)), - QueueOutcome::Queued { dropped: None } - )); - - let remaining = drain(&queue).await; - assert_eq!(remaining.len(), MAX_QUEUED_PACKAGES + 1); - assert!(remaining.contains(&funding_tx.compute_txid()), "funding is never dropped"); - assert!(remaining.contains(&oldest_fresh.compute_txid()), "a retry is dropped first"); - assert!(remaining.contains(&new_claim.compute_txid())); - assert!(remaining.contains(&new_funding_tx.compute_txid())); - assert!(!remaining.contains(&oldest_claim.compute_txid())); - } - - /// With no retry waiting, the bound falls on the fresh packages: the oldest droppable one - /// is dropped for an incoming one. - #[tokio::test] - async fn bound_drops_the_oldest_droppable_fresh_package() { - fn numbered_tx(n: u32) -> Transaction { - Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: bitcoin::absolute::LockTime::ZERO, - input: vec![txin(Txid::from_byte_array([11u8; 32]), n)], - output: vec![txout(1_000)], - } - } - - let queue = BroadcastQueue::new(); - let oldest = numbered_tx(0); - for n in 0..(MAX_QUEUED_PACKAGES as u32) { - assert!(matches!( - queue.push(claim_package(&numbered_tx(n))), - QueueOutcome::Queued { dropped: None } - )); - } - - let new_claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); - match queue.push(claim_package(&new_claim)) { - QueueOutcome::Queued { dropped: Some(dropped) } => { - assert_eq!(dropped.txids(), BTreeSet::from([oldest.compute_txid()])); - }, - _ => panic!("the incoming claim must be queued by dropping the oldest one"), - } - - let remaining = drain(&queue).await; - assert_eq!(remaining.len(), MAX_QUEUED_PACKAGES); - assert!(!remaining.contains(&oldest.compute_txid())); - assert_eq!(remaining.last(), Some(&new_claim.compute_txid())); - } - - /// When only funding packages are queued at the bound, an incoming droppable package is - /// refused, fresh or retried: LDK re-broadcasts claims and sweeps periodically, while a - /// dropped funding package would leave its transaction confirming without a recorded - /// candidate. - #[tokio::test] - async fn bound_refuses_a_droppable_package_over_queued_funding_packages() { - fn numbered_tx(n: u32) -> Transaction { - Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: bitcoin::absolute::LockTime::ZERO, - input: vec![txin(Txid::from_byte_array([8u8; 32]), n)], - output: vec![txout(1_000)], - } - } - - let queue = BroadcastQueue::new(); - for n in 0..(MAX_QUEUED_PACKAGES as u32) { - assert!(matches!( - queue.retry(funding_package(&numbered_tx(n)), deadline(60)), - QueueOutcome::Queued { dropped: None } - )); - } - - let claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); - assert!(matches!( - queue.push(BroadcastPackage::unclassified(claim.clone())), - QueueOutcome::Refused(_) - )); - assert!(matches!( - queue.retry(BroadcastPackage::unclassified(claim), deadline(60)), - QueueOutcome::Refused(_) - )); - } - - /// A cooperative close is never dropped at the bound: nothing re-broadcasts it, and the - /// queued package may hold the only copy of the signed closing transaction. - #[tokio::test] - async fn bound_never_drops_a_cooperative_close() { - 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 queue = BroadcastQueue::new(); - let coop_close_tx = numbered_tx(0); - assert!(matches!( - queue.retry(coop_close_package(&coop_close_tx), due()), - QueueOutcome::Queued { dropped: None } - )); - let oldest_claim = numbered_tx(1); - for n in 1..(MAX_QUEUED_PACKAGES as u32) { - assert!(matches!( - queue.retry(claim_package(&numbered_tx(n)), due()), - QueueOutcome::Queued { dropped: None } - )); - } - - // At the bound, an incoming claim drops the oldest waiting claim — not the older - // cooperative close. - let new_claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); - match queue.retry(claim_package(&new_claim), due()) { - QueueOutcome::Queued { dropped: Some(dropped) } => { - assert_eq!(dropped.txids(), BTreeSet::from([oldest_claim.compute_txid()])); - }, - _ => panic!("the incoming claim must be queued by dropping the oldest one"), - } - - // An incoming cooperative close is never dropped for the bound either. - let new_coop_close_tx = numbered_tx(MAX_QUEUED_PACKAGES as u32 + 1); - assert!(matches!( - queue.push(coop_close_package(&new_coop_close_tx)), - QueueOutcome::Queued { dropped: None } - )); - - let remaining = drain(&queue).await; - 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 are queued at the bound, an incoming claim is refused: LDK - /// re-broadcasts the claim periodically, while a dropped close would lose the only copy of - /// its signed closing transaction. - #[tokio::test] - async fn bound_refuses_a_claim_over_queued_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 queue = BroadcastQueue::new(); - for n in 0..(MAX_QUEUED_PACKAGES as u32) { - assert!(matches!( - queue.push(coop_close_package(&numbered_tx(n))), - QueueOutcome::Queued { dropped: None } - )); - } - - let claim = numbered_tx(MAX_QUEUED_PACKAGES as u32); - assert!(matches!( - queue.retry(claim_package(&claim), deadline(60)), - QueueOutcome::Refused(_) - )); } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index c06eaa528e..54d44b4e16 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -33,8 +33,7 @@ use bitcoin::{ WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::{ - ChannelFunding, FundingCandidate, FundingPurpose, TransactionType as LdkTransactionType, - INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, + ChannelFunding, FundingCandidate, FundingPurpose, INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::transaction::OutPoint as LdkOutPoint; @@ -64,7 +63,7 @@ use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed}; use crate::data_store::{StorableObject, UpdatableObject}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; use crate::logger::{log_debug, log_error, log_info, log_trace, log_warn, LdkLogger, Logger}; -use crate::payment::pending_payment_store::{PendingPaymentDetailsUpdate, SpliceIntent}; +use crate::payment::pending_payment_store::SpliceIntent; use crate::payment::store::{Channel, ConfirmationStatus, PaymentDetailsUpdate}; use crate::payment::{ FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, @@ -416,9 +415,9 @@ impl Wallet { }; // Hold the cross-store lock from payment-id resolution through the last write: - // a classification landing in between would leave the id resolved against a - // torn candidate index and the generic fallback below overwriting (or - // duplicating) the record classification just wrote. + // a funding-record write landing in between would leave the id resolved + // against a torn candidate index and the generic fallback below overwriting + // (or duplicating) the record that write had just made. let stores = self.payment_stores.lock().await; let mut payment_id = self @@ -530,8 +529,8 @@ impl Wallet { let payment_id = details.id; if new_tip.height >= height + ANTI_REORG_DELAY - 1 { // Graduate from the live record, not the snapshot listed - // above: a classification landing since then must not have - // its figures rolled back. The status-only update carries + // above: a write landing since then must not have its + // figures rolled back. The status-only update carries // no figures/txid/confirmation, so nothing a concurrent // writer wrote can be clobbered; the update machinery bumps // `latest_update_timestamp` and no-ops when the record is @@ -606,7 +605,7 @@ impl Wallet { if !txs_to_broadcast.is_empty() { let tx_count = txs_to_broadcast.len(); for tx in txs_to_broadcast { - self.broadcaster.broadcast_unclassified_transaction(tx); + self.broadcaster.broadcast(tx); } log_info!( self.logger, @@ -618,7 +617,7 @@ impl Wallet { }, WalletEvent::TxUnconfirmed { txid, tx, .. } => { // See `TxConfirmed`: id resolution and the writes below must not interleave - // with classification. + // with the funding-record writers. let stores = self.payment_stores.lock().await; let mut payment_id = self @@ -673,9 +672,9 @@ impl Wallet { }, WalletEvent::TxReplaced { txid, conflicts, .. } => { // See `TxConfirmed`: id resolution and the writes below must not interleave - // with classification. The pending entry written below embeds a read of the - // payment record, which must not go stale against a concurrent - // classification either. + // with the funding-record writers. The pending entry written below embeds a + // read of the payment record, which must not go stale against a concurrent + // write either. let stores = self.payment_stores.lock().await; let Some(payment_id) = self.find_payment_by_txid(txid).await? else { @@ -694,9 +693,8 @@ impl Wallet { conflict_txids.push(txid); // The payment already exists in the store at this point: `bump_fee_rbf` // updates the payment store with the replacement txid before the next sync - // cycle, and an id resolved through the candidate history comes from a - // classification whose payment-store write strictly precedes the candidate - // history it was resolved from. So we can safely fetch it here. + // cycle, and sync itself records a transaction the first time it observes it, + // before anything can report it replaced. So we can safely fetch it here. let stored_payment = stores.payment(&payment_id).await?; debug_assert!( stored_payment.is_some(), @@ -720,7 +718,7 @@ impl Wallet { }, WalletEvent::TxDropped { txid, tx } => { // See `TxConfirmed`: id resolution and the writes below must not interleave - // with classification. + // with the funding-record writers. let stores = self.payment_stores.lock().await; let mut payment_id = self @@ -832,12 +830,12 @@ impl Wallet { /// The id to record a transaction under that the funding-status check found foreign to the /// funding record resolved for it as `resolved_id`: its own txid-derived id, or `None` when a - /// funding record sits there already. A funding record wallet sync created before - /// classification keeps the txid-derived id of that transaction, so a wallet event for it + /// funding record sits there already. A funding record wallet sync created for a round it + /// could not attribute keeps the txid-derived id of that transaction, so a wallet event for it /// falls back to this id whenever the pending entry no longer maps it — which only happens /// once the negotiation settled and the entry was removed. The generic event handling must /// then skip its write: merging a wallet-view `Pending` payment into the settled record would - /// resurrect it with figures no classification derived. When `resolved_id` is the txid-derived + /// resurrect it with figures the negotiation never reported. When `resolved_id` is the txid-derived /// id already, the funding-status check has read that record, and finding the transaction /// foreign to it is this very case; only a fallback from a different id needs a read. async fn foreign_transaction_payment_id( @@ -1946,7 +1944,7 @@ impl Wallet { })?; let txid = tx.compute_txid(); - self.broadcaster.broadcast_unclassified_transaction(tx); + self.broadcaster.broadcast(tx); match send_amount { OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => { @@ -2245,147 +2243,6 @@ impl Wallet { Ok(tx) } - /// Classifies an on-chain broadcast handed to the broadcaster by LDK, recording a payment for it - /// before it is sent when it affects this node's wallet. - pub(crate) async fn classify_broadcast( - &self, tx: &Transaction, tx_type: &LdkTransactionType, - ) -> Result<(), Error> { - match tx_type { - LdkTransactionType::Funding { channels } => { - self.classify_funding(tx, channels, tx_type.clone().into()).await - }, - // 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 { .. } - | LdkTransactionType::Claim { .. } - | LdkTransactionType::Sweep { .. } => { - self.classify_regular_broadcast(tx, tx_type.clone().into()).await - }, - } - } - - /// Records a single-channel funding (channel open) broadcast as a pending on-chain payment, - /// tagged with its transaction type. Amount and fee come from the wallet's view of the - /// transaction. Batched funding is left for wallet sync. - async fn classify_funding( - &self, tx: &Transaction, channels: &[(PublicKey, ChannelId)], tx_type: TransactionType, - ) -> Result<(), Error> { - if channels.len() != 1 { - if channels.len() > 1 { - log_trace!( - self.logger, - "Skipping funding classification for batched broadcast ({} channels)", - channels.len() - ); - } - return Ok(()); - } - - let (_counterparty_node_id, channel_id) = channels[0]; - let txid = tx.compute_txid(); - let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); - - // 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 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 `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). - // - // TODO(https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4878): The - // re-typed re-broadcasts are upstream behavior that should be fixed in `rust-lightning`: - // the re-offer ought to keep its `InteractiveFunding` classification, or not recur at - // all. `zero_conf_splice_out_funding_rebroadcast_canary` pins the current behavior by - // asserting the log line below; when it fails against a newer LDK, re-evaluate whether - // this skip still sees traffic. - if amount_msat == Some(0) { - log_trace!( - self.logger, - "Not recording channel-funding broadcast {} as a payment: no wallet-level activity", - txid, - ); - return Ok(()); - } - - // A round this node signed is on record as an interactive funding of its channels, and - // that is what the transaction is however it is re-offered. Recording the re-offer would - // name the round a plain funding of the channel and give it the wallet's view of a funding - // output both parties own — the record wallet sync will create for it says both correctly - // — so leave the transaction to sync. A record the re-offer finds in place comes through - // unchanged either way (`funding_reclassification_update` declines the downgrade); this is - // what keeps a re-offer arriving before sync has seen the transaction from creating the - // record itself. - let named_interactive = self.channel_tx_facts(&txid).await.is_some_and(|facts| { - matches!(facts.self_role, Some(TransactionType::InteractiveFunding { .. })) - }); - if named_interactive { - log_trace!( - self.logger, - "Keeping interactive-funding classification over funding-typed rebroadcast {}", - txid, - ); - return Ok(()); - } - - // Resolution and the writes below must share one lock acquisition: resolved outside it, - // the id could go stale against a record wallet sync creates for the same transaction, - // and the write below would create a divergent record. - let stores = self.payment_stores.lock().await; - - // Adopt the id of a record that already tracks this transaction — e.g. a 0conf splice - // re-broadcast through LDK's generic funding path resolves back to its - // interactive-funding record here — otherwise generate a fresh id. - let payment_id = self.find_payment_by_txid(txid).await?.unwrap_or_else(random_payment_id); - - let details = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(tx_type), - }, - amount_msat, - fee_paid_msat, - direction, - PaymentStatus::Pending, - ); - let prior = self.persist_funding_payment_locked(&stores, details, Vec::new()).await?; - // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed - // and carrying wallet-view figures; `funding_reclassification_update` declines the - // downgrade, leaving no trace that a re-broadcast arrived. Log the arrival so tests can - // observe the traffic, from the record the write found rather than a read of our own. - if prior.is_some_and(|prior| { - matches!( - prior.kind, - PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } - ) - }) { - log_trace!( - self.logger, - "Keeping interactive-funding classification over funding-typed rebroadcast {}", - txid, - ); - } - log_debug!( - self.logger, - "Recorded channel-funding broadcast {} for channel {}", - txid, - channel_id, - ); - Ok(()) - } - /// Returns the `PaymentId` of the user-initiated splice intent the round `candidate` belongs /// to, if any, so the first recorded round of a splice adopts the id chosen at splice time /// rather than a fresh one. Only a history no record tracks yet gets here @@ -3073,189 +2930,31 @@ impl Wallet { 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( - &self, tx: &Transaction, tx_type: TransactionType, - ) -> Result<(), Error> { - let txid = tx.compute_txid(); - let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); - - if amount_msat == Some(0) && fee_paid_msat == Some(0) { - log_trace!( - self.logger, - "Not recording classified broadcast {} as a payment: no wallet-level activity", - txid, - ); - return Ok(()); - } - - let details = PaymentDetails::new( - PaymentId(txid.to_byte_array()), - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(tx_type), - }, - amount_msat, - fee_paid_msat, - direction, - PaymentStatus::Pending, - ); - self.payment_stores.lock().await.insert_or_update_payment(details).await?; - log_debug!(self.logger, "Recorded classified on-chain broadcast {}", txid); - Ok(()) - } - - /// Writes a freshly-classified funding payment to the authoritative payment store, adds a - /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`, and - /// merges the duplicate records wallet sync created for its candidates, as - /// [`Self::merge_duplicate_candidate_records`] describes. Returns the payment store's record as - /// it was before the write. - /// - /// Production callers go through [`Self::persist_funding_payment_locked`] because they resolve - /// the record's id under the same lock acquisition; this wrapper models that acquisition for - /// tests writing a record mid-flow. + /// Records a funding payment the way the node does: the rounds this node signed supply the + /// candidate history, wallet sync writes the payment record and its pending-store entry once + /// it observes the transaction, and the duplicates sync created for those rounds are merged + /// into the record. Composes that sequence for tests that need a recorded funding payment to + /// act on. #[cfg(test)] - async fn persist_funding_payment( + async fn record_funding_payment( &self, details: PaymentDetails, candidates: Vec, - ) -> 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. + ) -> Result<(), Error> { let stores = self.payment_stores.lock().await; let id = details.id; - let prior = - self.persist_funding_payment_locked(&stores, details, candidates.clone()).await?; - self.merge_duplicate_candidate_records(&stores, id, &candidates).await?; - Ok(prior) - } - - /// Writes a freshly recorded funding payment to the authoritative payment store and adds a - /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. The - /// caller holds the cross-store lock, resolving the record's id and performing both store - /// writes under one acquisition, so a funding confirmation never observes the record written - /// but the candidate history it needs still missing, and the resolved id never goes stale - /// against a concurrent sync write. Returns the payment store's record as it was before the - /// write, read inside the write's own critical section, so a caller needs no read of its own to - /// know what the write merged into. - async fn persist_funding_payment_locked( - &self, stores: &PaymentStoresGuard<'_>, 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 - // the record's state makes authoritative are merged: a full merge of the fresh - // Pending/Unconfirmed details would downgrade the confirmation state the wallet-sync - // events own. Which candidate is authoritative is equally stateful: substituting the - // confirmed candidate's figures requires seeing the confirmation. Selected from a read - // taken before the lock, the choice goes stale when a confirmation lands in between — - // the update still names the actively-broadcast candidate, the confirmed-figures guard - // then rightly refuses it, and the record is left with figures no classification derived. - let id = details.id; - let mut seen = None; - let written = stores - .mutate_payment(&id, |existing| { - let reclassification = - funding_reclassification_update(details.clone(), &candidates, existing); - seen = Some((existing.cloned(), reclassification.clone())); - match existing { - None => Some(details.clone()), - Some(current) => { - let mut updated = current.clone(); - updated.update(reclassification).then_some(updated) - }, - } - }) - .await; - // The closure runs only once the record has been read, so a write that failed before it ran - // wrote nothing. - written?; - let (prior, update) = seen.expect("the mutate closure always runs"); - - // The pending index must exist exactly while the authoritative record is Pending: - // graduation and rebroadcast read it, and a graduated payment must not be re-indexed. - // Deciding by the post-write status rather than by whether the write inserted also - // repairs a missing index — a crash or failed write between the two stores leaves a - // Pending record with no entry, and a merge alone would never recreate it, leaving the - // payment unable to graduate and its txids unmapped. - // - // The status must be read inside the pending store's critical section. Graduation writes - // `Succeeded` before removing the entry, so a read there that still observes `Pending` - // is ordered before the removal, which then also deletes anything inserted here. A - // status read taken before this write goes stale when graduation lands in between, and - // would re-index the graduated payment. - let mut leftover_intent_to_remove = None; - // The `move` closure would capture the `Option` by value, so hand it a reference; the - // borrow ends with the mutate's future, before the leftover is read below. - let leftover = &mut leftover_intent_to_remove; - stores - .mutate_pending_payment_async(&id, move |existing| async move { - // The record was written above and removal serializes on the cross-store lock held - // here, so absence means the write failed out; fall back to the fresh details. A - // promoted or (re)created entry embeds this post-write record rather than the - // fresh Unconfirmed details, so a confirmation wallet sync already recorded keeps - // driving graduation. - let recorded = stores.payment(&id).await?.unwrap_or(details); - Ok(match existing { - // First time we record this funding payment — or a crash between the two - // store writes left a Pending record with no index entry: (re)create it so - // the payment can graduate and its candidate txids stay mapped. A graduated - // payment is never `Pending`, so absence with an advanced record means the - // graduation path removed the entry and it must not be re-indexed. - None => (recorded.status == PaymentStatus::Pending).then(|| { - PendingPaymentDetails::tracked(recorded, Vec::new(), candidates, None) - }), - // An entry without a record yet — a pre-broadcast splice intent, the rounds - // an earlier signing recorded, or both — is promoted to carry this record, - // keeping everything it tracks. If the payment already advanced beyond - // `Pending` (wallet sync confirmed it through `ANTI_REORG_DELAY` first), it - // must not enter the pending store — and the splice behind the entry - // confirmed, so the leftover entry is removed below rather than left to look - // like a splice still in flight after a restart. - Some(mut entry) if entry.details().is_none() => { - if recorded.status == PaymentStatus::Pending { - entry.details = Some(recorded); - if !candidates.is_empty() { - entry.candidates = candidates; - } - Some(entry) - } else { - *leftover = entry.splice_intent; - None - } - }, - // The entry predates this write — an earlier round's recording or wallet sync - // recorded the transaction before this write (sync's arms and this write pair - // serialize on the cross-store lock, so nothing lands in between): merge only - // the funding classification (`tx_type`, candidate history and the figures of - // whichever candidate the record's state makes authoritative) into it. - Some(mut tracked) => { - let pending_update = PendingPaymentDetailsUpdate { - id, - payment_update: Some(update), - conflicting_txids: None, - candidates, - splice_intent: None, - }; - tracked.update(pending_update).then_some(tracked) - }, - }) - }) - .await?; - if let Some(intent) = leftover_intent_to_remove { - // Only remove the record while it still is the bare intent the closure saw: a fee bump - // submitted in between joins the bare record and replaces its intent, and that live - // intent must stay. + if !candidates.is_empty() { stores - .remove_pending_payment_if(&id, |record| { - record.details().is_none() - && record.candidates().is_empty() - && record.splice_intent() == Some(&intent) + .mutate_pending_payment(&id, |existing| { + let mut entry = existing.cloned().unwrap_or_else(|| { + PendingPaymentDetails::signed_rounds(id, Vec::new(), Vec::new(), None) + }); + entry.candidates = candidates.clone(); + Some(entry) }) .await?; } - Ok(prior) + stores.insert_or_update_payment(details.clone()).await?; + self.upsert_pending_payment(&stores, details, Vec::new()).await?; + self.merge_duplicate_candidate_records(&stores, id, &candidates).await } /// Merges duplicate records wallet sync created for this funding payment's candidates before @@ -3284,9 +2983,9 @@ impl Wallet { Some(duplicate) => duplicate, None => continue, }; - // Only a duplicate view of this candidate's transaction qualifies: an untyped record - // wallet sync created, or one a funding-typed rebroadcast classified onto it. Anything - // else keyed by the txid-derived id is left alone. + // Only a duplicate view of this candidate's transaction qualifies: a record wallet + // sync created for the round before it was a candidate, left untyped or named a plain + // funding. Anything else keyed by the txid-derived id is left alone. let status = match &duplicate.kind { PaymentKind::Onchain { txid, @@ -3395,7 +3094,7 @@ impl Wallet { stores .mutate_pending_payment_async(&id, move |existing| async move { // Only `Pending` payments belong in the pending store. Like in - // [`Self::persist_funding_payment`], the authoritative status is re-read inside + // [`Self::upsert_pending_payment`], the authoritative status is re-read inside // the store's critical section, where it cannot go stale against graduation. let is_pending = stores .payment(&id) @@ -3435,7 +3134,7 @@ impl Wallet { /// would ever clean it up, since graduation only removes entries whose record is still live. pub(crate) async fn remove_payment(&self, payment_id: &PaymentId) -> Result<(), Error> { // Hold the cross-store lock so the two-store removal cannot interleave with a sync arm's - // or classification's resolve-then-write sequence. The pending entry goes first: a failure + // or a funding-record writer's resolve-then-write sequence. The pending entry goes first: a failure // in between then leaves an unindexed record (benign, and the retry removes it) rather // than an entry indexing a removed record. let stores = self.payment_stores.lock().await; @@ -3485,10 +3184,10 @@ impl Wallet { } // The pending store only indexes in-flight records — graduation removes the entry — so a - // graduated record's transaction resolves through the payment store itself. Without this, a - // funding-typed broadcast classified after graduation (e.g. LDK re-broadcasting a promoted - // 0conf splice whose confirmation landed while the node was offline) would create a - // duplicate record, and a post-graduation reorg's events would never reach the record. + // graduated record's transaction resolves through the payment store itself. Without this, + // a wallet event naming a graduated record's transaction — a post-graduation reorg, or + // the first sight of a transaction whose confirmation landed while the node was offline — + // would miss the record and create a duplicate under the transaction's own id. let mut page_token = None; loop { let page = self.payment_stores.payments_page(page_token).await?; @@ -3516,8 +3215,8 @@ impl Wallet { /// Graduation to `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. /// /// The caller must hold the [`PaymentStores`] lock — from resolving `payment_id` - /// through its own last write, not just across this call — so that classification's two-store - /// write pair cannot interleave with the caller's decision sequence. The `stores` guard + /// through its own last write, not just across this call — so that a funding-record writer's + /// two-store write pair cannot interleave with the caller's decision sequence. The `stores` guard /// proves the lock is held across this call; the rest of that contract is the caller's. async fn apply_funding_status_update_locked( &self, stores: &PaymentStoresGuard<'_>, payment_id: PaymentId, event_txid: Txid, @@ -3525,8 +3224,8 @@ impl Wallet { ) -> Result { // The caller's wallet-level lock keeps the candidate history stable while we await its // 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 + // store's mutation lock: against a separate payment `get`, a funding-record write merging + // in between would have its `tx_type` and contribution figures clobbered by this stale // snapshot. let pending_payment = stores.pending_payment(&payment_id).await?; let mut outcome = FundingStatusUpdate::NotFunding; @@ -3855,7 +3554,7 @@ impl Wallet { stores.insert_or_update_payment(new_payment.clone()).await?; self.upsert_pending_payment(&stores, new_payment, Vec::new()).await?; - self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx); + self.broadcaster.broadcast(fee_bumped_tx); log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid); @@ -4362,65 +4061,9 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { ) } -/// Builds the payment-store update for a freshly classified funding payment. `details` describes -/// the actively broadcast candidate, but when the record already confirmed a *different* -/// candidate — wallet sync saw it win before this classification ran — the update instead carries -/// the confirmed candidate's txid and figures from the candidate history, mirroring what -/// [`Wallet::apply_funding_status_update_locked`] reports when confirmation arrives after -/// classification. -/// -/// `current` is the record as observed inside the payment store's `mutate` critical section — its -/// sole caller, [`Wallet::persist_funding_payment_locked`], builds and applies the update within -/// one closure — so the candidate choice cannot go stale against a concurrent confirmation before -/// the update lands. [`PaymentDetails::update`]'s confirmed-figures rule still arbitrates which -/// figures may land on the record. -fn funding_reclassification_update( - details: PaymentDetails, candidates: &[FundingTxCandidate], current: Option<&PaymentDetails>, -) -> PaymentDetailsUpdate { - // A funding-typed classification of a record already classified as interactive funding is a - // downgrade, not news: LDK re-broadcasts a promoted-but-unconfirmed splice through its - // generic funding path, where the figures are wallet-view rather than contribution-derived. - // Keep the record as classified; wallet-sync events own its confirmation state. - // - // TODO(https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4878): The - // re-typed re-broadcasts are upstream behavior that should be fixed in `rust-lightning`: - // the re-offer ought to keep its `InteractiveFunding` classification, or not recur at all. - // `zero_conf_splice_in_funding_rebroadcast_canary` pins the current behavior via the - // arrival log in `classify_funding`; when it fails against a newer LDK, re-evaluate - // whether this guard still sees traffic. - if let ( - Some(PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - }), - PaymentKind::Onchain { tx_type: Some(TransactionType::Funding { .. }), .. }, - ) = (current.map(|payment| &payment.kind), &details.kind) - { - return PaymentDetailsUpdate::new(details.id); - } - - let mut update = PaymentDetailsUpdate::funding_reclassification(details); - if let Some(PaymentKind::Onchain { - txid: confirmed_txid, - status: ConfirmationStatus::Confirmed { .. }, - .. - }) = current.map(|payment| &payment.kind) - { - if update.txid != Some(*confirmed_txid) { - if let Some(candidate) = candidates.iter().find(|c| c.txid == *confirmed_txid) { - update.txid = Some(candidate.txid); - update.amount_msat = Some(candidate.amount_msat); - update.fee_paid_msat = Some(candidate.fee_paid_msat); - } - } - } - update -} - #[cfg(all(test, any(feature = "chain-esplora", feature = "chain-electrum")))] mod tests { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - use std::time::Duration; use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate}; use bdk_wallet::Wallet as BdkWallet; @@ -4447,8 +4090,8 @@ mod tests { PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; use crate::payment::pending_payment_store::{ - test_funding_contribution_with_outputs, test_funding_contribution_with_parts, SpliceIntent, - SpliceKind, + test_funding_contribution_with_outputs, test_funding_contribution_with_parts, + PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind, }; use crate::types::{DynStore, DynStoreWrapper}; @@ -4842,7 +4485,7 @@ mod tests { awaiting_broadcast: false, }]; wallet - .persist_funding_payment(funding_payment(id, txid, PaymentStatus::Pending), candidates) + .record_funding_payment(funding_payment(id, txid, PaymentStatus::Pending), candidates) .await .unwrap(); @@ -4859,44 +4502,6 @@ mod tests { assert!(record.splice_intent().is_some()); } - #[tokio::test] - async fn recording_a_round_removes_the_intent_record_of_an_advanced_payment() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - - let id = PaymentId([23u8; 32]); - let txid = Txid::from_byte_array([24u8; 32]); - wallet - .payment_stores - .pending_payment_store() - .insert(PendingPaymentDetails::pending_splice(id, test_splice_intent())) - .await - .unwrap(); - // Wallet sync confirmed the payment through `ANTI_REORG_DELAY` before the record was written: - // the payment graduated, so the record must not enter the pending store... - wallet - .payment_stores - .payment_store() - .insert(funding_payment(id, txid, PaymentStatus::Succeeded)) - .await - .unwrap(); - - let candidates = vec![FundingTxCandidate { - txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }]; - wallet - .persist_funding_payment(funding_payment(id, txid, PaymentStatus::Pending), candidates) - .await - .unwrap(); - - // ...and the splice behind the intent confirmed, so the leftover intent record is removed - // rather than left to look like a splice still in flight after a restart. - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); - } - #[tokio::test] async fn refill_publishes_addresses_only_after_their_reveal_is_persisted() { let fail_store = FailSwitchStore::new(); @@ -6759,43 +6364,6 @@ mod tests { 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))]); - sign_and_observe_round(&wallet, &tx, &candidates).await; - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - let record = - wallet.payment_stores.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_stores.payment_store().get(&id).await.unwrap(), Some(payment)); - assert_eq!( - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), - Some(record) - ); - } - /// A replayed `SpliceNegotiated` event names a round already marked broadcast; nothing is /// written. #[tokio::test] @@ -7934,115 +7502,6 @@ mod tests { 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]); - let active_txid = Txid::from_byte_array([2u8; 32]); - let candidates = vec![ - FundingTxCandidate { - 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); - - // The record confirmed an earlier candidate: the update reports that candidate, not the - // active one. - let current = onchain_details(confirmed_txid, confirmed_status()); - let update = funding_reclassification_update(details.clone(), &candidates, Some(¤t)); - assert_eq!(update.txid, Some(confirmed_txid)); - assert_eq!(update.amount_msat, Some(Some(2_000_000))); - assert_eq!(update.fee_paid_msat, Some(Some(999))); - - // A confirmed candidate we did not contribute to still substitutes, with empty figures — - // the same figures a confirmation arriving after classification would report. - let uncontributed = vec![FundingTxCandidate { - txid: confirmed_txid, - amount_msat: None, - fee_paid_msat: None, - awaiting_broadcast: false, - }]; - let update = - funding_reclassification_update(details.clone(), &uncontributed, Some(¤t)); - assert_eq!(update.txid, Some(confirmed_txid)); - assert_eq!(update.amount_msat, Some(None)); - assert_eq!(update.fee_paid_msat, Some(None)); - } - - #[test] - fn funding_reclassification_update_keeps_the_active_candidate() { - let active_txid = Txid::from_byte_array([2u8; 32]); - let candidates = vec![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); - - // No record yet: the update describes the active candidate. - let update = funding_reclassification_update(details.clone(), &candidates, None); - assert_eq!(update.txid, Some(active_txid)); - assert_eq!(update.amount_msat, Some(Some(1_000_000))); - - // An unconfirmed record: still the active candidate (RBF rotation). - let unconfirmed = - onchain_details(Txid::from_byte_array([1u8; 32]), ConfirmationStatus::Unconfirmed); - let update = - funding_reclassification_update(details.clone(), &candidates, Some(&unconfirmed)); - assert_eq!(update.txid, Some(active_txid)); - - // The record confirmed the active candidate itself: nothing to substitute. - let current = onchain_details(active_txid, confirmed_status()); - let update = funding_reclassification_update(details.clone(), &candidates, Some(¤t)); - assert_eq!(update.txid, Some(active_txid)); - assert_eq!(update.amount_msat, Some(Some(1_000_000))); - - // A confirmed txid outside the candidate history (e.g. the record is an unrelated - // same-id payment): fall back to the active candidate; `PaymentDetails::update` keeps - // the confirmed figures in place on mismatch. - let foreign = onchain_details(Txid::from_byte_array([9u8; 32]), confirmed_status()); - let update = funding_reclassification_update(details, &candidates, Some(&foreign)); - assert_eq!(update.txid, Some(active_txid)); - } - - /// A funding-typed (re)classification of a record already classified as interactive funding - /// carries nothing the record doesn't have — LDK re-broadcasts a promoted-but-unconfirmed - /// splice through its generic funding path with wallet-view figures — so the update must - /// move nothing. - #[test] - fn funding_reclassification_update_skips_funding_over_interactive_funding() { - let txid = Txid::from_byte_array([1u8; 32]); - let payment_id = PaymentId(txid.to_byte_array()); - let current = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); - - let rebroadcast = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(TransactionType::Funding { channels: vec![] }), - }, - Some(10_000_000), - Some(0), - PaymentDirection::Inbound, - PaymentStatus::Pending, - ); - - let update = funding_reclassification_update(rebroadcast, &[], Some(¤t)); - let mut updated = current.clone(); - assert!(!updated.update(update), "the rebroadcast must not move the record"); - assert_eq!(updated, current); - } - /// Graduation must decide from the live record and write only the status: a pending-store /// snapshot taken before a concurrent classification landed must not roll the record's /// figures back when the payment graduates to `Succeeded`. @@ -8347,7 +7806,7 @@ mod tests { }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet.record_funding_payment(details, candidates).await.unwrap(); // Sync saw the close double-spend the splice's funding transaction. wallet @@ -8454,7 +7913,7 @@ mod tests { }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet.record_funding_payment(details, candidates).await.unwrap(); // Each close was recorded at broadcast, seen unconfirmed, then replaced by the round: // what the `TxReplaced` arm leaves behind. @@ -8566,7 +8025,7 @@ mod tests { }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet.record_funding_payment(details, candidates).await.unwrap(); wallet .payment_stores .pending_payment_store() @@ -8640,7 +8099,7 @@ mod tests { ]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet.record_funding_payment(details, candidates).await.unwrap(); wallet .payment_stores .pending_payment_store() @@ -8694,7 +8153,7 @@ mod tests { }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet.record_funding_payment(details, candidates).await.unwrap(); wallet .payment_stores .pending_payment_store() @@ -8762,7 +8221,7 @@ mod tests { ]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet.record_funding_payment(details, candidates).await.unwrap(); wallet .payment_stores .pending_payment_store() @@ -8906,27 +8365,6 @@ mod tests { ); } - /// Classifying a channel-open funding the payment store does not know costs one read of it: - /// the write merges against the record it reads, and whether a promoted splice's re-broadcast - /// met its record is told from that same read. - #[tokio::test] - async fn unknown_funding_broadcast_is_recorded_after_one_payment_store_read() { - let counting_store = ReadCountingStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - - let tx = wallet_paying_tx(&wallet, 1); - let tx_type = - LdkTransactionType::Funding { channels: vec![(counterparty_node_id, channel_id)] }; - let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); - wallet.classify_broadcast(&tx, &tx_type).await.unwrap(); - let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; - - assert!(wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap().is_some()); - assert_eq!(reads, 1, "classifying an unknown funding re-read the payment store"); - } - /// Recording a transaction the payment store does not know costs two reads of it: the /// funding-status check looks the resolved id up, and the generic write merges against the /// store. Nothing in between re-reads what the funding-status check has already seen. @@ -9080,7 +8518,7 @@ mod tests { 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.record_funding_payment(details, candidates).await.unwrap(); wallet .payment_stores .pending_payment_store() @@ -9116,784 +8554,21 @@ mod tests { .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 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. + /// Wallet sync can record a genuine replacement round before it is recorded as a candidate: + /// the counterparty broadcast a round this node did not contribute to, which is recorded only + /// when this node signs a later round of the splice. The funding-status gate then routes the + /// round's confirmation to a duplicate record keyed by the round's txid, whose pending entry + /// shadows the funding record in `find_payment_by_txid`'s direct probe. Once the round is + /// recorded as a candidate, the write must merge the duplicate — adopt its confirmation and + /// remove it — so a single record tracks the splice. #[tokio::test] - async fn funding_broadcast_without_wallet_activity_is_not_recorded() { + async fn recording_a_round_merges_duplicate_records_for_its_candidates() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(store, false).await; - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; - let tx_type = TransactionType::Funding { channels: vec![] }; - - // No inputs or outputs involve the wallet: nothing to record. - wallet.classify_funding(&dummy_tx(), &channels, tx_type.clone()).await.unwrap(); - assert!(wallet - .payment_stores - .payment_store() - .list_page(None) - .await - .unwrap() - .objects - .is_empty()); - assert!(wallet - .payment_stores - .pending_payment_store() - .list_filter(|_| true) - .await - .is_empty()); - - // A computable fee is not wallet participation. The wallet can resolve a splice's shared - // input whenever the previous funding transaction touched it (e.g. it funded the original - // channel open), so it derives the splice's fee even when no wallet funds move. - let prev_funding_outpoint = OutPoint { txid: Txid::from_byte_array([8u8; 32]), vout: 0 }; - wallet.inner.lock().unwrap().insert_txout( - prev_funding_outpoint, - TxOut { value: Amount::from_sat(100_000), script_pubkey: ScriptBuf::new() }, - ); - let splice_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: vec![bitcoin::TxIn { - previous_output: prev_funding_outpoint, - ..Default::default() - }], - output: vec![TxOut { - value: Amount::from_sat(99_000), - script_pubkey: ScriptBuf::new(), - }], - }; - wallet.classify_funding(&splice_tx, &channels, tx_type.clone()).await.unwrap(); - assert!(wallet - .payment_stores - .payment_store() - .list_page(None) - .await - .unwrap() - .objects - .is_empty()); - - // Control: a funding transaction the wallet participates in is still recorded. - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let funded_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - }; - wallet.classify_funding(&funded_tx, &channels, tx_type).await.unwrap(); - let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1); - match &payments[0].kind { - PaymentKind::Onchain { txid, .. } => assert_eq!(*txid, funded_tx.compute_txid()), - kind => panic!("unexpected kind {:?}", kind), - } - } - - /// A funding record's PaymentId is generated at record creation instead of being derived from a - /// txid: a replaceable transaction's txid is no stable identity for the record. Every lookup - /// resolves the record through its txid history (current txid, candidates, conflicts) rather - /// than re-deriving the id, so nothing may rely on the id and the txid coinciding. - #[tokio::test] - async fn funding_record_is_keyed_by_a_generated_id() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; - let tx_type = TransactionType::Funding { channels: vec![] }; - - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let funded_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - }; - let txid = funded_tx.compute_txid(); - wallet.classify_funding(&funded_tx, &channels, tx_type).await.unwrap(); - - let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1); - let record = &payments[0]; - assert_ne!(record.id, PaymentId(txid.to_byte_array()), "the id must not be the txid"); - match &record.kind { - PaymentKind::Onchain { txid: kind_txid, .. } => assert_eq!(*kind_txid, txid), - kind => panic!("unexpected kind {:?}", kind), - } - // The pending entry shares the id, and txid lookups resolve to the record. - assert!(wallet - .payment_stores - .pending_payment_store() - .get(&record.id) - .await - .unwrap() - .is_some()); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(record.id)); - } - - /// A funding transaction classified again — e.g. a 0conf splice re-broadcast through LDK's - /// generic funding path after a restart — must resolve to the record's generated id rather - /// than create a second record for the same transaction. - #[tokio::test] - async fn funding_rebroadcast_resolves_to_the_generated_id() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; - - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let funded_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - }; - let txid = funded_tx.compute_txid(); - - // The interactive-funding record written when the round was signed, keyed by a generated - // id. - let payment_id = PaymentId([42u8; 32]); - let candidates = vec![FundingTxCandidate { - txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }]; - let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); - - // The re-typed rebroadcast comes back through the generic funding path. - wallet - .classify_funding(&funded_tx, &channels, TransactionType::Funding { channels: vec![] }) - .await - .unwrap(); - - let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1, "the rebroadcast must not create a second record"); - assert_eq!(payments[0].id, payment_id); - // The interactive classification and contribution figures survive the generic - // wallet-view update (`funding_reclassification_update` declines the downgrade). - assert!(matches!( - payments[0].kind, - PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } - )); - assert_eq!(payments[0].amount_msat, Some(1_000_000)); - } - - /// The same re-broadcast arriving before wallet sync has seen the transaction finds no record - /// to be declined against. The round is on record as an interactive funding of its channel, - /// which is what the transaction is whichever path re-offers it, so the re-offer must record - /// nothing and leave the transaction to sync. - #[tokio::test] - async fn a_funding_rebroadcast_of_a_named_round_records_nothing() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let channels = vec![(counterparty_node_id, channel_id)]; - - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let funded_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - }; - let txid = funded_tx.compute_txid(); - - wallet - .record_channel_tx_facts(ChannelTxFacts::new(txid).with_self_role( - TransactionType::InteractiveFunding { - channels: vec![Channel { counterparty_node_id, channel_id }], - }, - )) - .await - .unwrap(); - - wallet - .classify_funding(&funded_tx, &channels, TransactionType::Funding { channels: vec![] }) - .await - .unwrap(); - - let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - assert!( - payments.is_empty(), - "the re-offer recorded the round as a plain funding: {:?}", - payments, - ); - } - - /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding - /// path: same txid, but typed as a plain funding transaction with wallet-view figures and no - /// contribution metadata. The rebroadcast must not overwrite the contribution-derived - /// figures or the interactive-funding classification — neither while the record is - /// unconfirmed nor once it confirmed under that same txid, where updates naming the - /// confirmed txid may otherwise move figures. - #[tokio::test] - async fn funding_rebroadcast_keeps_interactive_funding_classification() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - // The rebroadcast passes the wallet-activity guard: a splice-in funds the new channel - // output partly from the wallet, so the wallet sees movement. - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - }; - let txid = tx.compute_txid(); - let payment_id = PaymentId(txid.to_byte_array()); - - let candidates = vec![FundingTxCandidate { - txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }]; - let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); - - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; - let tx_type = TransactionType::Funding { channels: vec![] }; - - async fn assert_unchanged(wallet: &Wallet, payment_id: PaymentId, confirmed: bool) { - let payments = - wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1, "the rebroadcast must not mint a second record"); - let payment = &payments[0]; - assert_eq!(payment.id, payment_id); - assert_eq!(payment.amount_msat, Some(1_000_000)); - assert_eq!(payment.fee_paid_msat, Some(500)); - match &payment.kind { - PaymentKind::Onchain { - status, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } => assert_eq!(matches!(status, ConfirmationStatus::Confirmed { .. }), confirmed), - kind => panic!("unexpected kind {:?}", kind), - } - } - - wallet.classify_funding(&tx, &channels, tx_type.clone()).await.unwrap(); - assert_unchanged(&wallet, payment_id, false).await; - - // Confirm the record, then replay the rebroadcast: a monitor-update completion can race - // wallet sync around confirmation. - let event = WalletEvent::TxConfirmed { - txid, - tx: Arc::new(tx.clone()), - block_time: confirmed_block_time(5), - old_block_time: None, - }; - wallet.update_payment_store(vec![event]).await.unwrap(); - wallet.classify_funding(&tx, &channels, tx_type).await.unwrap(); - assert_unchanged(&wallet, payment_id, true).await; - } - - /// A user-initiated splice's record is keyed by the PaymentId chosen at splice time, not by - /// its funding txid. The generic funding path must resolve a rebroadcast of that funding tx - /// back to the existing record rather than creating a duplicate under the txid-derived id. - #[tokio::test] - async fn classify_funding_resolves_the_splice_time_payment_id() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - }; - let txid = tx.compute_txid(); - - let payment_id = PaymentId([21u8; 32]); - let candidates = vec![FundingTxCandidate { - txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }]; - let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); - - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; - let tx_type = TransactionType::Funding { channels: vec![] }; - wallet.classify_funding(&tx, &channels, tx_type).await.unwrap(); - - let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1, "the rebroadcast must not create a second record"); - assert_eq!(payments[0].id, payment_id); - assert_eq!(payments[0].amount_msat, Some(1_000_000)); - assert_eq!(payments[0].fee_paid_msat, Some(500)); - } - - /// A funding broadcast whose classification fails must be retried, not dropped: no timer - /// re-broadcasts a funding transaction, so a dropped package would keep the funding off-chain - /// until LDK re-hands it when the channel next resumes. The record is written before the - /// 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; - - 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_stores - .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_stores.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 survives a stop, as a package the loop has not - /// reached yet always has: the queue belongs to the broadcaster, not to the loop, so the next - /// `start()` classifies and broadcasts whatever was queued when the node stopped. A funding - /// package in particular has no other way back: no timer re-broadcasts it. - #[tokio::test] - async fn packages_awaiting_retry_survive_a_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 - }); - - // The restarted loop classifies the package from before the stop once its retry falls - // due. - let mut payments = Vec::new(); - for _ in 0..100 { - tokio::time::sleep(Duration::from_millis(100)).await; - payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - if !payments.is_empty() { - break; - } - } - assert_eq!( - payments.len(), - 1, - "the package from before stop() was not classified after restart" - ); - assert!( - matches!(&payments[0].kind, PaymentKind::Onchain { txid, .. } if *txid == tx.compute_txid()), - "the record does not track the package's transaction" - ); - - stop_sender.send(()).unwrap(); - loop_task.await.unwrap(); - } - - /// A re-broadcast of a package awaiting a classification retry is dropped as it is queued, - /// without another classification attempt: LDK re-hands pending claims every 30 seconds, so - /// over a store outage each copy would otherwise cost a failed write and an error log before - /// the queue recognized it. - #[tokio::test] - async fn rebroadcast_of_a_waiting_package_is_not_classified_again() { - use lightning::chain::chaininterface::BroadcasterInterface; - - let fail_store = FailSwitchStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.broadcaster.set_wallet(Arc::downgrade(&wallet)); - - let (stop_sender, stop_receiver) = tokio::sync::watch::channel(()); - let chain_source = Arc::clone(&wallet.chain_source); - let loop_task = tokio::spawn(async move { - chain_source.continuously_process_broadcast_queue(stop_receiver).await - }); - - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - }; - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let funding_type = LdkTransactionType::Funding { - channels: vec![(counterparty_node_id, ChannelId([7u8; 32]))], - }; - - // The first copy fails classification and waits for its retry. - fail_store.fail_writes.store(true, Ordering::Release); - wallet.broadcaster.broadcast_transactions(&[(&tx, funding_type.clone())]); - let mut failed_writes = 0; - for _ in 0..100 { - tokio::time::sleep(Duration::from_millis(100)).await; - failed_writes = fail_store.failed_writes.load(Ordering::Acquire); - if failed_writes > 0 { - break; - } - } - assert_eq!(failed_writes, 1, "classification never attempted a payment-store write"); - - // A second copy arrives well within the retry delay. It must not reach the store. - wallet.broadcaster.broadcast_transactions(&[(&tx, funding_type)]); - tokio::time::sleep(Duration::from_millis(500)).await; - assert_eq!( - fail_store.failed_writes.load(Ordering::Acquire), - 1, - "a re-broadcast of a package awaiting a retry was classified again" - ); - - // Once the store recovers, the waiting package is classified once. - fail_store.fail_writes.store(false, Ordering::Release); - let mut payments = Vec::new(); - for _ in 0..100 { - tokio::time::sleep(Duration::from_millis(100)).await; - payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - if !payments.is_empty() { - break; - } - } - assert_eq!(payments.len(), 1, "the waiting package was not classified after recovery"); - - stop_sender.send(()).unwrap(); - loop_task.await.unwrap(); - } - - /// Fresh packages go before a due retry. Both are ready at once when the loop returns from a - /// slow classification with fresh packages queued and a retry past its deadline; the queue - /// hands out every fresh package first, so broadcasts arriving during a store outage are never - /// held back by the outage's retries. - #[tokio::test] - async fn fresh_package_is_classified_before_a_due_retry() { - use lightning::chain::chaininterface::BroadcasterInterface; - - use crate::data_store::StorableObjectId; - - let gated_store = GatedStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(gated_store.clone())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.broadcaster.set_wallet(Arc::downgrade(&wallet)); - - let (stop_sender, stop_receiver) = tokio::sync::watch::channel(()); - let chain_source = Arc::clone(&wallet.chain_source); - let loop_task = tokio::spawn(async move { - chain_source.continuously_process_broadcast_queue(stop_receiver).await - }); - - // Three funding transactions paying the wallet, so each classification reaches the - // payment-store write. - let funding_tx = |input_byte: u8| { - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: vec![bitcoin::TxIn { - previous_output: bitcoin::OutPoint { - txid: Txid::from_byte_array([input_byte; 32]), - vout: 0, - }, - ..Default::default() - }], - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - } - }; - let retried_tx = funding_tx(1); - let parked_tx = funding_tx(2); - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let funding_type = LdkTransactionType::Funding { - channels: vec![(counterparty_node_id, ChannelId([7u8; 32]))], - }; - - // The first package fails classification and is scheduled to retry after the delay. - gated_store.fail_writes.store(true, Ordering::Release); - wallet.broadcaster.broadcast_transactions(&[(&retried_tx, funding_type.clone())]); - let mut failed_writes = 0; - for _ in 0..100 { - tokio::time::sleep(Duration::from_millis(100)).await; - failed_writes = gated_store.failed_writes.load(Ordering::Acquire); - if failed_writes > 0 { - break; - } - } - assert!(failed_writes > 0, "classification never attempted a payment-store write"); - - // The second package parks in its payment-store write, holding the loop past the retry's - // deadline. - gated_store.fail_writes.store(false, Ordering::Release); - gated_store.gate_writes.store(true, Ordering::Release); - wallet.broadcaster.broadcast_transactions(&[(&parked_tx, funding_type.clone())]); - gated_store.write_entered.notified().await; - - // Fresh packages queue while the retry falls due: several, so a queue that only sometimes - // hands out a fresh package ahead of a due retry cannot pass the ordering assertion below - // by luck. - let fresh_txs: Vec = - (3..11).map(|input_byte| funding_tx(input_byte)).collect(); - for fresh_tx in &fresh_txs { - wallet.broadcaster.broadcast_transactions(&[(fresh_tx, funding_type.clone())]); - } - tokio::time::sleep(crate::chain::FAILED_CLASSIFY_RETRY_DELAY + Duration::from_millis(500)) - .await; - - // Once the parked package completes, the fresh packages and the due retry are all ready. - gated_store.gate_writes.store(false, Ordering::Release); - gated_store.release.notify_one(); - let expected_writes = fresh_txs.len() + 2; - let mut payment_writes = Vec::new(); - let mut payments = Vec::new(); - for _ in 0..100 { - tokio::time::sleep(Duration::from_millis(100)).await; - payment_writes = gated_store.written_keys(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); - payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - if payment_writes.len() >= expected_writes && payments.len() >= expected_writes { - break; - } - } - assert_eq!(payment_writes.len(), expected_writes, "not every package was classified"); - // A funding record's id is its own, so find each transaction's record to learn the key - // its write went under. - let position = |tx: &Transaction| { - let txid = tx.compute_txid(); - let record = payments - .iter() - .find(|payment| { - matches!(payment.kind, PaymentKind::Onchain { txid: recorded, .. } if recorded == txid) - }) - .expect("classified"); - let key = record.id.encode_to_hex_str(); - payment_writes.iter().position(|written| *written == key).expect("classified") - }; - let retried_position = position(&retried_tx); - assert!( - fresh_txs.iter().all(|fresh_tx| position(fresh_tx) < retried_position), - "the due retry was classified before a fresh package" - ); - - stop_sender.send(()).unwrap(); - loop_task.await.unwrap(); - } - - /// Wallet sync can record a genuine replacement round before it is recorded as a candidate: - /// the counterparty broadcast a round this node did not contribute to, which is recorded only - /// when this node signs a later round of the splice. The funding-status gate then routes the - /// round's confirmation to a duplicate record keyed by the round's txid, whose pending entry - /// shadows the funding record in `find_payment_by_txid`'s direct probe. Once the round is - /// recorded as a candidate, the write must merge the duplicate — adopt its confirmation and - /// remove it — so a single record tracks the splice. - #[tokio::test] - async fn recording_a_round_merges_duplicate_records_for_its_candidates() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - let funding_id = PaymentId([21u8; 32]); - let txid1 = Txid::from_byte_array([1u8; 32]); - let txid2 = Txid::from_byte_array([2u8; 32]); + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); // Round 1 recorded normally. let round1 = vec![FundingTxCandidate { @@ -9903,7 +8578,7 @@ mod tests { awaiting_broadcast: false, }]; let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, round1).await.unwrap(); + wallet.record_funding_payment(details, round1).await.unwrap(); // Wallet sync recorded round 2's confirmation while the round was not yet a candidate: a // duplicate untyped record under the txid-derived id, plus its pending entry. @@ -9941,7 +8616,7 @@ mod tests { }, ]; let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); - wallet.persist_funding_payment(details, rounds).await.unwrap(); + wallet.record_funding_payment(details, rounds).await.unwrap(); // One record: the funding record carries the duplicate's confirmation and the confirmed // candidate's figures; the duplicate and its pending entry are gone, so the round's txid @@ -10020,7 +8695,7 @@ mod tests { }, ]; let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); - wallet.persist_funding_payment(details, rounds).await.unwrap(); + wallet.record_funding_payment(details, rounds).await.unwrap(); let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1, "the duplicate must be merged away"); @@ -10062,7 +8737,7 @@ mod tests { awaiting_broadcast: false, }]; let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, round1).await.unwrap(); + wallet.record_funding_payment(details, round1).await.unwrap(); // Wallet sync recorded round 2's confirmation while the round was not yet a candidate. let duplicate_id = PaymentId(txid2.to_byte_array()); @@ -10099,11 +8774,11 @@ mod tests { ]; let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); - let res = wallet.persist_funding_payment(details.clone(), rounds.clone()).await; + let res = wallet.record_funding_payment(details.clone(), rounds.clone()).await; assert!(res.is_err(), "the injected remove failure must surface"); // The merge re-runs with the record's next write; it must finish the cleanup. - wallet.persist_funding_payment(details, rounds).await.unwrap(); + wallet.record_funding_payment(details, rounds).await.unwrap(); let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; assert_eq!(payments.len(), 1, "the duplicate must be merged away"); @@ -10432,174 +9107,6 @@ mod tests { assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); } - /// Barrier test, classification-first ordering: wallet sync's confirmation handling must - /// wait for classification's two-store write pair. Classification is parked between its - /// payment-store and pending-store writes (the torn window) and only then is the - /// confirmation of the replacement candidate dispatched; unless the sync arm holds the - /// cross-store lock from payment-id resolution onwards, it resolves the id against the - /// still-missing pending index and mints a duplicate record keyed by the event txid. - #[tokio::test] - async fn funding_confirmation_waits_for_classification() { - let gated = NamespaceGatedStore::new(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); - let store: Arc = Arc::new(DynStoreWrapper(gated.clone())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - - let txid1 = Txid::from_byte_array([1u8; 32]); - let txid2 = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId(txid1.to_byte_array()); - let candidates = vec![ - FundingTxCandidate { - 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)); - - // Hold the gate so classification parks on its pending-store write: the payment record - // is persisted, the pending entry is not — the torn window a concurrent confirmation - // must not observe. - let gate_guard = gated.gate.write().await; - let classification = tokio::spawn({ - let wallet = Arc::clone(&wallet); - let candidates = candidates.clone(); - async move { wallet.persist_funding_payment(details, candidates).await } - }); - gated.parked.notified().await; - - // Only now dispatch the confirmation of the candidate that won. - let event = WalletEvent::TxConfirmed { - txid: txid2, - tx: Arc::new(dummy_tx()), - block_time: confirmed_block_time(5), - old_block_time: None, - }; - let sync = tokio::spawn({ - let wallet = Arc::clone(&wallet); - async move { wallet.update_payment_store(vec![event]).await } - }); - - // Liveness sanity only (both pre- and post-fix stall here): while classification is - // parked, no second record may have been committed. - tokio::time::sleep(Duration::from_millis(250)).await; - let payment_keys = KVStore::list( - &*store, - PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, - ) - .await - .unwrap(); - assert!(payment_keys.len() <= 1); - - drop(gate_guard); - classification.await.unwrap().unwrap(); - sync.await.unwrap().unwrap(); - - // Both writers converge on the classified record: the confirmation refreshes it in - // place with the confirmed candidate's figures rather than minting a second record - // keyed by the event txid. - let payment_keys = KVStore::list( - &*store, - PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, - ) - .await - .unwrap(); - assert_eq!(payment_keys.len(), 1, "the confirmation must not mint a duplicate record"); - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.id, payment_id); - assert_eq!(payment.amount_msat, Some(2_000_000)); - assert_eq!(payment.fee_paid_msat, Some(999)); - match &payment.kind { - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Confirmed { .. }, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - } => assert_eq!(*txid, txid2), - kind => panic!("unexpected kind {:?}", kind), - } - } - - /// Barrier test, sync-first ordering: classification must wait for wallet sync's complete - /// decision-plus-write sequence. Wallet sync is parked inside its generic-fallback window — - /// past the funding-status check that found no record, before its writes — by holding the - /// BDK wallet lock the fallback needs. Unless the sync arm holds the cross-store lock - /// across that window, classification lands in between and the fallback's stale merge - /// overwrites the contribution-derived figures with wallet-derived ones. - #[tokio::test(flavor = "multi_thread")] - async fn funding_classification_waits_for_wallet_sync() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - - let txid = Txid::from_byte_array([3u8; 32]); - let payment_id = PaymentId(txid.to_byte_array()); - let candidates = vec![FundingTxCandidate { - txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }]; - let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); - - // Park wallet sync inside its fallback window: the TxUnconfirmed arm reads no wallet - // state before that point, so it passes the funding-status check (no record exists yet) - // and then blocks on the wallet lock held here. The sleeps give the tasks time to reach - // their parking spots; they make the pre-fix failure deterministic, while the fixed - // code converges to the same final state under any arrival order. - let inner_guard = wallet.inner.lock().unwrap(); - let sync = tokio::spawn({ - let wallet = Arc::clone(&wallet); - let event = - WalletEvent::TxUnconfirmed { txid, tx: Arc::new(dummy_tx()), old_block_time: None }; - async move { wallet.update_payment_store(vec![event]).await } - }); - tokio::time::sleep(Duration::from_millis(250)).await; - - let classification = tokio::spawn({ - let wallet = Arc::clone(&wallet); - let candidates = candidates.clone(); - async move { wallet.persist_funding_payment(details, candidates).await } - }); - tokio::time::sleep(Duration::from_millis(250)).await; - - drop(inner_guard); - sync.await.unwrap().unwrap(); - classification.await.unwrap().unwrap(); - - // Both writers converge on one record carrying the classification: the generic - // fallback must not clobber the contribution-derived figures with its wallet-derived - // view of the transaction. - let payment_keys = KVStore::list( - &*store, - PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, - ) - .await - .unwrap(); - assert_eq!(payment_keys.len(), 1); - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.id, payment_id); - assert_eq!( - payment.amount_msat, - Some(1_000_000), - "wallet sync's fallback must not overwrite contribution figures" - ); - assert_eq!(payment.fee_paid_msat, Some(500)); - assert!(matches!( - &payment.kind, - PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } - )); - } - #[tokio::test] async fn max_funding_estimate_keeps_reserved_change_address_used() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/wallet/payment_stores.rs b/src/wallet/payment_stores.rs index f99b1e986b..7a17d124d8 100644 --- a/src/wallet/payment_stores.rs +++ b/src/wallet/payment_stores.rs @@ -23,12 +23,12 @@ use crate::Error; /// The wallet's payment store and pending payment store, with the lock serializing their writers. /// /// The writers must observe the payment record and its pending-store entry (candidate history -/// included) as one consistent unit: classification and wallet sync's event arms each hold the -/// lock from payment-id resolution through their last write (classification's being its two-store -/// write pair). Without the lock, a confirmation landing between classification's two writes sees -/// the record classified but the candidate history absent — resolving the wrong payment id or -/// stamping the confirmed candidate with another candidate's figures — and a classification -/// landing inside an arm's decision sequence gets overwritten by the arm's stale generic fallback. +/// included) as one consistent unit: wallet sync's event arms and the funding-record writers each +/// hold the lock from payment-id resolution through their last write. Without the lock, a +/// confirmation landing between a writer's two store writes sees the record but not the candidate +/// history — resolving the wrong payment id or stamping the confirmed candidate with another +/// candidate's figures — and a funding-record write landing inside an arm's decision sequence gets +/// overwritten by the arm's stale generic fallback. /// /// The writes are methods of [`PaymentStoresGuard`], which only [`Self::lock`] hands out, so a /// write compiles only for a holder of the lock. The reads are methods of this type and take no diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index ae4d523ab6..b1f32af1df 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2454,204 +2454,6 @@ async fn splice_channel() { ); } -/// Canary for the upstream behavior the zero-activity skip in `classify_funding` works around: -/// after a 0conf splice is promoted, LDK re-broadcasts the still-unconfirmed funding transaction -/// through its generic funding path — re-typed as a plain funding transaction without its -/// contribution metadata — on every monitor-update completion until it confirms. A splice-out -/// paying an external address moves no wallet funds, so the interactive-funding classification -/// declines to record it and each re-offer then arrives with nothing to record. The re-typing is -/// tracked upstream at . -/// -/// If this test fails, upstream likely stopped re-offering the transaction that way (or now -/// preserves its interactive-funding classification): re-evaluate whether the skip still sees -/// traffic. -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn zero_conf_splice_out_funding_rebroadcast_canary() { - let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = random_chain_source(&bitcoind, &electrsd); - - // The skip leaves no trace in the payment stores — that is its point — so observe it through - // Node A's logs. `setup_two_nodes` wires file loggers, so build the pair manually with a - // collector, Node B trusting Node A for 0conf. - let logger_a = Arc::new(CollectingLogWriter::new()); - let mut config_a = random_config(); - config_a.log_writer = TestLogWriter::Custom(logger_a.clone()); - let node_a = setup_node(&chain_source, config_a); - - 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(); - let premine_amount_sat = 5_000_000; - premine_and_distribute_funds( - &bitcoind.client, - &electrsd.client, - vec![address_a], - Amount::from_sat(premine_amount_sat), - ) - .await; - node_a.sync_wallets().unwrap(); - - open_channel(&node_a, &node_b, 2_000_000, false, &electrsd).await; - - // 0conf: the channel is ready without any confirmations. - 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 splice below is the only unconfirmed funding. - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); - - // Splice out to a third-party address: channel funds leave without touching Node A's - // on-chain wallet, so no classification path records the transaction. - let external_address = bitcoind.client.new_address().unwrap(); - node_a.splice_out(&user_channel_id_a, node_b.node_id(), &external_address, 500_000).unwrap(); - let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); - - // The 0conf splice locks without confirmations, re-signaled as `ChannelReady`. - expect_channel_ready_event!(node_a, node_b.node_id()); - expect_channel_ready_event!(node_b, node_a.node_id()); - - // Locking the splice completed monitor updates that re-offered the unconfirmed funding - // transaction; a payment drives further monitor updates and thus further re-broadcasts. - let amount_msat = 1_000_000; - let payment_id = - node_a.spontaneous_payment().send(amount_msat, node_b.node_id(), None).unwrap(); - expect_payment_successful_event!(node_a, payment_id, None); - expect_payment_received_event!(node_b, amount_msat); - - // Canary: the skip saw a re-offer. When this stops firing, LDK no longer re-offers the - // promoted-but-unconfirmed splice through the generic funding path. The line is also the - // synchronization point: it is the terminal action of classifying a re-offer, so once it - // appears the classification pipeline has demonstrably processed one. - let skipped = format!("Not recording channel-funding broadcast {}", txo.txid); - assert!( - logger_a.wait_for(&skipped).await, - "Node A never skipped a generic-funding re-broadcast of the promoted 0conf splice-out; if \ - upstream stopped re-offering it, re-evaluate the zero-activity skip in classify_funding" - ); - - // The re-offers must not have minted a record for a transaction the wallet has no stake in. - let splice_records = node_a.list_payments_matching( - |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == txo.txid), - ); - assert!( - splice_records.is_empty(), - "a zero-activity funding re-broadcast minted a record: {:?}", - splice_records - ); -} - -/// Canary for the upstream behavior the funding-over-interactive-funding guards in -/// `classify_funding` and `funding_reclassification_update` work around: LDK re-broadcasts a -/// promoted-but-unconfirmed 0conf splice through its generic funding path — re-typed as a plain -/// funding transaction with wallet-view figures and no contribution metadata — on every -/// monitor-update completion until it confirms. On the contributing side those re-offers name a -/// transaction the node has already recorded as an interactive funding, and must leave both the -/// classification and the figures alone. The re-typing is tracked upstream at -/// . -/// -/// If this test fails, upstream likely stopped re-offering the transaction that way (or now -/// preserves its interactive-funding classification): re-evaluate whether the guards still see -/// traffic. -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn zero_conf_splice_in_funding_rebroadcast_canary() { - let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = random_chain_source(&bitcoind, &electrsd); - - // The guard leaves no trace in the stores, so observe the re-offers through Node A's logs. - // `setup_two_nodes` wires file loggers, so build the pair manually with a collector, Node B - // trusting Node A for 0conf. - let logger_a = Arc::new(CollectingLogWriter::new()); - let mut config_a = random_config(); - config_a.log_writer = TestLogWriter::Custom(logger_a.clone()); - let node_a = setup_node(&chain_source, config_a); - - 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(); - let premine_amount_sat = 5_000_000; - premine_and_distribute_funds( - &bitcoind.client, - &electrsd.client, - vec![address_a], - Amount::from_sat(premine_amount_sat), - ) - .await; - node_a.sync_wallets().unwrap(); - - open_channel(&node_a, &node_b, 2_000_000, false, &electrsd).await; - - // 0conf: the channel is ready without any confirmations. - 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 splice below is the only unconfirmed funding and Node - // A's change from the open is spendable for the splice contribution. - 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 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()); - expect_channel_ready_event!(node_b, node_a.node_id()); - - let splice_payments = |node: &Node| { - node.list_payments_matching( - |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == txo.txid), - ) - }; - let payments = splice_payments(&node_a); - assert_eq!(payments.len(), 1); - let recorded_amount_msat = payments[0].amount_msat; - let recorded_fee_paid_msat = payments[0].fee_paid_msat; - - // Locking the splice completed monitor updates that re-offered the unconfirmed funding - // transaction; a payment drives further monitor updates and thus further re-broadcasts. - let amount_msat = 1_000_000; - let payment_id = - node_a.spontaneous_payment().send(amount_msat, node_b.node_id(), None).unwrap(); - expect_payment_successful_event!(node_a, payment_id, None); - expect_payment_received_event!(node_b, amount_msat); - - // Canary: generic re-offers of the splice reached classification while its record held the - // interactive-funding classification. Waiting for the second occurrence also makes the - // record assertions below deterministic — the broadcast loop classifies sequentially, so by - // the second arrival the first re-offer's store write has completed. - let rebroadcast = format!("funding-typed rebroadcast {}", txo.txid); - assert!( - logger_a.wait_for_count(&rebroadcast, 2).await, - "Node A saw no generic-funding re-broadcast of the round it recorded as an interactive \ - funding; if upstream stopped re-offering it, re-evaluate the guards in classify_funding \ - and funding_reclassification_update" - ); - - // The re-offers must not have disturbed the record's classification or figures. - let payments = splice_payments(&node_a); - assert_eq!(payments.len(), 1); - let payment = &payments[0]; - assert_eq!(payment.amount_msat, recorded_amount_msat); - assert_eq!(payment.fee_paid_msat, recorded_fee_paid_msat); - assert!(matches!( - payment.kind, - PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } - )); -} - /// Two splices of this node in flight on a zero-conf channel — the second submitted right after /// the first locked — are two payments: the second splice takes an intent record of its own /// rather than the first splice's, whose record keeps the first splice's transaction. The lock From 5dec9143ba73d6590aa9051ba3e552095cb9d10c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 20:06:24 +0200 Subject: [PATCH 38/49] Gate on-chain RBF positively on a wallet-only tx The RBF gate refused a payment whose recorded type named a funding transaction and allowed everything else, so a record carrying no type at all -- one wallet sync wrote before it could name the transaction, or one from a node version that predates classification -- passed as an ordinary payment and could have its replacement broadcast behind LDK's back. Decide it the other way round: allow the bump only when the recorded facts make nothing of the transaction and every input is an output this wallet owns and can re-sign. A channel transaction reaches for a funding, anchor, HTLC or spendable output the wallet does not hold, so it is refused whether or not anything named it. The confirmation, direction and payment-kind checks are unchanged. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 93 ++++++++++++++++++++++++++++------------------- 1 file changed, 55 insertions(+), 38 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 54d44b4e16..9f70c700ff 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3304,20 +3304,64 @@ impl Wallet { Error::InvalidPaymentId })?; - // Funding transactions (channel opens and splices) are driven by LDK's funding/splice - // lifecycle, not the on-chain wallet. Replacing one via on-chain RBF would broadcast a - // transaction LDK isn't tracking (and, for splices, can't sign). Fee-bumping a pending - // splice goes through `bump_channel_funding_fee` instead. - if let PaymentKind::Onchain { - tx_type: - Some(TransactionType::Funding { .. } | TransactionType::InteractiveFunding { .. }), - .. - } = &payment.kind - { + let txid = match &payment.kind { + PaymentKind::Onchain { txid, .. } => *txid, + _ => { + log_error!( + self.logger, + "Payment {} is not an on-chain payment, cannot be replaced via RBF", + payment_id + ); + return Err(Error::InvalidPaymentId); + }, + }; + + // The transaction and whether the wallet owns every input it spends, read before the + // persister lock so what this node recorded about the transaction can be consulted + // without holding it. `list_output` rather than `get_utxo`, so an output this very + // transaction spends still counts as the wallet's. + let owned_inputs = { + let locked_wallet = self.inner.lock().expect("lock"); + let tx = locked_wallet.tx_details(txid).map(|details| details.tx.deref().clone()); + tx.map(|tx| { + let owned: HashSet = + locked_wallet.list_output().map(|output| output.outpoint).collect(); + let all_owned = tx.input.iter().all(|input| owned.contains(&input.previous_output)); + (tx, all_owned) + }) + }; + let Some((old_tx, all_inputs_owned)) = owned_inputs else { + log_error!(self.logger, "Transaction {} not found in wallet", txid); + return Err(Error::InvalidPaymentId); + }; + + // Only an ordinary payment of this wallet's may be replaced, decided positively rather + // than by exclusion: what this node recorded must make nothing of the transaction, and + // every input must be an output this wallet owns and can re-sign. A transaction no + // recorded fact names is therefore still refused when it reaches beyond the wallet's own + // coins, rather than passing for want of a reason to reject it. + // + // Anything a channel of this node's has a claim on is driven by LDK's funding, splice and + // close lifecycle rather than by the on-chain wallet: replacing it would broadcast a + // transaction LDK isn't tracking, and an interactively negotiated funding cannot be + // re-signed by this node alone. Fee-bumping a pending splice goes through + // `bump_channel_funding_fee` instead. + if let Some(tx_type) = self.tx_provenance(txid, &old_tx).await.classify(&old_tx) { log_error!( self.logger, - "Cannot RBF funding payment {} via bump_fee_rbf; use bump_channel_funding_fee instead", + "Cannot RBF payment {} via bump_fee_rbf: {} is {:?}; a pending splice is fee-bumped with bump_channel_funding_fee", payment_id, + txid, + tx_type, + ); + return Err(Error::InvalidPaymentId); + } + if !all_inputs_owned { + log_error!( + self.logger, + "Cannot RBF payment {}: transaction {} spends inputs this wallet does not own", + payment_id, + txid, ); return Err(Error::InvalidPaymentId); } @@ -3345,36 +3389,9 @@ impl Wallet { return Err(Error::InvalidPaymentId); } - let txid = match &payment.kind { - PaymentKind::Onchain { txid, .. } => *txid, - _ => { - log_error!( - self.logger, - "Payment {} is not an on-chain payment, cannot be replaced via RBF", - payment_id - ); - return Err(Error::InvalidPaymentId); - }, - }; - let mut locked_persister = self.persister.lock().await; let mut locked_wallet = self.inner.lock().expect("lock"); - debug_assert!( - locked_wallet.tx_details(txid).is_some(), - "Transaction {} expected in wallet but not found", - txid, - ); - let old_tx = locked_wallet - .tx_details(txid) - .ok_or_else(|| { - log_error!(self.logger, "Transaction {} not found in wallet", txid); - Error::InvalidPaymentId - })? - .tx - .deref() - .clone(); - let old_fee_rate = locked_wallet.calculate_fee_rate(&old_tx).map_err(|e| { log_error!(self.logger, "Failed to calculate fee rate of transaction {}: {}", txid, e); Error::WalletOperationFailed From f7c3e2379b2f60bd6c03df9615ab045b952f8272 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 20:25:55 +0200 Subject: [PATCH 39/49] Patch LDK to the tx-only broadcasting branch On-chain payments are classified from recorded provenance now, so nothing reads the transaction type LDK reports at broadcast, and the pinned revision can go back to handing the broadcaster transactions alone. Point every rust-lightning crate at a local checkout of the branch that restores that signature. The patch section is temporary and must not reach a proposed branch: the paths it names exist only on the machine this was developed on, so the tree builds nowhere else. It has to be replaced by an accessible, reviewed revision, moved into the `rev` of each crate, before this work is proposed. Co-Authored-By: HAL 9000 --- Cargo.toml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 9f8a729655..f6567ff29a 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -245,3 +245,27 @@ harness = false #lightning-liquidity = { path = "../rust-lightning/lightning-liquidity" } #lightning-macros = { path = "../rust-lightning/lightning-macros" } #lightning-dns-resolver = { path = "../rust-lightning/lightning-dns-resolver" } + +# TEMPORARY - this section must not reach a proposed branch. +# +# Overrides the revision pinned above with a local checkout of the rust-lightning branch +# `2026-09-restore-tx-only-broadcasting-0.3`, which restores +# `BroadcasterInterface::broadcast_transactions` to taking `&[&Transaction]`. The paths below +# exist only on the machine this was developed on, so the tree builds nowhere else while they +# are in place. +# +# Before this work is proposed, delete this whole section and move the `rev` of every crate +# above to an accessible, reviewed revision carrying the same revert. +[patch."https://github.com/lightningdevkit/rust-lightning"] +lightning = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning" } +lightning-types = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-types" } +lightning-invoice = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-invoice" } +lightning-net-tokio = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-net-tokio" } +lightning-persister = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-persister" } +lightning-background-processor = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-background-processor" } +lightning-rapid-gossip-sync = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-rapid-gossip-sync" } +lightning-block-sync = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-block-sync" } +lightning-transaction-sync = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-transaction-sync" } +lightning-liquidity = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-liquidity" } +lightning-macros = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-macros" } +lightning-dns-resolver = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-dns-resolver" } From 1695e1ccb1c74fcac2137316b3d54ddbd0108d30 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 20:25:55 +0200 Subject: [PATCH 40/49] Stop using LDK's broadcast classification types The broadcaster takes the transactions of a `broadcast_transactions` call and nothing else: the type that accompanied each of them no longer exists, and nothing has read it since on-chain payments began to be classified from recorded provenance. `FundingCandidate` and `ChannelFunding` are gone from LDK with it, so the splice rounds handed to the signing-time recording are described by a pair of types of our own. They carry no funding purpose: every round listed here is a splice, and only a test ever read it back. Co-Authored-By: HAL 9000 --- src/channel/mod.rs | 3 +-- src/event.rs | 5 +++-- src/tx_broadcaster.rs | 8 +++----- src/wallet/mod.rs | 42 +++++++++++++++++++++++++++++------------- 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index a873df7eb5..7b61e6bf74 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -15,7 +15,6 @@ use bitcoin::absolute::LockTime; use bitcoin::secp256k1::PublicKey; use bitcoin::transaction::Version; use bitcoin::{OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid}; -use lightning::chain::chaininterface::FundingCandidate; use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::ln::channel_state::{ChannelDetails, SpliceCandidateDetails, SpliceCandidateStatus}; use lightning::ln::channelmanager::PaymentId; @@ -29,7 +28,7 @@ use crate::payment::pending_payment_store::{ }; use crate::payment::{PaymentKind, TransactionType}; use crate::types::{ChannelManager, PendingPaymentStore}; -use crate::wallet::{funding_candidates, random_payment_id, Wallet}; +use crate::wallet::{funding_candidates, random_payment_id, FundingCandidate, Wallet}; use crate::Error; /// Whether two contributions describe the same splice attempt. LDK may adjust a contribution diff --git a/src/event.rs b/src/event.rs index eb0338bb27..6ef2c642b6 100644 --- a/src/event.rs +++ b/src/event.rs @@ -15,7 +15,6 @@ use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; use bitcoin::{Amount, OutPoint, ScriptBuf, Txid}; use lightning::blinded_path::message::NextMessageHop; -use lightning::chain::chaininterface::FundingCandidate; use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::events::bump_transaction::BumpTransactionEvent; #[cfg(not(feature = "uniffi"))] @@ -71,7 +70,9 @@ use crate::types::{ Wallet, }; use crate::wallet::provenance::{ChannelOutputRole, ChannelTxFacts}; -use crate::wallet::{closed_channel_held_rounds, funding_candidates, held_splice_rounds}; +use crate::wallet::{ + closed_channel_held_rounds, funding_candidates, held_splice_rounds, FundingCandidate, +}; use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, UserChannelId, diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index f544cd13bc..2f5f6d973a 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -10,9 +10,7 @@ use std::ops::Deref; use std::sync::Mutex as StdMutex; use bitcoin::{Transaction, Txid}; -use lightning::chain::chaininterface::{ - BroadcasterInterface, TransactionType as LdkTransactionType, -}; +use lightning::chain::chaininterface::BroadcasterInterface; use tokio::sync::Notify; use crate::logger::{log_trace, LdkLogger}; @@ -151,8 +149,8 @@ impl BroadcasterInterface for TransactionBroadcaster where L::Target: LdkLogger, { - fn broadcast_transactions(&self, txs: &[(&Transaction, LdkTransactionType)]) { - self.queue_package(BroadcastPackage(txs.iter().map(|(tx, _)| (*tx).clone()).collect())); + fn broadcast_transactions(&self, txs: &[&Transaction]) { + self.queue_package(BroadcastPackage(txs.iter().map(|tx| (*tx).clone()).collect())); } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 9f70c700ff..f552d22fa5 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -32,14 +32,13 @@ use bitcoin::{ Address, Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, Transaction, TxOut, Txid, WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; -use lightning::chain::chaininterface::{ - ChannelFunding, FundingCandidate, FundingPurpose, INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, -}; +use lightning::chain::chaininterface::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::funding::FundingContribution; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; use lightning::ln::script::ShutdownScript; @@ -3626,11 +3625,33 @@ fn tracks_channel(entry: &PendingPaymentDetails, channel_id: ChannelId) -> bool entry.funding_channels().iter().any(|channel| channel.channel_id == channel_id) } +/// A round of an interactive funding negotiation that has a transaction, and the channels that +/// transaction funds. +#[derive(Clone, Debug)] +pub(crate) struct FundingCandidate { + /// The txid of this round. + pub txid: Txid, + /// The channels participating in this round. + pub channels: Vec, +} + +/// A single channel's participation in a [`FundingCandidate`]. +#[derive(Clone, Debug)] +pub(crate) struct ChannelFunding { + /// The `node_id` of the channel counterparty. + pub counterparty_node_id: PublicKey, + /// The ID of the channel. + pub channel_id: ChannelId, + /// This node's contribution to this channel in this round, or `None` where it contributed + /// nothing — a pure acceptor adding no value, or a leading RBF round before it began + /// contributing. + pub contribution: Option, +} + /// 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. +/// 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 { @@ -3645,7 +3666,6 @@ pub(crate) fn funding_candidates( channels: vec![ChannelFunding { counterparty_node_id, channel_id, - purpose: FundingPurpose::Splice, contribution: candidate.contribution.clone(), }], }) @@ -4087,7 +4107,6 @@ 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::*; @@ -5438,12 +5457,12 @@ mod tests { } /// 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. + /// the given order — the shape [`funding_candidates`] produces for the signing-time + /// recording. 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 { @@ -5451,7 +5470,6 @@ mod tests { channels: vec![ChannelFunding { counterparty_node_id, channel_id, - purpose: FundingPurpose::Splice, contribution: contribution.clone(), }], }) @@ -7467,7 +7485,6 @@ mod tests { /// 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, }; @@ -7513,7 +7530,6 @@ mod tests { 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()); From 1c4d9c4de36df450f6f4ef63cc59798cbb968288 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 21:05:06 +0200 Subject: [PATCH 41/49] Drop recorded channel facts nothing needs anymore The store of what this node's channels reported about the transactions they produced grew for the lifetime of the node: nothing ever removed a record, so a node kept evidence about channels it had settled years ago. A transaction's record now goes once every use this node has for it is over: nothing has been learned about the transaction for about a year, none of the channels it names is still held by the channel manager, the chain monitor or the output sweeper, no pending payment still refers to it, and whatever channel funding it records has been spent by a transaction buried twice over. Any one of those keeps the record, and the wallet keeps everything while it cannot reach the node's channel state at all, so the loss of that view is never mistaken for a node with no channels. The check shares the chain tip pass that graduates payments and resumes where the previous tip left it, so it costs one page of records a block however large the store is, and it runs after the pass has named what it could. A record is dropped only while it still is the one the check looked at, since a producer may have reported something about the transaction in between. Because a payment is classified when its transaction is observed, expiring a record never takes a classification back. It means a transaction of a long-resolved channel, met for the first time after its evidence expired, is reported without one -- which the public API now says. Co-Authored-By: HAL 9000 --- src/builder.rs | 9 + src/config.rs | 20 ++ src/payment/store.rs | 14 +- src/wallet/mod.rs | 472 ++++++++++++++++++++++++++++++++++++++- src/wallet/provenance.rs | 290 +++++++++++++++++++++++- 5 files changed, 795 insertions(+), 10 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index d8cbce66c2..f602abc419 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -113,6 +113,7 @@ use crate::types::{ PeerManager, PendingPaymentStore, }; use crate::wallet::persist::{read_address_pool, KVStoreWalletPersister}; +use crate::wallet::provenance::NodeChannelLiveness; use crate::wallet::Wallet; use crate::{Node, NodeMetrics, PersistedNodeMetrics}; @@ -2446,6 +2447,14 @@ fn build_with_store_internal( }, }; + // The wallet drops the facts it recorded for a channel once nothing holds that channel + // anymore, which it can only ask now that the node's channel state exists. + wallet.set_channel_liveness(Arc::new(NodeChannelLiveness::new( + &channel_manager, + &chain_monitor, + &output_sweeper, + ))); + let event_queue = match event_queue_res { Ok(event_queue) => Arc::new(event_queue), Err(e) => { diff --git a/src/config.rs b/src/config.rs index c9c7372ca9..60c7497771 100644 --- a/src/config.rs +++ b/src/config.rs @@ -81,6 +81,26 @@ pub(crate) const CHANNEL_TX_FACTS_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::n // back individually as they are needed. pub(crate) const CHANNEL_TX_FACTS_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap(); +// The number of blocks a channel transaction provenance record outlives the last thing the node +// learned about its transaction. +// +// Roughly a year at ten minutes a block. It is an absolute backstop rather than the usual reason +// a record goes: a record is dropped only once the channels it names are gone from the node's +// channel manager, chain monitor and output sweeper, and the funding it records has been spent +// and settled. Those checks are blind to a transaction of a channel that never reached them, so +// without the cap such a record would be kept forever. +pub(crate) const CHANNEL_TX_FACTS_RETENTION_BLOCKS: u32 = 52_560; + +// The number of pages of channel transaction provenance records one chain tip change examines. +// +// Pruning shares the pass that graduates payments, so it has to leave promptly; it resumes where +// it left off on the next tip and so walks the whole store over consecutive blocks. At the +// built-in backends' page size this is a couple of hundred records a block: a store whose records +// fit the cache is walked in a single tip and costs the backend nothing beyond listing its keys, +// while one at the limit above takes a few hundred blocks — which is also how stale the record +// count that walk maintains can get. +pub(crate) const CHANNEL_TX_FACTS_PRUNE_PAGES_PER_TIP: usize = 4; + // The default {Esplora,Electrum} client timeout we're using. const DEFAULT_PER_REQUEST_TIMEOUT_SECS: u8 = 10; diff --git a/src/payment/store.rs b/src/payment/store.rs index c760661845..d38433fe02 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -403,6 +403,14 @@ impl_writeable_tlv_based!(Channel, { /// /// Names the channels involved; a transaction's amount and fee are tracked on the /// [`PaymentDetails`] itself. +/// +/// The classification is written onto the payment when the transaction is observed, and what it +/// is derived from is kept only for a bounded time after the channels that produced the +/// transaction have resolved. The node therefore stops being able to classify transactions of +/// channels it settled long ago: such a transaction, met for the first time after that point, is +/// reported as [`PaymentKind::Onchain`] with no `tx_type` at all. A payment already classified +/// keeps its classification — expiry never takes a label back, it only leaves a later one +/// unwritten. #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] pub enum TransactionType { @@ -497,8 +505,10 @@ pub enum PaymentKind { status: ConfirmationStatus, /// The classification of this transaction, if known. /// - /// `None` for plain on-chain sends, and for records written by versions of LDK Node that - /// predate on-chain transaction classification. + /// `None` for plain on-chain sends, for records written by versions of LDK Node that + /// predate on-chain transaction classification, and for a transaction of a channel that + /// resolved long enough ago for what would classify it to have expired; see + /// [`TransactionType`]. tx_type: Option, }, /// A [BOLT 11] payment. diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index f552d22fa5..25dd4f19ef 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::future::Future; use std::ops::Deref; use std::str::FromStr; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_chain::ChainPosition; @@ -56,7 +56,10 @@ use payment_stores::{PaymentStores, PaymentStoresGuard}; use persist::KVStoreWalletPersister; use crate::channel::is_same_splice; -use crate::config::{Config, ADDRESS_POOL_SIZE}; +use crate::config::{ + Config, ADDRESS_POOL_SIZE, CHANNEL_TX_FACTS_PRUNE_PAGES_PER_TIP, + CHANNEL_TX_FACTS_RETENTION_BLOCKS, +}; #[cfg(test)] use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed}; use crate::data_store::{StorableObject, UpdatableObject}; @@ -70,7 +73,10 @@ use crate::payment::{ }; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; -use crate::wallet::provenance::{ChannelTxFacts, LocalFundingFigures, TxProvenance}; +use crate::wallet::provenance::{ + ChannelLiveness, ChannelTxFacts, FactsRetention, LocalFundingFigures, RetentionCheck, + TxProvenance, +}; use crate::{ChainSource, Error}; pub(crate) enum OnchainSendAmount { @@ -168,6 +174,11 @@ pub(crate) struct Wallet { // What this node's channels reported about the transactions they produced, keyed by // transaction id. channel_tx_facts_store: Arc, + // Where to ask which channels the node still holds on-chain state for, set once that state + // exists. Recorded facts are kept while it is unset. + channel_liveness: OnceLock>, + // How far the dropping of recorded facts has walked the store, and how many records it holds. + facts_retention: FactsRetention, } impl Wallet { @@ -196,6 +207,19 @@ impl Wallet { logger, payment_stores: PaymentStores::new(payment_store, pending_payment_store), channel_tx_facts_store, + channel_liveness: OnceLock::new(), + facts_retention: FactsRetention::new(), + } + } + + /// Tells the wallet where to ask which channels the node still holds on-chain state for, so + /// that the facts recorded for a channel can be dropped once nothing holds it anymore. + /// + /// The node's channel state is built on top of the wallet, so it can only be handed over + /// afterwards; until it is, no recorded fact is dropped. + pub(crate) fn set_channel_liveness(&self, liveness: Arc) { + if self.channel_liveness.set(liveness).is_err() { + debug_assert!(false, "The wallet is told where to find the node's channels once"); } } @@ -207,6 +231,8 @@ impl Wallet { /// overwriting it: one of the two producers is wrong, and the recorded facts came first. pub(crate) async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) -> Result<(), Error> { let txid = facts.txid; + // Dated by the chain tip the report arrives at, which is what retention measures from. + let facts = facts.reported_at_height(self.latest_checkpoint_height()); // The rejection is reported out of the closure rather than through it, so that the read, // the merge and the write stay one critical section of the store's mutation lock. let mut conflict = None; @@ -237,6 +263,11 @@ impl Wallet { } } + /// The height of the chain tip the wallet has seen. + fn latest_checkpoint_height(&self) -> u32 { + self.inner.lock().expect("lock").latest_checkpoint().height() + } + /// Everything this node recorded about `tx` and about the transactions its inputs spend, as /// classifying `tx` needs it. /// @@ -588,6 +619,10 @@ impl Wallet { self.name_recorded_transactions(unnamed_transactions).await?; + // After the naming above, so that nothing is dropped before the records it + // could still name have had it. + self.prune_channel_tx_facts(new_tip.height).await; + if !unconfirmed_outbound_txids.is_empty() { let txs_to_broadcast: Vec = { let locked_wallet = self.inner.lock().expect("lock"); @@ -827,6 +862,145 @@ impl Wallet { Ok(()) } + /// Drops the facts this node has no use for anymore, a bounded batch of the store at a time. + /// + /// A transaction's facts go only once all of it holds: nothing has been learned about the + /// transaction for [`CHANNEL_TX_FACTS_RETENTION_BLOCKS`], none of the channels the facts name + /// is still held by the node's channel manager, chain monitor or output sweeper, no pending + /// payment still refers to the transaction, and whatever funding the facts record has been + /// spent by a settled transaction buried past twice [`ANTI_REORG_DELAY`]. Each of those is a + /// way the facts could still be needed, so any one of them keeps them. + /// + /// The walk of the store resumes where the previous tip left it, so a batch costs one page + /// however large the store is. + /// + /// Nothing here is reported to the caller: dropping records is housekeeping, and failing the + /// chain tip pass over it would cost the payment graduations it shares the pass with. + async fn prune_channel_tx_facts(&self, tip_height: u32) { + let Some(live_channels) = self.channel_liveness.get().and_then(|l| l.live_channels()) + else { + return; + }; + let pending_txids = self.pending_referenced_txids().await; + + let mut walk = self.facts_retention.walk().await; + for _ in 0..CHANNEL_TX_FACTS_PRUNE_PAGES_PER_TIP { + let page = match self.channel_tx_facts_store.list_page(walk.cursor.clone()).await { + Ok(page) => page, + Err(e) => { + // Including a token the backend will not take back, which would otherwise + // fail every tip from here on: start the walk over instead. + log_error!(self.logger, "Failed to list recorded channel facts: {}", e); + walk.cursor = None; + return; + }, + }; + + for facts in page.objects { + let check = RetentionCheck { + tip_height, + retention_blocks: CHANNEL_TX_FACTS_RETENTION_BLOCKS, + live_channels: &live_channels, + pending_txids: &pending_txids, + funding_spends_settled: self.funding_spends_settled( + &facts, + tip_height, + &pending_txids, + ), + }; + if !facts.is_prunable(&check) { + continue; + } + let txid = facts.txid; + match self.drop_recorded_facts(facts).await { + Ok(true) => { + log_debug!( + self.logger, + "Dropped what was recorded about transaction {}: nothing needs it anymore", + txid, + ); + }, + Ok(false) => {}, + Err(e) => log_error!( + self.logger, + "Failed to drop what was recorded about transaction {}: {}", + txid, + e, + ), + } + } + + match page.next_page_token { + Some(token) => walk.cursor = Some(token), + None => { + // The walk has been all the way round; start the next one from the beginning. + walk.cursor = None; + break; + }, + } + } + } + + /// Drops the recorded facts `facts` was read as, and reports whether anything was dropped. + /// + /// The record goes only while it still is the one that was read: retention is decided from a + /// record in hand, and a producer merging a report into it since may have named a channel + /// that would have kept it. The store's own critical section is what makes that check and the + /// removal one step, which a read followed by a removal would not be. + async fn drop_recorded_facts(&self, facts: ChannelTxFacts) -> Result { + let txid = facts.txid; + self.channel_tx_facts_store.remove_if(&txid, |recorded| *recorded == facts).await + } + + /// Whether every funding output `facts` records has been spent by a transaction that is + /// buried past twice [`ANTI_REORG_DELAY`] and whose own payment has settled, so that nothing + /// is left to classify from these facts. Facts recording no funding output have no such + /// spend to wait for. + fn funding_spends_settled( + &self, facts: &ChannelTxFacts, tip_height: u32, pending_txids: &HashSet, + ) -> bool { + let mut funding_vouts = facts.funding_vouts().peekable(); + if funding_vouts.peek().is_none() { + return true; + } + + let locked_wallet = self.inner.lock().expect("lock"); + funding_vouts.all(|vout| { + let outpoint = OutPoint { txid: facts.txid, vout }; + locked_wallet.tx_graph().outspends(outpoint).iter().any(|spender| { + // A spender still pending has yet to be told what it is, so the facts that would + // tell it must stay. `get_tx` is canonical-only, so a spend that lost a conflict + // closes nothing. + !pending_txids.contains(spender) + && match locked_wallet.get_tx(*spender).map(|tx| tx.chain_position) { + Some(ChainPosition::Confirmed { anchor, .. }) => { + tip_height + >= anchor.block_id.height.saturating_add(2 * ANTI_REORG_DELAY) + }, + _ => false, + } + }) + }) + } + + /// Every transaction the pending payment store still refers to: each entry's own + /// transaction, the interactive-funding rounds it lists as candidates or as locked, and the + /// conflicts wallet sync recorded against it. + async fn pending_referenced_txids(&self) -> HashSet { + let mut txids = HashSet::new(); + for entry in self.payment_stores.pending_payments(|_| true).await { + if let Some(PaymentKind::Onchain { txid, .. }) = + entry.details().map(|details| &details.kind) + { + txids.insert(*txid); + } + txids.extend(entry.candidates().iter().map(|candidate| candidate.txid)); + txids.extend(entry.conflicting_txids().iter().copied()); + txids.extend(entry.locked_rounds().iter().copied()); + } + txids + } + /// The id to record a transaction under that the funding-status check found foreign to the /// funding record resolved for it as `resolved_id`: its own txid-derived id, or `None` when a /// funding record sits there already. A funding record wallet sync created for a round it @@ -4131,7 +4305,7 @@ mod tests { }; use crate::types::{DynStore, DynStoreWrapper}; - use crate::wallet::provenance::{ChannelOutputRole, LocalFundingFigures}; + use crate::wallet::provenance::{live_channels_of, ChannelOutputRole, LocalFundingFigures}; use crate::{NodeMetrics, PersistedNodeMetrics}; const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; @@ -10385,4 +10559,294 @@ mod tests { kind => panic!("unexpected kind {:?}", kind), } } + + /// The node's channel state as a test dictates it: the channels its channel manager lists, + /// the monitors its chain monitor holds, and the outputs its sweeper tracks. + #[derive(Default)] + struct TestChannelState { + channels: Vec, + monitors: Vec, + tracked_outputs: Vec>, + } + + /// A stand-in for the node's channel state. `None` stands for the state being unreachable, + /// as it is while the node is built and while it is torn down. + struct TestLiveness(Mutex>); + + impl TestLiveness { + fn holding(state: TestChannelState) -> Arc { + Arc::new(Self(Mutex::new(Some(state)))) + } + + fn holding_nothing() -> Arc { + Self::holding(TestChannelState::default()) + } + + fn unreachable() -> Arc { + Arc::new(Self(Mutex::new(None))) + } + } + + impl ChannelLiveness for TestLiveness { + fn live_channels(&self) -> Option> { + let locked = self.0.lock().unwrap(); + let state = locked.as_ref()?; + Some(live_channels_of( + state.channels.iter().copied(), + state.monitors.iter().copied(), + state.tracked_outputs.iter().copied(), + )) + } + } + + fn block_id_at(height: u32) -> BlockId { + let mut hash = [0u8; 32]; + hash[..4].copy_from_slice(&height.to_le_bytes()); + BlockId { height, hash: bitcoin::BlockHash::from_byte_array(hash) } + } + + /// Builds a transaction spending `outpoint` into the wallet. + fn tx_spending(wallet: &Wallet, outpoint: OutPoint) -> 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, ..Default::default() }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + } + } + + /// A wallet that recorded a channel's funding transaction and has since seen that funding + /// spent by a transaction confirmed at height 10, which no pending payment refers to. + /// Everything but the node's channel state and the chain tip is then in the state that lets + /// the recorded facts go. + async fn wallet_with_a_spent_funding( + store: Arc, channel: &Channel, + ) -> (Arc, Txid) { + let wallet = new_test_wallet(store, false).await; + let funding_txid = Txid::from_byte_array([41u8; 32]); + wallet + .record_channel_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + channel, + None, + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(); + let close = tx_spending(&wallet, OutPoint { txid: funding_txid, vout: 0 }); + insert_confirmed_tx(&wallet, close, 10); + (wallet, funding_txid) + } + + /// Runs the chain tip pass at `height`, which is where recorded facts are dropped. + async fn chain_tip_changed(wallet: &Wallet, height: u32) { + { + let mut locked = wallet.inner.lock().unwrap(); + let chain = locked.latest_checkpoint().insert(block_id_at(height)); + locked.apply_update(Update { chain: Some(chain), ..Default::default() }).unwrap(); + } + let event = WalletEvent::ChainTipChanged { + old_tip: block_id_at(height - 1), + new_tip: block_id_at(height), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + } + + /// A chain tip far enough past both the age cap and the burial of the spend above. + const LONG_AFTER: u32 = 100_000; + + #[tokio::test] + async fn the_facts_of_a_resolved_channel_are_reclaimed() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::holding_nothing()); + + chain_tip_changed(&wallet, LONG_AFTER).await; + + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_none(), + "nothing holds the channel and its funding is long spent", + ); + } + + #[tokio::test] + async fn the_facts_of_a_channel_the_node_still_holds_are_kept() { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + + let still_held = [ + ( + "the channel manager lists it", + TestChannelState { channels: vec![channel_id], ..Default::default() }, + ), + ( + "the chain monitor holds its monitor", + TestChannelState { monitors: vec![channel_id], ..Default::default() }, + ), + ( + "the sweeper tracks an output of it", + TestChannelState { tracked_outputs: vec![Some(channel_id)], ..Default::default() }, + ), + ]; + + for (why, state) in still_held { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::holding(state)); + + chain_tip_changed(&wallet, LONG_AFTER).await; + + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_some(), + "the facts are still needed: {}", + why, + ); + } + } + + #[tokio::test] + async fn nothing_is_dropped_while_the_nodes_channels_cannot_be_consulted() { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + + // Before the node's channel state is handed over, which is how the wallet starts out. + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); + + // And once it can no longer be reached, as while the node is torn down. + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::unreachable()); + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); + } + + #[tokio::test] + async fn facts_are_kept_until_the_age_cap_has_passed() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::holding_nothing()); + + // The facts were recorded at height 0, before the spend moved the wallet's tip. + chain_tip_changed(&wallet, CHANNEL_TX_FACTS_RETENTION_BLOCKS - 1).await; + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_some(), + "a block short of the cap is short of it", + ); + + chain_tip_changed(&wallet, CHANNEL_TX_FACTS_RETENTION_BLOCKS).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_none()); + } + + #[tokio::test] + async fn the_facts_of_an_unspent_funding_are_kept() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.set_channel_liveness(TestLiveness::holding_nothing()); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + + let funding_txid = Txid::from_byte_array([43u8; 32]); + wallet + .record_channel_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + &channel, + None, + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(); + + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_some(), + "a funding output nothing has been seen to spend can still be spent", + ); + + // A spend that has yet to be buried twice over does not settle it either. + let close = tx_spending(&wallet, OutPoint { txid: funding_txid, vout: 0 }); + insert_confirmed_tx(&wallet, close, LONG_AFTER - 2 * ANTI_REORG_DELAY + 1); + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); + } + + #[tokio::test] + async fn the_facts_a_pending_payment_still_needs_are_kept() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::holding_nothing()); + + // A pending record listing the funding transaction among its candidates: its + // classification can still be written, and these facts are what would write it. + let id = PaymentId([44u8; 32]); + let entry = PendingPaymentDetails::new( + funding_payment(id, Txid::from_byte_array([45u8; 32]), PaymentStatus::Pending), + Vec::new(), + vec![FundingTxCandidate { + txid: funding_txid, + amount_msat: Some(1_000), + fee_paid_msat: Some(10), + awaiting_broadcast: false, + }], + ); + wallet.payment_stores.pending_payment_store().insert(entry).await.unwrap(); + + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); + + // Once the payment is no longer pending, nothing refers to the transaction anymore. + wallet.payment_stores.pending_payment_store().remove(&id).await.unwrap(); + chain_tip_changed(&wallet, LONG_AFTER + 1).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_none()); + } + + #[tokio::test] + async fn a_record_a_producer_changed_since_the_check_is_not_dropped() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + + let evaluated = wallet.channel_tx_facts(&funding_txid).await.expect("recorded above"); + + // A producer reports a further output of the same transaction between the decision and + // the removal — the way a channel comes back into play for a record already judged + // disposable, since whatever makes it live again reports what it resolved. + let reopened = Channel { counterparty_node_id, channel_id: ChannelId([9u8; 32]) }; + wallet + .record_channel_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + &reopened, + None, + ChannelOutputRole::Spendable, + [1], + )) + .await + .unwrap(); + + assert!(!wallet.drop_recorded_facts(evaluated).await.unwrap()); + let kept = wallet.channel_tx_facts(&funding_txid).await.expect("the record stays"); + assert_eq!(kept.outputs.len(), 2); + } } diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index 005a21c284..a85bbb769c 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -14,21 +14,23 @@ //! pending — so records are merged rather than replaced, and a producer reporting a different //! value for something already recorded is rejected instead of overwriting it. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt; +use std::sync::{Arc, Weak}; use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; use bitcoin::{Sequence, Transaction, Txid}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::types::ChannelId; +use lightning::util::persist::PageToken; use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use crate::data_store::{StorableObject, StorableObjectId}; use crate::hex_utils; use crate::payment::store::{Channel, TransactionType}; use crate::payment::PaymentDirection; -use crate::types::UserChannelId; +use crate::types::{ChainMonitor, ChannelManager, Sweeper, UserChannelId}; /// The part a transaction output plays in a channel. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -111,6 +113,10 @@ pub(crate) struct ChannelTxFacts { /// This node's share of an interactive-funding candidate, and the funding record it belongs /// to. pub local_figures: Option, + /// The chain tip this node was at when it last learned something new about the transaction. + /// It dates the record for retention; it is not a fact about the transaction, and so is the + /// one part of a record a later report may move. + pub recorded_at_height: u32, } impl_writeable_tlv_based!(ChannelTxFacts, { @@ -118,12 +124,25 @@ impl_writeable_tlv_based!(ChannelTxFacts, { (2, outputs, optional_vec), (4, self_role, option), (6, local_figures, option), + (8, recorded_at_height, required), }); impl ChannelTxFacts { /// Facts about the transaction `txid`, to be filled in with what a producer reported. pub(crate) fn new(txid: Txid) -> Self { - Self { txid, outputs: Vec::new(), self_role: None, local_figures: None } + Self { + txid, + outputs: Vec::new(), + self_role: None, + local_figures: None, + recorded_at_height: 0, + } + } + + /// Dates these facts at the chain tip the node is at while reporting them. + pub(crate) fn reported_at_height(mut self, height: u32) -> Self { + self.recorded_at_height = height; + self } /// Records `vouts` of this transaction as controlled by `channel` in `role`. @@ -186,6 +205,9 @@ impl ChannelTxFacts { /// where they are still absent. Re-reporting a fact is therefore a no-op, which is what lets /// a producer replay its event without consequence. Reporting a *different* value for /// something already recorded is rejected, leaving the recorded facts as they were. + /// + /// A merge that changes something dates the record at the incoming report's height, so that + /// retention measures how long ago this node last learned anything about the transaction. pub(crate) fn merged_with( mut self, incoming: &ChannelTxFacts, ) -> Result, ChannelTxFactsConflict> { @@ -241,7 +263,149 @@ impl ChannelTxFacts { _ => {}, } - Ok(changed.then_some(self)) + if !changed { + return Ok(None); + } + self.recorded_at_height = self.recorded_at_height.max(incoming.recorded_at_height); + Ok(Some(self)) + } +} + +/// The channels this node still holds on-chain state for, as the retention of recorded facts +/// consults them. +pub(crate) trait ChannelLiveness: Send + Sync { + /// The channels the node's channel manager, chain monitor or output sweeper still knows + /// about, or `None` when that state cannot be consulted at all. Nothing is dropped while the + /// answer is `None`: without it there is no way to tell which facts are still needed. + fn live_channels(&self) -> Option>; +} + +/// The node's own channel state, as [`ChannelLiveness`]. +/// +/// The handles are weak because the node's channel state holds the wallet in turn, through the +/// keys manager, so strong ones here would keep both alive for good. A handle that no longer +/// upgrades means the node is being torn down, which is no time to be dropping records. +pub(crate) struct NodeChannelLiveness { + channel_manager: Weak, + chain_monitor: Weak, + output_sweeper: Weak, +} + +impl NodeChannelLiveness { + pub(crate) fn new( + channel_manager: &Arc, chain_monitor: &Arc, + output_sweeper: &Arc, + ) -> Self { + Self { + channel_manager: Arc::downgrade(channel_manager), + chain_monitor: Arc::downgrade(chain_monitor), + output_sweeper: Arc::downgrade(output_sweeper), + } + } +} + +impl ChannelLiveness for NodeChannelLiveness { + fn live_channels(&self) -> Option> { + let channel_manager = self.channel_manager.upgrade()?; + let chain_monitor = self.chain_monitor.upgrade()?; + let output_sweeper = self.output_sweeper.upgrade()?; + + Some(live_channels_of( + channel_manager.list_channels().into_iter().map(|channel| channel.channel_id), + chain_monitor.list_monitors(), + output_sweeper.tracked_spendable_outputs().into_iter().map(|output| output.channel_id), + )) + } +} + +/// The channels named by a node's open channels, by the monitors it holds and by the spendable +/// outputs its sweeper tracks, each named once. +/// +/// A channel counts as held if any one of the three names it: an open channel can still produce +/// transactions, a monitor can still claim from one, and a tracked output has yet to be swept. +/// A tracked output that names no channel — one the sweeper was given without one — says nothing +/// about which channel is held and is left out. +pub(crate) fn live_channels_of( + channels: impl IntoIterator, monitors: impl IntoIterator, + tracked_outputs: impl IntoIterator>, +) -> HashSet { + let mut live: HashSet = channels.into_iter().collect(); + live.extend(monitors); + live.extend(tracked_outputs.into_iter().flatten()); + live +} + +/// How far the pruning of recorded facts has walked the store. +/// +/// The walk visits every record over consecutive chain tips rather than in one pass, so a batch +/// costs one page however large the store is. +pub(crate) struct FactsRetention { + /// Where the walk resumes, held by the pruning pass alone. + walk: tokio::sync::Mutex, +} + +/// The pruning pass's place in its walk of the store. +pub(crate) struct FactsWalk { + /// Where the next batch resumes, or `None` to walk the store from the start. + pub cursor: Option, +} + +impl FactsRetention { + pub(crate) fn new() -> Self { + Self { walk: tokio::sync::Mutex::new(FactsWalk { cursor: None }) } + } + + /// Takes the pruning pass's place in its walk, for as long as the guard lives. + pub(crate) async fn walk(&self) -> tokio::sync::MutexGuard<'_, FactsWalk> { + self.walk.lock().await + } +} + +/// What deciding whether a transaction's facts are still needed takes, beyond the facts +/// themselves. +pub(crate) struct RetentionCheck<'a> { + /// The height of the chain tip the decision is taken at. + pub tip_height: u32, + /// How many blocks a record outlives the last thing this node learned about its transaction. + pub retention_blocks: u32, + /// The channels this node still holds on-chain state for. + pub live_channels: &'a HashSet, + /// The transactions the pending payment store still refers to — its records' own + /// transactions, their interactive-funding candidates, the rounds that locked and the + /// conflicts wallet sync listed. A payment is pending exactly while its classification can + /// still be written onto it, so a transaction named here has yet to reach its record. + pub pending_txids: &'a HashSet, + /// Whether every funding output the facts record has been spent by a transaction confirmed + /// at least `2 * ANTI_REORG_DELAY` deep whose own payment has settled. `true` for facts + /// recording no funding output, which nothing closes. + pub funding_spends_settled: bool, +} + +impl ChannelTxFacts { + /// Whether these facts have outlived every use this node has for them. + /// + /// All of it must hold at once, and the age cap is what makes the answer bounded for facts + /// the other checks are blind to — a transaction for a channel that never reached the + /// channel manager, the chain monitor or the sweeper satisfies them vacuously. + pub(crate) fn is_prunable(&self, check: &RetentionCheck<'_>) -> bool { + if check.tip_height < self.recorded_at_height.saturating_add(check.retention_blocks) { + return false; + } + if self.outputs.iter().any(|output| check.live_channels.contains(&output.channel_id)) { + return false; + } + if check.pending_txids.contains(&self.txid) { + return false; + } + check.funding_spends_settled + } + + /// The outputs of this transaction a channel holds its funds in. + pub(crate) fn funding_vouts(&self) -> impl Iterator + '_ { + self.outputs + .iter() + .filter(|output| output.role == ChannelOutputRole::Funding) + .map(|output| output.vout) } } @@ -1041,4 +1205,122 @@ mod tests { ); assert_eq!(provenance.local_figures(), Some(&figures)); } + + /// Facts about a funding transaction of `channel` whose age is measured from `height`. + fn funding_facts(channel: &Channel, height: u32) -> ChannelTxFacts { + ChannelTxFacts::new(test_txid(20)) + .with_outputs(channel, None, ChannelOutputRole::Funding, [0]) + .reported_at_height(height) + } + + /// A retention check that would drop the facts it is given: nothing is held, nothing is + /// pending, the funding is spent and settled, and the age cap has long passed. + fn everything_resolved<'a>( + live_channels: &'a HashSet, pending_txids: &'a HashSet, + ) -> RetentionCheck<'a> { + RetentionCheck { + tip_height: 100_000, + retention_blocks: 52_560, + live_channels, + pending_txids, + funding_spends_settled: true, + } + } + + #[test] + fn facts_of_a_resolved_channel_are_prunable() { + let channel = test_channel(1); + let (live, pending) = (HashSet::new(), HashSet::new()); + assert!(funding_facts(&channel, 10).is_prunable(&everything_resolved(&live, &pending))); + } + + #[test] + fn facts_are_kept_until_the_age_cap_has_passed() { + let channel = test_channel(1); + let (live, pending) = (HashSet::new(), HashSet::new()); + let facts = funding_facts(&channel, 50_000); + + let mut check = everything_resolved(&live, &pending); + check.tip_height = 50_000 + 52_560 - 1; + assert!(!facts.is_prunable(&check), "a block short of the cap is short of it"); + + check.tip_height = 50_000 + 52_560; + assert!(facts.is_prunable(&check)); + } + + #[test] + fn facts_are_kept_while_the_node_still_holds_their_channel() { + let channel = test_channel(1); + let pending = HashSet::new(); + let live: HashSet = [channel.channel_id].into_iter().collect(); + assert!(!funding_facts(&channel, 10).is_prunable(&everything_resolved(&live, &pending))); + + // Another channel being held says nothing about this one. + let other: HashSet = [test_channel(2).channel_id].into_iter().collect(); + assert!(funding_facts(&channel, 10).is_prunable(&everything_resolved(&other, &pending))); + } + + #[test] + fn facts_are_kept_while_a_pending_payment_names_their_transaction() { + let channel = test_channel(1); + let facts = funding_facts(&channel, 10); + let live = HashSet::new(); + let pending: HashSet = [facts.txid].into_iter().collect(); + assert!(!facts.is_prunable(&everything_resolved(&live, &pending))); + } + + #[test] + fn facts_are_kept_until_the_funding_they_record_is_spent_and_settled() { + let channel = test_channel(1); + let (live, pending) = (HashSet::new(), HashSet::new()); + let mut check = everything_resolved(&live, &pending); + check.funding_spends_settled = false; + + assert!(!funding_facts(&channel, 10).is_prunable(&check)); + + // Facts recording no funding of their own have no spend of one to wait for: what a + // commitment transaction's anchors and HTLCs say is answered by the age cap and by + // whether the channel is still held. + let no_funding = ChannelTxFacts::new(test_txid(21)) + .with_outputs(&channel, None, ChannelOutputRole::Anchor, [0]) + .reported_at_height(10); + let mut settled = check; + settled.funding_spends_settled = true; + assert!(no_funding.is_prunable(&settled)); + } + + #[test] + fn a_channel_any_of_the_three_sources_names_counts_as_held() { + let (open, monitored, swept) = (ChannelId([1; 32]), ChannelId([2; 32]), ChannelId([3; 32])); + + assert_eq!(live_channels_of([], [], []), HashSet::new()); + assert_eq!(live_channels_of([open], [], []), [open].into_iter().collect()); + assert_eq!(live_channels_of([], [monitored], []), [monitored].into_iter().collect()); + assert_eq!(live_channels_of([], [], [Some(swept)]), [swept].into_iter().collect()); + + // A tracked output without a channel names none, and a channel several sources name is + // named once. + assert_eq!( + live_channels_of([open], [open, monitored], [Some(swept), None]), + [open, monitored, swept].into_iter().collect(), + ); + } + + #[test] + fn a_record_is_dated_at_the_last_report_that_added_to_it() { + let channel = test_channel(1); + let first = ChannelTxFacts::new(test_txid(31)) + .with_outputs(&channel, None, ChannelOutputRole::Funding, [0]) + .reported_at_height(700); + + // A replay adds nothing, so it writes nothing and cannot refresh the record's age. + let replay = first.clone().reported_at_height(900); + assert_eq!(first.clone().merged_with(&replay).unwrap(), None); + + let later = ChannelTxFacts::new(test_txid(31)) + .with_outputs(&channel, None, ChannelOutputRole::Anchor, [1]) + .reported_at_height(900); + let merged = first.merged_with(&later).unwrap().expect("the anchor is new"); + assert_eq!(merged.recorded_at_height, 900); + } } From c43a71f44b016cecc554977dcf95c9d800f514a7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 21:05:53 +0200 Subject: [PATCH 42/49] Bound what the channel facts store may hold Records of what a channel reported about its transactions are dropped only once that channel has resolved, so between two of those passes a counterparty decides how much this node stores: how many HTLCs it puts on a commitment transaction, and how many channels and negotiated fundings it drives. A record is now refused once it would outgrow what one record may take up, and a transaction this node holds no record of at all is refused once the store holds as many records as it may. What this node already took on is still kept up to date however full the store is, so an obligation is never half-kept; refusing is only ever about taking on a new one. The store's size comes from the walk the dropping pass already makes: it visits every record over consecutive chain tips, so the count it arrives at is the store's own, without a second pass over it and without holding an index of every transaction in memory. A refusal is reported as an incomplete record rather than as a failure. There is nothing to retry -- a replay would meet the same full store -- and the cost is a transaction reported without a classification, which is bounded loss of detail rather than a lost write. Co-Authored-By: HAL 9000 --- src/config.rs | 17 ++++ src/event.rs | 25 +++-- src/wallet/mod.rs | 154 ++++++++++++++++++++++++++++--- src/wallet/provenance.rs | 195 ++++++++++++++++++++++++++++++++++----- 4 files changed, 350 insertions(+), 41 deletions(-) diff --git a/src/config.rs b/src/config.rs index 60c7497771..6375ed2e54 100644 --- a/src/config.rs +++ b/src/config.rs @@ -91,6 +91,23 @@ pub(crate) const CHANNEL_TX_FACTS_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsiz // without the cap such a record would be kept forever. pub(crate) const CHANNEL_TX_FACTS_RETENTION_BLOCKS: u32 = 52_560; +// The number of bytes one channel transaction provenance record may take up. +// +// A record is written whole and holds one entry per channel-controlled output of its transaction, +// so a counterparty loading a commitment transaction with HTLCs grows a record this node is +// obliged to keep. The limit is comfortably above a commitment transaction carrying the most +// HTLCs LDK allows, and bounds what any single transaction can cost. +pub(crate) const CHANNEL_TX_FACTS_MAX_RECORD_BYTES: usize = 128 * 1024; + +// The number of channel transaction provenance records the node keeps. +// +// Records are dropped only once the channels they belong to have resolved, so between prunes a +// counterparty opening and closing channels, or replacing a negotiated funding again and again, +// drives the store's growth. Past this many records nothing new is admitted and the transactions +// it would have described go unclassified, which is bounded loss of detail rather than unbounded +// storage. +pub(crate) const CHANNEL_TX_FACTS_MAX_RECORDS: usize = 100_000; + // The number of pages of channel transaction provenance records one chain tip change examines. // // Pruning shares the pass that graduates payments, so it has to leave promptly; it resumes where diff --git a/src/event.rs b/src/event.rs index 6ef2c642b6..952aba224c 100644 --- a/src/event.rs +++ b/src/event.rs @@ -69,7 +69,7 @@ use crate::types::{ ChainMonitor, CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, }; -use crate::wallet::provenance::{ChannelOutputRole, ChannelTxFacts}; +use crate::wallet::provenance::{ChannelOutputRole, ChannelTxFacts, FactsRecordOutcome}; use crate::wallet::{ closed_channel_held_rounds, funding_candidates, held_splice_rounds, FundingCandidate, }; @@ -1012,14 +1012,25 @@ where ChannelOutputRole::Funding, [vout as u32], ); - if let Err(e) = self.wallet.record_channel_tx_facts(facts).await { - log_error!( + match self.wallet.record_channel_tx_facts(facts).await { + Ok(FactsRecordOutcome::Recorded) => {}, + // Replaying would rebuild the same transaction and find the same + // full store, so the channel is funded with a transaction this + // node will report without a classification. + Ok(FactsRecordOutcome::Incomplete) => log_error!( self.logger, - "Failed to record the funding transaction of channel {}: {}", + "Funding channel {} with a transaction this node has no room to describe", temporary_channel_id, - e, - ); - return Err(ReplayEvent()); + ), + Err(e) => { + log_error!( + self.logger, + "Failed to record the funding transaction of channel {}: {}", + temporary_channel_id, + e, + ); + return Err(ReplayEvent()); + }, } } else { log_error!( diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 25dd4f19ef..c395da2e38 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -57,7 +57,7 @@ use persist::KVStoreWalletPersister; use crate::channel::is_same_splice; use crate::config::{ - Config, ADDRESS_POOL_SIZE, CHANNEL_TX_FACTS_PRUNE_PAGES_PER_TIP, + Config, ADDRESS_POOL_SIZE, CHANNEL_TX_FACTS_MAX_RECORDS, CHANNEL_TX_FACTS_PRUNE_PAGES_PER_TIP, CHANNEL_TX_FACTS_RETENTION_BLOCKS, }; #[cfg(test)] @@ -74,8 +74,8 @@ use crate::payment::{ use crate::runtime::Runtime; use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; use crate::wallet::provenance::{ - ChannelLiveness, ChannelTxFacts, FactsRetention, LocalFundingFigures, RetentionCheck, - TxProvenance, + ChannelLiveness, ChannelTxFacts, ChannelTxFactsRejection, FactsRecordOutcome, FactsRetention, + LocalFundingFigures, RetentionCheck, TxProvenance, }; use crate::{ChainSource, Error}; @@ -229,27 +229,66 @@ impl Wallet { /// Re-recording facts already known writes nothing, so a producer may safely replay its /// event. Facts that contradict what is recorded are rejected and logged rather than /// overwriting it: one of the two producers is wrong, and the recorded facts came first. - pub(crate) async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) -> Result<(), Error> { + /// + /// A report the store has no room for is likewise refused, and reported as + /// [`FactsRecordOutcome::Incomplete`] rather than as a failure: there is nothing to retry, + /// and the consequence is a transaction this node cannot say anything about, not a lost + /// write. Only what this node has no record of at all is refused that way — a transaction it + /// already describes goes on being described, however full the store is. + pub(crate) async fn record_channel_tx_facts( + &self, facts: ChannelTxFacts, + ) -> Result { let txid = facts.txid; // Dated by the chain tip the report arrives at, which is what retention measures from. let facts = facts.reported_at_height(self.latest_checkpoint_height()); // The rejection is reported out of the closure rather than through it, so that the read, // the merge and the write stay one critical section of the store's mutation lock. - let mut conflict = None; + let mut rejection = None; + let mut created = false; self.channel_tx_facts_store .mutate(&txid, |current| match current { Some(recorded) => match recorded.clone().merged_with(&facts) { Ok(merged) => merged, Err(e) => { - conflict = Some(e); + rejection = Some(e); + None + }, + }, + // A transaction nothing is recorded of yet needs room of its own; one already on + // record is merged into above however full the store is, so an obligation this + // node took on is never half-kept. + None if !self.facts_retention.has_room() => { + rejection = Some(ChannelTxFactsRejection::NoRoom { + limit: CHANNEL_TX_FACTS_MAX_RECORDS, + }); + None + }, + None => match facts.clone().size_checked() { + Ok(checked) => { + created = true; + Some(checked) + }, + Err(e) => { + rejection = Some(e); None }, }, - None => Some(facts), }) .await?; + if created { + self.facts_retention.record_created(); + } - match conflict { + match rejection { + Some(e) if e.is_resource_limit() => { + log_error!( + self.logger, + "Not recording what transaction {} is: {}. It will be reported without a classification", + txid, + e, + ); + Ok(FactsRecordOutcome::Incomplete) + }, Some(e) => { log_error!( self.logger, @@ -259,7 +298,7 @@ impl Wallet { ); Err(Error::PersistenceFailed) }, - None => Ok(()), + None => Ok(FactsRecordOutcome::Recorded), } } @@ -872,7 +911,8 @@ impl Wallet { /// way the facts could still be needed, so any one of them keeps them. /// /// The walk of the store resumes where the previous tip left it, so a batch costs one page - /// however large the store is. + /// however large the store is, and a full walk doubles as the census the admission of new + /// records is bounded by. /// /// Nothing here is reported to the caller: dropping records is housekeeping, and failing the /// chain tip pass over it would cost the payment graduations it shares the pass with. @@ -892,9 +932,11 @@ impl Wallet { // fail every tip from here on: start the walk over instead. log_error!(self.logger, "Failed to list recorded channel facts: {}", e); walk.cursor = None; + walk.seen = 0; return; }, }; + walk.seen = walk.seen.saturating_add(page.objects.len()); for facts in page.objects { let check = RetentionCheck { @@ -914,6 +956,8 @@ impl Wallet { let txid = facts.txid; match self.drop_recorded_facts(facts).await { Ok(true) => { + walk.seen = walk.seen.saturating_sub(1); + self.facts_retention.record_dropped(); log_debug!( self.logger, "Dropped what was recorded about transaction {}: nothing needs it anymore", @@ -933,8 +977,11 @@ impl Wallet { match page.next_page_token { Some(token) => walk.cursor = Some(token), None => { - // The walk has been all the way round; start the next one from the beginning. + // The walk has been all the way round, so what it counted is what the store + // holds. Start the next one from the beginning. + self.facts_retention.walk_completed(walk.seen); walk.cursor = None; + walk.seen = 0; break; }, } @@ -10677,6 +10724,8 @@ mod tests { wallet.channel_tx_facts(&funding_txid).await.is_none(), "nothing holds the channel and its funding is long spent", ); + // The walk went all the way round, so the store's size is known from here on. + assert_eq!(wallet.facts_retention.counted(), Some(0)); } #[tokio::test] @@ -10849,4 +10898,87 @@ mod tests { let kept = wallet.channel_tx_facts(&funding_txid).await.expect("the record stays"); assert_eq!(kept.outputs.len(), 2); } + + #[tokio::test] + async fn a_full_store_leaves_a_new_transaction_undescribed() { + 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 channel = Channel { counterparty_node_id, channel_id }; + + let admitted = Txid::from_byte_array([46u8; 32]); + assert_eq!( + wallet + .record_channel_tx_facts(ChannelTxFacts::new(admitted).with_outputs( + &channel, + None, + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(), + FactsRecordOutcome::Recorded, + ); + + // A walk of the store found it as full as it may get. + wallet.facts_retention.walk_completed(CHANNEL_TX_FACTS_MAX_RECORDS); + + // What the node already took on is still kept up to date... + assert_eq!( + wallet + .record_channel_tx_facts(ChannelTxFacts::new(admitted).with_outputs( + &channel, + None, + ChannelOutputRole::Anchor, + [1], + )) + .await + .unwrap(), + FactsRecordOutcome::Recorded, + ); + let kept = wallet.channel_tx_facts(&admitted).await.expect("the record stays"); + assert_eq!(kept.outputs.len(), 2); + + // ...while a transaction it holds no record of is refused, and said to be refused. + let refused = Txid::from_byte_array([47u8; 32]); + assert_eq!( + wallet + .record_channel_tx_facts(ChannelTxFacts::new(refused).with_outputs( + &channel, + None, + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(), + FactsRecordOutcome::Incomplete, + ); + assert!(wallet.channel_tx_facts(&refused).await.is_none()); + + // The cost of the refusal is a transaction reported without a classification, rather + // than one reported as something it may not be. + let close = tx_spending(&wallet, OutPoint { txid: refused, vout: 0 }); + let close_txid = close.compute_txid(); + insert_unconfirmed_tx(&wallet, close.clone()); + wallet + .update_payment_store(vec![WalletEvent::TxUnconfirmed { + txid: close_txid, + tx: Arc::new(close), + old_block_time: None, + }]) + .await + .unwrap(); + let payment = wallet + .payment_stores + .payment_store() + .get(&PaymentId(close_txid.to_byte_array())) + .await + .unwrap() + .expect("wallet sync records the transaction"); + assert!( + matches!(payment.kind, PaymentKind::Onchain { tx_type: None, .. }), + "unexpected kind {:?}", + payment.kind, + ); + } } diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index a85bbb769c..174f6ac961 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -16,7 +16,7 @@ use std::collections::{HashMap, HashSet}; use std::fmt; -use std::sync::{Arc, Weak}; +use std::sync::{Arc, Mutex, Weak}; use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; @@ -24,8 +24,10 @@ use bitcoin::{Sequence, Transaction, Txid}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::types::ChannelId; use lightning::util::persist::PageToken; +use lightning::util::ser::Writeable; use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use crate::config::{CHANNEL_TX_FACTS_MAX_RECORDS, CHANNEL_TX_FACTS_MAX_RECORD_BYTES}; use crate::data_store::{StorableObject, StorableObjectId}; use crate::hex_utils; use crate::payment::store::{Channel, TransactionType}; @@ -204,15 +206,16 @@ impl ChannelTxFacts { /// Outputs are unioned by `vout`, while `self_role` and `local_figures` are filled in only /// where they are still absent. Re-reporting a fact is therefore a no-op, which is what lets /// a producer replay its event without consequence. Reporting a *different* value for - /// something already recorded is rejected, leaving the recorded facts as they were. + /// something already recorded is rejected, leaving the recorded facts as they were, and so is + /// a report that would take the record past the size a single record is allowed. /// /// A merge that changes something dates the record at the incoming report's height, so that /// retention measures how long ago this node last learned anything about the transaction. pub(crate) fn merged_with( mut self, incoming: &ChannelTxFacts, - ) -> Result, ChannelTxFactsConflict> { + ) -> Result, ChannelTxFactsRejection> { if self.txid != incoming.txid { - return Err(ChannelTxFactsConflict::Txid { + return Err(ChannelTxFactsRejection::Txid { recorded: self.txid, incoming: incoming.txid, }); @@ -223,7 +226,7 @@ impl ChannelTxFacts { match self.outputs.iter().find(|recorded| recorded.vout == output.vout) { Some(recorded) if recorded == output => {}, Some(recorded) => { - return Err(ChannelTxFactsConflict::Output { + return Err(ChannelTxFactsRejection::Output { recorded: recorded.clone(), incoming: output.clone(), }) @@ -237,7 +240,7 @@ impl ChannelTxFacts { match (&self.self_role, &incoming.self_role) { (Some(recorded), Some(incoming)) if recorded != incoming => { - return Err(ChannelTxFactsConflict::SelfRole { + return Err(ChannelTxFactsRejection::SelfRole { recorded: recorded.clone(), incoming: incoming.clone(), }) @@ -251,7 +254,7 @@ impl ChannelTxFacts { match (&self.local_figures, &incoming.local_figures) { (Some(recorded), Some(incoming)) if recorded != incoming => { - return Err(ChannelTxFactsConflict::LocalFigures { + return Err(ChannelTxFactsRejection::LocalFigures { recorded: recorded.clone(), incoming: incoming.clone(), }) @@ -267,8 +270,37 @@ impl ChannelTxFacts { return Ok(None); } self.recorded_at_height = self.recorded_at_height.max(incoming.recorded_at_height); - Ok(Some(self)) + self.size_checked().map(Some) } + + /// These facts, or a rejection when storing them would take one record past the size a + /// record is allowed. + /// + /// A record is written whole, so its size is the one resource a producer drives without + /// creating a record of its own: every channel-controlled output of a transaction lands on + /// that transaction's record, and a counterparty decides how many HTLCs a commitment + /// transaction carries. What a refused report would have described stays unclassifiable. + pub(crate) fn size_checked(self) -> Result { + let bytes = self.serialized_length(); + if bytes > CHANNEL_TX_FACTS_MAX_RECORD_BYTES { + return Err(ChannelTxFactsRejection::TooLarge { + bytes, + limit: CHANNEL_TX_FACTS_MAX_RECORD_BYTES, + }); + } + Ok(self) + } +} + +/// What became of a producer's report of what a transaction is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FactsRecordOutcome { + /// Everything reported is on record. + Recorded, + /// Part of what was reported is not on record, because recording it would have taken the + /// facts past the resources they are allowed. Transactions that would have been classified + /// from the missing part are reported without a classification instead. + Incomplete, } /// The channels this node still holds on-chain state for, as the retention of recorded facts @@ -335,30 +367,72 @@ pub(crate) fn live_channels_of( live } -/// How far the pruning of recorded facts has walked the store. +/// How far the pruning of recorded facts has walked the store, and how many records that walk +/// found there. /// -/// The walk visits every record over consecutive chain tips rather than in one pass, so a batch -/// costs one page however large the store is. +/// The walk is what keeps the store's size known: it visits every record over consecutive chain +/// tips, so the count it arrives at is the store's own, without a second pass over it and without +/// holding an index of its keys in memory. Between walks the count follows the records created +/// and dropped, so it is exact except for records created during a walk that the walk had already +/// gone past — those are counted by the walk after, which bounds how far the store can run past +/// its limit at one walk's worth of growth. pub(crate) struct FactsRetention { - /// Where the walk resumes, held by the pruning pass alone. + /// Where the walk resumes and what it has counted, held by the pruning pass alone. walk: tokio::sync::Mutex, + /// How many records the store holds. `None` until a walk has completed, until when nothing + /// is refused for want of room. + count: Mutex>, } /// The pruning pass's place in its walk of the store. pub(crate) struct FactsWalk { /// Where the next batch resumes, or `None` to walk the store from the start. pub cursor: Option, + /// How many records this walk has counted so far. + pub seen: usize, } impl FactsRetention { pub(crate) fn new() -> Self { - Self { walk: tokio::sync::Mutex::new(FactsWalk { cursor: None }) } + Self { + walk: tokio::sync::Mutex::new(FactsWalk { cursor: None, seen: 0 }), + count: Mutex::new(None), + } } /// Takes the pruning pass's place in its walk, for as long as the guard lives. pub(crate) async fn walk(&self) -> tokio::sync::MutexGuard<'_, FactsWalk> { self.walk.lock().await } + + /// Whether the store has room for a record it does not hold yet. + pub(crate) fn has_room(&self) -> bool { + self.count.lock().expect("lock").map_or(true, |count| count < CHANNEL_TX_FACTS_MAX_RECORDS) + } + + /// Notes that a record was created. + pub(crate) fn record_created(&self) { + if let Some(count) = self.count.lock().expect("lock").as_mut() { + *count = count.saturating_add(1); + } + } + + /// Notes that a record was dropped. + pub(crate) fn record_dropped(&self) { + if let Some(count) = self.count.lock().expect("lock").as_mut() { + *count = count.saturating_sub(1); + } + } + + /// Notes that a walk of the whole store ended having counted `seen` records. + pub(crate) fn walk_completed(&self, seen: usize) { + *self.count.lock().expect("lock") = Some(seen); + } + + #[cfg(test)] + pub(crate) fn counted(&self) -> Option { + *self.count.lock().expect("lock") + } } /// What deciding whether a transaction's facts are still needed takes, beyond the facts @@ -428,12 +502,13 @@ impl StorableObject for ChannelTxFacts { } } -/// A reported fact that contradicts one already recorded for the same transaction. +/// A reported fact that was not recorded, leaving what is on record as it was. /// -/// Facts are immutable, so this means two producers disagree about the same transaction, which -/// they cannot both be right about. The recorded value stands and the reported one is dropped. +/// Most of these mean two producers disagree about the same transaction, which they cannot both +/// be right about: facts are immutable, so the recorded value stands and the reported one is +/// dropped. The remaining one is a report the record has no room for. #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum ChannelTxFactsConflict { +pub(crate) enum ChannelTxFactsRejection { /// The reported facts are about a different transaction altogether. Txid { recorded: Txid, incoming: Txid }, /// The same output is reported with a different role or a different channel. @@ -442,9 +517,24 @@ pub(crate) enum ChannelTxFactsConflict { SelfRole { recorded: TransactionType, incoming: TransactionType }, /// This node's share of the transaction is reported differently than it is recorded. LocalFigures { recorded: LocalFundingFigures, incoming: LocalFundingFigures }, + /// Recording the report would take the transaction's record past the size one record is + /// allowed. + TooLarge { bytes: usize, limit: usize }, + /// The store holds as many records as it is allowed to, and this report is about a + /// transaction it holds no record of. + NoRoom { limit: usize }, } -impl fmt::Display for ChannelTxFactsConflict { +impl ChannelTxFactsRejection { + /// Whether the report was refused for want of room rather than because it contradicts what is + /// on record. Both leave the recorded facts as they were, but only a contradiction says a + /// producer is wrong about something. + pub(crate) fn is_resource_limit(&self) -> bool { + matches!(self, Self::TooLarge { .. } | Self::NoRoom { .. }) + } +} + +impl fmt::Display for ChannelTxFactsRejection { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Txid { recorded, incoming } => { @@ -459,6 +549,12 @@ impl fmt::Display for ChannelTxFactsConflict { Self::LocalFigures { recorded, incoming } => { write!(f, "local funding figures {:?} reported as {:?}", recorded, incoming) }, + Self::TooLarge { bytes, limit } => { + write!(f, "record of {} bytes exceeds the {} bytes allowed", bytes, limit) + }, + Self::NoRoom { limit } => { + write!(f, "no room for a further record beside the {} already held", limit) + }, } } } @@ -801,7 +897,7 @@ mod tests { ChannelTxFacts::new(txid).with_outputs(&channel, None, ChannelOutputRole::Anchor, [0]); match recorded.clone().merged_with(&conflicting) { - Err(ChannelTxFactsConflict::Output { recorded, incoming }) => { + Err(ChannelTxFactsRejection::Output { recorded, incoming }) => { assert_eq!(recorded.role, ChannelOutputRole::Funding); assert_eq!(incoming.role, ChannelOutputRole::Anchor); }, @@ -822,7 +918,7 @@ mod tests { ); assert!(matches!( recorded.merged_with(&reattributed), - Err(ChannelTxFactsConflict::Output { .. }) + Err(ChannelTxFactsRejection::Output { .. }) )); } @@ -838,7 +934,7 @@ mod tests { }); match recorded.clone().merged_with(&conflicting) { - Err(ChannelTxFactsConflict::SelfRole { recorded, incoming }) => { + Err(ChannelTxFactsRejection::SelfRole { recorded, incoming }) => { assert_eq!(recorded, TransactionType::Funding { channels: vec![channel.clone()] }); assert_eq!( incoming, @@ -864,7 +960,7 @@ mod tests { assert!(matches!( recorded.merged_with(&conflicting), - Err(ChannelTxFactsConflict::LocalFigures { .. }) + Err(ChannelTxFactsRejection::LocalFigures { .. }) )); } @@ -874,7 +970,7 @@ mod tests { let other = ChannelTxFacts::new(test_txid(8)); assert_eq!( recorded.merged_with(&other), - Err(ChannelTxFactsConflict::Txid { recorded: test_txid(7), incoming: test_txid(8) }) + Err(ChannelTxFactsRejection::Txid { recorded: test_txid(7), incoming: test_txid(8) }) ); } @@ -1306,6 +1402,41 @@ mod tests { ); } + #[test] + fn a_report_that_would_outgrow_one_record_is_refused() { + let channel = test_channel(1); + let recorded = ChannelTxFacts::new(test_txid(30)).with_outputs( + &channel, + None, + ChannelOutputRole::Htlc, + 0..8, + ); + + // One output costs well under a hundred bytes, so a report of this many cannot fit. + let oversized = ChannelTxFacts::new(test_txid(30)).with_outputs( + &channel, + None, + ChannelOutputRole::Htlc, + 8..40_000, + ); + match recorded.clone().merged_with(&oversized) { + Err(ChannelTxFactsRejection::TooLarge { bytes, limit }) => { + assert!(bytes > limit, "{} is not past {}", bytes, limit); + assert_eq!(limit, CHANNEL_TX_FACTS_MAX_RECORD_BYTES); + }, + Ok(merged) => panic!( + "unexpected merge outcome: {} outputs recorded", + merged.map_or(0, |facts| facts.outputs.len()), + ), + Err(e) => panic!("unexpected rejection {:?}", e), + } + // The refusal is what the caller sees; what is on record is untouched, as it is for a + // contradiction. + assert_eq!(recorded.clone().merged_with(&recorded).unwrap(), None); + assert!(oversized.size_checked().is_err()); + assert!(recorded.size_checked().is_ok()); + } + #[test] fn a_record_is_dated_at_the_last_report_that_added_to_it() { let channel = test_channel(1); @@ -1323,4 +1454,22 @@ mod tests { let merged = first.merged_with(&later).unwrap().expect("the anchor is new"); assert_eq!(merged.recorded_at_height, 900); } + + #[test] + fn the_census_bounds_admission_only_once_a_walk_has_counted_the_store() { + let retention = FactsRetention::new(); + assert_eq!(retention.counted(), None); + // Nothing is refused while the store's size is unknown, however much is created. + for _ in 0..CHANNEL_TX_FACTS_MAX_RECORDS + 1 { + retention.record_created(); + } + assert!(retention.has_room()); + + retention.walk_completed(CHANNEL_TX_FACTS_MAX_RECORDS - 1); + assert!(retention.has_room()); + retention.record_created(); + assert!(!retention.has_room(), "the store is full"); + retention.record_dropped(); + assert!(retention.has_room(), "dropping a record makes room"); + } } From ca2db6b9dc20436eaffa0dedf30c7c7cd247bedb Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 21:06:20 +0200 Subject: [PATCH 43/49] Take back a splice intent its payment left behind A splice is persisted with nothing but its intent before anything about it has been observed. Wallet sync promotes that entry once it sees the transaction -- unless the payment has already advanced past pending, in which case no entry belongs in the store and the write is declined. Declining left the bare intent where it was, so a splice that was over went on looking like one still in flight, and the next restart acted on it. The intent is now taken back instead, and only while the entry still is the bare one: a round signed or a fee bump submitted since then is live state of its own, which the splice lifecycle has to be the one to resolve. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 109 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index c395da2e38..52227bc917 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3311,6 +3311,10 @@ impl Wallet { conflicting_txids: Vec, ) -> Result<(), Error> { let id = payment.id; + let mut leftover_intent_to_remove = None; + // The `move` closure would capture the `Option` by value, so hand it a reference; the + // borrow ends with the mutate's future, before the leftover is read below. + let leftover = &mut leftover_intent_to_remove; stores .mutate_pending_payment_async(&id, move |existing| async move { // Only `Pending` payments belong in the pending store. Like in @@ -3323,6 +3327,15 @@ impl Wallet { recorded.status == PaymentStatus::Pending }); if !is_pending { + // A bare splice intent under an advanced payment's id is the leftover of the + // splice that payment settles. Taking it back is left to the removal below, + // so that it happens under a check of what the entry still is; leaving it + // would have the next restart act on a splice that is long over. + if let Some(entry) = existing { + if entry.details().is_none() { + *leftover = entry.splice_intent; + } + } return Ok(None); } Ok(match existing { @@ -3345,6 +3358,19 @@ impl Wallet { }) }) .await?; + + if let Some(intent) = leftover_intent_to_remove { + // Only while the entry still is the bare intent the closure saw: a round signed or a + // fee bump submitted in between joins the entry, and what those track must stay. + stores + .remove_pending_payment_if(&id, |entry| { + entry.details().is_none() + && entry.candidates().is_empty() + && entry.locked_rounds().is_empty() + && entry.splice_intent() == Some(&intent) + }) + .await?; + } Ok(()) } @@ -10981,4 +11007,87 @@ mod tests { payment.kind, ); } + + #[tokio::test] + async fn recording_a_transaction_of_an_advanced_payment_removes_its_leftover_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([23u8; 32]); + let txid = Txid::from_byte_array([24u8; 32]); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, test_splice_intent())) + .await + .unwrap(); + // Wallet sync confirmed the payment through `ANTI_REORG_DELAY` before it got to write the + // entry: the payment graduated, so no entry belongs in the pending store... + wallet + .payment_stores + .payment_store() + .insert(funding_payment(id, txid, PaymentStatus::Succeeded)) + .await + .unwrap(); + + let stores = wallet.payment_stores.lock().await; + wallet + .upsert_pending_payment( + &stores, + funding_payment(id, txid, PaymentStatus::Pending), + Vec::new(), + ) + .await + .unwrap(); + drop(stores); + + // ...and the splice behind the intent confirmed, so the leftover intent record is removed + // rather than left to look like a splice still in flight after a restart. + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn a_leftover_intent_that_tracks_a_signed_round_is_kept() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([23u8; 32]); + let txid = Txid::from_byte_array([24u8; 32]); + let mut entry = PendingPaymentDetails::pending_splice(id, test_splice_intent()); + entry.candidates = vec![FundingTxCandidate { + txid: Txid::from_byte_array([25u8; 32]), + amount_msat: Some(1_000), + fee_paid_msat: Some(10), + awaiting_broadcast: true, + }]; + wallet.payment_stores.pending_payment_store().insert(entry).await.unwrap(); + wallet + .payment_stores + .payment_store() + .insert(funding_payment(id, txid, PaymentStatus::Succeeded)) + .await + .unwrap(); + + let stores = wallet.payment_stores.lock().await; + wallet + .upsert_pending_payment( + &stores, + funding_payment(id, txid, PaymentStatus::Pending), + Vec::new(), + ) + .await + .unwrap(); + drop(stores); + + // A round this node signed is live state of its own: the entry is what + // `drop_abandoned_splice_rounds` takes it back through, so it is not a leftover. + let kept = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert_eq!(kept.candidates().len(), 1); + } } From 7815b755ba92c6110330570b10bdc04ebd39ee6f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 21:35:58 +0200 Subject: [PATCH 44/49] Skip a replacement whose payment record is gone What a transaction's facts record names its payment from the moment a round is signed, which is before wallet sync creates the record and for as long as the facts are kept after `remove_payment` has taken it away. Those facts describe a transaction that happened and still classify later ones, so a bookkeeping removal leaves them where they are. Resolving a replaced transaction can therefore name a payment nothing holds a record of. That now skips the event, as a transaction resolving to no payment at all already did, rather than failing: the failure abandoned every remaining event of the batch, and the wallet's own view of the chain went unpersisted with it, discarding an ordinary sync. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 100 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 11 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 52227bc917..d3f5603e67 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -764,17 +764,20 @@ impl Wallet { conflicts.iter().map(|(_, conflict_txid)| *conflict_txid).collect(); conflict_txids.push(txid); - // The payment already exists in the store at this point: `bump_fee_rbf` - // updates the payment store with the replacement txid before the next sync - // cycle, and sync itself records a transaction the first time it observes it, - // before anything can report it replaced. So we can safely fetch it here. - let stored_payment = stores.payment(&payment_id).await?; - debug_assert!( - stored_payment.is_some(), - "Payment {:?} expected in store during WalletEvent::TxReplaced but not found", - payment_id, - ); - let payment = stored_payment.ok_or(Error::InvalidPaymentId)?; + // An id outlives its record: the facts recorded when a round was signed + // name its payment before anything has created it, and go on naming it + // once `remove_payment` has taken it away. Neither leaves anything to + // update here, and failing would abandon the rest of the batch and the + // wallet's own view of the chain with it. + let Some(payment) = stores.payment(&payment_id).await? else { + log_debug!( + self.logger, + "No payment {} on record for replaced transaction {}. Skipping.", + payment_id, + txid, + ); + continue; + }; // 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 @@ -3378,6 +3381,10 @@ impl Wallet { /// entry indexing its txids. An orphaned entry would keep resolving those txids to the removed /// record — routing later wallet-sync events to a payment that no longer exists — and nothing /// would ever clean it up, since graduation only removes entries whose record is still live. + /// + /// What this node recorded about the transactions themselves stays behind: those facts + /// describe transactions that happened, and classifying a later transaction — a close + /// spending a funding output, say — still reads them. pub(crate) async fn remove_payment(&self, payment_id: &PaymentId) -> Result<(), Error> { // Hold the cross-store lock so the two-store removal cannot interleave with a sync arm's // or a funding-record writer's resolve-then-write sequence. The pending entry goes first: a failure @@ -7986,6 +7993,77 @@ mod tests { assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); } + /// A round's facts name its payment for as long as they are kept, which outlasts the record: + /// they describe a transaction that happened, so `remove_payment` leaves them behind. A later + /// wallet event naming that transaction therefore resolves an id whose record is gone, and + /// has to skip — failing would abandon the rest of the batch and the wallet's own view of the + /// chain with it. + #[tokio::test] + async fn a_replacement_of_a_removed_payments_transaction_is_skipped() { + 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(); + + // A signed splice round the wallet has observed: the facts name its payment and sync has + // created the record. + 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))]); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let payment_id = + wallet.find_payment_by_txid(txid).await.unwrap().expect("the round names its payment"); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); + + wallet.remove_payment(&payment_id).await.unwrap(); + assert_eq!( + wallet.find_payment_by_txid(txid).await.unwrap(), + Some(payment_id), + "the round's facts go on naming the payment the user removed", + ); + + // The user fee-bumps the splice, so the wallet reports the signed round replaced. A + // second event in the same batch pins that the batch goes on being handled. + let other = wallet_paying_tx(&wallet, 2); + let other_txid = other.compute_txid(); + insert_unconfirmed_tx(&wallet, other.clone()); + let events = vec![ + WalletEvent::TxReplaced { + txid, + tx: Arc::new(tx.clone()), + conflicts: vec![(0, Txid::from_byte_array([0xB1; 32]))], + }, + WalletEvent::TxUnconfirmed { + txid: other_txid, + tx: Arc::new(other), + old_block_time: None, + }, + ]; + wallet.update_payment_store(events).await.unwrap(); + + assert!( + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none(), + "a removed payment must not come back", + ); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + assert!( + wallet + .payment_stores + .payment_store() + .get(&PaymentId(other_txid.to_byte_array())) + .await + .unwrap() + .is_some(), + "the rest of the batch must still be handled", + ); + } + /// Payments without a pending-store entry — lightning payments, and on-chain payments that /// already graduated — must remove cleanly: the unconditional pending-store removal relies /// on removing a missing key being a no-op. From 364621ca911b800df2e9bf2223da0dba7c690d5e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 21:39:23 +0200 Subject: [PATCH 45/49] Refuse to sign a round this node cannot measure Recording what a signed interactive funding round is, and what this node's share of it comes to, can be refused for want of room, and the signing went ahead regardless. The transaction was then released with nothing on record about this node's contribution, so whoever first observed it recorded the wallet's view of a funding output both parties own: the whole of it read as this node's spend, a figure nothing later corrects. The refusal now fails the signing. LDK re-offers the event while the transaction is unsigned, so the cost is a splice that does not complete until the dropping pass frees room, rather than a payment reporting what it is not. The commit introducing the refusal said its cost was a transaction reported without a classification. That holds for a producer reporting on something it has already released, which still only logs, and the places restating it now distinguish the two. Co-Authored-By: HAL 9000 --- src/config.rs | 7 ++-- src/event.rs | 21 ++++++++-- src/wallet/mod.rs | 87 +++++++++++++++++++++++++++++++--------- src/wallet/provenance.rs | 9 +++-- 4 files changed, 96 insertions(+), 28 deletions(-) diff --git a/src/config.rs b/src/config.rs index 6375ed2e54..e85d69f0e0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -103,9 +103,10 @@ pub(crate) const CHANNEL_TX_FACTS_MAX_RECORD_BYTES: usize = 128 * 1024; // // Records are dropped only once the channels they belong to have resolved, so between prunes a // counterparty opening and closing channels, or replacing a negotiated funding again and again, -// drives the store's growth. Past this many records nothing new is admitted and the transactions -// it would have described go unclassified, which is bounded loss of detail rather than unbounded -// storage. +// drives the store's growth. Past this many records nothing new is admitted. A producer that has +// already released what it reports on loses only the detail, so its transaction goes +// unclassified; one that will not release a transaction it cannot measure comes back for it once +// the dropping pass has freed room. pub(crate) const CHANNEL_TX_FACTS_MAX_RECORDS: usize = 100_000; // The number of pages of channel transaction provenance records one chain tip change examines. diff --git a/src/event.rs b/src/event.rs index 952aba224c..da54b8a174 100644 --- a/src/event.rs +++ b/src/event.rs @@ -948,11 +948,26 @@ where /// /// A failure is logged rather than reported: these facts accompany a transaction this node /// has already released or a claim it has already made, so there is nothing left to withhold, - /// and the producing event is re-offered until the claim resolves. + /// and the producing event is re-offered until the claim resolves. A refusal for want of + /// room costs a transaction reported without a classification, which is likewise nothing + /// this node can take back. async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) { let txid = facts.txid; - if let Err(e) = self.wallet.record_channel_tx_facts(facts).await { - log_error!(self.logger, "Failed to record what channel transaction {} is: {}", txid, e); + match self.wallet.record_channel_tx_facts(facts).await { + Ok(FactsRecordOutcome::Recorded) => {}, + Ok(FactsRecordOutcome::Incomplete) => log_error!( + self.logger, + "Reporting transaction {} without a classification: this node has no room to describe it", + txid, + ), + Err(e) => { + log_error!( + self.logger, + "Failed to record what channel transaction {} is: {}", + txid, + e + ) + }, } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d3f5603e67..4a57c68565 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -231,10 +231,11 @@ impl Wallet { /// overwriting it: one of the two producers is wrong, and the recorded facts came first. /// /// A report the store has no room for is likewise refused, and reported as - /// [`FactsRecordOutcome::Incomplete`] rather than as a failure: there is nothing to retry, - /// and the consequence is a transaction this node cannot say anything about, not a lost - /// write. Only what this node has no record of at all is refused that way — a transaction it - /// already describes goes on being described, however full the store is. + /// [`FactsRecordOutcome::Incomplete`] rather than as a failure: nothing was lost, and what + /// the refusal costs is the reporting producer's to weigh — for most of them a transaction + /// this node cannot say anything about, for one that will not proceed unrecorded a reason to + /// come back. Only what this node has no record of at all is refused that way — a + /// transaction it already describes goes on being described, however full the store is. pub(crate) async fn record_channel_tx_facts( &self, facts: ChannelTxFacts, ) -> Result { @@ -281,12 +282,7 @@ impl Wallet { match rejection { Some(e) if e.is_resource_limit() => { - log_error!( - self.logger, - "Not recording what transaction {} is: {}. It will be reported without a classification", - txid, - e, - ); + log_error!(self.logger, "Not recording what transaction {} is: {}", txid, e,); Ok(FactsRecordOutcome::Incomplete) }, Some(e) => { @@ -2635,7 +2631,9 @@ impl Wallet { /// replayed event), or without a local contribution or wallet-level activity. A failed write /// leaves the caller to replay: a losing RBF candidate's contribution figures exist only while /// the candidate is live in the channel's splice details, and both writes are idempotent, so - /// the replay completes whichever of them was lost. + /// the replay completes whichever of them was lost. A refusal to take the round's facts on + /// for want of room fails the same way, so the transaction stays unsigned rather than being + /// recorded with the wallet's view of a funding output both parties own. /// /// [`ChannelManager::funding_transaction_signed`]: lightning::ln::channelmanager::ChannelManager::funding_transaction_signed pub(crate) async fn record_signed_funding( @@ -2715,14 +2713,26 @@ impl Wallet { // The fact goes first: it is what ties the transaction to this payment, so a failure // afterwards leaves the round attributable rather than a history pointing at a payment // nothing would ever file the transaction under. - self.record_channel_tx_facts( - ChannelTxFacts::new(txid) - .with_self_role(TransactionType::InteractiveFunding { - channels: funding_channels.clone(), - }) - .with_local_figures(figures), - ) - .await?; + // + // A refusal for want of room fails the signing too, though nothing was lost: without + // this node's share on record, whoever first observes the transaction records it with + // the wallet's view of a funding output both parties own — the whole of it read as this + // node's spend. LDK re-offers the event while the transaction is unsigned, so replaying + // costs a splice that does not complete until the retention pass frees room, rather + // than a payment reporting a figure nothing later corrects. + let facts = ChannelTxFacts::new(txid) + .with_self_role(TransactionType::InteractiveFunding { + channels: funding_channels.clone(), + }) + .with_local_figures(figures); + if self.record_channel_tx_facts(facts).await? == FactsRecordOutcome::Incomplete { + log_error!( + self.logger, + "Not signing interactive funding {}: this node's share of it is not on record", + txid, + ); + return Err(Error::PersistenceFailed); + } stores .mutate_pending_payment(&payment_id, |existing| { @@ -5974,6 +5984,45 @@ mod tests { ); } + /// A round whose facts the store has no room for is not signed. Without this node's share on + /// record, whoever first observes the transaction records it with the wallet's view of a + /// funding output both parties own — the whole of it read as this node's spend — and nothing + /// later corrects that. The event is re-offered while the transaction is unsigned, so the + /// splice waits for room rather than being measured wrong. + #[tokio::test] + async fn a_round_this_node_has_no_room_to_measure_is_not_signed() { + 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))]); + + // The store holds as many records as it may, so a transaction it holds none for is + // refused room. + wallet.facts_retention.walk_completed(CHANNEL_TX_FACTS_MAX_RECORDS); + + assert!( + wallet.record_signed_funding(&tx, &candidates).await.is_err(), + "a round this node cannot measure must not be signed", + ); + assert!( + wallet.channel_tx_facts(&txid).await.is_none(), + "nothing was recorded, which is what the refusal means", + ); + assert!( + wallet + .payment_stores + .pending_payment_store() + .list_filter(|entry| entry.candidate(txid).is_some()) + .await + .is_empty(), + "a round the signing refused must not be left in a candidate history", + ); + } + /// A signing event replayed after its pending-store write was lost re-derives the round's /// figures, from a contribution LDK may have adjusted the fee fields of since. The round's /// facts are immutable, so the replay adopts what is on record instead of offering a second diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index 174f6ac961..87adf0adfc 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -279,7 +279,8 @@ impl ChannelTxFacts { /// A record is written whole, so its size is the one resource a producer drives without /// creating a record of its own: every channel-controlled output of a transaction lands on /// that transaction's record, and a counterparty decides how many HTLCs a commitment - /// transaction carries. What a refused report would have described stays unclassifiable. + /// transaction carries. What a refused report would have described stays undescribed, and + /// what that costs is for the producer that reported it to weigh. pub(crate) fn size_checked(self) -> Result { let bytes = self.serialized_length(); if bytes > CHANNEL_TX_FACTS_MAX_RECORD_BYTES { @@ -298,8 +299,10 @@ pub(crate) enum FactsRecordOutcome { /// Everything reported is on record. Recorded, /// Part of what was reported is not on record, because recording it would have taken the - /// facts past the resources they are allowed. Transactions that would have been classified - /// from the missing part are reported without a classification instead. + /// facts past the resources they are allowed. Nothing was lost, so what the refusal costs + /// is the reporting producer's to weigh: a transaction reported without a classification + /// for a producer that has nothing left to withhold, a reason to come back for one that + /// will not proceed unrecorded. Incomplete, } From baebb5b63988dc4ee3efce945234da2d70a96b70 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 22:30:54 +0200 Subject: [PATCH 46/49] f Refuse to sign a round this node cannot measure Refusing the round's facts for want of room failed the signing, and the handler turned that failure into a replay. LDK stops handling events at the first failure and leaves the failing one at the head of its queue, so everything behind it waits, claims on inbound HTLCs among them, while the same pass flags the channel manager for persistence and notifies a waiter that re-enters at once. A full store is not a condition a replay clears: room comes only from the dropping pass, which wants a record a year old whose channels have all resolved. The round's facts are now admitted however many records the store holds. The cap bounds what a counterparty drives by opening and closing channels or replacing a negotiated funding; a round this node chose to sign is not that, and its record names the round and this node's share of it while carrying no outputs. What can still refuse the round is a record already as large as one record may be, which nothing shrinks, so the round is reported unmeasurable and the handler cancels the splice, as it already does where LDK refuses the signed transaction. The failure is then the splice's rather than the node's, and the round is still never released with the wallet's view of a funding output both parties own standing in for this node's share. Co-Authored-By: HAL 9000 --- src/channel/mod.rs | 9 +- src/config.rs | 9 +- src/event.rs | 88 +++++++++------- src/wallet/mod.rs | 210 ++++++++++++++++++++++++++++++++------- src/wallet/provenance.rs | 25 ++++- 5 files changed, 256 insertions(+), 85 deletions(-) diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 7b61e6bf74..141bf9a437 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -28,7 +28,9 @@ use crate::payment::pending_payment_store::{ }; use crate::payment::{PaymentKind, TransactionType}; use crate::types::{ChannelManager, PendingPaymentStore}; -use crate::wallet::{funding_candidates, random_payment_id, FundingCandidate, Wallet}; +use crate::wallet::{ + funding_candidates, random_payment_id, FundingCandidate, SignedFundingRecord, Wallet, +}; use crate::Error; /// Whether two contributions describe the same splice attempt. LDK may adjust a contribution @@ -697,12 +699,13 @@ impl SpliceTracker { /// only [`ChannelManager::funding_transaction_signed`] releases. Holding the submit lock keeps /// the channel's intent records — one of which the funding record adopts — from changing /// mid-write: a concurrent [`Self::submit`] adding or replacing an intent, or a lock or - /// failure event settling one. + /// failure event settling one. Reports whether the round may be signed, as + /// [`Wallet::record_signed_funding`] decides it. /// /// [`ChannelManager::funding_transaction_signed`]: lightning::ln::channelmanager::ChannelManager::funding_transaction_signed pub(crate) async fn on_funding_ready_for_signing( &self, tx: &Transaction, candidates: &[FundingCandidate], - ) -> Result<(), Error> { + ) -> Result { let _guard = self.submit_lock.lock().await; self.wallet.record_signed_funding(tx, candidates).await } diff --git a/src/config.rs b/src/config.rs index e85d69f0e0..ef1e127f70 100644 --- a/src/config.rs +++ b/src/config.rs @@ -103,10 +103,11 @@ pub(crate) const CHANNEL_TX_FACTS_MAX_RECORD_BYTES: usize = 128 * 1024; // // Records are dropped only once the channels they belong to have resolved, so between prunes a // counterparty opening and closing channels, or replacing a negotiated funding again and again, -// drives the store's growth. Past this many records nothing new is admitted. A producer that has -// already released what it reports on loses only the detail, so its transaction goes -// unclassified; one that will not release a transaction it cannot measure comes back for it once -// the dropping pass has freed room. +// drives the store's growth. Past this many records nothing new is admitted and the transactions +// they would have described go unclassified, which is bounded loss of detail rather than +// unbounded storage. An interactive funding round this node is about to sign is admitted beside +// them: how many of those there are is this node's own decision, and refusing one would cost a +// figure nothing later corrects rather than a detail. pub(crate) const CHANNEL_TX_FACTS_MAX_RECORDS: usize = 100_000; // The number of pages of channel transaction provenance records one chain tip change examines. diff --git a/src/event.rs b/src/event.rs index da54b8a174..48be333ae5 100644 --- a/src/event.rs +++ b/src/event.rs @@ -72,6 +72,7 @@ use crate::types::{ use crate::wallet::provenance::{ChannelOutputRole, ChannelTxFacts, FactsRecordOutcome}; use crate::wallet::{ closed_channel_held_rounds, funding_candidates, held_splice_rounds, FundingCandidate, + SignedFundingRecord, }; use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, @@ -919,6 +920,27 @@ where Ok((payment_id, None)) } + /// Cancels a splice whose funding transaction this node will not sign, so that LDK releases + /// what it reserved for this node's contribution through `DiscardFunding` and surfaces the + /// failure through `SpliceNegotiationFailed`, which also settles the persisted intent and + /// takes back the round's record once the round is gone from the channel's history. + /// + /// A refusal means the splice is already beyond cancelling — LDK reset the round itself, or + /// the channel is gone — in which case those reports are on their way regardless and there is + /// nothing further to unwind. + fn cancel_splice(&self, counterparty_node_id: PublicKey, channel_id: ChannelId) { + if let Err(e) = + self.channel_manager.cancel_funding_contributed(&channel_id, &counterparty_node_id) + { + log_error!( + self.logger, + "Failed to cancel the splice on channel {}: {:?}", + channel_id, + e, + ); + } + } + /// 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, @@ -2740,18 +2762,37 @@ where // 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 + let record = match self .splice_tracker .on_funding_ready_for_signing(&partially_signed_tx, &candidates) .await { + Ok(record) => record, + Err(e) => { + log_error!( + self.logger, + "Failed to record the splice funding payment for channel {}: {}", + channel_id, + e, + ); + return Err(ReplayEvent()); + }, + }; + if record == SignedFundingRecord::Unmeasurable { + // Nothing was written and our signatures have not left the node, so the + // splice is cancelled rather than replayed. A replay would meet the same + // refusal, and an event that fails every time it is offered holds back + // every event queued behind it — including the ones that claim inbound + // HTLCs before they expire. Bounding the loss to this splice is the + // cheaper failure. log_error!( self.logger, - "Failed to record the splice funding payment for channel {}: {}", + "Not signing the funding transaction for channel {}, aborting the \ + splice: this node's share of the round is not on record", channel_id, - e, ); - return Err(ReplayEvent()); + self.cancel_splice(counterparty_node_id, channel_id); + return Ok(()); } match self.channel_manager.funding_transaction_signed( &channel_id, @@ -2768,13 +2809,8 @@ where }, Err(e) => { // The signed transaction never reached LDK, so nothing can ever - // broadcast it: cancel the splice. LDK responds with `DiscardFunding` - // (releasing whatever the wallet holds for the contribution) and - // `SpliceNegotiationFailed` (surfacing the failure, settling the - // persisted intent, and — the round now gone from the channel's - // history — taking back the record written above). If LDK had already - // reset the round when it refused the transaction, that report is on - // its way regardless, and the cancel finds nothing left to cancel. + // broadcast it: cancel the splice, which also takes back the record + // written above. log_error!( self.logger, "LDK refused the signed funding transaction for channel {}, \ @@ -2782,43 +2818,19 @@ where channel_id, e, ); - if let Err(e) = self - .channel_manager - .cancel_funding_contributed(&channel_id, &counterparty_node_id) - { - // Every cancel error means the splice is already beyond canceling - // (e.g. the channel is gone); there is nothing further to unwind. - log_error!( - self.logger, - "Failed to cancel the splice on channel {}: {:?}", - channel_id, - e, - ); - } + self.cancel_splice(counterparty_node_id, channel_id); }, } }, Err(()) => { // No record has been written for this transaction yet, so there is nothing to - // unwind: cancel the splice and let LDK's `DiscardFunding` and - // `SpliceNegotiationFailed` events release the contribution and settle the - // persisted intent. + // unwind: cancel the splice. log_error!( self.logger, "Failed signing the funding transaction for channel {}, aborting the splice", channel_id, ); - if let Err(e) = self - .channel_manager - .cancel_funding_contributed(&channel_id, &counterparty_node_id) - { - log_error!( - self.logger, - "Failed to cancel the splice on channel {}: {:?}", - channel_id, - e, - ); - } + self.cancel_splice(counterparty_node_id, channel_id); }, }, LdkEvent::SpliceNegotiated { diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 4a57c68565..c0612494a0 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -74,8 +74,8 @@ use crate::payment::{ use crate::runtime::Runtime; use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; use crate::wallet::provenance::{ - ChannelLiveness, ChannelTxFacts, ChannelTxFactsRejection, FactsRecordOutcome, FactsRetention, - LocalFundingFigures, RetentionCheck, TxProvenance, + ChannelLiveness, ChannelTxFacts, ChannelTxFactsRejection, FactsAdmission, FactsRecordOutcome, + FactsRetention, LocalFundingFigures, RetentionCheck, TxProvenance, }; use crate::{ChainSource, Error}; @@ -90,6 +90,20 @@ pub(crate) enum FundingAmount { Max, } +/// What recording an interactive funding round this node is about to sign came to, as it decides +/// whether the round may be signed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SignedFundingRecord { + /// This node's share of the round is on record, or the round needs nothing recorded. The + /// round may be signed. + Recorded, + /// This node's share of the round is not on record, and nothing this node does later puts it + /// there. Signing would release a transaction whoever first observes it records with the + /// wallet's view of a funding output both parties own — the whole of it read as this node's + /// spend, a figure nothing later corrects — so the round is cancelled instead. + Unmeasurable, +} + mod payment_stores; pub(crate) mod persist; pub(crate) mod provenance; @@ -232,12 +246,20 @@ impl Wallet { /// /// A report the store has no room for is likewise refused, and reported as /// [`FactsRecordOutcome::Incomplete`] rather than as a failure: nothing was lost, and what - /// the refusal costs is the reporting producer's to weigh — for most of them a transaction - /// this node cannot say anything about, for one that will not proceed unrecorded a reason to - /// come back. Only what this node has no record of at all is refused that way — a - /// transaction it already describes goes on being described, however full the store is. + /// the refusal costs the reporting producer is a transaction this node cannot say anything + /// about. Only what this node has no record of at all is refused that way — a transaction it + /// already describes goes on being described, however full the store is. pub(crate) async fn record_channel_tx_facts( &self, facts: ChannelTxFacts, + ) -> Result { + self.record_channel_tx_facts_admitted(facts, FactsAdmission::Capped).await + } + + /// Records what a producer reported, as [`Self::record_channel_tx_facts`] does, with + /// `admission` deciding whether the number of records the store may hold applies to a + /// transaction it holds no record of at all. + async fn record_channel_tx_facts_admitted( + &self, facts: ChannelTxFacts, admission: FactsAdmission, ) -> Result { let txid = facts.txid; // Dated by the chain tip the report arrives at, which is what retention measures from. @@ -255,10 +277,11 @@ impl Wallet { None }, }, - // A transaction nothing is recorded of yet needs room of its own; one already on - // record is merged into above however full the store is, so an obligation this - // node took on is never half-kept. - None if !self.facts_retention.has_room() => { + // A transaction nothing is recorded of yet needs room of its own, unless the + // producer is exempt from the cap; one already on record is merged into above + // however full the store is, so an obligation this node took on is never + // half-kept. + None if admission == FactsAdmission::Capped && !self.facts_retention.has_room() => { rejection = Some(ChannelTxFactsRejection::NoRoom { limit: CHANNEL_TX_FACTS_MAX_RECORDS, }); @@ -2631,14 +2654,18 @@ impl Wallet { /// replayed event), or without a local contribution or wallet-level activity. A failed write /// leaves the caller to replay: a losing RBF candidate's contribution figures exist only while /// the candidate is live in the channel's splice details, and both writes are idempotent, so - /// the replay completes whichever of them was lost. A refusal to take the round's facts on - /// for want of room fails the same way, so the transaction stays unsigned rather than being - /// recorded with the wallet's view of a funding output both parties own. + /// the replay completes whichever of them was lost. + /// + /// The round's facts are admitted however many records the store holds, so that a store full + /// of other transactions cannot leave this node signing a round it has no measure of. A + /// refusal that a replay would only meet again reports the round + /// [`SignedFundingRecord::Unmeasurable`], having written nothing, for the caller to cancel + /// the round rather than sign it. /// /// [`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> { + ) -> Result { let txid = tx.compute_txid(); let signed_round = match candidates.iter().find(|candidate| candidate.txid == txid) { Some(round) => round, @@ -2648,7 +2675,7 @@ impl Wallet { "Not recording signed funding {}: not among the channel's pending splice rounds", txid, ); - return Ok(()); + return Ok(SignedFundingRecord::Recorded); }, }; let funding_channels: Vec = signed_round @@ -2677,7 +2704,7 @@ impl Wallet { let (figures, mut history) = match self.interactive_funding_figures(payment_id, candidates, signed_round, tx) { Some(record) => record, - None => return Ok(()), + None => return Ok(SignedFundingRecord::Recorded), }; let figures = recorded_figures.unwrap_or(figures); // Only the signed round awaits broadcast: LDK broadcast the others once their signatures @@ -2689,7 +2716,7 @@ impl Wallet { let prior_pending = stores.pending_payment(&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(()); + return Ok(SignedFundingRecord::Recorded); } // 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 @@ -2714,24 +2741,26 @@ impl Wallet { // afterwards leaves the round attributable rather than a history pointing at a payment // nothing would ever file the transaction under. // - // A refusal for want of room fails the signing too, though nothing was lost: without - // this node's share on record, whoever first observes the transaction records it with - // the wallet's view of a funding output both parties own — the whole of it read as this - // node's spend. LDK re-offers the event while the transaction is unsigned, so replaying - // costs a splice that does not complete until the retention pass frees room, rather - // than a payment reporting a figure nothing later corrects. + // The round is admitted whatever the store's count: this node decides whether to sign + // it, and a record naming a round and this node's share of it carries no outputs, so + // it is among the smallest the store holds. What can still refuse it is a record + // already as large as one record may be, which nothing shrinks — so the round is + // reported unmeasurable for the caller to cancel, rather than released with the + // wallet's view of a funding output both parties own standing in for this node's + // share. let facts = ChannelTxFacts::new(txid) .with_self_role(TransactionType::InteractiveFunding { channels: funding_channels.clone(), }) .with_local_figures(figures); - if self.record_channel_tx_facts(facts).await? == FactsRecordOutcome::Incomplete { + let outcome = self.record_channel_tx_facts_admitted(facts, FactsAdmission::Exempt).await?; + if outcome == FactsRecordOutcome::Incomplete { log_error!( self.logger, "Not signing interactive funding {}: this node's share of it is not on record", txid, ); - return Err(Error::PersistenceFailed); + return Ok(SignedFundingRecord::Unmeasurable); } stores @@ -2771,7 +2800,7 @@ impl Wallet { e, ); } - Ok(()) + Ok(SignedFundingRecord::Recorded) } /// Marks a splice round recorded when signing ([`Self::record_signed_funding`]) as broadcast @@ -4378,7 +4407,9 @@ mod tests { use crate::config::ElectrumSyncConfig; #[cfg(feature = "chain-esplora")] use crate::config::EsploraSyncConfig; - use crate::config::{CHANNEL_TX_FACTS_CACHE_CAPACITY, PAYMENT_CACHE_CAPACITY}; + use crate::config::{ + CHANNEL_TX_FACTS_CACHE_CAPACITY, CHANNEL_TX_FACTS_MAX_RECORD_BYTES, PAYMENT_CACHE_CAPACITY, + }; use crate::io::test_utils::InMemoryStore; use crate::io::{ BDK_WALLET_ADDRESS_POOL_KEY, BDK_WALLET_ADDRESS_POOL_PRIMARY_NAMESPACE, @@ -5984,13 +6015,13 @@ mod tests { ); } - /// A round whose facts the store has no room for is not signed. Without this node's share on - /// record, whoever first observes the transaction records it with the wallet's view of a - /// funding output both parties own — the whole of it read as this node's spend — and nothing - /// later corrects that. The event is re-offered while the transaction is unsigned, so the - /// splice waits for room rather than being measured wrong. + /// A round this node is about to sign is measured however full the store is. The number of + /// records the store admits bounds what a counterparty drives; a round this node chose to + /// sign is admitted beside them, because without this node's share on record whoever first + /// observes the transaction records it with the wallet's view of a funding output both + /// parties own — the whole of it read as this node's spend, a figure nothing later corrects. #[tokio::test] - async fn a_round_this_node_has_no_room_to_measure_is_not_signed() { + async fn a_full_store_still_measures_a_round_this_node_signs() { 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(); @@ -6000,16 +6031,119 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - // The store holds as many records as it may, so a transaction it holds none for is - // refused room. + // The store holds as many records as it may, so a transaction a capped producer reports + // on is refused room. wallet.facts_retention.walk_completed(CHANNEL_TX_FACTS_MAX_RECORDS); + assert_eq!( + wallet + .record_channel_tx_facts(ChannelTxFacts::new(Txid::from_byte_array([77u8; 32]))) + .await + .unwrap(), + FactsRecordOutcome::Incomplete, + ); + + assert_eq!( + wallet.record_signed_funding(&tx, &candidates).await.unwrap(), + SignedFundingRecord::Recorded, + "a round this node signs is measured however full the store is", + ); + let id = + wallet.find_payment_by_txid(txid).await.unwrap().expect("the round names its payment"); + assert!( + wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("entry") + .candidate(txid) + .is_some(), + "the signed round is in the channel's candidate history", + ); + + // Our signatures leave the node and the counterparty broadcasts: wallet sync is the first + // to see the transaction, and files it under this node's share of the round rather than + // under the wallet's view of a funding output both parties own. + observe_unconfirmed(&wallet, &tx).await; + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + assert_eq!(payments[0].id, id); + assert_eq!(payments[0].amount_msat, Some(500_300_000)); + assert_eq!(payments[0].fee_paid_msat, Some(300_000)); + } + + /// A round whose facts are refused for a reason nothing later undoes is not signed, and is + /// reported unmeasurable so the caller cancels it. The record this round would merge into is + /// already as large as one record may be, which no later report shrinks, so replaying the + /// signing event would meet the same refusal forever while every event queued behind it + /// waited. + #[tokio::test] + async fn a_round_this_node_cannot_measure_is_not_signed() { + use lightning::util::ser::Writeable; + + 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 channel = Channel { counterparty_node_id, channel_id }; + + 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))]); + + // How large the transaction's record would be once the signing added what the round is + // and this node's share of it. + let signed_length = |facts: &ChannelTxFacts| { + facts + .clone() + .with_self_role(TransactionType::InteractiveFunding { + channels: vec![channel.clone()], + }) + .with_local_figures(LocalFundingFigures { + funding_payment_id: PaymentId([0u8; 32]), + amount_msat: Some(500_300_000), + fee_paid_msat: Some(300_000), + direction: PaymentDirection::Inbound, + }) + .serialized_length() + }; + // Grow the transaction's record to the largest one that may be stored, in coarse steps + // first and single outputs after so the search stays cheap. + let mut crowded = ChannelTxFacts::new(txid); + let mut vout = 0u32; + for step in [128u32, 1] { + loop { + let grown = crowded.clone().with_outputs( + &channel, + None, + ChannelOutputRole::Htlc, + vout..vout + step, + ); + if grown.serialized_length() > CHANNEL_TX_FACTS_MAX_RECORD_BYTES { + break; + } + crowded = grown; + vout += step; + } + } assert!( - wallet.record_signed_funding(&tx, &candidates).await.is_err(), - "a round this node cannot measure must not be signed", + signed_length(&crowded) > CHANNEL_TX_FACTS_MAX_RECORD_BYTES, + "the record must leave no room for what the signing adds", + ); + assert_eq!( + wallet.record_channel_tx_facts(crowded).await.unwrap(), + FactsRecordOutcome::Recorded, + ); + + assert_eq!( + wallet.record_signed_funding(&tx, &candidates).await.unwrap(), + SignedFundingRecord::Unmeasurable, + "a round this node cannot measure must be cancelled rather than replayed", ); assert!( - wallet.channel_tx_facts(&txid).await.is_none(), + wallet.channel_tx_facts(&txid).await.expect("the record stays").local_figures.is_none(), "nothing was recorded, which is what the refusal means", ); assert!( diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index 87adf0adfc..e2eb2e20d0 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -301,11 +301,32 @@ pub(crate) enum FactsRecordOutcome { /// Part of what was reported is not on record, because recording it would have taken the /// facts past the resources they are allowed. Nothing was lost, so what the refusal costs /// is the reporting producer's to weigh: a transaction reported without a classification - /// for a producer that has nothing left to withhold, a reason to come back for one that - /// will not proceed unrecorded. + /// for a producer that has nothing left to withhold, a round left unsigned for one that + /// will not release a transaction it cannot measure. Incomplete, } +/// Whether a report about a transaction this node holds no record of at all is subject to the +/// number of records the store may hold. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FactsAdmission { + /// Refused once the store holds as many records as it is allowed to. + /// + /// This is what bounds the store: a counterparty opening and closing channels, or replacing + /// a negotiated funding again and again, drives records about transactions this node only + /// reports on, and each of them costs nothing to refuse beyond a transaction going + /// unclassified. + Capped, + /// Admitted beside however many records the store holds, and counted like any other, so that + /// capped reports are refused the sooner. + /// + /// This is for the one report whose refusal costs more than the record: a round this node is + /// about to sign, which it will not release without its own share of it on record. How many + /// such records there can be is a question of how many rounds this node signs, which is its + /// own decision, and each carries no outputs. + Exempt, +} + /// The channels this node still holds on-chain state for, as the retention of recorded facts /// consults them. pub(crate) trait ChannelLiveness: Send + Sync { From b518e9b0ad29cde3ba23788b29fb29eb29d9016f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 21:41:30 +0200 Subject: [PATCH 47/49] Let the funding output alone name a funding A channel becoming pending reported both the output that funds it and that the transaction is a funding of that channel. The second says nothing the first does not, since a funding output is what a funding transaction is recognised by. It also cost something. One transaction can open several channels, and each reports only its own output; a report naming the transaction outright names one channel, so the next channel's contradicts it and is refused whole, taking its funding output with it. The payment then named one of the channels opened instead of all of them. A channel becoming ready already reports only its funding output, so the two backstops now agree. Co-Authored-By: HAL 9000 --- src/event.rs | 17 ++++----- src/wallet/mod.rs | 89 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/src/event.rs b/src/event.rs index 48be333ae5..32b78a7cef 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2188,15 +2188,16 @@ where "LDK Node has only ever persisted ChannelPending events from rust-lightning 0.0.115 or later", ); + // The funding output alone says what the transaction is, and says it for every + // channel a batched funding opens: each channel reports its own output, and the + // reports of one transaction are held together. let channel = Channel { counterparty_node_id, channel_id }; - let facts = ChannelTxFacts::new(funding_txo.txid) - .with_outputs( - &channel, - Some(UserChannelId(user_channel_id)), - ChannelOutputRole::Funding, - [funding_txo.vout], - ) - .with_self_role(TransactionType::Funding { channels: vec![channel.clone()] }); + let facts = ChannelTxFacts::new(funding_txo.txid).with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [funding_txo.vout], + ); self.record_channel_tx_facts(facts).await; let event = Event::ChannelPending { diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index c0612494a0..cedd6bb67d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -10894,6 +10894,95 @@ mod tests { } } + /// The funding output a channel reports is what names the transaction that opens it: nothing + /// else has to say so. + #[tokio::test] + async fn a_funding_transaction_is_named_by_the_output_it_creates() { + 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 channel = Channel { counterparty_node_id, channel_id }; + let funding = wallet_paying_tx(&wallet, 5); + let funding_txid = funding.compute_txid(); + insert_unconfirmed_tx(&wallet, funding.clone()); + + wallet + .record_channel_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + &channel, + None, + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(); + observe_unconfirmed(&wallet, &funding).await; + + let payment = wallet + .payment_stores + .payment_store() + .get(&PaymentId(funding_txid.to_byte_array())) + .await + .unwrap() + .expect("wallet sync records the funding transaction"); + match payment.kind { + PaymentKind::Onchain { + tx_type: Some(TransactionType::Funding { channels }), .. + } => { + assert_eq!(channels, vec![channel]); + }, + kind => panic!("unexpected kind {:?}", kind), + } + } + + /// One transaction can open several channels, each of which reports only the output that + /// funds it. The reports of one transaction are held together, so the payment names every + /// channel opened — which a report naming the transaction outright could not do, as the + /// second channel's would contradict the first's. + #[tokio::test] + async fn a_batched_funding_names_every_channel_it_opens() { + 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 = Channel { counterparty_node_id, channel_id }; + let second = Channel { counterparty_node_id, channel_id: ChannelId([8u8; 32]) }; + + let mut funding = wallet_paying_tx(&wallet, 6); + funding.output.push(funding.output[0].clone()); + let funding_txid = funding.compute_txid(); + insert_unconfirmed_tx(&wallet, funding.clone()); + + for (vout, channel) in [(0u32, &first), (1, &second)] { + wallet + .record_channel_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + channel, + None, + ChannelOutputRole::Funding, + [vout], + )) + .await + .unwrap(); + } + observe_unconfirmed(&wallet, &funding).await; + + let payment = wallet + .payment_stores + .payment_store() + .get(&PaymentId(funding_txid.to_byte_array())) + .await + .unwrap() + .expect("wallet sync records the funding transaction"); + match payment.kind { + PaymentKind::Onchain { + tx_type: Some(TransactionType::Funding { channels }), .. + } => { + assert_eq!(channels, vec![first, second]); + }, + kind => panic!("unexpected kind {:?}", kind), + } + } + /// The node's channel state as a test dictates it: the channels its channel manager lists, /// the monitors its chain monitor holds, and the outputs its sweeper tracks. #[derive(Default)] From ed85bd15e6bf27315fbdaa96d79ed30782e53623 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 21:42:52 +0200 Subject: [PATCH 48/49] Drop test scaffolding nothing exercises anymore The tests that needed a store parking one namespace's writes, the order the gated store's keys were written in, and a plain on-chain payment fixture went with the broadcast-time classification path and the writes it made. What they used is dead, and the compiler says so. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 107 +--------------------------------------------- 1 file changed, 2 insertions(+), 105 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index cedd6bb67d..ef08142f5e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -5068,7 +5068,7 @@ mod tests { /// An in-memory store whose writes can be made to park until aborted or released, /// signalling when a write has entered the gate, and whose writes can be made to fail, - /// counting the failures. Records the keys it wrote, in order. + /// counting the failures. #[derive(Clone)] struct GatedStore { inner: Arc, @@ -5077,7 +5077,6 @@ mod tests { failed_writes: Arc, write_entered: Arc, release: Arc, - writes: Arc>>, } impl GatedStore { @@ -5089,20 +5088,8 @@ mod tests { failed_writes: Arc::new(AtomicUsize::new(0)), write_entered: Arc::new(tokio::sync::Notify::new()), release: Arc::new(tokio::sync::Notify::new()), - writes: Arc::new(Mutex::new(Vec::new())), } } - - /// The keys written to `primary_namespace`, in write order. - fn written_keys(&self, primary_namespace: &str) -> Vec { - self.writes - .lock() - .unwrap() - .iter() - .filter(|(namespace, _)| namespace == primary_namespace) - .map(|(_, key)| key.clone()) - .collect() - } } impl KVStore for GatedStore { @@ -5121,7 +5108,6 @@ mod tests { let failed_writes = Arc::clone(&self.failed_writes); let write_entered = Arc::clone(&self.write_entered); let release = Arc::clone(&self.release); - let writes = Arc::clone(&self.writes); let primary_namespace = primary_namespace.to_string(); let secondary_namespace = secondary_namespace.to_string(); let key = key.to_string(); @@ -5134,10 +5120,7 @@ mod tests { failed_writes.fetch_add(1, Ordering::AcqRel); return Err(io::Error::new(io::ErrorKind::Other, "write failed")); } - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf) - .await?; - writes.lock().unwrap().push((primary_namespace, key)); - Ok(()) + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await } } @@ -5563,81 +5546,6 @@ mod tests { ); } - /// A pass-through [`KVStore`] that parks writes to one namespace: a matching writer first - /// signals `parked`, then waits until the test drops its `gate` write guard. Writes to every - /// other namespace pass straight through. - #[derive(Clone)] - struct NamespaceGatedStore { - inner: Arc, - gated_namespace: String, - parked: Arc, - gate: Arc>, - } - - impl NamespaceGatedStore { - fn new(gated_namespace: &str) -> Self { - Self { - inner: Arc::new(InMemoryStore::new()), - gated_namespace: gated_namespace.to_string(), - parked: Arc::new(tokio::sync::Notify::new()), - gate: Arc::new(tokio::sync::RwLock::new(())), - } - } - } - - impl KVStore for NamespaceGatedStore { - fn read( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) - } - - fn write( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, - ) -> impl Future> + 'static + Send { - let inner = Arc::clone(&self.inner); - let gated = primary_namespace == self.gated_namespace; - let parked = Arc::clone(&self.parked); - let gate = Arc::clone(&self.gate); - let primary_namespace = primary_namespace.to_string(); - let secondary_namespace = secondary_namespace.to_string(); - let key = key.to_string(); - async move { - if gated { - parked.notify_one(); - let _guard = gate.read().await; - } - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await - } - } - - fn remove( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, - ) -> impl Future> + 'static + Send { - KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) - } - - fn list( - &self, primary_namespace: &str, secondary_namespace: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::list(&*self.inner, primary_namespace, secondary_namespace) - } - } - - impl PaginatedKVStore for NamespaceGatedStore { - fn list_paginated( - &self, primary_namespace: &str, secondary_namespace: &str, - page_token: Option, - ) -> impl Future> + 'static + Send { - PaginatedKVStore::list_paginated( - &*self.inner, - primary_namespace, - secondary_namespace, - page_token, - ) - } - } - fn dummy_tx() -> Transaction { Transaction { version: bitcoin::transaction::Version::TWO, @@ -5672,17 +5580,6 @@ mod tests { ) } - fn onchain_details(txid: Txid, status: ConfirmationStatus) -> PaymentDetails { - PaymentDetails::new( - PaymentId([42u8; 32]), - PaymentKind::Onchain { txid, status, tx_type: None }, - Some(1_000_000), - Some(500), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ) - } - fn confirmed_status() -> ConfirmationStatus { ConfirmationStatus::Confirmed { block_hash: bitcoin::BlockHash::from_byte_array([8u8; 32]), From 8fe08d61b5fc582f351acc29aa1474e0581a0f4b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 21:43:59 +0200 Subject: [PATCH 49/49] Fix two references that point at the wrong thing A note on where the pending store's status check happens pointed at the function it sits in, having been re-pointed there when the function it named went away. It says the same thing without the cross-reference. Two retention tests, one over the check itself and one over the pass that applies it, shared a name; the second now reads like its neighbours. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index ef08142f5e..0190152c85 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3359,9 +3359,9 @@ impl Wallet { let leftover = &mut leftover_intent_to_remove; stores .mutate_pending_payment_async(&id, move |existing| async move { - // Only `Pending` payments belong in the pending store. Like in - // [`Self::upsert_pending_payment`], the authoritative status is re-read inside - // the store's critical section, where it cannot go stale against graduation. + // Only `Pending` payments belong in the pending store. The authoritative + // status is re-read inside the store's critical section, where it cannot go + // stale against graduation. let is_pending = stores .payment(&id) .await? @@ -11059,7 +11059,7 @@ mod tests { } #[tokio::test] - async fn facts_are_kept_until_the_age_cap_has_passed() { + async fn the_facts_of_a_channel_are_kept_until_the_age_cap() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); let channel = Channel { counterparty_node_id, channel_id };