diff --git a/src/builder.rs b/src/builder.rs index fbc5e53d8..4969ebe93 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::{ @@ -2451,6 +2452,14 @@ 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(&payment_store), + Arc::clone(&logger), + )); + #[cfg(cycle_tests)] let mut _leak_checker = crate::LeakChecker(Vec::new()); #[cfg(cycle_tests)] @@ -2490,6 +2499,7 @@ fn build_with_store_internal( scorer, peer_store, payment_store, + splice_tracker, lnurl_auth, is_running, node_metrics, diff --git a/src/chain/mod.rs b/src/chain/mod.rs index f01c1c8cb..2d53cf9d6 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -37,9 +37,15 @@ use crate::config::{BackgroundSyncConfig, Config, WALLET_SYNC_INTERVAL_MINIMUM_S use crate::fee_estimator::OnchainFeeEstimator; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::runtime::Runtime; +use crate::tx_broadcaster::{BroadcastPackage, RetryQueue, ScheduleOutcome}; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; +/// How long to wait before re-classifying a package whose classification failed. Long enough to +/// give a struggling store room to recover, short against the ~minutes until the transaction +/// could confirm. +const FAILED_CLASSIFY_RETRY_DELAY: Duration = Duration::from_secs(2); + /// We use this parent-child TRUC package to make sure the configured chain source supports /// broadcasting packages via the `submitpackage` Bitcoin Core RPC. const PARENT_TXID: &str = "9a015f93fac6cb203c2b994e18b85176eb0354a22a468255516f3c6002d3f696"; @@ -562,50 +568,91 @@ impl ChainSource { } } + /// Classifies the package's funding broadcasts into payment records, then broadcasts it. + /// Returns the package back on classification failure so the caller can retry it after a + /// delay: broadcasting a tx we failed to record would leave it on-chain without a payment, + /// while dropping the package would not keep an interactively funded tx off-chain (the + /// counterparty broadcasts it regardless), only leave it confirming without a recorded + /// candidate. + async fn classify_and_broadcast( + &self, package: BroadcastPackage, + ) -> Result<(), BroadcastPackage> { + if let Err(e) = self.tx_broadcaster.classify_package(&package).await { + log_error!( + self.logger, + "Delaying broadcast: failed to persist payment records, will retry: {:?}", + e, + ); + return Err(package); + } + let package = package.into_sorted_transactions(); + match &self.kind { + #[cfg(feature = "chain-esplora")] + ChainSourceKind::Esplora(esplora_chain_source) => { + esplora_chain_source.process_transaction_broadcast(package).await + }, + #[cfg(feature = "chain-electrum")] + ChainSourceKind::Electrum(electrum_chain_source) => { + electrum_chain_source.process_transaction_broadcast(package).await + }, + #[cfg(feature = "chain-bitcoind")] + ChainSourceKind::Bitcoind(bitcoind_chain_source) => { + bitcoind_chain_source.process_transaction_broadcast(package).await + }, + } + Ok(()) + } + pub(crate) async fn continuously_process_broadcast_queue( &self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>, ) { let mut receiver = self.tx_broadcaster.get_broadcast_queue().await; + // Packages whose classification failed, each waiting out FAILED_CLASSIFY_RETRY_DELAY + // before its next attempt. New packages keep flowing while these wait, and pending + // retries die with the loop on shutdown rather than resurfacing after a later start. + let mut retries = RetryQueue::new(); loop { - let tx_bcast_logger = Arc::clone(&self.logger); - tokio::select! { + let next_retry_at = retries.next_retry_at(); + let package = tokio::select! { _ = stop_tx_bcast_receiver.changed() => { log_debug!( - tx_bcast_logger, + self.logger, "Stopping broadcasting transactions.", ); return; } - Some(next_package) = receiver.recv() => { - // Classify funding broadcasts into payment records before sending. If - // classification fails we skip the broadcast, since broadcasting a tx we - // failed to record would leave it on-chain without a payment. - let package = match self.tx_broadcaster.classify_package(next_package).await { - Ok(package) => package, - Err(e) => { - log_error!( - tx_bcast_logger, - "Skipping broadcast: failed to persist payment records: {:?}", - e, - ); - continue; - }, - }; - let package = package.into_sorted_transactions(); - match &self.kind { - #[cfg(feature = "chain-esplora")] - ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_transaction_broadcast(package).await - }, - #[cfg(feature = "chain-electrum")] - ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_transaction_broadcast(package).await - }, - #[cfg(feature = "chain-bitcoind")] - ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_transaction_broadcast(package).await - }, - } + Some(next_package) = receiver.recv() => next_package, + _ = tokio::time::sleep_until( + next_retry_at.unwrap_or_else(tokio::time::Instant::now) + ), if next_retry_at.is_some() => { + retries.pop_next().expect("a retry is queued") + } + }; + if let Err(package) = self.classify_and_broadcast(package).await { + let retry_at = tokio::time::Instant::now() + FAILED_CLASSIFY_RETRY_DELAY; + match retries.schedule(package, retry_at) { + ScheduleOutcome::Scheduled { dropped: None } => {}, + ScheduleOutcome::Scheduled { dropped: Some(dropped) } => { + log_error!( + self.logger, + "Dropped the oldest package awaiting a classification retry; LDK re-broadcasts its transactions periodically: {:?}", + dropped.sorted_txids(), + ); + }, + ScheduleOutcome::AlreadyQueued(duplicate) => { + log_debug!( + self.logger, + "Dropped a re-broadcast package; an identical one already awaits a classification retry: {:?}", + duplicate.sorted_txids(), + ); + }, + ScheduleOutcome::Refused(package) => { + log_error!( + self.logger, + "Dropped a package failing classification; too many await retries: {:?}", + package.sorted_txids(), + ); + }, } } } diff --git a/src/channel/mod.rs b/src/channel/mod.rs new file mode 100644 index 000000000..9cfb57968 --- /dev/null +++ b/src/channel/mod.rs @@ -0,0 +1,903 @@ +// 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::sync::Arc; + +use bitcoin::absolute::LockTime; +use bitcoin::secp256k1::PublicKey; +use bitcoin::transaction::Version; +use bitcoin::{OutPoint, Transaction, TxIn}; +use lightning::chain::transaction::OutPoint as LdkOutPoint; +use lightning::ln::channel_state::{SpliceCandidateDetails, SpliceCandidateStatus}; +use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::FundingContribution; +use lightning::ln::types::ChannelId; + +use crate::data_store::StorableObject; +use crate::logger::{log_error, log_info, LdkLogger, Logger}; +use crate::payment::pending_payment_store::{ + PendingPaymentDetails, PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind, +}; +use crate::payment::store::PaymentDetails; +use crate::payment::PaymentStatus; +use crate::types::{ChannelManager, PaymentStore, PendingPaymentStore}; +use crate::wallet::{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. +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 [`Self::reconcile`] +/// can release anything it still reserves, and events about the splice can be described in +/// terms of the original request. +pub(crate) struct SpliceTracker { + channel_manager: Arc, + wallet: Arc, + pending_payment_store: Arc, + payment_store: Arc, + /// Serializes [`Self::submit`]'s persist-and-hand-off sequence with + /// [`Self::on_negotiation_failed`]'s settling of the intent. 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. + submit_lock: tokio::sync::Mutex<()>, + logger: Arc, +} + +impl SpliceTracker { + pub(crate) fn new( + channel_manager: Arc, wallet: Arc, + pending_payment_store: Arc, payment_store: Arc, + logger: Arc, + ) -> Self { + Self { + channel_manager, + wallet, + pending_payment_store, + payment_store, + submit_lock: tokio::sync::Mutex::new(()), + logger, + } + } + + /// Reconciles the persisted splice intents against live channel state, releasing whatever the + /// wallet still holds for splices that did not survive the restart. LDK only persists a + /// splice once its negotiation reaches `AwaitingSignatures`, so a splice lost earlier leaves + /// no trace in LDK — the intent record is what recognizes the loss. Run once at startup, + /// before background chain syncing and event processing start, so nothing can act on the + /// stale reservations first. + /// + /// Recovery is silent: no failure event is fabricated for a splice lost this way, since the + /// initiating call already returned and the channel simply shows no pending splice anymore. + pub(crate) async fn reconcile(&self) { + 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_manager + .list_channels_with_counterparty(&intent.counterparty_node_id) + .into_iter() + .find(|c| c.channel_id == intent.channel_id); + let Some(channel) = channel else { + // The channel is gone; there is nothing to splice anymore. + log_info!( + self.logger, + "Dropping the recorded splice of closed channel {} with counterparty {}", + intent.channel_id, + intent.counterparty_node_id, + ); + self.release_contribution(intent.channel_id, &intent.contribution).await; + 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. + self.on_channel_ready( + intent.counterparty_node_id, + intent.channel_id, + channel.funding_txo.map(|txo| txo.into_bitcoin_outpoint()), + ) + .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).await; + 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 + /// persisted record of it. A newer splice supersedes whatever intent its channel carried: at + /// most one splice is ever in flight per channel, and a fee bump replaces the splice it bumps. + /// + /// 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 whatever the wallet holds for the + /// contribution; a synchronous rejection leaves that 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, + ) -> Result<(), Error> { + let _guard = self.submit_lock.lock().await; + let intent = SpliceIntent { + counterparty_node_id, + channel_id, + pre_splice_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).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, + ); + self.release_contribution(channel_id, &contribution).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. + 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, + ); + self.discard_persisted_intent(&payment_id, restore).await; + self.release_contribution(channel_id, &contribution).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 + // releases whatever the wallet holds for it. + self.discard_persisted_intent(&payment_id, restore).await; + return Err(Error::ChannelSplicingFailed); + } + Ok(()) + } + + /// Releases everything the wallet may still hold for a contribution that is going nowhere: + /// its inputs are unlocked and its would-be transaction is canceled, freeing the addresses of + /// its change and splice-out outputs. + async fn release_contribution( + &self, channel_id: ChannelId, contribution: &FundingContribution, + ) { + let inputs: Vec = + contribution.inputs().iter().map(|input| input.outpoint()).collect(); + 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: contribution + .outputs() + .iter() + .chain(contribution.change_output()) + .cloned() + .collect(), + }; + 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. + /// + /// Reuses the channel's existing splice intent record when one is present — so a splice and + /// its later fee bumps share one [`PaymentId`] and at most one intent ever exists per + /// channel, which `Wallet::find_splice_payment_id` relies on — otherwise generates a fresh + /// id. 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. + async fn persist_intent( + &self, intent: SpliceIntent, + ) -> Result<(PaymentId, Option>), Error> { + let existing = self + .pending_payment_store + .list_filter(|p| { + p.splice_intent().is_some_and(|i| { + i.channel_id == intent.channel_id + && i.counterparty_node_id == intent.counterparty_node_id + }) + }) + .await + .into_iter() + .next(); + match existing { + Some(record) => { + let payment_id = record.id(); + let prior = 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))) + }, + None => { + 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 newer splice took over the channel's record in the meantime, + /// and its intent must stay. A record with no classified funding payment behind it is removed + /// entirely; otherwise the record stays — with the intent cleared — so the payment keeps + /// graduating. + 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; + let payment_store = Arc::clone(&self.payment_store); + self.pending_payment_store + .mutate_async(&payment_id, move |existing| async move { + let Some(record) = existing else { + return Ok(None); + }; + match record.splice_intent() { + Some(intent) if still_applies(intent) => {}, + _ => return Ok(None), + } + let recorded = payment_store.get(&payment_id).await?; + let replacement = record_with_intent_cleared(Some(record), recorded); + // A bare intent record with no payment to promote it into cannot be cleared + // in place; it is removed below. + *removal_flag = replacement.is_none(); + Ok(replacement) + }) + .await?; + if remove_bare_record { + 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 — 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 channel's 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 mut matched = None; + if let Some(contribution) = contribution { + matched = self.record_for_channel(counterparty_node_id, channel_id).await.and_then( + |record| { + let intent = record.splice_intent()?; + is_same_splice(&intent.contribution, contribution) + .then(|| (record.id(), intent.clone())) + }, + ); + } + FailureSettlement { tracker: self, _guard: guard, matched } + } + + /// Settles any persisted intent made obsolete by a newly locked funding transaction. An + /// intent whose pre-splice outpoint is the newly locked funding was created after the lock + /// and stays; one LDK still holds as a queued splice candidate is refreshed to the new + /// funding rather than settled. Also resolves [`Self::reconcile`]'s case of a funding that + /// moved while the node was down — the same situation, minus the event. + 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 Some(record) = self.record_for_channel(counterparty_node_id, channel_id).await else { + return; + }; + let payment_id = record.id(); + let Some(intent) = record.splice_intent().cloned() else { + return; + }; + if intent.pre_splice_funding_txo.into_bitcoin_outpoint() == funding_txo { + return; + } + + // LDK queues a splice initiated while another is pending and carries it across the + // pending splice's lock. A candidate still holding the intent's contribution is that + // queued splice: it is still live, so re-anchor the intent to the funding it now builds + // on instead of settling it. + let channel = self + .channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .into_iter() + .find(|c| c.channel_id == channel_id); + if let Some(channel) = &channel { + let candidates = channel + .splice_details + .as_ref() + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]); + let still_held = candidates.iter().any(|candidate| { + candidate + .contribution + .as_ref() + .is_some_and(|c| is_same_splice(c, &intent.contribution)) + }); + if still_held { + if let Some(new_funding_txo) = channel.funding_txo { + self.refresh_intent_funding(payment_id, &intent, new_funding_txo).await; + } + return; + } + } + + // The lock settled the intent. When the locked funding did not consume the intent's + // inputs — a replacement attempt or a counterparty-initiated splice locked instead — + // release them for other spends; a lock that spent them released nothing. + let inputs: Vec = + intent.contribution.inputs().iter().map(|i| i.outpoint()).collect(); + if !self.wallet.tx_spends_outpoints(funding_txo.txid, &inputs) { + if let Err(e) = self.wallet.unlock_outpoints(&inputs).await { + log_error!( + self.logger, + "Failed to release the inputs of a settled splice on channel {}: {}", + channel_id, + e, + ); + } + } + 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, + ); + } + } + + /// Settles any persisted intent for a closed channel, as there is nothing left to splice. + pub(crate) async fn on_channel_closed( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) { + if let Some(record) = self.record_for_channel(counterparty_node_id, channel_id).await { + self.clear_persisted_intent(record.id(), |_| true).await; + } + } + + /// Returns the pending record carrying a splice intent for the given channel, if any. A fee + /// bump reuses the channel's existing intent record, so at most one record matches. + async fn record_for_channel( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Option { + 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 + .into_iter() + .next() + } +} + +/// 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 hands 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<'_> { + /// 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) { + let FailureSettlement { tracker, _guard, matched } = self; + if let Some((payment_id, intent)) = matched { + tracker.clear_persisted_intent(payment_id, move |i| *i == intent).await; + } + } +} + +/// 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 pre-broadcast record whose payment +/// was classified but never mirrored into the pending store — a crash between classification's +/// two store writes — is promoted so the payment keeps graduating and its txids stay mapped; a +/// payment no longer `Pending` graduated already and must not be re-indexed, so its entry is +/// left alone for removal. +fn record_with_intent_cleared( + existing: Option, recorded: Option, +) -> Option { + match existing { + Some(PendingPaymentDetails::PendingSplice { .. }) => recorded + .filter(|details| details.status == PaymentStatus::Pending) + .map(|details| PendingPaymentDetails::tracked(details, Vec::new(), Vec::new(), None)), + Some(mut tracked @ PendingPaymentDetails::Tracked { .. }) => { + 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) + }, + None => None, + } +} + +/// What [`SpliceTracker::reconcile`] should do with a persisted intent whose channel and funding +/// are unchanged, decided from the splice rounds LDK reports on the channel. +#[derive(Debug, PartialEq, Eq)] +enum ReconcileDecision { + /// LDK still holds a splice of ours; leave the intent in place until the splice settles. + Keep, + /// LDK holds no splice of ours: the recorded splice died with the restart, so whatever was + /// reserved for it is released and the intent dropped. + Lost, +} + +/// Decides the startup action for a persisted intent from the channel's [`SpliceDetails`] +/// candidates. +/// +/// [`SpliceDetails`]: lightning::ln::channel_state::SpliceDetails +fn decide_reconcile(candidates: &[SpliceCandidateDetails]) -> 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 claimed: Vec = candidates + .iter() + .filter_map(|candidate| candidate.contribution.as_ref()) + .flat_map(|contribution| contribution.inputs().iter().map(|input| input.outpoint())) + .collect(); + contribution + .inputs() + .iter() + .map(|input| input.outpoint()) + .filter(|outpoint| !claimed.contains(outpoint)) + .collect() +} + +#[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_inputs, test_funding_contribution_with_outputs, + }; + use crate::payment::store::{ConfirmationStatus, PaymentKind}; + use crate::payment::PaymentDirection; + + 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 crash between classification's two store writes leaves the pending entry pre-broadcast + /// while the classified payment record exists. Dropping the intent must promote the entry + /// rather than remove it, so the payment keeps graduating and its txids stay mapped. + #[test] + fn intent_clearing_promotes_a_pre_broadcast_record_over_a_classified_payment() { + let id = PaymentId([9u8; 32]); + let existing = PendingPaymentDetails::pending_splice(id, test_intent()); + let recorded = payment_details(id, PaymentStatus::Pending); + + let replacement = + record_with_intent_cleared(Some(existing.clone()), Some(recorded.clone())); + let replacement = replacement.expect("the entry must be promoted, not removed"); + assert_eq!(replacement.details(), Some(&recorded)); + assert!(replacement.splice_intent().is_none()); + } + + /// A payment that already advanced beyond `Pending` graduated and lost its pending entry; + /// promotion must not re-index it. + #[test] + fn intent_clearing_does_not_reindex_an_advanced_payment() { + let id = PaymentId([9u8; 32]); + let existing = PendingPaymentDetails::pending_splice(id, test_intent()); + let recorded = payment_details(id, PaymentStatus::Succeeded); + assert!(record_with_intent_cleared(Some(existing.clone()), Some(recorded)).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(Some(existing.clone()), Some(details.clone())); + 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(253, &outputs); + let b = test_funding_contribution_with_outputs(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(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 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::{ScriptBuf, TxOut, 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])), + }], + } + } + + /// 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/data_store.rs b/src/data_store.rs index a9fe0d0f5..d6c51a7c0 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -384,6 +384,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; @@ -1112,6 +1150,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 846117ea7..84561436d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -13,13 +13,14 @@ use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, OutPoint}; +use bitcoin::{Amount, OutPoint, ScriptBuf}; use lightning::blinded_path::message::NextMessageHop; use lightning::events::bump_transaction::BumpTransactionEvent; #[cfg(not(feature = "uniffi"))] use lightning::events::PaidBolt12Invoice; use lightning::events::{ ClosureReason, Event as LdkEvent, FundingInfo, InboundHTLCLocator as LdkInboundHtlcLocator, + NegotiationFailureReason as LdkNegotiationFailureReason, OutboundHTLCLocator as LdkOutboundHtlcLocator, PaymentFailureReason, PaymentPurpose, ReplayEvent, }; @@ -31,10 +32,15 @@ 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}; use crate::connection::ConnectionManager; use crate::data_store::DataStoreUpdateResult; @@ -49,6 +55,7 @@ use crate::liquidity::LiquiditySource; 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::pending_payment_store::SpliceKind; use crate::payment::store::{ PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; @@ -114,6 +121,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`] @@ -307,7 +463,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 { @@ -317,6 +477,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, }, } @@ -401,6 +573,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), }, ); @@ -569,6 +743,7 @@ where onion_messenger: Arc, om_mailbox: Option>, prober: Option>, + splice_tracker: Arc, runtime: Arc, logger: L, config: Arc, @@ -587,7 +762,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, @@ -605,6 +780,7 @@ where onion_messenger, om_mailbox, prober, + splice_tracker, runtime, logger, config, @@ -1873,6 +2049,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), @@ -1900,6 +2080,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. @@ -2207,7 +2389,8 @@ where channel_id, user_channel_id, counterparty_node_id, - .. + reason, + contribution, } => { log_info!( self.logger, @@ -2216,19 +2399,38 @@ where counterparty_node_id, ); + // 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 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 { 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(()) @@ -2352,6 +2554,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, @@ -2369,6 +2576,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 { @@ -2430,6 +2642,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 821304a53..4e28dd3bf 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, @@ -140,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, @@ -175,6 +177,7 @@ use lnurl_auth::LnurlAuth; use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; +use payment::pending_payment_store::SpliceKind; use payment::{ Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, PaymentDetailsPage, SpontaneousPayment, @@ -271,6 +274,7 @@ pub struct Node { scorer: Arc>, peer_store: Arc>>, payment_store: Arc, + splice_tracker: Arc, lnurl_auth: Arc, is_running: Arc>, node_metrics: Arc, @@ -362,6 +366,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()); + // 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); @@ -684,6 +693,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), @@ -696,6 +706,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); @@ -1690,6 +1709,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); @@ -1703,18 +1730,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, @@ -1789,16 +1811,17 @@ 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::In { amount_sats: splice_amount_sats }, + )) .map_err(|e| { log_error!(self.logger, "Failed to splice channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( @@ -1817,6 +1840,12 @@ 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. 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 — without a failure event. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but @@ -1841,6 +1870,12 @@ 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. 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 — without a failure event. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but @@ -1857,6 +1892,12 @@ 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. 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 — without a failure event. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-out will be marked as an inbound payment if @@ -1871,6 +1912,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 { @@ -1913,22 +1962,24 @@ 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, - None, - ) + SpliceKind::Out { outputs }, + )) .map_err(|e| { log_error!(self.logger, "Failed to splice channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( @@ -1944,6 +1995,12 @@ 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. 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 — without a failure event. pub fn bump_channel_funding_fee( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, ) -> Result<(), Error> { @@ -1952,6 +2009,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); @@ -1990,16 +2055,17 @@ 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 {}, + )) .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 30a113537..be387ded2 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; 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}; use crate::payment::store::PaymentDetailsUpdate; @@ -36,37 +40,181 @@ impl_writeable_tlv_based!(FundingTxCandidate, { (4, fee_paid_msat, option), }); -/// 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, +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: OutPoint, + /// 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 broadcast and classified 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), broadcast-time classification + /// records `candidates` for interactive funding, 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's broadcast is + /// classified. 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, + }, } impl PendingPaymentDetails { pub(crate) fn new( details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, ) -> Self { - Self { details, conflicting_txids, candidates } + 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 } + } + + 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 { + 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 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> { - self.candidates.iter().find(|candidate| candidate.txid == txid) + match self { + Self::PendingSplice { .. } => None, + Self::Tracked { candidates, .. } => { + candidates.iter().find(|candidate| candidate.txid == txid) + }, + } } } -impl_writeable_tlv_based!(PendingPaymentDetails, { - (0, details, required), - (2, conflicting_txids, optional_vec), - (4, candidates, 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), + }, +); #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingPaymentDetailsUpdate { @@ -74,6 +222,10 @@ 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 { @@ -81,38 +233,73 @@ impl StorableObject for PendingPaymentDetails { type Update = PendingPaymentDetailsUpdate; fn id(&self) -> Self::Id { - self.details.id + match self { + Self::PendingSplice { id, .. } => *id, + Self::Tracked { details, .. } => details.id, + } } fn update(&mut self, update: Self::Update) -> bool { - let mut updated = false; - - // Update the underlying payment details if present - if let Some(payment_update) = update.payment_update { - updated |= self.details.update(payment_update); - } - - if let Some(new_conflicting_txids) = update.conflicting_txids { - if self.conflicting_txids != new_conflicting_txids { - self.conflicting_txids = new_conflicting_txids; - updated = true; - } - } - - 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 classify passes the complete candidate history, so a non-empty update replaces the - // stored list. An empty update (e.g. a non-funding payment) leaves it untouched. - if !update.candidates.is_empty() && self.candidates != update.candidates { - self.candidates = update.candidates; - updated = true; + 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 classify passes the candidate history as of its own broadcast, so a + // non-empty update replaces the stored list. An empty update (e.g. a non-funding + // payment) leaves it untouched — as does an update missing a stored candidate: + // the history only ever grows, so such an update was built before that candidate + // existed (a classification retry running after a newer round classified) and + // replacing would orphan the newer round's transactions. + let extends_history = |stored: &FundingTxCandidate| { + update.candidates.iter().any(|candidate| candidate.txid == stored.txid) + }; + if !update.candidates.is_empty() + && *candidates != update.candidates + && candidates.iter().all(extends_history) + { + *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 { @@ -128,23 +315,139 @@ 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()) + }; + // 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: None, + } + }, } } } +/// 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 +/// just the required TLV records: estimated fee, feerate, max feerate, and the is-splice flag. +#[cfg(test)] +pub(crate) fn test_funding_contribution() -> 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) -> FundingContribution { + let mut tlv_bytes = vec![ + 33u8, // BigSize length prefix over the TLV records below + 1, 8, 0, 0, 0, 0, 0, 0, 0, 0, // (1, estimated_fee: 0 sat) + 9, 8, // (9, feerate) + ]; + tlv_bytes.extend_from_slice(&feerate.to_be_bytes()); + tlv_bytes.extend_from_slice(&[11, 8]); // (11, max_feerate) + tlv_bytes.extend_from_slice(&feerate.to_be_bytes()); + tlv_bytes.extend_from_slice(&[13, 1, 1]); // (13, is_splice: true) + lightning::util::ser::Readable::read(&mut &tlv_bytes[..]) + .expect("hand-built TLV stream must decode") +} + +/// Like [`test_funding_contribution`], but with the given input-selection feerate in sat/kwu and +/// the given contributed outputs. +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_outputs( + feerate: u64, outputs: &[bitcoin::TxOut], +) -> FundingContribution { + use lightning::util::ser::Writeable; + let mut records = vec![ + 1, 8, 0, 0, 0, 0, 0, 0, 0, 0, // (1, estimated_fee: 0 sat) + ]; + 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") +} + +/// 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 { + use lightning::util::ser::{BigSize, Writeable}; + use lightning::util::wallet_utils::ConfirmedUtxo; + let mut records = vec![ + 1, 8, 0, 0, 0, 0, 0, 0, 0, 0, // (1, estimated_fee: 0 sat) + ]; + 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); + } + 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) + 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") +} + #[cfg(test)] mod tests { use bitcoin::hashes::Hash; + use lightning::util::ser::{Readable, Writeable}; use super::*; use crate::payment::store::ConfirmationStatus; @@ -237,12 +540,61 @@ 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" ); } + /// The candidate history only ever grows. An update carrying a shorter history was built + /// before the newer candidates existed — a classification retry running after a newer round + /// classified — and must not shrink the stored list, or the newer candidates' transactions + /// could no longer be mapped back to the record. + #[test] + fn candidate_history_never_shrinks() { + let txid_a = test_txid(1); + let txid_b = test_txid(2); + let txid_c = test_txid(3); + let payment_id = PaymentId(txid_a.to_byte_array()); + let candidate = |txid, fee| FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(fee), + }; + let history = vec![candidate(txid_a, 400), candidate(txid_b, 500)]; + + let mut pending = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, txid_b), + Vec::new(), + history.clone(), + ); + let stored_candidates = |pending: &PendingPaymentDetails| match pending { + PendingPaymentDetails::Tracked { candidates, .. } => candidates.clone(), + pending => panic!("unexpected variant {:?}", pending), + }; + let stale_update = PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: None, + candidates: vec![candidate(txid_a, 400)], + splice_intent: None, + }; + assert!(!pending.update(stale_update), "a stale history must not shrink the stored one"); + assert_eq!(stored_candidates(&pending), history); + + // A history that extends the stored one still replaces it, refreshed figures included. + let extended = vec![candidate(txid_a, 400), candidate(txid_b, 550), candidate(txid_c, 600)]; + let fresh_update = PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: None, + candidates: extended.clone(), + splice_intent: None, + }; + assert!(pending.update(fresh_update)); + assert_eq!(stored_candidates(&pending), extended); + } + #[test] fn funding_classification_pending_update_preserves_mirrored_confirmation() { use bitcoin::BlockHash; @@ -289,7 +641,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", @@ -304,17 +656,118 @@ 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)); + } + + 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: OutPoint { 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 [ + 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: OutPoint { 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) }], + ); + + 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()); } } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 782112dad..da283dd5d 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -5,14 +5,16 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +use std::collections::VecDeque; use std::ops::Deref; use std::sync::{Mutex as StdMutex, Weak}; -use bitcoin::Transaction; +use bitcoin::{Transaction, Txid}; use lightning::chain::chaininterface::{ BroadcasterInterface, TransactionType as LdkTransactionType, }; use tokio::sync::{mpsc, Mutex, MutexGuard}; +use tokio::time::Instant; use crate::logger::{log_error, LdkLogger}; use crate::types::Wallet; @@ -20,6 +22,13 @@ use crate::Error; const BCAST_PACKAGE_QUEUE_SIZE: usize = 256; +/// The most non-funding packages [`RetryQueue`] holds. Claims and sweeps re-enter the +/// broadcast queue on LDK's periodic rebroadcast timers, so one dropped here resurfaces on its +/// own once the store recovers. Funding packages don't count against the bound: nothing +/// re-broadcasts them for us, and they are finite — one per negotiated candidate, since a copy +/// of a waiting package is never queued twice. +const MAX_QUEUED_RETRIES: usize = BCAST_PACKAGE_QUEUE_SIZE; + /// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions` /// call, along with each transaction's type. Queued until the background task classifies and /// broadcasts it. Built only via [`BroadcastPackage::new`] from such a call, so unrelated @@ -47,6 +56,100 @@ impl BroadcastPackage { let txs = self.0.into_iter().map(|(tx, _)| tx).collect(); SortedTransactions::sort_parents_child_package_topologically(txs) } + + /// The packaged transactions' txids in sorted order, identifying the package's effect on + /// chain: two packages with the same txids broadcast the same transactions. + pub(crate) fn sorted_txids(&self) -> Vec { + let mut txids: Vec = self.0.iter().map(|(tx, _)| tx.compute_txid()).collect(); + txids.sort_unstable(); + txids + } + + /// Whether the package contains a funding transaction (a channel open or splice), whose + /// classification writes the payment record tracking the funding. + fn contains_funding(&self) -> bool { + self.0.iter().any(|(_, tx_type)| { + matches!( + tx_type, + Some( + LdkTransactionType::Funding { .. } + | LdkTransactionType::InteractiveFunding { .. } + ) + ) + }) + } +} + +/// What [`RetryQueue::schedule`] did with a package, so the caller can log the cases in which +/// the package won't be retried as-is. +pub(crate) enum ScheduleOutcome { + /// The package waits for its retry deadline. When the bound was reached, the oldest waiting + /// non-funding package was dropped to make room and is returned — its transactions resurface + /// with LDK's next periodic rebroadcast. + Scheduled { dropped: Option }, + /// A package broadcasting the same transactions already waits, and its retry covers this + /// one: the incoming package is dropped and returned. + AlreadyQueued(BroadcastPackage), + /// The bound was reached and every waiting package is a funding package, which must not be + /// dropped: the incoming package is refused and returned. + Refused(BroadcastPackage), +} + +/// Packages whose classification failed, each waiting out a retry delay before its next attempt. +/// Deduplicated and bounded: LDK re-broadcasts pending claims every 30 seconds (and sweeps once +/// per block) until they confirm, so while the store is unavailable, copies would otherwise +/// accumulate without bound and replay as a burst on recovery. An identical copy is never queued +/// twice — the waiting entry and its deadline stand; fee-bumped rebroadcast variants carry new +/// txids, so the bound — not the dedup — is what limits their accumulation. +pub(crate) struct RetryQueue(VecDeque<(Instant, Vec, BroadcastPackage)>); + +impl RetryQueue { + pub(crate) fn new() -> Self { + Self(VecDeque::new()) + } + + /// The deadline of the next retry, if a package is waiting. Packages are scheduled with a fixed + /// delay, so the front entry is always the next to retry. + pub(crate) fn next_retry_at(&self) -> Option { + self.0.front().map(|(deadline, _, _)| *deadline) + } + + /// Removes and returns the package scheduled to retry first. + pub(crate) fn pop_next(&mut self) -> Option { + self.0.pop_front().map(|(_, _, package)| package) + } + + /// Schedules a package to retry at `retry_at`, unless a package with the same transactions already + /// waits or accepting it would exceed [`MAX_QUEUED_RETRIES`] with no non-funding package to + /// drop for it; see [`ScheduleOutcome`]. + pub(crate) fn schedule( + &mut self, package: BroadcastPackage, retry_at: Instant, + ) -> ScheduleOutcome { + let txids = package.sorted_txids(); + if self.0.iter().any(|(_, waiting, _)| *waiting == txids) { + // Same transactions, same classification outcome: keep the waiting entry and its + // earlier deadline. The one same-txid package with a *different* type is LDK's + // re-typed generic-funding rebroadcast of a promoted 0conf splice, which always + // arrives after the interactive-funding original (the zero-conf rebroadcast canary + // tests assert that ordering), so the entry kept is the richer of the two — and its + // classification declines the downgrade anyway. + return ScheduleOutcome::AlreadyQueued(package); + } + + let mut dropped = None; + if !package.contains_funding() && self.0.len() >= MAX_QUEUED_RETRIES { + // Drop the oldest non-funding package: LDK re-broadcasts its transactions + // 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. + match self.0.iter().position(|(_, _, waiting)| !waiting.contains_funding()) { + Some(oldest) => dropped = self.0.remove(oldest).map(|(_, _, package)| package), + None => return ScheduleOutcome::Refused(package), + } + } + self.0.push_back((retry_at, txids, package)); + ScheduleOutcome::Scheduled { dropped } + } } pub(crate) struct SortedTransactions(Vec); @@ -133,12 +236,10 @@ where self.queue_receiver.lock().await } - /// Classifies a queued package into payment records and returns the package ready for the - /// chain client. Returns `Err` if any classification fails; callers must not broadcast the - /// package in that case, since a crash would leave the transaction on-chain without a record. - pub(crate) async fn classify_package( - &self, package: BroadcastPackage, - ) -> Result { + /// Classifies a queued package into payment records. Returns `Err` if any classification + /// fails; callers must not broadcast the package in that case, since a crash would leave the + /// transaction on-chain without a record — but must retry it later rather than drop it. + pub(crate) async fn classify_package(&self, package: &BroadcastPackage) -> Result<(), Error> { let wallet_opt = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade); if let Some(wallet) = wallet_opt { for (tx, tx_type) in package.transactions() { @@ -147,7 +248,7 @@ where } } } - Ok(package) + Ok(()) } pub(crate) fn broadcast_unclassified_transaction(&self, tx: Transaction) { @@ -173,7 +274,10 @@ mod tests { use bitcoin::hashes::Hash; use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; - use super::SortedTransactions; + use super::{ + BroadcastPackage, LdkTransactionType, RetryQueue, ScheduleOutcome, SortedTransactions, + MAX_QUEUED_RETRIES, + }; fn txin(txid: Txid, vout: u32) -> TxIn { TxIn { @@ -314,4 +418,142 @@ 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 deadline(secs: u64) -> tokio::time::Instant { + tokio::time::Instant::now() + std::time::Duration::from_secs(secs) + } + + /// A re-broadcast of the same transactions is not queued again: the waiting entry keeps its + /// earlier deadline and its package — the first arrival carries the richer classification + /// when LDK later re-types a rebroadcast. + #[tokio::test] + async fn retry_queue_queues_identical_transactions_once() { + let tx = parent_tx(1); + let mut retries = RetryQueue::new(); + + let first_deadline = deadline(2); + assert!(matches!( + retries.schedule(funding_package(&tx), first_deadline), + ScheduleOutcome::Scheduled { dropped: None } + )); + assert!(matches!( + retries.schedule(BroadcastPackage::unclassified(tx.clone()), deadline(4)), + ScheduleOutcome::AlreadyQueued(_) + )); + + assert_eq!(retries.next_retry_at(), Some(first_deadline)); + let kept = retries.pop_next().expect("the first package is kept"); + assert!( + matches!(kept.transactions()[0].1, Some(LdkTransactionType::Funding { .. })), + "the first-scheduled package must be kept" + ); + assert!(retries.pop_next().is_none()); + } + + #[tokio::test] + async fn retry_queue_retries_in_schedule_order() { + let (tx_a, tx_b) = (parent_tx(1), parent_tx(2)); + let mut retries = RetryQueue::new(); + + assert!(matches!( + retries.schedule(BroadcastPackage::unclassified(tx_a.clone()), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + assert!(matches!( + retries.schedule(BroadcastPackage::unclassified(tx_b.clone()), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + + let popped = retries.pop_next().expect("first package"); + assert_eq!(popped.sorted_txids(), vec![tx_a.compute_txid()]); + let popped = retries.pop_next().expect("second package"); + assert_eq!(popped.sorted_txids(), vec![tx_b.compute_txid()]); + } + + /// Distinct transactions (e.g. fee-bumped claim variants during a store outage) are held to + /// the bound: the oldest non-funding package is dropped for an incoming one, never a funding + /// package. + #[tokio::test] + async fn retry_queue_drops_the_oldest_non_funding_package_at_the_bound() { + fn numbered_tx(n: u32) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([7u8; 32]), n)], + output: vec![txout(1_000)], + } + } + + let mut retries = RetryQueue::new(); + let funding_tx = numbered_tx(0); + assert!(matches!( + retries.schedule(funding_package(&funding_tx), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + let oldest_claim = numbered_tx(1); + for n in 1..(MAX_QUEUED_RETRIES as u32) { + assert!(matches!( + retries.schedule(BroadcastPackage::unclassified(numbered_tx(n)), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + } + + // At the bound, an incoming non-funding package drops the oldest waiting one — not the + // older funding package. + let new_claim = numbered_tx(MAX_QUEUED_RETRIES as u32); + match retries.schedule(BroadcastPackage::unclassified(new_claim.clone()), deadline(2)) { + ScheduleOutcome::Scheduled { dropped: Some(dropped) } => { + assert_eq!(dropped.sorted_txids(), vec![oldest_claim.compute_txid()]); + }, + _ => panic!("the incoming claim must be scheduled by dropping the oldest one"), + } + + // An incoming funding package is never dropped for the bound. + let new_funding_tx = numbered_tx(MAX_QUEUED_RETRIES as u32 + 1); + assert!(matches!( + retries.schedule(funding_package(&new_funding_tx), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + + let mut remaining = Vec::new(); + while let Some(package) = retries.pop_next() { + remaining.extend(package.sorted_txids()); + } + assert!(remaining.contains(&funding_tx.compute_txid()), "funding is never dropped"); + assert!(remaining.contains(&new_claim.compute_txid())); + assert!(!remaining.contains(&oldest_claim.compute_txid())); + } + + /// When only funding packages wait at the bound, an incoming non-funding package is refused: + /// LDK re-broadcasts claims and sweeps periodically, while a dropped funding package would + /// leave its transaction confirming without a recorded candidate. + #[tokio::test] + async fn retry_queue_refuses_a_non_funding_package_over_waiting_funding_packages() { + fn numbered_tx(n: u32) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([8u8; 32]), n)], + output: vec![txout(1_000)], + } + } + + let mut retries = RetryQueue::new(); + for n in 0..(MAX_QUEUED_RETRIES as u32) { + assert!(matches!( + retries.schedule(funding_package(&numbered_tx(n)), deadline(2)), + ScheduleOutcome::Scheduled { dropped: None } + )); + } + + let claim = numbered_tx(MAX_QUEUED_RETRIES as u32); + assert!(matches!( + retries.schedule(BroadcastPackage::unclassified(claim), deadline(2)), + ScheduleOutcome::Refused(_) + )); + } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index b9c12b4a7..de18c2a3c 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)] @@ -160,9 +161,9 @@ pub(crate) struct Wallet { logger: Arc, pending_payment_store: Arc, // Serializes the writers that must observe the payment record and its pending-store entry - // (candidate history included) as one consistent unit: classification holds it across its - // two-store write pair, and wallet sync's event arms hold it from payment-id resolution - // through their last write. Without it, a confirmation landing between classification's two + // (candidate history included) as one consistent unit: classification and wallet sync's event + // arms each hold it from payment-id resolution through their last write (classification's + // being its two-store pair). Without it, a confirmation landing between classification's two // writes sees the record classified but the candidate history absent — resolving the wrong // payment id or stamping the confirmed candidate with another candidate's figures — and a // classification landing inside an arm's decision sequence gets overwritten by the arm's @@ -346,12 +347,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,6 +361,23 @@ impl Wallet { ) .await? { + 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()); + }, + } + + // The fallback id belongs to a settled funding payment whose entry is gone: + // skip rather than resurrect it (see `has_funding_record`). + if self.has_funding_record(&payment_id).await? { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); continue; } @@ -378,35 +396,40 @@ impl Wallet { self.payment_store.insert_or_update(payment.clone()).await?; if payment_status == PaymentStatus::Pending { - let pending_payment = - self.create_pending_payment_from_tx(payment, Vec::new()); - - self.pending_payment_store.insert_or_update(pending_payment).await?; + self.upsert_pending_payment(payment, Vec::new()).await?; } }, WalletEvent::ChainTipChanged { new_tip, .. } => { let pending_payments: Vec = self .pending_payment_store - .list_filter(|p| { - debug_assert!( - p.details.status == PaymentStatus::Pending, - "Non-pending payment {:?} found in pending store", - p.details.id, - ); - p.details.status == PaymentStatus::Pending - && matches!(p.details.kind, PaymentKind::Onchain { .. }) + .list_filter(|p| match p.details() { + // A pre-broadcast splice intent carries no payment yet and cannot graduate. + None => false, + Some(details) => { + debug_assert!( + details.status == PaymentStatus::Pending, + "Non-pending payment {:?} found in pending store", + details.id, + ); + details.status == PaymentStatus::Pending + && matches!(details.kind, PaymentKind::Onchain { .. }) + }, }) .await; let mut unconfirmed_outbound_txids: Vec = Vec::new(); for payment in pending_payments { - match payment.details.kind { + // The filter admits only Tracked funding payments. + let PendingPaymentDetails::Tracked { ref details, .. } = payment else { + continue; + }; + match details.kind { PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { height, .. }, .. } => { - let payment_id = payment.details.id; + let payment_id = details.id; if new_tip.height >= height + ANTI_REORG_DELAY - 1 { // Graduate from the live record, not the snapshot listed // above: a classification landing since then must not have @@ -449,8 +472,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 details.direction == PaymentDirection::Outbound { + unconfirmed_outbound_txids.push(txid); + } }, _ => {}, } @@ -487,12 +518,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,6 +532,23 @@ impl Wallet { ) .await? { + 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()); + }, + } + + // The fallback id belongs to a settled funding payment whose entry is gone: + // skip rather than resurrect it (see `has_funding_record`). + if self.has_funding_record(&payment_id).await? { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); continue; } @@ -515,10 +563,8 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ) }; - let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.payment_store.insert_or_update(payment).await?; - self.pending_payment_store.insert_or_update(pending_payment).await?; + self.payment_store.insert_or_update(payment.clone()).await?; + self.upsert_pending_payment(payment, Vec::new()).await?; }, WalletEvent::TxReplaced { txid, conflicts, .. } => { // See `TxConfirmed`: id resolution and the writes below must not interleave @@ -553,22 +599,31 @@ impl Wallet { payment_id, ); let payment = stored_payment.ok_or(Error::InvalidPaymentId)?; - 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?; + // 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; + } + + self.upsert_pending_payment(payment, conflict_txids).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 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,6 +632,23 @@ impl Wallet { ) .await? { + 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()); + }, + } + + // The fallback id belongs to a settled funding payment whose entry is gone: + // skip rather than resurrect it (see `has_funding_record`). + if self.has_funding_record(&payment_id).await? { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); continue; } @@ -591,10 +663,8 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ) }; - let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.payment_store.insert_or_update(payment).await?; - self.pending_payment_store.insert_or_update(pending_payment).await?; + self.payment_store.insert_or_update(payment.clone()).await?; + self.upsert_pending_payment(payment, Vec::new()).await?; }, _ => { continue; @@ -605,6 +675,157 @@ impl Wallet { Ok(()) } + /// Whether a funding-classified record exists under the given id. A funding record's id is + /// anchored to its first candidate's txid, so a wallet event for that transaction falls back + /// to this id whenever the pending entry no longer maps it — which only happens once the + /// negotiation settled and the entry was removed. The generic event handling must then skip + /// its write: merging a wallet-view `Pending` payment into the settled record would resurrect + /// it with figures no classification derived. + async fn has_funding_record(&self, payment_id: &PaymentId) -> Result { + Ok(self.payment_store.get(payment_id).await?.is_some_and(|payment| { + matches!( + payment.kind, + PaymentKind::Onchain { + tx_type: Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. } + ), + .. + } + ) + })) + } + + /// Fails a funding payment whose transaction has irrevocably lost a conflict: a transaction + /// outside the record's candidate history — e.g. a channel close double-spending a pending + /// splice's shared input — has confirmed through [`ANTI_REORG_DELAY`] while neither the + /// record's transaction nor any candidate is canonical anymore. Returns whether the payment + /// was failed; failing also removes the pending entry, dropping the dead record from the + /// tip-change pass. (Its transaction was already excluded from rebroadcast by the same + /// canonical-only `get_tx` gate used below.) + /// + /// Only funding-classified records are considered: nothing re-submits a replaced funding + /// transaction under the same record (an RBF round is a new candidate), so a buried foreign + /// conflict is final for them. The liveness check guards the case where the conflict + /// double-spent only one round of the negotiation: as long as some candidate — including one + /// classification hasn't recorded yet — can still confirm, the record must stay pending. + async fn fail_funding_payment_lost_to_conflict( + &self, payment: &PendingPaymentDetails, tip_height: u32, + ) -> Result { + 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); + } + + // 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_id).await? { + Some(entry) => entry, + None => return Ok(false), + }; + let PendingPaymentDetails::Tracked { details, conflicting_txids, candidates, .. } = &entry + else { + return Ok(false); + }; + let record_txid = match details.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + } => txid, + _ => return Ok(false), + }; + + let foreign_conflicts: Vec = 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() + || 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 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, @@ -876,6 +1097,51 @@ 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 + }) + } + + /// Whether the wallet-known transaction `txid` spends any of `outpoints`. `false` for a + /// transaction the wallet has never seen. + pub(crate) fn tx_spends_outpoints(&self, txid: Txid, outpoints: &[OutPoint]) -> bool { + let locked_wallet = self.inner.lock().expect("lock"); + locked_wallet.get_tx(txid).map_or(false, |wallet_tx| { + wallet_tx + .tx_node + .tx + .input + .iter() + .any(|input| outpoints.contains(&input.previous_output)) + }) + } + pub(crate) fn get_balances( &self, total_anchor_channels_reserve_sats: u64, ) -> Result<(u64, u64), Error> { @@ -1586,7 +1852,15 @@ impl Wallet { return Ok(()); } - let payment_id = PaymentId(txid.to_byte_array()); + // Resolution and the writes below must share one lock acquisition: resolved outside it, + // the id could go stale against a record wallet sync creates for the same transaction, + // and the write below would create a divergent record. + let guard = self.funding_payment_update_lock.lock().await; + + // Adopt the id of a record that already tracks this transaction — e.g. a 0conf splice + // re-broadcast through LDK's generic funding path resolves back to its + // interactive-funding record here — otherwise generate a fresh id. + let payment_id = self.find_payment_by_txid(txid).await?.unwrap_or_else(random_payment_id); // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed // and carrying wallet-view figures; `funding_reclassification_update` declines the @@ -1621,7 +1895,7 @@ impl Wallet { direction, PaymentStatus::Pending, ); - self.persist_funding_payment(details, Vec::new()).await?; + self.persist_funding_payment_locked(&guard, details, Vec::new()).await?; log_debug!( self.logger, "Recorded channel-funding broadcast {} for channel {}", @@ -1631,6 +1905,25 @@ impl Wallet { Ok(()) } + /// Returns the `PaymentId` of a user-initiated splice intent for one of the channels in + /// `candidate`, if any, so a classified splice adopts the id chosen at splice time rather than + /// deriving one from the first candidate's txid. A fee bump reuses the channel's existing intent, + /// so at most one in-flight intent matches and the first is unambiguous. + async fn find_splice_payment_id(&self, candidate: &FundingCandidate) -> Option { + self.pending_payment_store + .list_filter(|p| { + p.splice_intent().is_some_and(|intent| { + candidate.channels.iter().any(|channel| { + channel.channel_id == intent.channel_id + && channel.counterparty_node_id == intent.counterparty_node_id + }) + }) + }) + .await + .first() + .map(|p| p.id()) + } + /// 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 @@ -1644,10 +1937,6 @@ impl Wallet { 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"); @@ -1681,9 +1970,28 @@ impl Wallet { return Ok(()); } - // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable - // across RBF replacements. - let payment_id = PaymentId(first.txid.to_byte_array()); + // Resolution and the writes below must share one lock acquisition: resolved outside it, + // the id could go stale against a record wallet sync creates for the same transaction, + // and the write below would create a divergent record. + let guard = self.funding_payment_update_lock.lock().await; + + // Adopt the `PaymentId` generated when the splice was initiated so its splice intent, + // funding payment, and candidate history share one record. If the intent is already gone + // (e.g. the splice locked before this classification ran), adopt the id of a record wallet + // sync created for any candidate rather than creating a divergent one; otherwise generate + // a fresh id — 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. + let mut resolved_id = self.find_splice_payment_id(active).await; + if resolved_id.is_none() { + for candidate in candidates.iter() { + if let Some(id) = self.find_payment_by_txid(candidate.txid).await? { + resolved_id = Some(id); + break; + } + } + } + let payment_id = resolved_id.unwrap_or_else(random_payment_id); // 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 @@ -1713,7 +2021,7 @@ impl Wallet { direction, PaymentStatus::Pending, ); - self.persist_funding_payment(details, candidate_records).await?; + self.persist_funding_payment_locked(&guard, details, candidate_records).await?; log_debug!( self.logger, "Recorded interactive-funding broadcast {} ({} candidates, {} channels)", @@ -1760,13 +2068,31 @@ 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`. + /// + /// 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 entering classification mid-flow. + #[cfg(test)] async fn persist_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. - 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 + } + /// 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`. 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 + /// classified but the candidate history it needs still missing, and the resolved id never + /// goes stale against a concurrent sync write. + async fn persist_funding_payment_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, details: PaymentDetails, + candidates: Vec, + ) -> Result<(), Error> { + let merge_candidates = candidates.clone(); // 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 @@ -1807,38 +2133,155 @@ impl Wallet { // is ordered before the removal, which then also deletes anything inserted here. A // status read taken before this write goes stale when graduation lands in between, and // would re-index the graduated payment. + let mut leftover_intent_to_remove = None; + // The `move` closure would capture the `Option` by value, so hand it a reference; the + // borrow ends with the mutate's future, before the leftover is read below. + let leftover = &mut leftover_intent_to_remove; let payment_store = Arc::clone(&self.payment_store); self.pending_payment_store .mutate_async(&id, move |existing| async move { // The record was written above and payment records are never removed, so absence - // means the write failed out; fall back to the fresh details. + // means the write failed out; fall back to the fresh details. A promoted or + // (re)created entry embeds this post-write record rather than the fresh + // Unconfirmed details, so a confirmation wallet sync already recorded keeps + // driving graduation. let recorded = payment_store.get(&id).await?.unwrap_or(details); + // A candidate history that lacks the record's current txid is stale — a queued + // classification retrying after a newer round classified. The merge arm below + // refuses such a history; creating or promoting an entry from it would smuggle + // it past that refusal, so leave that to a fresh classification (the newer + // round's own write, or its retry) instead. + let stale = match &recorded.kind { + PaymentKind::Onchain { txid, .. } if !candidates.is_empty() => { + !candidates.iter().any(|c| c.txid == *txid) + }, + _ => false, + }; 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 && !stale).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, .. }) => { + if recorded.status == PaymentStatus::Pending && !stale { + Some(PendingPaymentDetails::tracked( + recorded, + Vec::new(), + candidates, + Some(intent), + )) + } else { + *leftover = Some(intent); + 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 classification — wallet sync recorded the - // transaction before it was classified (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. - Some(mut entry) => { + // An earlier candidate's classification or wallet sync recorded this payment + // before this classification ran (sync's arms and this write pair serialize + // on the cross-store lock, so nothing lands in between): merge only the + // 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), conflicting_txids: None, candidates, + splice_intent: None, }; - entry.update(pending_update).then_some(entry) + 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 splice + // entry point may have replaced the intent (a new attempt reuses the channel's record) + // in between, and that live intent must stay. + self.pending_payment_store + .remove_if(&id, |record| { + record.details().is_none() && record.splice_intent() == Some(&intent) + }) + .await?; + } + + // With the candidate history recorded, duplicates wallet sync created for rounds that were + // not yet candidates can be folded back into this record. Runs after both writes so the + // funding-status gate accepts the candidates it adopts, and under the same lock + // acquisition, so sync cannot interleave; a failure surfaces to the broadcast queue's + // classification retry, which re-runs this idempotently. + self.merge_duplicate_candidate_records(_guard, id, &merge_candidates).await?; + Ok(()) + } + + /// Merges duplicate records wallet sync created for this funding payment's candidates before + /// they were classified. 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. + /// + /// The caller must hold [`Self::funding_payment_update_lock`], per + /// [`Self::apply_funding_status_update_locked`]'s contract. + async fn merge_duplicate_candidate_records( + &self, guard: &tokio::sync::MutexGuard<'_, ()>, id: PaymentId, + candidates: &[FundingTxCandidate], + ) -> Result<(), Error> { + for candidate in candidates { + let duplicate_id = PaymentId(candidate.txid.to_byte_array()); + if duplicate_id == id { + continue; + } + let duplicate = match self.payment_store.get(&duplicate_id).await? { + Some(duplicate) => duplicate, + None => continue, + }; + // Only a duplicate view of this candidate's transaction qualifies: an untyped record + // wallet sync created, or one a funding-typed rebroadcast classified onto it. Anything + // else keyed by the txid-derived id is left alone. + let status = match &duplicate.kind { + PaymentKind::Onchain { + txid, + status, + tx_type: None | Some(TransactionType::Funding { .. }), + } if *txid == candidate.txid => status.clone(), + _ => continue, + }; + // Only a confirmation is worth adopting; an unconfirmed duplicate carries nothing the + // record needs — the actively-broadcast candidate stays the record's current txid. + if matches!(status, ConfirmationStatus::Confirmed { .. }) { + let outcome = self + .apply_funding_status_update_locked(guard, id, candidate.txid, status) + .await?; + debug_assert!(matches!(outcome, FundingStatusUpdate::Applied)); + if !matches!(outcome, FundingStatusUpdate::Applied) { + // Adoption declined; keep the duplicate rather than discard its confirmation. + continue; + } + } + log_debug!( + self.logger, + "Merging duplicate payment record for funding transaction {}", + candidate.txid, + ); + // Pending entry first: the retry of a failure between these two removals rediscovers + // the duplicate through its payment record. Removed the other way around, the + // leftover pending entry would be unreachable to the retry yet keep shadowing the + // funding record in `find_payment_by_txid`'s direct probe. + self.pending_payment_store.remove(&duplicate_id).await?; + self.payment_store.remove(&duplicate_id).await?; + } Ok(()) } @@ -1904,10 +2347,52 @@ impl Wallet { PaymentDetails::new(payment_id, kind, amount_msat, fee_paid_msat, direction, payment_status) } - fn create_pending_payment_from_tx( + /// Inserts or refreshes the pending-store entry tracking `payment` toward graduation, + /// atomically with reading the entry's current state. + async fn upsert_pending_payment( &self, payment: PaymentDetails, conflicting_txids: Vec, - ) -> PendingPaymentDetails { - PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()) + ) -> Result<(), Error> { + let id = payment.id; + let payment_store = Arc::clone(&self.payment_store); + self.pending_payment_store + .mutate_async(&id, move |existing| async move { + // Only `Pending` payments belong in the pending store. Like in + // [`Self::persist_funding_payment`], the authoritative status is re-read inside + // the store's critical section, where it cannot go stale against graduation. + let is_pending = payment_store + .get(&id) + .await? + .map_or(payment.status == PaymentStatus::Pending, |recorded| { + recorded.status == PaymentStatus::Pending + }); + if !is_pending { + return Ok(None); + } + Ok(match existing { + None => { + Some(PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())) + }, + // Promote a pre-broadcast splice intent: wallet sync saw the splice + // transaction before its broadcast-time classification recorded it. 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), + )) + }, + Some(mut tracked @ PendingPaymentDetails::Tracked { .. }) => { + let fresh = + PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()); + tracked.update(fresh.to_update()).then_some(tracked) + }, + }) + }) + .await?; + Ok(()) } async fn find_payment_by_txid(&self, target_txid: Txid) -> Result, Error> { @@ -1919,17 +2404,38 @@ impl Wallet { if let Some(replaced_details) = self .pending_payment_store .list_filter(|p| { - matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid) - || p.conflicting_txids.contains(&target_txid) + p.details().is_some_and( + |d| matches!(d.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. + // 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() }) .await .first() { - return Ok(Some(replaced_details.details.id)); + return Ok(Some(replaced_details.id())); + } + + // The pending store only indexes in-flight records — graduation removes the entry — so a + // graduated record's transaction resolves through the payment store itself. Without this, + // a funding-typed broadcast classified after graduation (e.g. LDK re-broadcasting a promoted + // 0conf splice whose confirmation landed while the node was offline) would create a + // duplicate record, and a post-graduation reorg's events would never reach the record. + let mut page_token = None; + loop { + let page = self.payment_store.list_page(page_token).await?; + if let Some(payment) = page.objects.iter().find( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), + ) { + return Ok(Some(payment.id)); + } + match page.next_page_token { + Some(token) => page_token = Some(token), + None => break, + } } Ok(None) @@ -1938,9 +2444,11 @@ impl Wallet { /// If `payment_id` refers to a classified funding payment, refreshes its confirmation status /// and the candidate txid the event refers to, while preserving the contribution-derived /// amount/fee and `tx_type` that wallet sync must not recompute from its own view: the wallet's - /// `sent`/`received` don't capture our contribution to a shared funding output. Returns `true` - /// when it handled the payment, so the caller skips the default on-chain path. Graduation to - /// `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. + /// `sent`/`received` don't capture our contribution to a shared funding output. Returns + /// [`FundingStatusUpdate::Applied`] when it handled the payment, so the caller skips the + /// default on-chain path — or [`FundingStatusUpdate::Foreign`] when the transaction is not + /// part of the payment's funding history, so the caller records it under its own id. + /// Graduation to `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. /// /// The caller must hold [`Self::funding_payment_update_lock`] — from resolving `payment_id` /// through its own last write, not just across this call — so that classification's two-store @@ -1949,38 +2457,51 @@ impl Wallet { async fn apply_funding_status_update_locked( &self, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, - ) -> Result { + ) -> Result { // The caller's wallet-level lock keeps the candidate history stable while we await its - // read. The funding-type gate and write then share the payment store's mutation lock: - // against a separate payment `get`, a classification merging in between would have its - // `tx_type` and contribution figures clobbered by this stale snapshot. + // read. The funding-type gate, the candidate lookup, and the write then share the payment + // store's mutation lock: against a separate payment `get`, a classification merging in + // between would have its `tx_type` and contribution figures clobbered by this stale + // snapshot. let pending_payment = self.pending_payment_store.get(&payment_id).await?; + let mut outcome = FundingStatusUpdate::NotFunding; let mut handled = None; self.payment_store .mutate(&payment_id, |existing| { let payment = existing?; - let tx_type = match &payment.kind { + let (current_txid, tx_type) = match &payment.kind { PaymentKind::Onchain { + txid, tx_type: tx_type @ Some( TransactionType::Funding { .. } | TransactionType::InteractiveFunding { .. }, ), .. - } => tx_type.clone(), + } => (*txid, tx_type.clone()), _ => return None, }; + // Adopt the event's txid only when the transaction is part of this payment's + // funding history: its current txid or a classified candidate. A conflicting + // transaction that is neither — a close also spends the funding outpoint — must + // not overwrite the record. + let owns_event_tx = event_txid == current_txid + || pending_payment.as_ref().is_some_and(|p| p.candidate(event_txid).is_some()); + if !owns_event_tx { + outcome = FundingStatusUpdate::Foreign; + return None; + } // Report the figures of the candidate that actually confirmed, which need not be // the last one broadcast (an earlier, lower-fee candidate may win) and may carry // no figures at all (`None`) for a round we didn't contribute to. (`direction` is // invariant across a splice's candidates and cannot be changed through the store // anyway.) let mut target = payment.clone(); - if let Some(pending) = pending_payment.as_ref() { - if let Some(candidate) = pending.candidate(event_txid) { - target.amount_msat = candidate.amount_msat; - target.fee_paid_msat = candidate.fee_paid_msat; - } + if let Some(candidate) = + pending_payment.as_ref().and_then(|p| p.candidate(event_txid)) + { + target.amount_msat = candidate.amount_msat; + target.fee_paid_msat = candidate.fee_paid_msat; } target.kind = PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; @@ -1998,17 +2519,16 @@ 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 // the same dual-write the default `TxConfirmed` path performs; an empty conflicting-txids // list leaves any stored conflicts intact (the update treats absent as "unchanged"). if payment.status == PaymentStatus::Pending { - let pending = self.create_pending_payment_from_tx(payment, Vec::new()); - self.pending_payment_store.insert_or_update(pending).await?; + self.upsert_pending_payment(payment, Vec::new()).await?; } - Ok(true) + Ok(FundingStatusUpdate::Applied) } #[allow(deprecated)] @@ -2249,8 +2769,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| { @@ -2258,8 +2776,8 @@ impl Wallet { Error::PersistenceFailed })?; - self.payment_store.insert_or_update(new_payment).await?; - self.pending_payment_store.insert_or_update(pending_payment_store).await?; + self.payment_store.insert_or_update(new_payment.clone()).await?; + self.upsert_pending_payment(new_payment, Vec::new()).await?; self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx); @@ -2311,6 +2829,29 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { } } +/// 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. +pub(crate) 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 + /// 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, @@ -2638,7 +3179,7 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { /// 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 +/// 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. @@ -2667,6 +3208,29 @@ fn funding_reclassification_update( return PaymentDetailsUpdate::new(details.id); } + // An interactive-funding classification carries the full candidate history as of its own + // broadcast, and once a record is funding-classified its txid only ever names a candidate + // from that history. A classification whose history lacks such a record's current txid was + // therefore built before that candidate existed — a queued retry running after a newer round + // classified. Applying it would rotate the record backwards; the newer round's + // classification already recorded everything this one knows. A record that is not yet + // funding-classified gives no such signal — wallet sync can have rotated its txid to a + // conflicting transaction that is no candidate at all — so its first classification must + // still land. + if !candidates.is_empty() { + if let Some(PaymentKind::Onchain { + txid: current_txid, + tx_type: + Some(TransactionType::Funding { .. } | TransactionType::InteractiveFunding { .. }), + .. + }) = current.map(|payment| &payment.kind) + { + if !candidates.iter().any(|c| c.txid == *current_txid) { + return PaymentDetailsUpdate::new(details.id); + } + } + } + let mut update = PaymentDetailsUpdate::funding_reclassification(details); if let Some(PaymentKind::Onchain { txid: confirmed_txid, @@ -2687,7 +3251,7 @@ fn funding_reclassification_update( #[cfg(all(test, any(feature = "chain-esplora", feature = "chain-electrum")))] mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::Duration; use bdk_chain::{BlockId, ConfirmationBlockTime}; @@ -2717,11 +3281,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 { @@ -2729,6 +3295,7 @@ mod tests { Self { inner: Arc::new(InMemoryStore::new()), fail_writes: Arc::new(AtomicBool::new(false)), + failed_writes: Arc::new(AtomicUsize::new(0)), } } } @@ -2745,11 +3312,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 @@ -2783,6 +3352,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 { @@ -2877,13 +3526,147 @@ mod tests { wallet.address_pool.lock().unwrap().available.iter().map(|(index, _)| *index).collect() } - #[tokio::test] - async fn refill_publishes_addresses_only_after_their_reveal_is_persisted() { - let fail_store = FailSwitchStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; + fn test_splice_intent() -> crate::payment::pending_payment_store::SpliceIntent { + use crate::payment::pending_payment_store::{SpliceIntent, SpliceKind}; - wallet.refill_address_pool().await.unwrap(); + 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 classification_promotes_a_pre_broadcast_intent_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([21u8; 32]); + let txid = Txid::from_byte_array([22u8; 32]); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::pending_splice(id, test_splice_intent())) + .await + .unwrap(); + + let candidates = vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + wallet + .persist_funding_payment(funding_payment(id, txid, PaymentStatus::Pending), candidates) + .await + .unwrap(); + + // The pre-broadcast record is promoted into the tracked funding payment, carrying its + // intent until the splice locks. + let record = wallet + .pending_payment_store + .get(&id) + .await + .unwrap() + .expect("the record must be promoted"); + assert!(record.details().is_some()); + assert!(record.splice_intent().is_some()); + } + + #[tokio::test] + async fn classification_removes_the_intent_record_of_an_advanced_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([23u8; 32]); + let txid = Txid::from_byte_array([24u8; 32]); + wallet + .pending_payment_store + .insert(PendingPaymentDetails::pending_splice(id, test_splice_intent())) + .await + .unwrap(); + // Wallet sync confirmed the payment through `ANTI_REORG_DELAY` before classification ran: + // the payment graduated, so the record must not enter the pending store... + wallet + .payment_store + .insert(funding_payment(id, txid, PaymentStatus::Succeeded)) + .await + .unwrap(); + + let candidates = vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + wallet + .persist_funding_payment(funding_payment(id, txid, PaymentStatus::Pending), candidates) + .await + .unwrap(); + + // ...and the splice behind the intent confirmed, so the leftover intent record is removed + // rather than left to look like a splice still in flight after a restart. + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// The startup decision whether a locked funding consumed a lost splice's inputs — release + /// them or not — must only trust transactions the wallet has actually seen spending them. + #[tokio::test] + async fn tx_spends_outpoints_only_matches_known_spends() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let spent = OutPoint { txid: Txid::from_byte_array([8u8; 32]), vout: 1 }; + let other = OutPoint { txid: Txid::from_byte_array([9u8; 32]), vout: 0 }; + let txid = { + let mut locked_wallet = wallet.inner.lock().unwrap(); + // Pay the wallet itself so the transaction is one it keeps. + let script_pubkey = + locked_wallet.next_unused_address(KeychainKind::External).address.script_pubkey(); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![bitcoin::TxIn { previous_output: spent, ..bitcoin::TxIn::default() }], + output: vec![TxOut { value: Amount::from_sat(1_000), script_pubkey }], + }; + let txid = tx.compute_txid(); + locked_wallet.apply_unconfirmed_txs([(tx, 1u64)]); + txid + }; + + assert!(wallet.tx_spends_outpoints(txid, &[spent, other])); + assert!(!wallet.tx_spends_outpoints(txid, &[other])); + // A transaction the wallet has never seen spends nothing, whatever the outpoints. + assert!(!wallet.tx_spends_outpoints(Txid::from_byte_array([7u8; 32]), &[spent])); + } + + #[tokio::test] + async fn refill_publishes_addresses_only_after_their_reveal_is_persisted() { + 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.refill_address_pool().await.unwrap(); assert_eq!(pooled_indices(&wallet).len(), ADDRESS_POOL_TARGET_SIZE); // Simulate a handout, then make wallet writes fail: the refill must not publish the @@ -3713,6 +4496,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]); @@ -3755,12 +4589,20 @@ mod tests { #[test] fn funding_reclassification_update_keeps_the_active_candidate() { + let prior_txid = Txid::from_byte_array([1u8; 32]); 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), - }]; + let candidates = vec![ + FundingTxCandidate { + txid: prior_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + }, + FundingTxCandidate { + txid: active_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }, + ]; let details = onchain_details(active_txid, ConfirmationStatus::Unconfirmed); // No record yet: the update describes the active candidate. @@ -3768,25 +4610,79 @@ mod tests { 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); + // An unconfirmed record on the prior candidate: rotate to the active one (RBF). + let unconfirmed = onchain_details(prior_txid, 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)); + let update = funding_reclassification_update(details, &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 classification whose candidate history lacks a funding-classified record's current txid + /// was built before that candidate existed — a queued retry running after a newer round + /// classified — and must move nothing, whatever the record's confirmation state. A record + /// that is not yet funding-classified gives no such signal (wallet sync can have rotated its + /// txid to a conflicting non-candidate), so its first classification must still land. + #[test] + fn funding_reclassification_update_refuses_a_stale_candidate_history() { + let stale_txid = Txid::from_byte_array([1u8; 32]); + let newer_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId(stale_txid.to_byte_array()); + let stale_history = vec![FundingTxCandidate { + txid: stale_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + }]; + let details = + interactive_funding_details(payment_id, stale_txid, Some(1_000_000), Some(400)); + + // The record moved on to a newer candidate while this classification was queued. + let unconfirmed = + interactive_funding_details(payment_id, newer_txid, Some(1_000_000), Some(500)); + let update = + funding_reclassification_update(details.clone(), &stale_history, Some(&unconfirmed)); + let mut updated = unconfirmed.clone(); + assert!(!updated.update(update), "a stale retry must not move an unconfirmed record"); + assert_eq!(updated, unconfirmed); + + // Same when the newer candidate has already confirmed. + let mut confirmed = unconfirmed.clone(); + confirmed.kind = PaymentKind::Onchain { + txid: newer_txid, + status: confirmed_status(), + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + let update = + funding_reclassification_update(details.clone(), &stale_history, Some(&confirmed)); + let mut updated = confirmed.clone(); + assert!(!updated.update(update), "a stale retry must not move a confirmed record"); + assert_eq!(updated, confirmed); + + // A record that was never funding-classified: wallet sync rotated its txid to a + // conflicting transaction, which is no candidate. Its first classification is not stale + // and must land. + let mut unclassified = + interactive_funding_details(payment_id, newer_txid, Some(1_000_000), Some(500)); + unclassified.kind = PaymentKind::Onchain { + txid: newer_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }; + let update = funding_reclassification_update(details, &stale_history, Some(&unclassified)); + let mut updated = unclassified.clone(); + assert!(updated.update(update), "a first classification must not be treated as stale"); + match &updated.kind { + PaymentKind::Onchain { txid, tx_type, .. } => { + assert_eq!(*txid, stale_txid); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } } /// A funding-typed (re)classification of a record already classified as interactive funding @@ -3960,84 +4856,45 @@ mod tests { assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(payment_id)); } - /// 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 - /// 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. + /// 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 funding_broadcast_without_wallet_activity_is_not_recorded() { + 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 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_store.list_page(None).await.unwrap().objects.is_empty()); - assert!(wallet.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(), - }], + 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![] }), }; - wallet.classify_funding(&splice_tx, &channels, tx_type.clone()).await.unwrap(); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); + graduated.status = PaymentStatus::Succeeded; + wallet.payment_store.insert_or_update(graduated).await.unwrap(); - // 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_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())); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(payment_id)); } - /// 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. + /// 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_rebroadcast_keeps_interactive_funding_classification() { + 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; - // 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 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() @@ -4045,61 +4902,1302 @@ mod tests { .reveal_next_address(KeychainKind::External) .address .script_pubkey(); - let tx = Transaction { + let close_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 }], + 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 txid = tx.compute_txid(); - let payment_id = PaymentId(txid.to_byte_array()); + 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, + txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), }]; - let details = interactive_funding_details(payment_id, txid, Some(1_000_000), 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(); - 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_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; + // 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(), + splice_intent: None, + }) + .await + .unwrap(); - // 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()), + 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(); - wallet.classify_funding(&tx, &channels, tx_type).await.unwrap(); - assert_unchanged(&wallet, payment_id, true).await; + + 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), + } + } + + /// 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(), + splice_intent: None, + }) + .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(), + splice_intent: None, + }) + .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(), + splice_intent: None, + }) + .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(), + splice_intent: None, + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, conflict_tx, 5); + insert_unconfirmed_tx(&wallet, live_candidate_tx); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some(), + "a candidate can still confirm, so the record must stay pending" + ); + } + + /// The failure write pair is record first, entry second: a crash in between leaves a + /// `Failed` record with a lingering entry. The next tip pass must finish the job — remove + /// the entry without disturbing the record. + #[tokio::test] + async fn a_failed_funding_payment_with_a_lingering_entry_is_cleaned_up() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut recorded = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_store.insert_or_update(recorded).await.unwrap(); + + // The entry embeds the pre-failure snapshot, as a crash between the two writes leaves it. + let snapshot = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the repair pass must not rewrite"); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none(), + "the lingering entry must be removed" + ); + } + + /// A crash between the failure's record write and its entry removal loses the wallet + /// changeset too, so the restart's catch-up sync replays the same events: `TxReplaced` for + /// the dead funding transaction resolves through the lingering entry to the already-`Failed` + /// record. Re-embedding that record would stamp `Failed` into the entry and hide it from the + /// pending listing that repairs it; the replay must instead finish the interrupted removal. + #[tokio::test] + async fn replayed_replacement_finishes_an_interrupted_failure() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut recorded = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_store.insert_or_update(recorded).await.unwrap(); + + let snapshot = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let events = vec![ + WalletEvent::TxReplaced { + txid: splice_txid, + tx: Arc::new(dummy_tx()), + conflicts: vec![(0, close_txid)], + }, + WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }, + ]; + wallet.update_payment_store(events).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the replay must not rewrite the record"); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none(), + "the replay must finish the interrupted entry removal" + ); + } + + /// A funding record's id is anchored to its first candidate's txid. Once the payment settles + /// and its entry is removed, a wallet event for that candidate no longer resolves through the + /// candidate history — the fallback keys it by its own txid, colliding with the record's id. + /// Recording the event there would merge a fresh wallet-view `Pending` payment into the + /// terminal record; such events must be skipped. + #[tokio::test] + async fn candidate_event_does_not_resurrect_a_settled_funding_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // The record's id derives from the first candidate r1; its txid rotated to the RBF round + // r2. The payment failed and its pending entry is gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let r2 = Txid::from_byte_array([4u8; 32]); + let payment_id = PaymentId(r1.to_byte_array()); + let mut recorded = interactive_funding_details(payment_id, r2, Some(1_000_000), Some(600)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_store.insert_or_update(recorded).await.unwrap(); + + // r1 reappears in the mempool after the failure... + let event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + + // ...and even confirms: the record settled as `Failed` and must stay that way. + let event = WalletEvent::TxConfirmed { + txid: r1, + tx: Arc::new(dummy_tx()), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + } + + /// The failure transition must apply regardless of the payment's direction: a splice-out + /// records as `Inbound` (funds return to the wallet) and dies to a conflicting close the + /// same way an outbound one does. + #[tokio::test] + async fn inbound_funding_payment_fails_once_a_foreign_conflict_confirms_to_depth() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let mut details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + details.direction = PaymentDirection::Inbound; + wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .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 + /// 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] + async fn funding_broadcast_without_wallet_activity_is_not_recorded() { + 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_store.list_page(None).await.unwrap().objects.is_empty()); + assert!(wallet.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_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_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_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let record = &payments[0]; + assert_ne!(record.id, PaymentId(txid.to_byte_array()), "the id must not be the txid"); + match &record.kind { + PaymentKind::Onchain { txid: kind_txid, .. } => assert_eq!(*kind_txid, txid), + kind => panic!("unexpected kind {:?}", kind), + } + // The pending entry shares the id, and txid lookups resolve to the record. + assert!(wallet.pending_payment_store.get(&record.id).await.unwrap().is_some()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(record.id)); + } + + /// A funding transaction classified again — e.g. a 0conf splice re-broadcast through LDK's + /// generic funding path after a restart — must resolve to the record's generated id rather + /// than create a second record for the same transaction. + #[tokio::test] + async fn funding_rebroadcast_resolves_to_the_generated_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; + + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let funded_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let txid = funded_tx.compute_txid(); + + // The record the interactive-funding classification created, 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), + }]; + let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + + // The re-typed rebroadcast comes back through the generic funding path. + wallet + .classify_funding(&funded_tx, &channels, TransactionType::Funding { channels: vec![] }) + .await + .unwrap(); + + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the rebroadcast must not create a second record"); + assert_eq!(payments[0].id, payment_id); + // The interactive classification and contribution figures survive the generic + // wallet-view update (`funding_reclassification_update` declines the downgrade). + assert!(matches!( + payments[0].kind, + PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } + )); + assert_eq!(payments[0].amount_msat, Some(1_000_000)); + } + + /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding + /// 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), + }]; + 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_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), + }]; + let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; + let tx_type = TransactionType::Funding { channels: vec![] }; + wallet.classify_funding(&tx, &channels, tx_type).await.unwrap(); + + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the rebroadcast must not create a second record"); + assert_eq!(payments[0].id, payment_id); + assert_eq!(payments[0].amount_msat, Some(1_000_000)); + assert_eq!(payments[0].fee_paid_msat, Some(500)); + } + + /// A funding broadcast whose classification fails must be retried, not dropped: 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 duplicate record instead of the funding record. + #[tokio::test] + async fn failed_funding_classification_is_retried_not_dropped() { + use lightning::chain::chaininterface::BroadcasterInterface; + + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.broadcaster.set_wallet(Arc::downgrade(&wallet)); + + // Run the production broadcast-queue loop. The broadcast itself fails fast against the + // fixture's unroutable Esplora server, which is irrelevant here: the record is written + // during classification, before the broadcast attempt. + let (stop_sender, stop_receiver) = tokio::sync::watch::channel(()); + let chain_source = Arc::clone(&wallet.chain_source); + let loop_task = tokio::spawn(async move { + chain_source.continuously_process_broadcast_queue(stop_receiver).await + }); + + // A funding transaction paying the wallet passes the wallet-activity guard, so its + // classification reaches the payment-store write. + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + + // Queue the broadcast while payment persistence is failing. + fail_store.fail_writes.store(true, Ordering::Release); + wallet.broadcaster.broadcast_transactions(&[( + &tx, + LdkTransactionType::Funding { + channels: vec![(counterparty_node_id, ChannelId([7u8; 32]))], + }, + )]); + + // Wait until the loop has actually failed a classification write; re-enabling writes + // before the first attempt would let the first attempt succeed and the test pass + // without any retry happening. A failed classification must not leave a partial + // record behind. + let mut failed_writes = 0; + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + failed_writes = fail_store.failed_writes.load(Ordering::Acquire); + if failed_writes > 0 { + break; + } + } + assert!(failed_writes > 0, "classification never attempted a payment-store write"); + assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); + + // Once writes recover, the package must still be alive to classify. + fail_store.fail_writes.store(false, Ordering::Release); + let mut recorded = Vec::new(); + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + recorded = wallet.payment_store.list_page(None).await.unwrap().objects; + if !recorded.is_empty() { + break; + } + } + assert!( + !recorded.is_empty(), + "the failed classification was never retried; the package was dropped" + ); + assert_eq!(recorded.len(), 1); + assert!(matches!( + recorded[0].kind, + PaymentKind::Onchain { tx_type: Some(TransactionType::Funding { .. }), .. } + )); + + stop_sender.send(()).unwrap(); + loop_task.await.unwrap(); + } + + /// A package awaiting a classification retry must die when the node stops. When the retry + /// was a detached task, it outlived the broadcast loop: its re-send into the still-open + /// queue succeeded after `stop()`, so a later `start()` would classify and broadcast the + /// stale package. + #[tokio::test] + async fn failed_classification_retry_dies_at_stop() { + use lightning::chain::chaininterface::BroadcasterInterface; + + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.broadcaster.set_wallet(Arc::downgrade(&wallet)); + + let (stop_sender, stop_receiver) = tokio::sync::watch::channel(()); + let chain_source = Arc::clone(&wallet.chain_source); + let loop_task = tokio::spawn(async move { + chain_source.continuously_process_broadcast_queue(stop_receiver).await + }); + + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + }; + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + + // Queue the broadcast while payment persistence is failing and wait for the loop to + // fail a classification attempt, leaving a retry pending. + fail_store.fail_writes.store(true, Ordering::Release); + wallet.broadcaster.broadcast_transactions(&[( + &tx, + LdkTransactionType::Funding { + channels: vec![(counterparty_node_id, ChannelId([7u8; 32]))], + }, + )]); + let mut failed_writes = 0; + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + failed_writes = fail_store.failed_writes.load(Ordering::Acquire); + if failed_writes > 0 { + break; + } + } + assert!(failed_writes > 0, "classification never attempted a payment-store write"); + + // Stop the node with the retry still pending, then bring the loop back up with + // working persistence, as a stop()/start() cycle would. + stop_sender.send(()).unwrap(); + loop_task.await.unwrap(); + fail_store.fail_writes.store(false, Ordering::Release); + + let (stop_sender, stop_receiver) = tokio::sync::watch::channel(()); + let chain_source = Arc::clone(&wallet.chain_source); + let loop_task = tokio::spawn(async move { + chain_source.continuously_process_broadcast_queue(stop_receiver).await + }); + + // Watch well past the retry delay: the package from before the stop must not be + // classified or broadcast by the restarted loop. + for _ in 0..40 { + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + wallet.payment_store.list_page(None).await.unwrap().objects.is_empty(), + "a package from before stop() resurfaced after restart" + ); + } + + stop_sender.send(()).unwrap(); + loop_task.await.unwrap(); + } + + /// A queued classification can retry after a newer candidate of the same funding already + /// classified: the retry carries the candidate history as of its own broadcast, which no + /// longer includes the newer candidate. Applying it would rotate the record's txid backwards + /// and shrink the stored candidate history, after which the newer transaction can no longer + /// be mapped back to the record and wallet sync would file it as a foreign duplicate. + #[tokio::test] + async fn stale_classification_retry_keeps_the_newer_candidate() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let txid_a = Txid::from_byte_array([1u8; 32]); + let txid_b = Txid::from_byte_array([2u8; 32]); + // The record's id is anchored to the first negotiated candidate, so the stale retry + // resolves to the same record. + let payment_id = PaymentId(txid_a.to_byte_array()); + let candidate_a = FundingTxCandidate { + txid: txid_a, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }; + let candidate_b = FundingTxCandidate { + txid: txid_b, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(999), + }; + + // The bump candidate B classifies first, carrying the full history [A, B]. + let fresh = interactive_funding_details(payment_id, txid_b, Some(1_000_000), Some(999)); + wallet + .persist_funding_payment(fresh, vec![candidate_a.clone(), candidate_b.clone()]) + .await + .unwrap(); + + // The queued classification of A retries, carrying the history as of A's broadcast. + let stale = interactive_funding_details(payment_id, txid_a, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(stale, vec![candidate_a.clone()]).await.unwrap(); + + let record = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + match &record.kind { + PaymentKind::Onchain { txid, .. } => { + assert_eq!(*txid, txid_b, "the stale retry must not rotate the record back"); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(record.fee_paid_msat, Some(999)); + + let pending = wallet.pending_payment_store.get(&payment_id).await.unwrap().unwrap(); + let PendingPaymentDetails::Tracked { candidates, .. } = &pending else { + panic!("unexpected variant {:?}", pending); + }; + assert_eq!( + *candidates, + vec![candidate_a, candidate_b], + "the stale retry must not shrink the candidate history" + ); + + // The consequence the history protects against: B must stay mapped to the record, or + // wallet sync would file it as a foreign duplicate. + assert_eq!(wallet.find_payment_by_txid(txid_b).await.unwrap(), Some(payment_id)); + } + + /// A missing pending entry is normally recreated from the incoming classification — but not + /// from a stale retry, whose truncated candidate history would otherwise slip past the merge + /// path's refusal. Recreation is left to a fresh classification instead. + #[tokio::test] + async fn stale_classification_retry_does_not_recreate_the_pending_entry() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let txid_a = Txid::from_byte_array([1u8; 32]); + let txid_b = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId(txid_a.to_byte_array()); + let candidate_a = FundingTxCandidate { + txid: txid_a, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }; + let candidate_b = FundingTxCandidate { + txid: txid_b, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(999), + }; + + // The newer round B classified, but its write pair was torn by the same store failure + // that queued this retry: the record exists, the pending entry does not. + let recorded = interactive_funding_details(payment_id, txid_b, Some(1_000_000), Some(999)); + wallet.payment_store.insert(recorded).await.unwrap(); + + // The queued classification of A retries with its pre-B history. + let stale = interactive_funding_details(payment_id, txid_a, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(stale, vec![candidate_a.clone()]).await.unwrap(); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none(), + "a stale retry must not recreate the pending entry from its truncated history" + ); + + // B's own retry recreates the entry with the full history. + let fresh = interactive_funding_details(payment_id, txid_b, Some(1_000_000), Some(999)); + wallet + .persist_funding_payment(fresh, vec![candidate_a.clone(), candidate_b.clone()]) + .await + .unwrap(); + let pending = wallet.pending_payment_store.get(&payment_id).await.unwrap().unwrap(); + let PendingPaymentDetails::Tracked { candidates, .. } = &pending else { + panic!("unexpected variant {:?}", pending); + }; + assert_eq!(*candidates, vec![candidate_a, candidate_b]); + } + + /// Wallet sync can record a genuine replacement round before classification records it as a + /// candidate — e.g. the counterparty broadcast a round whose classification failed here and + /// is still being retried. 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's classification lands, + /// it must merge the duplicate — adopt its confirmation and remove it — so a single record + /// tracks the splice. + #[tokio::test] + async fn classification_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 classified normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate: a + // duplicate untyped record under the txid-derived id, plus its pending entry. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_store.insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .pending_payment_store + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(duplicate_id)); + + // Round 2's classification lands (e.g. retried after a persistence failure). + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.persist_funding_payment(details, rounds).await.unwrap(); + + // One record: the funding record carries the duplicate's confirmation and the confirmed + // candidate's figures; the duplicate and its pending entry are gone, so the round's txid + // resolves to the funding record again. + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + assert_eq!(payment.amount_msat, Some(1_000_000)); + assert_eq!(payment.fee_paid_msat, Some(400)); + match &payment.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } => assert_eq!(*txid, txid2), + kind => panic!("unexpected kind {:?}", kind), + } + assert!(wallet.pending_payment_store.get(&duplicate_id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); + } + + /// A duplicate for an *unconfirmed* round carries no state the funding record needs: the + /// merge removes it without touching the record's active txid or figures, and the round's + /// txid maps back to the funding record through its candidate history. + #[tokio::test] + async fn classification_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 classification ran. + let duplicate_id = PaymentId(txid1.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: txid1, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_store.insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .pending_payment_store + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + + // Round 2 is the active broadcast; its classification lists both rounds. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.persist_funding_payment(details, rounds).await.unwrap(); + + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + // The record keeps tracking the actively-broadcast round; a duplicate that never confirmed + // has nothing to adopt. + match &payment.kind { + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } => { + assert_eq!(*txid, txid2) + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(payment.fee_paid_msat, Some(400)); + assert_eq!(wallet.find_payment_by_txid(txid1).await.unwrap(), Some(funding_id)); + } + + /// Removing the duplicate is two store writes, and the failure between them must leave a + /// state the classification retry can finish cleaning up. If the payment record went first, + /// a failure on the pending-entry removal would orphan that entry where the retry 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 classification pass coming to fix it. + #[tokio::test] + async fn classification_retry_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 classified normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_store.insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .pending_payment_store + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + + // Round 2's classification lands, 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), + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + }, + ]; + 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 broadcast loop re-runs a failed classification; the retry must finish the cleanup. + wallet.persist_funding_payment(details, rounds).await.unwrap(); + + let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, funding_id); + assert!(wallet.pending_payment_store.get(&duplicate_id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); } /// Barrier test, classification-first ordering: wallet sync's confirmation handling must diff --git a/tests/common/logging.rs b/tests/common/logging.rs index 3b231b3cd..58b1410a6 100644 --- a/tests/common/logging.rs +++ b/tests/common/logging.rs @@ -192,17 +192,21 @@ impl CollectingLogWriter { self.logs.lock().unwrap().iter().filter(|message| message.contains(text)).count() } - /// 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; } @@ -217,3 +221,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 5f4a95b7e..ddaa833d1 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -19,7 +19,8 @@ use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; use bitcoin::{Address, Amount, ScriptBuf, 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, distribute_funds_unconfirmed, do_channel_full_cycle, @@ -43,7 +44,9 @@ use ldk_node::payment::{ ConfirmationStatus, PayerProofOptions, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType}; +use ldk_node::{ + BuildError, Builder, Event, Node, NodeError, ReserveType, SpliceFailureReason, SpliceParameters, +}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -53,6 +56,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 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 @@ -81,6 +117,16 @@ async fn wait_for_classified_funding_payment(node: &Node, funding_txid: Txid) { }); } +/// Resolves the payment record whose current transaction is `funding_txid`. 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: &Node, funding_txid: Txid) -> PaymentDetails { + node.list_all_payments() + .into_iter() + .find(|p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == funding_txid)) + .expect("funding payment exists") +} + #[derive(Clone)] struct ContendedStore { inner: Arc, @@ -2093,9 +2139,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!( @@ -2146,9 +2190,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 @@ -2443,15 +2485,20 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // 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; + // 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())]) @@ -2484,12 +2531,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, @@ -2563,8 +2609,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 { .. }, .. } => { @@ -2624,8 +2670,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 { @@ -2687,8 +2732,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, @@ -2770,6 +2815,437 @@ async fn splice_in_rbf_joins_counterparty_splice() { node_b.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) + }; + + // On restart, reconciliation finds nothing behind the intent in LDK, releases whatever the + // wallet still reserved for it, and drops the record 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); + + // 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 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. Depending on timing, + // node_a may or may not have classified its own broadcast into a payment record 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();