diff --git a/src/builder.rs b/src/builder.rs index 54e79f4b3a..532e821c74 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::{ @@ -2550,6 +2551,13 @@ fn build_with_store_internal( }) }); + let splice_tracker = Arc::new(SpliceTracker::new( + Arc::clone(&channel_manager), + Arc::clone(&wallet), + Arc::clone(&pending_payment_store), + Arc::clone(&logger), + )); + #[cfg(cycle_tests)] let mut _leak_checker = crate::LeakChecker(Vec::new()); #[cfg(cycle_tests)] @@ -2601,6 +2609,7 @@ fn build_with_store_internal( payment_store, forwarding_store, forwarded_payment_aggregation_retention_secs, + splice_tracker, lnurl_auth, is_running, node_metrics, diff --git a/src/channel/mod.rs b/src/channel/mod.rs new file mode 100644 index 0000000000..1fef2fc734 --- /dev/null +++ b/src/channel/mod.rs @@ -0,0 +1,1669 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Persistence of in-flight user-initiated splices, so a splice LDK has not durably learned of +//! yet can be recognized — and whatever it reserved recovered — after a restart. + +use std::fmt; +use std::sync::Arc; + +use bitcoin::absolute::LockTime; +use bitcoin::secp256k1::PublicKey; +use bitcoin::transaction::Version; +use bitcoin::{OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid}; +use lightning::chain::chaininterface::FundingCandidate; +use lightning::chain::transaction::OutPoint as LdkOutPoint; +use lightning::ln::channel_state::{ChannelDetails, SpliceCandidateDetails, SpliceCandidateStatus}; +use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::FundingContribution; +use lightning::ln::types::ChannelId; + +use crate::data_store::{DataStoreUpdateResult, StorableObject, UpdatableObject}; +use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; +use crate::payment::pending_payment_store::{ + PendingPaymentDetails, PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind, +}; +use crate::payment::{PaymentKind, TransactionType}; +use crate::types::{ChannelManager, PendingPaymentStore}; +use crate::wallet::{funding_candidates, random_payment_id, SignedFundingRecord, Wallet}; +use crate::Error; + +/// Whether two contributions describe the same splice attempt. LDK may adjust a contribution +/// during negotiation — the quiescence tie-breaker rebuilds the acceptor's copy at a fresh +/// feerate, touching only its fee fields and change value — so fees and feerates do not identify +/// an attempt. Its inputs and outputs do: they are what the user asked to move. Contributions +/// carrying neither (channel-balance-only attempts) fall back to full equality. +pub(crate) fn is_same_splice(a: &FundingContribution, b: &FundingContribution) -> bool { + if a.inputs().is_empty() + && a.outputs().is_empty() + && b.inputs().is_empty() + && b.outputs().is_empty() + { + return a == b; + } + a.inputs().iter().map(|i| i.outpoint()).eq(b.inputs().iter().map(|i| i.outpoint())) + && a.outputs() == b.outputs() +} + +/// Tracks each user-initiated splice through a persisted [`SpliceIntent`] for as long as LDK is +/// not guaranteed to remember the splice itself: LDK only persists a splice once its negotiation +/// reaches `AwaitingSignatures`, and it abandons an in-progress negotiation whenever the peer +/// disconnects — which includes stopping the node. +/// +/// The intent is written before the contribution is handed to LDK, undone when LDK rejects the +/// hand-off synchronously, and cleared once the splice locks, its failure is surfaced, or its +/// channel closes. The record exists for recovery, not retry: a splice still recorded at the next +/// startup identifies one that was in flight when the node stopped, so [`Self::reconcile`] can +/// release whatever it still reserves that would otherwise never be released, and events about +/// the splice can be described in terms of the original request. Each splice has a record of its +/// own — a channel may carry several, a pending splice and the splices queued behind it — so that +/// each is recognized and described whatever became of the others; only a fee bump joins the +/// record of the round it replaces. +pub(crate) struct SpliceTracker { + channel_manager: Arc, + wallet: Arc, + pending_payment_store: Arc, + /// Serializes everything that reads or settles a channel's intent records against + /// [`Self::submit`]'s read-funding, persist and hand-off sequence: the settling of intents by + /// [`Self::on_negotiation_failed`], [`Self::on_channel_ready`] and + /// [`Self::on_channel_closed`], the funding record [`Self::on_funding_ready_for_signing`] + /// files under an intent's id, and the startup pass of [`Self::reconcile`]. Without it, the + /// failure event of a synchronously rejected + /// hand-off could settle the just-written intent while `submit` is still deciding whether to + /// keep it, and a lock event handled between `submit`'s funding read and its persist could + /// leave the new intent anchored at a funding the channel has moved past, which would never be + /// settled. Every public entry point takes it; the `_locked` variants assume it is held and + /// must not take it again (tokio's mutex is not reentrant). It nests outward of the wallet's + /// locks and the stores', which are taken while it is held and never hold it. An event + /// handler waiting on it waits for a `submit` to finish its bounded sequence, nothing more. + submit_lock: tokio::sync::Mutex<()>, + logger: Arc, +} + +impl SpliceTracker { + pub(crate) fn new( + channel_manager: Arc, wallet: Arc, + pending_payment_store: Arc, logger: Arc, + ) -> Self { + Self { + channel_manager, + wallet, + pending_payment_store, + submit_lock: tokio::sync::Mutex::new(()), + logger, + } + } + + /// Reconciles the persisted splice intents against live channel state, releasing what the + /// wallet still reserves for a splice that did not survive the restart — reservations that + /// would otherwise never be released. LDK only persists a splice once its negotiation reaches + /// `AwaitingSignatures`, so a splice lost earlier leaves no trace in LDK's channel state — the + /// intent record is what recognizes the loss. A round LDK did write is another matter: LDK + /// either still tracks it, or reports its failure at startup and returns what it reserved and + /// no other round of the channel uses through `DiscardFunding`; a round of a channel that + /// closed meanwhile is watched by the channel's monitor until the close matures. Such rounds + /// are left to those events. Run once at startup, before background chain syncing and event + /// processing start, so the stale reservations are gone before anything can act on them. Holds + /// the submit lock throughout, as the event handlers do. + /// + /// Recovery fabricates no failure event for a splice lost this way: the initiating call + /// already returned and the channel simply shows no pending splice anymore. LDK itself may + /// report the loss — a contribution it was still queueing or negotiating when it was last + /// persisted is failed as it is written, and the failure replayed at startup. That replay + /// runs after this reconciliation, so the report carries the splice's parameters only if + /// `decide_reconcile` kept the intent: a splice queued behind a pending one of ours, or a fee + /// bump of one, is reported with its parameters; a channel's only splice, whose intent + /// settled here, without them. + pub(crate) async fn reconcile(&self) { + let guard = self.submit_lock.lock().await; + let records = self.pending_payment_store.list_filter(|p| p.splice_intent().is_some()).await; + for record in records { + let payment_id = record.id(); + let Some(intent) = record.splice_intent().cloned() else { + continue; + }; + + let channel = self.channel(intent.counterparty_node_id, intent.channel_id); + let Some(channel) = channel else { + // The channel is gone; there is nothing to splice anymore. What the wallet reserves + // for the intent is released only while there is no recorded round: a round the + // closed channel's monitor watches is either spent by the close or returned through + // the `DiscardFunding` event the monitor queues once the close matures, and a + // recorded round the monitor never watched — the counterparty's `commitment_signed` + // never arrived before the node stopped — by the `DiscardFunding` LDK reports for + // it at the force-close, as at `ChannelClosed`. A bare intent has no such round — + // LDK never wrote the splice — so its reservations are released here or not at all. + log_info!( + self.logger, + "Dropping the recorded splice of closed channel {} with counterparty {}", + intent.channel_id, + intent.counterparty_node_id, + ); + if record.candidates().is_empty() { + self.release_contribution(intent.channel_id, &intent.contribution, &[], None) + .await; + } + // TODO(#1037): once inputs are locked at coin selection, the parts of the + // contribution no recorded round uses stay locked with no record to release them + // from after the intent is cleared here: release them before clearing. And + // `release_contribution` swallows a failed release, which then leaves locks + // without a record either: keep the intent when the release fails. One case stays: + // a hand-off LDK never wrote — the node stopped before the manager's next write — + // whose channel is force-closed as stale at this start and whose round the monitor + // never watched. LDK knows nothing of that round and reports no event for it; + // release its contribution here, which takes the monitor's watched transactions to + // tell such a round from a watched one, as `closed_channel_tracked_rounds` does at + // `ChannelClosed`. + self.clear_persisted_intent(payment_id, |i| *i == intent).await; + continue; + }; + + if channel.funding_txo != Some(intent.pre_splice_funding_txo) { + // The funding moved on while the node was down: the recorded splice, a + // replacement, or a counterparty splice locked — the same situation a live lock + // event resolves, so resolve it the same way. + if let Some(funding_txo) = channel.funding_txo { + self.settle_superseded_intents_locked( + &guard, + intent.counterparty_node_id, + intent.channel_id, + funding_txo.into_bitcoin_outpoint(), + Some(&channel), + ) + .await; + } + continue; + } + + let candidates = channel + .splice_details + .as_ref() + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]); + match decide_reconcile(candidates) { + ReconcileDecision::Keep => { + // A kept record may still reserve more than LDK's surviving rounds use — + // extras a fee bump lost with the restart had reserved. Release the + // difference. + let extras = unclaimed_inputs(&intent.contribution, candidates); + if let Err(e) = self.wallet.unlock_outpoints(&extras).await { + log_error!( + self.logger, + "Failed to release unused splice inputs on channel {}: {}", + intent.channel_id, + e, + ); + } + }, + ReconcileDecision::Lost => { + log_info!( + self.logger, + "Dropping a splice on channel {} with counterparty {} that did not survive \ + the restart", + intent.channel_id, + intent.counterparty_node_id, + ); + self.release_contribution( + intent.channel_id, + &intent.contribution, + candidates, + None, + ) + .await; + // TODO(#1037): `release_contribution` swallows a failed release. Once inputs + // are locked at coin selection, a failure here leaves locks without a record + // after the intent is cleared: keep the intent when the release fails. + self.clear_persisted_intent(payment_id, |i| *i == intent).await; + }, + } + } + } + + /// Persists a user-initiated splice as an intent and hands its contribution to + /// [`ChannelManager::funding_contributed`]. The intent — and any wallet state staged on the + /// splice's behalf — is durable before the hand-off, so every splice in flight has a persisted + /// record. Each splice gets a record of its own; only a fee bump joins the record of the round + /// it replaces ([`Self::persist_intent`]). + /// + /// The intent is anchored at the channel's funding as it stands under the submit lock, not at + /// `pre_splice_funding_txo`, the funding the caller read before building the contribution: a + /// splice locking in between moves the funding, and an intent anchored at the old one would + /// never be settled by the lock that superseded it. A funding that moved refuses a fee bump — + /// the round it was built to replace has locked — and a splice-in, whose inputs the locked + /// round may have spent; a splice-out carries no wallet inputs and proceeds, as LDK + /// re-validates its amount against the live balance ([`check_submission`]). Intents anchored + /// at a funding the channel has moved past are settled first, as their lock event would. + /// + /// On any failure the persisted intent is undone and the error returned for the caller to + /// surface. A failure before the hand-off also releases what the wallet reserves for the + /// contribution, short of what another round claims as well ([`Self::release_contribution`]): + /// a fee bump built by adjusting the fee of the round it replaces — `prior`, the contribution + /// it was built from — reuses that round's inputs and change address, which a refusal must + /// leave to the round that has locked meanwhile. A synchronous rejection leaves the release to + /// LDK, which queues a `DiscardFunding` for the parts of the contribution no pending splice + /// attempt still uses — a negotiated round, one still under negotiation, or a contribution + /// queued behind them — and nothing when there are none; the handler only unmarks the + /// addresses that event lists today, as it ignores the event's inputs. + /// + /// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed + pub(crate) async fn submit( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + pre_splice_funding_txo: LdkOutPoint, contribution: FundingContribution, kind: SpliceKind, + prior: Option, + ) -> Result<(), Error> { + let guard = self.submit_lock.lock().await; + let channel = self.channel(counterparty_node_id, channel_id); + let live_funding_txo = channel.as_ref().and_then(|channel| channel.funding_txo); + let candidates = channel + .as_ref() + .and_then(|channel| channel.splice_details.as_ref()) + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]); + let funding_txo = match check_submission(pre_splice_funding_txo, live_funding_txo, &kind) { + Ok(funding_txo) => funding_txo, + Err(refusal) => { + log_error!( + self.logger, + "Refusing to splice channel {} with counterparty {}: {}", + channel_id, + counterparty_node_id, + refusal, + ); + // TODO(#1037): `release_contribution` swallows a failed release. Once inputs are + // locked at coin selection, a failure here leaves locks without a record: surface + // it, or persist an intent for `reconcile` to release from. + self.release_contribution(channel_id, &contribution, candidates, prior.as_ref()) + .await; + return Err(Error::ChannelSplicingFailed); + }, + }; + // LDK promotes a zero-conf splice as soon as `splice_locked` is exchanged and only queues + // the `ChannelReady` event whose handling settles the locked splice's intent. A splice + // submitted in between builds on the new funding while the channel still carries that + // intent: settle it here as the event would. + self.settle_superseded_intents_locked( + &guard, + counterparty_node_id, + channel_id, + funding_txo.into_bitcoin_outpoint(), + channel.as_ref(), + ) + .await; + let intent = SpliceIntent { + counterparty_node_id, + channel_id, + pre_splice_funding_txo: funding_txo, + contribution: contribution.clone(), + kind, + }; + // A splice whose intent cannot be persisted is not attempted at all, rather than + // attempted without restart coverage. + let (payment_id, restore) = match self.persist_intent(intent, channel.as_ref()).await { + Ok(persisted) => persisted, + Err(e) => { + log_error!( + self.logger, + "Failed to persist the splice intent for channel {} with counterparty {}: {:?}", + channel_id, + counterparty_node_id, + e, + ); + // TODO(#1037): as at the refusal above, a failed release here leaves locks without + // a record. + self.release_contribution(channel_id, &contribution, candidates, prior.as_ref()) + .await; + return Err(e); + }, + }; + // Flush wallet state staged on the splice's behalf (e.g. input locks) only now that the + // intent record is durable: whatever the wallet reserves for a splice must never outlive + // the record through which a later startup would release it. + // TODO(#1037): the wallet does not stage anything yet, and #1037 persists its input locks + // at coin selection, ahead of the intent. Stage them instead, so that this flush is what + // makes them durable. + if let Err(e) = self.wallet.persist_staged().await { + log_error!( + self.logger, + "Failed to persist staged wallet state for splicing channel {} with counterparty \ + {}: {:?}", + channel_id, + counterparty_node_id, + e, + ); + // TODO(#1037): the intent is discarded before the release; a release that fails + // leaves locks without a record. Keep the intent instead when the release fails. + self.discard_persisted_intent(&payment_id, restore).await; + self.release_contribution(channel_id, &contribution, candidates, prior.as_ref()).await; + return Err(e); + } + if let Err(e) = self.channel_manager.funding_contributed( + &channel_id, + &counterparty_node_id, + contribution, + None, + ) { + log_error!( + self.logger, + "LDK rejected the splice contribution for channel {} with counterparty {}: {:?}", + channel_id, + counterparty_node_id, + e, + ); + // LDK returns what the contribution reserved and no pending splice attempt still uses + // through a `DiscardFunding` event, or nothing when every part is still in use, and the + // event's handling frees the addresses the wallet marked for it. + // TODO(#1037): the handler ignores the event's inputs; once inputs are locked at coin + // selection, it must unlock them as well. Unlocking them trusts the event to list only + // inputs no pending splice attempt still spends, which the pinned LDK guarantees. + self.discard_persisted_intent(&payment_id, restore).await; + return Err(Error::ChannelSplicingFailed); + } + Ok(()) + } + + /// Releases what the wallet may still reserve for a contribution that is going nowhere, short + /// of what another contribution claims as well — a splice candidate LDK tracks for the channel, + /// or the round a fee bump was built from (`prior`): the remaining inputs are unlocked and a + /// transaction paying the remaining outputs is canceled, freeing the addresses of its change + /// and splice-out outputs ([`unclaimed_parts`]). A fee bump built by adjusting the fee of the + /// round it replaces reuses that round's inputs and change address; released along with the + /// bump, they would be free for other spends while the round can still confirm. + async fn release_contribution( + &self, channel_id: ChannelId, contribution: &FundingContribution, + candidates: &[SpliceCandidateDetails], prior: Option<&FundingContribution>, + ) { + let claimants = candidates.iter().filter_map(|c| c.contribution.as_ref()).chain(prior); + let (inputs, outputs) = unclaimed_parts(contribution, claimants); + if inputs.is_empty() && outputs.is_empty() { + return; + } + // TODO(#1037): `cancel_tx` unlocks the transaction's inputs itself once inputs are locked + // at coin selection, making this unlock redundant. + if let Err(e) = self.wallet.unlock_outpoints(&inputs).await { + log_error!( + self.logger, + "Failed to release the inputs of a splice contribution on channel {}: {}", + channel_id, + e, + ); + } + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs + .into_iter() + .map(|previous_output| TxIn { previous_output, ..TxIn::default() }) + .collect(), + output: outputs, + }; + if let Err(e) = self.wallet.cancel_tx(tx).await { + log_error!( + self.logger, + "Failed to release the outputs of a splice contribution on channel {}: {}", + channel_id, + e, + ); + } + } + + /// Persists `intent` before its contribution is handed to LDK, outliving a restart that — + /// until the negotiation reaches `AwaitingSignatures` — LDK's own state does not. + /// + /// Each splice gets a record of its own, so a channel may carry several: a splice queued + /// behind a pending one negotiates as a splice of its own once the pending one locks. Only a + /// fee bump joins an existing record, that of the round it replaces ([`place_intent`]), decided + /// from the channel's pending records and the splice rounds LDK tracks for the channel + /// (`channel`, as the caller listed it). A record still anchored at another funding is one + /// [`Self::submit`] just failed to settle or to re-anchor; the new splice is refused rather + /// than recorded beside it. Returns the id and, for restoring on a rejected hand-off, `None` + /// when a fresh record was created or `Some(prior)` when an existing record's intent was + /// replaced (`prior` being `None` for a record that carried no intent). + async fn persist_intent( + &self, intent: SpliceIntent, channel: Option<&ChannelDetails>, + ) -> Result<(PaymentId, Option>), Error> { + let records = self + .pending_payment_store + .list_filter(|p| concerns_channel(p, intent.counterparty_node_id, intent.channel_id)) + .await; + let tracked_rounds: Vec = channel + .map(|channel| { + funding_candidates( + channel.splice_details.as_ref(), + intent.counterparty_node_id, + intent.channel_id, + ) + }) + .unwrap_or_default() + .into_iter() + .map(|candidate| candidate.txid) + .collect(); + match place_intent(&intent, &records, &tracked_rounds) { + IntentPlacement::Refused => { + log_error!( + self.logger, + "Refusing to splice channel {} with counterparty {}: the channel carries a \ + splice intent anchored at another funding", + intent.channel_id, + intent.counterparty_node_id, + ); + Err(Error::ChannelSplicingFailed) + }, + IntentPlacement::Reuse(payment_id) => { + let prior = records + .iter() + .find(|record| record.id() == payment_id) + .and_then(|record| record.splice_intent().cloned()); + write_intent(&self.pending_payment_store, Some((payment_id, prior)), intent).await + }, + IntentPlacement::Fresh => write_intent(&self.pending_payment_store, None, intent).await, + } + } + + /// 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(|result| { + // The record went while the hand-off was failing, as when wallet sync + // graduated its payment: there is no intent left to restore. + if result == DataStoreUpdateResult::NotFound { + log_debug!( + self.logger, + "The record of rejected splice payment {} is gone; nothing to restore", + payment_id, + ); + } + }), + None => self.pending_payment_store.remove(payment_id).await, + }; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to undo the intent of rejected splice payment {}: a stale intent record \ + may be left behind: {}", + payment_id, + e, + ); + } + } + + /// Clears the persisted intent behind a splice that settled — it locked, its failure was + /// surfaced, or its channel closed — but only while `still_applies` holds for the stored + /// intent: a mismatch means a fee bump took over the record in the meantime, and its intent + /// must stay. A record that tracks anything else stays, with the intent cleared, so its + /// payment keeps graduating and the rounds signed under the splice keep their place. A record + /// left with nothing to track is removed, along with any payment record under its id: wallet + /// sync files a funding payment under the id of the intent its round belongs to and indexes it + /// in the same write, so a payment record found under a bare intent is the first half of a + /// write that never completed, and the pending store will never drive it + /// ([`Wallet::drop_unindexed_record_of_settled_intent`]). The record goes first: a bare intent + /// left behind is found and settled again, an orphaned record would not be. + async fn clear_persisted_intent bool>( + &self, payment_id: PaymentId, still_applies: F, + ) { + let still_applies = &still_applies; + let result: Result<(), Error> = async { + let mut remove_bare_record = false; + // The `move` closure would capture a plain `bool` by copy, so hand it a reference; the + // borrow ends with the mutate's future, before the flag is read below. + let removal_flag = &mut remove_bare_record; + self.pending_payment_store + .mutate(&payment_id, move |existing| { + let record = existing?; + match record.splice_intent() { + Some(intent) if still_applies(intent) => {}, + _ => return None, + } + let replacement = record_with_intent_cleared(record); + // A bare intent record cannot be cleared in place; it is removed below. + *removal_flag = replacement.is_none(); + replacement + }) + .await?; + if remove_bare_record { + self.wallet.drop_unindexed_record_of_settled_intent(payment_id).await?; + self.pending_payment_store + .remove_if(&payment_id, |record| { + record.details().is_none() + && record.candidates().is_empty() + && record.locked_rounds().is_empty() + && record.splice_intent().is_some_and(still_applies) + }) + .await?; + } + Ok(()) + } + .await; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to clear the persisted intent of splice payment {}: a stale intent record \ + may be left behind: {}", + payment_id, + e, + ); + } + } + + /// Begins settling the recorded splice a failure event concerns, snapshotting the intent + /// `contribution` identifies among the channel's ([`record_of_failed_splice`]) — if any; a + /// failure of some other attempt (e.g. one superseded by a fee bump, whose failure LDK + /// reports separately) does not identify a record, so there is none to settle. The returned + /// [`FailureSettlement`] holds the submit lock until it is settled or dropped, so no new + /// splice can take the record in between: without it, a failure event could settle the intent + /// of an identical splice submitted while the event was being reported, or race `submit`'s + /// undo of a synchronously rejected hand-off. + /// + /// Settle only once the user-facing event is durably queued, and drop the settlement when + /// queueing fails: LDK then replays the failure event, and a cleared intent must mean the + /// failure was reported. + pub(crate) async fn on_negotiation_failed( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + contribution: Option<&FundingContribution>, + ) -> FailureSettlement<'_> { + let guard = self.submit_lock.lock().await; + let records = self.intent_records_for_channel(counterparty_node_id, channel_id).await; + let matched = record_of_failed_splice(&records, contribution); + FailureSettlement { tracker: self, _guard: guard, matched } + } + + /// Settles the persisted intents made obsolete by the channel's funding having moved on to + /// `funding_txo`, the funding a `ChannelReady` event reports as locked + /// ([`Self::settle_superseded_intents_locked`]). Takes the submit lock, so the settlement + /// cannot interleave with a splice being submitted. + pub(crate) async fn on_channel_ready( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + funding_txo: Option, + ) { + let Some(funding_txo) = funding_txo else { + return; + }; + let guard = self.submit_lock.lock().await; + let channel = self.channel(counterparty_node_id, channel_id); + self.settle_superseded_intents_locked( + &guard, + counterparty_node_id, + channel_id, + funding_txo, + channel.as_ref(), + ) + .await; + } + + /// Settles any persisted intent made obsolete by the channel's funding having moved on to + /// `funding_txo`: the funding a `ChannelReady` event reports as locked, the one a new splice + /// builds on ([`Self::submit`]), or the one [`Self::reconcile`] finds the channel at after a + /// funding moved while the node was down — the same situation, minus the event. Each of the + /// channel's intents is decided on its own + /// ([`decide_on_lock`]), against the splice candidates LDK tracks for the channel (`channel`, + /// as the caller listed it): one whose pre-splice outpoint is that funding was created after + /// the lock and stays; one LDK still tracks as a queued splice candidate is re-anchored to the + /// funding it now builds on rather than settled; any other is settled, and what the wallet + /// reserves for it is either spent by the locked round or returned by LDK through + /// `DiscardFunding`. The caller holds the submit lock. + async fn settle_superseded_intents_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, counterparty_node_id: PublicKey, + channel_id: ChannelId, funding_txo: OutPoint, channel: Option<&ChannelDetails>, + ) { + let records = self.intent_records_for_channel(counterparty_node_id, channel_id).await; + let candidates = channel + .and_then(|channel| channel.splice_details.as_ref()) + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]); + for record in records { + let payment_id = record.id(); + let Some(intent) = record.splice_intent().cloned() else { + continue; + }; + match decide_on_lock(&intent, funding_txo, candidates) { + LockDecision::Keep => {}, + LockDecision::Refresh => { + if let Some(new_funding_txo) = channel.and_then(|channel| channel.funding_txo) { + self.refresh_intent_funding(payment_id, &intent, new_funding_txo).await; + } + }, + LockDecision::Settle => { + // What the wallet reserves for the intent is not released here. The inputs the + // locked round spent are gone with it, and whatever a superseded round reserved + // beyond them, LDK returns through the `DiscardFunding` events it queues at the + // promotion. A guard on the wallet's transaction graph could not tell the two + // apart: today the graph learns an interactive funding from sync alone. Once + // inputs are locked at coin selection (#1037), releasing them here would free + // the promoted round's inputs for a conflicting spend: #1037 prepares and + // unlocks only `Funding`-typed broadcasts, and a splice round is broadcast as + // `InteractiveFunding`. + self.clear_persisted_intent(payment_id, |i| *i == intent).await; + }, + } + } + } + + /// Re-anchors a still-live intent to the funding outpoint it now builds on, but only while + /// the record still carries the intent this decision was made for. + async fn refresh_intent_funding( + &self, payment_id: PaymentId, intent: &SpliceIntent, new_funding_txo: LdkOutPoint, + ) { + let refreshed = SpliceIntent { pre_splice_funding_txo: new_funding_txo, ..intent.clone() }; + let result = self + .pending_payment_store + .mutate(&payment_id, |existing| { + let mut record = existing?.clone(); + if record.splice_intent() != Some(intent) { + return None; + } + let update = PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(Some(refreshed)), + }; + record.update(update).then_some(record) + }) + .await; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to re-anchor the intent of queued splice payment {}: {}", + payment_id, + e, + ); + } + } + + /// Records a splice round this node has just signed but not yet handed back to LDK — what it + /// is, this node's share of it and the funding payment it belongs to — through + /// [`Wallet::record_signed_funding`], so the record precedes any broadcast: the counterparty + /// cannot broadcast before receiving our `tx_signatures`, which only + /// [`ChannelManager::funding_transaction_signed`] releases. The payment record itself is left + /// to wallet sync. Holding the submit lock keeps the channel's intent records — one of which + /// the round's entry adopts — from changing mid-write: a concurrent [`Self::submit`] adding or + /// replacing an intent, or a lock or failure event settling one. Reports whether the round may + /// be signed, as [`Wallet::record_signed_funding`] decides it. + /// + /// [`ChannelManager::funding_transaction_signed`]: lightning::ln::channelmanager::ChannelManager::funding_transaction_signed + pub(crate) async fn on_funding_ready_for_signing( + &self, tx: &Transaction, candidates: &[FundingCandidate], + ) -> Result { + let _guard = self.submit_lock.lock().await; + self.wallet.record_signed_funding(tx, candidates).await + } + + /// Settles every persisted intent of a closed channel, as there is nothing left to splice. + /// Takes the submit lock, so the settlement cannot interleave with a splice being submitted. + /// What the wallet reserves for the intents is not released here: a round the channel's monitor + /// watches may still confirm, and what LDK reserved for the others it returns through + /// `DiscardFunding` once the close matures. A signed round the monitor never watched — the + /// counterparty's `commitment_signed` never arrived — is released by the `DiscardFunding` LDK + /// reports for it at the force-close, which arrives after `ChannelClosed` and lists what is + /// not used by any other round of the channel. + pub(crate) async fn on_channel_closed( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) { + let _guard = self.submit_lock.lock().await; + for record in self.intent_records_for_channel(counterparty_node_id, channel_id).await { + self.clear_persisted_intent(record.id(), |_| true).await; + } + } + + /// Returns the pending records carrying a splice intent for the given channel: one per + /// splice of the channel still in flight, a fee bump sharing the record of the round it + /// replaces. + async fn intent_records_for_channel( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Vec { + self.pending_payment_store + .list_filter(|p| { + p.splice_intent().is_some_and(|i| { + i.channel_id == channel_id && i.counterparty_node_id == counterparty_node_id + }) + }) + .await + } + + /// The channel as LDK lists it, if it still does. + fn channel( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Option { + self.channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .into_iter() + .find(|channel| channel.channel_id == channel_id) + } +} + +/// The in-progress settlement of a splice failure, returned by +/// [`SpliceTracker::on_negotiation_failed`]. It snapshots the recorded intent the failure +/// identifies and holds the submit lock, so the record cannot change between the snapshot and +/// [`Self::settle`]. +pub(crate) struct FailureSettlement<'a> { + tracker: &'a SpliceTracker, + _guard: tokio::sync::MutexGuard<'a, ()>, + /// The record and intent the failure identifies, if any. + matched: Option<(PaymentId, SpliceIntent)>, +} + +impl FailureSettlement<'_> { + /// 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; + } + } +} + +/// Why a submission is refused once the channel's funding turns out to differ from the one the +/// caller built the contribution against, decided by [`check_submission`]. +#[derive(Debug, PartialEq, Eq)] +enum SubmissionRefusal { + /// LDK no longer lists the channel, or lists it without a funding. + ChannelGone, + /// The round a fee bump was built to replace has locked; there is nothing left to bump. + BumpedRoundLocked, + /// A splice locked while the splice-in's inputs were being selected, and may have spent + /// them. + InputsMayBeSpent, +} + +impl fmt::Display for SubmissionRefusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ChannelGone => write!(f, "the channel is gone or has no funding"), + Self::BumpedRoundLocked => { + write!(f, "the funding moved since the bump was built; its round has locked") + }, + Self::InputsMayBeSpent => write!( + f, + "the funding moved since the splice was built; the locked round may have spent \ + its inputs" + ), + } + } +} + +/// Whether a submission built against `requested_funding` may proceed now that the channel's +/// funding is `live_funding`, and at which funding to anchor its intent. A funding that has not +/// moved proceeds. One that has means a splice locked while the contribution was being built, +/// and only a splice-out proceeds, anchored at the live funding: it carries no wallet inputs, +/// and LDK re-validates its amount against the live balance. A fee bump was built to replace +/// that very round — a bump template is only offered for an unconfirmed, unlocked pending round +/// — and is refused rather than handed to LDK as a fresh splice reusing the locked round's +/// inputs. A splice-in is refused because its inputs were selected before the lock and may be +/// among those the locked round spent, which the wallet only learns from a sync: handed to LDK, +/// such a contribution would negotiate a splice whose transaction can never confirm, and neither +/// LDK nor this node would ever fail it. Refusing costs the caller one retry of a rare race. +// TODO(#1037): once inputs are locked from coin selection until the round is broadcast, and the +// round is applied to the wallet's transaction graph as it is broadcast, a wallet-selected input +// cannot be one a promoted round spent, and `InputsMayBeSpent` can go with its refusal. +// `BumpedRoundLocked` stays: a bump reuses the locked round's inputs. This needs the unlock and +// the graph insertion to happen together, as #1037's broadcast preparation does. +fn check_submission( + requested_funding: LdkOutPoint, live_funding: Option, kind: &SpliceKind, +) -> Result { + let live_funding = live_funding.ok_or(SubmissionRefusal::ChannelGone)?; + if live_funding == requested_funding { + return Ok(live_funding); + } + match kind { + SpliceKind::Rbf {} => Err(SubmissionRefusal::BumpedRoundLocked), + SpliceKind::In { .. } => Err(SubmissionRefusal::InputsMayBeSpent), + SpliceKind::Out { .. } => Ok(live_funding), + } +} + +/// The parts of `contribution` none of `claimants` uses: the inputs none of them spends, and the +/// outputs — change included — paying a script none of them pays. Outputs are matched by script +/// rather than as a whole, as LDK's `DiscardFunding` matches them: a fee-adjusted bump pays its +/// change to the same address as the round it replaces, at a different amount. +fn unclaimed_parts<'a>( + contribution: &FundingContribution, + claimants: impl IntoIterator, +) -> (Vec, Vec) { + let mut claimed_inputs: Vec = Vec::new(); + let mut claimed_scripts: Vec<&ScriptBuf> = Vec::new(); + for claimant in claimants { + claimed_inputs.extend(claimant.inputs().iter().map(|input| input.outpoint())); + claimed_scripts.extend( + claimant + .outputs() + .iter() + .chain(claimant.change_output()) + .map(|output| &output.script_pubkey), + ); + } + let inputs = contribution + .inputs() + .iter() + .map(|input| input.outpoint()) + .filter(|outpoint| !claimed_inputs.contains(outpoint)) + .collect(); + let outputs = contribution + .outputs() + .iter() + .chain(contribution.change_output()) + .filter(|output| !claimed_scripts.contains(&&output.script_pubkey)) + .cloned() + .collect(); + (inputs, outputs) +} + +/// Whether a pending record concerns the given channel's splices: it carries a splice intent for +/// the channel, or tracks an interactive funding payment of it. +fn concerns_channel( + record: &PendingPaymentDetails, counterparty_node_id: PublicKey, channel_id: ChannelId, +) -> bool { + if let Some(intent) = record.splice_intent() { + return intent.channel_id == channel_id + && intent.counterparty_node_id == counterparty_node_id; + } + match record.details().map(|details| &details.kind) { + Some(PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + }) => channels.iter().any(|channel| { + channel.channel_id == channel_id && channel.counterparty_node_id == counterparty_node_id + }), + _ => false, + } +} + +/// Where the intent of a new submission is recorded, decided by [`place_intent`]. +#[derive(Debug, PartialEq, Eq)] +enum IntentPlacement { + /// The channel carries an intent anchored at another funding, one [`SpliceTracker::submit`] + /// just failed to settle or to re-anchor; the submission is refused. + Refused, + /// The submission joins the given record. + Reuse(PaymentId), + /// The submission gets a record of its own. + Fresh, +} + +/// Writes `intent` into the record `reuse` refers to, given with the intent that record carried, or +/// into a fresh record of its own. Returns the id and what to restore on a rejected hand-off, +/// as [`SpliceTracker::persist_intent`] describes. +/// +/// The record to reuse was listed under the submit lock, but wallet sync removes a record whose +/// payment graduated without taking that lock, so it can be gone by the time of the write. The +/// intent then gets a fresh record: reporting the gone record as written would send the splice +/// out with no record at all, which no restart could release it from. +async fn write_intent( + store: &PendingPaymentStore, reuse: Option<(PaymentId, Option)>, + intent: SpliceIntent, +) -> Result<(PaymentId, Option>), Error> { + if let Some((payment_id, prior)) = reuse { + let result = store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(Some(intent.clone())), + }) + .await?; + if result != DataStoreUpdateResult::NotFound { + return Ok((payment_id, Some(prior))); + } + } + let payment_id = random_payment_id(); + store.insert(PendingPaymentDetails::pending_splice(payment_id, intent)).await?; + Ok((payment_id, None)) +} + +/// Decides where the intent of a new submission is recorded, given the channel's pending records +/// (`records`: those carrying an intent for the channel or tracking a funding payment of it) and +/// the txids of the splice rounds LDK tracks for the channel (`tracked_rounds`, the pending rounds +/// with a transaction; not the funding). +/// +/// Every splice gets a record of its own, so that its failure is described from its own intent +/// and a restart recognizes it whatever became of the channel's other splices. A splice-in or +/// splice-out therefore always starts fresh: while a round of ours is pending and bumpable the +/// entry points refuse a new one, and a contribution LDK takes beside a pending splice — queued +/// behind it, or joining a round the counterparty is negotiating — is a splice of its own. Only +/// a fee bump joins an existing record, that of the round it replaces: the record tracking a +/// round LDK still tracks — intent-less when an earlier bump failed and its settlement cleared +/// the intent — or else the channel's bare intent record, whose round negotiated but the wallet +/// did not record (a splice-out to an external address). A bump joining a bare record shares its +/// fate: the bump's failure removes the record, so a later bump starts fresh. +fn place_intent( + intent: &SpliceIntent, records: &[PendingPaymentDetails], tracked_rounds: &[Txid], +) -> IntentPlacement { + let anchored_elsewhere = records.iter().any(|record| { + record + .splice_intent() + .is_some_and(|i| i.pre_splice_funding_txo != intent.pre_splice_funding_txo) + }); + if anchored_elsewhere { + return IntentPlacement::Refused; + } + match intent.kind { + SpliceKind::Rbf {} => { + let has_tracked_round = |record: &&PendingPaymentDetails| { + record.candidates().iter().any(|candidate| tracked_rounds.contains(&candidate.txid)) + }; + records + .iter() + .find(has_tracked_round) + .or_else(|| records.iter().find(|record| record.splice_intent().is_some())) + .map_or(IntentPlacement::Fresh, |record| IntentPlacement::Reuse(record.id())) + }, + SpliceKind::In { .. } | SpliceKind::Out { .. } => IntentPlacement::Fresh, + } +} + +/// The record, and its intent, of the splice a failure event identifies by `contribution` among +/// the channel's intent records: the one whose intent's contribution is the same attempt +/// ([`is_same_splice`]). A failure that reports no contribution does not identify a record, nor +/// does one whose contribution matches no recorded intent — an attempt superseded by a fee bump, +/// whose failure LDK reports separately. +fn record_of_failed_splice( + records: &[PendingPaymentDetails], contribution: Option<&FundingContribution>, +) -> Option<(PaymentId, SpliceIntent)> { + let contribution = contribution?; + records.iter().find_map(|record| { + let intent = record.splice_intent()?; + is_same_splice(&intent.contribution, contribution).then(|| (record.id(), intent.clone())) + }) +} + +/// What a lock of the funding a channel has moved on to — or a new splice building on it — +/// means for one of the channel's recorded intents, decided by [`decide_on_lock`]. +#[derive(Debug, PartialEq, Eq)] +enum LockDecision { + /// The intent is anchored at that funding: its splice was submitted after the lock. + Keep, + /// LDK still tracks the intent's contribution as a splice candidate — a splice queued behind + /// the one that locked, carried across the lock — so the intent is re-anchored to the new + /// funding. + Refresh, + /// The lock superseded the intent's splice: the splice locked, a replacement or a + /// counterparty splice locked instead, or the queued splice was failed at the lock. The + /// intent is settled. + /// + /// A queued splice fails at the lock when its contribution overlaps the promoted transaction. + /// LDK takes it out of the queue and reports the failure after the `ChannelReady` of the + /// lock, so the intent is settled here first and the failure surfaces without the splice's + /// parameters. The overlap check at queue time — against this node's own contributions to the + /// pending rounds — lets only a contribution containing an input or output the counterparty + /// contributed to the promoted round get this far, which this node's wallet does not produce. + Settle, +} + +/// Decides what the channel's funding having moved on to `funding_txo` means for `intent`, given +/// the splice candidates LDK tracks for the channel. +fn decide_on_lock( + intent: &SpliceIntent, funding_txo: OutPoint, candidates: &[SpliceCandidateDetails], +) -> LockDecision { + if intent.pre_splice_funding_txo.into_bitcoin_outpoint() == funding_txo { + return LockDecision::Keep; + } + let still_tracked = candidates.iter().any(|candidate| { + candidate.contribution.as_ref().is_some_and(|c| is_same_splice(c, &intent.contribution)) + }); + if still_tracked { + LockDecision::Refresh + } else { + LockDecision::Settle + } +} + +/// The replacement for a pending record whose splice intent is being dropped. A record that still +/// tracks something else — a payment, the rounds signed under the splice, or a round LDK promoted +/// — keeps it with just the intent cleared. A record left with nothing to track has nothing to +/// keep and is left for the caller to remove (see [`SpliceTracker::clear_persisted_intent`]). +fn record_with_intent_cleared(existing: &PendingPaymentDetails) -> Option { + let mut record = existing.clone(); + let update = PendingPaymentDetailsUpdate { + id: record.id(), + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + record.update(update); + (!record.is_empty()).then_some(record) +} + +/// What [`SpliceTracker::reconcile`] should do with a persisted intent whose channel and funding +/// are unchanged, decided from the splice rounds LDK reports on the channel. +#[derive(Debug, PartialEq, Eq)] +enum ReconcileDecision { + /// LDK still tracks a splice of ours; leave the intent in place until the splice settles. + Keep, + /// LDK tracks 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, whatever rounds + // survived are the counterparty's: the recorded splice is gone. + if candidates.iter().any(|candidate| candidate.contribution.is_some()) { + ReconcileDecision::Keep + } else { + ReconcileDecision::Lost + } +} + +/// The inputs `contribution` reserved that no candidate's own contribution still claims — extras +/// a splice attempt lost with the restart had reserved. A counterparty-only round carries no +/// contribution and claims nothing. +fn unclaimed_inputs( + contribution: &FundingContribution, candidates: &[SpliceCandidateDetails], +) -> Vec { + let claimants = candidates.iter().filter_map(|candidate| candidate.contribution.as_ref()); + unclaimed_parts(contribution, claimants).0 +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bitcoin::hashes::Hash; + use bitcoin::{Amount, Txid}; + + use super::*; + use crate::data_store::KeepAllEntries; + use crate::io::test_utils::InMemoryStore; + use crate::io::{ + PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + }; + use crate::payment::pending_payment_store::{ + test_funding_contribution, test_funding_contribution_with_feerate, + test_funding_contribution_with_inputs, test_funding_contribution_with_outputs, + test_funding_contribution_with_parts, FundingTxCandidate, + }; + use crate::payment::store::{ConfirmationStatus, PaymentDetails, PaymentKind}; + use crate::payment::{PaymentDirection, PaymentStatus}; + use crate::types::{DynStore, DynStoreWrapper}; + use lightning::ln::channel_state::SpliceCandidateStatus; + + fn pending_payment_store() -> PendingPaymentStore { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + PendingPaymentStore::new( + Vec::new(), + KeepAllEntries, + PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + store, + Arc::new(Logger::new_log_facade()), + ) + } + + 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, + ) + } + + #[tokio::test] + async fn an_intent_placed_into_a_record_joins_it() { + let store = pending_payment_store(); + let id = PaymentId([9u8; 32]); + let prior = test_intent(); + store.insert(PendingPaymentDetails::pending_splice(id, prior.clone())).await.unwrap(); + let bump = intent_with(SpliceKind::Rbf {}, 3, splice_out_contribution(1_000)); + + let written = write_intent(&store, Some((id, Some(prior.clone()))), bump.clone()).await; + assert_eq!(written.unwrap(), (id, Some(Some(prior)))); + let record = store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(record.splice_intent(), Some(&bump)); + } + + /// Wallet sync removes a record whose payment graduated, and takes no part in the submit + /// lock, so the record a fee bump was placed into can be gone by the time its intent is + /// written. The intent then gets a record of its own rather than none at all, which would + /// send the splice out without restart coverage. + #[tokio::test] + async fn an_intent_placed_into_a_record_that_is_gone_gets_a_fresh_one() { + let store = pending_payment_store(); + let gone = PaymentId([9u8; 32]); + let intent = test_intent(); + + let (payment_id, restore) = + write_intent(&store, Some((gone, None)), intent.clone()).await.unwrap(); + + assert_ne!(payment_id, gone); + assert_eq!(restore, None, "a rejected hand-off removes the fresh record"); + let record = store.get(&payment_id).await.unwrap().expect("the intent has a record"); + assert_eq!(record.splice_intent(), Some(&intent)); + assert!(store.get(&gone).await.unwrap().is_none()); + } + + /// A bare intent entry has nothing to keep once its intent is cleared: it is removed rather + /// than promoted, whatever the payment store has under its id — a payment record there is + /// the first half of a funding-record write of wallet sync's that never completed, which the + /// caller removes as well. + #[test] + fn intent_clearing_removes_a_bare_intent_entry() { + let id = PaymentId([9u8; 32]); + let existing = PendingPaymentDetails::pending_splice(id, test_intent()); + assert!(record_with_intent_cleared(&existing).is_none()); + } + + /// A tracked record keeps its payment details; only the intent is cleared. + #[test] + fn intent_clearing_keeps_a_tracked_record() { + let id = PaymentId([9u8; 32]); + let details = payment_details(id, PaymentStatus::Pending); + let existing = PendingPaymentDetails::tracked( + details.clone(), + Vec::new(), + Vec::new(), + Some(test_intent()), + ); + + let replacement = record_with_intent_cleared(&existing); + let replacement = replacement.expect("the entry must survive with its intent cleared"); + assert_eq!(replacement.details(), Some(&details)); + assert!(replacement.splice_intent().is_none()); + } + + #[test] + fn contributions_match_by_inputs_and_outputs() { + use bitcoin::{ScriptBuf, TxOut}; + + let outputs = + vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: ScriptBuf::new() }]; + // Fee fields differ, inputs and outputs agree: the same attempt. LDK may adjust a + // contribution during negotiation — the quiescence tie-breaker rebuilds the acceptor's + // copy at a fresh feerate — and events then carry the adjusted copy, which must still + // identify the recorded splice. + let a = test_funding_contribution_with_outputs(0, 253, &outputs); + let b = test_funding_contribution_with_outputs(0, 500, &outputs); + assert!(is_same_splice(&a, &b)); + + // Different outputs are a different attempt. + let other = vec![TxOut { value: Amount::from_sat(2_000), script_pubkey: ScriptBuf::new() }]; + assert!(!is_same_splice(&a, &test_funding_contribution_with_outputs(0, 253, &other))); + + // Contributions moving nothing (no inputs, no outputs) only match themselves exactly. + assert!(is_same_splice(&test_funding_contribution(), &test_funding_contribution())); + assert!(!is_same_splice( + &test_funding_contribution(), + &test_funding_contribution_with_feerate(500) + )); + } + + fn intent_with( + kind: SpliceKind, funding_byte: u8, contribution: FundingContribution, + ) -> SpliceIntent { + SpliceIntent { + pre_splice_funding_txo: LdkOutPoint { + txid: Txid::from_byte_array([funding_byte; 32]), + index: 0, + }, + contribution, + kind, + ..test_intent() + } + } + + fn splice_out_contribution(value_sat: u64) -> FundingContribution { + use bitcoin::{ScriptBuf, TxOut}; + let outputs = + vec![TxOut { value: Amount::from_sat(value_sat), script_pubkey: ScriptBuf::new() }]; + test_funding_contribution_with_outputs(300, 253, &outputs) + } + + /// A tracked record of the test channel whose funding payment is for `txid` and whose history + /// lists `candidates`, carrying `intent` if any. + fn tracked_record( + id: PaymentId, txid: Txid, candidates: &[Txid], intent: Option, + ) -> PendingPaymentDetails { + use crate::payment::store::Channel; + let base = test_intent(); + let details = PaymentDetails::new( + id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { + channels: vec![Channel { + counterparty_node_id: base.counterparty_node_id, + channel_id: base.channel_id, + }], + }), + }, + Some(1_000_000), + Some(500), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + let candidates = candidates + .iter() + .map(|txid| FundingTxCandidate { + txid: *txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }) + .collect(); + PendingPaymentDetails::tracked(details, Vec::new(), candidates, intent) + } + + fn txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + /// A splice-in or splice-out is a splice of its own, whatever the channel already carries: + /// a pending splice's bare intent, or the tracked record of its rounds. + #[test] + fn a_splice_in_or_out_gets_a_record_of_its_own() { + let pending = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(1_000)); + let records = vec![ + PendingPaymentDetails::pending_splice(PaymentId([1u8; 32]), pending.clone()), + tracked_record(PaymentId([2u8; 32]), txid(0x10), &[txid(0x10)], Some(pending)), + ]; + let tracked_rounds = [txid(0x10)]; + + let splice_in = + intent_with(SpliceKind::In { amount_sats: 5_000 }, 3, test_funding_contribution()); + assert_eq!(place_intent(&splice_in, &records, &tracked_rounds), IntentPlacement::Fresh); + let splice_out = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(2_000)); + assert_eq!(place_intent(&splice_out, &records, &tracked_rounds), IntentPlacement::Fresh); + assert_eq!(place_intent(&splice_out, &[], &[]), IntentPlacement::Fresh); + } + + /// A fee bump joins the record of the round it replaces: the one whose round LDK still + /// tracks, even when that record carries no intent any more and the channel also carries a + /// bare intent. + #[test] + fn a_bump_joins_the_record_of_a_round_ldk_still_tracks() { + let bump = intent_with(SpliceKind::Rbf {}, 3, test_funding_contribution()); + let bare_id = PaymentId([1u8; 32]); + let tracked_id = PaymentId([2u8; 32]); + let records = vec![ + PendingPaymentDetails::pending_splice( + bare_id, + intent_with( + SpliceKind::Out { outputs: Vec::new() }, + 3, + splice_out_contribution(1_000), + ), + ), + tracked_record(tracked_id, txid(0x11), &[txid(0x10), txid(0x11)], None), + ]; + assert_eq!( + place_intent(&bump, &records, &[txid(0x11)]), + IntentPlacement::Reuse(tracked_id) + ); + + // The tracked record of a splice that already locked — its funding is no tracked round — is + // not the bump's; the bare intent of the round LDK negotiated but the wallet did not record + // is. + assert_eq!(place_intent(&bump, &records, &[]), IntentPlacement::Reuse(bare_id)); + + // With neither, the bump starts fresh. + let locked_only = vec![tracked_record(tracked_id, txid(0x11), &[txid(0x11)], None)]; + assert_eq!(place_intent(&bump, &locked_only, &[]), IntentPlacement::Fresh); + } + + /// An intent anchored at another funding is one the lock handling failed to settle or to + /// re-anchor; a new splice is not recorded beside it, whatever its kind and whatever else the + /// channel carries. + #[test] + fn an_intent_anchored_elsewhere_refuses_every_kind() { + let stale = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 4, splice_out_contribution(1_000)); + let current = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(2_000)); + let records = vec![ + PendingPaymentDetails::pending_splice(PaymentId([1u8; 32]), current), + PendingPaymentDetails::pending_splice(PaymentId([2u8; 32]), stale), + ]; + for kind in [ + SpliceKind::In { amount_sats: 5_000 }, + SpliceKind::Out { outputs: Vec::new() }, + SpliceKind::Rbf {}, + ] { + let intent = intent_with(kind, 3, test_funding_contribution()); + assert_eq!(place_intent(&intent, &records, &[]), IntentPlacement::Refused); + } + } + + /// A failure identifies the record whose intent carries the failed contribution — fee fields + /// aside — among the channel's; one reporting no contribution, or a contribution of no + /// recorded intent, does not identify a record. + #[test] + fn a_failure_identifies_the_record_carrying_its_contribution() { + let first = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(1_000)); + let second = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(2_000)); + let (first_id, second_id) = (PaymentId([1u8; 32]), PaymentId([2u8; 32])); + let records = vec![ + PendingPaymentDetails::pending_splice(first_id, first.clone()), + tracked_record(second_id, txid(0x10), &[txid(0x10)], Some(second.clone())), + ]; + + let adjusted = { + use bitcoin::{ScriptBuf, TxOut}; + let outputs = + vec![TxOut { value: Amount::from_sat(2_000), script_pubkey: ScriptBuf::new() }]; + test_funding_contribution_with_outputs(900, 1_000, &outputs) + }; + assert_eq!(record_of_failed_splice(&records, Some(&adjusted)), Some((second_id, second))); + assert_eq!( + record_of_failed_splice(&records, Some(&first.contribution)), + Some((first_id, first)) + ); + assert_eq!(record_of_failed_splice(&records, Some(&splice_out_contribution(3_000))), None); + assert_eq!(record_of_failed_splice(&records, None), None); + } + + /// A lock keeps an intent anchored at the locked funding, re-anchors one LDK still tracks as a + /// candidate, and settles any other. + #[test] + fn a_lock_keeps_refreshes_or_settles_an_intent() { + let intent = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(1_000)); + let same_funding = intent.pre_splice_funding_txo.into_bitcoin_outpoint(); + let new_funding = OutPoint { txid: txid(0x20), vout: 0 }; + let tracked_candidates = [SpliceCandidateDetails { + contribution: Some(splice_out_contribution(1_000)), + status: SpliceCandidateStatus::WaitingOnLock, + }]; + let other = [ + SpliceCandidateDetails { + contribution: Some(splice_out_contribution(2_000)), + status: SpliceCandidateStatus::WaitingOnLock, + }, + SpliceCandidateDetails { + contribution: None, + status: SpliceCandidateStatus::WaitingOnLock, + }, + ]; + + assert_eq!(decide_on_lock(&intent, same_funding, &other), LockDecision::Keep); + assert_eq!( + decide_on_lock(&intent, new_funding, &tracked_candidates), + LockDecision::Refresh + ); + assert_eq!(decide_on_lock(&intent, new_funding, &other), LockDecision::Settle); + assert_eq!(decide_on_lock(&intent, new_funding, &[]), LockDecision::Settle); + } + + /// A submission proceeds at the funding it was built against while that is still the + /// channel's. Once the funding moved, only a splice-out proceeds, anchored at the live + /// funding; a bump and a splice-in are refused, as is any submission for a channel LDK no + /// longer lists with a funding. + #[test] + fn a_submission_is_checked_against_the_live_funding() { + let requested = LdkOutPoint { txid: txid(0x30), index: 0 }; + let moved = LdkOutPoint { txid: txid(0x31), index: 0 }; + let kinds = [ + SpliceKind::In { amount_sats: 5_000 }, + SpliceKind::Out { outputs: Vec::new() }, + SpliceKind::Rbf {}, + ]; + for kind in &kinds { + assert_eq!(check_submission(requested, Some(requested), kind), Ok(requested)); + assert_eq!( + check_submission(requested, None, kind), + Err(SubmissionRefusal::ChannelGone) + ); + } + assert_eq!( + check_submission(requested, Some(moved), &SpliceKind::Rbf {}), + Err(SubmissionRefusal::BumpedRoundLocked) + ); + assert_eq!( + check_submission(requested, Some(moved), &SpliceKind::In { amount_sats: 5_000 }), + Err(SubmissionRefusal::InputsMayBeSpent) + ); + assert_eq!( + check_submission(requested, Some(moved), &SpliceKind::Out { outputs: Vec::new() }), + Ok(moved) + ); + } + + /// The records concerning a channel's splices are those carrying an intent for it and those + /// tracking an interactive funding of it; records of other channels and of other payments are + /// not. + #[test] + fn records_concerning_a_channel() { + let base = test_intent(); + let (cp, channel_id) = (base.counterparty_node_id, base.channel_id); + let id = PaymentId([1u8; 32]); + assert!(concerns_channel( + &PendingPaymentDetails::pending_splice(id, base.clone()), + cp, + channel_id + )); + assert!(concerns_channel( + &tracked_record(id, txid(0x10), &[txid(0x10)], None), + cp, + channel_id + )); + + let other_channel = SpliceIntent { channel_id: ChannelId([8u8; 32]), ..base }; + assert!(!concerns_channel( + &PendingPaymentDetails::pending_splice(id, other_channel), + cp, + channel_id + )); + assert!(!concerns_channel( + &tracked_record(id, txid(0x10), &[], None), + cp, + ChannelId([8u8; 32]) + )); + let plain = PendingPaymentDetails::new( + payment_details(id, PaymentStatus::Pending), + Vec::new(), + Vec::new(), + ); + assert!(!concerns_channel(&plain, cp, channel_id)); + } + + fn negotiated_candidate(contribution: Option) -> SpliceCandidateDetails { + SpliceCandidateDetails { + contribution, + status: SpliceCandidateStatus::Negotiated { + txid: Txid::from_byte_array([9u8; 32]), + new_channel_value_satoshis: 100_000, + }, + } + } + + /// A previous transaction with a P2WPKH output at index 0 for a contribution input to spend; + /// `seed` varies the output script, and with it the txid. + fn test_prevtx(seed: u8) -> Transaction { + use bitcoin::WPubkeyHash; + + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + } + } + + /// Releasing a contribution spares the parts another contribution uses as well: a fee bump + /// built by adjusting the fee of the round it replaces shares that round's inputs and change + /// address — the change differing in amount only — so against that round every part is kept; + /// against a candidate using only some of the parts, the rest is released, a counterparty-only + /// alongside claiming nothing; against no other contribution, everything is. + #[test] + fn unclaimed_parts_spare_what_other_contributions_use() { + use bitcoin::WPubkeyHash; + + let prevtxs: Vec = (1u8..=3).map(test_prevtx).collect(); + let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 }; + let script = |seed: u8| ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])); + let change = |sats: u64| TxOut { value: Amount::from_sat(sats), script_pubkey: script(9) }; + let splice_out = TxOut { value: Amount::from_sat(50_000), script_pubkey: script(8) }; + let bump = test_funding_contribution_with_parts( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change(20_000)), + ); + + let prior = test_funding_contribution_with_parts( + 0, + 253, + &prevtxs, + &[splice_out.clone()], + Some(&change(21_000)), + ); + assert_eq!(unclaimed_parts(&bump, [&prior]), (Vec::new(), Vec::new())); + + let partial = + test_funding_contribution_with_parts(0, 253, &prevtxs[..2], &[], Some(&change(21_000))); + let candidates = [negotiated_candidate(None), negotiated_candidate(Some(partial))]; + let claimants = candidates.iter().filter_map(|candidate| candidate.contribution.as_ref()); + assert_eq!( + unclaimed_parts(&bump, claimants), + (vec![outpoint(&prevtxs[2])], vec![splice_out.clone()]) + ); + + assert_eq!( + unclaimed_parts(&bump, []), + (prevtxs.iter().map(outpoint).collect(), vec![splice_out, change(20_000)]) + ); + } + + /// 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; + /// the splice is not 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_tracks_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/config.rs b/src/config.rs index d524eac58e..5e9eafcb74 100644 --- a/src/config.rs +++ b/src/config.rs @@ -104,8 +104,10 @@ pub(crate) const CHANNEL_TX_FACTS_MAX_RECORD_BYTES: usize = 128 * 1024; // Records are dropped only once the channels they belong to have resolved, so between prunes a // counterparty opening and closing channels, or replacing a negotiated funding again and again, // drives the store's growth. Past this many records the store is full and the transactions -// it would have described go unclassified, which is bounded loss of detail rather than unbounded -// storage. +// it would have described go unclassified, which is bounded loss of detail rather than +// unbounded storage. An interactive funding round this node is about to sign is admitted beside +// them: how many of those there are is this node's own decision, and refusing one would cost a +// figure that is never corrected rather than a detail. pub(crate) const CHANNEL_TX_FACTS_MAX_RECORDS: usize = 100_000; // The number of pages of channel transaction provenance records one chain tip change examines. diff --git a/src/data_store.rs b/src/data_store.rs index 8d51660996..747e0b7ed6 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -470,6 +470,19 @@ where Ok(Some(new_object)) } + /// Like [`Self::mutate`], but allows the transformation to await fallible reads. + /// + /// The mutation lock remains held while `f` runs. This is useful when the new state must be + /// decided from an async read of another store without letting a concurrent writer invalidate + /// that decision. Callers must keep cross-store lock ordering consistent to avoid deadlocks. + pub(crate) async fn mutate_async(&self, id: &SO::Id, f: F) -> Result, Error> + where + F: FnOnce(Option) -> Fut, + Fut: Future, Error>>, + { + self.mutate_with(id, f).await + } + /// Returns whether an object is stored under `id`. pub(crate) async fn contains_key(&self, id: &SO::Id) -> Result { let _guard = self.mutation_lock.read().await; @@ -1246,6 +1259,43 @@ mod tests { assert_eq!(Some(expected), data_store.get(&id).await.unwrap()); } + #[tokio::test] + async fn mutate_async_awaits_fallible_reads() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let id = TestObjectId { id: [42u8; 4] }; + let other_id = TestObjectId { id: [43u8; 4] }; + let existing_object = TestObject::new(id, [23u8; 3]); + let other_object = TestObject::new(other_id, [24u8; 3]); + let data_store: DataStore> = DataStore::new( + vec![existing_object], + KeepAllEntries, + TEST_PRIMARY_NAMESPACE.to_string(), + TEST_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&store), + Arc::clone(&logger), + ); + let other_store: DataStore> = DataStore::new( + vec![other_object], + KeepAllEntries, + "other_datastore_test_primary".to_string(), + "other_datastore_test_secondary".to_string(), + store, + logger, + ); + + let result = data_store + .mutate_async(&id, |existing| async move { + let mut updated = existing.unwrap(); + updated.data = other_store.get(&other_id).await?.unwrap().data; + Ok(Some(updated)) + }) + .await; + let expected = TestObject::new(id, [24u8; 3]); + assert_eq!(Ok(Some(expected)), result); + assert_eq!(Some(expected), data_store.get(&id).await.unwrap()); + } + #[tokio::test] async fn mutate_runs_the_closure_without_the_cache_lock() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/event.rs b/src/event.rs index 657c938ed6..b0e1bdf38a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, FeeRate, OutPoint, Txid}; +use bitcoin::{Amount, FeeRate, OutPoint, ScriptBuf, Txid}; use lightning::blinded_path::message::NextMessageHop; use lightning::chain::chaininterface::FundingCandidate; use lightning::chain::transaction::OutPoint as LdkOutPoint; @@ -22,6 +22,7 @@ use lightning::events::bump_transaction::BumpTransactionEvent; use lightning::events::PaidBolt12Invoice; use lightning::events::{ ClosureReason, Event as LdkEvent, FundingInfo, InboundHTLCLocator as LdkInboundHtlcLocator, + NegotiationFailureReason as LdkNegotiationFailureReason, OutboundHTLCLocator as LdkOutboundHtlcLocator, PaymentFailureReason, PaymentPurpose, ReplayEvent, }; @@ -33,10 +34,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, UpdatableObject}; @@ -52,6 +58,7 @@ use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore; use crate::payment::forwarding_store::{ForwardRecord, ForwardingStore}; +use crate::payment::pending_payment_store::SpliceKind; use crate::payment::store::{ Channel, PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, @@ -64,7 +71,9 @@ use crate::types::{ Wallet, }; use crate::wallet::provenance::{ChannelOutputRole, ChannelTxFacts, FactsRecordOutcome}; -use crate::wallet::{closed_channel_tracked_rounds, funding_candidates, tracked_splice_rounds}; +use crate::wallet::{ + closed_channel_tracked_rounds, funding_candidates, tracked_splice_rounds, SignedFundingRecord, +}; use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, UserChannelId, @@ -121,6 +130,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`] @@ -314,7 +472,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 { @@ -324,6 +486,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, }, } @@ -408,6 +582,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), }, ); @@ -578,6 +754,7 @@ where onion_messenger: Arc, om_mailbox: Option>, prober: Option>, + splice_tracker: Arc, runtime: Arc, logger: L, config: Arc, @@ -598,7 +775,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, pending_funding_fee_rates: Arc>>, ) -> Self { Self { @@ -619,6 +796,7 @@ where onion_messenger, om_mailbox, prober, + splice_tracker, runtime, logger, config, @@ -745,6 +923,27 @@ where Ok((payment_id, None)) } + /// Cancels a splice whose funding transaction this node will not sign, so that LDK releases + /// what it reserved for this node's contribution through `DiscardFunding` and surfaces the + /// failure through `SpliceNegotiationFailed`, which also settles the persisted intent and + /// takes back the round's record once the round is gone from the channel's history. + /// + /// A refusal means the splice is already beyond cancelling — LDK reset the round itself, or + /// the channel is gone — in which case those reports are on their way regardless and there is + /// nothing further to unwind. + fn cancel_splice(&self, counterparty_node_id: PublicKey, channel_id: ChannelId) { + if let Err(e) = + self.channel_manager.cancel_funding_contributed(&channel_id, &counterparty_node_id) + { + log_error!( + self.logger, + "Failed to cancel the splice on channel {}: {:?}", + channel_id, + e, + ); + } + } + /// The channel's pending splice rounds that have a transaction, as LDK currently tracks them. fn pending_splice_rounds( &self, counterparty_node_id: PublicKey, channel_id: ChannelId, @@ -775,18 +974,23 @@ where /// Records what one of this node's channels reported about a transaction it produced, and /// classifies the transaction's payment record from it if wallet sync wrote that record first. /// - /// A failure is logged rather than reported: these facts accompany a transaction this node - /// has already released or a claim it has already made, so there is nothing left to withhold, - /// and the producing event is re-offered until the claim resolves. + /// A failure is logged rather than reported: these facts accompany a transaction this node has + /// already released or a claim it has already made, so there is nothing left to withhold, and + /// the producing event is re-offered until the claim resolves. A refusal for want of room costs + /// a transaction reported without a classification, which this node likewise cannot take back. async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) { let txid = facts.txid; match self.wallet.record_channel_tx_facts(facts).await { Ok(FactsRecordOutcome::Recorded) => { self.wallet.classify_recorded_transaction(txid).await }, - // Refused for lack of room, which the wallet has logged. The report was not recorded, - // so the transaction cannot be classified from it. - Ok(FactsRecordOutcome::Incomplete) => {}, + Ok(FactsRecordOutcome::Incomplete) => { + log_error!( + self.logger, + "Reporting transaction {} without a classification: this node has no room to describe it", + txid, + ); + }, Err(e) => { log_error!( self.logger, @@ -859,15 +1063,17 @@ where ChannelOutputRole::Funding, [vout as u32], ); - match self.wallet.record_channel_tx_facts(facts).await { + match self.wallet.record_own_funding_tx_facts(facts).await { Ok(FactsRecordOutcome::Recorded) => {}, - // Replaying would rebuild the same transaction and find the same - // full store, so the channel is funded with a transaction this - // node will report without a classification. + // Admitted however full the store is, the report can only be + // refused as too large a record, which one funding output does + // not make. Replaying would meet the same refusal, so the + // channel is funded with a transaction this node will report + // without a classification. Ok(FactsRecordOutcome::Incomplete) => { log_error!( self.logger, - "Funding channel {} with a transaction this node has no room to describe", + "Funding channel {} with a transaction this node could not describe", temporary_channel_id, ); }, @@ -1999,15 +2205,16 @@ where "LDK Node has only ever persisted ChannelPending events from rust-lightning 0.0.115 or later", ); + // The funding output alone says what the transaction is, and says it for every + // channel a batched funding opens: each channel reports its own output, and the + // reports of one transaction are kept together. let channel = Channel { counterparty_node_id, channel_id }; - let facts = ChannelTxFacts::new(funding_txo.txid) - .with_outputs( - &channel, - Some(UserChannelId(user_channel_id)), - ChannelOutputRole::Funding, - [funding_txo.vout], - ) - .with_self_role(TransactionType::Funding { channels: vec![channel.clone()] }); + let facts = ChannelTxFacts::new(funding_txo.txid).with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [funding_txo.vout], + ); self.record_channel_tx_facts(facts).await; let event = Event::ChannelPending { @@ -2136,6 +2343,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), @@ -2205,6 +2416,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. @@ -2561,7 +2774,6 @@ where } } }, - // TODO(splicing): Revisit error handling once splicing API is settled in LDK 0.3 LdkEvent::FundingTransactionReadyForSigning { channel_id, counterparty_node_id, @@ -2569,25 +2781,47 @@ where .. } => match self.wallet.sign_owned_inputs(unsigned_transaction) { Ok(partially_signed_tx) => { - // Record the splice round before handing our signatures to LDK: + // Record what the round is before handing our signatures to LDK: // `funding_transaction_signed` releases them to the counterparty, after which // either party may broadcast — and wallet sync could observe the transaction - // before this node has recorded what it is. The round's place in the channel's - // splice history is written from that history, and the round's broadcast adds - // nothing to it. On a failed write, replay rather than proceed unrecorded: LDK - // re-offers the event in-session and regenerates it across restarts while the - // transaction is unsigned. + // before this node has recorded anything about it. The round is recorded from + // the channel's pending splice history through the splice tracker, whose lock + // keeps the channel's intent record from changing hands mid-write; the payment + // record itself is left to wallet sync. On a failed write, replay rather than + // proceed unrecorded: LDK re-offers the event in-session and regenerates it + // across restarts while the transaction is unsigned. let candidates = self.pending_splice_rounds(counterparty_node_id, channel_id); - if let Err(e) = - self.wallet.record_signed_funding(&partially_signed_tx, &candidates).await + let record = match self + .splice_tracker + .on_funding_ready_for_signing(&partially_signed_tx, &candidates) + .await { + Ok(record) => record, + Err(e) => { + log_error!( + self.logger, + "Failed to record the signed splice round of channel {}: {}", + channel_id, + e, + ); + return Err(ReplayEvent()); + }, + }; + if record == SignedFundingRecord::Unmeasurable { + // The round was not recorded and our signatures have not left the node, so + // the splice is cancelled rather than replayed. A replay would meet the + // same refusal, and an event that fails every time it is offered holds back + // every event queued behind it — including the ones that claim inbound + // HTLCs before they expire. Bounding the loss to this splice is the cheaper + // failure. log_error!( self.logger, - "Failed to record the signed splice round for channel {}: {}", + "Not signing the funding transaction for channel {}, aborting the \ + splice: this node's share of the round is not on record", channel_id, - e, ); - return Err(ReplayEvent()); + self.cancel_splice(counterparty_node_id, channel_id); + return Ok(()); } match self.channel_manager.funding_transaction_signed( &channel_id, @@ -2603,23 +2837,29 @@ where ); }, Err(e) => { - // Either the round was reset after its history was read above — LDK - // then reports the failure through `SpliceNegotiationFailed`, whose - // handling takes the record back — or LDK rejected the witnesses, in - // which case the round stays pending in LDK, and the record with it. - // TODO(splicing): cancel the contribution here through - // `ChannelManager::cancel_funding_contributed`; a follow-up wires it. + // The signed transaction never reached LDK, so it can never be + // broadcast: cancel the splice, which also takes back the record + // written above. log_error!( self.logger, - "LDK refused the signed funding transaction for channel {}: {:?}", + "LDK refused the signed funding transaction for channel {}, \ + aborting the splice: {:?}", channel_id, e, ); + self.cancel_splice(counterparty_node_id, channel_id); }, } }, Err(()) => { - log_error!(self.logger, "Failed signing funding transaction"); + // No record has been written for this transaction yet, so there is nothing to + // unwind: cancel the splice. + log_error!( + self.logger, + "Failed signing the funding transaction for channel {}, aborting the splice", + channel_id, + ); + self.cancel_splice(counterparty_node_id, channel_id); }, }, LdkEvent::SpliceNegotiated { @@ -2689,7 +2929,8 @@ where channel_id, user_channel_id, counterparty_node_id, - .. + reason, + contribution, } => { log_info!( self.logger, @@ -2700,12 +2941,13 @@ where // A round this node signed was recorded when signing; if the failed round was // among them, it can no longer be broadcast, so take its record back. The - // rounds LDK still tracks tell which recorded ones it abandoned (a contribution - // can fail while an earlier signed round still awaits its signatures). A closed - // channel is left to its `ChannelClosed` event: LDK queues one for every channel it - // removes — before the failures a force-close reports, after the one a cooperative - // close reports — and that event carries the channel's last funding, which this - // handler can no longer read from the channel. + // splice intent the record carried stays behind as a bare intent for the report + // below. The rounds LDK still tracks tell which recorded ones it abandoned (a + // contribution can fail while an earlier signed round still awaits its signatures). + // A closed channel is left to its `ChannelClosed` event: LDK queues one for every + // channel it removes — before the failures a force-close reports, after the one a + // cooperative close reports — and that event carries the channel's last funding, + // which this handler can no longer read from the channel. if let Some(tracked_rounds) = self.tracked_splice_rounds(counterparty_node_id, channel_id) { @@ -2723,19 +2965,38 @@ where } } + // Snapshot the recorded splice this failure concerns; the settlement keeps the + // channel's record from changing hands until the report is settled below. + let contribution = contribution.map(|c| c.into_contribution()); + let settlement = self + .splice_tracker + .on_negotiation_failed(counterparty_node_id, channel_id, contribution.as_ref()) + .await; + + let 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(()) @@ -2900,6 +3161,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, @@ -2917,6 +3183,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 { @@ -2978,6 +3249,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 a5f2a72e18..2e2384f414 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, @@ -177,6 +179,7 @@ use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; pub use payment::forwarding_store::aggregate_channel_pair_stats; use payment::forwarding_store::{run_forwarded_payment_aggregation, ForwardingStore}; +use payment::pending_payment_store::SpliceKind; use payment::{ Bolt11Payment, Bolt12Payment, ForwardingAnalytics, OnchainPayment, PaymentDetails, PaymentDetailsPage, SpontaneousPayment, @@ -275,6 +278,7 @@ pub struct Node { payment_store: Arc, forwarding_store: Arc, forwarded_payment_aggregation_retention_secs: u64, + splice_tracker: Arc, lnurl_auth: Arc, is_running: Arc>, node_metrics: Arc, @@ -361,6 +365,11 @@ impl Node { // against them. self.runtime.block_on(self.wallet.record_channel_outputs()); + // Release whatever the wallet still reserves for splices that did not survive the restart — + // before background syncing and broadcasting start below, so the stale reservations are + // gone before anything can act on them. + self.runtime.block_on(self.splice_tracker.reconcile()); + // A splice round recorded when this node signed it is taken back once LDK reports the // negotiation failed or the channel closed. LDK reports the loss of a negotiation its last // channel manager write carried mid-way, but a round committed, negotiated and signed @@ -723,6 +732,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), @@ -736,6 +746,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); @@ -1812,6 +1831,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 the wallet does not reserve anything 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 = min_fee_rate.unwrap_or_else(|| { self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding) }); @@ -1826,18 +1853,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, @@ -1899,6 +1921,10 @@ impl Node { _ => min_feerate, }; + // TODO(#1037): the inputs are locked from coin selection on, and a failure of the + // build after it — LDK validating the selected inputs — returns here with the inputs + // still locked; `submit`'s own failure paths release them or leave them to + // `DiscardFunding`. let contribution = self .runtime .block_on(funding_template.splice_in( @@ -1912,16 +1938,18 @@ impl Node { Error::ChannelSplicingFailed })?; - self.channel_manager - .funding_contributed( - &channel_details.channel_id, - &counterparty_node_id, + self.runtime + .block_on(self.splice_tracker.submit( + counterparty_node_id, + channel_details.channel_id, + pre_splice_funding_txo, contribution, + SpliceKind::In { amount_sats: splice_amount_sats }, None, - ) + )) .map_err(|e| { log_error!(self.logger, "Failed to splice channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( @@ -1946,6 +1974,15 @@ impl Node { /// pending splice that requires a higher feerate to be replaced, or when the counterparty /// initiates a splice concurrently and our contribution is added to theirs. /// + /// 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. A splice LDK was still queueing or negotiating when the + /// node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if a splice this node contributed to is still pending on the channel; one + /// lost earlier is dropped without a failure event. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but @@ -1983,6 +2020,15 @@ impl Node { /// /// The amount spliced in is determined at `min_fee_rate`, or at the estimate if unset. /// + /// 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. A splice LDK was still queueing or negotiating when the + /// node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if a splice this node contributed to is still pending on the channel; one + /// lost earlier is dropped without a failure event. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but @@ -2014,6 +2060,15 @@ impl Node { /// pending splice that requires a higher feerate to be replaced, or when the counterparty /// initiates a splice concurrently and our contribution is added to theirs. /// + /// 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. A splice LDK was still queueing or negotiating when the + /// node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if a splice this node contributed to is still pending on the channel; one + /// lost earlier is dropped without a failure event. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-out will be marked as an inbound payment if @@ -2034,6 +2089,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 the wallet does not reserve anything 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 { @@ -2077,22 +2140,25 @@ impl Node { value: Amount::from_sat(splice_amount_sats), script_pubkey: address.script_pubkey(), }]; - let contribution = - funding_template.splice_out(outputs, feerate, max_feerate).map_err(|e| { - log_error!(self.logger, "Failed to splice channel: {}", e); - Error::ChannelSplicingFailed - })?; + let contribution = funding_template + .splice_out(outputs.clone(), feerate, max_feerate) + .map_err(|e| { + log_error!(self.logger, "Failed to splice channel: {}", e); + Error::ChannelSplicingFailed + })?; - self.channel_manager - .funding_contributed( - &channel_details.channel_id, - &counterparty_node_id, + self.runtime + .block_on(self.splice_tracker.submit( + counterparty_node_id, + channel_details.channel_id, + pre_splice_funding_txo, contribution, + SpliceKind::Out { outputs }, None, - ) + )) .map_err(|e| { log_error!(self.logger, "Failed to splice channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( @@ -2113,6 +2179,15 @@ impl Node { /// of a feerate estimate retrieved from the configured chain source. As with the estimate, we /// pay the feerate required to replace the pending splice if it is higher, but no more than 1.5 /// times this feerate: if replacing it requires more, the bump fails. + /// + /// 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. A fee bump LDK was still queueing or negotiating when + /// the node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if this node contributed to the splice it bumps; one lost earlier is dropped + /// without a failure event. pub fn bump_channel_funding_fee( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, min_fee_rate: Option, @@ -2128,6 +2203,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 the wallet does not reserve anything 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 = min_fee_rate.unwrap_or_else(|| { self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding) }); @@ -2155,6 +2238,12 @@ impl Node { return Err(Error::ChannelSplicingFailed); }; + // The round the bump replaces: a bump that only adjusts its fee reuses its inputs and + // change address, which a failed submission must not release. + let prior_contribution = funding_template.prior_contribution().cloned(); + // TODO(#1037): a bump that re-selects its inputs locks them from coin selection on, + // and a failure of the build after it returns here with the inputs still locked; + // `submit`'s own failure paths release them or leave them to `DiscardFunding`. let contribution = self .runtime .block_on(funding_template.rbf_prior_contribution( @@ -2167,16 +2256,18 @@ impl Node { Error::ChannelSplicingFailed })?; - self.channel_manager - .funding_contributed( - &channel_details.channel_id, - &counterparty_node_id, + self.runtime + .block_on(self.splice_tracker.submit( + counterparty_node_id, + channel_details.channel_id, + pre_splice_funding_txo, contribution, - None, - ) + SpliceKind::Rbf {}, + prior_contribution, + )) .map_err(|e| { log_error!(self.logger, "Failed to RBF channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 959b017d63..8eebc693a5 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -5,9 +5,13 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use bitcoin::Txid; -use lightning::impl_writeable_tlv_based; +use bitcoin::secp256k1::PublicKey; +use bitcoin::{TxOut, Txid}; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::FundingContribution; +use lightning::ln::types::ChannelId; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use crate::data_store::{StorableObject, StorableObjectUpdate, UpdatableObject}; use crate::payment::store::{Channel, PaymentDetailsUpdate, TransactionType}; @@ -44,14 +48,76 @@ impl_writeable_tlv_based!(FundingTxCandidate, { (6, awaiting_broadcast, required), }); +/// 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(crate) enum SpliceKind { + /// [`Node::splice_in`] with a resolved amount. + /// + /// [`Node::splice_in`]: crate::Node::splice_in + In { amount_sats: u64 }, + /// [`Node::splice_out`] to the given outputs. + /// + /// [`Node::splice_out`]: crate::Node::splice_out + Out { outputs: Vec }, + /// [`Node::bump_channel_funding_fee`] of a pending splice. + /// + /// [`Node::bump_channel_funding_fee`]: crate::Node::bump_channel_funding_fee + Rbf {}, +} + +impl_writeable_tlv_based_enum!(SpliceKind, + (0, In) => { + (0, amount_sats, required), + }, + (2, Out) => { + (0, outputs, required_vec), + }, + (4, Rbf) => {}, +); + +/// A user-initiated splice that has been handed to LDK but is not yet guaranteed to survive a +/// restart. LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, and it +/// abandons an in-progress negotiation whenever the peer disconnects (which includes stopping the +/// node). Until the new funding transaction locks we keep enough state to recognize a splice LDK +/// no longer knows about and to describe events about it in terms of the original request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SpliceIntent { + /// The channel counterparty. + pub counterparty_node_id: PublicKey, + /// The channel being spliced. + pub channel_id: ChannelId, + /// The channel's funding outpoint when the splice was initiated. It only changes once a splice + /// locks, so a mismatch with the channel's current funding outpoint means the splice (or a + /// replacement) completed and the intent is stale. + pub pre_splice_funding_txo: LdkOutPoint, + /// The contribution handed to [`ChannelManager::funding_contributed`], kept to match later + /// events about the splice back to this intent. + /// + /// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed + pub contribution: FundingContribution, + /// The parameters of the originating API call. + pub kind: SpliceKind, +} + +impl_writeable_tlv_based!(SpliceIntent, { + (0, counterparty_node_id, required), + (2, channel_id, required), + (4, pre_splice_funding_txo, required), + (6, contribution, required), + (8, kind, required), +}); + /// A pending payment tracked by LDK Node, keyed by [`PaymentId`]. /// /// Each part of an entry is written by a different subsystem and is present on its own schedule, -/// so all of them are optional. Signing a round of an interactive funding adds the round to -/// `candidates` and lists the `funding_channels` it belongs to, still without a transaction anyone -/// has seen; wallet sync adds `details` once it observes the transaction, and records -/// `conflicting_txids` for any wallet transaction. A splice uses all of them; the fields do not -/// partition by payment type. An entry with none of them tracks no payment and is removed. +/// so all of them are optional. A user-initiated splice is persisted with nothing but its +/// [`SpliceIntent`] before its contribution is handed to LDK; signing a round of it adds the +/// round to `candidates` and lists the `funding_channels` it belongs to, still without a +/// transaction anyone has seen; wallet sync adds `details` once it observes the transaction, and +/// records `conflicting_txids` for any wallet transaction; the `ChannelReady` arm records +/// `locked_rounds`. A splice uses all of them; the fields do not partition by payment type. An +/// entry with none of them tracks no payment and is removed. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingPaymentDetails { /// The payment this entry tracks. @@ -73,6 +139,13 @@ pub(crate) struct PendingPaymentDetails { /// RBF history, keyed by each candidate's txid and recorded as each round is signed. /// Empty for non-funding payments. pub candidates: Vec, + /// The live splice intent, or `None` for a non-splice payment or a splice that has + /// locked. It is owned by the splice entry points and the splice tracker — persisted at + /// splice initiation and cleared once the splice locks or its failure is surfaced — and + /// outlives the rounds negotiated under it, because a fee bump is a fresh negotiation LDK + /// likewise abandons if the peer disconnects before signing, and shares the bumped round's + /// record rather than getting one of its own. + pub splice_intent: Option, /// The candidates LDK promoted to the channel's funding, as `ChannelReady` reported them. /// A zero-conf splice locks before its transaction confirms, and every later splice builds /// on it, so such a round can still confirm once the channel's funding has moved on from @@ -84,6 +157,13 @@ pub(crate) struct PendingPaymentDetails { impl PendingPaymentDetails { pub(crate) fn new( details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, + ) -> Self { + Self::tracked(details, conflicting_txids, candidates, None) + } + + pub(crate) fn tracked( + details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, + splice_intent: Option, ) -> Self { Self { id: details.id, @@ -91,6 +171,19 @@ impl PendingPaymentDetails { conflicting_txids, funding_channels: Vec::new(), candidates, + splice_intent, + locked_rounds: Vec::new(), + } + } + + pub(crate) fn pending_splice(id: PaymentId, intent: SpliceIntent) -> Self { + Self { + id, + details: None, + conflicting_txids: Vec::new(), + funding_channels: Vec::new(), + candidates: Vec::new(), + splice_intent: Some(intent), locked_rounds: Vec::new(), } } @@ -100,6 +193,7 @@ impl PendingPaymentDetails { /// record for it exists. pub(crate) fn signed_rounds( id: PaymentId, funding_channels: Vec, candidates: Vec, + splice_intent: Option, ) -> Self { Self { id, @@ -107,6 +201,7 @@ impl PendingPaymentDetails { conflicting_txids: Vec::new(), funding_channels, candidates, + splice_intent, locked_rounds: Vec::new(), } } @@ -136,6 +231,11 @@ impl PendingPaymentDetails { true } + /// The splice intent this record carries, if it is a splice that has not yet locked. + pub(crate) fn splice_intent(&self) -> Option<&SpliceIntent> { + self.splice_intent.as_ref() + } + /// Returns this node's recorded funding figures for the candidate with the given txid, if any. pub(crate) fn candidate(&self, txid: Txid) -> Option<&FundingTxCandidate> { self.candidates.iter().find(|candidate| candidate.txid == txid) @@ -164,7 +264,10 @@ impl PendingPaymentDetails { /// Whether this entry tracks nothing anymore and can be dropped. pub(crate) fn is_empty(&self) -> bool { - self.details.is_none() && self.candidates.is_empty() && self.locked_rounds.is_empty() + self.details.is_none() + && self.splice_intent.is_none() + && self.candidates.is_empty() + && self.locked_rounds.is_empty() } } @@ -174,6 +277,7 @@ impl_writeable_tlv_based!(PendingPaymentDetails, { (4, conflicting_txids, optional_vec), (6, funding_channels, optional_vec), (8, candidates, optional_vec), + (10, splice_intent, option), (12, locked_rounds, optional_vec), }); @@ -183,6 +287,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. Clearing the intent of an entry that carries only the intent is done by removing + /// the entry, not through this field. + pub splice_intent: Option>, } impl StorableObject for PendingPaymentDetails { @@ -233,6 +341,13 @@ impl UpdatableObject for PendingPaymentDetails { updated = true; } + if let Some(new_splice_intent) = update.splice_intent { + if self.splice_intent != new_splice_intent { + self.splice_intent = new_splice_intent; + updated = true; + } + } + updated } @@ -250,12 +365,14 @@ impl StorableObjectUpdate for PendingPaymentDetailsUpdate impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { fn from(value: &PendingPaymentDetails) -> Self { match &value.details { - // An entry with no record yet carries nothing a payment-tracking merge could apply. + // An entry with no record yet carries nothing a payment-tracking merge could apply + // beyond its intent, which the entry containing it owns outright. None => Self { id: value.id, payment_update: None, conflicting_txids: None, candidates: value.candidates.clone(), + splice_intent: value.splice_intent.clone().map(Some), }, Some(details) => { let conflicting_txids = if value.conflicting_txids.is_empty() { @@ -263,11 +380,16 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { } else { Some(value.conflicting_txids.clone()) }; + // Leave the splice intent unchanged: it is owned by the splice entry points and the + // splice tracker, never by a payment-tracking merge. Emitting the current value + // here would let an `insert_or_update` of a payment record (e.g. from wallet sync, + // built without an intent) clobber a live intent to `None`. Self { id: details.id, payment_update: Some(details.to_update()), conflicting_txids, candidates: value.candidates.clone(), + splice_intent: None, } }, } @@ -297,6 +419,27 @@ pub(crate) fn test_funding_contribution_with_outputs( pub(crate) fn test_funding_contribution_with_parts( estimated_fee_sat: u64, feerate: u64, prevtxs: &[bitcoin::Transaction], outputs: &[bitcoin::TxOut], change_output: Option<&bitcoin::TxOut>, +) -> lightning::ln::funding::FundingContribution { + test_funding_contribution_inheriting( + estimated_fee_sat, + feerate, + prevtxs, + outputs, + change_output, + &[], + &[], + ) +} + +/// Like [`test_funding_contribution_with_parts`], but recording `inherited_inputs` and +/// `inherited_output_scripts` as parts a still-pending splice attempt reserved before this +/// contribution, as LDK records them at the hand-off of a fee bump built from the round it +/// replaces: the contribution's `reserved_inputs` and `reserved_outputs` leave them out. +#[cfg(test)] +pub(crate) fn test_funding_contribution_inheriting( + estimated_fee_sat: u64, feerate: u64, prevtxs: &[bitcoin::Transaction], + outputs: &[bitcoin::TxOut], change_output: Option<&bitcoin::TxOut>, + inherited_inputs: &[bitcoin::OutPoint], inherited_output_scripts: &[bitcoin::ScriptBuf], ) -> lightning::ln::funding::FundingContribution { use lightning::util::ser::{BigSize, Writeable}; use lightning::util::wallet_utils::ConfirmedUtxo; @@ -340,6 +483,42 @@ pub(crate) fn test_funding_contribution_with_parts( records.extend_from_slice(&[11, 8]); // (11, max_feerate) records.extend_from_slice(&feerate.to_be_bytes()); records.extend_from_slice(&[13, 1, 1]); // (13, is_splice: true) + if !inherited_inputs.is_empty() || !inherited_output_scripts.is_empty() { + // (17, pending_components): a length-prefixed TLV stream of its own. + let mut components = Vec::new(); + if !inherited_inputs.is_empty() { + let mut bytes = Vec::new(); + for outpoint in inherited_inputs { + outpoint.write(&mut bytes).expect("in-memory write must succeed"); + } + components.push(1); // (1, inputs) + BigSize(bytes.len() as u64) + .write(&mut components) + .expect("in-memory write must succeed"); + components.extend(bytes); + } + if !inherited_output_scripts.is_empty() { + let mut bytes = Vec::new(); + for script in inherited_output_scripts { + script.write(&mut bytes).expect("in-memory write must succeed"); + } + components.push(3); // (3, output_scripts) + BigSize(bytes.len() as u64) + .write(&mut components) + .expect("in-memory write must succeed"); + components.extend(bytes); + } + let mut component_bytes = Vec::new(); + BigSize(components.len() as u64) + .write(&mut component_bytes) + .expect("in-memory write must succeed"); + component_bytes.extend(components); + records.push(17); + BigSize(component_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); + records.extend(component_bytes); + } let mut tlv_bytes = Vec::new(); // BigSize length prefix over the TLV records above. BigSize(records.len() as u64).write(&mut tlv_bytes).expect("in-memory write must succeed"); @@ -348,6 +527,32 @@ pub(crate) fn test_funding_contribution_with_parts( .expect("hand-built TLV stream must decode") } +/// Builds a [`FundingContribution`] for tests carrying just the required TLV records: a zero +/// estimated fee, the default feerate, and no contributed outputs. +/// +/// [`FundingContribution`]: lightning::ln::funding::FundingContribution +#[cfg(test)] +pub(crate) fn test_funding_contribution() -> lightning::ln::funding::FundingContribution { + test_funding_contribution_with_feerate(253) +} + +/// Like [`test_funding_contribution`], but with the given input-selection feerate in sat/kwu. +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_feerate( + feerate: u64, +) -> lightning::ln::funding::FundingContribution { + test_funding_contribution_with_outputs(0, feerate, &[]) +} + +/// Like [`test_funding_contribution`], but with the given input-selection feerate in sat/kwu and +/// an input spending output 0 — which must be P2WPKH — of each given previous transaction. +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_inputs( + feerate: u64, prevtxs: &[bitcoin::Transaction], +) -> FundingContribution { + test_funding_contribution_with_parts(0, feerate, prevtxs, &[], None) +} + #[cfg(test)] mod tests { use bitcoin::hashes::Hash; @@ -457,6 +662,188 @@ mod tests { ); } + fn test_intent() -> SpliceIntent { + use std::str::FromStr; + + SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution: test_funding_contribution(), + kind: SpliceKind::In { amount_sats: 500_000 }, + } + } + + #[test] + fn payment_tracking_merge_preserves_a_live_splice_intent() { + let payment_id = PaymentId([7u8; 32]); + let txid = test_txid(8); + let intent = test_intent(); + let mut record = PendingPaymentDetails::tracked( + pending_onchain_payment(payment_id, txid), + Vec::new(), + Vec::new(), + Some(intent.clone()), + ); + + // Wallet sync merges its view of a transaction through `to_update()` of a fresh record, + // which is built without an intent; the merge must leave the live intent in place. + let fresh = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, txid), + vec![test_txid(9)], + Vec::new(), + ); + assert!(record.update(fresh.to_update())); + assert_eq!(record.splice_intent(), Some(&intent)); + } + + #[test] + fn splice_kind_round_trips() { + for kind in [ + SpliceKind::In { amount_sats: 500_000 }, + SpliceKind::Out { + outputs: vec![TxOut { + value: bitcoin::Amount::from_sat(400_000), + script_pubkey: bitcoin::ScriptBuf::new(), + }], + }, + SpliceKind::Rbf {}, + ] { + let encoded = kind.encode(); + let decoded = SpliceKind::read(&mut &encoded[..]).unwrap(); + assert_eq!(kind, decoded); + } + } + + #[test] + fn pending_splice_round_trips() { + use std::str::FromStr; + + let id = PaymentId([10u8; 32]); + let intent = SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution: test_funding_contribution(), + kind: SpliceKind::In { amount_sats: 500_000 }, + }; + let record = PendingPaymentDetails::pending_splice(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()); + } + + /// A fee bump's contribution inherits the inputs and change of the round it replaces, which + /// LDK records in the contribution at the hand-off so that `reserved_inputs` and + /// `reserved_outputs` leave them out: what a failure of the bump releases, and what a retry + /// must reserve again. That record is a private field the contribution's `PartialEq` ignores, + /// so a persisted intent's round trip is checked through those accessors. + #[test] + fn pending_splice_keeps_the_contribution_reserved_parts() { + use std::str::FromStr; + + use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, WPubkeyHash}; + + let prevtx = |seed: u8| Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + }; + let prevtxs = [prevtx(1), prevtx(2)]; + let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 }; + let script = |seed: u8| ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])); + let change = TxOut { value: Amount::from_sat(21_000), script_pubkey: script(9) }; + let splice_out = TxOut { value: Amount::from_sat(50_000), script_pubkey: script(8) }; + let reserved = |contribution: &FundingContribution| { + ( + contribution.reserved_inputs().map(|input| input.outpoint()).collect::>(), + contribution.reserved_outputs().cloned().collect::>(), + ) + }; + + // Without the record, every part counts as reserved. + let plain = test_funding_contribution_with_parts( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change), + ); + assert_eq!( + reserved(&plain), + (prevtxs.iter().map(outpoint).collect(), vec![splice_out.clone(), change.clone()]) + ); + + // The bump reuses the first input and the change address of the round it replaces; the + // second input and the splice-out output are its own. + let contribution = test_funding_contribution_inheriting( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change), + &[outpoint(&prevtxs[0])], + &[change.script_pubkey.clone()], + ); + let expected = (vec![outpoint(&prevtxs[1])], vec![splice_out]); + assert_eq!(reserved(&contribution), expected); + + let intent = SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution, + kind: SpliceKind::Rbf {}, + }; + let record = PendingPaymentDetails::pending_splice(PaymentId([10u8; 32]), intent); + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + let intent = decoded.splice_intent.expect("a pending splice decoded without its intent"); + assert_eq!(reserved(&intent.contribution), expected); + } + + #[test] + fn tracked_payment_round_trips() { + // An entry without a payment record round-trips in `pending_splice_round_trips`; here we + // cover one carrying the record and its candidate history. + let payment_id = PaymentId([7u8; 32]); + let txid = Txid::from_byte_array([8u8; 32]); + let record = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, txid), + vec![Txid::from_byte_array([9u8; 32])], + vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000), + fee_paid_msat: Some(100), + awaiting_broadcast: false, + }], + ); + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + assert_eq!(decoded.id(), payment_id); + assert!(decoded.details().is_some()); + } + /// A candidate with the given txid byte, with a stake of ours in it if `ours`. fn candidate(txid_byte: u8, ours: bool) -> FundingTxCandidate { FundingTxCandidate { @@ -473,10 +860,10 @@ mod tests { PendingPaymentDetails::new(pending_onchain_payment(payment_id, txid), vec![], candidates) } - /// An entry written by a node from before the rounds LDK promoted were kept on it reads as an - /// entry with none, everything else intact. + /// An entry written by a node from before the rounds LDK promoted and the splice's intent were + /// kept on it reads as an entry with neither, everything else intact. #[test] - fn an_entry_written_without_locked_rounds_reads_with_none() { + fn an_entry_written_without_locked_rounds_or_an_intent_reads_with_neither() { let mut stored = entry(vec![candidate(2, false), candidate(3, true)]); stored.conflicting_txids = vec![test_txid(9)]; @@ -494,8 +881,9 @@ mod tests { let written_before = write_as_before().unwrap(); let decoded: PendingPaymentDetails = Readable::read(&mut &written_before[..]) - .expect("an entry written before the rounds were kept still reads"); + .expect("an entry written before the rounds and the intent were kept still reads"); assert!(decoded.locked_rounds().is_empty()); + assert!(decoded.splice_intent.is_none()); assert_eq!(decoded, stored); } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index b883ea918a..5687a94378 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -56,6 +56,7 @@ use lightning_invoice::RawBolt11Invoice; use payment_stores::{PaymentStores, PaymentStoresGuard}; use persist::KVStoreWalletPersister; +use crate::channel::is_same_splice; use crate::config::{ Config, ADDRESS_POOL_SIZE, CHANNEL_TX_FACTS_MAX_RECORDS, CHANNEL_TX_FACTS_PRUNE_PAGES_PER_TIP, CHANNEL_TX_FACTS_RETENTION_BLOCKS, @@ -65,8 +66,7 @@ use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed}; use crate::data_store::{StorableObject, UpdatableObject}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; use crate::logger::{log_debug, log_error, log_info, log_trace, log_warn, LdkLogger, Logger}; -#[cfg(test)] -use crate::payment::pending_payment_store::PendingPaymentDetailsUpdate; +use crate::payment::pending_payment_store::SpliceIntent; use crate::payment::store::{Channel, ConfirmationStatus, PaymentDetailsUpdate}; use crate::payment::{ FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, @@ -75,8 +75,8 @@ use crate::payment::{ use crate::runtime::Runtime; use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; use crate::wallet::provenance::{ - ChannelLiveness, ChannelTxFacts, ChannelTxFactsRejection, FactsRecordOutcome, FactsRetention, - LocalFundingFigures, RetentionCheck, TxProvenance, + ChannelLiveness, ChannelTxFacts, ChannelTxFactsRejection, FactsAdmission, FactsRecordOutcome, + FactsRetention, LocalFundingFigures, RetentionCheck, TxProvenance, }; use crate::{ChainSource, Error}; @@ -91,6 +91,20 @@ pub(crate) enum FundingAmount { Max, } +/// What recording an interactive funding round this node is about to sign came to, as it decides +/// whether the round may be signed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SignedFundingRecord { + /// This node's share of the round is on record, or the round has nothing to record. The round + /// may be signed. + Recorded, + /// This node's share of the round is not on record, and this node will not put it there later. + /// Signing would release a transaction whoever first observes it records with the wallet's view + /// of a funding output both parties own — the whole of it read as this node's spend, a figure + /// that is never corrected — so the round is cancelled instead. + Unmeasurable, +} + mod payment_stores; pub(crate) mod persist; pub(crate) mod provenance; @@ -264,12 +278,33 @@ impl Wallet { /// overwriting it: one of the two producers is wrong, and the recorded facts came first. /// /// A report the store has no room for is likewise refused, and reported as - /// [`FactsRecordOutcome::Incomplete`] rather than as a failure: there is nothing to retry, - /// and the consequence is a transaction this node cannot say anything about, not a lost - /// write. Only what this node has no record of at all is refused that way — a transaction it + /// [`FactsRecordOutcome::Incomplete`] rather than as a failure: the store is as it was, and + /// what the refusal costs the reporting producer is a transaction this node cannot say anything + /// about. Only what this node has no record of at all is refused that way — a transaction it /// already describes goes on being described, however full the store is. pub(crate) async fn record_channel_tx_facts( &self, facts: ChannelTxFacts, + ) -> Result { + self.record_channel_tx_facts_admitted(facts, FactsAdmission::Capped).await + } + + /// Records the funding transaction this node built for a channel it is opening, as + /// [`Self::record_channel_tx_facts`] does, admitted however many records the store contains. + /// + /// The cap bounds what a counterparty drives; a channel this node chose to fund is not that, + /// and without its funding output on record the transaction and every transaction spending + /// it, the channel's close among them, are reported without a classification. + pub(crate) async fn record_own_funding_tx_facts( + &self, facts: ChannelTxFacts, + ) -> Result { + self.record_channel_tx_facts_admitted(facts, FactsAdmission::Exempt).await + } + + /// Records what a producer reported, as [`Self::record_channel_tx_facts`] does, with + /// `admission` deciding whether the number of records the store may contain applies to a + /// transaction it has no record of at all. + async fn record_channel_tx_facts_admitted( + &self, facts: ChannelTxFacts, admission: FactsAdmission, ) -> Result { let txid = facts.txid; // Dated by the chain tip the report arrives at, which is what retention measures from. @@ -287,10 +322,10 @@ impl Wallet { None }, }, - // A transaction nothing is recorded of yet needs room of its own; one already on - // record is merged into above however full the store is, so an obligation this - // node took on is never half-kept. - None if !self.facts_retention.has_room() => { + // A transaction not yet recorded at all needs room of its own, unless the producer + // is exempt from the cap; one already on record is merged into above however full + // the store is, so an obligation this node took on is never half-kept. + None if admission == FactsAdmission::Capped && !self.facts_retention.has_room() => { rejection = Some(ChannelTxFactsRejection::NoRoom { limit: CHANNEL_TX_FACTS_MAX_RECORDS, }); @@ -314,12 +349,7 @@ impl Wallet { match rejection { Some(e) if e.is_resource_limit() => { - log_error!( - self.logger, - "Not recording what transaction {} is: {}. It will be reported without a classification", - txid, - e, - ); + log_error!(self.logger, "Not recording what transaction {} is: {}", txid, e,); Ok(FactsRecordOutcome::Incomplete) }, Some(e) => { @@ -643,8 +673,8 @@ impl Wallet { let pending_payments: Vec = self .payment_stores .pending_payments(|p| match p.details() { - // An entry of signed rounds whose transaction nothing has observed - // yet carries no payment and cannot graduate. + // A pre-broadcast splice intent carries no payment yet and cannot + // graduate. None => false, Some(details) => { debug_assert!( @@ -662,7 +692,19 @@ impl Wallet { let mut unclassified_transactions: Vec<(PaymentId, Txid)> = Vec::new(); for payment in pending_payments { - // The filter admits only entries with a record. + // The filter admits only Tracked funding payments. A splice intent such a + // record carries — there is one record per splice, so only the intent of + // the round it tracks or of a fee bump of it — goes with the entry when + // the payment graduates: the lock and the graduation both follow the + // confirmation of the round the record tracks, so a lock handled after + // the graduation finds no intent to settle, and one handled before it + // leaves a record whose intent is already cleared. + // TODO(#1037): once inputs are locked, the graduated round's locks sit on + // spent outpoints: #1037 does not release anything for a splice at + // broadcast, since it prepares only `Funding`-typed packages, until the + // `InteractiveFunding` broadcast arm applies the round and unlocks its + // inputs. A bump's extra inputs return through the `DiscardFunding` LDK + // queues at the lock, once that handler passes the inputs to `cancel_tx`. let Some(details) = payment.details() else { continue; }; @@ -1324,10 +1366,12 @@ impl Wallet { } /// Fails the funding payment `payment_id` while its record still waits on the unconfirmed - /// funding transaction `record_txid`, and removes its pending entry, reporting what it did. As - /// with graduation, the decision is made from the live record and only the status is written. - /// A record already `Failed` — a prior pass whose entry removal was lost to a crash — still - /// matches, no-ops the update, and gets its lingering entry removed. + /// funding transaction `record_txid`, and removes its pending entry — keeping a splice intent + /// it carries as a bare intent under an id of its own, which the splice tracker settles — + /// reporting what it did. As with graduation, the decision is made from the live record and + /// only the status is written. A record already `Failed` — a prior pass whose entry removal + /// was lost to a crash — still matches, no-ops the update, and gets its lingering entry + /// removed, its intent kept unless the prior pass already did. async fn fail_unconfirmed_funding_payment_locked( &self, stores: &PaymentStoresGuard<'_>, payment_id: PaymentId, record_txid: Txid, ) -> Result { @@ -1361,11 +1405,59 @@ impl Wallet { }) .await?; if outcome != FundingPaymentFailure::MovedOn { - stores.remove_pending_payment(&payment_id).await?; + self.remove_failed_funding_entry_locked(stores, payment_id).await?; } Ok(outcome) } + /// Removes the pending entry of the funding payment `payment_id`, failed just now, keeping a + /// splice intent the entry carries as a bare intent under an id of its own. + async fn remove_failed_funding_entry_locked( + &self, stores: &PaymentStoresGuard<'_>, payment_id: PaymentId, + ) -> Result<(), Error> { + // A splice intent the entry carries outlives the record as a bare intent, for the + // splice tracker to settle at the lock or the close that failed the payment, or to + // re-anchor when LDK carries a queued fee bump the promoted round does not overlap + // across the lock and begins a fresh splice from it. The intent moves to an id of its + // own: the fresh round adopts the id of the bare intent carrying its contribution + // (`find_splice_payment_id`), and under this record's id it would take a `Failed` + // record and go untracked. The drop pass (`drop_abandoned_splice_rounds_locked`) keeps + // the intent under the record's id instead, having removed the record. Keeping the + // intent before removing the entry loses nothing to a crash in between: the replay + // finds the intent kept and adds no second copy. A settlement the splice tracker has + // under way, one that read the intent before this routine ran or that lands between the + // read above and the insert, leaves a bare copy of a settled intent behind; the + // channel's next lock, close or startup reconciliation settles the copy. + let intent = + stores.pending_payment(&payment_id).await?.and_then(|entry| entry.splice_intent); + if let Some(intent) = intent { + // The payment's own entry carries the intent without a record when the payment was + // failed before wallet sync observed a round of it; it is not the copy looked for. + let kept_already = stores + .pending_payments(|p| { + p.id() != payment_id + && p.details().is_none() + && p.splice_intent() == Some(&intent) + }) + .await; + if kept_already.is_empty() { + let kept_id = random_payment_id(); + stores + .insert_pending_payment(PendingPaymentDetails::pending_splice(kept_id, intent)) + .await?; + log_debug!( + self.logger, + "Kept the splice intent of failed funding payment {} as bare intent {} for \ + the splice tracker to settle", + payment_id, + kept_id, + ); + } + } + stores.remove_pending_payment(&payment_id).await?; + Ok(()) + } + /// Resolves the funding payments of the closed channel `channel_id`, whose monitor settled on /// and still watches `tracked_rounds` (as [`closed_channel_tracked_rounds`] lists them): a /// round that was never broadcast is dropped from its record, as @@ -1421,16 +1513,17 @@ impl Wallet { /// Fails every funding payment of `channel_id` still waiting on an unconfirmed splice round /// while its record lists no round of ours that is among `tracked_rounds` or was promoted to /// the channel's funding (see [`Self::resolve_promoted_splice_round`]), removing its pending - /// entry; a payment with such a round is left as it is. The rounds of ours are the candidates - /// recorded with a stake, and the record's own transaction only when the candidates do not list - /// it, as for a record from before candidates were tracked: a recorded candidate counts by its - /// stake alone, whichever round the record points at. A payment that moved on — its round - /// confirmed, or it was failed already — is not touched beyond the entry a failure cut short - /// left behind. An entry without a record yet — wallet sync never observed a transaction of its - /// splice — is failed on the same terms, under a record written for it then from the share of - /// its newest round of ours the signing recorded, and removed; it is removed without one when - /// that round moved no wallet funds and so has no share on record. `resolution` is the occasion - /// the log lines report. + /// entry and keeping a splice intent it carries as a bare intent of its own; a payment with + /// such a round is left as it is. The rounds of ours are the candidates recorded with a stake, + /// and the record's own transaction only when the candidates do not list it, as for a record + /// from before candidates were tracked: a recorded candidate counts by its stake alone, + /// whichever round the record points at. A payment that moved on — its round confirmed, or it + /// was failed already — is not touched beyond the entry a failure cut short left behind. An + /// entry without a record yet — wallet sync never observed a transaction of its splice — is + /// failed on the same terms, under a record written for it then from the share of its newest + /// round of ours the signing recorded, its entry removed and its intent kept likewise; it loses + /// its rounds without a record, keeping an intent it carries, when that round moved no wallet + /// funds and so has no share on record. `resolution` is the occasion the log lines report. async fn fail_funding_payments_without_tracked_round_locked( &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, tracked_rounds: &[Txid], resolution: FundingResolution, @@ -1445,14 +1538,15 @@ impl Wallet { for entry in entries { let details = match entry.details() { Some(details) => details, - // An entry with no payment record yet — wallet sync has not observed a - // transaction of this splice — has no payment to fail. The drop pass has taken - // back its rounds that were never broadcast, so the rounds left are ones LDK - // released: the entry lists no round of ours that is tracked or locked, so its - // rounds cannot confirm anymore. The attempt is failed under a record written for - // it now, from the share of its newest round of ours the signing recorded, so - // that it shows in the payment list as one wallet sync had observed would; the - // entry has no further round to track and goes. + // An entry with no payment record yet — wallet sync has not observed a transaction + // of this splice — has no payment to fail. The drop pass has taken back its rounds + // that were never broadcast, so the rounds left are ones LDK released: the entry + // lists no round of ours that is tracked or locked, so its rounds cannot confirm + // anymore. The attempt is failed under a record written for it now, from the share + // of its newest round of ours the signing recorded, so that it shows in the payment + // list as one wallet sync had observed would; the entry goes, a splice intent it + // carries kept as a bare intent of its own for the failure LDK reports to be + // described from. None => { let payment_id = entry.id(); let rounds_of_ours: Vec = entry @@ -1479,8 +1573,9 @@ impl Wallet { // an external address, yet a later round signed with it in the history lists it // as ours by its contribution, and the drop of that later round leaves it the // newest. Such a round was never a payment of the wallet's, so there is nothing - // to fail: the entry goes without a record. A failed read is no answer about - // the round, and fails the pass for the event to be replayed. + // to fail: the rounds go without a record, a splice intent the entry carries + // kept under its id as the drop pass keeps one. A failed read is no answer + // about the round, and fails the pass for the event to be replayed. let newest_round = rounds_of_ours.last().copied(); let figures = match newest_round { Some(txid) => self @@ -1491,10 +1586,21 @@ impl Wallet { None => None, }; let (Some(newest_round), Some(figures)) = (newest_round, figures) else { - stores.remove_pending_payment(&payment_id).await?; + stores + .mutate_pending_payment(&payment_id, |existing| { + let mut entry = existing?.clone(); + entry.candidates.clear(); + entry.locked_rounds.clear(); + entry.funding_channels.clear(); + Some(entry) + }) + .await?; + stores + .remove_pending_payment_if(&payment_id, |entry| entry.is_empty()) + .await?; log_info!( self.logger, - "Removed the entry of unobserved funding payment {} {} without a record: no round of ours with a share on record", + "Dropped the rounds of unobserved funding payment {} {} without a record: no round of ours with a share on record", payment_id, occasion, ); @@ -1515,7 +1621,7 @@ impl Wallet { PaymentStatus::Failed, ); stores.insert_or_update_payment(failed).await?; - stores.remove_pending_payment(&payment_id).await?; + self.remove_failed_funding_entry_locked(stores, payment_id).await?; log_info!( self.logger, "Failed unobserved funding payment {} {} under splice round {}: no round of ours can confirm", @@ -1973,6 +2079,37 @@ impl Wallet { } } + /// Flushes any staged wallet changes to the persister, providing an explicit durability point + /// for state that was staged rather than persisted where it was written. + pub(crate) async fn persist_staged(&self) -> Result<(), Error> { + let mut locked_persister = self.persister.lock().await; + let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + }) + } + + /// Releases the given outpoints from the wallet's locked set — making them available to coin + /// selection again — and persists the change. Outpoints that are not locked are left alone. + pub(crate) async fn unlock_outpoints(&self, outpoints: &[OutPoint]) -> Result<(), Error> { + if outpoints.is_empty() { + return Ok(()); + } + let mut locked_persister = self.persister.lock().await; + let change_set = { + let mut locked_wallet = self.inner.lock().expect("lock"); + for outpoint in outpoints { + locked_wallet.unlock_outpoint(*outpoint); + } + locked_wallet.take_staged().unwrap_or_default() + }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + }) + } + pub(crate) fn get_balances( &self, total_anchor_channels_reserve_sats: u64, ) -> Result<(u64, u64), Error> { @@ -2612,6 +2749,86 @@ impl Wallet { Ok(tx) } + /// Returns the `PaymentId` of the user-initiated splice intent the round `candidate` belongs + /// to, if any, so the first recorded round of a splice adopts the id chosen at splice time + /// rather than a fresh one. Only a history no record tracks yet gets here + /// ([`Self::resolve_interactive_funding_id`]), so only the channel's bare intent records — + /// those of its splices with no round on record — can be the round's: a tracked record already + /// has its rounds, and a channel carries one record per splice in flight. Among the bare + /// records, the round's is the one whose intent carries the round's contribution + /// ([`is_same_splice`]; LDK may adjust a contribution's fee fields, not its inputs or outputs) + /// or, when none does, the channel's only bare record. With several bare records and no + /// match, the round is not filed under any of them and gets a fresh id. + async fn find_splice_payment_id(&self, candidate: &FundingCandidate) -> Option { + let channel_of = |intent: &SpliceIntent| { + candidate.channels.iter().find(|channel| { + channel.channel_id == intent.channel_id + && channel.counterparty_node_id == intent.counterparty_node_id + }) + }; + let bare = self + .payment_stores + .pending_payments(|p| { + p.details().is_none() + && p.candidates().is_empty() + && p.splice_intent().is_some_and(|i| channel_of(i).is_some()) + }) + .await; + let carries_contribution = |p: &&PendingPaymentDetails| { + p.splice_intent().is_some_and(|intent| { + channel_of(intent) + .and_then(|channel| channel.contribution.as_ref()) + .is_some_and(|contribution| is_same_splice(contribution, &intent.contribution)) + }) + }; + match (bare.iter().find(carries_contribution), bare.as_slice()) { + (Some(record), _) => Some(record.id()), + (None, [only]) => Some(only.id()), + (None, _) => None, + } + } + + /// Resolves the id under which the `active` round of the interactive funding with negotiated + /// history `candidates` is recorded. A round already on record keeps its record: the id of the + /// first round of the history any record tracks is adopted (wallet sync may record a round + /// before this node does), so a replacement, a replayed signing and a sync-created record + /// converge on one record. A record already failed is passed over: wallet sync fails a payment + /// whose round lost to a conflicting spend confirmed while the channel stays open, LDK still + /// tracks the round and a fee bump of it is signed with the round among its candidates, and a + /// failed record's status is never revisited, so the bump filed under it would go untracked. + /// Only a history no live record tracks falls back to the channel's splice intents: a + /// user-initiated splice adopts the `PaymentId` generated when it was initiated, so its intent, + /// funding payment and candidate history share one record. A channel carries one intent per + /// splice in flight, and only a bare one — of a splice with no round on record — can be a first + /// round's ([`Self::find_splice_payment_id`]), which is why the intents must not decide the id + /// of a round already on record: a fee bump this node signs of a round wallet sync recorded + /// first must converge on the record sync created, not be filed under the bump's intent as a + /// second record. Otherwise a fresh id is generated — an id derived from a txid would tie the + /// record's identity to one round of a replaceable transaction, and resolution through the + /// record's txid history is what keeps its identity stable across RBF replacements. The caller + /// holds the cross-store lock: resolved outside it, the id could go stale against a record + /// wallet sync creates for the same transaction before the caller's write. + async fn resolve_interactive_funding_id( + &self, stores: &PaymentStoresGuard<'_>, candidates: &[FundingCandidate], + active: &FundingCandidate, + ) -> Result { + for candidate in candidates.iter() { + if let Some(id) = self.find_payment_by_txid(candidate.txid).await? { + let failed = stores + .payment(&id) + .await? + .is_some_and(|payment| payment.status == PaymentStatus::Failed); + if !failed { + return Ok(id); + } + } + } + if let Some(id) = self.find_splice_payment_id(active).await { + return Ok(id); + } + Ok(random_payment_id()) + } + /// Builds this node's share of the `active` round of an interactive funding whose negotiated /// history is `candidates`, and the per-candidate figures of that history, for recording the /// round under `payment_id`. Returns `None` when there is nothing to record: no local @@ -2692,8 +2909,9 @@ impl Wallet { /// transaction, and resolves its identity through the fact. /// /// `candidates` is the channel's pending splice history as [`funding_candidates`] lists it from - /// the channel's [`SpliceDetails`], so the history is written in full, under the first - /// candidate's txid as id. + /// the channel's [`SpliceDetails`], so the history is written in full, under the id + /// [`Self::resolve_interactive_funding_id`] resolves (that of a record already tracking any + /// round of the history, else the channel's splice intent, else a fresh one). /// /// Nothing is recorded for a round missing from the history (reset between the event's /// emission and its handling, so LDK will refuse the signed transaction), already recorded (a @@ -2702,10 +2920,15 @@ impl Wallet { /// the candidate is live in the channel's splice details, and both writes are idempotent, so /// the replay completes whichever of them was lost. /// + /// The round's facts are admitted however many records the store contains, so that a store full + /// of other transactions cannot leave this node signing a round it has no measure of. A refusal + /// that a replay would only meet again reports the round [`SignedFundingRecord::Unmeasurable`], + /// without writing anything, for the caller to cancel the round rather than sign it. + /// /// [`ChannelManager::funding_transaction_signed`]: lightning::ln::channelmanager::ChannelManager::funding_transaction_signed pub(crate) async fn record_signed_funding( &self, tx: &Transaction, candidates: &[FundingCandidate], - ) -> Result<(), Error> { + ) -> Result { let txid = tx.compute_txid(); let signed_round = match candidates.iter().find(|candidate| candidate.txid == txid) { Some(round) => round, @@ -2715,7 +2938,7 @@ impl Wallet { "Not recording signed funding {}: not among the channel's pending splice rounds", txid, ); - return Ok(()); + return Ok(SignedFundingRecord::Recorded); }, }; let funding_channels: Vec = signed_round @@ -2727,9 +2950,9 @@ impl Wallet { }) .collect(); - // The reads and the writes below must share one lock acquisition, as in every - // funding-record write: read outside it, the record could change under us before the - // write. + // Resolution, the reads and the writes below must share one lock acquisition, as in every + // funding-record write: done outside it, the id could go stale against a record wallet + // sync creates for the same transaction before the write. let stores = self.payment_stores.lock().await; // The facts of a round already on record identify its payment and this node's share of // it. Those facts are immutable, so a replay adopts them rather than deriving figures @@ -2737,18 +2960,17 @@ impl Wallet { // would be refused rather than recorded, leaving the event replaying forever. let recorded_figures = self.channel_tx_facts(&txid).await.and_then(|facts| facts.local_figures); - // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable - // across RBF replacements. let payment_id = match &recorded_figures { Some(figures) => figures.funding_payment_id, - None => PaymentId(candidates.first().map_or(txid, |first| first.txid).to_byte_array()), + None => self.resolve_interactive_funding_id(&stores, candidates, signed_round).await?, }; let (figures, mut history) = match self.interactive_funding_figures(payment_id, candidates, signed_round, tx) { Some(record) => record, - None => return Ok(()), + None => return Ok(SignedFundingRecord::Recorded), }; let figures = recorded_figures.unwrap_or(figures); + let direction = figures.direction; // Only the signed round awaits broadcast: LDK broadcast the others once their signatures // were exchanged. if let Some(signed) = history.iter_mut().find(|candidate| candidate.txid == txid) { @@ -2758,7 +2980,7 @@ impl Wallet { let prior_pending = stores.pending_payment(&payment_id).await?; // A replayed signing event re-offers a transaction already recorded; nothing to add. if prior_pending.as_ref().is_some_and(|entry| entry.candidate(txid).is_some()) { - return Ok(()); + return Ok(SignedFundingRecord::Recorded); } // Merge LDK's history into the recorded one — refreshing the rounds both list, appending // the new ones — rather than replace it: LDK's history omits a recorded round it has since @@ -2782,20 +3004,60 @@ impl Wallet { // The fact goes first: it is what ties the transaction to this payment, so a failure // afterwards leaves the round attributable rather than a history pointing at a payment // the transaction would never be filed under. - self.record_channel_tx_facts( - ChannelTxFacts::new(txid) - .with_self_role(TransactionType::InteractiveFunding { - channels: funding_channels.clone(), - }) - .with_local_figures(figures), - ) - .await?; + // + // The round is admitted whatever the store's count: this node decides whether to sign it, + // and a record describing a round and this node's share of it carries no outputs, so it is + // among the smallest the store contains. What can still refuse it is a record already as + // large as one record may be, which never shrinks — so the round is reported unmeasurable + // for the caller to cancel, rather than released with the wallet's view of a funding output + // both parties own standing in for this node's share. + let facts = ChannelTxFacts::new(txid) + .with_self_role(TransactionType::InteractiveFunding { + channels: funding_channels.clone(), + }) + .with_local_figures(figures); + let outcome = self.record_channel_tx_facts_admitted(facts, FactsAdmission::Exempt).await?; + if outcome == FactsRecordOutcome::Incomplete { + log_error!( + self.logger, + "Not signing interactive funding {}: this node's share of it is not on record", + txid, + ); + return Ok(SignedFundingRecord::Unmeasurable); + } + // The rounds this node contributed nothing to are candidates of this payment all the same, + // and one of them may be the round that confirms. Their facts carry the payment's id as + // the signed round's do, with no share of their own: the pending entry written below lists + // them, but graduation removes it, and the facts are then all that ties such a round to the + // payment (`find_payment_by_txid`). A round whose facts already carry a payment keeps it — + // the facts are immutable — so only rounds attributed to nothing yet are written. + for candidate in candidates.iter().filter(|candidate| candidate.txid != txid) { + if aggregate_local_stakes(candidate).amount_msat.is_some() { + continue; + } + let attributed = self + .channel_tx_facts(&candidate.txid) + .await + .is_some_and(|facts| facts.local_figures.is_some()); + if attributed { + continue; + } + self.record_channel_tx_facts(ChannelTxFacts::new(candidate.txid).with_local_figures( + LocalFundingFigures { + funding_payment_id: payment_id, + amount_msat: None, + fee_paid_msat: None, + direction, + }, + )) + .await?; + } stores .mutate_pending_payment(&payment_id, |existing| { let mut changed = existing.is_none(); let mut entry = existing.cloned().unwrap_or_else(|| { - PendingPaymentDetails::signed_rounds(payment_id, Vec::new(), Vec::new()) + PendingPaymentDetails::signed_rounds(payment_id, Vec::new(), Vec::new(), None) }); if entry.funding_channels.is_empty() && !funding_channels.is_empty() { entry.funding_channels = funding_channels.clone(); @@ -2814,15 +3076,32 @@ impl Wallet { txid, candidates.len(), ); - Ok(()) + + // The history is complete; merging the duplicates wallet sync created for earlier rounds + // is a courtesy. The signed round can have no duplicate yet, as our signatures have not + // left the node, and the round's `SpliceNegotiated` event re-runs the merge, replaying on + // failure, so a failure here is logged rather than replaying the signing. + if let Err(e) = self.merge_duplicate_candidate_records(&stores, payment_id, &recorded).await + { + log_error!( + self.logger, + "Failed to merge duplicate records into funding payment {}: {}", + payment_id, + e, + ); + } + Ok(SignedFundingRecord::Recorded) } /// Marks a splice round recorded when signing ([`Self::record_signed_funding`]) as broadcast /// once LDK reports the splice negotiated: `SpliceNegotiated` is emitted only once our /// `tx_signatures` for the round are ready to send, so the counterparty may have them by then - /// and may broadcast the round, which is therefore no longer dropped as abandoned. Nothing is - /// written for a round no funding payment of `channel_id` tracks (no local contribution, or no - /// wallet-level activity) or one already marked (a replayed event). + /// and may broadcast the round, which is therefore no longer dropped as abandoned. Then merges + /// the duplicate records wallet sync created for the record's candidates + /// ([`Self::merge_duplicate_candidate_records`]), completing a merge the signing left + /// unfinished. Nothing is written for a round no funding payment of `channel_id` tracks (no + /// local contribution, or no wallet-level activity); a replayed event finds the round marked + /// already and only re-runs the merge. pub(crate) async fn record_broadcast_splice_round( &self, channel_id: ChannelId, txid: Txid, ) -> Result<(), Error> { @@ -2832,13 +3111,12 @@ impl Wallet { let entries = stores .pending_payments(|entry| { - tracks_channel(entry, channel_id) - && entry.candidate(txid).is_some_and(|candidate| candidate.awaiting_broadcast) + tracks_channel(entry, channel_id) && entry.candidate(txid).is_some() }) .await; for entry in entries { let payment_id = entry.id(); - stores + let marked = stores .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); let round = entry @@ -2849,13 +3127,19 @@ impl Wallet { Some(entry) }) .await?; - log_debug!( - self.logger, - "Marked splice round {} of channel {} as broadcast in funding payment {}", - txid, - channel_id, - payment_id, - ); + if marked.is_some() { + log_debug!( + self.logger, + "Marked splice round {} of channel {} as broadcast in funding payment {}", + txid, + channel_id, + payment_id, + ); + } + // The round's record is complete, so the duplicates wallet sync created for earlier + // rounds can be folded in. A failure replays the event, which re-runs the merge + // idempotently. + self.merge_duplicate_candidate_records(&stores, payment_id, entry.candidates()).await?; } Ok(()) } @@ -2883,9 +3167,11 @@ impl Wallet { /// Dropping the record's current round hands the record back to the last remaining round this /// node contributed to, figures included; dropping the last such round removes the record, as /// whatever rounds remain are not this node's payment (LDK keeps this node's contributions to a - /// suffix of the rounds). A record that no longer waits on the dropped round — wallet sync - /// moved it on, or an earlier drop was cut short after moving it — keeps its state and only - /// loses the round from its history. + /// suffix of the rounds), while a splice intent the record carried stays behind as a bare + /// intent, for the failure LDK reports to be described from and for its settlement to remove. A + /// record that no longer waits on the dropped round — wallet sync moved it on, or an earlier + /// drop was cut short after moving it — keeps its state and only loses the round from its + /// history. pub(crate) async fn drop_abandoned_splice_rounds( &self, channel_id: ChannelId, tracked_rounds: &[Txid], ) -> Result<(), Error> { @@ -2943,12 +3229,12 @@ impl Wallet { // one. let handed_back = remaining.last().filter(|round| round.amount_msat.is_some()); - // An entry with no payment record yet — nothing has observed a transaction of this - // splice — has no record to hand back or remove: only its history shrinks, and with - // the last round of ours it loses the rest of the history too. An entry left tracking - // nothing goes. + // An entry with no payment record yet — wallet sync has not observed a transaction of + // this splice — has no record to hand back or remove: only its history shrinks, and + // with the last round of ours it loses the rest of the history too, leaving a splice + // intent it carries behind as a bare intent for the failure LDK reports to be described + // from. An entry left with nothing to track is removed. if entry.details().is_none() { - let mut emptied = false; stores .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); @@ -2959,13 +3245,10 @@ impl Wallet { entry.locked_rounds.clear(); entry.funding_channels.clear(); } - emptied = entry.is_empty(); - (!emptied).then_some(entry) + Some(entry) }) .await?; - if emptied { - stores.remove_pending_payment(&payment_id).await?; - } + stores.remove_pending_payment_if(&payment_id, |entry| entry.is_empty()).await?; log_debug!( self.logger, "Dropped abandoned splice round(s) {:?} of unobserved funding payment {}", @@ -3003,26 +3286,73 @@ impl Wallet { .await?; }, None => { - // A removal has no critical section to decide in, so the record is read first. - let record = stores.payment(&payment_id).await?; - if record.as_ref().map_or(true, waits_on_abandoned) { + // Whether the record still waits on the dropped rounds is decided inside the + // removal's own critical section, from the record found there. A record already + // gone, its removal cut short between the two stores, takes the removal path + // too, for the entry to follow it. + stores + .remove_payment_if(&payment_id, |current| { + if waits_on_abandoned(current) { + return true; + } + history_only = true; + mirrored = Some(current.clone()) + .filter(|current| current.status == PaymentStatus::Pending); + false + }) + .await?; + if !history_only { // Nothing of this node's was ever broadcast under the record, so it goes // rather than fail a payment for a transaction that never existed. The - // payment record goes first: the entry keeps resolving the rounds' txids, - // so a removal that fails midway is finished by the replayed event. + // payment record went first: the entry keeps resolving the rounds' txids, + // so a removal that fails midway is finished by the replayed event. A + // splice intent the entry carries outlives the record as a bare intent: + // the failure LDK reports for the round is described from it, and its + // settlement removes it (`SpliceTracker::on_negotiation_failed`); one left + // behind by a node that stopped in between is found by + // `SpliceTracker::reconcile` at the next startup, which settles it once LDK + // tracks no round of ours, or by whatever next concerns the channel's + // splice. The intent is read from the entry as it stands, not as listed + // above: a fee bump submitted since may have replaced it, and that intent + // must stay just the same. stores.remove_payment(&payment_id).await?; - stores.remove_pending_payment(&payment_id).await?; - log_debug!( - self.logger, - "Dropped abandoned splice round(s) {:?} and removed funding payment {}: nothing of ours \ - was broadcast under it", - abandoned_txids, - payment_id, - ); + let kept_intent = stores + .mutate_pending_payment(&payment_id, |existing| match existing { + Some(entry) => entry.splice_intent().map(|intent| { + PendingPaymentDetails::pending_splice( + payment_id, + intent.clone(), + ) + }), + None => None, + }) + .await? + .is_some(); + stores + .remove_pending_payment_if(&payment_id, |entry| { + entry.splice_intent().is_none() + }) + .await?; + if kept_intent { + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} and removed funding payment {}: this node did not \ + broadcast anything of its own under it; the splice's intent stays until its failure is \ + surfaced", + abandoned_txids, + payment_id, + ); + } else { + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} and removed funding payment {}: this node did not \ + broadcast anything of its own under it", + abandoned_txids, + payment_id, + ); + } continue; } - history_only = true; - mirrored = record.filter(|current| current.status == PaymentStatus::Pending); }, } if history_only { @@ -3099,10 +3429,69 @@ impl Wallet { Ok(()) } + /// Removes the payment record under `payment_id` — the id of a bare splice intent whose splice + /// settled — when it is the first half of a funding-record write that never completed. + /// + /// Wallet sync files a funding payment under the id the round's recorded facts point to, which + /// for a user-initiated splice is the id its intent was created with, and indexes it in the + /// pending store in the same write. A record found under a bare intent therefore lost that + /// index to a write that failed in between, and the pending store will never drive it: it + /// neither graduates nor maps its transaction back to itself. A record an entry does index + /// stays, as does one that is not a pending, unconfirmed interactive funding — a record that + /// confirmed or succeeded was driven to that state and is a payment of its own. The caller + /// removes the bare entry afterwards. + pub(crate) async fn drop_unindexed_record_of_settled_intent( + &self, payment_id: PaymentId, + ) -> Result<(), Error> { + // Serialize with the funding-record writers, so that the check and the removal cannot + // interleave with a write completing the record. + let stores = self.payment_stores.lock().await; + let indexed = stores + .pending_payment(&payment_id) + .await? + .is_some_and(|entry| entry.details().is_some()); + if indexed { + log_debug!( + self.logger, + "Keeping the funding record of payment {}: its pending entry indexes it", + payment_id, + ); + return Ok(()); + } + let half_written = + stores.payment(&payment_id).await?.and_then(|record| match &record.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } if record.status == PaymentStatus::Pending => Some(*txid), + _ => None, + }); + match half_written { + Some(recorded) => { + stores.remove_payment(&payment_id).await?; + log_info!( + self.logger, + "Dropped the half-written funding record of splice round {}", + recorded, + ); + }, + None => { + log_debug!( + self.logger, + "No half-written funding record to drop under payment {}", + payment_id, + ); + }, + } + Ok(()) + } + /// Records a funding payment the way the node does: the rounds this node signed supply the - /// candidate history, and wallet sync writes the payment record and its pending-store entry - /// once it observes the transaction. Composes that sequence for tests that need a recorded - /// funding payment to act on. + /// candidate history, wallet sync writes the payment record and its pending-store entry once + /// it observes the transaction, and the duplicates sync created for those rounds are merged + /// into the record. Composes that sequence for tests that need a recorded funding payment to + /// act on. #[cfg(test)] async fn record_funding_payment( &self, details: PaymentDetails, candidates: Vec, @@ -3113,7 +3502,7 @@ impl Wallet { stores .mutate_pending_payment(&id, |existing| { let mut entry = existing.cloned().unwrap_or_else(|| { - PendingPaymentDetails::signed_rounds(id, Vec::new(), Vec::new()) + PendingPaymentDetails::signed_rounds(id, Vec::new(), Vec::new(), None) }); entry.candidates = candidates.clone(); Some(entry) @@ -3121,7 +3510,72 @@ impl Wallet { .await?; } stores.insert_or_update_payment(details.clone()).await?; - self.upsert_pending_payment(&stores, details, Vec::new()).await + self.upsert_pending_payment(&stores, details, Vec::new()).await?; + self.merge_duplicate_candidate_records(&stores, id, &candidates).await + } + + /// Merges duplicate records wallet sync created for this funding payment's candidates before + /// they were recorded as such. Sync re-keys an event for a round it cannot attribute to the + /// funding record — not yet a candidate, so the funding-status gate reports it foreign — to + /// the round's txid-derived id, creating an untyped duplicate whose pending entry then + /// shadows the funding record in [`Self::find_payment_by_txid`]'s direct probe. Once the + /// round is a recorded candidate, the duplicate's confirmation (if any) belongs on the + /// funding record: adopt it, then remove the duplicate and its pending entry. + /// + /// Runs once a record's candidate history is written, so the funding-status gate accepts the + /// candidates it adopts, and under the writer's lock acquisition, so sync cannot interleave. + /// It is idempotent: a failure at signing time ([`Self::record_signed_funding`]) is left to the + /// signed round's `SpliceNegotiated` event ([`Self::record_broadcast_splice_round`]), which + /// re-runs the merge and replays on failure. The caller must hold the [`PaymentStores`] lock, + /// per [`Self::apply_funding_status_update_locked`]'s contract. + async fn merge_duplicate_candidate_records( + &self, stores: &PaymentStoresGuard<'_>, id: PaymentId, candidates: &[FundingTxCandidate], + ) -> Result<(), Error> { + for candidate in candidates { + let duplicate_id = PaymentId(candidate.txid.to_byte_array()); + if duplicate_id == id { + continue; + } + let duplicate = match stores.payment(&duplicate_id).await? { + Some(duplicate) => duplicate, + None => continue, + }; + // Only a duplicate view of this candidate's transaction qualifies: a record wallet sync + // created for the round before it was a candidate, left untyped or classified as a + // plain funding. Anything else keyed by the txid-derived id is left alone. + let status = match &duplicate.kind { + PaymentKind::Onchain { + txid, + status, + tx_type: None | Some(TransactionType::Funding { .. }), + } if *txid == candidate.txid => status.clone(), + _ => continue, + }; + // Only a confirmation is worth adopting; an unconfirmed duplicate carries nothing the + // record needs — the actively-broadcast candidate stays the record's current txid. + if matches!(status, ConfirmationStatus::Confirmed { .. }) { + let outcome = self + .apply_funding_status_update_locked(stores, id, candidate.txid, status) + .await?; + debug_assert!(matches!(outcome, FundingStatusUpdate::Applied)); + if !matches!(outcome, FundingStatusUpdate::Applied) { + // Adoption declined; keep the duplicate rather than discard its confirmation. + continue; + } + } + log_debug!( + self.logger, + "Merging duplicate payment record for funding transaction {}", + candidate.txid, + ); + // Pending entry first: the retry of a failure between these two removals rediscovers + // the duplicate through its payment record. Removed the other way around, the + // leftover pending entry would be unreachable to the retry yet keep shadowing the + // funding record in `find_payment_by_txid`'s direct probe. + stores.remove_pending_payment(&duplicate_id).await?; + stores.remove_payment(&duplicate_id).await?; + } + Ok(()) } /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. @@ -3194,25 +3648,66 @@ impl Wallet { conflicting_txids: Vec, ) -> Result<(), Error> { let id = payment.id; + let mut leftover_intent_to_remove = None; + // The `move` closure would capture the `Option` by value, so hand it a reference; the + // borrow ends with the mutate's future, before the leftover is read below. + let leftover = &mut leftover_intent_to_remove; stores - .mutate_pending_payment(&id, |existing| match existing { - None => Some(PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())), - // Promote an entry that has no record yet: wallet sync saw the splice - // transaction before this node recorded a payment for it. The entry keeps the - // rounds signed under it, and gains the record. - Some(entry) if entry.details().is_none() => { - let mut entry = entry.clone(); - entry.details = Some(payment); - entry.conflicting_txids = conflicting_txids; - Some(entry) - }, - Some(tracked) => { - let mut tracked = tracked.clone(); - let fresh = PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()); - tracked.update(fresh.to_update()).then_some(tracked) - }, + .mutate_pending_payment_async(&id, move |existing| async move { + // Only `Pending` payments belong in the pending store. The authoritative + // status is re-read inside the store's critical section, where it cannot go + // stale against graduation. + let is_pending = stores + .payment(&id) + .await? + .map_or(payment.status == PaymentStatus::Pending, |recorded| { + recorded.status == PaymentStatus::Pending + }); + if !is_pending { + // A bare splice intent under an advanced payment's id is the leftover of the + // splice that payment settles. Taking it back is left to the removal below, + // so that it happens under a check of what the entry still is; leaving it + // would have the next restart act on a splice that is long over. + if let Some(entry) = existing { + if entry.details().is_none() { + *leftover = entry.splice_intent; + } + } + return Ok(None); + } + Ok(match existing { + None => { + Some(PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())) + }, + // Promote an entry that has no record yet: wallet sync saw the splice + // transaction before this node recorded a payment for it. The entry keeps + // the splice intent and the rounds signed under it, and gains the record. + Some(mut entry) if entry.details().is_none() => { + entry.details = Some(payment); + entry.conflicting_txids = conflicting_txids; + Some(entry) + }, + Some(mut tracked) => { + let fresh = + PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()); + tracked.update(fresh.to_update()).then_some(tracked) + }, + }) }) .await?; + + if let Some(intent) = leftover_intent_to_remove { + // Only while the entry still is the bare intent the closure saw: a round signed or a + // fee bump submitted in between joins the entry, and what those track must stay. + stores + .remove_pending_payment_if(&id, |entry| { + entry.details().is_none() + && entry.candidates().is_empty() + && entry.locked_rounds().is_empty() + && entry.splice_intent() == Some(&intent) + }) + .await?; + } Ok(()) } @@ -3236,11 +3731,15 @@ impl Wallet { /// The payment the transaction `target_txid` belongs to, as far as anything on record says. /// - /// A transaction this node signed a round of an interactive funding for identifies its - /// payment outright, in the facts the signing recorded about it; that is the only answer that - /// holds before the payment record exists. Otherwise the pending store is asked, by the - /// record's own transaction, by its candidate history and by the conflicts wallet sync listed - /// for it. + /// A round of an interactive funding identifies its payment outright, in the facts the + /// signing recorded about it — about the round it signed and about every other candidate of + /// the history, the rounds this node contributed nothing to included. That is the only answer + /// that holds before the payment record exists, and the only one left once the record + /// graduated out of the pending store. Otherwise the pending store is asked, by the record's + /// own transaction, by its candidate history and by the conflicts wallet sync listed for it. + /// The payment store itself is never listed: it is bounded, and a record wallet sync created + /// under a transaction's own id is reached through that id, which the wallet-sync arms derive + /// from the transaction when nothing resolves it. async fn find_payment_by_txid(&self, target_txid: Txid) -> Result, Error> { if let Some(figures) = self.channel_tx_facts(&target_txid).await.and_then(|facts| facts.local_figures) @@ -3258,8 +3757,9 @@ impl Wallet { |d| matches!(d.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), ) // A middle RBF round is not the record's current txid and may never have - // received a `TxReplaced` event of its own, so map any of its candidate - // txids (an earlier RBF round may confirm) back to the record. + // received a `TxReplaced` event of its own, and a splice keyed by a generated + // PaymentId is not found by the txid-derived id above: map any of the + // candidate txids (an earlier RBF round may confirm) back to the record. || p.candidate(target_txid).is_some() }; let matches = self @@ -3829,15 +4329,25 @@ enum FundingResolution { /// The outcome of [`Wallet::fail_unconfirmed_funding_payment_locked`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FundingPaymentFailure { - /// The payment was failed and its pending entry removed. + /// The payment was failed and its pending entry removed, a splice intent it carried kept as a + /// bare intent of its own. Failed, /// The payment was failed already — by a pass whose entry removal was lost to a crash — and - /// only the lingering entry was removed. + /// only the lingering entry was removed, its intent kept likewise. EntryRemoved, /// The record no longer waits on the transaction, and the pass left it unchanged. MovedOn, } +/// Generates a fresh funding-record [`PaymentId`] from the OS entropy source. A funding record's id +/// carries no meaning beyond uniqueness: the record is found through its transaction history +/// ([`Wallet::find_payment_by_txid`]), never re-derived from a txid. +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 @@ -4188,7 +4698,9 @@ mod tests { use crate::config::ElectrumSyncConfig; #[cfg(feature = "chain-esplora")] use crate::config::EsploraSyncConfig; - use crate::config::{CHANNEL_TX_FACTS_CACHE_CAPACITY, PAYMENT_CACHE_CAPACITY}; + use crate::config::{ + CHANNEL_TX_FACTS_CACHE_CAPACITY, CHANNEL_TX_FACTS_MAX_RECORD_BYTES, PAYMENT_CACHE_CAPACITY, + }; use crate::io::test_utils::InMemoryStore; use crate::io::{ BDK_WALLET_ADDRESS_POOL_KEY, BDK_WALLET_ADDRESS_POOL_PRIMARY_NAMESPACE, @@ -4201,7 +4713,9 @@ mod tests { }; use crate::payment::pending_payment_store::{ test_funding_contribution_with_outputs, test_funding_contribution_with_parts, + PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind, }; + use crate::types::{DynStore, DynStoreWrapper, UserChannelId}; use crate::wallet::provenance::{ live_channels_of, ChannelOutput, ChannelOutputFact, ChannelOutputRole, LocalFundingFigures, @@ -4374,57 +4888,137 @@ mod tests { } } - /// 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 { - let logger = Arc::new(Logger::new_log_facade()); - let mut config = Config::default(); - config.network = Network::Regtest; - let config = Arc::new(config); + /// 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>>, + } - let mut wallet_persister = - KVStoreWalletPersister::new(Arc::clone(&store), Arc::clone(&logger)); - #[allow(deprecated)] - let bdk_wallet = if load_existing { - BdkWallet::load() - .descriptor(KeychainKind::External, Some(EXTERNAL_DESCRIPTOR)) - .descriptor(KeychainKind::Internal, Some(INTERNAL_DESCRIPTOR)) - .extract_keys() - .check_network(Network::Regtest) - .load_wallet_async(&mut wallet_persister) - .await - .unwrap() - .unwrap() - } else { - BdkWallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR) - .network(Network::Regtest) - .create_wallet_async(&mut wallet_persister) - .await - .unwrap() - }; + impl FailRemoveStore { + fn new() -> Self { + Self { + inner: Arc::new(InMemoryStore::new()), + fail_remove_in: Arc::new(std::sync::Mutex::new(None)), + } + } - let fee_estimator = Arc::new(OnchainFeeEstimator::new()); - let broadcaster = Arc::new(Broadcaster::new(Arc::clone(&logger))); - let node_metrics = Arc::new(PersistedNodeMetrics::new(NodeMetrics::default())); - #[cfg(feature = "chain-esplora")] - let (chain_source, _) = ChainSource::new_esplora( - "http://localhost:1".to_string(), - HashMap::new(), - EsploraSyncConfig::default(), - Arc::clone(&fee_estimator), - Arc::clone(&broadcaster), - Arc::clone(&store), - Arc::clone(&config), - Arc::clone(&logger), - node_metrics, - ) - .unwrap(); - #[cfg(all(not(feature = "chain-esplora"), feature = "chain-electrum"))] - let (chain_source, _) = ChainSource::new_electrum( - "tcp://localhost:1".to_string(), - ElectrumSyncConfig::default(), - Arc::clone(&fee_estimator), - Arc::clone(&broadcaster), + 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 { + let logger = Arc::new(Logger::new_log_facade()); + let mut config = Config::default(); + config.network = Network::Regtest; + let config = Arc::new(config); + + let mut wallet_persister = + KVStoreWalletPersister::new(Arc::clone(&store), Arc::clone(&logger)); + #[allow(deprecated)] + let bdk_wallet = if load_existing { + BdkWallet::load() + .descriptor(KeychainKind::External, Some(EXTERNAL_DESCRIPTOR)) + .descriptor(KeychainKind::Internal, Some(INTERNAL_DESCRIPTOR)) + .extract_keys() + .check_network(Network::Regtest) + .load_wallet_async(&mut wallet_persister) + .await + .unwrap() + .unwrap() + } else { + BdkWallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR) + .network(Network::Regtest) + .create_wallet_async(&mut wallet_persister) + .await + .unwrap() + }; + + let fee_estimator = Arc::new(OnchainFeeEstimator::new()); + let broadcaster = Arc::new(Broadcaster::new(Arc::clone(&logger))); + let node_metrics = Arc::new(PersistedNodeMetrics::new(NodeMetrics::default())); + #[cfg(feature = "chain-esplora")] + let (chain_source, _) = ChainSource::new_esplora( + "http://localhost:1".to_string(), + HashMap::new(), + EsploraSyncConfig::default(), + Arc::clone(&fee_estimator), + Arc::clone(&broadcaster), + Arc::clone(&store), + Arc::clone(&config), + Arc::clone(&logger), + node_metrics, + ) + .unwrap(); + #[cfg(all(not(feature = "chain-esplora"), feature = "chain-electrum"))] + let (chain_source, _) = ChainSource::new_electrum( + "tcp://localhost:1".to_string(), + ElectrumSyncConfig::default(), + Arc::clone(&fee_estimator), + Arc::clone(&broadcaster), Arc::clone(&store), Arc::clone(&config), Arc::clone(&logger), @@ -4478,6 +5072,24 @@ mod tests { wallet.address_pool.lock().unwrap().available.iter().map(|(index, _)| *index).collect() } + fn test_splice_intent() -> crate::payment::pending_payment_store::SpliceIntent { + use crate::payment::pending_payment_store::{SpliceIntent, SpliceKind}; + + SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([13u8; 32]), + pre_splice_funding_txo: lightning::chain::transaction::OutPoint { + txid: Txid::from_byte_array([3u8; 32]), + index: 0, + }, + contribution: crate::payment::pending_payment_store::test_funding_contribution(), + kind: SpliceKind::In { amount_sats: 10_000 }, + } + } + fn funding_payment(id: PaymentId, txid: Txid, status: PaymentStatus) -> PaymentDetails { PaymentDetails::new( id, @@ -4493,6 +5105,44 @@ mod tests { ) } + #[tokio::test] + async fn recording_a_round_promotes_a_pre_broadcast_intent_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([21u8; 32]); + let txid = Txid::from_byte_array([22u8; 32]); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, test_splice_intent())) + .await + .unwrap(); + + let candidates = vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + wallet + .record_funding_payment(funding_payment(id, txid, PaymentStatus::Pending), candidates) + .await + .unwrap(); + + // The pre-broadcast record is promoted into the tracked funding payment, carrying its + // intent until the splice locks. + let record = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the record must be promoted"); + assert!(record.details().is_some()); + assert!(record.splice_intent().is_some()); + } + #[tokio::test] async fn refill_publishes_addresses_only_after_their_reveal_is_persisted() { let fail_store = FailSwitchStore::new(); @@ -5240,6 +5890,14 @@ mod tests { ) } + fn confirmed_status() -> ConfirmationStatus { + ConfirmationStatus::Confirmed { + block_hash: bitcoin::BlockHash::from_byte_array([8u8; 32]), + height: 100, + timestamp: 1, + } + } + /// 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) { @@ -5405,6 +6063,138 @@ mod tests { (tx, contribution) } + /// The intent of a user-initiated splice of `channel_id` with `counterparty_node_id`, anchored + /// at the channel's funding `pre_splice_funding` when the splice was submitted. + fn splice_intent_for( + counterparty_node_id: PublicKey, channel_id: ChannelId, pre_splice_funding: LdkOutPoint, + ) -> SpliceIntent { + SpliceIntent { + counterparty_node_id, + channel_id, + pre_splice_funding_txo: pre_splice_funding, + contribution: test_funding_contribution_with_outputs(300, 253, &[]), + kind: SpliceKind::Out { outputs: Vec::new() }, + } + } + + /// The pending entries carrying `intent` as a bare intent — of a splice with no round on + /// record. + async fn bare_entries_carrying( + wallet: &Wallet, intent: &SpliceIntent, + ) -> Vec { + wallet + .payment_stores + .pending_payment_store() + .list_filter(|p| p.details().is_none() && p.splice_intent() == Some(intent)) + .await + } + + /// A round signed under the channel's splice intent that has since locked with zero + /// confirmations — clearing its intent — with a second splice submitted against the locked + /// funding before the round's `SpliceNegotiated` event was handled: the channel's intent no + /// longer belongs to the recorded round. + struct LockedRoundWithNewerIntent { + first_id: PaymentId, + tx: Transaction, + candidates: Vec, + second_id: PaymentId, + second_intent: SpliceIntent, + } + + async fn lock_a_signed_round_and_submit_another_splice( + wallet: &Wallet, + ) -> LockedRoundWithNewerIntent { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let first_id = PaymentId([31u8; 32]); + let first_intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(first_id, first_intent)) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + // The round locks with zero confirmations, which clears its intent... + let cleared = PendingPaymentDetailsUpdate { + id: first_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + wallet.payment_stores.pending_payment_store().update(cleared).await.unwrap(); + // ...and a second splice of the channel is submitted against the new funding. + let second_id = PaymentId([32u8; 32]); + let second_intent = + splice_intent_for(counterparty_node_id, channel_id, LdkOutPoint { txid, index: 0 }); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(second_id, second_intent.clone())) + .await + .unwrap(); + + LockedRoundWithNewerIntent { first_id, tx, candidates, second_id, second_intent } + } + + /// A recorded round is marked broadcast in its own record once the channel carries the intent + /// of a newer splice: after a zero-conf lock, the user may submit a second splice before the + /// locked round's `SpliceNegotiated` event is handled, and the event must mark the round in + /// its own record without touching the new splice's intent. + #[tokio::test] + async fn negotiation_marks_a_recorded_round_broadcast_under_a_newer_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + let txid = setup.tx.compute_txid(); + + let channel_id = setup.candidates[0].channels[0].channel_id; + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&setup.first_id) + .await + .unwrap() + .expect("entry"); + assert!(!entry.candidate(txid).expect("candidate").awaiting_broadcast); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&setup.second_id).await.unwrap(), + Some(PendingPaymentDetails::pending_splice(setup.second_id, setup.second_intent)), + "the newer splice's intent must be left untouched" + ); + } + + /// The signing event of a recorded round, replayed once the channel carries the intent of a + /// newer splice, is a no-op: the round is on record, so the newer intent is not consulted. + #[tokio::test] + async fn a_replayed_signing_under_a_newer_intent_is_a_no_op() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + + fail_store.fail_writes.store(true, Ordering::Release); + wallet.record_signed_funding(&setup.tx, &setup.candidates).await.unwrap(); + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 0, + "a replayed signing must produce no new write" + ); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&setup.second_id).await.unwrap(), + Some(PendingPaymentDetails::pending_splice(setup.second_id, setup.second_intent)), + ); + } + /// Signing a round writes no payment record. It records what the round is, this node's share /// of it and the funding payment it belongs to, and leaves the record itself to whoever first /// observes the transaction. @@ -5460,15 +6250,14 @@ mod tests { ); } - /// A signing event replayed after its pending-store write was lost re-derives the round's - /// figures, from a contribution LDK may have adjusted the fee fields of since. The round's - /// facts are immutable, so the replay adopts what is on record instead of offering a second - /// answer the facts would refuse — which would leave the event replaying forever. + /// A round this node is about to sign is measured however full the store is. The number of + /// records the store admits bounds what a counterparty drives; a round this node chose to + /// sign is admitted beside them, because without this node's share on record whoever first + /// observes the transaction records it with the wallet's view of a funding output both + /// parties own — the whole of it read as this node's spend, a figure that is never corrected. #[tokio::test] - async fn a_replayed_signing_adopts_the_recorded_figures() { - let fail_store = - FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + async fn a_full_store_still_measures_a_round_this_node_signs() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); @@ -5476,33 +6265,178 @@ mod tests { let txid = tx.compute_txid(); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - fail_store.fail_writes.store(true, Ordering::Release); - assert!(wallet.record_signed_funding(&tx, &candidates).await.is_err()); - fail_store.fail_writes.store(false, Ordering::Release); - // LDK re-offers the event with the round's contribution carrying a different estimated - // fee, which would derive a different share of the same transaction. - let splice_out = tx.output.last().expect("the splice-out output").clone(); - let adjusted = test_funding_contribution_with_outputs(900, 253, &[splice_out]); - let adjusted_candidates = - splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(adjusted))]); - wallet.record_signed_funding(&tx, &adjusted_candidates).await.unwrap(); + // The store contains as many records as it may, so a transaction a capped producer reports + // on is refused room. + wallet.facts_retention.walk_completed(CHANNEL_TX_FACTS_MAX_RECORDS); + assert_eq!( + wallet + .record_channel_tx_facts(ChannelTxFacts::new(Txid::from_byte_array([77u8; 32]))) + .await + .unwrap(), + FactsRecordOutcome::Incomplete, + ); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - let figures = wallet - .channel_tx_facts(&txid) + assert_eq!( + wallet.record_signed_funding(&tx, &candidates).await.unwrap(), + SignedFundingRecord::Recorded, + "a round this node signs is measured however full the store is", + ); + let id = wallet + .find_payment_by_txid(txid) .await - .expect("facts") - .local_figures - .expect("this node\'s share"); - assert_eq!(figures.funding_payment_id, id); - assert_eq!(figures.fee_paid_msat, Some(300_000), "the recorded share stands"); - let entry = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); - assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); - } + .unwrap() + .expect("the round's facts identify its payment"); + assert!( + wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("entry") + .candidate(txid) + .is_some(), + "the signed round is in the channel's candidate history", + ); - /// The counterparty broadcasts the round it has our signatures for before this node has + // Our signatures leave the node and the counterparty broadcasts: wallet sync is the first + // to see the transaction, and files it under this node's share of the round rather than + // under the wallet's view of a funding output both parties own. + observe_unconfirmed(&wallet, &tx).await; + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + assert_eq!(payments[0].id, id); + assert_eq!(payments[0].amount_msat, Some(500_300_000)); + assert_eq!(payments[0].fee_paid_msat, Some(300_000)); + } + + /// A round whose facts are refused for a reason that is never undone is not signed, and is + /// reported unmeasurable so the caller cancels it. The record this round would merge into is + /// already as large as one record may be, which later reports cannot shrink, so replaying the + /// signing event would meet the same refusal forever while every event queued behind it waited. + #[tokio::test] + async fn a_round_this_node_cannot_measure_is_not_signed() { + use lightning::util::ser::Writeable; + + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + + // How large the transaction's record would be once the signing added what the round is + // and this node's share of it. + let signed_length = |facts: &ChannelTxFacts| { + facts + .clone() + .with_self_role(TransactionType::InteractiveFunding { + channels: vec![channel.clone()], + }) + .with_local_figures(LocalFundingFigures { + funding_payment_id: PaymentId([0u8; 32]), + amount_msat: Some(500_300_000), + fee_paid_msat: Some(300_000), + direction: PaymentDirection::Inbound, + }) + .serialized_length() + }; + // Grow the transaction's record to the largest one that may be stored, in coarse steps + // first and single outputs after so the search stays cheap. + let mut crowded = ChannelTxFacts::new(txid); + let mut vout = 0u32; + for step in [128u32, 1] { + loop { + let grown = crowded.clone().with_outputs( + &channel, + None, + ChannelOutputRole::Htlc, + vout..vout + step, + ); + if grown.serialized_length() > CHANNEL_TX_FACTS_MAX_RECORD_BYTES { + break; + } + crowded = grown; + vout += step; + } + } + assert!( + signed_length(&crowded) > CHANNEL_TX_FACTS_MAX_RECORD_BYTES, + "the record must leave no room for what the signing adds", + ); + assert_eq!( + wallet.record_channel_tx_facts(crowded).await.unwrap(), + FactsRecordOutcome::Recorded, + ); + + assert_eq!( + wallet.record_signed_funding(&tx, &candidates).await.unwrap(), + SignedFundingRecord::Unmeasurable, + "a round this node cannot measure must be cancelled rather than replayed", + ); + assert!( + wallet.channel_tx_facts(&txid).await.expect("the record stays").local_figures.is_none(), + "this node's share was not recorded, which is what the refusal means", + ); + assert!( + wallet + .payment_stores + .pending_payment_store() + .list_filter(|entry| entry.candidate(txid).is_some()) + .await + .is_empty(), + "a round the signing refused must not be left in a candidate history", + ); + } + + /// A signing event replayed after its pending-store write was lost re-derives the round's + /// figures, from a contribution LDK may have adjusted the fee fields of since. The round's + /// facts are immutable, so the replay adopts what is on record instead of offering a second + /// answer the facts would refuse — which would leave the event replaying forever. + #[tokio::test] + async fn a_replayed_signing_adopts_the_recorded_figures() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.record_signed_funding(&tx, &candidates).await.is_err()); + fail_store.fail_writes.store(false, Ordering::Release); + + // LDK re-offers the event with the round's contribution carrying a different estimated + // fee, which would derive a different share of the same transaction. + let splice_out = tx.output.last().expect("the splice-out output").clone(); + let adjusted = test_funding_contribution_with_outputs(900, 253, &[splice_out]); + let adjusted_candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(adjusted))]); + wallet.record_signed_funding(&tx, &adjusted_candidates).await.unwrap(); + + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let figures = wallet + .channel_tx_facts(&txid) + .await + .expect("facts") + .local_figures + .expect("this node\'s share"); + assert_eq!(figures.funding_payment_id, id); + assert_eq!(figures.fee_paid_msat, Some(300_000), "the recorded share stands"); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); + } + + /// The counterparty broadcasts the round it has our signatures for before this node has /// any payment record for it — the guarantee the signing-time recording exists for. Wallet /// sync must file the transaction under the funding payment the signing recorded, resolved /// through the round's recorded facts, instead of minting a second record under the @@ -5512,18 +6446,21 @@ mod tests { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - // A counterparty round precedes ours, so the payment's id is not the round's own txid. - let prior_txid = Txid::from_byte_array([0xAA; 32]); + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent)) + .await + .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); - let candidates = splice_candidates( - counterparty_node_id, - channel_id, - &[(prior_txid, None), (txid, Some(contribution))], - ); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(prior_txid.to_byte_array()); // Our signatures leave the node and the counterparty broadcasts: wallet sync is the first // to see the transaction. @@ -5647,453 +6584,465 @@ mod tests { ); } - /// Once LDK reports a round recorded at signing negotiated, there is nothing to add but the - /// broadcast itself: the round's awaiting-broadcast mark is cleared and the record left as - /// written. + /// A round this node contributed nothing to is a candidate of the payment all the same, and + /// may be the one that confirms. Once the record graduates, the entry that listed the round + /// as a candidate is gone, so the round's facts must identify the payment, as a signed + /// round's do. #[tokio::test] - async fn negotiation_of_a_signed_round_marks_it_broadcast() { + async fn a_graduated_records_counterparty_round_still_identifies_its_payment() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let prior_txid = Txid::from_byte_array([0xAA; 32]); - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + // The counterparty's round, then one of ours signed with it in the history. + let (counterparty_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let counterparty_txid = counterparty_tx.compute_txid(); + let (tx, contribution) = splice_out_round(&wallet, 2, 499_000, 700); let txid = tx.compute_txid(); let candidates = splice_candidates( counterparty_node_id, channel_id, - &[(prior_txid, None), (txid, Some(contribution))], + &[(counterparty_txid, None), (txid, Some(contribution))], ); sign_and_observe_round(&wallet, &tx, &candidates).await; - let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert!(record.candidate(txid).unwrap().awaiting_broadcast); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + // Graduation: the record settles and its pending entry, with the candidate history, goes. + let mut graduated = PaymentDetailsUpdate::new(id); + graduated.status = Some(PaymentStatus::Succeeded); + wallet.payment_stores.payment_store().update(graduated).await.unwrap(); + wallet.payment_stores.pending_payment_store().remove(&id).await.unwrap(); - assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(payment)); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!( - record.candidates().iter().map(|c| c.txid).collect::>(), - vec![prior_txid, txid] - ); - assert!(!record.candidate(txid).unwrap().awaiting_broadcast); - assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); + assert_eq!(wallet.find_payment_by_txid(counterparty_txid).await.unwrap(), Some(id)); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); } - /// A replayed `SpliceNegotiated` event refers to a round already marked broadcast; the store - /// is not written. + /// The counterparty's round is the one that confirms: the record adopts it as its transaction + /// before graduating, and the round still identifies the payment afterwards. #[tokio::test] - async fn marking_a_broadcast_round_again_writes_nothing() { - let fail_store = FailSwitchStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + async fn a_graduated_record_is_identified_by_the_counterparty_round_it_adopted() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let (counterparty_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let counterparty_txid = counterparty_tx.compute_txid(); + let (tx, contribution) = splice_out_round(&wallet, 2, 499_000, 700); let txid = tx.compute_txid(); - let candidates = - splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - - fail_store.fail_writes.store(true, Ordering::Release); - wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - assert_eq!( - fail_store.failed_writes.load(Ordering::Acquire), - 0, - "marking a round broadcast again must produce no new write" + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], ); - } + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - /// A round no funding payment tracks — this node contributed nothing to it, so signing never - /// recorded it — has no mark to clear, and the clearing writes nothing. - #[tokio::test] - async fn marking_an_unrecorded_round_broadcast_writes_nothing() { - let fail_store = FailSwitchStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (_counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let confirmation_height = 100; + insert_confirmed_tx(&wallet, counterparty_tx.clone(), confirmation_height); + let event = WalletEvent::TxConfirmed { + txid: counterparty_txid, + tx: Arc::new(counterparty_tx), + block_time: confirmed_block_time(confirmation_height), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } + if txid == counterparty_txid + )); + assert_eq!(payment.amount_msat, None); - fail_store.fail_writes.store(true, Ordering::Release); - let txid = Txid::from_byte_array([0xAA; 32]); - wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 0); - assert!(wallet - .payment_stores - .payment_store() - .list_page(None) - .await - .unwrap() - .objects - .is_empty()); + let mut graduated = PaymentDetailsUpdate::new(id); + graduated.status = Some(PaymentStatus::Succeeded); + wallet.payment_stores.payment_store().update(graduated).await.unwrap(); + wallet.payment_stores.pending_payment_store().remove(&id).await.unwrap(); + + assert_eq!(wallet.find_payment_by_txid(counterparty_txid).await.unwrap(), Some(id)); } - /// A replayed signing event re-offers a transaction already recorded, and the recording writes - /// nothing. + /// The first round of a user-initiated splice is on no record when it is signed, so it adopts + /// the id of the channel's splice intent: the bare intent entry becomes the round's record and + /// keeps carrying the intent. #[tokio::test] - async fn signing_a_recorded_round_again_writes_nothing() { - let fail_store = FailSwitchStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + async fn signing_a_first_round_adopts_the_intent_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) + .await + .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; - fail_store.fail_writes.store(true, Ordering::Release); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert_eq!( - fail_store.failed_writes.load(Ordering::Acquire), - 0, - "a replayed signing must produce no new write" - ); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + let txid_derived_id = PaymentId(txid.to_byte_array()); + assert!(wallet + .payment_stores + .payment_store() + .get(&txid_derived_id) + .await + .unwrap() + .is_none()); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); } - /// A signed round absent from the channel's pending splice history was reset between the - /// event's emission and its handling (the counterparty aborted): LDK will refuse the signed - /// transaction, so the round is not recorded — not even when the history contains another - /// round this node contributed to. + /// A fee bump of a round whose payment wallet sync failed — the round lost to a conflicting + /// spend confirmed while the channel stayed open — adopts the channel's splice intent rather + /// than the failed record: the failed round in its history does not decide the id, so the + /// bump is recorded under the intent's id, its entry carrying the intent. #[tokio::test] - async fn signing_skips_a_round_missing_from_the_splice_history() { + async fn signing_a_bump_of_a_failed_round_adopts_the_channels_intent() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - - let other_txid = Txid::from_byte_array([0xAA; 32]); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); let candidates = splice_candidates( counterparty_node_id, channel_id, - &[(other_txid, Some(contribution))], + &[(txid, Some(contribution.clone()))], ); - - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - wallet.record_signed_funding(&tx, &[]).await.unwrap(); - assert!(wallet + sign_and_observe_round(&wallet, &tx, &candidates).await; + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet .payment_stores .payment_store() - .list_page(None) + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) .await - .unwrap() - .objects - .is_empty()); - assert!(wallet - .payment_stores - .pending_payment_store() - .list_page(None) - .await - .unwrap() - .objects - .is_empty()); - } - - /// A round this node did not contribute to is not its payment: the signing-time recording - /// declines it. - #[tokio::test] - async fn signing_skips_a_round_without_a_local_contribution() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - - let (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); - let candidates = - splice_candidates(counterparty_node_id, channel_id, &[(tx.compute_txid(), None)]); + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert!(wallet - .payment_stores - .payment_store() - .list_page(None) - .await - .unwrap() - .objects - .is_empty()); - assert!(wallet + // The bump's intent, recorded at submission with no record left to join. + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let id = PaymentId([31u8; 32]); + let intent = SpliceIntent { + contribution: bump_contribution.clone(), + kind: SpliceKind::Rbf {}, + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + wallet .payment_stores .pending_payment_store() - .list_page(None) + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) .await - .unwrap() - .objects - .is_empty()); - } - - /// A splice-out to an external address moves no wallet funds; the signing-time recording - /// declines it — wallet sync cannot observe it either, so there is no race to close. - #[tokio::test] - async fn signing_skips_a_wallet_untouched_transaction() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - - let splice_out = - TxOut { value: Amount::from_sat(500_000), script_pubkey: ScriptBuf::new() }; - let contribution = - test_funding_contribution_with_outputs(300, 253, std::slice::from_ref(&splice_out)); - let tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: vec![bitcoin::TxIn { - previous_output: OutPoint { txid: Txid::from_byte_array([1u8; 32]), vout: 0 }, - ..Default::default() - }], - output: vec![splice_out], - }; - let candidates = splice_candidates( + .unwrap(); + let bump_candidates = splice_candidates( counterparty_node_id, channel_id, - &[(tx.compute_txid(), Some(contribution))], + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert!(wallet + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let failed = wallet .payment_stores .payment_store() - .list_page(None) + .get(&failed_id) .await .unwrap() - .objects - .is_empty()); + .expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); assert!(wallet .payment_stores .pending_payment_store() - .list_page(None) + .get(&failed_id) .await .unwrap() - .objects - .is_empty()); + .is_none()); } - /// The signing write merges LDK's history into the recorded one instead of replacing it: a - /// recorded round LDK no longer lists survives the write, since dropping the rounds LDK - /// abandoned is [`Wallet::drop_abandoned_splice_rounds`]'s job, once LDK reports the failure. + /// A fee bump signed while the channel's intent is still live joins the record of the round + /// it replaces: that round is on record, so the history decides the id, and the intent the + /// bump shares with the first round stays on the record. #[tokio::test] - async fn signing_merges_ldk_history_into_the_recorded_one() { + async fn signing_a_bump_joins_the_replaced_rounds_record() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - let prior_txid = Txid::from_byte_array([0xAA; 32]); + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) + .await + .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); let candidates = splice_candidates( counterparty_node_id, channel_id, - &[(prior_txid, None), (txid, Some(contribution))], + &[(txid, Some(contribution.clone()))], ); sign_and_observe_round(&wallet, &tx, &candidates).await; - let (next_tx, next_contribution) = splice_out_round(&wallet, 2, 400_000, 700); - let next_txid = next_tx.compute_txid(); - let next_candidates = splice_candidates( + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( counterparty_node_id, channel_id, - &[(prior_txid, None), (next_txid, Some(next_contribution))], + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); - sign_and_observe_round(&wallet, &next_tx, &next_candidates).await; + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1); - let id = payments[0].id; - assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); - assert!( - matches!(&payments[0].kind, PaymentKind::Onchain { txid: t, .. } if *t == next_txid) - ); - assert_eq!(payments[0].amount_msat, Some(400_700_000)); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(payments.len(), 1, "the bump must join the first round's record"); + assert_eq!(payments[0].id, id); + assert!(matches!(payments[0].kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); assert_eq!( - record.candidates().iter().map(|c| c.txid).collect::>(), - vec![prior_txid, txid, next_txid] + entry.candidates().iter().map(|c| c.txid).collect::>(), + vec![txid, bump_txid] ); - assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); - assert_eq!(record.candidate(next_txid).unwrap().amount_msat, Some(400_700_000)); + assert_eq!(entry.splice_intent(), Some(&intent)); } - /// LDK abandoned a signed first round (the counterparty aborted before the signatures were - /// exchanged) and reports the failure: the round was never broadcast, so its entry goes rather - /// than wait on a transaction that will never exist — while another channel's entry is left - /// alone. + /// A splice queued behind a pending splice of this node is a splice of its own, negotiating + /// once the pending one locks. Its first round is on no record when it is signed, and the + /// pending round's record — tracked, and still carrying the pending splice's intent — is not + /// its: only a bare intent record can be a first round's. The queued round gets a fresh id and + /// the pending round's record stays as it stands. #[tokio::test] - async fn dropping_an_abandoned_first_round_removes_its_entry() { + async fn signing_a_queued_splice_does_not_join_the_pending_rounds_record() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let other_channel_id = ChannelId([8u8; 32]); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) + .await + .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let (other_tx, other_contribution) = splice_out_round(&wallet, 2, 400_000, 700); - let other_txid = other_tx.compute_txid(); - let other_candidates = splice_candidates( + sign_and_observe_round(&wallet, &tx, &candidates).await; + + let (queued_tx, queued_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let queued_txid = queued_tx.compute_txid(); + let queued_candidates = splice_candidates( counterparty_node_id, - other_channel_id, - &[(other_txid, Some(other_contribution))], + channel_id, + &[(queued_txid, Some(queued_contribution))], ); - wallet.record_signed_funding(&other_tx, &other_candidates).await.unwrap(); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - let other_id = wallet.find_payment_by_txid(other_txid).await.unwrap().expect("other id"); - - wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + sign_and_observe_round(&wallet, &queued_tx, &queued_candidates).await; - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); - // The round's provenance fact outlives the drop — it says what the transaction would have - // been, which a drop does not change — so the txid still identifies the payment it belonged - // to, and the stores no longer have a record under that payment. - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); - let other = wallet - .payment_stores - .pending_payment_store() - .get(&other_id) + let queued_id = wallet + .find_payment_by_txid(queued_txid) .await .unwrap() - .expect("the other channel's entry stays"); - assert!(other.candidate(other_txid).is_some()); - assert_eq!(wallet.find_payment_by_txid(other_txid).await.unwrap(), Some(other_id)); + .expect("the queued round must be recorded"); + assert_ne!(queued_id, id, "the queued splice must not join the pending round's record"); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(entry.splice_intent(), Some(&intent)); } - /// LDK abandoned a signed fee bump while the round it replaces stays pending: the bump leaves - /// the recorded history and the record tracks the original round again, figures included. + /// A channel carries one intent per splice in flight, each under its own record. Signing the + /// first round of either splice files it under the intent whose contribution it carries, and + /// leaves the other splice's record alone. #[tokio::test] - async fn dropping_an_abandoned_bump_restores_the_prior_round() { + async fn signing_the_first_rounds_of_two_splices_files_each_under_its_own_intent() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let (tx_a, contribution_a) = splice_out_round(&wallet, 1, 500_000, 300); + let (tx_b, contribution_b) = splice_out_round(&wallet, 2, 400_000, 700); + let (txid_a, txid_b) = (tx_a.compute_txid(), tx_b.compute_txid()); + let (id_a, id_b) = (PaymentId([31u8; 32]), PaymentId([32u8; 32])); + let intent_a = SpliceIntent { + contribution: contribution_a.clone(), + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + let intent_b = SpliceIntent { + contribution: contribution_b.clone(), + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + for (id, intent) in [(id_a, intent_a.clone()), (id_b, intent_b.clone())] { + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent)) + .await + .unwrap(); + } - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); - let txid = tx.compute_txid(); - let candidates = splice_candidates( - counterparty_node_id, - channel_id, - &[(txid, Some(contribution.clone()))], - ); - sign_and_observe_round(&wallet, &tx, &candidates).await; - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - - let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); - let bump_txid = bump_tx.compute_txid(); - let bump_candidates = splice_candidates( - counterparty_node_id, - channel_id, - &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], - ); - wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); - record_unseen_round(&wallet, &bump_tx).await; - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); - - wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); - - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!( - matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid), - "the original round must be the actively-tracked transaction again" + let candidates_a = + splice_candidates(counterparty_node_id, channel_id, &[(txid_a, Some(contribution_a))]); + sign_and_observe_round(&wallet, &tx_a, &candidates_a).await; + assert_eq!(wallet.find_payment_by_txid(txid_a).await.unwrap(), Some(id_a)); + let entry_b = + wallet.payment_stores.pending_payment_store().get(&id_b).await.unwrap().expect("entry"); + assert_eq!(entry_b, PendingPaymentDetails::pending_splice(id_b, intent_b.clone())); + + let candidates_b = + splice_candidates(counterparty_node_id, channel_id, &[(txid_b, Some(contribution_b))]); + sign_and_observe_round(&wallet, &tx_b, &candidates_b).await; + assert_eq!(wallet.find_payment_by_txid(txid_b).await.unwrap(), Some(id_b)); + let entry_b = + wallet.payment_stores.pending_payment_store().get(&id_b).await.unwrap().expect("entry"); + assert_eq!(entry_b.candidates().iter().map(|c| c.txid).collect::>(), vec![txid_b]); + assert_eq!(entry_b.splice_intent(), Some(&intent_b)); + let entry_a = + wallet.payment_stores.pending_payment_store().get(&id_a).await.unwrap().expect("entry"); + assert_eq!(entry_a.candidates().iter().map(|c| c.txid).collect::>(), vec![txid_a]); + assert_eq!(entry_a.splice_intent(), Some(&intent_a)); + assert_eq!( + wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects.len(), + 2 ); - assert_eq!(payment.amount_msat, Some(500_300_000)); - assert_eq!(payment.fee_paid_msat, Some(300_000)); - assert_eq!(record.details(), Some(&payment)); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); } - /// A round awaiting broadcast that the wallet has nonetheless seen — the counterparty broadcast - /// it with our signatures while LDK still waited on its own, and the channel then closed — may - /// still confirm and keeps its place, even once evicted from the mempool: the lookup is not - /// canonical-only. + /// A first round whose contribution is none of the channel's bare intents' — LDK may adjust + /// a contribution's fee fields, not its inputs or outputs — is still the channel's only bare + /// intent's round when there is just one. When there are several, none is known to be its, + /// and the round gets a fresh id while both intents stay. #[tokio::test] - async fn dropping_keeps_a_round_the_wallet_has_seen() { + async fn signing_a_first_round_none_of_several_bare_intents_claims_gets_a_fresh_id() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let (id_a, id_b) = (PaymentId([31u8; 32]), PaymentId([32u8; 32])); + let intent_a = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let intent_b = SpliceIntent { + contribution: test_funding_contribution_with_outputs(400, 253, &[]), + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + for (id, intent) in [(id_a, intent_a.clone()), (id_b, intent_b.clone())] { + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent)) + .await + .unwrap(); + } let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - sign_and_observe_round(&wallet, &tx, &candidates).await; - evict_tx(&wallet, txid); - assert!(wallet.inner.lock().unwrap().get_tx(txid).is_none(), "evicted: not canonical"); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - - wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); - assert_eq!(payment.status, PaymentStatus::Pending); + let round_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("recorded"); + assert!(round_id != id_a && round_id != id_b, "neither intent is known to be the round's"); + for (id, intent) in [(id_a, intent_a), (id_b, intent_b)] { + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("entry"); + assert_eq!(entry, PendingPaymentDetails::pending_splice(id, intent)); + } } - /// The channel force-closed with a negotiated round unconfirmed and a fee bump of it signed - /// but never exchanged, before wallet sync picked the negotiated round up: LDK lists neither - /// anymore, but the negotiated round was handed to the broadcaster and may still confirm, so - /// only the bump is dropped. + /// A splice submitted after the previous splice locked with zero confirmations has an intent + /// of its own: the locked splice's intent was settled before the new one was persisted + /// (`SpliceTracker::submit`). Signing the new splice's first round files it under the new + /// intent's id and leaves the locked round's record as it stands. #[tokio::test] - async fn dropping_keeps_rounds_handed_to_the_broadcaster() { + async fn signing_a_splice_after_a_zero_conf_lock_gets_its_own_record() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let first_txid = setup.tx.compute_txid(); + let first_entry = wallet + .payment_stores + .pending_payment_store() + .get(&setup.first_id) + .await + .unwrap() + .expect("first entry"); - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let (tx, contribution) = splice_out_round(&wallet, 2, 400_000, 700); let txid = tx.compute_txid(); - let candidates = splice_candidates( - counterparty_node_id, - channel_id, - &[(txid, Some(contribution.clone()))], - ); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); - let bump_txid = bump_tx.compute_txid(); - let bump_candidates = splice_candidates( - counterparty_node_id, - channel_id, - &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(setup.second_id)); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&setup.second_id) + .await + .unwrap() + .expect("entry"); + assert_eq!(entry.splice_intent(), Some(&setup.second_intent)); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&setup.first_id).await.unwrap(), + Some(first_entry) ); - wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); - - wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - let kept = record.candidate(txid).expect("the negotiated round keeps its place"); - assert_eq!(kept.amount_msat, Some(500_300_000)); - assert_eq!(kept.fee_paid_msat, Some(300_000)); - assert!(!kept.awaiting_broadcast); - // Wallet sync has not picked the round up, so there is no payment record either way. - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert_eq!(wallet.find_payment_by_txid(first_txid).await.unwrap(), Some(setup.first_id)); } - /// LDK abandoned the only round this node contributed to, an RBF of a counterparty-initiated - /// round it did not: what remains is not this node's payment, so the record goes instead of - /// being handed to a round the wallet will never observe. + /// Signing a splice round records its funding payment with the channel's full pending splice + /// history, so a wallet sync that observes the transaction before the broadcast (the + /// counterparty may broadcast first) resolves to the funding record through any round of that + /// history instead of filing the round as a foreign duplicate. Only the signed round awaits + /// broadcast; LDK broadcast the negotiated predecessor already. #[tokio::test] - async fn dropping_the_last_contributed_round_removes_the_record() { + async fn signing_records_the_round_with_the_full_splice_history() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + // The signed round is an RBF of a counterparty-initiated round (`prior_txid`, no + // contribution of ours), so the history LDK reports has two entries. let prior_txid = Txid::from_byte_array([0xAA; 32]); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); @@ -6102,26 +7051,59 @@ mod tests { channel_id, &[(prior_txid, None), (txid, Some(contribution))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - record_unseen_round(&wallet, &tx).await; - let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); - wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let payment = &payments[0]; + let id = payment.id; + assert_ne!(id, PaymentId(prior_txid.to_byte_array())); + assert_ne!(id, PaymentId(txid.to_byte_array())); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(payment.fee_paid_msat, Some(300_000)); + assert_eq!(payment.direction, PaymentDirection::Inbound); + assert_eq!(payment.status, PaymentStatus::Pending); + match &payment.kind { + PaymentKind::Onchain { + txid: recorded_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels }), + } => { + assert_eq!(*recorded_txid, txid); + assert_eq!(channels.len(), 1); + assert_eq!(channels[0].counterparty_node_id, counterparty_node_id); + assert_eq!(channels[0].channel_id, channel_id); + }, + kind => panic!("unexpected kind {:?}", kind), + } + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!( + record.candidates().iter().map(|c| c.txid).collect::>(), + vec![prior_txid, txid] + ); + let prior = record.candidate(prior_txid).unwrap(); + assert_eq!(prior.amount_msat, None); + assert!(!prior.awaiting_broadcast); + let signed = record.candidate(txid).unwrap(); + assert_eq!(signed.amount_msat, Some(500_300_000)); + assert_eq!(signed.fee_paid_msat, Some(300_000)); + assert!(signed.awaiting_broadcast); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); } - /// The record moved on before the drop: wallet sync confirmed the original round while its - /// bump awaited signatures, then LDK abandoned the bump. The confirmed record is left as it - /// stands; only the bump leaves the recorded history. + /// A fee bump of a round whose payment wallet sync failed — the round lost to a conflicting + /// spend confirmed while the channel stayed open, so LDK still tracks it and offers the bump — + /// is signed with the failed round among its candidates. The failed record takes no round: its + /// status is never revisited, so the bump would go untracked under it. The bump gets a record + /// of its own. #[tokio::test] - async fn dropping_leaves_a_record_that_moved_on() { + async fn signing_a_bump_of_a_failed_round_gets_a_record_of_its_own() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); let candidates = splice_candidates( @@ -6129,8 +7111,21 @@ mod tests { channel_id, &[(txid, Some(contribution.clone()))], ); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_stores + .payment_store() + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); let bump_txid = bump_tx.compute_txid(); @@ -6139,81 +7134,154 @@ mod tests { channel_id, &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); - wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; - insert_confirmed_tx(&wallet, tx.clone(), 105); - let event = WalletEvent::TxConfirmed { - txid, - tx: Arc::new(tx), - block_time: confirmed_block_time(105), - old_block_time: None, - }; - wallet.update_payment_store(vec![event]).await.unwrap(); + let bump_id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("a record"); + assert_ne!(bump_id, failed_id); let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!(matches!( - payment.kind, - PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } - if t == txid - )); + wallet.payment_stores.payment_store().get(&bump_id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&bump_id) + .await + .unwrap() + .expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let failed = wallet + .payment_stores + .payment_store() + .get(&failed_id) + .await + .unwrap() + .expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&failed_id) + .await + .unwrap() + .is_none()); + } - wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + /// The bump's history carries a round this node contributed nothing to over from the failed + /// record, whose signing attributed the round to the failed payment. Facts are immutable, so + /// the bump's record lists the round as a candidate and leaves its facts as they are, rather + /// than failing the signing over a second attribution. + #[tokio::test] + async fn a_bump_of_a_failed_round_leaves_a_counterparty_rounds_facts_as_they_are() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (counterparty_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let counterparty_txid = counterparty_tx.compute_txid(); + let (tx, contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_stores + .payment_store() + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); - assert_eq!( - wallet.payment_stores.payment_store().get(&id).await.unwrap(), - Some(payment.clone()) + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 498_000, 1_100); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (counterparty_txid, None), + (txid, Some(contribution)), + (bump_txid, Some(bump_contribution)), + ], ); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - assert_eq!(record.details(), Some(&payment)); - assert!(record.candidate(bump_txid).is_none()); + wallet + .record_signed_funding(&bump_tx, &bump_candidates) + .await + .expect("the signing is recorded"); + + let bump_id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("a record"); + assert_ne!(bump_id, failed_id); + let figures = wallet + .channel_tx_facts(&counterparty_txid) + .await + .expect("facts") + .local_figures + .expect("figures"); + assert_eq!(figures.funding_payment_id, failed_id); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&bump_id) + .await + .unwrap() + .expect("entry"); + assert!(entry.candidate(counterparty_txid).is_some()); } - /// The record moved on to a bump the entry does not list, so the entry still lists only the - /// original round. Abandoning that round, with no round of ours remaining, leaves the record - /// as it stands and only shrinks the entry's history, its copy of the record catching up. + /// Once LDK reports a round recorded at signing negotiated, there is nothing to add but the + /// broadcast itself: the round's awaiting-broadcast mark is cleared and the record left as + /// written. #[tokio::test] - async fn dropping_the_last_round_leaves_a_record_that_moved_on() { + async fn negotiation_of_a_signed_round_marks_it_broadcast() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let prior_txid = Txid::from_byte_array([0xAA; 32]); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); - let candidates = - splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - record_unseen_round(&wallet, &tx).await; - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - - // Wallet sync moved the record onto a bump the entry does not list. - let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); - let bump_txid = bump_tx.compute_txid(); - let mut moved_on = PaymentDetailsUpdate::new(id); - moved_on.txid = Some(bump_txid); - wallet.payment_stores.payment_store().update(moved_on).await.unwrap(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert!(record.candidate(txid).unwrap().awaiting_broadcast); - wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - assert_eq!( - wallet.payment_stores.payment_store().get(&id).await.unwrap(), - Some(payment.clone()) - ); + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(payment)); let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert!(record.candidates().is_empty()); - assert_eq!(record.details(), Some(&payment)); + assert_eq!( + record.candidates().iter().map(|c| c.txid).collect::>(), + vec![prior_txid, txid] + ); + assert!(!record.candidate(txid).unwrap().awaiting_broadcast); + assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); } - /// A removal that was cut short between the two stores — the payment record went, the pending - /// entry stayed — is finished by the replayed drop: the entry alone still resolves the round's - /// txid, so it is what the replayed event finds and removes. + /// A replayed `SpliceNegotiated` event refers to a round already marked broadcast; the store + /// is not written. #[tokio::test] - async fn a_cut_short_removal_is_finished_by_the_replayed_drop() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + async fn marking_a_broadcast_round_again_writes_nothing() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); @@ -6222,23 +7290,747 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - record_unseen_round(&wallet, &tx).await; - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - wallet.payment_stores.payment_store().remove(&id).await.unwrap(); - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); - - wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + fail_store.fail_writes.store(true, Ordering::Release); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 0, + "marking a round broadcast again must produce no new write" + ); } - /// A hand-back that was cut short between the two stores — the payment record tracks the - /// original round again, the pending entry still lists the bump and mirrors the record as it - /// was — is finished by the replayed drop: the bump leaves the history and the entry's copy of - /// the record catches up with the record. + /// A round no funding payment tracks — this node contributed nothing to it, so signing never + /// recorded it — has no mark to clear, and the clearing writes nothing. #[tokio::test] - async fn a_cut_short_hand_back_is_finished_by_the_replayed_drop() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + async fn marking_an_unrecorded_round_broadcast_writes_nothing() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + fail_store.fail_writes.store(true, Ordering::Release); + let txid = Txid::from_byte_array([0xAA; 32]); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 0); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + } + + /// A replayed signing event re-offers a transaction already recorded, and the recording writes + /// nothing. + #[tokio::test] + async fn signing_a_recorded_round_again_writes_nothing() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + fail_store.fail_writes.store(true, Ordering::Release); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 0, + "a replayed signing must produce no new write" + ); + } + + /// A signed round absent from the channel's pending splice history was reset between the + /// event's emission and its handling (the counterparty aborted): LDK will refuse the signed + /// transaction, so the round is not recorded — not even when the history contains another + /// round this node contributed to. + #[tokio::test] + async fn signing_skips_a_round_missing_from_the_splice_history() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let other_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(other_txid, Some(contribution))], + ); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_signed_funding(&tx, &[]).await.unwrap(); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + } + + /// A round this node did not contribute to is not its payment: the signing-time recording + /// declines it. + #[tokio::test] + async fn signing_skips_a_round_without_a_local_contribution() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(tx.compute_txid(), None)]); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + } + + /// A splice-out to an external address moves no wallet funds; the signing-time recording + /// declines it — wallet sync cannot observe it either, so there is no race to close. + #[tokio::test] + async fn signing_skips_a_wallet_untouched_transaction() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let splice_out = + TxOut { value: Amount::from_sat(500_000), script_pubkey: ScriptBuf::new() }; + let contribution = + test_funding_contribution_with_outputs(300, 253, std::slice::from_ref(&splice_out)); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: OutPoint { txid: Txid::from_byte_array([1u8; 32]), vout: 0 }, + ..Default::default() + }], + output: vec![splice_out], + }; + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(tx.compute_txid(), Some(contribution))], + ); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + } + + /// The signing write merges LDK's history into the recorded one instead of replacing it: a + /// recorded round LDK no longer lists survives the write, since dropping the rounds LDK + /// abandoned is [`Wallet::drop_abandoned_splice_rounds`]'s job, once LDK reports the failure. + #[tokio::test] + async fn signing_merges_ldk_history_into_the_recorded_one() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let prior_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + + let (next_tx, next_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let next_txid = next_tx.compute_txid(); + let next_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (next_txid, Some(next_contribution))], + ); + sign_and_observe_round(&wallet, &next_tx, &next_candidates).await; + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let id = payments[0].id; + assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); + assert!( + matches!(&payments[0].kind, PaymentKind::Onchain { txid: t, .. } if *t == next_txid) + ); + assert_eq!(payments[0].amount_msat, Some(400_700_000)); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!( + record.candidates().iter().map(|c| c.txid).collect::>(), + vec![prior_txid, txid, next_txid] + ); + assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); + assert_eq!(record.candidate(next_txid).unwrap().amount_msat, Some(400_700_000)); + } + + /// LDK abandoned a signed first round (the counterparty aborted before the signatures were + /// exchanged) and reports the failure: the round was never broadcast, so its entry goes rather + /// than wait on a transaction that will never exist — while another channel's entry is left + /// alone. + #[tokio::test] + async fn dropping_an_abandoned_first_round_removes_its_entry() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let other_channel_id = ChannelId([8u8; 32]); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let (other_tx, other_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let other_txid = other_tx.compute_txid(); + let other_candidates = splice_candidates( + counterparty_node_id, + other_channel_id, + &[(other_txid, Some(other_contribution))], + ); + wallet.record_signed_funding(&other_tx, &other_candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let other_id = wallet.find_payment_by_txid(other_txid).await.unwrap().expect("other id"); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + // The round's provenance fact outlives the drop — it says what the transaction would have + // been, which a drop does not change — so the txid still identifies the payment it belonged + // to, and the stores no longer have a record under that payment. + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + let other = wallet + .payment_stores + .pending_payment_store() + .get(&other_id) + .await + .unwrap() + .expect("the other channel's entry stays"); + assert!(other.candidate(other_txid).is_some()); + assert_eq!(wallet.find_payment_by_txid(other_txid).await.unwrap(), Some(other_id)); + } + + /// The abandoned first round was signed under the channel's splice intent: the record goes, + /// but the intent stays behind as a bare intent, so the failure LDK reports next can still be + /// described in the splice's own terms before its settlement removes the intent. A repeated + /// drop leaves the bare intent alone. + #[tokio::test] + async fn dropping_an_abandoned_first_round_keeps_its_bare_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::pending_splice(id, intent); + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare.clone()) + ); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare) + ); + } + + /// The intent was already settled off the record when the abandoned first round is dropped — + /// a lock or the channel's close settled it first — so there is nothing left to keep: the + /// record and its entry both go. + #[tokio::test] + async fn dropping_an_abandoned_first_round_whose_intent_settled_removes_its_entry() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent)) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let settled = PendingPaymentDetailsUpdate { + id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + wallet.payment_stores.pending_payment_store().update(settled).await.unwrap(); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + /// Settling a bare splice intent removes the unindexed funding record under its id, if any: + /// it is the first half of a write that never completed, and the pending store will never drive + /// it. The bare entry itself is left to the settlement, and a record an entry indexes stays. + #[tokio::test] + async fn settling_a_bare_intent_drops_its_half_written_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::pending_splice(id, intent); + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let txid = Txid::from_byte_array([0xBB; 32]); + let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); + wallet.payment_stores.payment_store().insert_or_update(half_written).await.unwrap(); + + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare) + ); + + // The same record with an entry indexing it is a funding payment wallet sync recorded in + // full, and stays. + let indexed_txid = Txid::from_byte_array([0xCC; 32]); + let record = + interactive_funding_details(id, indexed_txid, Some(400_700_000), Some(700_000)); + wallet.payment_stores.payment_store().insert_or_update(record.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::new(record.clone(), Vec::new(), Vec::new())) + .await + .unwrap(); + + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(record)); + assert_eq!(wallet.find_payment_by_txid(indexed_txid).await.unwrap(), Some(id)); + } + + /// Settling a bare splice intent leaves alone a record under its id that is not the + /// half-written record of a funding round: a payment that succeeded, or whose transaction + /// confirmed, was broadcast and driven to that state, and is a payment of its own. + #[tokio::test] + async fn settling_a_bare_intent_leaves_a_settled_record_alone() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::pending_splice(id, intent); + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let txid = Txid::from_byte_array([0xBB; 32]); + let settled = [ + (confirmed_status(), PaymentStatus::Succeeded), + (confirmed_status(), PaymentStatus::Pending), + (ConfirmationStatus::Unconfirmed, PaymentStatus::Succeeded), + ]; + for (confirmation, status) in settled { + let kind = PaymentKind::Onchain { + txid, + status: confirmation, + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + let record = PaymentDetails::new( + id, + kind, + Some(500_300_000), + Some(300_000), + PaymentDirection::Outbound, + status, + ); + wallet.payment_stores.payment_store().insert_or_update(record.clone()).await.unwrap(); + + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(record)); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare.clone()) + ); + wallet.payment_stores.payment_store().remove(&id).await.unwrap(); + } + } + + /// LDK abandoned a signed fee bump of a counterparty-initiated round this node did not + /// contribute to: no remaining round is this node's payment, so the record goes as a first + /// round's does, and the bump's intent stays behind as a bare intent. + #[tokio::test] + async fn dropping_an_abandoned_bump_of_a_counterparty_round_keeps_its_bare_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::pending_splice(id, intent); + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let prior_txid = Txid::from_byte_array([9u8; 32]); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let bump_txid = bump_tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &candidates).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), Some(id)); + + wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare) + ); + } + + /// LDK abandoned a signed fee bump while the round it replaces stays pending: the bump leaves + /// the recorded history and the record tracks the original round again, figures included. + #[tokio::test] + async fn dropping_an_abandoned_bump_restores_the_prior_round() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid), + "the original round must be the actively-tracked transaction again" + ); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(payment.fee_paid_msat, Some(300_000)); + assert_eq!(record.details(), Some(&payment)); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + } + + /// A round awaiting broadcast that the wallet has nonetheless seen — the counterparty broadcast + /// it with our signatures while LDK still waited on its own, and the channel then closed — may + /// still confirm and keeps its place, even once evicted from the mempool: the lookup is not + /// canonical-only. + #[tokio::test] + async fn dropping_keeps_a_round_the_wallet_has_seen() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + sign_and_observe_round(&wallet, &tx, &candidates).await; + evict_tx(&wallet, txid); + assert!(wallet.inner.lock().unwrap().get_tx(txid).is_none(), "evicted: not canonical"); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(payment.status, PaymentStatus::Pending); + } + + /// The channel force-closed with a negotiated round unconfirmed and a fee bump of it signed + /// but never exchanged, before wallet sync picked the negotiated round up: LDK lists neither + /// anymore, but the negotiated round was handed to the broadcaster and may still confirm, so + /// only the bump is dropped. + #[tokio::test] + async fn dropping_keeps_rounds_handed_to_the_broadcaster() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let kept = record.candidate(txid).expect("the negotiated round keeps its place"); + assert_eq!(kept.amount_msat, Some(500_300_000)); + assert_eq!(kept.fee_paid_msat, Some(300_000)); + assert!(!kept.awaiting_broadcast); + // Wallet sync has not picked the round up, so there is no payment record either way. + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + } + + /// LDK abandoned the only round this node contributed to, an RBF of a counterparty-initiated + /// round it did not: what remains is not this node's payment, so the record goes instead of + /// being handed to a round the wallet will never observe. + #[tokio::test] + async fn dropping_the_last_contributed_round_removes_the_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let prior_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); + + wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + /// The record moved on before the drop: wallet sync confirmed the original round while its + /// bump awaited signatures, then LDK abandoned the bump. The confirmed record is left as it + /// stands; only the bump leaves the recorded history. + #[tokio::test] + async fn dropping_leaves_a_record_that_moved_on() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + insert_confirmed_tx(&wallet, tx.clone(), 105); + let event = WalletEvent::TxConfirmed { + txid, + tx: Arc::new(tx), + block_time: confirmed_block_time(105), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } + if t == txid + )); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + assert_eq!( + wallet.payment_stores.payment_store().get(&id).await.unwrap(), + Some(payment.clone()) + ); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.details(), Some(&payment)); + assert!(record.candidate(bump_txid).is_none()); + } + + /// The record moved on to a bump the entry does not list, so the entry still lists only the + /// original round. Abandoning that round, with no round of ours remaining, leaves the record + /// as it stands and only shrinks the entry's history, its copy of the record catching up. + #[tokio::test] + async fn dropping_the_last_round_leaves_a_record_that_moved_on() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + // Wallet sync moved the record onto a bump the entry does not list. + let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let mut moved_on = PaymentDetailsUpdate::new(id); + moved_on.txid = Some(bump_txid); + wallet.payment_stores.payment_store().update(moved_on).await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert_eq!( + wallet.payment_stores.payment_store().get(&id).await.unwrap(), + Some(payment.clone()) + ); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert!(record.candidates().is_empty()); + assert_eq!(record.details(), Some(&payment)); + } + + /// A removal that was cut short between the two stores — the payment record went, the pending + /// entry stayed — is finished by the replayed drop: the entry alone still resolves the round's + /// txid, so it is what the replayed event finds and removes. + #[tokio::test] + async fn a_cut_short_removal_is_finished_by_the_replayed_drop() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + wallet.payment_stores.payment_store().remove(&id).await.unwrap(); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + /// A hand-back that was cut short between the two stores — the payment record tracks the + /// original round again, the pending entry still lists the bump and mirrors the record as it + /// was — is finished by the replayed drop: the bump leaves the history and the entry's copy of + /// the record catches up with the record. + #[tokio::test] + async fn a_cut_short_hand_back_is_finished_by_the_replayed_drop() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); @@ -6458,6 +8250,27 @@ mod tests { assert_eq!(entry.details().map(|details| details.status), Some(PaymentStatus::Pending)); } + /// Recording a first splice round costs one read of the payment store: the signing writes no + /// payment record, so the only read left is the duplicate merge's probe for a record wallet + /// sync may have keyed by the round's own txid. + #[tokio::test] + async fn a_first_round_signing_reads_the_payment_store_once() { + let counting_store = ReadCountingStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + + let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; + assert_eq!(reads, 1, "recording a first round re-read the payment store"); + } + /// The signing write fails at the pending store: no payment record is minted for a round /// nothing may broadcast, no entry is left half-written, and the replayed event records the /// round in full once the store recovers. @@ -6713,8 +8526,8 @@ mod tests { } /// A middle RBF candidate must map back to the funding record: it is neither the record's - /// id (derived from the first candidate), nor its current txid (the active candidate), nor - /// in `conflicting_txids` (it never got a `TxReplaced` event of its own). + /// id (here the txid-derived id of the first candidate), nor its current txid (the active + /// candidate), nor in `conflicting_txids` (it never got a `TxReplaced` event of its own). #[tokio::test] async fn find_payment_by_txid_maps_candidate_txids() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); @@ -6966,6 +8779,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7184,6 +8998,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7269,6 +9084,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7362,6 +9178,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7382,75 +9199,292 @@ mod tests { wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!(entry.candidates.len(), 2, "both rounds stay on record"); - // The first round turns up in the mempool, and a second close spends its input and - // confirms to depth too: no round can confirm now. - insert_unconfirmed_tx(&wallet, first_tx); - let second_close = wallet_paying_tx(&wallet, 1); - insert_confirmed_tx(&wallet, second_close, 6); + // The first round turns up in the mempool, and a second close spends its input and + // confirms to depth too: no round can confirm now. + insert_unconfirmed_tx(&wallet, first_tx); + let second_close = wallet_paying_tx(&wallet, 1); + insert_confirmed_tx(&wallet, second_close, 6); + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(5 + ANTI_REORG_DELAY - 1), + new_tip: block_id(6 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + } + + /// An unseen round that confirms after all is adopted by its record like any other + /// candidate — which a record failed while the round was unseen could never do. + #[tokio::test] + async fn funding_payment_adopts_an_unseen_candidate_once_it_confirms() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let first_tx = wallet_paying_tx(&wallet, 1); + let first_txid = first_tx.compute_txid(); + let second_tx = wallet_paying_tx(&wallet, 2); + let second_txid = second_tx.compute_txid(); + insert_unconfirmed_tx(&wallet, second_tx); + + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: first_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: second_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + ]; + let details = + interactive_funding_details(payment_id, second_txid, Some(1_000_000), Some(600)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + + let close_tx = wallet_paying_tx(&wallet, 2); + let close_txid = close_tx.compute_txid(); + wallet + .payment_stores + .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(); + + // The first round confirms. + insert_confirmed_tx(&wallet, first_tx.clone(), 6); + let event = WalletEvent::TxConfirmed { + txid: first_txid, + tx: Arc::new(first_tx), + block_time: confirmed_block_time(6), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + match &payment.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, first_txid, "the record adopts the round that confirmed"); + assert!(matches!(status, ConfirmationStatus::Confirmed { height: 6, .. })); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(payment.fee_paid_msat, Some(500), "with the fee of that round"); + assert!( + wallet + .payment_stores + .payment_store() + .get(&PaymentId(first_txid.to_byte_array())) + .await + .unwrap() + .is_none(), + "the round is not recorded a second time" + ); + let entry = + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().unwrap(); + assert!(matches!( + entry.details().unwrap().kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + } + + /// 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), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: bumped_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + ]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .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_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.payment_stores.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), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .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(5 + ANTI_REORG_DELAY - 1), - new_tip: block_id(6 + ANTI_REORG_DELAY - 1), + 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_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.status, PaymentStatus::Pending); assert!(wallet .payment_stores .pending_payment_store() .get(&payment_id) .await .unwrap() - .is_none()); + .is_some()); } - /// An unseen round that confirms after all is adopted by its record like any other - /// candidate — which a record failed while the round was unseen could never do. + /// 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_adopts_an_unseen_candidate_once_it_confirms() { + 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 first_tx = wallet_paying_tx(&wallet, 1); - let first_txid = first_tx.compute_txid(); - let second_tx = wallet_paying_tx(&wallet, 2); - let second_txid = second_tx.compute_txid(); - insert_unconfirmed_tx(&wallet, second_tx); + 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: first_txid, + txid: splice_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), awaiting_broadcast: false, }, FundingTxCandidate { - txid: second_txid, + txid: live_candidate_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(600), awaiting_broadcast: false, }, ]; let details = - interactive_funding_details(payment_id, second_txid, Some(1_000_000), Some(600)); + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); wallet.record_funding_payment(details, candidates).await.unwrap(); - - let close_tx = wallet_paying_tx(&wallet, 2); - let close_txid = close_tx.compute_txid(); wallet .payment_stores .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, - conflicting_txids: Some(vec![close_txid]), + conflicting_txids: Some(vec![conflict_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); - insert_confirmed_tx(&wallet, close_tx, 5); + + 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]) }; @@ -7460,119 +9494,272 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - // The first round confirms. - insert_confirmed_tx(&wallet, first_tx.clone(), 6); - let event = WalletEvent::TxConfirmed { - txid: first_txid, - tx: Arc::new(first_tx), - block_time: confirmed_block_time(6), - old_block_time: None, + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.payment_stores.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 — from the record's status alone: the wallet + /// here never saw the round, as after a crash that also lost the wallet's changeset. + #[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_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + // The entry embeds the pre-failure snapshot, as a crash between the two writes leaves it. + let snapshot = + 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), + awaiting_broadcast: false, + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.payment_stores.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_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Pending); - match &payment.kind { - PaymentKind::Onchain { txid, status, tx_type } => { - assert_eq!(*txid, first_txid, "the record adopts the round that confirmed"); - assert!(matches!(status, ConfirmationStatus::Confirmed { height: 6, .. })); - assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the repair pass must not rewrite"); + assert!( + wallet.payment_stores.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 lost 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_stores.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), + awaiting_broadcast: false, + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.payment_stores.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)], }, - kind => panic!("unexpected kind {:?}", kind), - } - assert_eq!(payment.fee_paid_msat, Some(500), "with the fee of that round"); + 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_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the replay must not rewrite the record"); assert!( - wallet - .payment_stores - .payment_store() - .get(&PaymentId(first_txid.to_byte_array())) - .await - .unwrap() - .is_none(), - "the round is not recorded a second time" + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), + "the replay must finish the interrupted entry removal" ); - let entry = - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().unwrap(); - assert!(matches!( - entry.details().unwrap().kind, - PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } - )); } - /// 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. + /// Recording a transaction the payment store does not know costs two reads of it: the + /// funding-status check looks the resolved id up, and the generic write merges against the + /// store. Nothing in between re-reads what the funding-status check has already seen. + #[tokio::test] + async fn unknown_transaction_is_recorded_after_two_payment_store_reads() { + let counting_store = ReadCountingStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let tx = wallet_paying_tx(&wallet, 1); + let txid = tx.compute_txid(); + let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let event = WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; + + let payment_id = PaymentId(txid.to_byte_array()); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); + assert_eq!(reads, 2, "recording an unknown transaction re-read the payment store"); + } + + /// A funding record wallet sync created for a round this node had not recorded keeps the + /// txid-derived id of its first candidate. Once the payment settles and its entry is removed, a + /// wallet event for that candidate no longer resolves through the candidate history — the + /// fallback keys it by its own txid, colliding with the record's id. Recording the event there + /// would merge a fresh wallet-view `Pending` payment into the terminal record; such events must + /// be skipped. + #[tokio::test] + async fn candidate_event_does_not_resurrect_a_settled_funding_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // The record keeps the txid-derived id of its first candidate r1, as one created for an + // unrecorded round does; its txid rotated to the RBF round r2. The payment failed and its + // pending entry is gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let r2 = Txid::from_byte_array([4u8; 32]); + let payment_id = PaymentId(r1.to_byte_array()); + 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_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + // r1 reappears in the mempool after the failure... + let event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + + // ...and even confirms: the record settled as `Failed` and must stay that way. + let event = WalletEvent::TxConfirmed { + 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_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + } + + /// The same collision through a conflict list: a pending entry listing a settled funding + /// record's transaction as a conflict of its own round resolves an event for that transaction + /// to the entry's record, which finds it foreign, and the fallback to the transaction's own id + /// lands on the settled record. That id is read before anything is written under it. #[tokio::test] - async fn funding_payment_survives_a_confirmed_conflict_that_is_a_candidate() { + async fn conflict_listed_event_does_not_resurrect_a_settled_funding_payment() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(store, false).await; - let bumped_tx = wallet_paying_tx(&wallet, 3); - let bumped_txid = bumped_tx.compute_txid(); + // A settled funding record under the txid-derived id of r1, its pending entry gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let settled_id = PaymentId(r1.to_byte_array()); + let mut settled = interactive_funding_details(settled_id, r1, Some(1_000_000), Some(600)); + settled.status = PaymentStatus::Failed; + settled.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(settled).await.unwrap(); - 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), - awaiting_broadcast: false, - }, - FundingTxCandidate { - txid: bumped_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(600), - awaiting_broadcast: false, - }, - ]; - let details = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.record_funding_payment(details, candidates).await.unwrap(); + // A live funding record whose entry lists r1 as a conflict of its round r2. + let r2 = Txid::from_byte_array([4u8; 32]); + let live_id = PaymentId(r2.to_byte_array()); + let live = interactive_funding_details(live_id, r2, Some(2_000_000), Some(700)); + wallet.payment_stores.payment_store().insert_or_update(live.clone()).await.unwrap(); wallet .payment_stores .pending_payment_store() - .update(PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: None, - conflicting_txids: Some(vec![bumped_txid]), - candidates: Vec::new(), - }) + .insert_or_update(PendingPaymentDetails::new(live.clone(), vec![r1], Vec::new())) .await .unwrap(); + assert_eq!(wallet.find_payment_by_txid(r1).await.unwrap(), Some(live_id)); - 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), - }; + 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_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Pending); - assert!( - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), - "the entry must survive for classification to adopt the confirmed candidate" - ); + wallet.payment_stores.payment_store().get(&settled_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the settled record must not resurrect"); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&settled_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.payment_stores.payment_store().get(&live_id).await.unwrap(), Some(live)); } - /// 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. + /// 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 funding_payment_survives_a_foreign_conflict_short_of_depth() { + 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; + // The splice round and the close spend the same input. + let splice_tx = wallet_paying_tx(&wallet, 3); + let splice_txid = splice_tx.compute_txid(); 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, @@ -7580,8 +9767,9 @@ mod tests { fee_paid_msat: Some(500), awaiting_broadcast: false, }]; - let details = + let mut details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + details.direction = PaymentDirection::Inbound; wallet.record_funding_payment(details, candidates).await.unwrap(); wallet .payment_stores @@ -7591,396 +9779,585 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); + insert_unconfirmed_tx(&wallet, splice_tx); 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), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), }; wallet.update_payment_store(vec![event]).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Pending); + assert_eq!(payment.status, PaymentStatus::Failed); assert!(wallet .payment_stores .pending_payment_store() .get(&payment_id) .await .unwrap() - .is_some()); + .is_none()); } - /// 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. + /// Wallet sync can record a genuine replacement round before it is recorded as a candidate: + /// the counterparty broadcast a round this node did not contribute to, which is recorded only + /// when this node signs a later round of the splice. The funding-status gate then routes the + /// round's confirmation to a duplicate record keyed by the round's txid, whose pending entry + /// shadows the funding record in `find_payment_by_txid`'s direct probe. Once the round is + /// recorded as a candidate, the write must merge the duplicate — adopt its confirmation and + /// remove it — so a single record tracks the splice. #[tokio::test] - async fn funding_payment_survives_while_a_candidate_can_still_confirm() { + async fn recording_a_round_merges_duplicate_records_for_its_candidates() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(store, false).await; - let 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 funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); - let splice_txid = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId([21u8; 32]); - let candidates = vec![ + // Round 1 recorded normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate: a + // duplicate untyped record under the txid-derived id, plus its pending entry. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(duplicate_id)); + + // Round 2 is recorded as a candidate, with the history of a later round this node signs. + let rounds = vec![ FundingTxCandidate { - txid: splice_txid, + txid: txid1, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), awaiting_broadcast: false, }, FundingTxCandidate { - txid: live_candidate_txid, + txid: txid2, amount_msat: Some(1_000_000), - fee_paid_msat: Some(600), + fee_paid_msat: Some(400), awaiting_broadcast: false, }, ]; - let details = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.record_funding_payment(details, candidates).await.unwrap(); - wallet + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.record_funding_payment(details, rounds).await.unwrap(); + + // One record: the funding record carries the duplicate's confirmation and the confirmed + // candidate's figures; the duplicate and its pending entry are gone, so the round's txid + // resolves to the funding record again. + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + assert_eq!(payment.amount_msat, Some(1_000_000)); + assert_eq!(payment.fee_paid_msat, Some(400)); + match &payment.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } => assert_eq!(*txid, txid2), + kind => panic!("unexpected kind {:?}", kind), + } + assert!(wallet .payment_stores .pending_payment_store() - .update(PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: None, - conflicting_txids: Some(vec![conflict_txid]), - candidates: Vec::new(), - }) + .get(&duplicate_id) .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_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Pending); - assert!( - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), - "a candidate can still confirm, so the record must stay pending" - ); + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); } - /// 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 — from the record's status alone: the wallet - /// here never saw the round, as after a crash that also lost the wallet's changeset. + /// 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 a_failed_funding_payment_with_a_lingering_entry_is_cleaned_up() { + async fn recording_drops_unconfirmed_duplicates_without_adopting_their_txid() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(store, false).await; - let 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_stores.payment_store().insert_or_update(recorded).await.unwrap(); - - // The entry embeds the pre-failure snapshot, as a crash between the two writes leaves it. - let snapshot = - 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), - awaiting_broadcast: false, - }]; - let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); - wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); - insert_confirmed_tx(&wallet, close_tx, 5); + // Wallet sync saw round 1 — still unconfirmed — before any round was recorded. + let duplicate_id = PaymentId(txid1.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: txid1, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); - 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(); + // Round 2 is the active broadcast; its record lists both rounds. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.record_funding_payment(details, rounds).await.unwrap(); - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed); - assert_eq!(payment.latest_update_timestamp, 7, "the repair pass must not rewrite"); - assert!( - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), - "the lingering entry must be removed" - ); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + // The record keeps tracking the actively-broadcast round; a duplicate that never confirmed + // has nothing to adopt. + match &payment.kind { + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } => { + assert_eq!(*txid, txid2) + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(payment.fee_paid_msat, Some(400)); + assert_eq!(wallet.find_payment_by_txid(txid1).await.unwrap(), Some(funding_id)); } - /// 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 lost 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. + /// Removing the duplicate is two store writes, and the failure between them must leave a + /// state a re-run of the merge (a replayed `SpliceNegotiated` event) can finish cleaning up. + /// If the payment record went first, a failure on the pending-entry removal would orphan that + /// entry where the re-run can no longer discover it (the record lookup misses), and it would + /// keep shadowing the funding record in `find_payment_by_txid`'s direct probe — re-creating + /// the duplicate problem with no further merge coming to fix it. #[tokio::test] - async fn replayed_replacement_finishes_an_interrupted_failure() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + async fn a_rerun_merge_completes_a_partially_failed_duplicate_removal() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); let wallet = new_test_wallet(store, false).await; - let 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_stores.payment_store().insert_or_update(recorded).await.unwrap(); + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); - let snapshot = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - let candidates = vec![FundingTxCandidate { - txid: splice_txid, + // Round 1 recorded normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), awaiting_broadcast: false, }]; - let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); - wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); - - insert_confirmed_tx(&wallet, close_tx, 5); + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); - 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)], + // Round 2 is recorded as a candidate, but one of the duplicate's two removals fails. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, }, - WalletEvent::ChainTipChanged { - old_tip: block_id(9), - new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, }, ]; - wallet.update_payment_store(events).await.unwrap(); - - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed); - assert_eq!(payment.latest_update_timestamp, 7, "the replay must not rewrite the record"); - assert!( - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), - "the replay must finish the interrupted entry removal" - ); - } - - /// Recording a transaction the payment store does not know costs two reads of it: the - /// funding-status check looks the resolved id up, and the generic write merges against the - /// store. Nothing in between re-reads what the funding-status check has already seen. - #[tokio::test] - async fn unknown_transaction_is_recorded_after_two_payment_store_reads() { - let counting_store = ReadCountingStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; + let 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.record_funding_payment(details.clone(), rounds.clone()).await; + assert!(res.is_err(), "the injected remove failure must surface"); - let tx = wallet_paying_tx(&wallet, 1); - let txid = tx.compute_txid(); - let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); - let event = WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx), old_block_time: None }; - wallet.update_payment_store(vec![event]).await.unwrap(); - let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; + // The merge re-runs with the record's next write; it must finish the cleanup. + wallet.record_funding_payment(details, rounds).await.unwrap(); - let payment_id = PaymentId(txid.to_byte_array()); - assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); - assert_eq!(reads, 2, "recording an unknown transaction re-read the payment store"); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, funding_id); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); } - /// 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. + /// Signing a later round merges the duplicates of earlier rounds as a courtesy: the signed + /// round itself can have no duplicate yet, as our signatures have not left the node, and the + /// round's own `SpliceNegotiated` event re-runs the merge, replaying on failure. A merge + /// failure must therefore not fail the signing, whose record is complete once both stores are + /// written, and must not leave the record half rolled back. #[tokio::test] - async fn candidate_event_does_not_resurrect_a_settled_funding_payment() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + async fn signing_survives_a_failed_duplicate_merge() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - // 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_stores.payment_store().insert_or_update(recorded).await.unwrap(); + // Round 1 is recorded at signing; round 2 is a counterparty-initiated replacement the + // wallet observed before it was recorded as a candidate, filed as an untyped duplicate. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new())) + .await + .unwrap(); - // 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(); + // This node signs round 3, a bump of the replacement, but the duplicate's removal fails. + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + // The signing is recorded in full and the duplicate is left as it was. + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!( + entry.candidates().iter().map(|c| c.txid).collect::>(), + vec![txid, replacement_txid, bump_txid] + ); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + // The signing writes no payment record: the one wallet sync made for the first round + // still describes that round, and the entry still mirrors it. let payment = - wallet.payment_stores.payment_store().get(&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); + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); assert!(wallet .payment_stores .pending_payment_store() - .get(&payment_id) + .get(&duplicate_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(); + .is_some()); - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); - assert_eq!(payment.latest_update_timestamp, 7); + // The bump's `SpliceNegotiated` event merges the duplicate away. + wallet.record_broadcast_splice_round(channel_id, bump_txid).await.unwrap(); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); assert!(wallet .payment_stores .pending_payment_store() - .get(&payment_id) + .get(&duplicate_id) .await .unwrap() .is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); } - /// The same collision through a conflict list: a pending entry listing a settled funding - /// record's transaction as a conflict of its own round resolves an event for that transaction - /// to the entry's record, which finds it foreign, and the fallback to the transaction's own id - /// lands on the settled record. That id is read before anything is written under it. + /// A duplicate merge failing under the `SpliceNegotiated` write must fail that write: the + /// replay it triggers is the merge's only re-run. The mark, cleared before the merge, stays + /// cleared and the duplicate is left as it was; the replayed write finds the round marked + /// already and merges the duplicate away. #[tokio::test] - async fn conflict_listed_event_does_not_resurrect_a_settled_funding_payment() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + async fn a_failed_duplicate_merge_fails_the_negotiation_write_until_its_replay() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - // A settled funding record under the txid-derived id of r1, its pending entry gone. - let r1 = Txid::from_byte_array([2u8; 32]); - let settled_id = PaymentId(r1.to_byte_array()); - let mut settled = interactive_funding_details(settled_id, r1, Some(1_000_000), Some(600)); - settled.status = PaymentStatus::Failed; - settled.latest_update_timestamp = 7; - wallet.payment_stores.payment_store().insert_or_update(settled).await.unwrap(); - - // A live funding record whose entry lists r1 as a conflict of its round r2. - let r2 = Txid::from_byte_array([4u8; 32]); - let live_id = PaymentId(r2.to_byte_array()); - let live = interactive_funding_details(live_id, r2, Some(2_000_000), Some(700)); - wallet.payment_stores.payment_store().insert_or_update(live.clone()).await.unwrap(); + // Round 1 is recorded at signing; round 2 is a counterparty-initiated replacement the + // wallet observed before it was recorded as a candidate, filed as an untyped duplicate; + // round 3, a bump this node signs, records round 2 as a candidate, but the signing's + // merge of the duplicate fails and is left to the bump's `SpliceNegotiated` event. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); wallet .payment_stores .pending_payment_store() - .insert_or_update(PendingPaymentDetails::new(live.clone(), vec![r1], Vec::new())) + .insert_or_update(PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new())) .await .unwrap(); - assert_eq!(wallet.find_payment_by_txid(r1).await.unwrap(), Some(live_id)); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); - let event = - WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; - wallet.update_payment_store(vec![event]).await.unwrap(); + // The event's write meets the same failure: it must surface, so the event is replayed, + // with the mark cleared and the duplicate untouched. + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let res = wallet.record_broadcast_splice_round(channel_id, bump_txid).await; + assert!(res.is_err(), "a failed merge must fail the write"); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(!entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_some()); - let payment = - wallet.payment_stores.payment_store().get(&settled_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed, "the settled record must not resurrect"); - assert_eq!(payment.latest_update_timestamp, 7); + // The replayed write finds the round marked already and merges the duplicate away. + wallet.record_broadcast_splice_round(channel_id, bump_txid).await.unwrap(); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); assert!(wallet .payment_stores .pending_payment_store() - .get(&settled_id) + .get(&duplicate_id) .await .unwrap() .is_none()); - assert_eq!(wallet.payment_stores.payment_store().get(&live_id).await.unwrap(), Some(live)); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); } - /// 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. + /// A merge cut short between adopting a confirmed duplicate's confirmation and removing the + /// duplicate leaves the funding record confirmed on the duplicate's transaction, the pending + /// entry at its prior status and the duplicate untouched, and a re-run completes the removal: + /// the merge is idempotent, so the record's next write or a replayed `SpliceNegotiated` event + /// can finish what a failure cut short. The failure injected is the pending store's, which the + /// adoption writes after the payment store. #[tokio::test] - async fn inbound_funding_payment_fails_once_a_foreign_conflict_confirms_to_depth() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + async fn a_torn_duplicate_merge_is_completed_by_a_rerun() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - // The splice round and the close spend the same input. - let splice_tx = wallet_paying_tx(&wallet, 3); - let splice_txid = splice_tx.compute_txid(); - let close_tx = wallet_paying_tx(&wallet, 3); - let close_txid = close_tx.compute_txid(); + // Round 1 is recorded at signing, round 2 is a counterparty-initiated replacement, and + // round 3 is this node's bump of it, recorded with the channel's history when signed. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); - let payment_id = PaymentId([21u8; 32]); - let candidates = vec![FundingTxCandidate { - txid: splice_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }]; - let mut details = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - details.direction = PaymentDirection::Inbound; - wallet.record_funding_payment(details, candidates).await.unwrap(); + // Wallet sync filed the replacement's confirmation under an untyped record of its own, a + // duplicate of the funding record that already lists the replacement as a candidate. + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: confirmed_status(), + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + let duplicate_entry = PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new()); wallet .payment_stores .pending_payment_store() - .update(PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: None, - conflicting_txids: Some(vec![close_txid]), - candidates: Vec::new(), - }) + .insert_or_update(duplicate_entry.clone()) .await .unwrap(); + let entry_before = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + let rounds = entry_before.candidates().to_vec(); - insert_unconfirmed_tx(&wallet, splice_tx); - 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(); - + // The merge adopts the confirmation onto the payment record, then fails to mirror it onto + // the pending entry and stops short of removing the duplicate. + fail_store.fail_writes.store(true, Ordering::Release); + { + let guard = wallet.payment_stores.lock().await; + let res = wallet.merge_duplicate_candidate_records(&guard, id, &rounds).await; + assert!(res.is_err(), "the injected pending-store failure must surface"); + } + fail_store.fail_writes.store(false, Ordering::Release); let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed); + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } + if t == replacement_txid + )); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(entry_before) + ); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate_entry) + ); + + // A re-run finds the confirmation adopted, mirrors it, and removes the duplicate. + { + let guard = wallet.payment_stores.lock().await; + wallet.merge_duplicate_candidate_records(&guard, id, &rounds).await.unwrap(); + } + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); assert!(wallet .payment_stores .pending_payment_store() - .get(&payment_id) + .get(&duplicate_id) .await .unwrap() .is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); } #[tokio::test] @@ -8468,57 +10845,328 @@ mod tests { .get(&id) .await .unwrap() - .expect("the record stays"); - assert_eq!(payment.status, PaymentStatus::Failed); - assert!(matches!( - payment.kind, - PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } - if txid == counterparty_txid - )); + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } + if txid == counterparty_txid + )); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + /// The same at a close whose monitor watches the counterparty's round the record moved onto: + /// the record pointing at a round recorded without a stake does not make it a round of ours. + #[tokio::test] + async fn closing_with_a_tracked_round_not_ours_the_record_adopted_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let rounds = [(counterparty_txid, None), (tx.compute_txid(), Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + let event = WalletEvent::TxUnconfirmed { + txid: counterparty_txid, + tx: Arc::new(dummy_tx()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().unwrap(); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) + ); + + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) + .await + .unwrap(); + + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } + if txid == counterparty_txid + )); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + /// A record failed as a round this node did not contribute to locked keeps the splice intent + /// it carries as a bare intent under an id of its own: the intent is the splice tracker's to + /// settle — at this lock, or from the failure LDK reports for the contribution — and the + /// record's removal must not take it along, nor may it stay under the failed record's id, + /// which the fresh round of a fee bump LDK carries across the lock would adopt. + #[tokio::test] + async fn promoting_a_round_not_ours_keeps_the_failed_records_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) + .await + .unwrap(); + let counterparty_txid = Txid::from_byte_array([0xBB; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + let kept = bare_entries_carrying(&wallet, &intent).await; + assert_eq!(kept.len(), 1, "one bare entry carries the intent: {kept:?}"); + assert_ne!(kept[0].id(), id); + } + + /// The close keeps the intent of a record it fails the same way, for the splice tracker's + /// settlement of the closed channel's intents to find. + #[tokio::test] + async fn closing_keeps_the_failed_records_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + sign_and_observe_round(&wallet, &tx, &candidates).await; + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + let kept = bare_entries_carrying(&wallet, &intent).await; + assert_eq!(kept.len(), 1, "one bare entry carries the intent: {kept:?}"); + assert_ne!(kept[0].id(), id); + } + + /// The unobserved round LDK released was signed under the channel's splice intent: with the + /// channel closed and the monitor no longer watching the round, the attempt is failed under a + /// record written for it, and the intent stays behind as a bare intent under an id of its own, + /// so the failure LDK reports next can still be described in the splice's own terms before its + /// settlement removes the intent. + #[tokio::test] + async fn closing_keeps_the_intent_of_an_unobserved_round() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let recorded = + record_unobserved_broadcast_round(&wallet, &tx, &[(txid, Some(contribution))]).await; + assert_eq!(recorded, id); + + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + + assert_failed_unobserved_round(&wallet, id, txid).await; + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + let kept = bare_entries_carrying(&wallet, &intent).await; + assert_eq!(kept.len(), 1, "one bare entry carries the intent: {kept:?}"); + assert_ne!(kept[0].id(), id); + } + + /// A fee bump LDK carries across the lock of a round it does not overlap begins a fresh + /// splice, and the fresh round adopts the id of the bare intent carrying its contribution. + /// The intent kept from the failed record must therefore sit under an id of its own: the + /// fresh round then gets a `Pending` record whose entry carries the intent, where under the + /// failed record's id it would take that record — left `Failed` with the fresh round's txid — + /// and go untracked. + #[tokio::test] + async fn a_kept_intent_signs_its_fresh_round_under_an_id_of_its_own() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + // The bump's intent joined the record of the round it was to replace. + let id = PaymentId([31u8; 32]); + let intent = SpliceIntent { + contribution: bump_contribution.clone(), + kind: SpliceKind::Rbf {}, + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) + .await + .unwrap(); + let counterparty_txid = Txid::from_byte_array([0xBB; 32]); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + // LDK begins a fresh splice from the bump; its round is signed. + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(bump_txid, Some(bump_contribution))], + ); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; + + let bump_id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("a record"); + assert_ne!(bump_id, id); + let payment = + wallet.payment_stores.payment_store().get(&bump_id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&bump_id) + .await + .unwrap() + .expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(bump_txid).is_some()); + let failed = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } - /// The same at a close whose monitor watches the counterparty's round the record moved onto: - /// the record pointing at a round recorded without a stake does not make it a round of ours. + /// A crash between keeping the intent and removing the failed record's entry leaves both; + /// the replay removes the entry and adds no second copy of the intent. #[tokio::test] - async fn closing_with_a_tracked_round_not_ours_the_record_adopted_fails_the_payment() { + async fn a_replayed_failure_does_not_duplicate_the_kept_intent() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (_, channel_id) = test_counterparty_and_channel(); - let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) + .await + .unwrap(); + let counterparty_txid = Txid::from_byte_array([0xBB; 32]); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); - let rounds = [(counterparty_txid, None), (tx.compute_txid(), Some(contribution))]; - let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; - let event = WalletEvent::TxUnconfirmed { - txid: counterparty_txid, - tx: Arc::new(dummy_tx()), - old_block_time: None, - }; - wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().unwrap(); - assert!( - matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], ); - + sign_and_observe_round(&wallet, &tx, &candidates).await; + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + // The prior pass failed the record and kept the intent, then crashed before removing + // the entry. wallet - .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) + .payment_stores + .payment_store() + .mutate(&id, |existing| { + let mut update = PaymentDetailsUpdate::new(id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) .await .unwrap(); - - let payment = wallet + let kept_id = PaymentId([32u8; 32]); + wallet .payment_stores - .payment_store() - .get(&id) + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(kept_id, intent.clone())) .await - .unwrap() - .expect("the record stays"); - assert_eq!(payment.status, PaymentStatus::Failed); - assert!(matches!( - payment.kind, - PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } - if txid == counterparty_txid - )); + .unwrap(); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + assert_eq!( + bare_entries_carrying(&wallet, &intent).await, + vec![PendingPaymentDetails::pending_splice(kept_id, intent)], + ); } /// A round of ours nothing had broadcast when the counterparty's round locked — our @@ -9615,6 +12263,95 @@ mod tests { } } + /// The funding output a channel reports is what classifies the transaction that opens it; the + /// report does not have to say so separately. + #[tokio::test] + async fn a_funding_transaction_is_classified_by_the_output_it_creates() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let funding = wallet_paying_tx(&wallet, 5); + let funding_txid = funding.compute_txid(); + insert_unconfirmed_tx(&wallet, funding.clone()); + + wallet + .record_channel_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + &channel, + None, + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(); + observe_unconfirmed(&wallet, &funding).await; + + let payment = wallet + .payment_stores + .payment_store() + .get(&PaymentId(funding_txid.to_byte_array())) + .await + .unwrap() + .expect("wallet sync records the funding transaction"); + match payment.kind { + PaymentKind::Onchain { + tx_type: Some(TransactionType::Funding { channels }), .. + } => { + assert_eq!(channels, vec![channel]); + }, + kind => panic!("unexpected kind {:?}", kind), + } + } + + /// One transaction can open several channels, each of which reports only the output that funds + /// it. The reports of one transaction are kept together, so the payment lists every channel + /// opened — which a report stating outright what the transaction is could not do, as the second + /// channel's would contradict the first's. + #[tokio::test] + async fn a_batched_funding_lists_every_channel_it_opens() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let first = Channel { counterparty_node_id, channel_id }; + let second = Channel { counterparty_node_id, channel_id: ChannelId([8u8; 32]) }; + + let mut funding = wallet_paying_tx(&wallet, 6); + funding.output.push(funding.output[0].clone()); + let funding_txid = funding.compute_txid(); + insert_unconfirmed_tx(&wallet, funding.clone()); + + for (vout, channel) in [(0u32, &first), (1, &second)] { + wallet + .record_channel_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + channel, + None, + ChannelOutputRole::Funding, + [vout], + )) + .await + .unwrap(); + } + observe_unconfirmed(&wallet, &funding).await; + + let payment = wallet + .payment_stores + .payment_store() + .get(&PaymentId(funding_txid.to_byte_array())) + .await + .unwrap() + .expect("wallet sync records the funding transaction"); + match payment.kind { + PaymentKind::Onchain { + tx_type: Some(TransactionType::Funding { channels }), .. + } => { + assert_eq!(channels, vec![first, second]); + }, + kind => panic!("unexpected kind {:?}", kind), + } + } + /// The node's channel state as a test dictates it: the channels its channel manager lists, /// the monitors its chain monitor has, and the outputs its sweeper tracks, for retention; and /// its channel outputs, for the startup pass. @@ -10215,4 +12952,189 @@ mod tests { payment.kind, ); } + + /// The funding transaction this node builds for a channel it opens is described however full + /// the store is. The number of records the store admits bounds what a counterparty drives; a + /// channel this node chose to fund is admitted beside them, because without the funding + /// output on record the funding transaction and every transaction spending it, the channel's + /// close among them, is reported without a classification. + #[tokio::test] + async fn a_full_store_still_describes_a_funding_this_node_builds() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + + // The store contains as many records as it may, so a transaction a capped producer + // reports on is refused room. + wallet.facts_retention.walk_completed(CHANNEL_TX_FACTS_MAX_RECORDS); + assert_eq!( + wallet + .record_channel_tx_facts(ChannelTxFacts::new(Txid::from_byte_array([48u8; 32]))) + .await + .unwrap(), + FactsRecordOutcome::Incomplete, + ); + + let funding_txid = Txid::from_byte_array([49u8; 32]); + assert_eq!( + wallet + .record_own_funding_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + &channel, + None, + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(), + FactsRecordOutcome::Recorded, + "a funding this node builds is described however full the store is", + ); + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); + + // A close spending the funding output is classified from the record. + let close = tx_spending(&wallet, OutPoint { txid: funding_txid, vout: 0 }); + let close_txid = close.compute_txid(); + insert_unconfirmed_tx(&wallet, close.clone()); + wallet + .update_payment_store(vec![WalletEvent::TxUnconfirmed { + txid: close_txid, + tx: Arc::new(close), + old_block_time: None, + }]) + .await + .unwrap(); + let payment = wallet + .payment_stores + .payment_store() + .get(&PaymentId(close_txid.to_byte_array())) + .await + .unwrap() + .expect("wallet sync records the transaction"); + assert!( + matches!( + payment.kind, + PaymentKind::Onchain { + tx_type: Some(TransactionType::CooperativeClose { .. }), + .. + } + ), + "unexpected kind {:?}", + payment.kind, + ); + } + + #[tokio::test] + async fn recording_a_transaction_of_an_advanced_payment_removes_its_leftover_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([23u8; 32]); + let txid = Txid::from_byte_array([24u8; 32]); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, test_splice_intent())) + .await + .unwrap(); + // Wallet sync confirmed the payment through `ANTI_REORG_DELAY` before it got to write the + // entry: the payment graduated, so no entry belongs in the pending store... + wallet + .payment_stores + .payment_store() + .insert(funding_payment(id, txid, PaymentStatus::Succeeded)) + .await + .unwrap(); + + let stores = wallet.payment_stores.lock().await; + wallet + .upsert_pending_payment( + &stores, + funding_payment(id, txid, PaymentStatus::Pending), + Vec::new(), + ) + .await + .unwrap(); + drop(stores); + + // ...and the splice behind the intent confirmed, so the leftover intent record is removed + // rather than left to look like a splice still in flight after a restart. + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn a_leftover_intent_that_tracks_a_signed_round_is_kept() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([23u8; 32]); + let txid = Txid::from_byte_array([24u8; 32]); + let mut entry = PendingPaymentDetails::pending_splice(id, test_splice_intent()); + entry.candidates = vec![FundingTxCandidate { + txid: Txid::from_byte_array([25u8; 32]), + amount_msat: Some(1_000), + fee_paid_msat: Some(10), + awaiting_broadcast: true, + }]; + wallet.payment_stores.pending_payment_store().insert(entry).await.unwrap(); + wallet + .payment_stores + .payment_store() + .insert(funding_payment(id, txid, PaymentStatus::Succeeded)) + .await + .unwrap(); + + let stores = wallet.payment_stores.lock().await; + wallet + .upsert_pending_payment( + &stores, + funding_payment(id, txid, PaymentStatus::Pending), + Vec::new(), + ) + .await + .unwrap(); + drop(stores); + + // A round this node signed is live state of its own: the entry is what + // `drop_abandoned_splice_rounds` takes it back through, so it is not a leftover. + let kept = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert_eq!(kept.candidates().len(), 1); + } + + #[tokio::test] + async fn a_payment_that_already_advanced_gets_no_pending_entry() { + 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 sync confirmed the payment through `ANTI_REORG_DELAY` before a writer with a + // `Pending` copy read earlier got to the pending store: the payment graduated, so no entry + // belongs there. + wallet + .payment_stores + .payment_store() + .insert(funding_payment(id, txid, PaymentStatus::Succeeded)) + .await + .unwrap(); + + let stores = wallet.payment_stores.lock().await; + wallet + .upsert_pending_payment( + &stores, + funding_payment(id, txid, PaymentStatus::Pending), + Vec::new(), + ) + .await + .unwrap(); + drop(stores); + + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } } diff --git a/src/wallet/payment_stores.rs b/src/wallet/payment_stores.rs index e00f5d598e..5ab26a7432 100644 --- a/src/wallet/payment_stores.rs +++ b/src/wallet/payment_stores.rs @@ -8,6 +8,7 @@ //! The wallet's payment stores behind one API, so that every write the wallet makes to them //! happens under the lock that keeps a payment record and its pending-store entry consistent. +use std::future::Future; use std::ops::Deref; use std::sync::Arc; @@ -31,6 +32,10 @@ use crate::Error; /// write compiles only for a holder of the lock. The reads are methods of this type and take no /// lock; a caller whose write depends on what it read takes the lock first and reads through the /// guard. +/// +/// The payment store is bounded, so nothing here lists it: a transaction is mapped to its payment +/// through the pending store, which keeps every entry in memory, and through what was recorded +/// about the transaction, which is read by its id. pub(super) struct PaymentStores { payment_store: Arc, pending_payment_store: Arc, @@ -118,6 +123,16 @@ impl PaymentStoresGuard<'_> { self.stores.payment_store.remove(id).await } + /// Removes the payment record stored under `id` only while `predicate` holds for it, in one + /// critical section of the store; see + /// [`DataStore::remove_if`](crate::data_store::DataStore::remove_if). Returns whether the + /// record was removed. + pub(super) async fn remove_payment_if bool>( + &self, id: &PaymentId, predicate: F, + ) -> Result { + self.stores.payment_store.remove_if(id, predicate).await + } + /// Transforms the payment record stored under `id` through `f` and persists the result, all /// in one critical section of the store; see /// [`DataStore::mutate`](crate::data_store::DataStore::mutate). @@ -130,11 +145,28 @@ impl PaymentStoresGuard<'_> { self.stores.payment_store.mutate(id, f).await } + /// Stores `entry`, overwriting the entry already stored under its id, if any. + pub(super) async fn insert_pending_payment( + &self, entry: PendingPaymentDetails, + ) -> Result<(), Error> { + self.stores.pending_payment_store.insert(entry).await + } + /// Removes the pending-store entry stored under `id`, if any. pub(super) async fn remove_pending_payment(&self, id: &PaymentId) -> Result<(), Error> { self.stores.pending_payment_store.remove(id).await } + /// Removes the pending-store entry stored under `id` only while `predicate` holds for it, in + /// one critical section of the store; see + /// [`DataStore::remove_if`](crate::data_store::DataStore::remove_if). Returns whether the + /// entry was removed. + pub(super) async fn remove_pending_payment_if bool>( + &self, id: &PaymentId, predicate: F, + ) -> Result { + self.stores.pending_payment_store.remove_if(id, predicate).await + } + /// Transforms the pending-store entry stored under `id` through `f` and persists the result, /// all in one critical section of the store; see /// [`DataStore::mutate`](crate::data_store::DataStore::mutate). @@ -146,4 +178,16 @@ impl PaymentStoresGuard<'_> { { self.stores.pending_payment_store.mutate(id, f).await } + + /// [`Self::mutate_pending_payment`] with a transformation that awaits fallible reads; see + /// [`DataStore::mutate_async`](crate::data_store::DataStore::mutate_async). + pub(super) async fn mutate_pending_payment_async( + &self, id: &PaymentId, f: F, + ) -> Result, Error> + where + F: FnOnce(Option) -> Fut, + Fut: Future, Error>>, + { + self.stores.pending_payment_store.mutate_async(id, f).await + } } diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index 94523de9d1..445f0d37b3 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -335,7 +335,8 @@ impl ChannelTxFacts { /// A record is written whole, so its size is the one resource a producer drives without /// creating a record of its own: every channel-controlled output of a transaction lands on /// that transaction's record, and a counterparty decides how many HTLCs a commitment - /// transaction carries. What a refused report would have described stays unclassifiable. + /// transaction carries. What a refused report would have described stays undescribed, and + /// what that costs is for the producer that reported it to weigh. pub(crate) fn size_checked(self) -> Result { let bytes = self.serialized_length(); if bytes > CHANNEL_TX_FACTS_MAX_RECORD_BYTES { @@ -354,11 +355,36 @@ pub(crate) enum FactsRecordOutcome { /// Everything reported is on record. Recorded, /// Part of what was reported is not on record, because recording it would have taken the - /// facts past the resources they are allowed. Transactions that would have been classified - /// from the missing part are reported without a classification instead. + /// facts past the resources they are allowed. Nothing was lost, so what the refusal costs + /// is the reporting producer's to weigh: a transaction reported without a classification + /// for a producer that has nothing left to withhold, a round left unsigned for one that + /// will not release a transaction it cannot measure. Incomplete, } +/// Whether a report about a transaction this node has no record of at all is subject to the +/// number of records the store may contain. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FactsAdmission { + /// Refused once the store contains as many records as it is allowed to. + /// + /// This is what bounds the store: a counterparty opening and closing channels, or replacing + /// a negotiated funding again and again, drives records about transactions this node only + /// reports on, and each of them costs nothing to refuse beyond a transaction going + /// unclassified. + Capped, + /// Admitted beside however many records the store contains, and counted like any other, so + /// that capped reports are refused the sooner. + /// + /// This is for the reports whose refusal costs more than the record: the funding transaction + /// this node builds for a channel it opens, without which the funding and every transaction + /// spending it go unclassified, and a round this node is about to sign, which it will not + /// release without its own share of it on record. How many such records there can be is a + /// question of how many channels this node funds and rounds it signs, which is its own + /// decision. + Exempt, +} + /// One of this node's channel outputs, which only LDK will spend: the funding output of a channel /// the channel manager lists or the chain monitor watches, or an output a channel resolved to /// this node that the sweeper tracks. diff --git a/tests/common/logging.rs b/tests/common/logging.rs index 5e2f2e5dcf..1f667aae4d 100644 --- a/tests/common/logging.rs +++ b/tests/common/logging.rs @@ -197,17 +197,21 @@ impl CollectingLogWriter { self.logs.lock().unwrap().clone() } - /// Waits up to ten seconds for a logged message containing `text`, returning whether one - /// arrived. Polling beats a fixed sleep: it returns as soon as the line lands and only pays - /// the full timeout when the line never comes. + /// Waits up to [`INTEROP_TIMEOUT_SECS`] for a logged message containing `text`, returning + /// whether one arrived. Polling beats a fixed sleep: it returns as soon as the line lands and + /// only pays the full timeout when the line never comes. + /// + /// [`INTEROP_TIMEOUT_SECS`]: super::INTEROP_TIMEOUT_SECS pub(crate) async fn wait_for(&self, text: &str) -> bool { self.wait_for_count(text, 1).await } - /// Waits up to ten seconds for `occurrences` logged messages containing `text`, returning - /// whether they arrived. + /// Waits up to [`INTEROP_TIMEOUT_SECS`] for `occurrences` logged messages containing `text`, + /// returning whether they arrived. + /// + /// [`INTEROP_TIMEOUT_SECS`]: super::INTEROP_TIMEOUT_SECS pub(crate) async fn wait_for_count(&self, text: &str, occurrences: usize) -> bool { - for _ in 0..100 { + for _ in 0..(super::INTEROP_TIMEOUT_SECS * 10) { if self.count(text) >= occurrences { return true; } @@ -222,3 +226,30 @@ impl LogWriter for CollectingLogWriter { self.logs.lock().unwrap().push(record.args.to_string()); } } + +/// Forwards every record to an inner [`CollectingLogWriter`] and signals `seen` when a record +/// contains `marker`. The signal fires from inside the logging call, so a test can react within +/// the emitting code path's timing — where the collector's polling `wait_for` (100ms granularity) +/// is too coarse. +pub(crate) struct MarkerLogWriter { + inner: Arc, + marker: &'static str, + seen: Arc, +} + +impl MarkerLogWriter { + pub(crate) fn new( + inner: Arc, marker: &'static str, seen: Arc, + ) -> Self { + Self { inner, marker, seen } + } +} + +impl LogWriter for MarkerLogWriter { + fn log(&self, record: LogRecord) { + if record.args.to_string().contains(self.marker) { + self.seen.notify_one(); + } + LogWriter::log(&*self.inner, record); + } +} diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 080fb8c359..69352609ad 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -21,7 +21,8 @@ use bitcoin::hashes::Hash; use bitcoin::hex::DisplayHex; use bitcoin::{Address, Amount, FeeRate, ScriptBuf, Transaction, Txid}; use common::logging::{ - init_log_logger, validate_log_entry, CollectingLogWriter, MultiNodeLogger, TestLogWriter, + init_log_logger, validate_log_entry, CollectingLogWriter, MarkerLogWriter, MultiNodeLogger, + TestLogWriter, }; use common::{ bump_fee_and_broadcast, configure_chain_source, distribute_funds_unconfirmed, @@ -49,7 +50,10 @@ use ldk_node::payment::{ ConfirmationStatus, ForwardedPaymentId, PayerProofOptions, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType, UserChannelId}; +use ldk_node::{ + BuildError, Builder, Event, Node, NodeError, ReserveType, SpliceFailureReason, + SpliceParameters, UserChannelId, +}; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::ln::channelmanager::{PaymentId, BREAKDOWN_TIMEOUT}; use lightning::routing::gossip::{NodeAlias, NodeId}; @@ -60,10 +64,43 @@ use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; use serde_json::json; -/// Waits until `node` has recorded the funding broadcast `funding_txid` (a channel open or splice -/// candidate) as a payment carrying a `tx_type`, syncing its wallet until it has. Wallet sync -/// records the payment when it first observes the transaction, so the sync is what settles this, -/// and a chain source can lag the broadcast, so one `sync_wallets` call may not observe it yet. +/// Pops the next event, panicking unless it is a `SpliceNegotiationFailed` from the given +/// counterparty, and returns its reason and parameters. +macro_rules! expect_splice_negotiation_failed_event { + ($node:expr, $counterparty_node_id:expr) => {{ + let event = tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + $node.next_event_async(), + ) + .await + .unwrap_or_else(|_| { + panic!("{} timed out waiting for SpliceNegotiationFailed event", $node.node_id()) + }); + match event { + ref e @ Event::SpliceNegotiationFailed { + counterparty_node_id, + ref reason, + ref parameters, + .. + } => { + println!("{} got event {:?}", $node.node_id(), e); + assert_eq!(counterparty_node_id, $counterparty_node_id); + let reason = reason.clone(); + let parameters = parameters.clone(); + $node.event_handled().unwrap(); + (reason, parameters) + }, + ref e => { + panic!("{} got unexpected event!: {:?}", std::stringify!($node), e); + }, + } + }}; +} + +/// Waits until `node` has recorded the funding transaction `funding_txid` (a channel open or splice +/// round) as a payment carrying a `tx_type`, syncing its wallet until it has. The payment is +/// recorded when wallet sync first observes the transaction, which a sync can run too early for: +/// the chain source may lag the broadcast. async fn wait_for_classified_funding_payment(node: &Node, funding_txid: Txid) { let poll = async { loop { @@ -2899,9 +2936,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!( @@ -2952,9 +2987,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 @@ -2979,6 +3012,75 @@ async fn splice_channel() { ); } +/// Two splices of this node in flight on a zero-conf channel — the second submitted right after +/// the first locked — are two payments: the second splice takes an intent record of its own +/// rather than the first splice's, whose record keeps the first splice's transaction. The lock +/// handler settles the first splice's intent before the second is submitted, so this guards +/// behavior in place before one record per splice rather than failing without it. +/// +/// Pinned to Esplora, as the sibling below is: both wait for a record of a splice round that is +/// still unconfirmed, and wallet sync creates it from what the wallet has seen. A bitcoind chain +/// source learns an unconfirmed transaction from its mempool poll, which offers a mempool entry +/// to the wallet once, so a round that falls outside that one offer reaches the wallet only when +/// it confirms. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn zero_conf_queued_splice_is_recorded_as_its_own_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let node_a = setup_node(&chain_source, random_config()); + let mut config_b = random_config(); + config_b.node_config.trusted_peers_0conf.push(node_a.node_id()); + let node_b = setup_node(&chain_source, config_b); + + // Two coins: the second splice-in below cannot spend the first one's unconfirmed change. + let address_a = node_a.onchain_payment().new_address().unwrap(); + let second_address_a = node_a.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, second_address_a], + Amount::from_sat(5_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 2_000_000, false, &electrsd).await; + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000, None).unwrap(); + let first = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, first.txid).await; + // The zero-conf splice locks without confirmations, re-signaled as `ChannelReady`. + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + node_a.sync_wallets().unwrap(); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000, None).unwrap(); + let second = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, second.txid).await; + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let first_payment = funding_payment(&node_a, first.txid); + let second_payment = funding_payment(&node_a, second.txid); + assert_ne!(first_payment.id, second_payment.id, "each splice must have a record of its own"); + for payment in [&first_payment, &second_payment] { + assert!(matches!( + payment.kind, + PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } + )); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn splice_channel_with_fee_rate() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -3187,15 +3289,20 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // classification — the `tx_type` assertion below catches a regression deterministically. wait_for_tx(&electrsd.client, original_txo.txid).await; wait_for_classified_funding_payment(&node_b, original_txo.txid).await; + // The record's random id is fixed at creation; capture it while the original candidate is + // current so its stability can be asserted across the RBF rounds below. + let splice_payment_id = funding_payment(&node_b, original_txo.txid).id; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); // For `confirm_original`, capture the original candidate's fee and raw transaction now, before // the RBF replaces it, so it can be force-confirmed (instead of the RBF) further below. let original_candidate: Option<(Option, String)> = if confirm_original { - let payment_id = PaymentId(original_txo.txid.to_byte_array()); - let fee = - node_b.payment(&payment_id).unwrap().expect("splice payment exists").fee_paid_msat; + let fee = node_b + .payment(&splice_payment_id) + .unwrap() + .expect("splice payment exists") + .fee_paid_msat; let raw_tx: String = bitcoind .client .call("getrawtransaction", &[json!(original_txo.txid.to_string())]) @@ -3226,12 +3333,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, @@ -3305,8 +3411,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 { .. }, .. } => { @@ -3366,8 +3472,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 { @@ -3430,8 +3535,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, @@ -3601,6 +3706,8 @@ fn only_signed_round_txid(logs: &CollectingLogWriter) -> Txid { } /// `node`'s payment for the funding transaction `funding_txid`, which it must have recorded. +/// Funding records are keyed by a random id generated at creation, so they are found through their +/// transaction history rather than by deriving an id from a txid. fn funding_payment(node: &TestNode, funding_txid: Txid) -> PaymentDetails { node.list_all_payments() .into_iter() @@ -4171,7 +4278,8 @@ async fn splice_round_superseded_on_an_open_channel_fails_its_payment() { /// /// LDK reports the round itself after `ChannelClosed`: a `DiscardFunding` for node A's /// contribution, whose handling reclaims its addresses, and a `SpliceNegotiationFailed` the node -/// passes on. +/// reports with reason `ChannelClosing` and no parameters, its intent having been cleared at the +/// close. #[cfg(feature = "chain-esplora")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { @@ -4201,7 +4309,9 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { node_a.disconnect(node_b.node_id()).unwrap(); node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); expect_event!(node_a, ChannelClosed); - expect_event!(node_a, SpliceNegotiationFailed); + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::ChannelClosing)); + assert_eq!(parameters, None, "the intent outlived the close"); assert!( logs_a.wait_for_count(RECLAIMED_ADDRESSES, reclaimed_a + 1).await, "node A's contribution to the discarded round was not reclaimed" @@ -4391,9 +4501,9 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { .filter(|outpoint| spends(&first_round, *outpoint)) .collect(); assert_eq!(shared, vec![funding_txo], "the bump reused an input of the first round"); - let payment_id = PaymentId(first_txo.txid.to_byte_array()); - let payment = node_a.payment(&payment_id).unwrap().expect("the splice has a payment"); + let payment = funding_payment(&node_a, bump_txo.txid); assert_eq!(payment.status, PaymentStatus::Pending); + let payment_id = payment.id; // Neither node reconnects to the other: node B closes on its own and node A learns of the // close from the chain alone. The commitment conflicts with the bump in the mempool: let it @@ -4464,6 +4574,593 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { node_a.stop().unwrap(); } +/// A mid-negotiation failure is surfaced to the user exactly once: the initiator disconnects +/// while the interactive negotiation is in flight, LDK fails the splice with `PeerDisconnected`, +/// and one `SpliceNegotiationFailed` — carrying the reason and the originating request's +/// parameters — reports it. The splice is not retried automatically; the application initiates a +/// new one, which completes. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_failure_surfaced_after_disconnect_mid_negotiation() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // The negotiation is synchronized through a log marker: LDK's peer handler logs every received + // message, and the counterparty's `splice_ack` is the earliest point where a disconnect fails + // the splice — any sooner and the contribution is still queued, which LDK resumes on reconnect + // by itself and no failure occurs. + let logger_a = Arc::new(CollectingLogWriter::new()); + let splice_ack_seen = Arc::new(tokio::sync::Notify::new()); + let mut config_a = random_config(); + config_a.log_writer = TestLogWriter::Custom(Arc::new(MarkerLogWriter::new( + logger_a.clone(), + "Received message SpliceAck", + splice_ack_seen.clone(), + ))); + // `Node::disconnect` persists a peer-store removal before severing the connection, and the + // negotiation keeps running during that write. The default composite test store turns it into + // several fsyncs plus a cross-store comparison, wide enough to lose the race below; a plain + // SQLite store keeps it to a single quick write. + config_a.store_type = TestStoreType::Sqlite; + let node_a = setup_node(&chain_source, config_a); + let node_b = setup_node(&chain_source, random_config()); + + // Fund Node A with many small UTXOs: every input the splice contributes adds an interactive-tx + // round trip, stretching the negotiation so the disconnect below reliably lands inside it. + let addresses_a: Vec
= + (0..240).map(|_| node_a.onchain_payment().new_address().unwrap()).collect(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + addresses_a, + Amount::from_sat(32_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 95 of the 32k-sat UTXOs into the contribution. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 3_000_000, None).unwrap(); + + // Disconnect as soon as the negotiation is in flight. The negotiation keeps running while the + // disconnect is processed — `Node::disconnect` first persists a peer-store removal on the + // node's own runtime, which the negotiation is keeping busy — so in principle the negotiation + // could still complete first, the disconnect would then not fail the splice (a signed round is + // resumed on reconnect rather than failed) and the failure-event assert below would trip. The + // ~95 remaining per-input round trips make that window wide enough to survive a heavily loaded + // machine; if this ever flakes, widen the contribution further. + tokio::time::timeout(std::time::Duration::from_secs(10), splice_ack_seen.notified()) + .await + .expect("node A never received splice_ack"); + 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, None).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, None).unwrap(); + + let onchain_balance_before_sat = node_a.list_balances().total_onchain_balance_sats; + node_a.stop().unwrap(); + (onchain_balance_before_sat, address, user_channel_id_a) + }; + + // A funding-record write of wallet sync's cut short after its payment-store half leaves a + // payment record under the splice's intent that is not indexed by any pending entry. Plant one + // while the node is down: the intent's settlement at startup must take it along rather than + // leave a payment that would never be driven. + let half_written_txid = { + use bitcoin::hashes::hex::FromHex; + use ldk_node::io::sqlite_store::{SqliteStore, KV_TABLE_NAME, SQLITE_DB_FILE_NAME}; + use lightning::util::ser::Writeable; + + let store = SqliteStore::new( + config_a.node_config.storage_dir_path.clone().into(), + Some(SQLITE_DB_FILE_NAME.to_string()), + Some(KV_TABLE_NAME.to_string()), + ) + .unwrap(); + let payment_keys: HashSet = + store.list("payments", "").await.unwrap().into_iter().collect(); + let bare_intent_keys: Vec = store + .list("pending_payments", "") + .await + .unwrap() + .into_iter() + .filter(|key| !payment_keys.contains(key)) + .collect(); + assert_eq!(bare_intent_keys.len(), 1, "the dropped splice must have left one bare intent"); + let key = &bare_intent_keys[0]; + let id = PaymentId(<[u8; 32]>::from_hex(key).unwrap()); + let txid = Txid::from_byte_array([0xEE; 32]); + let half_written = PaymentDetails { + id, + kind: PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels: Vec::new() }), + }, + amount_msat: Some(500_000_000), + fee_paid_msat: Some(300_000), + direction: PaymentDirection::Outbound, + status: PaymentStatus::Pending, + latest_update_timestamp: 0, + }; + store.write("payments", "", key, half_written.encode()).await.unwrap(); + txid + }; + + // On restart, reconciliation finds no round behind the intent in LDK, releases whatever the + // wallet still reserved for it, and drops the record — with the half-written payment under its + // id — without an event of its own. The one failure surfaced is LDK's replay of the event it + // persisted at shutdown for the dropped contribution — carrying no parameters, since the record + // it would match is already gone. + let node_a = setup_node(&chain_source, config_a.clone()); + node_a.sync_wallets().unwrap(); + + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, None); + assert!( + node_a + .list_payments_matching( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == half_written_txid) + ) + .is_empty(), + "the half-written record under the dropped splice's intent must go with it", + ); + + // The replayed failure was consumed, so another restart must not report it again. + node_a.stop().unwrap(); + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a consumed splice failure must not be reported again"); + + // The application initiates a new splice-out, which completes. + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + node_a + .splice_out(&user_channel_id_a, node_b.node_id(), &splice_out_address, 500_000, None) + .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 tracking 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, None).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(), None).unwrap(); + node_a.stop().unwrap(); + (original_txo, user_channel_id_a) + }; + + // On restart, reconciliation keeps the record — LDK still tracks 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(), None).unwrap(); + + let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + assert_ne!(original_txo, rbf_txo, "the new fee bump should produce a different funding txo"); + + // Restarting again must stay silent: the negotiated splice now carries the bump at the + // intended feerate. + node_a.stop().unwrap(); + let node_a = setup_node(&chain_source, config_a.clone()); + node_a.sync_wallets().unwrap(); + node_a.connect(node_b.node_id(), node_b_addr.clone(), false).unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a carried fee bump must not be reported as lost"); + + wait_for_tx(&electrsd.client, rbf_txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // The locked fee bump cleared its intent, so a further restart must stay silent. + node_a.stop().unwrap(); + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a locked fee bump must produce no events"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice queued behind a pending splice of this node on a confirmed channel — accepted once +/// the pending round has a confirmation — is a splice of its own, with an intent record of its +/// own. Graduating the pending splice's payment removes that splice's record, and the queued +/// splice's survives it: a restart fails the queued contribution LDK never got to negotiate, and +/// the replayed failure is described from the queued splice's own intent. Before one record per +/// splice, the queued intent rode on the pending splice's record and was lost with it, so the +/// failure carried no parameters. +/// +/// Pinned to Esplora so the nodes sync only when told to: the pending splice's lock needs the +/// counterparty's `splice_locked`, which it sends only once it has seen the confirmations. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn queued_splice_failure_surfaced_after_restart() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let node_b = setup_node(&chain_source, random_config()); + + let (pending_txid, pending_payment_id, node_b_addr) = { + let node_a = setup_node(&chain_source, config_a.clone()); + + // Two coins for node_a: the queued splice-in cannot spend the pending one's unconfirmed + // change. + let address_a = node_a.onchain_payment().new_address().unwrap(); + let second_address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, second_address_a, address_b], + Amount::from_sat(5_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000, None).unwrap(); + let pending = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, pending.txid).await; + + // With one confirmation, seen by node_a alone, LDK takes a further splice-in as a splice + // of its own, queued until the pending one locks. Queueing it starts a quiescence + // handshake LDK breaks off with a warning until then, disconnecting the peers. + wait_for_tx(&electrsd.client, pending.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 300_000, None).unwrap(); + + // Five more confirmations graduate the pending splice's payment on node_a, removing its + // record, while its lock still waits on node_b. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + node_a.sync_wallets().unwrap(); + let pending_payment = funding_payment(&node_a, pending.txid); + assert_eq!(pending_payment.status, PaymentStatus::Succeeded); + + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.stop().unwrap(); + (pending.txid, pending_payment.id, node_b_addr) + }; + + // LDK failed the queued contribution when it was last persisted and replays the failure at + // startup. The queued splice's own record survived the pending splice's graduation, so the + // failure is described from it. + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, Some(SpliceParameters::In { amount_sats: 300_000 })); + + // The pending splice locks once node_b catches up, under its one record: the one that + // graduated before the restart, not a second one the lock or the sync created. + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + node_b.sync_wallets().unwrap(); + node_a.sync_wallets().unwrap(); + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + let pending_payments = node_a.list_payments_matching( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == pending_txid), + ); + assert_eq!(pending_payments.len(), 1); + assert_eq!(pending_payments[0].id, pending_payment_id); + assert_eq!(pending_payments[0].status, PaymentStatus::Succeeded); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice confirmed while its node was offline keeps exactly one payment record under its +/// splice-time id across the restart, whether or not wallet sync had observed the transaction +/// before the node stopped. +#[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, None).unwrap(); + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + + // Stop node_a as soon as the splice is negotiated. node_b broadcasts the transaction + // either way, so it reaches the chain while node_a is offline. node_a recorded the round + // when it signed the funding transaction; depending on timing, its wallet sync may or may + // not also have observed the transaction 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 must file the confirmed transaction under the splice-time + // `PaymentId`, whether or not it had observed the transaction before the stop: 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();