diff --git a/CHANGELOG.md b/CHANGELOG.md index 2261dbeadc..eb414b7474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,9 +35,23 @@ `Config::manually_handle_unknown_bolt11_payments` to `true`. Otherwise matching inbound HTLCs will be failed back instead of emitting `Event::PaymentClaimable`. +- The funding payment of a channel opened through a `Node::open_channel` variant is now + recorded under the `PaymentId` returned in the `ChannelOpen` result, rather than under an id + derived from the funding transaction's txid. Channels opened before upgrading, and channels + this node opens as an LSPS2 service, keep the txid-derived id. Applications that derived the + payment id from the funding txid should use the returned id instead. +- An outbound channel open that is still in flight at shutdown may be reported through + `Event::ChannelClosed` at the next startup, with the `user_channel_id` of the open and the + `ClosureReason::DisconnectedPeer` reason. Such an event may be emitted more than once for the + same open. Applications should handle a `ChannelClosed` event for a `user_channel_id` they do + not hold a channel for. ## Feature and API updates - `Node::splice_out_max` withdraws the maximum available channel balance to an on-chain address. +- All `Node::open_channel` variants now return a `ChannelOpen` holding the `user_channel_id` + of the channel and the `PaymentId` the funding payment is recorded under. Once a variant + returns `Ok`, the open is tracked across restarts: an open the node loses while shut down is + reported through `Event::ChannelClosed` rather than disappearing silently. - All `Node::open_channel` variants now take an optional `FeeRate` (after `push_to_counterparty_msat`) that, when set, is used for the channel's funding transaction instead of the internal fee estimation. For the `_with_all` variants it also determines the rate at which the maximum funding amount is diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index febc94d91c..4685d18f0e 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -80,17 +80,17 @@ interface Node { [Throws=NodeError] void disconnect(PublicKey node_id); [Throws=NodeError] - UserChannelId open_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); + ChannelOpen open_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); [Throws=NodeError] - UserChannelId open_announced_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); + ChannelOpen open_announced_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); [Throws=NodeError] - UserChannelId open_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); + ChannelOpen open_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); [Throws=NodeError] - UserChannelId open_announced_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); + ChannelOpen open_announced_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); [Throws=NodeError] - UserChannelId open_0reserve_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); + ChannelOpen open_0reserve_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); [Throws=NodeError] - UserChannelId open_0reserve_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); + ChannelOpen open_0reserve_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); [Throws=NodeError] void splice_in([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, u64 splice_amount_sats, FeeRate? min_fee_rate); [Throws=NodeError] @@ -392,6 +392,8 @@ typedef string ChannelId; [Custom] typedef string UserChannelId; +typedef dictionary ChannelOpen; + typedef interface Mnemonic; [Remote] diff --git a/src/builder.rs b/src/builder.rs index 21f15508b4..1009cf290e 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -55,6 +55,7 @@ use lightning_dns_resolver::OMDomainResolver; use vss_client::headers::VssHeaderProvider; use crate::chain::ChainSource; +use crate::channel::{OpenChannelTracker, SpliceTracker}; #[cfg(feature = "chain-bitcoind")] use crate::config::BitcoindRestClientConfig; use crate::config::{ @@ -731,7 +732,7 @@ impl NodeBuilder { /// previously configured. /// /// Connects to the PostgreSQL database at the given `connection_string`, e.g., - /// `"postgres://user:password@localhost/ldk_db"`. + /// `"postgres://:@localhost/ldk_db"`. /// /// The given `db_name` will be used or default to /// [`DEFAULT_DB_NAME`](io::postgres_store::DEFAULT_DB_NAME). The `connection_string` must @@ -1334,7 +1335,7 @@ impl Builder { /// previously configured. /// /// Connects to the PostgreSQL database at the given `connection_string`, e.g., - /// `"postgres://user:password@localhost/ldk_db"`. + /// `"postgres://:@localhost/ldk_db"`. /// /// The given `db_name` will be used or default to /// [`DEFAULT_DB_NAME`](io::postgres_store::DEFAULT_DB_NAME). The `connection_string` must @@ -2565,6 +2566,15 @@ 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), + )); + let open_channel_tracker = + Arc::new(OpenChannelTracker::new(Arc::clone(&pending_payment_store), Arc::clone(&logger))); + #[cfg(cycle_tests)] let mut _leak_checker = crate::LeakChecker(Vec::new()); #[cfg(cycle_tests)] @@ -2616,6 +2626,8 @@ fn build_with_store_internal( payment_store, forwarding_store, forwarded_payment_aggregation_retention_secs, + splice_tracker, + open_channel_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..ede603bc98 --- /dev/null +++ b/src/channel/mod.rs @@ -0,0 +1,1678 @@ +// 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. + +mod open; + +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; + +pub(crate) use open::{lost_open_close_event, ListedChannel, OpenChannelTracker}; + +/// 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 doesn't know of that round and won't report an 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), + open_channel_intent: None, + }) + .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)), + open_channel_intent: None, + }; + 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())), + open_channel_intent: None, + }) + .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), + open_channel_intent: 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 doesn't have +/// a contribution, so it doesn't claim any input. +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))); + + // A contribution without inputs or outputs only matches another empty one. + 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, even with a + /// counterparty-only round alongside that doesn't claim any part; 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 doesn't claim any input 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/channel/open.rs b/src/channel/open.rs new file mode 100644 index 0000000000..e1b2c00de5 --- /dev/null +++ b/src/channel/open.rs @@ -0,0 +1,500 @@ +// 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 channel opens this node initiated, so an open LDK has not durably +//! learned of yet can be recognized — and its loss reported — after a restart. + +use std::sync::Arc; + +use bitcoin::secp256k1::PublicKey; +use lightning::events::{ClosureReason, Event as LdkEvent}; +use lightning::ln::channelmanager::PaymentId; + +use crate::data_store::{StorableObject, UpdatableObject}; +use crate::logger::{log_error, log_info, LdkLogger, Logger}; +use crate::payment::pending_payment_store::{ + OpenChannelIntent, PendingPaymentDetails, PendingPaymentDetailsUpdate, +}; +use crate::types::{PendingPaymentStore, UserChannelId}; +use crate::wallet::random_payment_id; +use crate::Error; + +/// Tracks each channel open this node initiated through a persisted [`OpenChannelIntent`] for as +/// long as LDK is not guaranteed to remember the channel itself. LDK persists a channel only once +/// it is funded. An outbound channel whose `accept_channel` has not arrived when the peer +/// disconnects — which includes stopping the node — is kept in memory to be resumed, so it is +/// gone once the process restarts, with no event reporting it; one further along is closed with +/// `ClosureReason::DisconnectedPeer`, which LDK does persist and replay. +/// +/// The intent is written before the open is handed to LDK, undone when LDK rejects the open +/// synchronously, and cleared once LDK reports the channel pending or closed. The entry carrying +/// the intent is the one wallet sync later files the funding payment under, so the payment id +/// returned to the caller identifies the funding payment from the start. An intent still on +/// record at the next startup identifies an open that was in flight when the node stopped: +/// [`Self::reconcile`] settles the ones LDK still lists and reports the others as lost. +pub(crate) struct OpenChannelTracker { + pending_payment_store: Arc, + logger: Arc, +} + +/// A channel LDK lists, as [`OpenChannelTracker::reconcile`] matches the persisted intents +/// against it. +pub(crate) struct ListedChannel { + pub counterparty_node_id: PublicKey, + pub user_channel_id: UserChannelId, + /// Whether the channel is funded. LDK persists a channel only once it is; an unfunded channel + /// it lists is one it kept in memory across a disconnect, to resume it with the peer. + pub funded: bool, +} + +impl OpenChannelTracker { + pub(crate) fn new( + pending_payment_store: Arc, logger: Arc, + ) -> Self { + Self { pending_payment_store, logger } + } + + /// Persists `intent` under a fresh payment id — the id the funding payment will be recorded + /// under — before the open is handed to LDK, so that no event about the channel can precede + /// it. Returns the id. + pub(crate) async fn persist_intent( + &self, intent: OpenChannelIntent, + ) -> Result { + let payment_id = random_payment_id(); + self.pending_payment_store + .insert(PendingPaymentDetails::pending_open(payment_id, intent)) + .await?; + Ok(payment_id) + } + + /// Undoes an intent persisted for an open LDK then rejected synchronously, so that the next + /// startup does not report an open that never started. Best effort: a failure is logged and + /// the stale entry is left to be reported as a lost open. + pub(crate) async fn discard_persisted_intent( + &self, payment_id: PaymentId, intent: &OpenChannelIntent, + ) { + let result = self + .pending_payment_store + .remove_if(&payment_id, |record| { + record.details().is_none() && record.open_channel_intent() == Some(intent) + }) + .await; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to undo the intent of rejected channel open {}: a stale intent record may \ + be left behind: {}", + intent.user_channel_id.0, + e, + ); + } + } + + /// The payment id under which the funding of the live open of `user_channel_id` with + /// `counterparty_node_id` is to be recorded, if such an open is on record. + pub(crate) async fn funding_payment_id( + &self, counterparty_node_id: PublicKey, user_channel_id: UserChannelId, + ) -> Option { + self.pending_payment_store + .list_filter(|record| concerns_open(record, counterparty_node_id, user_channel_id)) + .await + .first() + .map(|record| record.id()) + } + + /// Clears the persisted intent of the open of `user_channel_id` with `counterparty_node_id` + /// once LDK reported its channel pending — LDK persists the channel from then on — or closed. + /// A record that tracks anything else stays with the intent cleared; one left with nothing to + /// track is removed. Idempotent, so a replayed event doesn't settle anything twice. Every + /// record of the open is attempted; the first failure is logged and returned, and the intent + /// it left behind is settled, or reported again, by the next startup's [`Self::reconcile`]. + pub(crate) async fn settle( + &self, counterparty_node_id: PublicKey, user_channel_id: UserChannelId, + ) -> Result<(), Error> { + let records = self + .pending_payment_store + .list_filter(|record| concerns_open(record, counterparty_node_id, user_channel_id)) + .await; + let mut first_error = None; + for record in records { + let payment_id = record.id(); + let result: Result<(), Error> = async { + let mut remove_bare_record = false; + let removal_flag = &mut remove_bare_record; + self.pending_payment_store + .mutate(&payment_id, move |existing| { + let record = existing?; + if !concerns_open(record, counterparty_node_id, user_channel_id) { + return None; + } + let replacement = record_with_open_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.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_none() + && concerns_open(record, counterparty_node_id, user_channel_id) + }) + .await?; + } + Ok(()) + } + .await; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to clear the persisted intent of channel open {} (payment {}): a stale \ + intent record may be left behind: {}", + user_channel_id.0, + payment_id, + e, + ); + first_error.get_or_insert(e); + } + } + first_error.map_or(Ok(()), Err) + } + + /// Reconciles the persisted intents against the channels LDK lists (`listed`), at startup + /// after LDK replayed the events it persisted. An intent whose channel LDK lists funded is + /// settled without a report: LDK persists the channel from then on and reports whatever + /// becomes of it. One whose channel LDK lists unfunded is left alone: LDK has the channel in + /// memory only, kept across a disconnect to resume it with the peer — the node was stopped + /// and started again without the process restarting — and the event LDK reports about it in + /// the end settles the intent. One whose channel LDK does not list is gone with no event: its + /// intent is returned for the caller to report as a lost open, and settled by that report. + /// + /// Nothing is reported unless the replay completed (`replay_complete`): an event the handler + /// failed on stays queued in LDK along with every event behind it, and may be the + /// `ChannelClosed` or `ChannelPending` of a recorded open. Reporting the open as lost would + /// then duplicate, or contradict, LDK's own report once the background processor delivers it. + /// The intents stay on record for that report, or for the next startup, to settle. + pub(crate) async fn reconcile( + &self, replay_complete: bool, listed: impl IntoIterator, + ) -> Vec { + let records = + self.pending_payment_store.list_filter(|p| p.open_channel_intent().is_some()).await; + if records.is_empty() { + return Vec::new(); + } + if !replay_complete { + log_info!( + self.logger, + "Leaving {} channel open intent(s) unreconciled: LDK's event replay did not \ + complete, so the events settling them may still be queued", + records.len(), + ); + return Vec::new(); + } + + let listed: Vec = listed.into_iter().collect(); + let mut lost = Vec::new(); + for record in records { + let Some(intent) = record.open_channel_intent().cloned() else { + continue; + }; + match listed_channel(&intent, &listed) { + Some(channel) if channel.funded => { + log_info!( + self.logger, + "Channel open {} with {} is funded; settling its intent", + intent.user_channel_id.0, + intent.counterparty_node_id, + ); + // A failure is logged by `settle`; the intent is left for the next startup. + let _ = self.settle(intent.counterparty_node_id, intent.user_channel_id).await; + }, + Some(_) => { + log_info!( + self.logger, + "Channel open {} with {} is still being negotiated; leaving its intent", + intent.user_channel_id.0, + intent.counterparty_node_id, + ); + }, + None => { + log_info!( + self.logger, + "Channel open {} with {} did not survive the restart; reporting it as closed", + intent.user_channel_id.0, + intent.counterparty_node_id, + ); + lost.push(intent); + }, + } + } + lost + } +} + +/// Whether `record` carries the live intent of the open of `user_channel_id` with +/// `counterparty_node_id`. +fn concerns_open( + record: &PendingPaymentDetails, counterparty_node_id: PublicKey, user_channel_id: UserChannelId, +) -> bool { + record.open_channel_intent().is_some_and(|intent| { + intent.counterparty_node_id == counterparty_node_id + && intent.user_channel_id == user_channel_id + }) +} + +/// The channel LDK lists for the open `intent` records, if any. Both the counterparty and the +/// `user_channel_id` are matched: a counterparty may have several channels with this node, and +/// `user_channel_id` alone is only unique per node by convention. +fn listed_channel<'a>( + intent: &OpenChannelIntent, listed: &'a [ListedChannel], +) -> Option<&'a ListedChannel> { + listed.iter().find(|channel| { + channel.counterparty_node_id == intent.counterparty_node_id + && channel.user_channel_id == intent.user_channel_id + }) +} + +/// The event reporting the loss of the open `intent` records, shaped as the `ChannelClosed` LDK +/// would have reported had it closed the channel at the disconnect: the channel is only ever +/// known by its temporary id, was never funded, and never held a balance. +pub(crate) fn lost_open_close_event(intent: &OpenChannelIntent) -> LdkEvent { + LdkEvent::ChannelClosed { + channel_id: intent.temporary_channel_id, + user_channel_id: intent.user_channel_id.0, + reason: ClosureReason::DisconnectedPeer, + counterparty_node_id: Some(intent.counterparty_node_id), + channel_capacity_sats: None, + channel_funding_txo: None, + last_local_balance_msat: None, + } +} + +/// The replacement for a pending record whose open-channel intent is being dropped. A record +/// that still tracks something else — the funding payment wallet sync already filed under it — +/// 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 [`OpenChannelTracker::settle`]). +fn record_with_open_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: None, + open_channel_intent: Some(None), + }; + record.update(update); + (!record.is_empty()).then_some(record) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bitcoin::hashes::Hash; + use bitcoin::Txid; + use lightning::ln::types::ChannelId; + + 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::store::{ConfirmationStatus, PaymentDetails, PaymentKind}; + use crate::payment::{PaymentDirection, PaymentStatus}; + use crate::types::{DynStore, DynStoreWrapper}; + + 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 counterparty() -> PublicKey { + PublicKey::from_str("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + .unwrap() + } + + fn intent(user_channel_id: u128) -> OpenChannelIntent { + OpenChannelIntent { + counterparty_node_id: counterparty(), + user_channel_id: UserChannelId(user_channel_id), + temporary_channel_id: ChannelId([user_channel_id as u8; 32]), + } + } + + fn funding_payment(id: PaymentId) -> 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, + PaymentStatus::Pending, + ) + } + + fn listed( + counterparty_node_id: PublicKey, user_channel_id: u128, funded: bool, + ) -> ListedChannel { + ListedChannel { + counterparty_node_id, + user_channel_id: UserChannelId(user_channel_id), + funded, + } + } + + /// Settling and matching key on the counterparty and the `user_channel_id` together: another + /// open to the same peer is a different open. + #[test] + fn an_intent_matches_only_the_channel_listed_under_its_counterparty_and_id() { + let open = intent(7); + assert!(listed_channel(&open, &[listed(counterparty(), 7, true)]).is_some()); + assert!(listed_channel(&open, &[listed(counterparty(), 8, true)]).is_none()); + let other = PublicKey::from_str( + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + ) + .unwrap(); + assert!(listed_channel(&open, &[listed(other, 7, true)]).is_none()); + assert!(listed_channel(&open, &[]).is_none()); + } + + /// A lost open is reported as the close LDK would have reported at the disconnect, under the + /// identifiers the caller knows the channel by. + #[test] + fn a_lost_open_is_reported_as_a_disconnect_close() { + let open = intent(7); + match lost_open_close_event(&open) { + LdkEvent::ChannelClosed { + channel_id, + user_channel_id, + reason, + counterparty_node_id, + channel_capacity_sats, + channel_funding_txo, + last_local_balance_msat, + } => { + assert_eq!(channel_id, open.temporary_channel_id); + assert_eq!(user_channel_id, 7); + assert_eq!(reason, ClosureReason::DisconnectedPeer); + assert_eq!(counterparty_node_id, Some(counterparty())); + assert_eq!(channel_capacity_sats, None); + assert_eq!(channel_funding_txo, None); + assert_eq!(last_local_balance_msat, None); + }, + event => panic!("unexpected event {:?}", event), + } + } + + /// A record that tracks the funding payment keeps it with the intent cleared; a bare one is + /// removed. The open to the same peer under another `user_channel_id` is left alone. + #[tokio::test] + async fn settling_clears_only_the_intent_of_the_settled_open() { + let store = Arc::new(pending_payment_store()); + let tracked_id = PaymentId([1u8; 32]); + let mut tracked = + PendingPaymentDetails::new(funding_payment(tracked_id), Vec::new(), Vec::new()); + tracked.open_channel_intent = Some(intent(7)); + store.insert(tracked).await.unwrap(); + let bare_id = PaymentId([2u8; 32]); + store.insert(PendingPaymentDetails::pending_open(bare_id, intent(7))).await.unwrap(); + let other_id = PaymentId([3u8; 32]); + store.insert(PendingPaymentDetails::pending_open(other_id, intent(8))).await.unwrap(); + + let tracker = + OpenChannelTracker::new(Arc::clone(&store), Arc::new(Logger::new_log_facade())); + tracker.settle(counterparty(), UserChannelId(7)).await.unwrap(); + + let tracked = store.get(&tracked_id).await.unwrap().expect("the tracked record stays"); + assert!(tracked.open_channel_intent().is_none()); + assert!(tracked.details().is_some()); + assert!(store.get(&bare_id).await.unwrap().is_none(), "a bare intent record is removed"); + let other = store.get(&other_id).await.unwrap().expect("the other open is untouched"); + assert_eq!(other.open_channel_intent(), Some(&intent(8))); + + // Settling again finds nothing to do. + tracker.settle(counterparty(), UserChannelId(7)).await.unwrap(); + assert!(store.get(&tracked_id).await.unwrap().is_some()); + } + + /// The funding payment id of a live open is its record's id, and there is none for an open + /// that was settled. + #[tokio::test] + async fn the_funding_payment_id_is_that_of_the_live_intent_record() { + let store = Arc::new(pending_payment_store()); + let tracker = + OpenChannelTracker::new(Arc::clone(&store), Arc::new(Logger::new_log_facade())); + let id = tracker.persist_intent(intent(7)).await.unwrap(); + assert_eq!(tracker.funding_payment_id(counterparty(), UserChannelId(7)).await, Some(id)); + assert_eq!(tracker.funding_payment_id(counterparty(), UserChannelId(8)).await, None); + + tracker.discard_persisted_intent(id, &intent(7)).await; + assert_eq!(tracker.funding_payment_id(counterparty(), UserChannelId(7)).await, None); + assert!(store.get(&id).await.unwrap().is_none()); + } + + /// With LDK's replay incomplete, the intents are left as they are and nothing is reported. + #[tokio::test] + async fn reconcile_reports_nothing_while_the_replay_is_incomplete() { + let store = Arc::new(pending_payment_store()); + let tracker = + OpenChannelTracker::new(Arc::clone(&store), Arc::new(Logger::new_log_facade())); + let id = tracker.persist_intent(intent(7)).await.unwrap(); + + assert!(tracker.reconcile(false, []).await.is_empty()); + assert_eq!( + store.get(&id).await.unwrap().and_then(|r| r.open_channel_intent().cloned()), + Some(intent(7)) + ); + + // With the replay complete and the channel listed funded, the open survived: its intent + // is settled without a report. + let survivor = tracker.persist_intent(intent(8)).await.unwrap(); + assert_eq!( + tracker.reconcile(true, [listed(counterparty(), 8, true)]).await, + vec![intent(7)], + "only the open LDK no longer lists is reported", + ); + assert!(store.get(&survivor).await.unwrap().is_none(), "a settled bare intent is removed"); + // The lost open is left on record for the report's `ChannelClosed` to settle. + assert!(store.get(&id).await.unwrap().is_some()); + } + + /// An open whose channel LDK lists unfunded is still in flight — LDK kept it in memory across + /// the node's stop and start — so it is neither settled nor reported. + #[tokio::test] + async fn reconcile_leaves_an_open_ldk_still_negotiates_alone() { + let store = Arc::new(pending_payment_store()); + let tracker = + OpenChannelTracker::new(Arc::clone(&store), Arc::new(Logger::new_log_facade())); + let id = tracker.persist_intent(intent(7)).await.unwrap(); + + assert!(tracker.reconcile(true, [listed(counterparty(), 7, false)]).await.is_empty()); + assert_eq!( + store.get(&id).await.unwrap().and_then(|r| r.open_channel_intent().cloned()), + Some(intent(7)), + "the intent of an open still being negotiated stays", + ); + } +} 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 747e0b7ed6..31a714a2a5 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -451,7 +451,12 @@ where self.mutate_with(id, |current| async move { Ok(f(current.as_ref())) }).await } - async fn mutate_with(&self, id: &SO::Id, f: F) -> Result, Error> + /// 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_with(&self, id: &SO::Id, f: F) -> Result, Error> where F: FnOnce(Option) -> Fut, Fut: Future, Error>>, @@ -470,19 +475,6 @@ 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; @@ -1260,7 +1252,7 @@ mod tests { } #[tokio::test] - async fn mutate_async_awaits_fallible_reads() { + async fn mutate_with_awaits_fallible_reads() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let logger = Arc::new(TestLogger::new()); let id = TestObjectId { id: [42u8; 4] }; @@ -1285,7 +1277,7 @@ mod tests { ); let result = data_store - .mutate_async(&id, |existing| async move { + .mutate_with(&id, |existing| async move { let mut updated = existing.unwrap(); updated.data = other_store.get(&other_id).await?.unwrap().data; Ok(Some(updated)) diff --git a/src/event.rs b/src/event.rs index 99b217fd52..540af5b3ec 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::{OpenChannelTracker, 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, @@ -63,8 +70,12 @@ use crate::types::{ ChainMonitor, CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, }; -use crate::wallet::provenance::{ChannelOutputRole, ChannelTxFacts, FactsRecordOutcome}; -use crate::wallet::{closed_channel_tracked_rounds, funding_candidates, tracked_splice_rounds}; +use crate::wallet::provenance::{ + ChannelOutputRole, ChannelTxFacts, FactsRecordOutcome, LocalFundingFigures, +}; +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 +132,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`] @@ -317,7 +477,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 { @@ -327,6 +491,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, }, } @@ -411,6 +587,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), }, ); @@ -581,6 +759,8 @@ where onion_messenger: Arc, om_mailbox: Option>, prober: Option>, + splice_tracker: Arc, + open_channel_tracker: Arc, runtime: Arc, logger: L, config: Arc, @@ -601,6 +781,7 @@ where peer_store: Arc>, keys_manager: Arc, static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, prober: Option>, + splice_tracker: Arc, open_channel_tracker: Arc, runtime: Arc, logger: L, config: Arc, pending_funding_fee_rates: Arc>>, ) -> Self { @@ -622,6 +803,8 @@ where onion_messenger, om_mailbox, prober, + splice_tracker, + open_channel_tracker, runtime, logger, config, @@ -757,6 +940,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, @@ -787,18 +991,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, @@ -831,6 +1040,15 @@ where let fee_rate_override = self.pending_funding_fee_rates.lock().expect("lock").remove(&user_channel_id); + // The payment id the open was given, if this node initiated it through one of the + // `open_channel` methods: the funding payment is recorded under it. A channel + // this node opens as an LSPS2 service, or an open begun on an older version, has + // none, and its payment is recorded under the id wallet sync derives. + let funding_payment_id = self + .open_channel_tracker + .funding_payment_id(counterparty_node_id, UserChannelId(user_channel_id)) + .await; + // Sign the final funding transaction and broadcast it. let channel_amount = Amount::from_sat(channel_value_satoshis); let funding_transaction = self @@ -865,21 +1083,40 @@ where counterparty_node_id, channel_id: ChannelId::v1_from_funding_outpoint(funding_txo), }; - let facts = ChannelTxFacts::new(txid).with_outputs( + let mut facts = ChannelTxFacts::new(txid).with_outputs( &channel, Some(UserChannelId(user_channel_id)), ChannelOutputRole::Funding, [vout as u32], ); - match self.wallet.record_channel_tx_facts(facts).await { + // The facts are what survives the open's intent, which settles once + // the channel is pending, and the entry's graduation: wallet sync + // resolves a transaction's payment from them first, so the funding + // payment is filed under the id returned to the caller whenever the + // transaction is seen. The figures are the wallet's view of the + // transaction, taken while its inputs are still the wallet's own + // outputs — the same figures sync would derive. + if let Some(funding_payment_id) = funding_payment_id { + let (amount_msat, fee_paid_msat, direction) = + self.wallet.onchain_payment_fields(&final_tx); + facts = facts.with_local_figures(LocalFundingFigures { + funding_payment_id, + amount_msat, + fee_paid_msat, + direction, + }); + } + 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, ); }, @@ -2010,15 +2247,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 { @@ -2069,6 +2307,21 @@ where ); }); } + + // An open this node initiated no longer needs its intent: the channel is funded, + // and LDK persists a funded channel and reports its closes. That holds because + // the chain monitor runs in deferred mode (see the builder): LDK writes the + // channel manager holding the funded channel before the channel's initial + // monitor persist completes, and this event follows that persist. Settled only + // once the user event is queued, so that a crash before then leaves the intent to + // be settled by the replay of this event or by the next startup. A failure, which + // the tracker logs, does not replay the event: the next startup settles the + // intent, as LDK lists the channel funded, whereas a replay would queue the event + // to the user a second time. + let _ = self + .open_channel_tracker + .settle(counterparty_node_id, UserChannelId(user_channel_id)) + .await; }, LdkEvent::ChannelReady { channel_id, @@ -2147,6 +2400,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), @@ -2216,6 +2473,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. @@ -2267,6 +2526,19 @@ where if let Some(peer_info) = peer_to_reconnect { self.remove_peer_after_reconnect(peer_info, channel_id); } + + // An open this node initiated that failed before its channel was funded — the + // counterparty rejected it, the peer disconnected, its funding transaction could + // not be built, or the restart lost it and this is the close reporting that — + // is over once its close is reported. Settled only once the user event is + // queued, so that a crash before then leaves the intent for the next startup to + // report the open again. A failure, which the tracker logs, replays the event: + // an intent left behind would have the next startup report the open as lost once + // more, as LDK no longer lists the channel. + self.open_channel_tracker + .settle(counterparty_node_id, UserChannelId(user_channel_id)) + .await + .map_err(|_| ReplayEvent())?; }, LdkEvent::DiscardFunding { channel_id, funding_info } => { // LDK lets a splice round go with this event — a sibling round locked, or the @@ -2572,7 +2844,6 @@ where } } }, - // TODO(splicing): Revisit error handling once splicing API is settled in LDK 0.3 LdkEvent::FundingTransactionReadyForSigning { channel_id, counterparty_node_id, @@ -2580,25 +2851,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, @@ -2614,23 +2907,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 { @@ -2700,7 +2999,8 @@ where channel_id, user_channel_id, counterparty_node_id, - .. + reason, + contribution, } => { log_info!( self.logger, @@ -2711,12 +3011,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) { @@ -2734,19 +3035,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(()) @@ -2963,6 +3283,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, @@ -2980,6 +3305,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 { @@ -3041,6 +3371,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 af19bf51e7..bc03c89bd4 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; @@ -118,6 +119,7 @@ mod util; mod wallet; use std::default::Default; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; #[cfg(cycle_tests)] @@ -132,6 +134,7 @@ pub use bitcoin::FeeRate; use bitcoin::{Address, Amount, BlockHash, Network}; pub use builder::{BuildError, Builder}; use chain::ChainSource; +use channel::{lost_open_close_event, ListedChannel, OpenChannelTracker, 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 +143,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, @@ -163,6 +166,7 @@ use lightning::ln::channelmanager::PaymentId; use lightning::ln::funding::{FundingContributionError, FundingTemplate}; use lightning::ln::msgs::{BaseMessageHandler, SocketAddress}; use lightning::ln::peer_handler::CustomMessageHandler; +use lightning::ln::types::ChannelId; use lightning::routing::gossip::NodeAlias; use lightning::sign::EntropySource; use lightning::util::persist::KVStore; @@ -181,6 +185,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::{OpenChannelIntent, SpliceKind}; use payment::{ Bolt11Payment, Bolt12Payment, ForwardingAnalytics, OnchainPayment, PaymentDetails, PaymentDetailsPage, SpontaneousPayment, @@ -199,7 +204,8 @@ use types::{ KeysManager, OnionMessenger, PaymentStore, PeerManager, Router, Scorer, Sweeper, Wallet, }; pub use types::{ - ChannelCounterparty, ChannelDetails, CustomTlvRecord, PeerDetails, ReserveType, UserChannelId, + ChannelCounterparty, ChannelDetails, ChannelOpen, CustomTlvRecord, PeerDetails, ReserveType, + UserChannelId, }; #[cfg(feature = "storage-vss")] pub use vss_client; @@ -280,6 +286,8 @@ pub struct Node { payment_store: Arc, forwarding_store: Arc, forwarded_payment_aggregation_retention_secs: u64, + splice_tracker: Arc, + open_channel_tracker: Arc, lnurl_auth: Arc, is_running: Arc>, node_metrics: Arc, @@ -366,6 +374,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 @@ -728,6 +741,8 @@ impl Node { Arc::clone(&self.onion_messenger), self.om_mailbox.clone(), self.prober.clone(), + Arc::clone(&self.splice_tracker), + Arc::clone(&self.open_channel_tracker), Arc::clone(&self.runtime), Arc::clone(&self.logger), Arc::clone(&self.config), @@ -741,6 +756,59 @@ 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. + // An event the handler fails on stays queued in LDK, along with every event behind it, + // for the background processor to deliver; the reconciliation below must know that. + let replay_handler = &event_handler; + let replay_failed = AtomicBool::new(false); + self.runtime.block_on(self.channel_manager.process_pending_events_async(|event| async { + let result = replay_handler.handle_event(event).await; + if result.is_err() { + replay_failed.store(true, Ordering::Release); + } + result + })); + + // A channel open this node initiated that LDK no longer lists did not survive the restart: + // LDK persists a channel only once it is funded, so an outbound channel still waiting for + // the counterparty's acceptance is gone once the process restarts, with no event + // reporting it. (Across a disconnect alone — which a stop and start of this node without + // a process restart amounts to — LDK keeps such a channel in memory to resume it, and + // still lists it, unfunded.) Report a lost open as closed through the handler, as LDK + // would have reported a close it persisted, so the report takes the same path as one — + // the peer is dropped from the peer store, the user event is queued, and the open's + // intent is settled. After the replay, so that a close LDK did persist is not reported + // twice, and before the node runs, so that no new open can be mistaken for a lost one. + let listed_channels: Vec = self + .channel_manager + .list_channels() + .into_iter() + .map(|channel| ListedChannel { + counterparty_node_id: channel.counterparty.node_id, + user_channel_id: UserChannelId(channel.user_channel_id), + funded: channel.funding_txo.is_some(), + }) + .collect(); + let lost_opens = self.runtime.block_on( + self.open_channel_tracker + .reconcile(!replay_failed.load(Ordering::Acquire), listed_channels), + ); + for intent in lost_opens { + let event = lost_open_close_event(&intent); + if self.runtime.block_on(event_handler.handle_event(event)).is_err() { + // The intent stays on record, so the next startup reports the open again. + log_error!( + self.logger, + "Failed to report the loss of channel open {} with {}; it will be reported at \ + the next startup", + intent.user_channel_id.0, + intent.counterparty_node_id, + ); + } + } + // Setup background processing let background_persister = Arc::clone(&self.kv_store); let background_event_handler = Arc::clone(&event_handler); @@ -1377,7 +1445,7 @@ impl Node { push_to_counterparty_msat: Option, fee_rate: Option, channel_config: Option, announce_for_forwarding: bool, disable_counterparty_reserve: bool, - ) -> Result { + ) -> Result { let peer_info = PeerInfo { node_id, address }; let con_node_id = peer_info.node_id; @@ -1453,13 +1521,34 @@ impl Node { self.pending_funding_fee_rates.lock().expect("lock").insert(user_channel_id, fee_rate); } + // Record the open before handing it to LDK, for the same reason: the funding payment is + // filed under the record's id from the first event about the channel on, and an open LDK + // forgets at a restart before the channel is funded is recognized by the record. An open + // whose record cannot be written is not attempted, so that every open this node initiates + // is one a restart can account for. + let temporary_channel_id = ChannelId::temporary_from_entropy_source(&*self.keys_manager); + let intent = OpenChannelIntent { + counterparty_node_id: peer_info.node_id, + user_channel_id: UserChannelId(user_channel_id), + temporary_channel_id, + }; + let funding_payment_id = + match self.runtime.block_on(self.open_channel_tracker.persist_intent(intent.clone())) { + Ok(payment_id) => payment_id, + Err(e) => { + log_error!(self.logger, "Failed to record the channel open: {}", e); + self.pending_funding_fee_rates.lock().expect("lock").remove(&user_channel_id); + return Err(e); + }, + }; + let result = if disable_counterparty_reserve { self.channel_manager.create_channel_to_trusted_peer_0reserve( peer_info.node_id, channel_amount_sats, push_msat, user_channel_id, - None, + Some(temporary_channel_id), Some(user_config), ) } else { @@ -1468,7 +1557,7 @@ impl Node { channel_amount_sats, push_msat, user_channel_id, - None, + Some(temporary_channel_id), Some(user_config), ) }; @@ -1483,8 +1572,21 @@ impl Node { zero_reserve_string, peer_info.node_id ); - self.runtime.block_on(self.peer_store.add_peer(peer_info))?; - Ok(UserChannelId(user_channel_id)) + // The open is under way whatever happens from here on, so the caller gets its + // identifiers. The peer is already in the in-memory peer map, so reconnecting + // to it keeps working this session; the next successful write persists it. + if let Err(e) = self.runtime.block_on(self.peer_store.add_peer(peer_info)) { + log_error!( + self.logger, + "Failed to add peer {} to peer store: {}", + intent.counterparty_node_id, + e + ); + } + Ok(ChannelOpen { + user_channel_id: UserChannelId(user_channel_id), + funding_payment_id, + }) }, Err(e) => { log_error!( @@ -1494,6 +1596,9 @@ impl Node { e ); self.pending_funding_fee_rates.lock().expect("lock").remove(&user_channel_id); + self.runtime.block_on( + self.open_channel_tracker.discard_persisted_intent(funding_payment_id, &intent), + ); Err(Error::ChannelCreationFailed) }, } @@ -1562,14 +1667,19 @@ impl Node { /// If `fee_rate` is set it will be used for the funding transaction. Otherwise we'll /// retrieve a reasonable estimate from the configured chain source. /// - /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. + /// Returns a [`ChannelOpen`] holding the [`UserChannelId`] allowing to locally keep track of + /// the channel and the [`PaymentId`] of the payment that funds it; see + /// [`ChannelOpen::funding_payment_id`] for when that payment is recorded. An open this node + /// does not remember after a restart because it stopped before the channel was funded is + /// reported through [`Event::ChannelClosed`] at the next startup — possibly more than once, + /// should the node stop, or a persistence failure occur, right after the report is queued. /// /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_channel( &self, node_id: PublicKey, address: SocketAddress, channel_amount_sats: u64, push_to_counterparty_msat: Option, fee_rate: Option, channel_config: Option, - ) -> Result { + ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } @@ -1608,14 +1718,19 @@ impl Node { /// If `fee_rate` is set it will be used for the funding transaction. Otherwise we'll /// retrieve a reasonable estimate from the configured chain source. /// - /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. + /// Returns a [`ChannelOpen`] holding the [`UserChannelId`] allowing to locally keep track of + /// the channel and the [`PaymentId`] of the payment that funds it; see + /// [`ChannelOpen::funding_payment_id`] for when that payment is recorded. An open this node + /// does not remember after a restart because it stopped before the channel was funded is + /// reported through [`Event::ChannelClosed`] at the next startup — possibly more than once, + /// should the node stop, or a persistence failure occur, right after the report is queued. /// /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_announced_channel( &self, node_id: PublicKey, address: SocketAddress, channel_amount_sats: u64, push_to_counterparty_msat: Option, fee_rate: Option, channel_config: Option, - ) -> Result { + ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } @@ -1652,13 +1767,18 @@ impl Node { /// If `fee_rate` is set it will be used for the funding transaction. Otherwise we'll /// retrieve a reasonable estimate from the configured chain source. /// - /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. + /// Returns a [`ChannelOpen`] holding the [`UserChannelId`] allowing to locally keep track of + /// the channel and the [`PaymentId`] of the payment that funds it; see + /// [`ChannelOpen::funding_payment_id`] for when that payment is recorded. An open this node + /// does not remember after a restart because it stopped before the channel was funded is + /// reported through [`Event::ChannelClosed`] at the next startup — possibly more than once, + /// should the node stop, or a persistence failure occur, right after the report is queued. /// /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_channel_with_all( &self, node_id: PublicKey, address: SocketAddress, push_to_counterparty_msat: Option, fee_rate: Option, channel_config: Option, - ) -> Result { + ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } @@ -1694,13 +1814,18 @@ impl Node { /// If `fee_rate` is set it will be used for the funding transaction. Otherwise we'll /// retrieve a reasonable estimate from the configured chain source. /// - /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. + /// Returns a [`ChannelOpen`] holding the [`UserChannelId`] allowing to locally keep track of + /// the channel and the [`PaymentId`] of the payment that funds it; see + /// [`ChannelOpen::funding_payment_id`] for when that payment is recorded. An open this node + /// does not remember after a restart because it stopped before the channel was funded is + /// reported through [`Event::ChannelClosed`] at the next startup — possibly more than once, + /// should the node stop, or a persistence failure occur, right after the report is queued. /// /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_announced_channel_with_all( &self, node_id: PublicKey, address: SocketAddress, push_to_counterparty_msat: Option, fee_rate: Option, channel_config: Option, - ) -> Result { + ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } @@ -1742,14 +1867,19 @@ impl Node { /// If `fee_rate` is set it will be used for the funding transaction. Otherwise we'll /// retrieve a reasonable estimate from the configured chain source. /// - /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. + /// Returns a [`ChannelOpen`] holding the [`UserChannelId`] allowing to locally keep track of + /// the channel and the [`PaymentId`] of the payment that funds it; see + /// [`ChannelOpen::funding_payment_id`] for when that payment is recorded. An open this node + /// does not remember after a restart because it stopped before the channel was funded is + /// reported through [`Event::ChannelClosed`] at the next startup — possibly more than once, + /// should the node stop, or a persistence failure occur, right after the report is queued. /// /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_0reserve_channel( &self, node_id: PublicKey, address: SocketAddress, channel_amount_sats: u64, push_to_counterparty_msat: Option, fee_rate: Option, channel_config: Option, - ) -> Result { + ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } @@ -1783,11 +1913,16 @@ impl Node { /// If `fee_rate` is set it will be used for the funding transaction. Otherwise we'll /// retrieve a reasonable estimate from the configured chain source. /// - /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. + /// Returns a [`ChannelOpen`] holding the [`UserChannelId`] allowing to locally keep track of + /// the channel and the [`PaymentId`] of the payment that funds it; see + /// [`ChannelOpen::funding_payment_id`] for when that payment is recorded. An open this node + /// does not remember after a restart because it stopped before the channel was funded is + /// reported through [`Event::ChannelClosed`] at the next startup — possibly more than once, + /// should the node stop, or a persistence failure occur, right after the report is queued. pub fn open_0reserve_channel_with_all( &self, node_id: PublicKey, address: SocketAddress, push_to_counterparty_msat: Option, fee_rate: Option, channel_config: Option, - ) -> Result { + ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } @@ -1814,6 +1949,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) }); @@ -1828,18 +1971,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, @@ -1901,6 +2039,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( @@ -1914,16 +2056,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!( @@ -1948,6 +2092,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 @@ -1985,6 +2138,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 @@ -2021,6 +2183,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 @@ -2099,6 +2270,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 + })?; + if let FundingAmount::Exact { amount_sats } = &splice_amount { if *amount_sats > channel_details.next_splice_out_maximum_sat { return Err(Error::ChannelSplicingFailed); @@ -2154,22 +2333,26 @@ impl Node { Error::ChannelSplicingFailed })?, }; - let contribution = - funding_template.splice_out(vec![output], feerate, max_feerate).map_err(|e| { - log_error!(self.logger, "Failed to splice channel: {}", e); - Error::ChannelSplicingFailed - })?; + let outputs = vec![output]; + 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!( @@ -2195,6 +2378,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, @@ -2210,6 +2402,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) }); @@ -2237,6 +2437,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( @@ -2249,16 +2455,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!( @@ -2437,6 +2645,10 @@ impl Node { } /// Remove the payment with the given id from the store. + /// + /// This does not cancel a channel open or a splice still in flight whose funding payment the + /// id identifies: it is still tracked, and the payment may be recorded under the id again once + /// its funding transaction is seen. pub fn remove_payment(&self, payment_id: &PaymentId) -> Result<(), Error> { self.runtime.block_on(self.wallet.remove_payment(payment_id)) } diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 089e43a96b..e6368cd344 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -16,6 +16,7 @@ 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}; use crate::payment::{PaymentDetails, PaymentKind}; +use crate::types::UserChannelId; /// One candidate transaction in an interactive-funding (splice) RBF history, holding this node's /// share of the funding amount and fee for that candidate. Both are `None` for a candidate this @@ -108,6 +109,28 @@ impl_writeable_tlv_based!(SpliceIntent, { (8, kind, required), }); +/// A channel open this node initiated through one of the `Node::open_channel` methods that is not +/// yet guaranteed to survive a restart. LDK persists a channel only once it is funded: an +/// unfunded outbound channel whose `accept_channel` has not arrived when the peer disconnects is +/// kept in memory to be resumed, so a restart of the process loses it without reporting its loss. +/// Until the channel is pending we keep enough state to recognize an open LDK no longer knows +/// about and to report its loss in terms of the original request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct OpenChannelIntent { + /// The channel counterparty. + pub counterparty_node_id: PublicKey, + /// The local identifier of the channel, returned to the caller of the open. + pub user_channel_id: UserChannelId, + /// The channel's id before it is funded, chosen by this node when the open was initiated. + pub temporary_channel_id: ChannelId, +} + +impl_writeable_tlv_based!(OpenChannelIntent, { + (0, counterparty_node_id, required), + (2, user_channel_id, required), + (4, temporary_channel_id, 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, @@ -116,8 +139,10 @@ impl_writeable_tlv_based!(SpliceIntent, { /// 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. +/// `locked_rounds`. A splice uses all of them; the fields do not partition by payment type. A +/// channel open this node initiated is persisted with nothing but its [`OpenChannelIntent`] +/// under the payment id returned to the caller, and wallet sync adds `details` once it observes +/// the funding transaction. An entry carrying none of them doesn't track a payment and is removed. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingPaymentDetails { /// The payment this entry tracks. @@ -140,10 +165,15 @@ pub(crate) struct PendingPaymentDetails { /// 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. - /// Set when a splice is initiated and cleared once it locks or its failure is surfaced. A fee - /// bump shares the entry of the round it replaces, so the intent outlives any single round - /// negotiated under it. + /// Written by the splice entry points when a splice is initiated and cleared by the splice + /// tracker once it locks or its failure is surfaced. A fee bump shares the entry of the round + /// it replaces, so the intent outlives any single round negotiated under it. pub splice_intent: Option, + /// The live intent of a channel open this node initiated, or `None` for any other payment and + /// for an open whose channel became pending or closed. Written by the `open_channel` entry + /// points before the open is handed to LDK and cleared by the open-channel tracker once LDK + /// reports the channel pending or closed. + pub open_channel_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 @@ -170,11 +200,11 @@ impl PendingPaymentDetails { funding_channels: Vec::new(), candidates, splice_intent, + open_channel_intent: None, locked_rounds: Vec::new(), } } - #[cfg(test)] pub(crate) fn pending_splice(id: PaymentId, intent: SpliceIntent) -> Self { Self { id, @@ -183,6 +213,22 @@ impl PendingPaymentDetails { funding_channels: Vec::new(), candidates: Vec::new(), splice_intent: Some(intent), + open_channel_intent: None, + locked_rounds: Vec::new(), + } + } + + /// An entry for a channel open this node initiated, before the funding transaction exists and + /// therefore before a payment record for it does. + pub(crate) fn pending_open(id: PaymentId, intent: OpenChannelIntent) -> Self { + Self { + id, + details: None, + conflicting_txids: Vec::new(), + funding_channels: Vec::new(), + candidates: Vec::new(), + splice_intent: None, + open_channel_intent: Some(intent), locked_rounds: Vec::new(), } } @@ -201,6 +247,7 @@ impl PendingPaymentDetails { funding_channels, candidates, splice_intent, + open_channel_intent: None, locked_rounds: Vec::new(), } } @@ -235,6 +282,11 @@ impl PendingPaymentDetails { self.splice_intent.as_ref() } + /// The intent of the channel open this record tracks, if its channel is not yet pending. + pub(crate) fn open_channel_intent(&self) -> Option<&OpenChannelIntent> { + self.open_channel_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) @@ -265,6 +317,7 @@ impl PendingPaymentDetails { pub(crate) fn is_empty(&self) -> bool { self.details.is_none() && self.splice_intent.is_none() + && self.open_channel_intent.is_none() && self.candidates.is_empty() && self.locked_rounds.is_empty() } @@ -278,6 +331,7 @@ impl_writeable_tlv_based!(PendingPaymentDetails, { (8, candidates, optional_vec), (10, splice_intent, option), (12, locked_rounds, optional_vec), + (14, open_channel_intent, option), }); #[derive(Clone, Debug, PartialEq, Eq)] @@ -290,6 +344,10 @@ pub(crate) struct PendingPaymentDetailsUpdate { /// 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>, + /// The open-channel intent to set (`Some(Some(..))`) or clear (`Some(None)`), or `None` to + /// leave it unchanged. As with `splice_intent`, an entry carrying only the intent is removed + /// rather than cleared through this field. + pub open_channel_intent: Option>, } impl StorableObject for PendingPaymentDetails { @@ -347,6 +405,13 @@ impl UpdatableObject for PendingPaymentDetails { } } + if let Some(new_open_channel_intent) = update.open_channel_intent { + if self.open_channel_intent != new_open_channel_intent { + self.open_channel_intent = new_open_channel_intent; + updated = true; + } + } + updated } @@ -372,6 +437,7 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { conflicting_txids: None, candidates: value.candidates.clone(), splice_intent: value.splice_intent.clone().map(Some), + open_channel_intent: value.open_channel_intent.clone().map(Some), }, Some(details) => { let conflicting_txids = if value.conflicting_txids.is_empty() { @@ -379,16 +445,17 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { } else { Some(value.conflicting_txids.clone()) }; - // Leave the splice intent unchanged: it is owned by the writers that persist and - // settle splices, 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`. + // Leave the intents unchanged: they are owned by the splice and open-channel entry + // points and their trackers, 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, + open_channel_intent: None, } }, } @@ -543,6 +610,15 @@ pub(crate) fn test_funding_contribution_with_feerate( test_funding_contribution_with_outputs(0, feerate, &[]) } +/// Like [`test_funding_contribution`], but with the given input-selection feerate in sat/kwu and +/// an input spending output 0 — which must be P2WPKH — of each given previous transaction. +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_inputs( + feerate: u64, prevtxs: &[bitcoin::Transaction], +) -> FundingContribution { + test_funding_contribution_with_parts(0, feerate, prevtxs, &[], None) +} + #[cfg(test)] mod tests { use bitcoin::hashes::Hash; @@ -652,6 +728,115 @@ 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)); + } + + fn test_open_intent() -> OpenChannelIntent { + use std::str::FromStr; + + OpenChannelIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + user_channel_id: UserChannelId(42), + temporary_channel_id: ChannelId([5u8; 32]), + } + } + + /// Wallet sync files the funding payment of an open under the open's entry while the open + /// is still in flight, and merges its later view of the transaction through `to_update()` of + /// a fresh record built without an intent; neither may clear the live intent. + #[test] + fn payment_tracking_merge_preserves_a_live_open_channel_intent() { + let payment_id = PaymentId([7u8; 32]); + let txid = test_txid(8); + let intent = test_open_intent(); + let mut record = PendingPaymentDetails::pending_open(payment_id, intent.clone()); + assert!(!record.is_empty(), "an entry carrying an open intent tracks the open"); + + record.details = Some(pending_onchain_payment(payment_id, txid)); + 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.open_channel_intent(), Some(&intent)); + + // Only an update that clears the intent does so, and a record left with nothing else is + // then empty. + let mut bare = PendingPaymentDetails::pending_open(payment_id, intent.clone()); + assert!(bare.update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: None, + open_channel_intent: Some(None), + })); + assert!(bare.is_empty()); + } + + /// An open's entry round-trips with its intent, bare and once the payment is recorded, and an + /// entry written before open intents were kept reads with none. + #[test] + fn open_channel_intent_round_trips() { + let payment_id = PaymentId([7u8; 32]); + let intent = test_open_intent(); + let bare = PendingPaymentDetails::pending_open(payment_id, intent.clone()); + let decoded: PendingPaymentDetails = + Readable::read(&mut &bare.encode()[..]).expect("encoding must round-trip"); + assert_eq!(decoded, bare); + assert_eq!(decoded.open_channel_intent(), Some(&intent)); + + let mut tracked = bare.clone(); + tracked.details = Some(pending_onchain_payment(payment_id, test_txid(8))); + let decoded: PendingPaymentDetails = + Readable::read(&mut &tracked.encode()[..]).expect("encoding must round-trip"); + assert_eq!(decoded, tracked); + + let stored = entry(vec![candidate(2, false)]); + let decoded: PendingPaymentDetails = + Readable::read(&mut &stored.encode()[..]).expect("encoding must round-trip"); + assert!(decoded.open_channel_intent().is_none()); + } + #[test] fn splice_kind_round_trips() { for kind in [ @@ -836,6 +1021,7 @@ mod tests { .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!(decoded.open_channel_intent.is_none()); assert_eq!(decoded, stored); } diff --git a/src/types.rs b/src/types.rs index 9eb3fdaa8c..cd5afdd509 100644 --- a/src/types.rs +++ b/src/types.rs @@ -18,6 +18,7 @@ use lightning::ln::channel_state::{ ChannelDetails as LdkChannelDetails, ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, OutboundHTLCDetails, }; +use lightning::ln::channelmanager::PaymentId; use lightning::ln::msgs::{RoutingMessageHandler, SocketAddress}; use lightning::ln::peer_handler::IgnoringMessageHandler; use lightning::ln::types::ChannelId; @@ -351,6 +352,30 @@ pub(crate) type ChannelTxFactsStore = DataStore, Kee #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub struct UserChannelId(pub u128); +/// What one of the `Node::open_channel` methods returns: the identifiers of the channel being +/// opened and of the payment that will fund it. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ChannelOpen { + /// The local identifier of the channel, allowing to keep track of it before and after it is + /// funded. + pub user_channel_id: UserChannelId, + /// The identifier of the payment that funds the channel. + /// + /// [`Node::payment`] and [`Node::list_payments`] show the payment once the funding + /// transaction has been broadcast — the channel becomes pending at that point, see + /// [`Event::ChannelPending`] — and recorded, which a background task does shortly after. + /// Until then [`Node::payment`] returns `None`. If the open fails before the channel is + /// funded, which is reported through [`Event::ChannelClosed`] with this open's + /// `user_channel_id`, no payment is ever recorded under this identifier. + /// + /// [`Node::payment`]: crate::Node::payment + /// [`Node::list_payments`]: crate::Node::list_payments + /// [`Event::ChannelPending`]: crate::Event::ChannelPending + /// [`Event::ChannelClosed`]: crate::Event::ChannelClosed + pub funding_payment_id: PaymentId, +} + impl Writeable for UserChannelId { fn write(&self, writer: &mut W) -> Result<(), lightning::io::Error> { Ok(self.0.write(writer)?) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index a818bcc10c..bc2d589b0b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -58,6 +58,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, @@ -67,8 +68,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, @@ -77,8 +77,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}; @@ -93,6 +93,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; @@ -270,12 +284,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. @@ -293,10 +328,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, }); @@ -320,12 +355,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) => { @@ -668,7 +698,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; }; @@ -1330,10 +1372,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 { @@ -1367,11 +1411,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 means a crash in between doesn't lose anything: the + // replay finds the intent kept and doesn't add a 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 @@ -1427,16 +1519,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, @@ -1451,14 +1544,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 @@ -1485,8 +1579,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 @@ -1497,10 +1592,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, ); @@ -1521,7 +1627,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", @@ -1982,6 +2088,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> { @@ -2639,25 +2776,43 @@ impl Wallet { Ok(()) } - /// Returns the `PaymentId` of a user-initiated splice intent for one of the channels in - /// `candidate`, if any, so the first recorded round of a splice adopts the id chosen at splice - /// time rather than a fresh one. The intent identifies the channel, not the round, so it - /// decides the id only for a history no record tracks yet - /// ([`Self::resolve_interactive_funding_id`]). A fee bump reuses the channel's existing intent, - /// so at most one in-flight intent matches and the first is unambiguous. + /// Returns the `PaymentId` of the user-initiated splice intent the round `candidate` belongs + /// to, if any, so the first recorded round of a splice adopts the id chosen at splice time + /// rather than a fresh one. Only a history no record tracks yet gets here + /// ([`Self::resolve_interactive_funding_id`]), so only the channel's bare intent records — + /// those of its splices with no round on record — can be the round's: a tracked record already + /// has its rounds, and a channel carries one record per splice in flight. Among the bare + /// records, the round's is the one whose intent carries the round's contribution + /// ([`is_same_splice`]; LDK may adjust a contribution's fee fields, not its inputs or outputs) + /// or, when none does, the channel's only bare record. 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 { - self.payment_stores + let channel_of = |intent: &SpliceIntent| { + candidate.channels.iter().find(|channel| { + channel.channel_id == intent.channel_id + && channel.counterparty_node_id == intent.counterparty_node_id + }) + }; + let bare = self + .payment_stores .pending_payments(|p| { - p.splice_intent().is_some_and(|intent| { - candidate.channels.iter().any(|channel| { - channel.channel_id == intent.channel_id - && channel.counterparty_node_id == intent.counterparty_node_id - }) - }) + p.details().is_none() + && p.candidates().is_empty() + && p.splice_intent().is_some_and(|i| channel_of(i).is_some()) }) - .await - .first() - .map(|p| p.id()) + .await; + let carries_contribution = |p: &&PendingPaymentDetails| { + p.splice_intent().is_some_and(|intent| { + channel_of(intent) + .and_then(|channel| channel.contribution.as_ref()) + .is_some_and(|contribution| is_same_splice(contribution, &intent.contribution)) + }) + }; + match (bare.iter().find(carries_contribution), bare.as_slice()) { + (Some(record), _) => Some(record.id()), + (None, [only]) => Some(only.id()), + (None, _) => None, + } } /// Resolves the id under which the `active` round of the interactive funding with negotiated @@ -2668,11 +2823,13 @@ impl Wallet { /// 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. - /// A history no live record tracks adopts the id of the channel's splice intent - /// ([`Self::find_splice_payment_id`]), so a user-initiated splice's intent, funding payment and - /// candidate history share one record. Otherwise a fresh id is generated. 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. + /// A history no live record tracks adopts the id of the channel's bare splice intent, one with + /// no round on record yet ([`Self::find_splice_payment_id`]), so a user-initiated splice's + /// intent, funding payment and candidate history share one record. A round already on record + /// keeps the record's id over the intent's: a fee bump this node signs of a round wallet sync + /// recorded first must converge on that record. Otherwise a fresh id is generated. 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, @@ -2785,10 +2942,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, @@ -2798,7 +2960,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 @@ -2827,7 +2989,7 @@ impl Wallet { let (figures, mut history) = match self.interactive_funding_figures(payment_id, candidates, signed_round, tx) { Some(record) => record, - None => return Ok(()), + None => return Ok(SignedFundingRecord::Recorded), }; let figures = recorded_figures.unwrap_or(figures); let direction = figures.direction; @@ -2840,7 +3002,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 @@ -2864,14 +3026,27 @@ 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); + } // Any round of the history may be the one that confirms, including the rounds this // node didn't contribute to. Write facts for those too, carrying this payment's id and // no share, so `find_payment_by_txid` can still map a confirming round to the payment @@ -2938,7 +3113,7 @@ impl Wallet { e, ); } - Ok(()) + Ok(SignedFundingRecord::Recorded) } /// Marks a splice round recorded when signing ([`Self::record_signed_funding`]) as broadcast @@ -3016,11 +3191,13 @@ impl Wallet { /// 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), and takes back their attribution to it, for wallet sync to record - /// them from its own view. 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. When that round was the last of ours, the record is of a - /// round of the counterparty's alone: the remaining rounds lose their attribution to it and the - /// entry its history, and wallet sync records the rounds from its own view. + /// them from its own view, 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. When that round was the last of ours, the record is of a round of the + /// counterparty's alone: the remaining rounds lose their attribution to it and the entry its + /// history, and wallet sync records the rounds from its own view. pub(crate) async fn drop_abandoned_splice_rounds( &self, channel_id: ChannelId, tracked_rounds: &[Txid], ) -> Result<(), Error> { @@ -3078,15 +3255,15 @@ 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() { if handed_back.is_none() { self.withdraw_unshared_attributions(&entry, payment_id).await?; } - let mut emptied = false; stores .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); @@ -3097,13 +3274,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 {}", @@ -3141,25 +3315,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, as is the - // attribution of the remaining rounds to the payment, taken back before the entry - // for the same reason. + // payment record went first, and the rounds' attribution to the payment goes + // before the entry: 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?; self.withdraw_unshared_attributions(&entry, 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; } // No round of ours remains, so the remaining rounds are the counterparty's @@ -3170,8 +3392,6 @@ impl Wallet { // itself is left as it stands. The attribution goes before the entry's write, // so that a drop cut short is finished by the replayed event. self.withdraw_unshared_attributions(&entry, payment_id).await?; - history_only = true; - mirrored = record.filter(|current| current.status == PaymentStatus::Pending); }, } if history_only { @@ -3282,6 +3502,64 @@ 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, 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 @@ -3497,6 +3775,10 @@ impl Wallet { conflicting_txids: Vec, ) -> Result<(), Error> { let id = payment.id; + let mut leftover_intent_to_remove = None; + // The `move` closure would capture the `Option` by value, so hand it a reference; the + // borrow ends with the mutate's future, before the leftover is read below. + let leftover = &mut leftover_intent_to_remove; stores .mutate_pending_payment_async(&id, move |existing| async move { // Only `Pending` payments belong in the pending store. The authoritative @@ -3509,6 +3791,20 @@ impl Wallet { recorded.status == PaymentStatus::Pending }); if !is_pending { + // A bare intent under an advanced payment's id is the leftover of the splice + // or channel open 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 or open that is long over. + if let Some(entry) = existing { + if entry.details().is_none() + && entry.candidates().is_empty() + && entry.locked_rounds().is_empty() + && (entry.splice_intent().is_some() + || entry.open_channel_intent().is_some()) + { + *leftover = Some(entry); + } + } return Ok(None); } Ok(match existing { @@ -3531,6 +3827,12 @@ impl Wallet { }) }) .await?; + + if let Some(bare_entry) = 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 == bare_entry).await?; + } Ok(()) } @@ -3539,16 +3841,43 @@ impl Wallet { /// record — routing later wallet-sync events to a payment that no longer exists — and nothing /// would ever clean it up, since graduation only removes entries whose record is still live. /// + /// Removing the payment does not cancel a channel open or a splice still in flight under its + /// id: the entry goes back to what it tracked before the payment was recorded — the intent of + /// either, the rounds this node signed and the ones LDK promoted — without the record and the + /// conflicts wallet sync listed for it, so that the open or splice is still recognized and + /// settled by the events about it and by a restart. The payment may be recorded under the id + /// again once a transaction of it is seen. + /// /// What this node recorded about the transactions themselves stays behind: those facts /// describe transactions that happened, and classifying a later transaction — a close /// spending a funding output, say — still reads them. pub(crate) async fn remove_payment(&self, payment_id: &PaymentId) -> Result<(), Error> { // Hold the cross-store lock so the two-store removal cannot interleave with a sync arm's - // or a funding-record writer's resolve-then-write sequence. The pending entry goes first: a failure - // in between then leaves an unindexed record (benign, and the retry removes it) rather - // than an entry indexing a removed record. + // or a funding-record writer's resolve-then-write sequence. The pending entry goes first: + // a failure in between then leaves an unindexed record (benign, and the retry removes it) + // rather than an entry indexing a removed record. let stores = self.payment_stores.lock().await; - stores.remove_pending_payment(payment_id).await?; + let mut tracks_an_intent = false; + let intent_flag = &mut tracks_an_intent; + stores + .mutate_pending_payment(payment_id, move |existing| { + let entry = existing?; + if entry.splice_intent().is_none() && entry.open_channel_intent().is_none() { + return None; + } + *intent_flag = true; + // Back to the entry of a splice or open whose transaction wallet sync has yet to + // observe: the rounds this node signed and the ones LDK promoted are the splice + // itself, and a restart reconciles the splice through them, so they stay. + let mut demoted = entry.clone(); + demoted.details = None; + demoted.conflicting_txids.clear(); + Some(demoted) + }) + .await?; + if !tracks_an_intent { + stores.remove_pending_payment(payment_id).await?; + } stores.remove_payment(payment_id).await } @@ -3560,9 +3889,16 @@ impl Wallet { /// 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 facts are read strictly: a read that fails fails the caller's pass, as any other store + /// failure in it does, rather than filing the transaction under an id of its own beside the + /// one its facts carry. 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) + if let Some(figures) = self + .channel_tx_facts_store + .get(&target_txid) + .await? + .and_then(|facts| facts.local_figures) { return Ok(Some(figures.funding_payment_id)); } @@ -4151,10 +4487,11 @@ enum FundingResolution { /// The outcome of [`Wallet::fail_unconfirmed_funding_payment_locked`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FundingPaymentFailure { - /// The payment was failed and its pending entry removed. + /// The payment was failed and its pending entry removed, a splice intent it carried kept as a + /// bare intent of its own. Failed, /// The payment was failed already — by a pass whose entry removal was lost to a crash — and - /// only the lingering entry was removed. + /// only the lingering entry was removed, its intent kept likewise. EntryRemoved, /// The record no longer waits on the transaction, and the pass left it unchanged. MovedOn, @@ -4163,7 +4500,7 @@ enum FundingPaymentFailure { /// Generates a fresh funding-record [`PaymentId`] from the OS entropy source. The record is found /// through its transaction history ([`Wallet::find_payment_by_txid`]), so the id only has to be /// unique. -fn random_payment_id() -> PaymentId { +pub(crate) fn random_payment_id() -> PaymentId { let mut bytes = [0u8; 32]; getrandom::fill(&mut bytes).expect("getrandom failed"); PaymentId(bytes) @@ -4520,7 +4857,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, @@ -4532,9 +4871,10 @@ mod tests { PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; use crate::payment::pending_payment_store::{ - test_funding_contribution_with_outputs, test_funding_contribution_with_parts, SpliceIntent, - SpliceKind, + test_funding_contribution_with_outputs, test_funding_contribution_with_parts, + OpenChannelIntent, PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind, }; + use crate::types::{DynStore, DynStoreWrapper, UserChannelId}; use crate::wallet::provenance::{ live_channels_of, ChannelOutput, ChannelOutputFact, ChannelOutputRole, LocalFundingFigures, @@ -4921,6 +5261,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, @@ -5084,6 +5442,44 @@ mod tests { (wallet, psbt) } + #[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(); @@ -6018,6 +6414,18 @@ mod tests { } } + /// The pending entries carrying `intent` as a bare intent — of a splice with no round on + /// record. + async fn bare_entries_carrying( + wallet: &Wallet, intent: &SpliceIntent, + ) -> Vec { + wallet + .payment_stores + .pending_payment_store() + .list_filter(|p| p.details().is_none() && p.splice_intent() == Some(intent)) + .await + } + /// A round signed under the channel's splice intent that has since locked with zero /// confirmations — clearing its intent — with a second splice submitted against the locked /// funding before the round's `SpliceNegotiated` event was handled: the channel's intent no @@ -6057,6 +6465,7 @@ mod tests { conflicting_txids: None, candidates: Vec::new(), splice_intent: Some(None), + open_channel_intent: None, }; wallet.payment_stores.pending_payment_store().update(cleared).await.unwrap(); // ...and a second splice of the channel is submitted against the new funding. @@ -6075,8 +6484,8 @@ mod tests { /// A recorded round is marked broadcast in its own record once the channel carries the intent /// of a newer splice: after a zero-conf lock, the user may submit a second splice before the - /// locked round's `SpliceNegotiated` event is handled, and the event must neither file the - /// round under the new splice as a second record nor touch the new splice's intent. + /// locked round's `SpliceNegotiated` event is handled, and the event must mark the round in + /// its own record without touching the new splice's intent. #[tokio::test] async fn negotiation_marks_a_recorded_round_broadcast_under_a_newer_intent() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); @@ -6179,15 +6588,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(); @@ -6195,47 +6603,192 @@ 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", + ); + + // 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)); } - /// 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 - /// transaction's own id. + /// 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_counterparty_broadcast_does_not_duplicate_the_funding_record() { + 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 pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let channel = Channel { counterparty_node_id, channel_id }; - let id = PaymentId([31u8; 32]); - let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); - wallet + 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 + /// transaction's own id. + #[tokio::test] + async fn a_counterparty_broadcast_does_not_duplicate_the_funding_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet .payment_stores .pending_payment_store() .insert(PendingPaymentDetails::pending_splice(id, intent)) @@ -6620,6 +7173,265 @@ mod tests { assert_eq!(entry.splice_intent(), Some(&intent)); } + /// A splice queued behind a pending splice of this node is a splice of its own, negotiating + /// once the pending one locks. Its first round is on no record when it is signed, and the + /// pending round's record — tracked, and still carrying the pending splice's intent — is not + /// its: only a bare intent record can be a first round's. The queued round gets a fresh id and + /// the pending round's record stays as it stands. + #[tokio::test] + async fn signing_a_queued_splice_does_not_join_the_pending_rounds_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .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; + + let (queued_tx, queued_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let queued_txid = queued_tx.compute_txid(); + let queued_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(queued_txid, Some(queued_contribution))], + ); + sign_and_observe_round(&wallet, &queued_tx, &queued_candidates).await; + + let queued_id = wallet + .find_payment_by_txid(queued_txid) + .await + .unwrap() + .expect("the queued round must be recorded"); + assert_ne!(queued_id, id, "the queued splice must not join the pending round's record"); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(entry.splice_intent(), Some(&intent)); + } + + /// A channel carries one intent per splice in flight, each under its own record. Signing the + /// first round of either splice files it under the intent whose contribution it carries, and + /// leaves the other splice's record alone. + #[tokio::test] + async fn signing_the_first_rounds_of_two_splices_files_each_under_its_own_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let (tx_a, contribution_a) = splice_out_round(&wallet, 1, 500_000, 300); + let (tx_b, contribution_b) = splice_out_round(&wallet, 2, 400_000, 700); + let (txid_a, txid_b) = (tx_a.compute_txid(), tx_b.compute_txid()); + let (id_a, id_b) = (PaymentId([31u8; 32]), PaymentId([32u8; 32])); + let intent_a = SpliceIntent { + contribution: contribution_a.clone(), + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + let intent_b = SpliceIntent { + contribution: contribution_b.clone(), + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + for (id, intent) in [(id_a, intent_a.clone()), (id_b, intent_b.clone())] { + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent)) + .await + .unwrap(); + } + + 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 + ); + } + + /// A first round whose contribution is none of the channel's bare intents' — LDK may adjust + /// a contribution's fee fields, not its inputs or outputs — is still the channel's only bare + /// intent's round when there is just one. When there are several, none is known to be its, + /// and the round gets a fresh id while both intents stay. + #[tokio::test] + async fn signing_a_first_round_none_of_several_bare_intents_claims_gets_a_fresh_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let (id_a, id_b) = (PaymentId([31u8; 32]), PaymentId([32u8; 32])); + let intent_a = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let intent_b = SpliceIntent { + contribution: test_funding_contribution_with_outputs(400, 253, &[]), + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + for (id, intent) in [(id_a, intent_a.clone()), (id_b, intent_b.clone())] { + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::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 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)); + } + } + + /// A splice submitted after the previous splice locked with zero confirmations has an intent + /// of its own: the locked splice's intent was settled before the new one was persisted + /// (`SpliceTracker::submit`). Signing the new splice's first round files it under the new + /// intent's id and leaves the locked round's record as it stands. + #[tokio::test] + async fn signing_a_splice_after_a_zero_conf_lock_gets_its_own_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let first_txid = setup.tx.compute_txid(); + let first_entry = wallet + .payment_stores + .pending_payment_store() + .get(&setup.first_id) + .await + .unwrap() + .expect("first entry"); + + let (tx, contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(setup.second_id)); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&setup.second_id) + .await + .unwrap() + .expect("entry"); + assert_eq!(entry.splice_intent(), Some(&setup.second_intent)); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&setup.first_id).await.unwrap(), + Some(first_entry) + ); + assert_eq!(wallet.find_payment_by_txid(first_txid).await.unwrap(), Some(setup.first_id)); + } + + /// Signing a splice round records its funding payment with the channel's full pending splice + /// history, so a wallet sync that observes the transaction before the broadcast (the + /// counterparty may broadcast first) resolves to the funding record through any round of that + /// history instead of filing the round as a foreign duplicate. Only the signed round awaits + /// broadcast; LDK broadcast the negotiated predecessor already. + #[tokio::test] + async fn signing_records_the_round_with_the_full_splice_history() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // The signed round is an RBF of a counterparty-initiated round (`prior_txid`, no + // contribution of ours), so the history LDK reports has two entries. + let prior_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + + sign_and_observe_round(&wallet, &tx, &candidates).await; + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let payment = &payments[0]; + let id = payment.id; + assert_ne!(id, PaymentId(prior_txid.to_byte_array())); + assert_ne!(id, PaymentId(txid.to_byte_array())); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(payment.fee_paid_msat, Some(300_000)); + assert_eq!(payment.direction, PaymentDirection::Inbound); + assert_eq!(payment.status, PaymentStatus::Pending); + match &payment.kind { + PaymentKind::Onchain { + txid: recorded_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels }), + } => { + assert_eq!(*recorded_txid, txid); + assert_eq!(channels.len(), 1); + assert_eq!(channels[0].counterparty_node_id, counterparty_node_id); + assert_eq!(channels[0].channel_id, channel_id); + }, + kind => panic!("unexpected kind {:?}", kind), + } + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!( + record.candidates().iter().map(|c| c.txid).collect::>(), + vec![prior_txid, txid] + ); + let prior = record.candidate(prior_txid).unwrap(); + assert_eq!(prior.amount_msat, None); + assert!(!prior.awaiting_broadcast); + let signed = record.candidate(txid).unwrap(); + assert_eq!(signed.amount_msat, Some(500_300_000)); + assert_eq!(signed.fee_paid_msat, Some(300_000)); + assert!(signed.awaiting_broadcast); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); + } + /// A fee bump of a round whose payment wallet sync failed — the round lost to a conflicting /// spend confirmed while the channel stayed open, so LDK still tracks it and offers the bump — /// is signed with the failed round among its candidates. The failed record takes no round: its @@ -6695,10 +7507,10 @@ mod tests { .is_none()); } - /// 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. + /// The bump's history still lists a round this node didn't contribute to, carried 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())); @@ -6989,96 +7801,298 @@ mod tests { .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. + /// 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), + open_channel_intent: 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 signing_merges_ldk_history_into_the_recorded_one() { + 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 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 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(); - 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; + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); - 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)); + 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 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. + /// 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_first_round_removes_its_entry() { + 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 other_channel_id = ChannelId([8u8; 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, &[(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( + 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, - other_channel_id, - &[(other_txid, Some(other_contribution))], + channel_id, + &[(prior_txid, None), (bump_txid, Some(bump_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.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, &[]).await.unwrap(); + 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 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)); + 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 @@ -7869,6 +8883,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. @@ -8131,39 +9166,295 @@ mod tests { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(store, false).await; - let txid1 = Txid::from_byte_array([1u8; 32]); - let txid2 = Txid::from_byte_array([2u8; 32]); - let txid3 = Txid::from_byte_array([3u8; 32]); - let payment_id = PaymentId(txid1.to_byte_array()); - let candidates = vec![ - FundingTxCandidate { - txid: txid1, + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); + let txid3 = Txid::from_byte_array([3u8; 32]); + let payment_id = PaymentId(txid1.to_byte_array()); + let candidates = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid3, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(700), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(payment_id, txid3, Some(1_000_000), Some(700)); + let entry = PendingPaymentDetails::new(details, Vec::new(), candidates); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + // The first candidate resolves via the txid-derived id and the active candidate via the + // record's current txid; the middle one must resolve through the candidate history. + assert_eq!(wallet.find_payment_by_txid(txid1).await.unwrap(), Some(payment_id)); + assert_eq!(wallet.find_payment_by_txid(txid3).await.unwrap(), Some(payment_id)); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(payment_id)); + } + + /// The intent of a channel open this node initiated with `counterparty_node_id`. + fn open_intent_for( + counterparty_node_id: PublicKey, user_channel_id: u128, + ) -> OpenChannelIntent { + OpenChannelIntent { + counterparty_node_id, + user_channel_id: UserChannelId(user_channel_id), + temporary_channel_id: ChannelId([0x0cu8; 32]), + } + } + + /// The funding transaction of a channel this node opens: it spends a confirmed output of the + /// wallet's worth 90 000 sats, pays `channel_value_sat` to the funding script and the rest, + /// less `fee_sat`, back to the wallet as change. + fn v1_funding_tx( + wallet: &Wallet, input_byte: u8, channel_value_sat: u64, fee_sat: u64, + ) -> Transaction { + let coin = wallet_paying_tx(wallet, input_byte); + insert_confirmed_tx(wallet, coin.clone(), 100); + let change_script = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::Internal) + .address + .script_pubkey(); + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: OutPoint { txid: coin.compute_txid(), vout: 0 }, + ..Default::default() + }], + output: vec![ + TxOut { + value: Amount::from_sat(channel_value_sat), + script_pubkey: ScriptBuf::new(), + }, + TxOut { + value: Amount::from_sat(90_000 - channel_value_sat - fee_sat), + script_pubkey: change_script, + }, + ], + } + } + + /// Records what the `FundingGenerationReady` handler records about the funding transaction of + /// an open this node initiated: its funding output, and the figures of the open's payment. + async fn record_v1_funding_facts( + wallet: &Wallet, tx: &Transaction, channel: &Channel, funding_payment_id: PaymentId, + ) { + let (amount_msat, fee_paid_msat, direction) = wallet.onchain_payment_fields(tx); + let facts = ChannelTxFacts::new(tx.compute_txid()) + .with_outputs(channel, Some(UserChannelId(7)), ChannelOutputRole::Funding, [0]) + .with_local_figures(LocalFundingFigures { + funding_payment_id, + amount_msat, + fee_paid_msat, + direction, + }); + assert_eq!( + wallet.record_own_funding_tx_facts(facts).await.unwrap(), + FactsRecordOutcome::Recorded + ); + } + + /// Wallet sync files the funding transaction of an open this node initiated under the payment + /// id the open was given, which its facts carry, with the wallet's own view of the amount and + /// the fee — not under an id derived from the txid. The open's bare entry, when it is still + /// there, gains the record and keeps its intent; when the open settled first, the record is + /// filed all the same. + #[tokio::test] + async fn a_v1_funding_is_filed_under_the_opens_payment_id() { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let channel_value_sat = 60_000; + let fee_sat = 400; + + for intent_still_live in [true, false] { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let id = PaymentId([0x0au8; 32]); + let intent = open_intent_for(counterparty_node_id, 7); + if intent_still_live { + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_open(id, intent.clone())) + .await + .unwrap(); + } + + let tx = v1_funding_tx(&wallet, 1, channel_value_sat, fee_sat); + let txid = tx.compute_txid(); + record_v1_funding_facts(&wallet, &tx, &channel, id).await; + observe_unconfirmed(&wallet, &tx).await; + + let payments = + wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "intent live: {}", intent_still_live); + let payment = &payments[0]; + assert_eq!(payment.id, id, "filed under the open's id, not the txid's"); + assert_ne!(payment.id, PaymentId(txid.to_byte_array())); + assert_eq!(payment.amount_msat, Some(channel_value_sat * 1000)); + assert_eq!(payment.fee_paid_msat, Some(fee_sat * 1000)); + assert_eq!(payment.direction, PaymentDirection::Outbound); + assert_eq!( + payment.kind, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::Funding { channels: vec![channel.clone()] }), + } + ); + + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the pending entry tracks the payment toward graduation"); + assert!(entry.details().is_some()); + assert_eq!( + entry.open_channel_intent(), + intent_still_live.then_some(&intent), + "a live intent is kept through the promotion of its entry", + ); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + } + } + + /// A facts read that fails is no answer about whose payment a transaction is: the lookup + /// fails, and with it the pass, which writes no record, rather than filing the transaction + /// under an id of its own beside the one its facts carry. A later pass that can read them + /// files the only record. + #[tokio::test] + async fn a_funding_whose_facts_cannot_be_read_is_not_filed_under_another_id() { + let fail_store = + FailSwitchStore::failing_only(CHANNEL_TX_FACTS_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 channel = Channel { counterparty_node_id, channel_id }; + let id = PaymentId([0x0au8; 32]); + + let tx = v1_funding_tx(&wallet, 1, 60_000, 400); + let txid = tx.compute_txid(); + record_v1_funding_facts(&wallet, &tx, &channel, id).await; + + insert_unconfirmed_tx(&wallet, tx.clone()); + let event = + WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx.clone()), old_block_time: None }; + fail_store.fail_reads.store(true, Ordering::Release); + let result = wallet.update_payment_store(vec![event.clone()]).await; + assert!(matches!(result, Err(Error::PersistenceFailed)), "{:?}", result); + assert!( + wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects.is_empty(), + "a pass that cannot read the facts doesn't file a payment", + ); + + fail_store.fail_reads.store(false, Ordering::Release); + wallet.update_payment_store(vec![event]).await.unwrap(); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + assert_eq!(payments[0].id, id); + + // The lookup itself fails, not only the pass around it: a wallet whose cache does not + // hold the facts — one loaded over the same store — gets no answer rather than a wrong + // one. + let cold_wallet = new_test_wallet(Arc::clone(&store), true).await; + fail_store.fail_reads.store(true, Ordering::Release); + assert!(cold_wallet.find_payment_by_txid(txid).await.is_err()); + fail_store.fail_reads.store(false, Ordering::Release); + assert_eq!(cold_wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + } + + /// Removing the payment of a channel open or a splice still in flight drops the record and + /// the conflicts listed for it, but not the intent, the rounds this node signed or the ones + /// LDK promoted: the open or splice is still settled by the events about it and recognized + /// by a restart, and a round of it still resolves to its id. + #[tokio::test] + async fn remove_payment_keeps_a_live_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // The funding payment of an open, filed while the open is still in flight. + let open_id = PaymentId([0x0au8; 32]); + let open_txid = Txid::from_byte_array([1u8; 32]); + let conflicting_txid = Txid::from_byte_array([2u8; 32]); + let open_intent = open_intent_for(counterparty_node_id, 7); + let details = funding_payment(open_id, open_txid, PaymentStatus::Pending); + wallet.payment_stores.payment_store().insert_or_update(details.clone()).await.unwrap(); + let mut entry = PendingPaymentDetails::new(details, vec![conflicting_txid], Vec::new()); + entry.open_channel_intent = Some(open_intent.clone()); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + wallet.remove_payment(&open_id).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&open_id).await.unwrap().is_none()); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&open_id) + .await + .unwrap() + .expect("the open is still tracked"); + assert_eq!(entry, PendingPaymentDetails::pending_open(open_id, open_intent)); + assert_eq!(wallet.find_payment_by_txid(open_txid).await.unwrap(), None); + assert_eq!(wallet.find_payment_by_txid(conflicting_txid).await.unwrap(), None); + + // The payment of a splice with a signed round and a round LDK promoted. + let splice_id = PaymentId([0x0bu8; 32]); + let round_txid = Txid::from_byte_array([3u8; 32]); + let locked_txid = Txid::from_byte_array([4u8; 32]); + let splice_intent = splice_intent_for( + counterparty_node_id, + channel_id, + LdkOutPoint { txid: locked_txid, index: 0 }, + ); + let details = funding_payment(splice_id, round_txid, PaymentStatus::Pending); + wallet.payment_stores.payment_store().insert_or_update(details.clone()).await.unwrap(); + let mut entry = PendingPaymentDetails::tracked( + details, + Vec::new(), + vec![FundingTxCandidate { + txid: round_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), awaiting_broadcast: false, - }, - FundingTxCandidate { - txid: txid2, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(600), - awaiting_broadcast: false, - }, - FundingTxCandidate { - txid: txid3, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(700), - awaiting_broadcast: false, - }, - ]; - let details = interactive_funding_details(payment_id, txid3, Some(1_000_000), Some(700)); - let entry = PendingPaymentDetails::new(details, Vec::new(), candidates); + }], + Some(splice_intent.clone()), + ); + assert!(entry.record_locked_round(locked_txid)); wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); - // The first candidate resolves via the txid-derived id and the active candidate via the - // record's current txid; the middle one must resolve through the candidate history. - assert_eq!(wallet.find_payment_by_txid(txid1).await.unwrap(), Some(payment_id)); - assert_eq!(wallet.find_payment_by_txid(txid3).await.unwrap(), Some(payment_id)); - assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(payment_id)); + wallet.remove_payment(&splice_id).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&splice_id).await.unwrap().is_none()); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&splice_id) + .await + .unwrap() + .expect("the splice is still tracked"); + assert_eq!(entry.splice_intent(), Some(&splice_intent)); + assert!(entry.details().is_none()); + assert!(entry.conflicting_txids().is_empty()); + assert_eq!(entry.candidates().len(), 1, "the round this node signed stays"); + assert_eq!(entry.candidates()[0].txid, round_txid); + assert_eq!(entry.locked_rounds(), &[locked_txid]); + assert_eq!(wallet.find_payment_by_txid(round_txid).await.unwrap(), Some(splice_id)); } /// Removing a payment must also drop its pending-store entry. The entry indexes the @@ -8378,6 +9669,7 @@ mod tests { conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), splice_intent: None, + open_channel_intent: None, }) .await .unwrap(); @@ -8597,6 +9889,7 @@ mod tests { conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), splice_intent: None, + open_channel_intent: None, }) .await .unwrap(); @@ -8683,6 +9976,7 @@ mod tests { conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), splice_intent: None, + open_channel_intent: None, }) .await .unwrap(); @@ -8777,6 +10071,7 @@ mod tests { conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), splice_intent: None, + open_channel_intent: None, }) .await .unwrap(); @@ -8863,6 +10158,7 @@ mod tests { conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), splice_intent: None, + open_channel_intent: None, }) .await .unwrap(); @@ -8955,6 +10251,7 @@ mod tests { conflicting_txids: Some(vec![bumped_txid]), candidates: Vec::new(), splice_intent: None, + open_channel_intent: None, }) .await .unwrap(); @@ -9009,6 +10306,7 @@ mod tests { conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), splice_intent: None, + open_channel_intent: None, }) .await .unwrap(); @@ -9077,6 +10375,7 @@ mod tests { conflicting_txids: Some(vec![conflict_txid]), candidates: Vec::new(), splice_intent: None, + open_channel_intent: None, }) .await .unwrap(); @@ -9378,6 +10677,7 @@ mod tests { conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), splice_intent: None, + open_channel_intent: None, }) .await .unwrap(); @@ -10951,6 +12251,277 @@ mod tests { assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } + /// A record failed as a round this node did not contribute to locked keeps the splice intent + /// it carries as a bare intent under an id of its own: the intent is the splice tracker's to + /// settle — at this lock, or from the failure LDK reports for the contribution — and the + /// record's removal must not take it along, nor may it stay under the failed record's id, + /// which the fresh round of a fee bump LDK carries across the lock would adopt. + #[tokio::test] + async fn promoting_a_round_not_ours_keeps_the_failed_records_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::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()); + } + + /// A crash between keeping the intent and removing the failed record's entry leaves both; + /// the replay removes the entry and adds no second copy of the intent. + #[tokio::test] + async fn a_replayed_failure_does_not_duplicate_the_kept_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::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(); + // The prior pass failed the record and kept the intent, then crashed before removing + // the entry. + wallet + .payment_stores + .payment_store() + .mutate(&id, |existing| { + let mut update = PaymentDetailsUpdate::new(id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + let kept_id = PaymentId([32u8; 32]); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(kept_id, intent.clone())) + .await + .unwrap(); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + assert_eq!( + bare_entries_carrying(&wallet, &intent).await, + vec![PendingPaymentDetails::pending_splice(kept_id, intent)], + ); + } + /// A round of ours nothing had broadcast when the counterparty's round locked — our /// signatures were never exchanged — is dropped with the promotion, and its record with it, /// rather than failed: no transaction of ours ever existed to fail a payment for. @@ -12045,6 +13616,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. @@ -12646,6 +14306,241 @@ mod tests { ); } + /// 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()); + } + + /// The intent of a channel open is a leftover under an advanced payment just as a splice's + /// is: the funding confirmed, so the open is not one still in flight. + #[tokio::test] + async fn recording_a_transaction_of_an_advanced_payment_removes_its_leftover_open_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, _) = test_counterparty_and_channel(); + + let id = PaymentId([23u8; 32]); + let txid = Txid::from_byte_array([24u8; 32]); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_open( + id, + open_intent_for(counterparty_node_id, 7), + )) + .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); + + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + /// A round LDK promoted can still confirm once the record graduated, so an entry recording + /// one is not a leftover. + #[tokio::test] + async fn a_leftover_intent_that_tracks_a_locked_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 locked_txid = Txid::from_byte_array([25u8; 32]); + let mut entry = PendingPaymentDetails::pending_splice(id, test_splice_intent()); + assert!(entry.record_locked_round(locked_txid)); + 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); + + let kept = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert_eq!(kept.locked_rounds(), &[locked_txid]); + } + + #[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())); diff --git a/src/wallet/payment_stores.rs b/src/wallet/payment_stores.rs index e88831725c..66fb682824 100644 --- a/src/wallet/payment_stores.rs +++ b/src/wallet/payment_stores.rs @@ -119,6 +119,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). @@ -131,11 +141,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). @@ -149,7 +176,7 @@ impl PaymentStoresGuard<'_> { } /// [`Self::mutate_pending_payment`] with a transformation that awaits fallible reads; see - /// [`DataStore::mutate_async`](crate::data_store::DataStore::mutate_async). + /// [`DataStore::mutate_with`](crate::data_store::DataStore::mutate_with). pub(super) async fn mutate_pending_payment_async( &self, id: &PaymentId, f: F, ) -> Result, Error> @@ -157,6 +184,6 @@ impl PaymentStoresGuard<'_> { F: FnOnce(Option) -> Fut, Fut: Future, Error>>, { - self.stores.pending_payment_store.mutate_async(id, f).await + self.stores.pending_payment_store.mutate_with(id, f).await } } diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index 94523de9d1..ea4906a7d7 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -99,8 +99,9 @@ impl ChannelOutputFact { } } -/// This node's share of an interactively negotiated funding transaction, and the funding payment -/// the transaction belongs to. +/// This node's share of a funding transaction it took part in building — the funding +/// transaction of a channel it opened, or a round of an interactively negotiated funding — and +/// the funding payment the transaction belongs to. /// /// The amount and the fee are `None` for a candidate this node contributed nothing to, e.g. a /// counterparty-initiated round before one of ours replaced it. @@ -243,7 +244,7 @@ impl ChannelTxFacts { self } - /// Records this node's share of an interactively negotiated funding candidate, and the funding + /// Records this node's share of a funding candidate it took part in building, and the funding /// payment the candidate belongs to. pub(crate) fn with_local_figures(mut self, local_figures: LocalFundingFigures) -> Self { self.local_figures = Some(local_figures); @@ -335,7 +336,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 +356,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. @@ -721,8 +748,8 @@ impl TxProvenance { classify(tx, self.self_facts.as_ref(), &self.parent_facts) } - /// This node's share of the transaction, for a candidate of an interactively negotiated - /// funding a producer reported the figures of. + /// This node's share of the transaction, for a funding candidate it took part in building + /// that a producer reported the figures of. pub(crate) fn local_figures(&self) -> Option<&LocalFundingFigures> { self.self_facts.as_ref()?.local_figures.as_ref() } 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 a1e79de0a1..9402f41171 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 { @@ -3124,6 +3161,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_out_max() { do_splice_out_max(false, None, false).await; @@ -4562,7 +4668,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() { @@ -4592,7 +4699,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" @@ -4855,159 +4964,746 @@ 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 simple_bolt12_send_receive() { +async fn splice_failure_surfaced_after_disconnect_mid_negotiation() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); - let address_a = node_a.onchain_payment().new_address().unwrap(); - let premine_amount_sat = 5_000_000; + // 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, - vec![address_a], - Amount::from_sat(premine_amount_sat), + addresses_a, + Amount::from_sat(32_000), ) .await; - node_a.sync_wallets().unwrap(); - open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; + 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()); - // Sleep until we broadcasted a node announcement. - while node_b.status().latest_node_announcement_broadcast_timestamp.is_none() { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } + 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 { .. }), + .. + } + )); - // Sleep one more sec to make sure the node announcement propagates. - tokio::time::sleep(std::time::Duration::from_secs(1)).await; + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} - let expected_amount_msat = 100_000_000; - let offer = - node_b.bolt12_payment().receive(expected_amount_msat, "asdf", None, Some(1)).unwrap(); - let expected_quantity = Some(1); - let expected_payer_note = Some("Test".to_string()); - let payment_id = node_a - .bolt12_payment() - .send(&offer, expected_quantity, expected_payer_note.clone(), None) - .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); - let event = node_a.next_event_async().await; - let (invoice, payment_preimage) = match event { - ref e @ Event::PaymentSuccessful { - payment_id: ref evt_id, - ref bolt12_invoice, - ref payment_preimage, - .. - } => { - println!("{} got event {:?}", node_a.node_id(), e); - assert_eq!(*evt_id, payment_id); - assert!( - bolt12_invoice.is_some(), - "bolt12_invoice should be present for BOLT12 payments" - ); - let invoice = bolt12_invoice.as_ref().unwrap().bolt12_invoice().unwrap().clone(); - let captured = (invoice, payment_preimage.unwrap()); - node_a.event_handled().unwrap(); - captured - }, - ref e => panic!("{} got unexpected event!: {:?}", "node_a", e), - }; + // 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); - // The payer proof is built purely from what the event handed us -- nothing is read back out - // of the payment store. - let expected_proof_note = "Paid in full".to_string(); - let options = PayerProofOptions { - note: Some(expected_proof_note.clone()), - include_offer_description: true, - include_invoice_amount: true, - ..Default::default() - }; - let payer_proof = node_a - .bolt12_payment() - .create_payer_proof(payment_id, payment_preimage, &invoice, Some(options)) - .unwrap(); - assert_eq!(payer_proof.payment_preimage(), payment_preimage); - assert_eq!(payer_proof.invoice_amount_msats(), Some(expected_amount_msat)); - assert_eq!(payer_proof.proof_note().map(|n| n.to_string()), Some(expected_proof_note)); - assert!(payer_proof.offer_description().is_some()); - // Fields we didn't ask to disclose stay absent. - assert!(payer_proof.offer_issuer().is_none()); - assert!(payer_proof.invoice_created_at().is_none()); - let node_a_payments = - node_a.list_payments_matching(|p| matches!(p.kind, PaymentKind::Bolt12Offer { .. })); - assert_eq!(node_a_payments.len(), 1); - match node_a_payments.first().unwrap().kind { - PaymentKind::Bolt12Offer { - hash, - preimage, - secret: _, - offer_id, - quantity: ref qty, - payer_note: ref note, - } => { - assert!(hash.is_some()); - assert!(preimage.is_some()); - assert_eq!(offer_id, offer.id()); - assert_eq!(&expected_quantity, qty); - assert_eq!(expected_payer_note.unwrap(), note.clone().unwrap().0); - // TODO: We should eventually set and assert the secret sender-side, too, but the BOLT12 - // API currently doesn't allow to do that. - }, - _ => { - panic!("Unexpected payment kind"); - }, - } - assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(expected_amount_msat)); + let (onchain_balance_before_sat, splice_out_address, user_channel_id_a) = { + let node_a = setup_node(&chain_source, config_a.clone()); - expect_payment_received_event!(node_b, expected_amount_msat); - let node_b_payments = - node_b.list_payments_matching(|p| matches!(p.kind, PaymentKind::Bolt12Offer { .. })); - assert_eq!(node_b_payments.len(), 1); - match node_b_payments.first().unwrap().kind { - PaymentKind::Bolt12Offer { hash, preimage, secret, offer_id, .. } => { - assert!(hash.is_some()); - assert!(preimage.is_some()); - assert!(secret.is_some()); - assert_eq!(offer_id, offer.id()); - }, - _ => { - panic!("Unexpected payment kind"); - }, - } - assert_eq!(node_b_payments.first().unwrap().amount_msat, Some(expected_amount_msat)); + 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; - // Test send_using_amount - let offer_amount_msat = 100_000_000; - let less_than_offer_amount = offer_amount_msat - 10_000; - let expected_amount_msat = offer_amount_msat + 10_000; - let offer = node_b.bolt12_payment().receive(offer_amount_msat, "asdf", None, Some(1)).unwrap(); - let expected_quantity = Some(1); - let expected_payer_note = Some("Test".to_string()); - assert!(node_a - .bolt12_payment() - .send_using_amount(&offer, less_than_offer_amount, None, None, None) - .is_err()); - let payment_id = node_a - .bolt12_payment() - .send_using_amount( - &offer, - expected_amount_msat, - expected_quantity, - expected_payer_note.clone(), - None, + 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 + }; - expect_payment_successful_event!(node_a, payment_id, None); + // 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(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).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()); + + // Sleep until we broadcasted a node announcement. + while node_b.status().latest_node_announcement_broadcast_timestamp.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + // Sleep one more sec to make sure the node announcement propagates. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let expected_amount_msat = 100_000_000; + let offer = + node_b.bolt12_payment().receive(expected_amount_msat, "asdf", None, Some(1)).unwrap(); + let expected_quantity = Some(1); + let expected_payer_note = Some("Test".to_string()); + let payment_id = node_a + .bolt12_payment() + .send(&offer, expected_quantity, expected_payer_note.clone(), None) + .unwrap(); + + let event = node_a.next_event_async().await; + let (invoice, payment_preimage) = match event { + ref e @ Event::PaymentSuccessful { + payment_id: ref evt_id, + ref bolt12_invoice, + ref payment_preimage, + .. + } => { + println!("{} got event {:?}", node_a.node_id(), e); + assert_eq!(*evt_id, payment_id); + assert!( + bolt12_invoice.is_some(), + "bolt12_invoice should be present for BOLT12 payments" + ); + let invoice = bolt12_invoice.as_ref().unwrap().bolt12_invoice().unwrap().clone(); + let captured = (invoice, payment_preimage.unwrap()); + node_a.event_handled().unwrap(); + captured + }, + ref e => panic!("{} got unexpected event!: {:?}", "node_a", e), + }; + + // The payer proof is built purely from what the event handed us -- nothing is read back out + // of the payment store. + let expected_proof_note = "Paid in full".to_string(); + let options = PayerProofOptions { + note: Some(expected_proof_note.clone()), + include_offer_description: true, + include_invoice_amount: true, + ..Default::default() + }; + let payer_proof = node_a + .bolt12_payment() + .create_payer_proof(payment_id, payment_preimage, &invoice, Some(options)) + .unwrap(); + assert_eq!(payer_proof.payment_preimage(), payment_preimage); + assert_eq!(payer_proof.invoice_amount_msats(), Some(expected_amount_msat)); + assert_eq!(payer_proof.proof_note().map(|n| n.to_string()), Some(expected_proof_note)); + assert!(payer_proof.offer_description().is_some()); + // Fields we didn't ask to disclose stay absent. + assert!(payer_proof.offer_issuer().is_none()); + assert!(payer_proof.invoice_created_at().is_none()); + let node_a_payments = + node_a.list_payments_matching(|p| matches!(p.kind, PaymentKind::Bolt12Offer { .. })); + assert_eq!(node_a_payments.len(), 1); + match node_a_payments.first().unwrap().kind { + PaymentKind::Bolt12Offer { + hash, + preimage, + secret: _, + offer_id, + quantity: ref qty, + payer_note: ref note, + } => { + assert!(hash.is_some()); + assert!(preimage.is_some()); + assert_eq!(offer_id, offer.id()); + assert_eq!(&expected_quantity, qty); + assert_eq!(expected_payer_note.unwrap(), note.clone().unwrap().0); + // TODO: We should eventually set and assert the secret sender-side, too, but the BOLT12 + // API currently doesn't allow to do that. + }, + _ => { + panic!("Unexpected payment kind"); + }, + } + assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(expected_amount_msat)); + + expect_payment_received_event!(node_b, expected_amount_msat); + let node_b_payments = + node_b.list_payments_matching(|p| matches!(p.kind, PaymentKind::Bolt12Offer { .. })); + assert_eq!(node_b_payments.len(), 1); + match node_b_payments.first().unwrap().kind { + PaymentKind::Bolt12Offer { hash, preimage, secret, offer_id, .. } => { + assert!(hash.is_some()); + assert!(preimage.is_some()); + assert!(secret.is_some()); + assert_eq!(offer_id, offer.id()); + }, + _ => { + panic!("Unexpected payment kind"); + }, + } + assert_eq!(node_b_payments.first().unwrap().amount_msat, Some(expected_amount_msat)); + + // Test send_using_amount + let offer_amount_msat = 100_000_000; + let less_than_offer_amount = offer_amount_msat - 10_000; + let expected_amount_msat = offer_amount_msat + 10_000; + let offer = node_b.bolt12_payment().receive(offer_amount_msat, "asdf", None, Some(1)).unwrap(); + let expected_quantity = Some(1); + let expected_payer_note = Some("Test".to_string()); + assert!(node_a + .bolt12_payment() + .send_using_amount(&offer, less_than_offer_amount, None, None, None) + .is_err()); + let payment_id = node_a + .bolt12_payment() + .send_using_amount( + &offer, + expected_amount_msat, + expected_quantity, + expected_payer_note.clone(), + None, + ) + .unwrap(); + + expect_payment_successful_event!(node_a, payment_id, None); let node_a_payments = node_a.list_payments_matching(|p| { matches!(p.kind, PaymentKind::Bolt12Offer { .. }) && p.id == payment_id }); @@ -7594,3 +8290,342 @@ async fn open_channel_with_fee_rate_override() { node_c.stop().unwrap(); node_d.stop().unwrap(); } + +/// Asserts that the funding payment of a channel opened through `open_channel` is recorded under +/// the id the open returned, and under no id derived from the funding transaction's txid. +fn assert_funding_payment_filed_under( + node: &TestNode, payment_id: PaymentId, funding_txid: Txid, funding_amount_sat: u64, + status: PaymentStatus, +) { + let payment = node + .payment(&payment_id) + .unwrap() + .expect("the funding payment is recorded under the id the open returned"); + assert_eq!(payment.status, status); + assert_eq!(payment.direction, PaymentDirection::Outbound); + assert_eq!(payment.amount_msat, Some(funding_amount_sat * 1000)); + assert!(payment.fee_paid_msat.is_some(), "this node paid the funding transaction's fee"); + match payment.kind { + PaymentKind::Onchain { txid, tx_type, .. } => { + assert_eq!(txid, funding_txid); + assert!( + matches!(tx_type, Some(TransactionType::Funding { .. })), + "unexpected transaction type {tx_type:?}" + ); + }, + ref kind => panic!("unexpected payment kind {kind:?}"), + } + assert!( + node.payment(&PaymentId(funding_txid.to_byte_array())).unwrap().is_none(), + "no record under the txid-derived id" + ); +} + +/// Waits for the next event of `node`, which must be a `ChannelClosed` for `user_channel_id` +/// with `counterparty_node_id`, and returns its closure reason. +async fn expect_channel_closed_event( + node: &TestNode, user_channel_id: UserChannelId, + counterparty_node_id: bitcoin::secp256k1::PublicKey, +) -> Option { + let event = tokio::time::timeout( + std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), + node.next_event_async(), + ) + .await + .unwrap_or_else(|_| panic!("{} timed out waiting for ChannelClosed event", node.node_id())); + match event { + Event::ChannelClosed { + user_channel_id: closed_user_channel_id, + counterparty_node_id: closed_counterparty_node_id, + reason, + .. + } => { + println!("{} got event ChannelClosed with reason {:?}", node.node_id(), reason); + assert_eq!(closed_user_channel_id, user_channel_id); + assert_eq!(closed_counterparty_node_id, counterparty_node_id); + node.event_handled().unwrap(); + reason + }, + e => panic!("{} got unexpected event!: {:?}", node.node_id(), e), + } +} + +/// The keys of the pending-store entries of `storage_dir_path`'s SQLite store that index no +/// payment: the entries an open or a splice this node initiated is recorded in until it has a +/// funding transaction. Read while the node is down. +async fn bare_pending_entry_keys(storage_dir_path: &str) -> Vec { + use ldk_node::io::sqlite_store::{SqliteStore, KV_TABLE_NAME, SQLITE_DB_FILE_NAME}; + + let store = SqliteStore::new( + storage_dir_path.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(); + store + .list("pending_payments", "") + .await + .unwrap() + .into_iter() + .filter(|key| !payment_keys.contains(key)) + .collect() +} + +/// The payment funding a channel opened through `open_channel` is recorded under the id the open +/// returned: once the funding transaction is broadcast, across a restart before it confirms, and +/// once it confirms — never under an id derived from the funding txid. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn open_channel_funding_payment_is_filed_under_the_returned_id() { + 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 node_a = setup_node(&chain_source, config_a.clone()); + let node_b = setup_node(&chain_source, random_config()); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(1_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let funding_amount_sat = 500_000; + let opened = node_a + .open_channel( + node_b.node_id(), + node_b.listening_addresses().unwrap().first().unwrap().clone(), + funding_amount_sat, + None, + None, + None, + ) + .unwrap(); + + let funding_txo = expect_channel_pending_event!(node_a, node_b.node_id()); + expect_channel_pending_event!(node_b, node_a.node_id()); + // The funding transaction is broadcast, and its payment recorded, by a background task once + // LDK hands it over: wait for the transaction to be seen, and let wallet sync record the + // payment should the broadcaster not have yet. + wait_for_tx(&electrsd.client, funding_txo.txid).await; + node_a.sync_wallets().unwrap(); + assert_funding_payment_filed_under( + &node_a, + opened.funding_payment_id, + funding_txo.txid, + funding_amount_sat, + PaymentStatus::Pending, + ); + + // The channel being pending ended the open as one in flight: removing the payment leaves no + // trace of the open behind that a restart could take for one still in flight. (The pending + // store still holds the premine's inbound payment, which has a record.) + node_a.remove_payment(&opened.funding_payment_id).unwrap(); + assert!(node_a.payment(&opened.funding_payment_id).unwrap().is_none()); + node_a.stop().unwrap(); + assert!( + bare_pending_entry_keys(&config_a.node_config.storage_dir_path).await.is_empty(), + "a pending channel's open is settled, so removing its payment leaves no intent behind", + ); + + // The open is past its funding at the restart, so LDK still lists the channel: the restart + // reports nothing for it. + let node_a = setup_node(&chain_source, config_a); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "an open LDK still lists must not be reported lost"); + + // Confirming the funding records the payment again, under the returned id still: the facts + // recorded about the funding transaction carry it. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id = expect_channel_ready_event!(node_a, node_b.node_id()); + assert_eq!(user_channel_id, opened.user_channel_id); + expect_channel_ready_event!(node_b, node_a.node_id()); + assert_funding_payment_filed_under( + &node_a, + opened.funding_payment_id, + funding_txo.txid, + funding_amount_sat, + PaymentStatus::Succeeded, + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// An open LDK refuses outright fails without a trace: no event, no payment, nothing persisted. +/// An open the counterparty rejects is reported through `ChannelClosed` under the returned +/// `user_channel_id`, leaves no payment under the returned id, and is not reported again after a +/// restart. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn refused_and_rejected_opens_leave_no_funding_payment() { + 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 node_a = setup_node(&chain_source, config_a.clone()); + // Without an alias node_b cannot announce channels, so it rejects an announced open — every + // time: LDK retries a rejected open with a less demanding channel type before giving up. + let mut config_b = random_config(); + config_b.node_config.node_alias = None; + let node_b = setup_node(&chain_source, config_b); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(1_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + + // Below the minimum channel value LDK accepts, so the open never gets past LDK. + let refused = node_a.open_channel(node_b.node_id(), node_b_addr.clone(), 999, None, None, None); + assert!( + matches!(refused, Err(NodeError::ChannelCreationFailed)), + "unexpected open result {refused:?}" + ); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + assert!(node_a.next_event().is_none(), "an open LDK refused must not be reported"); + + let opened = node_a + .open_announced_channel(node_b.node_id(), node_b_addr, 100_000, None, None, None) + .unwrap(); + let reason = + expect_channel_closed_event(&node_a, opened.user_channel_id, node_b.node_id()).await; + assert!( + matches!(reason, Some(lightning::events::ClosureReason::CounterpartyForceClosed { .. })), + "unexpected closure reason {reason:?}" + ); + assert!( + node_a.payment(&opened.funding_payment_id).unwrap().is_none(), + "a rejected open never gets a funding payment" + ); + assert!( + node_a.list_payments_matching(|p| p.direction == PaymentDirection::Outbound).is_empty(), + "neither open leaves an outbound payment behind" + ); + + // Neither open leaves anything for a restart to act on. + node_a.stop().unwrap(); + let leftover_keys = bare_pending_entry_keys(&config_a.node_config.storage_dir_path).await; + assert!( + leftover_keys.is_empty(), + "a refused or rejected open leaves no pending entry; left {leftover_keys:?}, the rejected \ + open's payment id being {}", + opened.funding_payment_id.0.to_lower_hex_string() + ); + + let node_a = setup_node(&chain_source, config_a); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a settled open must not be reported again"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// An open this node initiated but LDK no longer knows of after a restart — one still waiting for +/// the counterparty's acceptance at shutdown, which LDK keeps resumable rather than persisting a +/// close for — is reported at startup through `ChannelClosed` under the open's `user_channel_id`, +/// with `DisconnectedPeer` as the reason, and is not reported again after a further restart. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn lost_open_is_reported_after_restart() { + use ldk_node::io::sqlite_store::{SqliteStore, KV_TABLE_NAME, SQLITE_DB_FILE_NAME}; + use lightning::events::ClosureReason; + use lightning::ln::types::ChannelId; + use lightning::util::ser::Writeable; + + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let node_b = setup_node(&chain_source, random_config()); + + // Set up node_a manually so it can be restarted with the same config, and stop it right away: + // the open below is planted while it is down. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let node_a = setup_node(&chain_source, config_a.clone()); + node_a.stop().unwrap(); + + // The entry `open_channel` persists before handing the open to LDK, in the layout the node + // reads it back in: the payment id it returned, and the intent of the open — with no payment + // recorded under it yet, as for an open whose funding was never generated. + struct PlantedOpenChannelIntent { + counterparty_node_id: bitcoin::secp256k1::PublicKey, + user_channel_id: u128, + temporary_channel_id: ChannelId, + } + lightning::impl_writeable_tlv_based!(PlantedOpenChannelIntent, { + (0, counterparty_node_id, required), + (2, user_channel_id, required), + (4, temporary_channel_id, required), + }); + struct PlantedPendingPayment { + id: PaymentId, + open_channel_intent: Option, + } + lightning::impl_writeable_tlv_based!(PlantedPendingPayment, { + (0, id, required), + (14, open_channel_intent, option), + }); + + let funding_payment_id = PaymentId([0xAB; 32]); + let user_channel_id = UserChannelId(0x1234_5678_9ABC_DEF0_u128); + let temporary_channel_id = ChannelId::from_bytes([0xCD; 32]); + let planted = PlantedPendingPayment { + id: funding_payment_id, + open_channel_intent: Some(PlantedOpenChannelIntent { + counterparty_node_id: node_b.node_id(), + user_channel_id: user_channel_id.0, + temporary_channel_id, + }), + }; + 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 key = funding_payment_id.0.to_lower_hex_string(); + store.write("pending_payments", "", &key, planted.encode()).await.unwrap(); + drop(store); + + // LDK lists no channel for the planted open, so startup reports it lost. + let node_a = setup_node(&chain_source, config_a.clone()); + let reason = expect_channel_closed_event(&node_a, user_channel_id, node_b.node_id()).await; + assert!( + matches!(reason, Some(ClosureReason::DisconnectedPeer)), + "unexpected closure reason {reason:?}" + ); + assert!( + node_a.payment(&funding_payment_id).unwrap().is_none(), + "a lost open has no funding payment" + ); + + // The report settles the open: the entry is gone, and a further restart stays silent. + node_a.stop().unwrap(); + let leftover_keys = bare_pending_entry_keys(&config_a.node_config.storage_dir_path).await; + assert!(leftover_keys.is_empty(), "a reported open leaves no pending entry: {leftover_keys:?}"); + + let node_a = setup_node(&chain_source, config_a); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a reported open must not be reported again"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +}