diff --git a/Cargo.toml b/Cargo.toml index 9f8a729655..f6567ff29a 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -245,3 +245,27 @@ harness = false #lightning-liquidity = { path = "../rust-lightning/lightning-liquidity" } #lightning-macros = { path = "../rust-lightning/lightning-macros" } #lightning-dns-resolver = { path = "../rust-lightning/lightning-dns-resolver" } + +# TEMPORARY - this section must not reach a proposed branch. +# +# Overrides the revision pinned above with a local checkout of the rust-lightning branch +# `2026-09-restore-tx-only-broadcasting-0.3`, which restores +# `BroadcasterInterface::broadcast_transactions` to taking `&[&Transaction]`. The paths below +# exist only on the machine this was developed on, so the tree builds nowhere else while they +# are in place. +# +# Before this work is proposed, delete this whole section and move the `rev` of every crate +# above to an accessible, reviewed revision carrying the same revert. +[patch."https://github.com/lightningdevkit/rust-lightning"] +lightning = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning" } +lightning-types = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-types" } +lightning-invoice = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-invoice" } +lightning-net-tokio = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-net-tokio" } +lightning-persister = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-persister" } +lightning-background-processor = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-background-processor" } +lightning-rapid-gossip-sync = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-rapid-gossip-sync" } +lightning-block-sync = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-block-sync" } +lightning-transaction-sync = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-transaction-sync" } +lightning-liquidity = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-liquidity" } +lightning-macros = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-macros" } +lightning-dns-resolver = { path = "/home/tnull/worktrees/rust-lightning/2026-09-restore-tx-only-broadcasting-0.3/lightning-dns-resolver" } diff --git a/src/builder.rs b/src/builder.rs index 1158044e47..f602abc419 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -53,11 +53,13 @@ use lightning_dns_resolver::OMDomainResolver; use vss_client::headers::VssHeaderProvider; use crate::chain::ChainSource; +use crate::channel::SpliceTracker; #[cfg(feature = "chain-bitcoind")] use crate::config::BitcoindRestClientConfig; use crate::config::{ default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig, TorConfig, + CHANNEL_TX_FACTS_CACHE_CAPACITY, CHANNEL_TX_FACTS_CACHE_WARMUP_COUNT, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL, DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, PAYMENT_CACHE_CAPACITY, PAYMENT_CACHE_WARMUP_COUNT, @@ -83,6 +85,8 @@ use crate::io::utils::{ use crate::io::vss_store::VssStoreBuilder; use crate::io::{ self, CHANNEL_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE, FORWARDED_PAYMENT_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, @@ -104,11 +108,12 @@ use crate::probing::{ use crate::runtime::{Runtime, RuntimeSpawner}; use crate::tx_broadcaster::TransactionBroadcaster; use crate::types::{ - AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreRef, DynStoreWrapper, - GossipSync, Graph, KeysManager, MessageRouter, OnionMessenger, PaymentStore, PeerManager, - PendingPaymentStore, + AsyncPersister, ChainMonitor, ChannelManager, ChannelTxFactsStore, DynStore, DynStoreRef, + DynStoreWrapper, GossipSync, Graph, KeysManager, MessageRouter, OnionMessenger, PaymentStore, + PeerManager, PendingPaymentStore, }; use crate::wallet::persist::{read_address_pool, KVStoreWalletPersister}; +use crate::wallet::provenance::NodeChannelLiveness; use crate::wallet::Wallet; use crate::{Node, NodeMetrics, PersistedNodeMetrics}; @@ -1552,6 +1557,7 @@ fn build_with_store_internal( channel_forwarding_stats_res, node_metris_res, pending_payment_store_res, + channel_tx_facts_store_res, address_pool_res, ) = runtime.block_on(async move { tokio::join!( @@ -1575,6 +1581,13 @@ fn build_with_store_internal( PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, Arc::clone(&logger_ref), ), + read_n_objects( + &*kv_store_ref, + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_TX_FACTS_CACHE_WARMUP_COUNT, + Arc::clone(&logger_ref), + ), read_address_pool(&*kv_store_ref, &*logger_ref), ) }); @@ -1906,6 +1919,24 @@ fn build_with_store_internal( }, }; + let channel_tx_facts_store = match channel_tx_facts_store_res { + Ok(channel_tx_facts) => Arc::new(ChannelTxFactsStore::new( + // The read hands us the newest records first, while the cache treats the objects it + // is seeded with as increasingly recently used. Reverse them, so that the newest + // record is the last one to be evicted rather than the first. + channel_tx_facts.into_iter().rev().collect(), + KeepLeastRecentlyUsed::new(CHANNEL_TX_FACTS_CACHE_CAPACITY), + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&kv_store), + Arc::clone(&logger), + )), + Err(e) => { + log_error!(logger, "Failed to read channel transaction facts from store: {}", e); + return Err(BuildError::ReadFailed); + }, + }; + let persisted_pool_indices = match address_pool_res { Ok(indices) => indices, Err(e) => { @@ -1926,6 +1957,7 @@ fn build_with_store_internal( Arc::clone(&config), Arc::clone(&logger), Arc::clone(&pending_payment_store), + Arc::clone(&channel_tx_facts_store), )); // Fill the address pool up front so LDK's sync `SignerProvider` callbacks can hand out @@ -1935,8 +1967,6 @@ fn build_with_store_internal( BuildError::WalletSetupFailed })?; - tx_broadcaster.set_wallet(Arc::downgrade(&wallet)); - // Initialize the KeysManager let cur_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_err(|e| { log_error!(logger, "Failed to get current time: {}", e); @@ -2417,6 +2447,14 @@ fn build_with_store_internal( }, }; + // The wallet drops the facts it recorded for a channel once nothing holds that channel + // anymore, which it can only ask now that the node's channel state exists. + wallet.set_channel_liveness(Arc::new(NodeChannelLiveness::new( + &channel_manager, + &chain_monitor, + &output_sweeper, + ))); + let event_queue = match event_queue_res { Ok(event_queue) => Arc::new(event_queue), Err(e) => { @@ -2500,6 +2538,13 @@ fn build_with_store_internal( }) }); + let splice_tracker = Arc::new(SpliceTracker::new( + Arc::clone(&channel_manager), + Arc::clone(&wallet), + Arc::clone(&pending_payment_store), + Arc::clone(&logger), + )); + #[cfg(cycle_tests)] let mut _leak_checker = crate::LeakChecker(Vec::new()); #[cfg(cycle_tests)] @@ -2551,6 +2596,7 @@ fn build_with_store_internal( payment_store, forwarding_store, forwarded_payment_aggregation_retention_secs, + splice_tracker, lnurl_auth, is_running, node_metrics, diff --git a/src/chain/mod.rs b/src/chain/mod.rs index f01c1c8cb8..4096890c90 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -35,8 +35,9 @@ use crate::config::ElectrumSyncConfig; use crate::config::EsploraSyncConfig; use crate::config::{BackgroundSyncConfig, Config, WALLET_SYNC_INTERVAL_MINIMUM_SECS}; use crate::fee_estimator::OnchainFeeEstimator; -use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::logger::{log_debug, log_info, log_trace, LdkLogger, Logger}; use crate::runtime::Runtime; +use crate::tx_broadcaster::BroadcastPackage; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -562,52 +563,44 @@ impl ChainSource { } } + /// Hands the package to the configured chain source, parents before their child so a CPFP + /// package a chain source submits one transaction at a time is still accepted. + async fn broadcast(&self, package: BroadcastPackage) { + let package = package.into_sorted_transactions(); + match &self.kind { + #[cfg(feature = "chain-esplora")] + ChainSourceKind::Esplora(esplora_chain_source) => { + esplora_chain_source.process_transaction_broadcast(package).await + }, + #[cfg(feature = "chain-electrum")] + ChainSourceKind::Electrum(electrum_chain_source) => { + electrum_chain_source.process_transaction_broadcast(package).await + }, + #[cfg(feature = "chain-bitcoind")] + ChainSourceKind::Bitcoind(bitcoind_chain_source) => { + bitcoind_chain_source.process_transaction_broadcast(package).await + }, + } + } + pub(crate) async fn continuously_process_broadcast_queue( &self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>, ) { - let mut receiver = self.tx_broadcaster.get_broadcast_queue().await; loop { - let tx_bcast_logger = Arc::clone(&self.logger); - tokio::select! { + let package = tokio::select! { + // A stop request is polled first, so a queue that always has a package ready + // cannot starve it. + biased; _ = stop_tx_bcast_receiver.changed() => { log_debug!( - tx_bcast_logger, + self.logger, "Stopping broadcasting transactions.", ); return; } - Some(next_package) = receiver.recv() => { - // Classify funding broadcasts into payment records before sending. If - // classification fails we skip the broadcast, since broadcasting a tx we - // failed to record would leave it on-chain without a payment. - let package = match self.tx_broadcaster.classify_package(next_package).await { - Ok(package) => package, - Err(e) => { - log_error!( - tx_bcast_logger, - "Skipping broadcast: failed to persist payment records: {:?}", - e, - ); - continue; - }, - }; - let package = package.into_sorted_transactions(); - match &self.kind { - #[cfg(feature = "chain-esplora")] - ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_transaction_broadcast(package).await - }, - #[cfg(feature = "chain-electrum")] - ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_transaction_broadcast(package).await - }, - #[cfg(feature = "chain-bitcoind")] - ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_transaction_broadcast(package).await - }, - } - } - } + package = self.tx_broadcaster.next_package() => package, + }; + self.broadcast(package).await; } } } diff --git a/src/channel/mod.rs b/src/channel/mod.rs new file mode 100644 index 0000000000..141bf9a437 --- /dev/null +++ b/src/channel/mod.rs @@ -0,0 +1,1586 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Persistence of in-flight user-initiated splices, so a splice LDK has not durably learned of +//! yet can be recognized — and whatever it reserved recovered — after a restart. + +use std::fmt; +use std::sync::Arc; + +use bitcoin::absolute::LockTime; +use bitcoin::secp256k1::PublicKey; +use bitcoin::transaction::Version; +use bitcoin::{OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid}; +use lightning::chain::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::{StorableObject, UpdatableObject}; +use crate::logger::{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, FundingCandidate, SignedFundingRecord, Wallet, +}; +use crate::Error; + +/// Whether two contributions describe the same splice attempt. LDK may adjust a contribution +/// during negotiation — the quiescence tie-breaker rebuilds the acceptor's copy at a fresh +/// feerate, touching only its fee fields and change value — so fees and feerates do not identify +/// an attempt. Its inputs and outputs do: they are what the user asked to move. Contributions +/// carrying neither (channel-balance-only attempts) fall back to full equality. +pub(crate) fn is_same_splice(a: &FundingContribution, b: &FundingContribution) -> bool { + if a.inputs().is_empty() + && a.outputs().is_empty() + && b.inputs().is_empty() + && b.outputs().is_empty() + { + return a == b; + } + a.inputs().iter().map(|i| i.outpoint()).eq(b.inputs().iter().map(|i| i.outpoint())) + && a.outputs() == b.outputs() +} + +/// Tracks each user-initiated splice through a persisted [`SpliceIntent`] for as long as LDK is +/// not guaranteed to remember the splice itself: LDK only persists a splice once its negotiation +/// reaches `AwaitingSignatures`, and it abandons an in-progress negotiation whenever the peer +/// disconnects — which includes stopping the node. +/// +/// The intent is written before the contribution is handed to LDK, undone when LDK rejects the +/// hand-off synchronously, and cleared once the splice locks, its failure is surfaced, or its +/// channel closes. The record exists for recovery, not retry: a splice still recorded at the next +/// startup identifies one that was in flight when the node stopped, so [`Self::reconcile`] can +/// release what it still reserves where nothing else will, and events about the splice can be +/// described in terms of the original request. Each splice has a record of its own — a channel may +/// carry several, a pending splice and the splices queued behind it — so that each is recognized +/// and described whatever became of the others; only a fee bump joins the record of the round it +/// replaces. +pub(crate) struct SpliceTracker { + channel_manager: Arc, + wallet: Arc, + 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 nothing would + /// settle. Every public entry point takes it; the `_locked` variants assume it is held and + /// must not take it again (tokio's mutex is not reentrant). It nests outward of the wallet's + /// locks and the stores', which are taken while it is held and never hold it. An event + /// handler waiting on it waits for a `submit` to finish its bounded sequence, nothing more. + submit_lock: tokio::sync::Mutex<()>, + logger: Arc, +} + +impl SpliceTracker { + pub(crate) fn new( + channel_manager: Arc, wallet: Arc, + pending_payment_store: Arc, logger: Arc, + ) -> Self { + Self { + channel_manager, + wallet, + pending_payment_store, + submit_lock: tokio::sync::Mutex::new(()), + logger, + } + } + + /// Reconciles the persisted splice intents against live channel state, releasing whatever the + /// wallet still holds for a splice that did not survive the restart and nothing else will + /// release. LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, so a + /// splice lost earlier leaves no trace in LDK's channel state — the intent record is what + /// recognizes the loss. A round LDK did write is another matter: LDK either still holds it, or + /// reports its failure at startup and returns what it reserved and no other round of the + /// channel uses through `DiscardFunding`; a round of a channel that closed meanwhile is watched + /// by the channel's monitor until the close matures. Such rounds are left to those events. Run + /// once at startup, before background chain syncing and event processing start, so nothing can + /// act on the stale reservations first. Holds the submit lock throughout, as the event handlers + /// do. + /// + /// Recovery fabricates no failure event for a splice lost this way: the initiating call + /// already returned and the channel simply shows no pending splice anymore. LDK itself may + /// report the loss — a contribution it was still queueing or negotiating when it was last + /// persisted is failed as it is written, and the failure replayed at startup. That replay + /// runs after this reconciliation, so the report carries the splice's parameters only if + /// `decide_reconcile` kept the intent: a splice queued behind a pending one of ours, or a fee + /// bump of one, is reported with its parameters; a channel's only splice, whose intent + /// settled here, without them. + pub(crate) async fn reconcile(&self) { + let guard = self.submit_lock.lock().await; + let records = self.pending_payment_store.list_filter(|p| p.splice_intent().is_some()).await; + for record in records { + let payment_id = record.id(); + let Some(intent) = record.splice_intent().cloned() else { + continue; + }; + + let channel = self.channel(intent.counterparty_node_id, intent.channel_id); + let Some(channel) = channel else { + // The channel is gone; there is nothing to splice anymore. What the wallet holds + // for the intent is released only while no recorded round exists: a round the + // closed channel's monitor watches is either spent by the close or returned through + // the `DiscardFunding` event the monitor queues once the close matures, and a + // recorded round the monitor never watched — the counterparty's `commitment_signed` + // never arrived before the node stopped — by the `DiscardFunding` LDK reports for + // it at the force-close, as at `ChannelClosed`. A bare intent has no such round — + // LDK never wrote the splice — so nothing else would release it. + log_info!( + self.logger, + "Dropping the recorded splice of closed channel {} with counterparty {}", + intent.channel_id, + intent.counterparty_node_id, + ); + if record.candidates().is_empty() { + self.release_contribution(intent.channel_id, &intent.contribution, &[], None) + .await; + } + // TODO(#1037): once inputs are locked at coin selection, the parts of the + // contribution no recorded round uses stay locked with no record to release them + // from after the intent is cleared here: release them before clearing. And + // `release_contribution` swallows a failed release, which then leaves locks no + // record names either: keep the intent when the release fails. One case stays: a + // hand-off LDK never wrote — the node stopped before the manager's next write — + // whose channel is force-closed as stale at this start and whose round the monitor + // never watched. LDK knows nothing of that round and reports no event for it; + // release its contribution here, which takes the monitor's watched transactions to + // tell such a round from a watched one, as `closed_channel_held_rounds` does at + // `ChannelClosed`. + self.clear_persisted_intent(payment_id, |i| *i == intent).await; + continue; + }; + + if channel.funding_txo != Some(intent.pre_splice_funding_txo) { + // The funding moved on while the node was down: the recorded splice, a + // replacement, or a counterparty splice locked — the same situation a live lock + // event resolves, so resolve it the same way. + if let Some(funding_txo) = channel.funding_txo { + self.settle_superseded_intents_locked( + &guard, + intent.counterparty_node_id, + intent.channel_id, + funding_txo.into_bitcoin_outpoint(), + Some(&channel), + ) + .await; + } + continue; + } + + let candidates = channel + .splice_details + .as_ref() + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]); + match decide_reconcile(candidates) { + ReconcileDecision::Keep => { + // A kept record may still reserve more than LDK's surviving rounds use — + // extras a fee bump lost with the restart had reserved. Release the + // difference. + let extras = unclaimed_inputs(&intent.contribution, candidates); + if let Err(e) = self.wallet.unlock_outpoints(&extras).await { + log_error!( + self.logger, + "Failed to release unused splice inputs on channel {}: {}", + intent.channel_id, + e, + ); + } + }, + ReconcileDecision::Lost => { + log_info!( + self.logger, + "Dropping a splice on channel {} with counterparty {} that did not survive \ + the restart", + intent.channel_id, + intent.counterparty_node_id, + ); + self.release_contribution( + intent.channel_id, + &intent.contribution, + candidates, + None, + ) + .await; + // TODO(#1037): `release_contribution` swallows a failed release. Once inputs + // are locked at coin selection, a failure here leaves locks no record names + // after the intent is cleared: keep the intent when the release fails. + self.clear_persisted_intent(payment_id, |i| *i == intent).await; + }, + } + } + } + + /// Persists a user-initiated splice as an intent and hands its contribution to + /// [`ChannelManager::funding_contributed`]. The intent — and any wallet state staged on the + /// splice's behalf — is durable before the hand-off, so no splice is ever in flight without a + /// persisted record of it. Each splice gets a record of its own; only a fee bump joins the + /// record of the round it replaces ([`Self::persist_intent`]). + /// + /// The intent is anchored at the channel's funding as it stands under the submit lock, not at + /// `pre_splice_funding_txo`, the funding the caller read before building the contribution: a + /// splice locking in between moves the funding, and an intent anchored at the old one would + /// never be settled by the lock that superseded it. A funding that moved refuses a fee bump — + /// the round it was built to replace has locked — and a splice-in, whose inputs the locked + /// round may have spent; a splice-out carries no wallet inputs and proceeds, as LDK + /// re-validates its amount against the live balance ([`check_submission`]). Intents anchored + /// at a funding the channel has moved past are settled first, as their lock event would. + /// + /// On any failure the persisted intent is undone and the error returned for the caller to + /// surface. A failure before the hand-off also releases what the wallet holds for the + /// contribution and no other round claims ([`Self::release_contribution`]): a fee bump built + /// by adjusting the fee of the round it replaces — `prior`, the contribution it was built + /// from — reuses that round's inputs and change address, which a refusal must leave to the + /// round that has locked meanwhile. A synchronous rejection leaves the release to 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 names 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 no record names: surface + // it, or persist an intent for `reconcile` to release from. + self.release_contribution(channel_id, &contribution, candidates, prior.as_ref()) + .await; + return Err(Error::ChannelSplicingFailed); + }, + }; + // LDK promotes a zero-conf splice as soon as `splice_locked` is exchanged and only queues + // the `ChannelReady` event whose handling settles the locked splice's intent. A splice + // submitted in between builds on the new funding while the channel still carries that + // intent: settle it here as the event would. + self.settle_superseded_intents_locked( + &guard, + counterparty_node_id, + channel_id, + funding_txo.into_bitcoin_outpoint(), + channel.as_ref(), + ) + .await; + let intent = SpliceIntent { + counterparty_node_id, + channel_id, + pre_splice_funding_txo: funding_txo, + contribution: contribution.clone(), + kind, + }; + // A splice whose intent cannot be persisted is not attempted at all, rather than + // attempted without restart coverage. + let (payment_id, restore) = match self.persist_intent(intent, channel.as_ref()).await { + Ok(persisted) => persisted, + Err(e) => { + log_error!( + self.logger, + "Failed to persist the splice intent for channel {} with counterparty {}: {:?}", + channel_id, + counterparty_node_id, + e, + ); + // TODO(#1037): as at the refusal above, a failed release here leaves locks no + // record names. + self.release_contribution(channel_id, &contribution, candidates, prior.as_ref()) + .await; + return Err(e); + }, + }; + // Flush wallet state staged on the splice's behalf (e.g. input locks) only now that the + // intent record is durable: whatever the wallet holds for a splice must never outlive the + // record through which a later startup would release it. + // TODO(#1037): nothing is staged yet, and #1037 persists its input locks at coin + // selection, ahead of the intent. Stage them instead, so that this flush is what makes + // them durable. + if let Err(e) = self.wallet.persist_staged().await { + log_error!( + self.logger, + "Failed to persist staged wallet state for splicing channel {} with counterparty \ + {}: {:?}", + channel_id, + counterparty_node_id, + e, + ); + // TODO(#1037): the intent is discarded before the release; a release that fails + // leaves locks no record names. Keep the intent instead when the release fails. + self.discard_persisted_intent(&payment_id, restore).await; + self.release_contribution(channel_id, &contribution, candidates, prior.as_ref()).await; + return Err(e); + } + if let Err(e) = self.channel_manager.funding_contributed( + &channel_id, + &counterparty_node_id, + contribution, + None, + ) { + log_error!( + self.logger, + "LDK rejected the splice contribution for channel {} with counterparty {}: {:?}", + channel_id, + counterparty_node_id, + e, + ); + // LDK returns 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 name 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 hold for a contribution that is going nowhere, short of + /// what another contribution claims as well — a splice candidate LDK holds for the channel, or + /// the round a fee bump was built from (`prior`): the remaining inputs are unlocked and a + /// transaction paying the remaining outputs is canceled, freeing the addresses of its change + /// and splice-out outputs ([`unclaimed_parts`]). A fee bump built by adjusting the fee of the + /// round it replaces reuses that round's inputs and change address; released along with the + /// bump, they would be free for other spends while the round can still confirm. + async fn release_contribution( + &self, channel_id: ChannelId, contribution: &FundingContribution, + candidates: &[SpliceCandidateDetails], prior: Option<&FundingContribution>, + ) { + let claimants = candidates.iter().filter_map(|c| c.contribution.as_ref()).chain(prior); + let (inputs, outputs) = unclaimed_parts(contribution, claimants); + if inputs.is_empty() && outputs.is_empty() { + return; + } + // TODO(#1037): `cancel_tx` unlocks the transaction's inputs itself once inputs are locked + // at coin selection, making this unlock redundant. + if let Err(e) = self.wallet.unlock_outpoints(&inputs).await { + log_error!( + self.logger, + "Failed to release the inputs of a splice contribution on channel {}: {}", + channel_id, + e, + ); + } + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs + .into_iter() + .map(|previous_output| TxIn { previous_output, ..TxIn::default() }) + .collect(), + output: outputs, + }; + if let Err(e) = self.wallet.cancel_tx(tx).await { + log_error!( + self.logger, + "Failed to release the outputs of a splice contribution on channel {}: {}", + channel_id, + e, + ); + } + } + + /// Persists `intent` before its contribution is handed to LDK, outliving a restart that — + /// until the negotiation reaches `AwaitingSignatures` — LDK's own state does not. + /// + /// Each splice gets a record of its own, so a channel may carry several: a splice queued + /// behind a pending one negotiates as a splice of its own once the pending one locks. Only a + /// fee bump joins an existing record, that of the round it replaces ([`place_intent`]), decided + /// from the channel's pending records and the splice rounds LDK holds for the channel + /// (`channel`, as the caller listed it). A record still anchored at another funding is one + /// [`Self::submit`] just failed to settle or to re-anchor; the new splice is refused rather + /// than recorded beside it. Returns the id and, for restoring on a rejected hand-off, `None` + /// when a fresh record was created or `Some(prior)` when an existing record's intent was + /// replaced (`prior` being `None` for a record that carried no intent). + async fn persist_intent( + &self, intent: SpliceIntent, channel: Option<&ChannelDetails>, + ) -> Result<(PaymentId, Option>), Error> { + let records = self + .pending_payment_store + .list_filter(|p| concerns_channel(p, intent.counterparty_node_id, intent.channel_id)) + .await; + let held_rounds: Vec = channel + .map(|channel| { + funding_candidates( + channel.splice_details.as_ref(), + intent.counterparty_node_id, + intent.channel_id, + ) + }) + .unwrap_or_default() + .into_iter() + .map(|candidate| candidate.txid) + .collect(); + match place_intent(&intent, &records, &held_rounds) { + IntentPlacement::Refused => { + log_error!( + self.logger, + "Refusing to splice channel {} with counterparty {}: the channel carries a \ + splice intent anchored at another funding", + intent.channel_id, + intent.counterparty_node_id, + ); + Err(Error::ChannelSplicingFailed) + }, + IntentPlacement::Reuse(payment_id) => { + let prior = records + .iter() + .find(|record| record.id() == payment_id) + .and_then(|record| record.splice_intent().cloned()); + self.pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(Some(intent)), + }) + .await?; + Ok((payment_id, Some(prior))) + }, + IntentPlacement::Fresh => { + let payment_id = random_payment_id(); + self.pending_payment_store + .insert(PendingPaymentDetails::pending_splice(payment_id, intent)) + .await?; + Ok((payment_id, None)) + }, + } + } + + /// Undoes a splice intent persisted for a hand-off that then failed before LDK took the + /// splice: restores an existing record's prior intent, or removes a freshly created record. + async fn discard_persisted_intent( + &self, payment_id: &PaymentId, restore: Option>, + ) { + let result = match restore { + Some(prior) => self + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: *payment_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(prior), + }) + .await + .map(|_| ()), + None => self.pending_payment_store.remove(payment_id).await, + }; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to undo the intent of rejected splice payment {}: a stale intent record \ + may be left behind: {}", + payment_id, + e, + ); + } + } + + /// Clears the persisted intent behind a splice that settled — it locked, its failure was + /// surfaced, or its channel closed — but only while `still_applies` holds for the stored + /// intent: a mismatch means a fee bump took over the record in the meantime, and its intent + /// must stay. A 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, which no entry would ever drive + /// ([`Wallet::drop_unindexed_record_of_settled_intent`]). The record goes first: a bare intent + /// left behind is found and settled again, an orphaned record would not be. + async fn clear_persisted_intent bool>( + &self, payment_id: PaymentId, still_applies: F, + ) { + 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) identifies nothing and settles nothing. The returned + /// [`FailureSettlement`] holds the submit lock until it is settled or dropped, so no new + /// splice can take the record in between: without it, a failure event could settle the intent + /// of an identical splice submitted while the event was being reported, or race `submit`'s + /// undo of a synchronously rejected hand-off. + /// + /// Settle only once the user-facing event is durably queued, and drop the settlement when + /// queueing fails: LDK then replays the failure event, and a cleared intent must mean the + /// failure was reported. + pub(crate) async fn on_negotiation_failed( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + contribution: Option<&FundingContribution>, + ) -> FailureSettlement<'_> { + let guard = self.submit_lock.lock().await; + let records = self.intent_records_for_channel(counterparty_node_id, channel_id).await; + let matched = record_of_failed_splice(&records, contribution); + FailureSettlement { tracker: self, _guard: guard, matched } + } + + /// Settles the persisted intents made obsolete by the channel's funding having moved on to + /// `funding_txo`, the funding a `ChannelReady` event reports as locked + /// ([`Self::settle_superseded_intents_locked`]). Takes the submit lock, so the settlement + /// cannot interleave with a splice being submitted. + pub(crate) async fn on_channel_ready( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + funding_txo: Option, + ) { + let Some(funding_txo) = funding_txo else { + return; + }; + let guard = self.submit_lock.lock().await; + let channel = self.channel(counterparty_node_id, channel_id); + self.settle_superseded_intents_locked( + &guard, + counterparty_node_id, + channel_id, + funding_txo, + channel.as_ref(), + ) + .await; + } + + /// Settles any persisted intent made obsolete by the channel's funding having moved on to + /// `funding_txo`: the funding a `ChannelReady` event reports as locked, the one a new splice + /// builds on ([`Self::submit`]), or the one [`Self::reconcile`] finds the channel at after a + /// funding moved while the node was down — the same situation, minus the event. Each of the + /// channel's intents is decided on its own + /// ([`decide_on_lock`]), against the splice candidates LDK holds for the channel (`channel`, + /// as the caller listed it): one whose pre-splice outpoint is that funding was created after + /// the lock and stays; one LDK still holds as a queued splice candidate is re-anchored to the + /// funding it now builds on rather than settled; any other is settled, and what the wallet + /// holds for it is either spent by the locked round or returned by LDK through + /// `DiscardFunding`. The caller holds the submit lock. + async fn settle_superseded_intents_locked( + &self, _guard: &tokio::sync::MutexGuard<'_, ()>, counterparty_node_id: PublicKey, + channel_id: ChannelId, funding_txo: OutPoint, channel: Option<&ChannelDetails>, + ) { + let records = self.intent_records_for_channel(counterparty_node_id, channel_id).await; + let candidates = channel + .and_then(|channel| channel.splice_details.as_ref()) + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]); + for record in records { + let payment_id = record.id(); + let Some(intent) = record.splice_intent().cloned() else { + continue; + }; + match decide_on_lock(&intent, funding_txo, candidates) { + LockDecision::Keep => {}, + LockDecision::Refresh => { + if let Some(new_funding_txo) = channel.and_then(|channel| channel.funding_txo) { + self.refresh_intent_funding(payment_id, &intent, new_funding_txo).await; + } + }, + LockDecision::Settle => { + // Nothing the wallet holds for the intent is released here. The inputs the + // locked round spent are gone with it, and whatever a superseded round reserved + // beyond them, LDK returns through the `DiscardFunding` events it queues at the + // promotion. A guard on the wallet's transaction graph could not tell the two + // apart: today the graph learns an interactive funding from sync alone. Once + // inputs are locked at coin selection (#1037), releasing them here would free + // the promoted round's inputs for a conflicting spend: #1037 prepares and + // unlocks only `Funding`-typed broadcasts, and a splice round is broadcast as + // `InteractiveFunding`. + self.clear_persisted_intent(payment_id, |i| *i == intent).await; + }, + } + } + } + + /// Re-anchors a still-live intent to the funding outpoint it now builds on, but only while + /// the record still carries the intent this decision was made for. + async fn refresh_intent_funding( + &self, payment_id: PaymentId, intent: &SpliceIntent, new_funding_txo: LdkOutPoint, + ) { + let refreshed = SpliceIntent { pre_splice_funding_txo: new_funding_txo, ..intent.clone() }; + let result = self + .pending_payment_store + .mutate(&payment_id, |existing| { + let mut record = existing?.clone(); + if record.splice_intent() != Some(intent) { + return None; + } + let update = PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(Some(refreshed)), + }; + record.update(update).then_some(record) + }) + .await; + if let Err(e) = result { + log_error!( + self.logger, + "Failed to re-anchor the intent of queued splice payment {}: {}", + payment_id, + e, + ); + } + } + + /// Records the funding payment of a splice round this node has just signed but not yet handed + /// back to LDK, through [`Wallet::record_signed_funding`], so the record precedes any + /// broadcast: the counterparty cannot broadcast before receiving our `tx_signatures`, which + /// only [`ChannelManager::funding_transaction_signed`] releases. Holding the submit lock keeps + /// the channel's intent records — one of which the funding record adopts — from changing + /// mid-write: a concurrent [`Self::submit`] adding or replacing an intent, or a lock or + /// failure event settling one. 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. + /// Nothing the wallet holds for the intents is released here: a round the channel's monitor + /// watches may still confirm, and what LDK reserved for the others it returns through + /// `DiscardFunding` once the close matures. A signed round the monitor never watched — the + /// counterparty's `commitment_signed` never arrived — is released by the `DiscardFunding` LDK + /// reports for it at the force-close, which arrives after `ChannelClosed` and names what no + /// other round of the channel uses. + pub(crate) async fn on_channel_closed( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) { + 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, +} + +/// Decides where the intent of a new submission is recorded, given the channel's pending records +/// (`records`: those carrying an intent for the channel or tracking a funding payment of it) and +/// the txids of the splice rounds LDK holds for the channel (`held_rounds`, the pending rounds +/// with a transaction; not the funding). +/// +/// Every splice gets a record of its own, so that its failure is described from its own intent +/// and a restart recognizes it whatever became of the channel's other splices. A splice-in or +/// splice-out therefore always starts fresh: while a round of ours is pending and bumpable the +/// entry points refuse a new one, and a contribution LDK takes beside a pending splice — queued +/// behind it, or joining a round the counterparty is negotiating — is a splice of its own. Only +/// a fee bump joins an existing record, that of the round it replaces: the record tracking a +/// round LDK still holds — intent-less when an earlier bump failed and its settlement cleared +/// the intent — or else the channel's bare intent record, whose round negotiated but recorded +/// nothing (a splice-out to an external address). A bump joining a bare record shares its fate: +/// the bump's failure removes the record, so a later bump starts fresh. +fn place_intent( + intent: &SpliceIntent, records: &[PendingPaymentDetails], held_rounds: &[Txid], +) -> IntentPlacement { + let anchored_elsewhere = records.iter().any(|record| { + record + .splice_intent() + .is_some_and(|i| i.pre_splice_funding_txo != intent.pre_splice_funding_txo) + }); + if anchored_elsewhere { + return IntentPlacement::Refused; + } + match intent.kind { + SpliceKind::Rbf {} => { + let tracks_held_round = |record: &&PendingPaymentDetails| { + record.candidates().iter().any(|candidate| held_rounds.contains(&candidate.txid)) + }; + records + .iter() + .find(tracks_held_round) + .or_else(|| records.iter().find(|record| record.splice_intent().is_some())) + .map_or(IntentPlacement::Fresh, |record| IntentPlacement::Reuse(record.id())) + }, + SpliceKind::In { .. } | SpliceKind::Out { .. } => IntentPlacement::Fresh, + } +} + +/// The record, and its intent, of the splice a failure event identifies by `contribution` among +/// the channel's intent records: the one whose intent's contribution is the same attempt +/// ([`is_same_splice`]). A failure that reports no contribution identifies nothing, as does one +/// whose contribution matches no recorded intent — an attempt superseded by a fee bump, whose +/// failure LDK reports separately. +fn record_of_failed_splice( + records: &[PendingPaymentDetails], contribution: Option<&FundingContribution>, +) -> Option<(PaymentId, SpliceIntent)> { + let contribution = contribution?; + records.iter().find_map(|record| { + let intent = record.splice_intent()?; + is_same_splice(&intent.contribution, contribution).then(|| (record.id(), intent.clone())) + }) +} + +/// What a lock of the funding a channel has moved on to — or a new splice building on it — +/// means for one of the channel's recorded intents, decided by [`decide_on_lock`]. +#[derive(Debug, PartialEq, Eq)] +enum LockDecision { + /// The intent is anchored at that funding: its splice was submitted after the lock. + Keep, + /// LDK still holds the intent's contribution as a splice candidate — a splice queued behind + /// the one that locked, carried across the lock — so the intent is re-anchored to the new + /// funding. + Refresh, + /// The lock superseded the intent's splice: the splice locked, a replacement or a + /// counterparty splice locked instead, or the queued splice was failed at the lock. The + /// intent is settled. + /// + /// A queued splice fails at the lock when its contribution overlaps the promoted transaction. + /// LDK takes it out of the queue and reports the failure after the `ChannelReady` of the + /// lock, so the intent is settled here first and the failure surfaces without the splice's + /// parameters. The overlap check at queue time — against this node's own contributions to the + /// pending rounds — lets only a contribution naming an input or output the counterparty + /// contributed to the promoted round get this far, which this node's wallet does not produce. + Settle, +} + +/// Decides what the channel's funding having moved on to `funding_txo` means for `intent`, given +/// the splice candidates LDK holds for the channel. +fn decide_on_lock( + intent: &SpliceIntent, funding_txo: OutPoint, candidates: &[SpliceCandidateDetails], +) -> LockDecision { + if intent.pre_splice_funding_txo.into_bitcoin_outpoint() == funding_txo { + return LockDecision::Keep; + } + let still_held = candidates.iter().any(|candidate| { + candidate.contribution.as_ref().is_some_and(|c| is_same_splice(c, &intent.contribution)) + }); + if still_held { + LockDecision::Refresh + } else { + LockDecision::Settle + } +} + +/// The replacement for a pending record whose splice intent is being dropped. A record that still +/// tracks something else — a payment, the rounds signed under the splice, or a round LDK promoted +/// — keeps it with just the intent cleared. A record left with nothing to track has nothing to +/// keep and is left for the caller to remove (see [`SpliceTracker::clear_persisted_intent`]). +fn record_with_intent_cleared(existing: &PendingPaymentDetails) -> Option { + let mut record = existing.clone(); + let update = PendingPaymentDetailsUpdate { + id: record.id(), + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + record.update(update); + (!record.is_empty()).then_some(record) +} + +/// What [`SpliceTracker::reconcile`] should do with a persisted intent whose channel and funding +/// are unchanged, decided from the splice rounds LDK reports on the channel. +#[derive(Debug, PartialEq, Eq)] +enum ReconcileDecision { + /// LDK still holds a splice of ours; leave the intent in place until the splice settles. + Keep, + /// LDK holds no splice of ours: the recorded splice died with the restart, so whatever was + /// reserved for it is released and the intent dropped. + Lost, +} + +/// Decides the startup action for a persisted intent from the channel's [`SpliceDetails`] +/// candidates. +/// +/// [`SpliceDetails`]: lightning::ln::channel_state::SpliceDetails +fn decide_reconcile(candidates: &[SpliceCandidateDetails]) -> ReconcileDecision { + // A round short of `Negotiated` is one LDK still drives on its own: only `AwaitingSignatures` + // survives a restart, and LDK resumes the signature exchange itself on reconnect. + let in_flight = candidates + .iter() + .any(|candidate| !matches!(candidate.status, SpliceCandidateStatus::Negotiated { .. })); + if in_flight { + return ReconcileDecision::Keep; + } + + // LDK persists a splice once negotiated, so a negotiated candidate carrying a local + // contribution is a splice of ours LDK sees through to lock — even one negotiated at a + // different feerate than a recorded fee bump asked for. Without one, only counterparty + // rounds (or nothing) survived: the recorded splice is gone. + if candidates.iter().any(|candidate| candidate.contribution.is_some()) { + ReconcileDecision::Keep + } else { + ReconcileDecision::Lost + } +} + +/// The inputs `contribution` reserved that no candidate's own contribution still claims — extras +/// a splice attempt lost with the restart had reserved. A counterparty-only round carries no +/// contribution and claims nothing. +fn unclaimed_inputs( + contribution: &FundingContribution, candidates: &[SpliceCandidateDetails], +) -> Vec { + let 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::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 lightning::ln::channel_state::SpliceCandidateStatus; + + fn test_intent() -> SpliceIntent { + SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([7u8; 32]), + pre_splice_funding_txo: LdkOutPoint { + txid: Txid::from_byte_array([3u8; 32]), + index: 0, + }, + contribution: test_funding_contribution(), + kind: SpliceKind::Rbf {}, + } + } + + fn payment_details(id: PaymentId, status: PaymentStatus) -> PaymentDetails { + PaymentDetails::new( + id, + PaymentKind::Onchain { + txid: Txid::from_byte_array([1u8; 32]), + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(1_000_000), + Some(500), + PaymentDirection::Outbound, + status, + ) + } + + /// A bare intent entry has nothing to keep once its intent is cleared: it is removed rather + /// than promoted, whatever the payment store holds under its id — a payment record there is + /// the first half of a signing write that never completed, which the caller removes as well. + #[test] + fn intent_clearing_removes_a_bare_intent_entry() { + let id = PaymentId([9u8; 32]); + let existing = PendingPaymentDetails::pending_splice(id, test_intent()); + assert!(record_with_intent_cleared(&existing).is_none()); + } + + /// A tracked record keeps its payment details; only the intent is cleared. + #[test] + fn intent_clearing_keeps_a_tracked_record() { + let id = PaymentId([9u8; 32]); + let details = payment_details(id, PaymentStatus::Pending); + let existing = PendingPaymentDetails::tracked( + details.clone(), + Vec::new(), + Vec::new(), + Some(test_intent()), + ); + + let replacement = record_with_intent_cleared(&existing); + let replacement = replacement.expect("the entry must survive with its intent cleared"); + assert_eq!(replacement.details(), Some(&details)); + assert!(replacement.splice_intent().is_none()); + } + + #[test] + fn contributions_match_by_inputs_and_outputs() { + use bitcoin::{ScriptBuf, TxOut}; + + let outputs = + vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: ScriptBuf::new() }]; + // Fee fields differ, inputs and outputs agree: the same attempt. LDK may adjust a + // contribution during negotiation — the quiescence tie-breaker rebuilds the acceptor's + // copy at a fresh feerate — and events then carry the adjusted copy, which must still + // identify the recorded splice. + let a = test_funding_contribution_with_outputs(0, 253, &outputs); + let b = test_funding_contribution_with_outputs(0, 500, &outputs); + assert!(is_same_splice(&a, &b)); + + // Different outputs are a different attempt. + let other = vec![TxOut { value: Amount::from_sat(2_000), script_pubkey: ScriptBuf::new() }]; + assert!(!is_same_splice(&a, &test_funding_contribution_with_outputs(0, 253, &other))); + + // Contributions moving nothing (no inputs, no outputs) only match themselves exactly. + assert!(is_same_splice(&test_funding_contribution(), &test_funding_contribution())); + assert!(!is_same_splice( + &test_funding_contribution(), + &test_funding_contribution_with_feerate(500) + )); + } + + fn intent_with( + kind: SpliceKind, funding_byte: u8, contribution: FundingContribution, + ) -> SpliceIntent { + SpliceIntent { + pre_splice_funding_txo: LdkOutPoint { + txid: Txid::from_byte_array([funding_byte; 32]), + index: 0, + }, + contribution, + kind, + ..test_intent() + } + } + + fn splice_out_contribution(value_sat: u64) -> FundingContribution { + use bitcoin::{ScriptBuf, TxOut}; + let outputs = + vec![TxOut { value: Amount::from_sat(value_sat), script_pubkey: ScriptBuf::new() }]; + test_funding_contribution_with_outputs(300, 253, &outputs) + } + + /// A tracked record of the test channel whose funding payment names `txid` and whose history + /// lists `candidates`, carrying `intent` if any. + fn tracked_record( + id: PaymentId, txid: Txid, candidates: &[Txid], intent: Option, + ) -> PendingPaymentDetails { + use crate::payment::store::Channel; + let base = test_intent(); + let details = PaymentDetails::new( + id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { + channels: vec![Channel { + counterparty_node_id: base.counterparty_node_id, + channel_id: base.channel_id, + }], + }), + }, + Some(1_000_000), + Some(500), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + let candidates = candidates + .iter() + .map(|txid| FundingTxCandidate { + txid: *txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }) + .collect(); + PendingPaymentDetails::tracked(details, Vec::new(), candidates, intent) + } + + fn txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + /// A splice-in or splice-out is a splice of its own, whatever the channel already carries: + /// a pending splice's bare intent, or the tracked record of its rounds. + #[test] + fn a_splice_in_or_out_gets_a_record_of_its_own() { + let pending = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(1_000)); + let records = vec![ + PendingPaymentDetails::pending_splice(PaymentId([1u8; 32]), pending.clone()), + tracked_record(PaymentId([2u8; 32]), txid(0x10), &[txid(0x10)], Some(pending)), + ]; + let held = [txid(0x10)]; + + let splice_in = + intent_with(SpliceKind::In { amount_sats: 5_000 }, 3, test_funding_contribution()); + assert_eq!(place_intent(&splice_in, &records, &held), IntentPlacement::Fresh); + let splice_out = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(2_000)); + assert_eq!(place_intent(&splice_out, &records, &held), IntentPlacement::Fresh); + assert_eq!(place_intent(&splice_out, &[], &[]), IntentPlacement::Fresh); + } + + /// A fee bump joins the record of the round it replaces: the one tracking a round LDK still + /// holds, even when that record carries no intent any more and the channel also carries a + /// bare intent. + #[test] + fn a_bump_joins_the_record_tracking_a_held_round() { + let bump = intent_with(SpliceKind::Rbf {}, 3, test_funding_contribution()); + let bare_id = PaymentId([1u8; 32]); + let tracked_id = PaymentId([2u8; 32]); + let records = vec![ + PendingPaymentDetails::pending_splice( + bare_id, + intent_with( + SpliceKind::Out { outputs: Vec::new() }, + 3, + splice_out_contribution(1_000), + ), + ), + tracked_record(tracked_id, txid(0x11), &[txid(0x10), txid(0x11)], None), + ]; + assert_eq!( + place_intent(&bump, &records, &[txid(0x11)]), + IntentPlacement::Reuse(tracked_id) + ); + + // The tracked record of a splice that already locked — its funding is no held round — is + // not the bump's; the bare intent of the round LDK negotiated but the wallet did not record + // is. + assert_eq!(place_intent(&bump, &records, &[]), IntentPlacement::Reuse(bare_id)); + + // With neither, the bump starts fresh. + let locked_only = vec![tracked_record(tracked_id, txid(0x11), &[txid(0x11)], None)]; + assert_eq!(place_intent(&bump, &locked_only, &[]), IntentPlacement::Fresh); + } + + /// An intent anchored at another funding is one the lock handling failed to settle or to + /// re-anchor; nothing is recorded beside it, whatever the new splice's kind and whatever + /// else the channel carries. + #[test] + fn an_intent_anchored_elsewhere_refuses_every_kind() { + let stale = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 4, splice_out_contribution(1_000)); + let current = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(2_000)); + let records = vec![ + PendingPaymentDetails::pending_splice(PaymentId([1u8; 32]), current), + PendingPaymentDetails::pending_splice(PaymentId([2u8; 32]), stale), + ]; + for kind in [ + SpliceKind::In { amount_sats: 5_000 }, + SpliceKind::Out { outputs: Vec::new() }, + SpliceKind::Rbf {}, + ] { + let intent = intent_with(kind, 3, test_funding_contribution()); + assert_eq!(place_intent(&intent, &records, &[]), IntentPlacement::Refused); + } + } + + /// A failure identifies the record whose intent carries the failed contribution — fee fields + /// aside — among the channel's; one reporting no contribution, or a contribution of no + /// recorded intent, identifies nothing. + #[test] + fn a_failure_identifies_the_record_carrying_its_contribution() { + let first = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(1_000)); + let second = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(2_000)); + let (first_id, second_id) = (PaymentId([1u8; 32]), PaymentId([2u8; 32])); + let records = vec![ + PendingPaymentDetails::pending_splice(first_id, first.clone()), + tracked_record(second_id, txid(0x10), &[txid(0x10)], Some(second.clone())), + ]; + + let adjusted = { + use bitcoin::{ScriptBuf, TxOut}; + let outputs = + vec![TxOut { value: Amount::from_sat(2_000), script_pubkey: ScriptBuf::new() }]; + test_funding_contribution_with_outputs(900, 1_000, &outputs) + }; + assert_eq!(record_of_failed_splice(&records, Some(&adjusted)), Some((second_id, second))); + assert_eq!( + record_of_failed_splice(&records, Some(&first.contribution)), + Some((first_id, first)) + ); + assert_eq!(record_of_failed_splice(&records, Some(&splice_out_contribution(3_000))), None); + assert_eq!(record_of_failed_splice(&records, None), None); + } + + /// A lock keeps an intent anchored at the locked funding, re-anchors one LDK still holds as a + /// candidate, and settles any other. + #[test] + fn a_lock_keeps_refreshes_or_settles_an_intent() { + let intent = + intent_with(SpliceKind::Out { outputs: Vec::new() }, 3, splice_out_contribution(1_000)); + let same_funding = intent.pre_splice_funding_txo.into_bitcoin_outpoint(); + let new_funding = OutPoint { txid: txid(0x20), vout: 0 }; + let held = [SpliceCandidateDetails { + contribution: Some(splice_out_contribution(1_000)), + status: SpliceCandidateStatus::WaitingOnLock, + }]; + let other = [ + SpliceCandidateDetails { + contribution: Some(splice_out_contribution(2_000)), + status: SpliceCandidateStatus::WaitingOnLock, + }, + SpliceCandidateDetails { + contribution: None, + status: SpliceCandidateStatus::WaitingOnLock, + }, + ]; + + assert_eq!(decide_on_lock(&intent, same_funding, &other), LockDecision::Keep); + assert_eq!(decide_on_lock(&intent, new_funding, &held), LockDecision::Refresh); + assert_eq!(decide_on_lock(&intent, new_funding, &other), LockDecision::Settle); + assert_eq!(decide_on_lock(&intent, new_funding, &[]), LockDecision::Settle); + } + + /// A submission proceeds at the funding it was built against while that is still the + /// channel's. Once the funding moved, only a splice-out proceeds, anchored at the live + /// funding; a bump and a splice-in are refused, as is any submission for a channel LDK no + /// longer lists with a funding. + #[test] + fn a_submission_is_checked_against_the_live_funding() { + let requested = LdkOutPoint { txid: txid(0x30), index: 0 }; + let moved = LdkOutPoint { txid: txid(0x31), index: 0 }; + let kinds = [ + SpliceKind::In { amount_sats: 5_000 }, + SpliceKind::Out { outputs: Vec::new() }, + SpliceKind::Rbf {}, + ]; + for kind in &kinds { + assert_eq!(check_submission(requested, Some(requested), kind), Ok(requested)); + assert_eq!( + check_submission(requested, None, kind), + Err(SubmissionRefusal::ChannelGone) + ); + } + assert_eq!( + check_submission(requested, Some(moved), &SpliceKind::Rbf {}), + Err(SubmissionRefusal::BumpedRoundLocked) + ); + assert_eq!( + check_submission(requested, Some(moved), &SpliceKind::In { amount_sats: 5_000 }), + Err(SubmissionRefusal::InputsMayBeSpent) + ); + assert_eq!( + check_submission(requested, Some(moved), &SpliceKind::Out { outputs: Vec::new() }), + Ok(moved) + ); + } + + /// The records concerning a channel's splices are those carrying an intent for it and those + /// tracking an interactive funding of it; records of other channels and of other payments are + /// not. + #[test] + fn records_concerning_a_channel() { + let base = test_intent(); + let (cp, channel_id) = (base.counterparty_node_id, base.channel_id); + let id = PaymentId([1u8; 32]); + assert!(concerns_channel( + &PendingPaymentDetails::pending_splice(id, base.clone()), + cp, + channel_id + )); + assert!(concerns_channel( + &tracked_record(id, txid(0x10), &[txid(0x10)], None), + cp, + channel_id + )); + + let other_channel = SpliceIntent { channel_id: ChannelId([8u8; 32]), ..base }; + assert!(!concerns_channel( + &PendingPaymentDetails::pending_splice(id, other_channel), + cp, + channel_id + )); + assert!(!concerns_channel( + &tracked_record(id, txid(0x10), &[], None), + cp, + ChannelId([8u8; 32]) + )); + let plain = PendingPaymentDetails::new( + payment_details(id, PaymentStatus::Pending), + Vec::new(), + Vec::new(), + ); + assert!(!concerns_channel(&plain, cp, channel_id)); + } + + fn negotiated_candidate(contribution: Option) -> SpliceCandidateDetails { + SpliceCandidateDetails { + contribution, + status: SpliceCandidateStatus::Negotiated { + txid: Txid::from_byte_array([9u8; 32]), + new_channel_value_satoshis: 100_000, + }, + } + } + + /// A previous transaction with a P2WPKH output at index 0 for a contribution input to spend; + /// `seed` varies the output script, and with it the txid. + fn test_prevtx(seed: u8) -> Transaction { + use bitcoin::WPubkeyHash; + + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + } + } + + /// Releasing a contribution spares the parts another contribution uses as well: a fee bump + /// built by adjusting the fee of the round it replaces shares that round's inputs and change + /// address — the change differing in amount only — so against that round nothing is released; + /// against a candidate using only some of the parts, the rest is, a counterparty-only round + /// alongside claiming nothing; against no other contribution, everything is. + #[test] + fn unclaimed_parts_spare_what_other_contributions_use() { + use bitcoin::WPubkeyHash; + + let prevtxs: Vec = (1u8..=3).map(test_prevtx).collect(); + let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 }; + let script = |seed: u8| ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])); + let change = |sats: u64| TxOut { value: Amount::from_sat(sats), script_pubkey: script(9) }; + let splice_out = TxOut { value: Amount::from_sat(50_000), script_pubkey: script(8) }; + let bump = test_funding_contribution_with_parts( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change(20_000)), + ); + + let prior = test_funding_contribution_with_parts( + 0, + 253, + &prevtxs, + &[splice_out.clone()], + Some(&change(21_000)), + ); + assert_eq!(unclaimed_parts(&bump, [&prior]), (Vec::new(), Vec::new())); + + let partial = + test_funding_contribution_with_parts(0, 253, &prevtxs[..2], &[], Some(&change(21_000))); + let candidates = [negotiated_candidate(None), negotiated_candidate(Some(partial))]; + let claimants = candidates.iter().filter_map(|candidate| candidate.contribution.as_ref()); + assert_eq!( + unclaimed_parts(&bump, claimants), + (vec![outpoint(&prevtxs[2])], vec![splice_out.clone()]) + ); + + assert_eq!( + unclaimed_parts(&bump, []), + (prevtxs.iter().map(outpoint).collect(), vec![splice_out, change(20_000)]) + ); + } + + /// While any round is short of `Negotiated`, LDK drives the splice itself; the intent stays + /// in place until the splice settles. + #[test] + fn reconcile_keeps_the_intent_while_ldk_drives_a_round() { + let in_flight = SpliceCandidateDetails { + contribution: Some(test_funding_contribution()), + status: SpliceCandidateStatus::AwaitingSignatures { + is_initiator: true, + funding_feerate_sat_per_1000_weight: 253, + new_channel_value_satoshis: 100_000, + txid: Txid::from_byte_array([9u8; 32]), + }, + }; + assert_eq!(decide_reconcile(&[in_flight]), ReconcileDecision::Keep); + } + + /// A negotiated candidate carrying a local contribution is a splice LDK sees through to lock; + /// nothing was lost. This holds on zero-conf channels too, where the pre-splice funding + /// outpoint has not moved on yet. + #[test] + fn reconcile_trusts_a_negotiated_contribution() { + let negotiated = [negotiated_candidate(Some(test_funding_contribution()))]; + assert_eq!(decide_reconcile(&negotiated), ReconcileDecision::Keep); + } + + /// A fee bump that only survives as a candidate negotiated at a lower feerate than requested + /// is not lost: the recorded bump is moot, but the splice lives on and locks. The old + /// higher-feerate attempt's extra reservations are released through the input difference, not + /// by dropping the record. + #[test] + fn reconcile_keeps_a_bump_negotiated_at_a_lower_feerate() { + let lower = [negotiated_candidate(Some(test_funding_contribution_with_feerate(253)))]; + assert_eq!(decide_reconcile(&lower), ReconcileDecision::Keep); + } + + /// With no contribution of ours in LDK — no splice at all, or only a counterparty round — the + /// recorded splice died with the restart. + #[test] + fn reconcile_finds_the_splice_lost_when_ldk_holds_no_contribution() { + assert_eq!(decide_reconcile(&[]), ReconcileDecision::Lost); + let counterparty_only = [negotiated_candidate(None)]; + assert_eq!(decide_reconcile(&counterparty_only), ReconcileDecision::Lost); + } + + /// The inputs a kept record reserves beyond what LDK's candidates still claim are identified + /// for release; a counterparty-only round claims nothing and must not suppress the + /// difference. + #[test] + fn unclaimed_inputs_are_those_no_candidate_contribution_uses() { + let prevtxs: Vec = (1u8..=3).map(test_prevtx).collect(); + let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 }; + let recorded = test_funding_contribution_with_inputs(253, &prevtxs); + + // Every input still claimed by a surviving candidate: nothing to release. + let all = + [negotiated_candidate(Some(test_funding_contribution_with_inputs(253, &prevtxs)))]; + assert!(unclaimed_inputs(&recorded, &all).is_empty()); + + // A candidate claiming two of the three inputs: the third is released, even with a + // counterparty-only round alongside. + let partial = [ + negotiated_candidate(None), + negotiated_candidate(Some(test_funding_contribution_with_inputs(253, &prevtxs[..2]))), + ]; + assert_eq!(unclaimed_inputs(&recorded, &partial), vec![outpoint(&prevtxs[2])]); + + // No candidates at all: everything is released. + assert_eq!( + unclaimed_inputs(&recorded, &[]), + prevtxs.iter().map(outpoint).collect::>() + ); + } +} diff --git a/src/config.rs b/src/config.rs index cb74b55c80..ef1e127f70 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,6 +65,61 @@ pub(crate) const PAYMENT_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000). // may displace those entries. pub(crate) const PAYMENT_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap(); +// The number of channel transaction provenance records we keep in memory. +// +// A record is written when a channel produces a transaction and read back when the wallet meets +// that transaction, so the working set is a node's recent channel activity rather than its whole +// history. Records are small — a handful of outpoints, each with a role and a channel reference +// — so this bounds the store's share of memory well below the payment store's while still +// covering the channels a node is busy with. +pub(crate) const CHANNEL_TX_FACTS_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).unwrap(); + +// The number of channel transaction provenance records we read into the cache when starting up. +// +// This matches the built-in storage backends' page size, so warming the cache costs a single page +// listing and one batch of reads. Later activity may displace those entries, which are then read +// back individually as they are needed. +pub(crate) const CHANNEL_TX_FACTS_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap(); + +// The number of blocks a channel transaction provenance record outlives the last thing the node +// learned about its transaction. +// +// Roughly a year at ten minutes a block. It is an absolute backstop rather than the usual reason +// a record goes: a record is dropped only once the channels it names are gone from the node's +// channel manager, chain monitor and output sweeper, and the funding it records has been spent +// and settled. Those checks are blind to a transaction of a channel that never reached them, so +// without the cap such a record would be kept forever. +pub(crate) const CHANNEL_TX_FACTS_RETENTION_BLOCKS: u32 = 52_560; + +// The number of bytes one channel transaction provenance record may take up. +// +// A record is written whole and holds one entry per channel-controlled output of its transaction, +// so a counterparty loading a commitment transaction with HTLCs grows a record this node is +// obliged to keep. The limit is comfortably above a commitment transaction carrying the most +// HTLCs LDK allows, and bounds what any single transaction can cost. +pub(crate) const CHANNEL_TX_FACTS_MAX_RECORD_BYTES: usize = 128 * 1024; + +// The number of channel transaction provenance records the node keeps. +// +// Records are dropped only once the channels they belong to have resolved, so between prunes a +// counterparty opening and closing channels, or replacing a negotiated funding again and again, +// drives the store's growth. Past this many records nothing new is admitted and the transactions +// they would have described go unclassified, which is bounded loss of detail rather than +// unbounded storage. An interactive funding round this node is about to sign is admitted beside +// them: how many of those there are is this node's own decision, and refusing one would cost a +// figure nothing later corrects rather than a detail. +pub(crate) const CHANNEL_TX_FACTS_MAX_RECORDS: usize = 100_000; + +// The number of pages of channel transaction provenance records one chain tip change examines. +// +// Pruning shares the pass that graduates payments, so it has to leave promptly; it resumes where +// it left off on the next tip and so walks the whole store over consecutive blocks. At the +// built-in backends' page size this is a couple of hundred records a block: a store whose records +// fit the cache is walked in a single tip and costs the backend nothing beyond listing its keys, +// while one at the limit above takes a few hundred blocks — which is also how stale the record +// count that walk maintains can get. +pub(crate) const CHANNEL_TX_FACTS_PRUNE_PAGES_PER_TIP: usize = 4; + // The default {Esplora,Electrum} client timeout we're using. const DEFAULT_PER_REQUEST_TIMEOUT_SECS: u8 = 10; diff --git a/src/data_store.rs b/src/data_store.rs index bdd190621d..747e0b7ed6 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -389,6 +389,44 @@ where Ok(()) } + /// Removes the object stored under `id` only while `predicate` holds for it. The read, the + /// predicate, and the removal share one critical section of the mutation lock, so a + /// concurrent write cannot land in between and be deleted by mistake — unlike a separate + /// [`Self::get`] followed by [`Self::remove`]. Returns whether the object was removed. + pub(crate) async fn remove_if bool>( + &self, id: &SO::Id, predicate: F, + ) -> Result { + let _guard = self.mutation_lock.write().await; + + match self.lookup(id).await? { + Some(object) if predicate(&object) => {}, + _ => return Ok(false), + } + + let store_key = id.encode_to_hex_str(); + KVStore::remove( + &*self.kv_store, + &self.primary_namespace, + &self.secondary_namespace, + &store_key, + false, + ) + .await + .map_err(|e| { + log_error!( + self.logger, + "Removing object data for key {}/{}/{} failed due to: {}", + &self.primary_namespace, + &self.secondary_namespace, + store_key, + e + ); + Error::PersistenceFailed + })?; + self.cache.lock().expect("lock").remove(id); + Ok(true) + } + /// Returns the object stored under `id`, if any. pub(crate) async fn get(&self, id: &SO::Id) -> Result, Error> { let _guard = self.mutation_lock.read().await; @@ -1163,6 +1201,36 @@ mod tests { .is_ok()); } + #[tokio::test] + async fn remove_if_only_removes_while_the_predicate_holds() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let id = TestObjectId { id: [42u8; 4] }; + let existing_object = TestObject::new(id, [23u8; 3]); + let data_store: DataStore> = DataStore::new( + vec![existing_object], + KeepAllEntries, + TEST_PRIMARY_NAMESPACE.to_string(), + TEST_SECONDARY_NAMESPACE.to_string(), + store, + logger, + ); + + // A failed predicate — the entry no longer looks like what the caller decided to delete — + // must leave the entry in place. + let result = data_store.remove_if(&id, |object| object.data != existing_object.data).await; + assert_eq!(Ok(false), result); + assert_eq!(Some(existing_object), data_store.get(&id).await.unwrap()); + + let result = data_store.remove_if(&id, |object| object.data == existing_object.data).await; + assert_eq!(Ok(true), result); + assert!(data_store.get(&id).await.unwrap().is_none()); + + // An absent entry is not an error; there is just nothing to remove. + let result = data_store.remove_if(&id, |_| true).await; + assert_eq!(Ok(false), result); + } + #[tokio::test] async fn mutate_transforms_existing_entry() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/event.rs b/src/event.rs index e700873cdc..32b78a7cef 100644 --- a/src/event.rs +++ b/src/event.rs @@ -13,13 +13,15 @@ use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, OutPoint}; +use bitcoin::{Amount, OutPoint, ScriptBuf, Txid}; use lightning::blinded_path::message::NextMessageHop; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::events::bump_transaction::BumpTransactionEvent; #[cfg(not(feature = "uniffi"))] use lightning::events::PaidBolt12Invoice; use lightning::events::{ ClosureReason, Event as LdkEvent, FundingInfo, InboundHTLCLocator as LdkInboundHtlcLocator, + NegotiationFailureReason as LdkNegotiationFailureReason, OutboundHTLCLocator as LdkOutboundHtlcLocator, PaymentFailureReason, PaymentPurpose, ReplayEvent, }; @@ -31,10 +33,15 @@ use lightning::util::config::{ChannelConfigOverrides, ChannelConfigUpdate}; use lightning::util::errors::APIError; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, ReadableArgs, Writeable, Writer}; -use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use lightning::{ + impl_writeable_tlv_based, impl_writeable_tlv_based_enum, + impl_writeable_tlv_based_enum_upgradable, +}; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_types::payment::{PaymentHash, PaymentPreimage}; +use lightning_types::string::UntrustedString; +use crate::channel::SpliceTracker; use crate::config::{may_announce_channel, Config, PEER_RECONNECTION_INTERVAL}; use crate::connection::ConnectionManager; use crate::data_store::{DataStoreUpdateResult, UpdatableObject}; @@ -50,14 +57,22 @@ use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore; use crate::payment::forwarding_store::{ForwardRecord, ForwardingStore}; +use crate::payment::pending_payment_store::SpliceKind; use crate::payment::store::{ - PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, + Channel, PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, + TransactionType, }; use crate::payment::PaymentMetadata; use crate::probing::Prober; use crate::runtime::Runtime; use crate::types::{ - CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, + ChainMonitor, CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, + Wallet, +}; +use crate::wallet::provenance::{ChannelOutputRole, ChannelTxFacts, FactsRecordOutcome}; +use crate::wallet::{ + closed_channel_held_rounds, funding_candidates, held_splice_rounds, FundingCandidate, + SignedFundingRecord, }; use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, @@ -115,6 +130,155 @@ impl From for HTLCLocator { } } +/// The reason a channel splice failed. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum SpliceFailureReason { + /// The reason was not available. + Unknown, + /// The peer disconnected during negotiation. The splice may be re-initiated once the peer + /// reconnects. + PeerDisconnected, + /// The counterparty explicitly aborted the negotiation. Re-initiating with the same + /// parameters is unlikely to succeed — consider adjusting them or waiting for the + /// counterparty to initiate. + CounterpartyAborted { + /// The counterparty's abort message. + /// + /// This is counterparty-provided data. Use `Display` on [`UntrustedString`] for safe + /// logging. + msg: UntrustedString, + }, + /// An error occurred during interactive transaction negotiation (e.g., the counterparty sent + /// an invalid message). The negotiation was aborted. + NegotiationError { + /// A developer-readable error message. + msg: String, + }, + /// The funding contribution was invalid (e.g., insufficient balance for the splice amount). + /// The splice may be re-initiated with adjusted parameters. + ContributionInvalid, + /// The negotiation was locally canceled. + LocallyCanceled, + /// The channel is closing, so the negotiation cannot continue. See [`Event::ChannelClosed`] + /// for the closure reason. + ChannelClosing, + /// The contribution's feerate was too low to replace the splice's in-flight funding + /// transaction. The fee bump may be re-initiated once feerates allow it. + FeeRateTooLow, + /// A fee bump could not be initiated (e.g., a prior splice funding transaction already + /// confirmed). The channel remains operational. + CannotInitiateRbf, +} + +impl From for SpliceFailureReason { + fn from(reason: LdkNegotiationFailureReason) -> Self { + match reason { + LdkNegotiationFailureReason::Unknown => Self::Unknown, + LdkNegotiationFailureReason::PeerDisconnected => Self::PeerDisconnected, + LdkNegotiationFailureReason::CounterpartyAborted { msg } => { + Self::CounterpartyAborted { msg } + }, + LdkNegotiationFailureReason::NegotiationError { msg } => Self::NegotiationError { msg }, + LdkNegotiationFailureReason::ContributionInvalid => Self::ContributionInvalid, + LdkNegotiationFailureReason::LocallyCanceled => Self::LocallyCanceled, + LdkNegotiationFailureReason::ChannelClosing => Self::ChannelClosing, + LdkNegotiationFailureReason::FeeRateTooLow => Self::FeeRateTooLow, + LdkNegotiationFailureReason::CannotInitiateRbf => Self::CannotInitiateRbf, + } + } +} + +impl_writeable_tlv_based_enum_upgradable!(SpliceFailureReason, + (1, Unknown) => {}, + (3, PeerDisconnected) => {}, + (5, CounterpartyAborted) => { + (1, msg, required), + }, + (7, NegotiationError) => { + (1, msg, required), + }, + (9, ContributionInvalid) => {}, + (11, LocallyCanceled) => {}, + (13, ChannelClosing) => {}, + (15, FeeRateTooLow) => {}, + (17, CannotInitiateRbf) => {}, +); + +/// An output paid from a channel by a splice-out. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct SpliceOutput { + /// The amount paid to the output, in satoshis. + pub amount_sats: u64, + /// The script the output pays to. + pub script_pubkey: ScriptBuf, +} + +impl_writeable_tlv_based!(SpliceOutput, { + (0, amount_sats, required), + (2, script_pubkey, required), +}); + +/// The parameters of the [`Node`] API call that initiated a splice. +/// +/// [`Node`]: crate::Node +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum SpliceParameters { + /// Funds were added to the channel via [`Node::splice_in`] or [`Node::splice_in_with_all`]. + /// + /// [`Node::splice_in`]: crate::Node::splice_in + /// [`Node::splice_in_with_all`]: crate::Node::splice_in_with_all + In { + /// The amount added to the channel, in satoshis. For [`Node::splice_in_with_all`], the + /// amount the available funds resolved to. + /// + /// [`Node::splice_in_with_all`]: crate::Node::splice_in_with_all + amount_sats: u64, + }, + /// Funds were removed from the channel via [`Node::splice_out`]. + /// + /// [`Node::splice_out`]: crate::Node::splice_out + Out { + /// The outputs paid from the channel. + outputs: Vec, + }, + /// The splice's in-flight funding transaction was fee-bumped via + /// [`Node::bump_channel_funding_fee`]. + /// + /// [`Node::bump_channel_funding_fee`]: crate::Node::bump_channel_funding_fee + FeeBump, +} + +impl From<&SpliceKind> for SpliceParameters { + fn from(kind: &SpliceKind) -> Self { + match kind { + SpliceKind::In { amount_sats } => Self::In { amount_sats: *amount_sats }, + SpliceKind::Out { outputs } => Self::Out { + outputs: outputs + .iter() + .map(|o| SpliceOutput { + amount_sats: o.value.to_sat(), + script_pubkey: o.script_pubkey.clone(), + }) + .collect(), + }, + SpliceKind::Rbf {} => Self::FeeBump, + } + } +} + +impl_writeable_tlv_based_enum_upgradable!(SpliceParameters, + (1, In) => { + (1, amount_sats, required), + }, + (3, Out) => { + (1, outputs, required_vec), + }, + (5, FeeBump) => {}, +); + /// An event emitted by [`Node`], which should be handled by the user. /// /// [`Node`]: [`crate::Node`] @@ -308,7 +472,11 @@ pub enum Event { /// The outpoint of the channel's splice funding transaction. new_funding_txo: OutPoint, }, - /// A channel splice negotiation round with local inputs or outputs has failed. + /// A channel splice negotiation round with local inputs or outputs, or a fee bump of a + /// splice's funding transaction, has failed. + /// + /// A failed fee bump leaves the splice it meant to bump unaffected; in particular, the + /// splice's in-flight funding transaction may still confirm. /// /// This event is not emitted when only the counterparty contributes to a splice. SpliceNegotiationFailed { @@ -318,6 +486,18 @@ pub enum Event { user_channel_id: UserChannelId, /// The `node_id` of the channel counterparty. counterparty_node_id: PublicKey, + /// The reason the splice failed. + /// + /// Will be `None` for events serialized by LDK Node v0.7. + reason: Option, + /// The parameters of the [`Node`] API call that initiated the failed splice or fee bump. + /// + /// Will be `None` when the failure does not identify the channel's last locally-initiated + /// splice — e.g. when a fee bump superseded the failed attempt — and for events + /// serialized by LDK Node v0.7. + /// + /// [`Node`]: crate::Node + parameters: Option, }, } @@ -402,6 +582,8 @@ impl_writeable_tlv_based_enum!(Event, (3, counterparty_node_id, required), (5, user_channel_id, required), // TLV 7 (abandoned_funding_txo) may be set for LDK Node v0.7. + (9, reason, upgradable_option), + (11, parameters, upgradable_option), }, ); @@ -559,6 +741,7 @@ where wallet: Arc, bump_tx_event_handler: Arc, channel_manager: Arc, + chain_monitor: Arc, connection_manager: Arc>, output_sweeper: Arc, network_graph: Arc, @@ -571,6 +754,7 @@ where onion_messenger: Arc, om_mailbox: Option>, prober: Option>, + splice_tracker: Arc, runtime: Arc, logger: L, config: Arc, @@ -583,19 +767,21 @@ where pub fn new( event_queue: Arc>, wallet: Arc, bump_tx_event_handler: Arc, - channel_manager: Arc, connection_manager: Arc>, - output_sweeper: Arc, network_graph: Arc, - liquidity_source: Arc>>, payment_store: Arc, - forwarding_store: Arc, peer_store: Arc>, - keys_manager: Arc, static_invoice_store: Option, - onion_messenger: Arc, om_mailbox: Option>, - prober: Option>, runtime: Arc, logger: L, config: Arc, + channel_manager: Arc, chain_monitor: Arc, + connection_manager: Arc>, output_sweeper: Arc, + network_graph: Arc, liquidity_source: Arc>>, + payment_store: Arc, forwarding_store: Arc, + peer_store: Arc>, keys_manager: Arc, + static_invoice_store: Option, onion_messenger: Arc, + om_mailbox: Option>, prober: Option>, + splice_tracker: Arc, runtime: Arc, logger: L, config: Arc, ) -> Self { Self { event_queue, wallet, bump_tx_event_handler, channel_manager, + chain_monitor, connection_manager, output_sweeper, network_graph, @@ -608,6 +794,7 @@ where onion_messenger, om_mailbox, prober, + splice_tracker, runtime, logger, config, @@ -733,6 +920,79 @@ where Ok((payment_id, None)) } + /// Cancels a splice whose funding transaction this node will not sign, so that LDK releases + /// what it reserved for this node's contribution through `DiscardFunding` and surfaces the + /// failure through `SpliceNegotiationFailed`, which also settles the persisted intent and + /// takes back the round's record once the round is gone from the channel's history. + /// + /// A refusal means the splice is already beyond cancelling — LDK reset the round itself, or + /// the channel is gone — in which case those reports are on their way regardless and there is + /// nothing further to unwind. + fn cancel_splice(&self, counterparty_node_id: PublicKey, channel_id: ChannelId) { + if let Err(e) = + self.channel_manager.cancel_funding_contributed(&channel_id, &counterparty_node_id) + { + log_error!( + self.logger, + "Failed to cancel the splice on channel {}: {:?}", + channel_id, + e, + ); + } + } + + /// The channel's pending splice rounds that have a transaction, as LDK currently holds them. + fn pending_splice_rounds( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Vec { + let splice_details = self + .channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .and_then(|channel| channel.splice_details); + funding_candidates(splice_details.as_ref(), counterparty_node_id, channel_id) + } + + /// The splice rounds LDK holds for the channel, as [`held_splice_rounds`] lists them, or + /// `None` once the channel is gone. + fn held_splice_rounds( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Option> { + self.channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .map(|channel| held_splice_rounds(channel.splice_details.as_ref(), channel.funding_txo)) + } + + /// Records what one of this node's channels reported about a transaction it produced. + /// + /// A failure is logged rather than reported: these facts accompany a transaction this node + /// has already released or a claim it has already made, so there is nothing left to withhold, + /// and the producing event is re-offered until the claim resolves. A refusal for want of + /// room costs a transaction reported without a classification, which is likewise nothing + /// this node can take back. + async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) { + let txid = facts.txid; + match self.wallet.record_channel_tx_facts(facts).await { + Ok(FactsRecordOutcome::Recorded) => {}, + Ok(FactsRecordOutcome::Incomplete) => log_error!( + self.logger, + "Reporting transaction {} without a classification: this node has no room to describe it", + txid, + ), + Err(e) => { + log_error!( + self.logger, + "Failed to record what channel transaction {} is: {}", + txid, + e + ) + }, + } + } + pub async fn handle_event(&self, event: LdkEvent) -> Result<(), ReplayEvent> { match event { LdkEvent::FundingGenerationReady { @@ -755,7 +1015,7 @@ where let funding_transaction = self .wallet .create_funding_transaction( - output_script, + output_script.clone(), channel_amount, confirmation_target, locktime, @@ -763,6 +1023,60 @@ where .await; match funding_transaction { Ok(final_tx) => { + // Record what the transaction is before handing it to LDK, which is what + // authorizes either party to broadcast it. LDK identifies the funding + // output by the same script and value, and names the channel after that + // outpoint, so the fact matches the channel LDK will report from here on + // rather than the temporary one this event carries. + let txid = final_tx.compute_txid(); + let funding_vout = final_tx + .output + .iter() + .position(|output| { + output.script_pubkey == output_script + && output.value == channel_amount + }) + .and_then(|index| u16::try_from(index).ok()); + if let Some(vout) = funding_vout { + let funding_txo = LdkOutPoint { txid, index: vout }; + let channel = Channel { + counterparty_node_id, + channel_id: ChannelId::v1_from_funding_outpoint(funding_txo), + }; + let facts = ChannelTxFacts::new(txid).with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [vout as u32], + ); + match self.wallet.record_channel_tx_facts(facts).await { + Ok(FactsRecordOutcome::Recorded) => {}, + // Replaying would rebuild the same transaction and find the same + // full store, so the channel is funded with a transaction this + // node will report without a classification. + Ok(FactsRecordOutcome::Incomplete) => log_error!( + self.logger, + "Funding channel {} with a transaction this node has no room to describe", + temporary_channel_id, + ), + Err(e) => { + log_error!( + self.logger, + "Failed to record the funding transaction of channel {}: {}", + temporary_channel_id, + e, + ); + return Err(ReplayEvent()); + }, + } + } else { + log_error!( + self.logger, + "Failed to locate the funding output of channel {} in the transaction funding it", + temporary_channel_id, + ); + } + let needs_manual_broadcast = self .liquidity_source .lsps2_service() @@ -834,7 +1148,22 @@ where }, } }, - LdkEvent::FundingTxBroadcastSafe { user_channel_id, counterparty_node_id, .. } => { + LdkEvent::FundingTxBroadcastSafe { + channel_id, + user_channel_id, + counterparty_node_id, + funding_txo, + .. + } => { + let channel = Channel { counterparty_node_id, channel_id }; + let facts = ChannelTxFacts::new(funding_txo.txid).with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [funding_txo.vout], + ); + self.record_channel_tx_facts(facts).await; + self.liquidity_source .lsps2_service() .lsps2_funding_tx_broadcast_safe(user_channel_id, counterparty_node_id); @@ -1545,17 +1874,42 @@ where .await; }, LdkEvent::SpendableOutputs { outputs, channel_id, counterparty_node_id } => { + let spendable_outpoints: Vec<(Txid, u32)> = outputs + .iter() + .map(|output| { + let outpoint = output.spendable_outpoint(); + (outpoint.txid, outpoint.index as u32) + }) + .collect(); + match self .output_sweeper .track_spendable_outputs(outputs, channel_id, counterparty_node_id, true, None) .await { - Ok(_) => return Ok(()), + Ok(_) => {}, Err(_) => { log_error!(self.logger, "Failed to track spendable outputs"); return Err(ReplayEvent()); }, }; + + // Record which channel resolved these outputs only once the sweeper holds them: + // the sweep itself must never wait on bookkeeping, and the sweeper's own record + // is durable, so a failure here costs a label rather than the funds. + if let (Some(counterparty_node_id), Some(channel_id)) = + (counterparty_node_id, channel_id) + { + let channel = Channel { counterparty_node_id, channel_id }; + for facts in ChannelTxFacts::per_transaction( + &channel, + None, + ChannelOutputRole::Spendable, + spendable_outpoints, + ) { + self.record_channel_tx_facts(facts).await; + } + } }, LdkEvent::OpenChannelRequest { temporary_channel_id, @@ -1834,6 +2188,18 @@ where "LDK Node has only ever persisted ChannelPending events from rust-lightning 0.0.115 or later", ); + // The funding output alone says what the transaction is, and says it for every + // channel a batched funding opens: each channel reports its own output, and the + // reports of one transaction are held together. + let channel = Channel { counterparty_node_id, channel_id }; + let facts = ChannelTxFacts::new(funding_txo.txid).with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [funding_txo.vout], + ); + self.record_channel_tx_facts(facts).await; + let event = Event::ChannelPending { channel_id, user_channel_id: UserChannelId(user_channel_id), @@ -1907,11 +2273,62 @@ where ); } + // The funding this channel now runs on is either the one it opened with or the + // splice round that just locked, so recording it here also catches a round that + // locked before anything else reported it. + if let Some(funding_txo) = funding_txo { + let channel = Channel { counterparty_node_id, channel_id }; + let facts = ChannelTxFacts::new(funding_txo.txid).with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [funding_txo.vout], + ); + self.record_channel_tx_facts(facts).await; + } + + // A splice round LDK promoted to the funding — a zero-conf splice before its + // transaction confirms — can still confirm once a later splice builds on it and + // once the channel closes, when LDK holds it no longer, so its funding payment + // records the promotion and is kept at the close (see + // `closed_channel_held_rounds`). LDK discards the round's siblings as it promotes + // the round, so the channel's other funding payments are resolved now, by the + // rounds the channel manager holds once the channel is updated — the promoted + // round, and whatever was negotiated behind it — or left to the close for a + // channel the manager no longer lists (see + // `Wallet::resolve_promoted_splice_round`). + if let Some(funding_txo) = funding_txo { + let held_rounds = self.held_splice_rounds(counterparty_node_id, channel_id); + if let Err(e) = self + .wallet + .resolve_promoted_splice_round( + channel_id, + funding_txo.txid, + held_rounds.as_deref(), + ) + .await + { + log_error!( + self.logger, + "Failed to resolve the funding payments of channel {} as splice round \ + {} locked: {}", + channel_id, + funding_txo.txid, + e, + ); + return Err(ReplayEvent()); + } + } + self.liquidity_source .lsps2_service() .handle_channel_ready(user_channel_id, &channel_id, &counterparty_node_id) .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), @@ -1931,14 +2348,51 @@ where reason, user_channel_id, counterparty_node_id, + channel_funding_txo, .. } => { log_info!(self.logger, "Channel {} closed due to: {}", channel_id, reason); + // A splice round this node signed dies with the channel unless LDK had already + // handed it to the broadcaster. Whatever the channel manager reports for a round + // still awaiting the counterparty's signatures when the channel closes is queued + // after this event, so its record is taken back here. The channel manager holds + // only the closed channel's last funding, but the channel's monitor still watches + // every pending round the counterparty committed to and the background processor + // has flushed to it, and our signatures may have left the node for such a round, so + // it is kept (see `closed_channel_held_rounds`). A payment left with no round of + // ours the monitor watches, and none LDK promoted to the funding before, is failed: + // the monitor's `DiscardFunding` events settle such payments once the close + // matures, but reach the handler ahead of this event when one sync delivers the + // close and its maturity, and then find the channel still listed with every round + // held. The monitor's guard is not `Send`, so its watched transactions are + // collected before anything is awaited. + let watched_txids: Vec = self + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| { + monitor.get_outputs_to_watch().into_iter().map(|(txid, _)| txid).collect() + }) + .unwrap_or_default(); + let held_rounds = closed_channel_held_rounds(channel_funding_txo, watched_txids); + if let Err(e) = + self.wallet.resolve_closed_channel_splice_rounds(channel_id, &held_rounds).await + { + log_error!( + self.logger, + "Failed to resolve the funding payments of channel {} at its close: {}", + channel_id, + e, + ); + return Err(ReplayEvent()); + } + // `counterparty_node_id` has been set on every `ChannelClosed` since LDK 0.0.117. let counterparty_node_id = counterparty_node_id .expect("counterparty_node_id is always set since LDK 0.0.117"); + 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. @@ -1992,6 +2446,58 @@ where } }, LdkEvent::DiscardFunding { channel_id, funding_info } => { + // LDK lets a splice round go with this event — a sibling round locked, or the + // channel's close matured — naming this node's contribution to the round rather + // than the round, so the event itself resolves no funding payment. For a channel + // the manager lists, the payments were resolved as the sibling's promotion was + // handled, from the rounds the manager holds (see + // `Wallet::resolve_promoted_splice_round`), and the event only takes back a round + // nothing broadcast that the manager no longer holds: its pending rounds and its + // funding, the monitor left out — its updates land after the manager's, deferred + // to the background processor's flush, so it may still watch a round the manager + // let go. For a channel the manager no longer lists — the monitor's events for the + // rounds of a closed channel — the funding its monitor settled on and whatever it + // still watches decide, as at `ChannelClosed`. The monitor's guard is not `Send`, + // so its state is collected before anything is awaited. + let channel = self + .channel_manager + .list_channels() + .into_iter() + .find(|channel| channel.channel_id == channel_id); + let resolved = match channel { + Some(channel) => { + let held_rounds = held_splice_rounds( + channel.splice_details.as_ref(), + channel.funding_txo, + ); + self.wallet.drop_abandoned_splice_rounds(channel_id, &held_rounds).await + }, + None => { + let held_rounds = match self.chain_monitor.get_monitor(channel_id) { + Ok(monitor) => closed_channel_held_rounds( + Some(monitor.get_funding_txo()), + monitor.get_outputs_to_watch().into_iter().map(|(txid, _)| txid), + ), + Err(()) => Vec::new(), + }; + self.wallet + .resolve_closed_channel_splice_rounds(channel_id, &held_rounds) + .await + }, + }; + if let Err(e) = resolved { + log_error!( + self.logger, + "Failed to resolve the funding payments of channel {} for a discarded \ + splice round: {}", + channel_id, + e, + ); + return Err(ReplayEvent()); + } + + // TODO(#1037): once inputs are locked at coin selection, `inputs` are locks this + // event returns: unlock them here. if let FundingInfo::Contribution { inputs: _, outputs } = funding_info { log_info!( self.logger, @@ -2085,6 +2591,64 @@ where } self.bump_tx_event_handler.handle_event(&bte).await; + + // Record what the claim is spending only once it has been made: a claim must + // never wait on bookkeeping, and LDK re-offers the event until the claim + // resolves, so a failure here costs a label rather than the funds. + let facts = match &bte { + BumpTransactionEvent::ChannelClose { + channel_id, + counterparty_node_id, + commitment_tx, + anchor_descriptor, + pending_htlcs, + .. + } => { + let channel = Channel { + counterparty_node_id: *counterparty_node_id, + channel_id: *channel_id, + }; + // An HTLC below the dust limit is paid to fees instead of to an output of + // its own, and so has no output index to record. + let htlc_vouts = + pending_htlcs.iter().filter_map(|htlc| htlc.transaction_output_index); + vec![ChannelTxFacts::new(commitment_tx.compute_txid()) + .with_outputs( + &channel, + None, + ChannelOutputRole::Anchor, + [anchor_descriptor.outpoint.vout], + ) + .with_outputs(&channel, None, ChannelOutputRole::Htlc, htlc_vouts) + .with_self_role(TransactionType::UnilateralClose { + counterparty_node_id: *counterparty_node_id, + channel_id: *channel_id, + })] + }, + BumpTransactionEvent::HTLCResolution { + channel_id, + counterparty_node_id, + htlc_descriptors, + .. + } => { + let channel = Channel { + counterparty_node_id: *counterparty_node_id, + channel_id: *channel_id, + }; + ChannelTxFacts::per_transaction( + &channel, + None, + ChannelOutputRole::Htlc, + htlc_descriptors.iter().map(|descriptor| { + let outpoint = descriptor.outpoint(); + (outpoint.txid, outpoint.vout) + }), + ) + }, + }; + for facts in facts { + self.record_channel_tx_facts(facts).await; + } }, LdkEvent::OnionMessageIntercepted { next_hop, message, .. } => { if let NextMessageHop::NodeId(peer_node_id) = next_hop { @@ -2182,7 +2746,6 @@ where } } }, - // TODO(splicing): Revisit error handling once splicing API is settled in LDK 0.3 LdkEvent::FundingTransactionReadyForSigning { channel_id, counterparty_node_id, @@ -2190,6 +2753,48 @@ where .. } => match self.wallet.sign_owned_inputs(unsigned_transaction) { Ok(partially_signed_tx) => { + // Record the splice's funding payment before handing our signatures to LDK: + // `funding_transaction_signed` releases them to the counterparty, after which + // either party may broadcast — and wallet sync could observe the transaction + // before this node has recorded it. The record is written from the channel's + // pending splice history through the splice tracker, whose lock keeps the + // channel's intent record from changing hands mid-write, and the round's + // broadcast adds nothing to it. On a failed write, replay rather than proceed + // unrecorded: LDK re-offers the event in-session and regenerates it across + // restarts while the transaction is unsigned. + let candidates = self.pending_splice_rounds(counterparty_node_id, channel_id); + let record = match self + .splice_tracker + .on_funding_ready_for_signing(&partially_signed_tx, &candidates) + .await + { + Ok(record) => record, + Err(e) => { + log_error!( + self.logger, + "Failed to record the splice funding payment for channel {}: {}", + channel_id, + e, + ); + return Err(ReplayEvent()); + }, + }; + if record == SignedFundingRecord::Unmeasurable { + // Nothing was written and our signatures have not left the node, so the + // splice is cancelled rather than replayed. A replay would meet the same + // refusal, and an event that fails every time it is offered holds back + // every event queued behind it — including the ones that claim inbound + // HTLCs before they expire. Bounding the loss to this splice is the + // cheaper failure. + log_error!( + self.logger, + "Not signing the funding transaction for channel {}, aborting the \ + splice: this node's share of the round is not on record", + channel_id, + ); + self.cancel_splice(counterparty_node_id, channel_id); + return Ok(()); + } match self.channel_manager.funding_transaction_signed( &channel_id, &counterparty_node_id, @@ -2204,13 +2809,30 @@ where ); }, Err(e) => { - // TODO(splicing): Abort splice once supported in LDK 0.3 - debug_assert!(false, "Failed signing funding transaction: {:?}", e); - log_error!(self.logger, "Failed signing funding transaction: {:?}", e); + // The signed transaction never reached LDK, so nothing can ever + // broadcast it: cancel the splice, which also takes back the record + // written above. + log_error!( + self.logger, + "LDK refused the signed funding transaction for channel {}, \ + aborting the splice: {:?}", + channel_id, + e, + ); + self.cancel_splice(counterparty_node_id, channel_id); }, } }, - Err(()) => log_error!(self.logger, "Failed signing funding transaction"), + Err(()) => { + // No record has been written for this transaction yet, so there is nothing to + // unwind: cancel the splice. + 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 { channel_id, @@ -2227,6 +2849,39 @@ where new_funding_txo, ); + let channel = Channel { counterparty_node_id, channel_id }; + let facts = ChannelTxFacts::new(new_funding_txo.txid) + .with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [new_funding_txo.vout], + ) + .with_self_role(TransactionType::InteractiveFunding { + channels: vec![channel.clone()], + }); + self.record_channel_tx_facts(facts).await; + + // LDK emits this event only once our `tx_signatures` for the round are ready to + // send, so the counterparty may already hold them and may broadcast the round + // without us. The round's funding payment, recorded when the round was signed, + // therefore no longer awaits broadcast. On a failed write, replay: LDK re-offers + // the event in-session and persists it across restarts. + if let Err(e) = self + .wallet + .record_broadcast_splice_round(channel_id, new_funding_txo.txid) + .await + { + log_error!( + self.logger, + "Failed to mark splice round {} of channel {} as broadcast: {}", + new_funding_txo.txid, + channel_id, + e, + ); + return Err(ReplayEvent()); + } + let event = Event::SpliceNegotiated { channel_id, user_channel_id: UserChannelId(user_channel_id), @@ -2246,7 +2901,8 @@ where channel_id, user_channel_id, counterparty_node_id, - .. + reason, + contribution, } => { log_info!( self.logger, @@ -2255,19 +2911,63 @@ where counterparty_node_id, ); + // A round this node signed was recorded when signing; if the failed round was + // among them, nothing can broadcast it anymore, so take its record back. The + // splice intent the record carried stays behind as a bare intent for the report + // below. The rounds LDK still holds tell which recorded ones it abandoned (a + // contribution can fail while an earlier signed round still awaits its + // signatures). A closed channel is left to its `ChannelClosed` event: LDK queues + // one for every channel it removes — before the failures a force-close reports, + // after the one a cooperative close reports — and that event carries the + // channel's last funding, which this handler can no longer read from the channel. + if let Some(held_rounds) = self.held_splice_rounds(counterparty_node_id, channel_id) + { + if let Err(e) = + self.wallet.drop_abandoned_splice_rounds(channel_id, &held_rounds).await + { + log_error!( + self.logger, + "Failed to drop the abandoned splice round of channel {} from its \ + funding payment: {}", + channel_id, + e, + ); + return Err(ReplayEvent()); + } + } + + // 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(()) @@ -2400,6 +3100,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, @@ -2417,6 +3122,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 { @@ -2478,6 +3188,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/io/mod.rs b/src/io/mod.rs index b7e4d2131f..4d229e8b0d 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -37,6 +37,10 @@ pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// The channel transaction provenance facts will be persisted under this prefix. +pub(crate) const CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE: &str = "channel_tx_facts"; +pub(crate) const CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + /// Forwarded payment information is persisted under this primary namespace. pub(crate) const FORWARDED_PAYMENT_PERSISTENCE_PRIMARY_NAMESPACE: &str = "forwarded_payments"; pub(crate) const FORWARDED_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = "details"; diff --git a/src/lib.rs b/src/lib.rs index 9b0700b967..cdf58a4044 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -91,6 +91,7 @@ compile_error!("at least one chain source feature must be enabled"); mod balance; mod builder; mod chain; +mod channel; pub mod config; mod connection; mod data_store; @@ -132,6 +133,7 @@ pub use bitcoin::FeeRate; use bitcoin::{Address, Amount, BlockHash, Network}; pub use builder::{BuildError, Builder}; use chain::ChainSource; +use channel::SpliceTracker; use config::{ default_user_config, may_announce_channel, AsyncPaymentsRole, ChannelConfig, Config, LNURL_AUTH_TIMEOUT_SECS, NODE_ANN_BCAST_INTERVAL, PEER_RECONNECTION_INTERVAL, @@ -140,7 +142,7 @@ use config::{ use connection::ConnectionManager; pub use error::Error as NodeError; use error::Error; -pub use event::Event; +pub use event::{Event, SpliceFailureReason, SpliceOutput, SpliceParameters}; use event::{EventHandler, EventQueue}; use fee_estimator::{ max_funding_feerate, rbf_splice_feerates, ConfirmationTarget, FeeEstimator, OnchainFeeEstimator, @@ -177,6 +179,7 @@ use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; pub use payment::forwarding_store::aggregate_channel_pair_stats; use payment::forwarding_store::{run_forwarded_payment_aggregation, ForwardingStore}; +use payment::pending_payment_store::SpliceKind; use payment::{ Bolt11Payment, Bolt12Payment, ForwardingAnalytics, OnchainPayment, PaymentDetails, PaymentDetailsPage, SpontaneousPayment, @@ -275,6 +278,7 @@ pub struct Node { payment_store: Arc, forwarding_store: Arc, forwarded_payment_aggregation_retention_secs: u64, + splice_tracker: Arc, lnurl_auth: Arc, is_running: Arc>, node_metrics: Arc, @@ -366,6 +370,27 @@ impl Node { ) })?; + // Release whatever the wallet still holds for splices that did not survive the restart — + // before background syncing and broadcasting start below, so nothing can act on the stale + // reservations first. + self.runtime.block_on(self.splice_tracker.reconcile()); + + // A splice round recorded when this node signed it is taken back once LDK reports the + // negotiation failed or the channel closed. LDK reports the loss of a negotiation its last + // channel manager write carried mid-way, but a round committed, negotiated and signed + // since that write gets no report if the node stopped before the next one, so drop what + // LDK's persisted state does not hold before anything runs on the records: no background + // task has started yet, so a failure here fails the start cleanly. A channel LDK no + // longer lists is left to its `ChannelClosed` event. + let channels = self.channel_manager.list_channels(); + self.runtime.block_on(self.wallet.drop_splice_rounds_lost_across_restart( + |channel_id| { + channels.iter().find(|channel| channel.channel_id == channel_id).map(|channel| { + wallet::held_splice_rounds(channel.splice_details.as_ref(), channel.funding_txo) + }) + }, + ))?; + // Spawn background task continuously syncing onchain, lightning, and fee rate cache. let stop_sync_receiver = self.stop_sender.subscribe(); let chain_source = Arc::clone(&self.chain_source); @@ -685,6 +710,7 @@ impl Node { Arc::clone(&self.wallet), bump_tx_event_handler, Arc::clone(&self.channel_manager), + Arc::clone(&self.chain_monitor), Arc::clone(&self.connection_manager), Arc::clone(&self.output_sweeper), Arc::clone(&self.network_graph), @@ -697,6 +723,7 @@ impl Node { Arc::clone(&self.onion_messenger), self.om_mailbox.clone(), self.prober.clone(), + Arc::clone(&self.splice_tracker), Arc::clone(&self.runtime), Arc::clone(&self.logger), Arc::clone(&self.config), @@ -709,6 +736,15 @@ impl Node { }); } + // Consume any events LDK replays from its last persisted state (e.g. a `DiscardFunding` + // for a splice that died before the node stopped) before the node is running: a replayed + // event describes pre-restart state and must act before new user operations build on it. + let replay_handler = &event_handler; + self.runtime.block_on( + self.channel_manager + .process_pending_events_async(|event| replay_handler.handle_event(event)), + ); + // Setup background processing let background_persister = Arc::clone(&self.kv_store); let background_event_handler = Arc::clone(&event_handler); @@ -1723,6 +1759,14 @@ impl Node { if let Some(channel_details) = open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) { + // The channel's current funding outpoint anchors the persisted splice intent, and a + // channel without one is not ready to splice: check before any contribution is + // built, so nothing is reserved for a splice that cannot be submitted. + let pre_splice_funding_txo = channel_details.funding_txo.ok_or_else(|| { + log_error!(self.logger, "Failed to splice channel: channel not yet ready"); + Error::ChannelSplicingFailed + })?; + let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); let max_feerate = max_funding_feerate(min_feerate); @@ -1736,18 +1780,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, @@ -1809,6 +1848,10 @@ impl Node { _ => min_feerate, }; + // TODO(#1037): the inputs are locked from coin selection on, and a failure of the + // build after it — LDK validating the selected inputs — returns here with nothing + // releasing them; `submit`'s own failure paths release them or leave them to + // `DiscardFunding`. let contribution = self .runtime .block_on(funding_template.splice_in( @@ -1822,16 +1865,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!( @@ -1850,6 +1895,15 @@ impl Node { /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. /// + /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice + /// may be initiated once the cause of the failure is addressed. A splice still pending when the + /// node stops is resumed by LDK when possible; otherwise it is dropped at the next startup, + /// releasing anything reserved for it. 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 @@ -1874,6 +1928,15 @@ impl Node { /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. /// + /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice + /// may be initiated once the cause of the failure is addressed. A splice still pending when the + /// node stops is resumed by LDK when possible; otherwise it is dropped at the next startup, + /// releasing anything reserved for it. 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 @@ -1890,6 +1953,15 @@ impl Node { /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. /// + /// A splice that fails during negotiation (e.g. because the peer disconnected) is reported + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice + /// may be initiated once the cause of the failure is addressed. A splice still pending when the + /// node stops is resumed by LDK when possible; otherwise it is dropped at the next startup, + /// releasing anything reserved for it. 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 @@ -1904,6 +1976,14 @@ impl Node { if let Some(channel_details) = open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) { + // The channel's current funding outpoint anchors the persisted splice intent, and a + // channel without one is not ready to splice: check before any contribution is + // built, so nothing is reserved for a splice that cannot be submitted. + let pre_splice_funding_txo = channel_details.funding_txo.ok_or_else(|| { + log_error!(self.logger, "Failed to splice channel: channel not yet ready"); + Error::ChannelSplicingFailed + })?; + let splice_amount_msat = splice_amount_sats.checked_mul(1_000).ok_or(Error::ChannelSplicingFailed)?; if splice_amount_msat > channel_details.outbound_capacity_msat { @@ -1946,22 +2026,25 @@ impl Node { value: Amount::from_sat(splice_amount_sats), script_pubkey: address.script_pubkey(), }]; - let contribution = - funding_template.splice_out(outputs, feerate, max_feerate).map_err(|e| { - log_error!(self.logger, "Failed to splice channel: {}", e); - Error::ChannelSplicingFailed - })?; + let contribution = funding_template + .splice_out(outputs.clone(), feerate, max_feerate) + .map_err(|e| { + log_error!(self.logger, "Failed to splice channel: {}", e); + Error::ChannelSplicingFailed + })?; - self.channel_manager - .funding_contributed( - &channel_details.channel_id, - &counterparty_node_id, + self.runtime + .block_on(self.splice_tracker.submit( + counterparty_node_id, + channel_details.channel_id, + pre_splice_funding_txo, contribution, + SpliceKind::Out { outputs }, None, - ) + )) .map_err(|e| { log_error!(self.logger, "Failed to splice channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( @@ -1977,6 +2060,15 @@ impl Node { /// Fee-bumps the pending splice on a channel by replacing its in-flight funding transaction /// (RBF). The splice's amount and destination are preserved; only the fee rate is raised. /// Errors if the channel has no pending splice to bump. + /// + /// A fee bump that fails during negotiation (e.g. because the peer disconnected) is reported + /// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; the fee may be + /// bumped again once the cause of the failure is addressed. A fee bump still pending when the + /// node stops is resumed by LDK when possible; otherwise it is dropped at the next startup, + /// releasing anything reserved for it. A fee bump LDK was still queueing or negotiating when + /// the node stopped is reported through [`Event::SpliceNegotiationFailed`] at startup, with its + /// parameters only if this node contributed to the splice it bumps; one lost earlier is dropped + /// without a failure event. pub fn bump_channel_funding_fee( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, ) -> Result<(), Error> { @@ -1985,6 +2077,14 @@ impl Node { if let Some(channel_details) = open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) { + // The channel's current funding outpoint anchors the persisted splice intent, and a + // channel without one is not ready to splice: check before any contribution is + // built, so nothing is reserved for a splice that cannot be submitted. + let pre_splice_funding_txo = channel_details.funding_txo.ok_or_else(|| { + log_error!(self.logger, "Failed to RBF channel: channel not yet ready"); + Error::ChannelSplicingFailed + })?; + let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); @@ -2011,6 +2111,12 @@ impl Node { return Err(Error::ChannelSplicingFailed); }; + // The round the bump replaces: a bump that only adjusts its fee reuses its inputs and + // change address, which a failed submission must not release. + let prior_contribution = funding_template.prior_contribution().cloned(); + // TODO(#1037): a bump that re-selects its inputs locks them from coin selection on, + // and a failure of the build after it returns here with nothing releasing them; + // `submit`'s own failure paths release them or leave them to `DiscardFunding`. let contribution = self .runtime .block_on(funding_template.rbf_prior_contribution( @@ -2023,16 +2129,18 @@ impl Node { Error::ChannelSplicingFailed })?; - self.channel_manager - .funding_contributed( - &channel_details.channel_id, - &counterparty_node_id, + self.runtime + .block_on(self.splice_tracker.submit( + counterparty_node_id, + channel_details.channel_id, + pre_splice_funding_txo, contribution, - None, - ) + SpliceKind::Rbf {}, + prior_contribution, + )) .map_err(|e| { log_error!(self.logger, "Failed to RBF channel: {:?}", e); - Error::ChannelSplicingFailed + e }) } else { log_error!( diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index e14f64c380..d0afd90259 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -5,12 +5,16 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use bitcoin::Txid; -use lightning::impl_writeable_tlv_based; +use bitcoin::secp256k1::PublicKey; +use bitcoin::{TxOut, Txid}; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::FundingContribution; +use lightning::ln::types::ChannelId; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use crate::data_store::{StorableObject, StorableObjectUpdate, UpdatableObject}; -use crate::payment::store::PaymentDetailsUpdate; +use crate::payment::store::{Channel, PaymentDetailsUpdate, TransactionType}; use crate::payment::{PaymentDetails, PaymentKind}; /// One candidate transaction in an interactive-funding (splice) RBF history, holding this node's @@ -28,44 +32,253 @@ pub(crate) struct FundingTxCandidate { /// This node's share of the on-chain fee for this candidate, in millisatoshis, or `None` if /// this node did not contribute to it. pub fee_paid_msat: Option, + /// Whether this node signed the candidate but LDK has yet to report the round negotiated. Set + /// when the round is recorded at signing time, cleared when LDK reports the splice negotiated + /// (`SpliceNegotiated`, emitted only once our `tx_signatures` for the round are ready to send). + /// Such a round may be abandoned without a trace — the counterparty aborts, or the channel + /// closes, before the signatures are exchanged — so only such a round may be dropped from the + /// history, and only once LDK no longer holds it. + pub awaiting_broadcast: bool, } impl_writeable_tlv_based!(FundingTxCandidate, { (0, txid, required), (2, amount_msat, option), (4, fee_paid_msat, option), + (6, awaiting_broadcast, required), }); -/// Represents a pending payment +/// The parameters of the API call that initiated a splice, recording what was attempted +/// independently of the contribution built from them. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PendingPaymentDetails { - /// The full payment details - pub details: PaymentDetails, - /// Transaction IDs that have replaced or conflict with this payment. +pub(crate) enum SpliceKind { + /// [`Node::splice_in`] with a resolved amount. + /// + /// [`Node::splice_in`]: crate::Node::splice_in + In { amount_sats: u64 }, + /// [`Node::splice_out`] to the given outputs. + /// + /// [`Node::splice_out`]: crate::Node::splice_out + Out { outputs: Vec }, + /// [`Node::bump_channel_funding_fee`] of a pending splice. + /// + /// [`Node::bump_channel_funding_fee`]: crate::Node::bump_channel_funding_fee + Rbf {}, +} + +impl_writeable_tlv_based_enum!(SpliceKind, + (0, In) => { + (0, amount_sats, required), + }, + (2, Out) => { + (0, outputs, required_vec), + }, + (4, Rbf) => {}, +); + +/// A user-initiated splice that has been handed to LDK but is not yet guaranteed to survive a +/// restart. LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, and it +/// abandons an in-progress negotiation whenever the peer disconnects (which includes stopping the +/// node). Until the new funding transaction locks we keep enough state to recognize a splice LDK +/// no longer knows about and to describe events about it in terms of the original request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SpliceIntent { + /// The channel counterparty. + pub counterparty_node_id: PublicKey, + /// The channel being spliced. + pub channel_id: ChannelId, + /// The channel's funding outpoint when the splice was initiated. It only changes once a splice + /// locks, so a mismatch with the channel's current funding outpoint means the splice (or a + /// replacement) completed and the intent is stale. + pub pre_splice_funding_txo: LdkOutPoint, + /// The contribution handed to [`ChannelManager::funding_contributed`], kept to match later + /// events about the splice back to this intent. + /// + /// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed + pub contribution: FundingContribution, + /// The parameters of the originating API call. + pub kind: SpliceKind, +} + +impl_writeable_tlv_based!(SpliceIntent, { + (0, counterparty_node_id, required), + (2, channel_id, required), + (4, pre_splice_funding_txo, required), + (6, contribution, required), + (8, kind, required), +}); + +/// A pending payment tracked by LDK Node, keyed by [`PaymentId`]. +/// +/// Each part of an entry is written by a different subsystem and is present on its own schedule, +/// so all of them are optional. A user-initiated splice is persisted with nothing but its +/// [`SpliceIntent`] before its contribution is handed to LDK; signing a round of it adds the +/// round to `candidates` and names the `funding_channels` it belongs to, still without a +/// transaction anyone has seen; wallet sync adds `details` once it observes the transaction, and +/// records `conflicting_txids` for any wallet transaction; the `ChannelReady` arm records +/// `locked_rounds`. A splice uses all of them; the fields do not partition by payment type. An +/// entry holding none of them tracks nothing and is removed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PendingPaymentDetails { + /// The payment this entry tracks. + pub id: PaymentId, + /// The full payment details, or `None` for a splice whose transaction wallet sync has yet to + /// observe — including one this node has signed but nothing has broadcast. + pub details: Option, + /// Transaction IDs wallet sync observed to have replaced or to conflict with this + /// payment, used to map later events about those txids back to this record. This is + /// BDK's view, distinct from `candidates`: it can hold conflicts that were never + /// negotiated candidates, while a candidate replaced between wallet syncs may never + /// appear here (it gets no `TxReplaced` event of its own). pub conflicting_txids: Vec, + /// The channels whose interactive funding `candidates` are rounds of, as the signing of a + /// round named them. Empty for a non-funding payment and for a record wallet sync created + /// on its own, whose channels its classification names instead. + pub funding_channels: Vec, /// For interactive funding (splices), this node's per-candidate funding figures across the - /// RBF history, keyed by each candidate's txid. Empty for non-funding payments and for - /// records written before per-candidate tracking existed. - pub(crate) candidates: Vec, + /// RBF history, keyed by each candidate's txid and recorded as each round is signed. + /// Empty for non-funding payments. + pub candidates: Vec, + /// The live splice intent, or `None` for a non-splice payment or a splice that has + /// locked. It is owned by the splice entry points and the splice tracker — persisted at + /// splice initiation and cleared once the splice locks or its failure is surfaced — and + /// outlives the rounds negotiated under it, because a fee bump is a fresh negotiation LDK + /// likewise abandons if the peer disconnects before signing, and shares the bumped round's + /// record rather than getting one of its own. + pub splice_intent: Option, + /// The candidates LDK promoted to the channel's funding, as `ChannelReady` reported them. + /// A zero-conf splice locks before its transaction confirms, and every later splice builds + /// on it, so such a round can still confirm once the channel's funding has moved on from + /// it and once the channel has closed, when LDK holds it no longer. Kept apart from the + /// candidates, which each funding-record write replaces as a whole. + pub locked_rounds: Vec, } impl PendingPaymentDetails { pub(crate) fn new( details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, ) -> Self { - Self { details, conflicting_txids, candidates } + Self::tracked(details, conflicting_txids, candidates, None) + } + + pub(crate) fn tracked( + details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, + splice_intent: Option, + ) -> Self { + Self { + id: details.id, + details: Some(details), + conflicting_txids, + funding_channels: Vec::new(), + candidates, + splice_intent, + locked_rounds: Vec::new(), + } + } + + pub(crate) fn pending_splice(id: PaymentId, intent: SpliceIntent) -> Self { + Self { + id, + details: None, + conflicting_txids: Vec::new(), + funding_channels: Vec::new(), + candidates: Vec::new(), + splice_intent: Some(intent), + locked_rounds: Vec::new(), + } + } + + /// An entry for the rounds of an interactive funding of `funding_channels` this node has + /// signed, before any transaction of it has been observed and therefore before a payment + /// record for it exists. + pub(crate) fn signed_rounds( + id: PaymentId, funding_channels: Vec, candidates: Vec, + splice_intent: Option, + ) -> Self { + Self { + id, + details: None, + conflicting_txids: Vec::new(), + funding_channels, + candidates, + splice_intent, + locked_rounds: Vec::new(), + } + } + + /// The full payment details, or `None` for a splice whose transaction has not been observed. + pub(crate) fn details(&self) -> Option<&PaymentDetails> { + self.details.as_ref() + } + + /// Transaction IDs that have replaced or conflict with this payment. + pub(crate) fn conflicting_txids(&self) -> &[Txid] { + &self.conflicting_txids + } + + /// The rounds LDK promoted to the channel's funding, as `ChannelReady` reported them. + pub(crate) fn locked_rounds(&self) -> &[Txid] { + &self.locked_rounds + } + + /// Records that LDK promoted the round with the given txid to the channel's funding. Returns + /// whether the record changed: a round recorded as promoted already leaves it as it is. + pub(crate) fn record_locked_round(&mut self, txid: Txid) -> bool { + if self.locked_rounds.contains(&txid) { + return false; + } + self.locked_rounds.push(txid); + true + } + + /// The splice intent this record carries, if it is a splice that has not yet locked. + pub(crate) fn splice_intent(&self) -> Option<&SpliceIntent> { + self.splice_intent.as_ref() } /// Returns this node's recorded funding figures for the candidate with the given txid, if any. pub(crate) fn candidate(&self, txid: Txid) -> Option<&FundingTxCandidate> { self.candidates.iter().find(|candidate| candidate.txid == txid) } + + /// This node's recorded funding figures across the candidate history, in LDK's order; empty + /// for a splice without a signed round yet and for non-funding payments. + pub(crate) fn candidates(&self) -> &[FundingTxCandidate] { + &self.candidates + } + + /// The channels of the interactive funding this entry tracks: those the signing of a round + /// named, else those its classification names. + pub(crate) fn funding_channels(&self) -> &[Channel] { + if !self.funding_channels.is_empty() { + return &self.funding_channels; + } + match self.details.as_ref().map(|details| &details.kind) { + Some(PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + }) => channels, + _ => &[], + } + } + + /// Whether this entry tracks nothing anymore and can be dropped. + pub(crate) fn is_empty(&self) -> bool { + self.details.is_none() + && self.splice_intent.is_none() + && self.candidates.is_empty() + && self.locked_rounds.is_empty() + } } impl_writeable_tlv_based!(PendingPaymentDetails, { - (0, details, required), - (2, conflicting_txids, optional_vec), - (4, candidates, optional_vec), + (0, id, required), + (2, details, option), + (4, conflicting_txids, optional_vec), + (6, funding_channels, optional_vec), + (8, candidates, optional_vec), + (10, splice_intent, option), + (12, locked_rounds, optional_vec), }); #[derive(Clone, Debug, PartialEq, Eq)] @@ -74,13 +287,17 @@ pub(crate) struct PendingPaymentDetailsUpdate { pub payment_update: Option, pub conflicting_txids: Option>, pub candidates: Vec, + /// The splice intent to set (`Some(Some(..))`) or clear (`Some(None)`), or `None` to leave it + /// unchanged. Clearing the intent of an entry that tracks nothing else is done by removing the + /// entry, not through this field. + pub splice_intent: Option>, } impl StorableObject for PendingPaymentDetails { type Id = PaymentId; fn id(&self) -> Self::Id { - self.details.id + self.id } } @@ -90,9 +307,13 @@ impl UpdatableObject for PendingPaymentDetails { fn update(&mut self, update: Self::Update) -> bool { let mut updated = false; - // Update the underlying payment details if present - if let Some(payment_update) = update.payment_update { - updated |= self.details.update(payment_update); + // Update the underlying payment details if present. An entry with no record yet is not + // given one here: only the writer that observed the transaction knows what the record + // says, and it sets the field directly. + if let (Some(payment_update), Some(details)) = + (update.payment_update, self.details.as_mut()) + { + updated |= details.update(payment_update); } if let Some(new_conflicting_txids) = update.conflicting_txids { @@ -102,19 +323,31 @@ impl UpdatableObject for PendingPaymentDetails { } } - if let PaymentKind::Onchain { txid, .. } = &self.details.kind { + if let Some(PaymentKind::Onchain { txid, .. }) = + self.details.as_ref().map(|details| &details.kind) + { + let txid = *txid; let conflicts_len = self.conflicting_txids.len(); - self.conflicting_txids.retain(|conflicting_txid| conflicting_txid != txid); + self.conflicting_txids.retain(|conflicting_txid| *conflicting_txid != txid); updated |= self.conflicting_txids.len() != conflicts_len; } - // Each classify passes the complete candidate history, so a non-empty update replaces the - // stored list. An empty update (e.g. a non-funding payment) leaves it untouched. + // Each funding-record write passes the candidate history as of its own round, so a + // non-empty update replaces the stored list. An empty update (e.g. a non-funding + // payment) leaves it untouched. Dropping an abandoned round, the only writer that + // shrinks it, goes through the store's `mutate` instead. if !update.candidates.is_empty() && self.candidates != update.candidates { self.candidates = update.candidates; updated = true; } + if let Some(new_splice_intent) = update.splice_intent { + if self.splice_intent != new_splice_intent { + self.splice_intent = new_splice_intent; + updated = true; + } + } + updated } @@ -131,23 +364,199 @@ impl StorableObjectUpdate for PendingPaymentDetailsUpdate impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { fn from(value: &PendingPaymentDetails) -> Self { - let conflicting_txids = if value.conflicting_txids.is_empty() { - None - } else { - Some(value.conflicting_txids.clone()) - }; - Self { - id: value.id(), - payment_update: Some(value.details.to_update()), - conflicting_txids, - candidates: value.candidates.clone(), + match &value.details { + // An entry with no record yet carries nothing a payment-tracking merge could apply + // beyond its intent, which the entry that holds it owns outright. + None => Self { + id: value.id, + payment_update: None, + conflicting_txids: None, + candidates: value.candidates.clone(), + splice_intent: value.splice_intent.clone().map(Some), + }, + Some(details) => { + let conflicting_txids = if value.conflicting_txids.is_empty() { + None + } else { + Some(value.conflicting_txids.clone()) + }; + // Leave the splice intent unchanged: it is owned by the splice entry points and the + // splice tracker, never by a payment-tracking merge. Emitting the current value + // here would let an `insert_or_update` of a payment record (e.g. from wallet sync, + // built without an intent) clobber a live intent to `None`. + Self { + id: details.id, + payment_update: Some(details.to_update()), + conflicting_txids, + candidates: value.candidates.clone(), + splice_intent: None, + } + }, } } } +/// Builds a [`FundingContribution`] for tests through its `Readable` impl — the only path open +/// outside `rust-lightning`, which keeps its builder private. The length-prefixed stream holds +/// the required TLV records (the given estimated fee in satoshis, feerate, max feerate, and the +/// is-splice flag) plus the given contributed outputs. +/// +/// [`FundingContribution`]: lightning::ln::funding::FundingContribution +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_outputs( + estimated_fee_sat: u64, feerate: u64, outputs: &[bitcoin::TxOut], +) -> lightning::ln::funding::FundingContribution { + test_funding_contribution_with_parts(estimated_fee_sat, feerate, &[], outputs, None) +} + +/// Builds a [`FundingContribution`] for tests from its parts: the given estimated fee, an input +/// spending output 0 — which must be P2WPKH — of each given previous transaction, the given +/// contributed outputs and change output, and the given input-selection feerate (also used as +/// the maximum), with the is-splice flag set. +/// +/// [`FundingContribution`]: lightning::ln::funding::FundingContribution +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_parts( + estimated_fee_sat: u64, feerate: u64, prevtxs: &[bitcoin::Transaction], + outputs: &[bitcoin::TxOut], change_output: Option<&bitcoin::TxOut>, +) -> lightning::ln::funding::FundingContribution { + test_funding_contribution_inheriting( + estimated_fee_sat, + feerate, + prevtxs, + outputs, + change_output, + &[], + &[], + ) +} + +/// Like [`test_funding_contribution_with_parts`], but recording `inherited_inputs` and +/// `inherited_output_scripts` as parts a still-pending splice attempt reserved before this +/// contribution, as LDK records them at the hand-off of a fee bump built from the round it +/// replaces: the contribution's `reserved_inputs` and `reserved_outputs` leave them out. +#[cfg(test)] +pub(crate) fn test_funding_contribution_inheriting( + estimated_fee_sat: u64, feerate: u64, prevtxs: &[bitcoin::Transaction], + outputs: &[bitcoin::TxOut], change_output: Option<&bitcoin::TxOut>, + inherited_inputs: &[bitcoin::OutPoint], inherited_output_scripts: &[bitcoin::ScriptBuf], +) -> lightning::ln::funding::FundingContribution { + use lightning::util::ser::{BigSize, Writeable}; + use lightning::util::wallet_utils::ConfirmedUtxo; + let mut records = vec![1, 8]; // (1, estimated_fee) + records.extend_from_slice(&estimated_fee_sat.to_be_bytes()); + if !prevtxs.is_empty() { + let mut input_bytes = Vec::new(); + for prevtx in prevtxs { + ConfirmedUtxo::new_p2wpkh(prevtx.clone(), 0) + .expect("test prevtx output 0 must be P2WPKH") + .write(&mut input_bytes) + .expect("in-memory write must succeed"); + } + records.push(3); // (3, inputs) + BigSize(input_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); + records.extend_from_slice(&input_bytes); + } + if !outputs.is_empty() { + let mut output_bytes = Vec::new(); + for output in outputs { + output.write(&mut output_bytes).expect("in-memory write must succeed"); + } + records.push(5); // (5, outputs) + BigSize(output_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); + records.extend_from_slice(&output_bytes); + } + if let Some(change_output) = change_output { + let change_bytes = change_output.encode(); + records.push(7); // (7, change_output) + BigSize(change_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); + records.extend_from_slice(&change_bytes); + } + records.extend_from_slice(&[9, 8]); // (9, feerate) + records.extend_from_slice(&feerate.to_be_bytes()); + records.extend_from_slice(&[11, 8]); // (11, max_feerate) + records.extend_from_slice(&feerate.to_be_bytes()); + records.extend_from_slice(&[13, 1, 1]); // (13, is_splice: true) + if !inherited_inputs.is_empty() || !inherited_output_scripts.is_empty() { + // (17, pending_components): a length-prefixed TLV stream of its own. + let mut components = Vec::new(); + if !inherited_inputs.is_empty() { + let mut bytes = Vec::new(); + for outpoint in inherited_inputs { + outpoint.write(&mut bytes).expect("in-memory write must succeed"); + } + components.push(1); // (1, inputs) + BigSize(bytes.len() as u64) + .write(&mut components) + .expect("in-memory write must succeed"); + components.extend(bytes); + } + if !inherited_output_scripts.is_empty() { + let mut bytes = Vec::new(); + for script in inherited_output_scripts { + script.write(&mut bytes).expect("in-memory write must succeed"); + } + components.push(3); // (3, output_scripts) + BigSize(bytes.len() as u64) + .write(&mut components) + .expect("in-memory write must succeed"); + components.extend(bytes); + } + let mut component_bytes = Vec::new(); + BigSize(components.len() as u64) + .write(&mut component_bytes) + .expect("in-memory write must succeed"); + component_bytes.extend(components); + records.push(17); + BigSize(component_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); + records.extend(component_bytes); + } + let mut tlv_bytes = Vec::new(); + // BigSize length prefix over the TLV records above. + BigSize(records.len() as u64).write(&mut tlv_bytes).expect("in-memory write must succeed"); + tlv_bytes.extend(records); + lightning::util::ser::Readable::read(&mut &tlv_bytes[..]) + .expect("hand-built TLV stream must decode") +} + +/// Builds a [`FundingContribution`] for tests carrying just the required TLV records: a zero +/// estimated fee, the default feerate, and no contributed outputs. +/// +/// [`FundingContribution`]: lightning::ln::funding::FundingContribution +#[cfg(test)] +pub(crate) fn test_funding_contribution() -> lightning::ln::funding::FundingContribution { + test_funding_contribution_with_feerate(253) +} + +/// Like [`test_funding_contribution`], but with the given input-selection feerate in sat/kwu. +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_feerate( + feerate: u64, +) -> lightning::ln::funding::FundingContribution { + test_funding_contribution_with_outputs(0, feerate, &[]) +} + +/// Like [`test_funding_contribution`], but with the given input-selection feerate in sat/kwu and +/// an input spending output 0 — which must be P2WPKH — of each given previous transaction. +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_inputs( + feerate: u64, prevtxs: &[bitcoin::Transaction], +) -> FundingContribution { + test_funding_contribution_with_parts(0, feerate, prevtxs, &[], None) +} + #[cfg(test)] mod tests { use bitcoin::hashes::Hash; + use lightning::util::ser::{Readable, Writeable}; use super::*; use crate::payment::store::ConfirmationStatus; @@ -163,16 +572,23 @@ mod tests { // original and RBF candidates. let counterparty_txid = Txid::from_byte_array([4u8; 32]); let candidates = vec![ - FundingTxCandidate { txid: counterparty_txid, amount_msat: None, fee_paid_msat: None }, + FundingTxCandidate { + txid: counterparty_txid, + amount_msat: None, + fee_paid_msat: None, + awaiting_broadcast: false, + }, FundingTxCandidate { txid: first_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(1_000), + awaiting_broadcast: false, }, FundingTxCandidate { txid: rbf_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(5_000), + awaiting_broadcast: false, }, ]; @@ -240,84 +656,228 @@ mod tests { assert!(pending_payment.update(update)); assert_eq!( - pending_payment.conflicting_txids, + pending_payment.conflicting_txids(), Vec::::new(), "current txid must not remain in its own conflict list" ); } - #[test] - fn funding_classification_pending_update_preserves_mirrored_confirmation() { - use bitcoin::BlockHash; + 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 }, + } + } - use crate::payment::store::PaymentDetailsUpdate; + #[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()), + ); - let txid = test_txid(7); - let payment_id = PaymentId(txid.to_byte_array()); + // 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)); + } - // A pending entry wallet sync has already mirrored a confirmation into (via - // `apply_funding_status_update_locked`) before classification ran. - let confirmed_details = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Confirmed { - block_hash: BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, - }, - tx_type: None, + #[test] + fn splice_kind_round_trips() { + for kind in [ + SpliceKind::In { amount_sats: 500_000 }, + SpliceKind::Out { + outputs: vec![TxOut { + value: bitcoin::Amount::from_sat(400_000), + script_pubkey: bitcoin::ScriptBuf::new(), + }], }, - Some(2_000_000), - Some(999), - PaymentDirection::Outbound, - PaymentStatus::Pending, + SpliceKind::Rbf {}, + ] { + let encoded = kind.encode(); + let decoded = SpliceKind::read(&mut &encoded[..]).unwrap(); + assert_eq!(kind, decoded); + } + } + + #[test] + fn pending_splice_round_trips() { + use std::str::FromStr; + + let id = PaymentId([10u8; 32]); + let intent = SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution: test_funding_contribution(), + kind: SpliceKind::In { amount_sats: 500_000 }, + }; + let record = PendingPaymentDetails::pending_splice(id, intent); + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + assert_eq!(decoded.id(), id); + assert!(decoded.details().is_none()); + } + + /// A fee bump's contribution inherits the inputs and change of the round it replaces, which + /// LDK records in the contribution at the hand-off so that `reserved_inputs` and + /// `reserved_outputs` leave them out: what a failure of the bump releases, and what a retry + /// must reserve again. That record is a private field the contribution's `PartialEq` ignores, + /// so a persisted intent's round trip is checked through those accessors. + #[test] + fn pending_splice_keeps_the_contribution_reserved_parts() { + use std::str::FromStr; + + use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, WPubkeyHash}; + + let prevtx = |seed: u8| Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + }; + let prevtxs = [prevtx(1), prevtx(2)]; + let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 }; + let script = |seed: u8| ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])); + let change = TxOut { value: Amount::from_sat(21_000), script_pubkey: script(9) }; + let splice_out = TxOut { value: Amount::from_sat(50_000), script_pubkey: script(8) }; + let reserved = |contribution: &FundingContribution| { + ( + contribution.reserved_inputs().map(|input| input.outpoint()).collect::>(), + contribution.reserved_outputs().cloned().collect::>(), + ) + }; + + // Without the record, every part counts as reserved. + let plain = test_funding_contribution_with_parts( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change), ); - let mirrored = PendingPaymentDetails::new(confirmed_details, Vec::new(), Vec::new()); - - // A fresh classification is always Unconfirmed and carries the candidate history; its - // figures are the active candidate's. - let fresh = pending_onchain_payment(payment_id, txid); - let candidates = vec![FundingTxCandidate { - txid, - amount_msat: fresh.amount_msat, - fee_paid_msat: fresh.fee_paid_msat, - }]; - - // The old fresh-insert path merged the full fresh record, downgrading the mirrored - // confirmation. - let mut downgraded = mirrored.clone(); - let full_update = - PendingPaymentDetails::new(fresh.clone(), Vec::new(), candidates.clone()).to_update(); - assert!(downgraded.update(full_update)); - assert!( - matches!( - downgraded.details.kind, - PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } - ), - "a full merge of a fresh classification downgrades a mirrored confirmation", + assert_eq!( + reserved(&plain), + (prevtxs.iter().map(outpoint).collect(), vec![splice_out.clone(), change.clone()]) ); - // The narrow classification update merges the candidates while preserving the - // confirmation state wallet sync owns. It names the confirmed txid, so its - // contribution-derived figures replace the mirrored wallet-view ones. - let mut merged = mirrored.clone(); - let narrow_update = PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: Some(PaymentDetailsUpdate::funding_reclassification(fresh)), - conflicting_txids: None, - candidates: candidates.clone(), + // The bump reuses the first input and the change address of the round it replaces; the + // second input and the splice-out output are its own. + let contribution = test_funding_contribution_inheriting( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change), + &[outpoint(&prevtxs[0])], + &[change.script_pubkey.clone()], + ); + let expected = (vec![outpoint(&prevtxs[1])], vec![splice_out]); + assert_eq!(reserved(&contribution), expected); + + let intent = SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution, + kind: SpliceKind::Rbf {}, }; - assert!(merged.update(narrow_update)); - assert!( - matches!( - merged.details.kind, - PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } - ), - "a narrow classification update must not downgrade a mirrored confirmation", + let record = PendingPaymentDetails::pending_splice(PaymentId([10u8; 32]), intent); + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + let intent = decoded.splice_intent.expect("a pending splice decoded without its intent"); + assert_eq!(reserved(&intent.contribution), expected); + } + + #[test] + fn tracked_payment_round_trips() { + // An entry without a payment record round-trips in `pending_splice_round_trips`; here we + // cover one carrying the record and its candidate history. + let payment_id = PaymentId([7u8; 32]); + let txid = Txid::from_byte_array([8u8; 32]); + let record = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, txid), + vec![Txid::from_byte_array([9u8; 32])], + vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000), + fee_paid_msat: Some(100), + awaiting_broadcast: false, + }], ); - assert_eq!(merged.candidates, candidates); - assert_eq!(merged.details.amount_msat, Some(1_000)); - assert_eq!(merged.details.fee_paid_msat, Some(100)); + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + assert_eq!(decoded.id(), payment_id); + assert!(decoded.details().is_some()); + } + + /// A candidate with the given txid byte, with a stake of ours in it if `ours`. + fn candidate(txid_byte: u8, ours: bool) -> FundingTxCandidate { + FundingTxCandidate { + txid: test_txid(txid_byte), + amount_msat: ours.then_some(1_000), + fee_paid_msat: ours.then_some(100), + awaiting_broadcast: false, + } + } + + fn entry(candidates: Vec) -> PendingPaymentDetails { + let payment_id = PaymentId([1u8; 32]); + let txid = candidates.last().expect("at least one candidate").txid; + PendingPaymentDetails::new(pending_onchain_payment(payment_id, txid), vec![], candidates) + } + + /// The rounds LDK promoted round-trip with the entry, absent or present, and the merge of a + /// record's full update, as wallet sync writes it, leaves them. + #[test] + fn locked_rounds_round_trip_and_survive_a_merge() { + let mut stored = entry(vec![candidate(2, false)]); + let decoded: PendingPaymentDetails = + Readable::read(&mut &stored.encode()[..]).expect("encoding must round-trip"); + assert!(decoded.locked_rounds().is_empty()); + + assert!(stored.record_locked_round(test_txid(2))); + assert!(!stored.record_locked_round(test_txid(2))); + let decoded: PendingPaymentDetails = + Readable::read(&mut &stored.encode()[..]).expect("encoding must round-trip"); + assert_eq!(decoded, stored); + + let synced = entry(vec![candidate(2, false), candidate(3, false)]); + assert!(stored.update(synced.to_update())); + assert_eq!(stored.candidates().len(), 2); + assert_eq!(stored.locked_rounds(), &[test_txid(2)]); } } diff --git a/src/payment/store.rs b/src/payment/store.rs index 46cc57b87b..d38433fe02 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -9,7 +9,6 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; use bitcoin::{BlockHash, Txid}; -use lightning::chain::chaininterface::TransactionType as LdkTransactionType; use lightning::ln::channelmanager::PaymentId; use lightning::ln::msgs::DecodeError; use lightning::ln::types::ChannelId; @@ -283,28 +282,12 @@ impl UpdatableObject for PaymentDetails { } } - // Once an on-chain record is confirmed, its txid and figures describe the candidate that - // confirmed, which need not be the last one broadcast. An update that doesn't assert the - // confirmation state was built without knowing it — e.g. a late funding classification - // whose candidate lost to the counterparty's broadcast — so it must not move them. The - // exception is an update naming the confirmed txid itself: its figures describe the very - // candidate that confirmed and correct the wallet-view amount/fee a sync-created record - // carries, which cannot represent our contribution to a shared funding output. - let keep_confirmed_figures = update.confirmation_status.is_none() - && matches!( - self.kind, - PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } - if update.txid != Some(txid) - ); - - if !keep_confirmed_figures { - if let Some(amount_opt) = update.amount_msat { - update_if_necessary!(self.amount_msat, amount_opt); - } + if let Some(amount_opt) = update.amount_msat { + update_if_necessary!(self.amount_msat, amount_opt); + } - if let Some(fee_paid_msat_opt) = update.fee_paid_msat { - update_if_necessary!(self.fee_paid_msat, fee_paid_msat_opt); - } + if let Some(fee_paid_msat_opt) = update.fee_paid_msat { + update_if_necessary!(self.fee_paid_msat, fee_paid_msat_opt); } if let Some(skimmed_fee_msat) = update.counterparty_skimmed_fee_msat { @@ -334,7 +317,7 @@ impl UpdatableObject for PaymentDetails { if let Some(tx_id) = update.txid { match self.kind { - PaymentKind::Onchain { ref mut txid, .. } if !keep_confirmed_figures => { + PaymentKind::Onchain { ref mut txid, .. } => { update_if_necessary!(*txid, tx_id); }, _ => {}, @@ -415,12 +398,19 @@ impl_writeable_tlv_based!(Channel, { (2, channel_id, required), }); -/// The classification of a [`PaymentKind::Onchain`] transaction, as reported by LDK when the -/// transaction was broadcast. +/// The classification of a [`PaymentKind::Onchain`] transaction: what the channels of this node +/// that took part in it make the transaction out to be. /// -/// Mirrors [`lightning::chain::chaininterface::TransactionType`], retaining the channel references -/// but dropping the broadcast-time contribution data; a transaction's amount and fee are tracked on -/// the [`PaymentDetails`] itself. +/// Names the channels involved; a transaction's amount and fee are tracked on the +/// [`PaymentDetails`] itself. +/// +/// The classification is written onto the payment when the transaction is observed, and what it +/// is derived from is kept only for a bounded time after the channels that produced the +/// transaction have resolved. The node therefore stops being able to classify transactions of +/// channels it settled long ago: such a transaction, met for the first time after that point, is +/// reported as [`PaymentKind::Onchain`] with no `tx_type` at all. A payment already classified +/// keeps its classification — expiry never takes a label back, it only leaves a later one +/// unwritten. #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] pub enum TransactionType { @@ -498,58 +488,6 @@ impl_writeable_tlv_based_enum!(TransactionType, } ); -impl From for TransactionType { - fn from(tx_type: LdkTransactionType) -> Self { - let to_channels = |channels: Vec<(PublicKey, ChannelId)>| -> Vec { - channels - .into_iter() - .map(|(counterparty_node_id, channel_id)| Channel { - counterparty_node_id, - channel_id, - }) - .collect() - }; - match tx_type { - LdkTransactionType::Funding { channels } => { - TransactionType::Funding { channels: to_channels(channels) } - }, - LdkTransactionType::CooperativeClose { counterparty_node_id, channel_id } => { - TransactionType::CooperativeClose { counterparty_node_id, channel_id } - }, - LdkTransactionType::UnilateralClose { counterparty_node_id, channel_id } => { - TransactionType::UnilateralClose { counterparty_node_id, channel_id } - }, - LdkTransactionType::AnchorBump { counterparty_node_id, channel_id } => { - TransactionType::AnchorBump { counterparty_node_id, channel_id } - }, - LdkTransactionType::Claim { counterparty_node_id, channel_id } => { - TransactionType::Claim { counterparty_node_id, channel_id } - }, - LdkTransactionType::Sweep { channels } => { - TransactionType::Sweep { channels: to_channels(channels) } - }, - LdkTransactionType::InteractiveFunding { candidates } => { - // Every candidate (the original negotiation plus any RBF replacements) references - // the same channel(s); take the active (last) candidate's channel references. - let channels = candidates - .last() - .map(|candidate| { - candidate - .channels - .iter() - .map(|cf| Channel { - counterparty_node_id: cf.counterparty_node_id, - channel_id: cf.channel_id, - }) - .collect() - }) - .unwrap_or_default(); - TransactionType::InteractiveFunding { channels } - }, - } - } -} - /// Represents the kind of a payment. #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] @@ -567,8 +505,10 @@ pub enum PaymentKind { status: ConfirmationStatus, /// The classification of this transaction, if known. /// - /// `None` for plain on-chain sends, and for records written by versions of LDK Node that - /// predate on-chain transaction classification. + /// `None` for plain on-chain sends, for records written by versions of LDK Node that + /// predate on-chain transaction classification, and for a transaction of a channel that + /// resolved long enough ago for what would classify it to have expired; see + /// [`TransactionType`]. tx_type: Option, }, /// A [BOLT 11] payment. @@ -787,33 +727,6 @@ impl PaymentDetailsUpdate { tx_type: None, } } - - /// Builds an update that merges a freshly-classified funding payment's classification - /// (`tx_type`), broadcast txid, and our contribution figures (amount/fee) into an existing - /// record, while leaving the top-level [`PaymentStatus`] and the on-chain - /// [`ConfirmationStatus`] untouched. - /// - /// Funding classification runs off the broadcaster queue and can land *after* wallet sync has - /// already advanced a record's confirmation state (e.g. when the counterparty's broadcast of - /// the funding transaction is observed first). Merging only the funding-specific fields keeps - /// such a late classification from downgrading a `Confirmed`/`Succeeded` payment back to - /// `Unconfirmed`/`Pending`; the confirmation state is owned by the wallet-sync events instead. - /// - /// The txid and figures are taken from the freshly broadcast (active) candidate, so they only - /// apply while the record is unconfirmed. Once a candidate confirms, the record's txid and - /// figures describe that candidate — which need not be the one being classified (e.g. the - /// counterparty broadcast an earlier candidate and it won) — and [`PaymentDetails::update`] - /// leaves them in place for updates like this one that don't carry a confirmation state. - pub(crate) fn funding_reclassification(details: PaymentDetails) -> Self { - let mut update = Self::new(details.id); - update.amount_msat = Some(details.amount_msat); - update.fee_paid_msat = Some(details.fee_paid_msat); - if let PaymentKind::Onchain { txid, tx_type, .. } = details.kind { - update.txid = Some(txid); - update.tx_type = Some(tx_type); - } - update - } } impl From<&PaymentDetails> for PaymentDetailsUpdate { @@ -1081,418 +994,6 @@ mod tests { } } - #[test] - fn transaction_type_from_ldk_variants() { - use std::str::FromStr; - - let pubkey = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channel_id = ChannelId([5u8; 32]); - let channel = Channel { counterparty_node_id: pubkey, channel_id }; - - let variants = vec![ - ( - LdkTransactionType::Funding { channels: vec![(pubkey, channel_id)] }, - TransactionType::Funding { channels: vec![channel.clone()] }, - ), - ( - LdkTransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id }, - TransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id }, - ), - ( - LdkTransactionType::UnilateralClose { counterparty_node_id: pubkey, channel_id }, - TransactionType::UnilateralClose { counterparty_node_id: pubkey, channel_id }, - ), - ( - LdkTransactionType::AnchorBump { counterparty_node_id: pubkey, channel_id }, - TransactionType::AnchorBump { counterparty_node_id: pubkey, channel_id }, - ), - ( - LdkTransactionType::Claim { counterparty_node_id: pubkey, channel_id }, - TransactionType::Claim { counterparty_node_id: pubkey, channel_id }, - ), - ( - LdkTransactionType::Sweep { channels: vec![(pubkey, channel_id)] }, - TransactionType::Sweep { channels: vec![channel] }, - ), - ]; - - for (ldk_type, expected_type) in variants { - assert_eq!(TransactionType::from(ldk_type), expected_type); - } - } - - #[test] - fn funding_reclassification_does_not_downgrade_an_advanced_record() { - use std::str::FromStr; - - use bitcoin::hashes::Hash; - - // A splice funding payment wallet sync has already advanced to Succeeded/Confirmed. - let txid = Txid::from_byte_array([7u8; 32]); - let id = PaymentId(txid.to_byte_array()); - let tx_type = Some(TransactionType::InteractiveFunding { - channels: vec![Channel { - counterparty_node_id: PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(), - channel_id: ChannelId([3u8; 32]), - }], - }); - let advanced = PaymentDetails::new( - id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Confirmed { - block_hash: BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, - }, - tx_type: tx_type.clone(), - }, - Some(2_000_000), - Some(999), - PaymentDirection::Outbound, - PaymentStatus::Succeeded, - ); - - // A fresh funding classification for the same payment is always Pending/Unconfirmed. - let fresh = PaymentDetails::new( - id, - PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type }, - Some(1_000_000), - Some(500), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - - // The naive full update `insert_or_update` applied before the fix downgrades both the - // top-level status and the on-chain confirmation status — the bug Codex flagged. - let mut downgraded = advanced.clone(); - downgraded.update((&fresh).into()); - assert_eq!( - downgraded.status, - PaymentStatus::Pending, - "a full update from a fresh classification downgrades the top-level status", - ); - assert!( - matches!( - downgraded.kind, - PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } - ), - "a full update from a fresh classification downgrades the confirmation status", - ); - - // The narrowed reclassification update merges only the funding fields and preserves the - // advanced confirmation state that wallet sync owns. - let mut merged = advanced.clone(); - merged.update(PaymentDetailsUpdate::funding_reclassification(fresh)); - assert_eq!( - merged.status, - PaymentStatus::Succeeded, - "reclassification must not downgrade the top-level status", - ); - assert!( - matches!( - merged.kind, - PaymentKind::Onchain { - status: ConfirmationStatus::Confirmed { .. }, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } - ), - "reclassification must preserve the confirmation status and keep the funding tx_type", - ); - // The late classification names the confirmed txid, so its contribution-derived figures - // replace the record's; only an update for a different candidate leaves them in place - // (covered by `funding_reclassification_keeps_confirmed_candidate_figures`). - assert_eq!(merged.amount_msat, Some(1_000_000)); - assert_eq!(merged.fee_paid_msat, Some(500)); - } - - #[test] - fn funding_reclassification_keeps_confirmed_candidate_figures() { - use std::str::FromStr; - - use bitcoin::hashes::Hash; - - // A funding payment whose first candidate wallet sync has already seen confirm — e.g. the - // counterparty's broadcast of it was picked up before our own later candidate was - // classified. The record is unclassified (created by the sync fallthrough). - let confirmed_txid = Txid::from_byte_array([7u8; 32]); - let id = PaymentId(confirmed_txid.to_byte_array()); - let confirmed = PaymentDetails::new( - id, - PaymentKind::Onchain { - txid: confirmed_txid, - status: ConfirmationStatus::Confirmed { - block_hash: BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, - }, - tx_type: None, - }, - Some(2_000_000), - Some(999), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - - // Our own, different (e.g. fee-bumped) candidate is classified late. - let late_txid = Txid::from_byte_array([9u8; 32]); - let tx_type = Some(TransactionType::InteractiveFunding { - channels: vec![Channel { - counterparty_node_id: PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(), - channel_id: ChannelId([3u8; 32]), - }], - }); - let late = PaymentDetails::new( - id, - PaymentKind::Onchain { - txid: late_txid, - status: ConfirmationStatus::Unconfirmed, - tx_type, - }, - Some(1_000_000), - Some(500), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - - // The confirmed record's txid and figures describe the candidate that confirmed; the late - // classification must not replace them with an unconfirmed candidate's. The - // classification itself (`tx_type`) still lands. - let mut classified = confirmed.clone(); - classified.update(PaymentDetailsUpdate::funding_reclassification(late.clone())); - assert!( - matches!( - classified.kind, - PaymentKind::Onchain { - txid, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } if txid == confirmed_txid - ), - "a late classification must set the tx_type but not replace a confirmed record's txid", - ); - assert_eq!(classified.amount_msat, Some(2_000_000)); - assert_eq!(classified.fee_paid_msat, Some(999)); - - // While the record is still unconfirmed, the freshly broadcast candidate is the active - // one, so its txid and figures do replace the stored ones (RBF rotation). - let mut unconfirmed = confirmed.clone(); - if let PaymentKind::Onchain { ref mut status, .. } = unconfirmed.kind { - *status = ConfirmationStatus::Unconfirmed; - } - unconfirmed.update(PaymentDetailsUpdate::funding_reclassification(late)); - assert!( - matches!(unconfirmed.kind, PaymentKind::Onchain { txid, .. } if txid == late_txid), - "classifying a new candidate of an unconfirmed record rotates the txid", - ); - assert_eq!(unconfirmed.amount_msat, Some(1_000_000)); - assert_eq!(unconfirmed.fee_paid_msat, Some(500)); - } - - #[test] - fn funding_reclassification_merges_figures_for_the_confirmed_candidate() { - use std::str::FromStr; - - use bitcoin::hashes::Hash; - - // Wallet sync confirmed the transaction before classification ran, so the record carries - // the wallet's own view of amount/fee, which cannot represent our contribution to a shared - // funding output. - let confirmed_txid = Txid::from_byte_array([7u8; 32]); - let id = PaymentId(confirmed_txid.to_byte_array()); - let mut record = PaymentDetails::new( - id, - PaymentKind::Onchain { - txid: confirmed_txid, - status: ConfirmationStatus::Confirmed { - block_hash: BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, - }, - tx_type: None, - }, - Some(2_000_000), - Some(999), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - - // The late classification names the candidate that confirmed, so its contribution-derived - // figures are authoritative and must replace the wallet-view ones; only an update for a - // different (losing) candidate leaves a confirmed record's figures in place. - let tx_type = Some(TransactionType::InteractiveFunding { - channels: vec![Channel { - counterparty_node_id: PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(), - channel_id: ChannelId([3u8; 32]), - }], - }); - let classified = PaymentDetails::new( - id, - PaymentKind::Onchain { - txid: confirmed_txid, - status: ConfirmationStatus::Unconfirmed, - tx_type, - }, - Some(1_000_000), - Some(500), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - - assert!(record.update(PaymentDetailsUpdate::funding_reclassification(classified))); - assert!( - matches!( - record.kind, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Confirmed { .. }, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - } if txid == confirmed_txid - ), - "the confirmed txid, confirmation state, and classification must all be in place", - ); - assert_eq!(record.amount_msat, Some(1_000_000)); - assert_eq!(record.fee_paid_msat, Some(500)); - } - - #[tokio::test] - async fn funding_classification_merge_preserves_advanced_record() { - use std::str::FromStr; - use std::sync::Arc; - - use bitcoin::hashes::Hash; - use lightning::util::test_utils::TestLogger; - - use crate::data_store::{DataStore, KeepAllEntries}; - use crate::io::test_utils::InMemoryStore; - use crate::types::{DynStore, DynStoreWrapper}; - - let txid = Txid::from_byte_array([7u8; 32]); - let id = PaymentId(txid.to_byte_array()); - let tx_type = Some(TransactionType::InteractiveFunding { - channels: vec![Channel { - counterparty_node_id: PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(), - channel_id: ChannelId([3u8; 32]), - }], - }); - // A funding payment wallet sync has already recorded (unclassified, via the default - // on-chain path) and advanced to Succeeded/Confirmed. - let advanced = PaymentDetails::new( - id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Confirmed { - block_hash: BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, - }, - tx_type: None, - }, - Some(2_000_000), - Some(999), - PaymentDirection::Outbound, - PaymentStatus::Succeeded, - ); - // A fresh funding classification for the same payment is always Pending/Unconfirmed. - let fresh = PaymentDetails::new( - id, - PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type }, - Some(1_000_000), - Some(500), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - - let new_store = |seed: Vec| { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let logger = Arc::new(TestLogger::new()); - DataStore::>::new( - seed, - KeepAllEntries, - "payment_test_primary".to_string(), - "payment_test_secondary".to_string(), - store, - logger, - ) - }; - - // The pre-fix fresh-insert path — a full `insert_or_update` merge landing after a racing - // wallet sync already advanced the record — downgrades it. - let store = new_store(vec![advanced.clone()]); - store.insert_or_update(fresh.clone()).await.unwrap(); - let downgraded = store.get(&id).await.unwrap().unwrap(); - assert_eq!( - downgraded.status, - PaymentStatus::Pending, - "a full merge of a fresh classification downgrades an advanced record", - ); - - // Classification instead applies only the narrow reclassification when a record exists — - // no matter when it appeared — setting the `tx_type` while preserving the confirmation - // state wallet sync owns. The update names the confirmed txid, so its - // contribution-derived figures replace the record's wallet-view ones. - let store = new_store(vec![advanced.clone()]); - let update = PaymentDetailsUpdate::funding_reclassification(fresh.clone()); - let written = store - .mutate(&id, |existing| match existing { - Some(current) => { - let mut updated = current.clone(); - updated.update(update).then_some(updated) - }, - None => Some(fresh.clone()), - }) - .await; - assert!(matches!(written, Ok(Some(_))), "the reclassification must merge"); - let merged = store.get(&id).await.unwrap().unwrap(); - assert_eq!(merged.status, PaymentStatus::Succeeded); - assert!(matches!( - merged.kind, - PaymentKind::Onchain { - status: ConfirmationStatus::Confirmed { .. }, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } - )); - assert_eq!(merged.amount_msat, Some(1_000_000)); - assert_eq!(merged.fee_paid_msat, Some(500)); - - // And it inserts the fresh details when no record exists yet. - let store = new_store(Vec::new()); - let update = PaymentDetailsUpdate::funding_reclassification(fresh.clone()); - let written = store - .mutate(&id, |existing| match existing { - Some(current) => { - let mut updated = current.clone(); - updated.update(update).then_some(updated) - }, - None => Some(fresh.clone()), - }) - .await; - assert!(matches!(written, Ok(Some(_))), "the fresh details must insert"); - let inserted = store.get(&id).await.unwrap().unwrap(); - assert_eq!(inserted.status, PaymentStatus::Pending); - assert!(matches!( - inserted.kind, - PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } - )); - } - #[derive(Clone, Debug, PartialEq, Eq)] struct LegacyBolt11JitKind { hash: PaymentHash, diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 782112dadb..2f5f6d973a 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -5,47 +5,68 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +use std::collections::VecDeque; use std::ops::Deref; -use std::sync::{Mutex as StdMutex, Weak}; +use std::sync::Mutex as StdMutex; -use bitcoin::Transaction; -use lightning::chain::chaininterface::{ - BroadcasterInterface, TransactionType as LdkTransactionType, -}; -use tokio::sync::{mpsc, Mutex, MutexGuard}; +use bitcoin::{Transaction, Txid}; +use lightning::chain::chaininterface::BroadcasterInterface; +use tokio::sync::Notify; -use crate::logger::{log_error, LdkLogger}; -use crate::types::Wallet; -use crate::Error; +use crate::logger::{log_trace, LdkLogger}; -const BCAST_PACKAGE_QUEUE_SIZE: usize = 256; - -/// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions` -/// call, along with each transaction's type. Queued until the background task classifies and -/// broadcasts it. Built only via [`BroadcastPackage::new`] from such a call, so unrelated -/// transactions can't be grouped into one package by accident. -pub(crate) struct BroadcastPackage(Vec<(Transaction, Option)>); +/// A package of transactions to broadcast together: everything LDK handed over in one +/// `broadcast_transactions` call, or a single transaction the wallet broadcasts itself. Queued +/// until the background task sends it. Built only from one such source, so unrelated transactions +/// can't be grouped into one package by accident. +pub(crate) struct BroadcastPackage(Vec); impl BroadcastPackage { - /// Builds a package from the transactions of a single `broadcast_transactions` call. - fn new(txs: &[(&Transaction, LdkTransactionType)]) -> Self { - Self(txs.iter().map(|(tx, tx_type)| ((*tx).clone(), Some(tx_type.clone()))).collect()) + /// The txids of the packaged transactions, identifying the package's effect on chain. + fn txids(&self) -> Vec { + self.0.iter().map(Transaction::compute_txid).collect() } - /// Builds a package for wallet-originated broadcasts that have no LDK classification. - fn unclassified(tx: Transaction) -> Self { - Self(vec![(tx, None)]) + /// Consumes the package into its transactions, ready for the chain client. + pub(crate) fn into_sorted_transactions(self) -> SortedTransactions { + SortedTransactions::sort_parents_child_package_topologically(self.0) } +} - /// The packaged transactions and their types, for classification. - fn transactions(&self) -> &[(Transaction, Option)] { - &self.0 +/// The packages handed to the broadcaster, waiting in arrival order for the background task to +/// send them. +/// +/// The queue belongs to the broadcaster and outlives the task draining it: what is queued when the +/// node stops is broadcast after the next start. +pub(crate) struct BroadcastQueue { + packages: StdMutex>, + /// Wakes the draining task when a package is queued. + notify: Notify, +} + +impl BroadcastQueue { + pub(crate) fn new() -> Self { + Self { packages: StdMutex::new(VecDeque::new()), notify: Notify::new() } } - /// Consumes the package into its transactions, ready for the chain client. - pub(crate) fn into_sorted_transactions(self) -> SortedTransactions { - let txs = self.0.into_iter().map(|(tx, _)| tx).collect(); - SortedTransactions::sort_parents_child_package_topologically(txs) + /// Queues a package to broadcast. + pub(crate) fn push(&self, package: BroadcastPackage) { + self.packages.lock().expect("lock").push_back(package); + self.notify.notify_one(); + } + + /// The next package to broadcast, waiting for one while the queue is empty. + /// + /// Safe to drop before completion: a package leaves the queue only as the future completes. + pub(crate) async fn next(&self) -> BroadcastPackage { + loop { + if let Some(package) = self.packages.lock().expect("lock").pop_front() { + return package; + } + // A package queued between the check above and the wait below is not missed: with + // no task waiting, `notify_one` stores a permit that completes the next `notified`. + self.notify.notified().await; + } } } @@ -96,13 +117,7 @@ pub(crate) struct TransactionBroadcaster where L::Target: LdkLogger, { - queue_sender: mpsc::Sender, - queue_receiver: Mutex>, - /// Weak handle to the [`Wallet`] that classifies funding broadcasts (channel opens and - /// splices) into payment records. Remains `None` while the builder is wiring the node up, - /// during which broadcasts are forwarded to the queue but no payment record is written. - /// [`Self::set_wallet`] installs the handle once the [`Wallet`] exists. - wallet: StdMutex>>, + queue: BroadcastQueue, logger: L, } @@ -111,49 +126,22 @@ where L::Target: LdkLogger, { pub(crate) fn new(logger: L) -> Self { - let (queue_sender, queue_receiver) = mpsc::channel(BCAST_PACKAGE_QUEUE_SIZE); - Self { - queue_sender, - queue_receiver: Mutex::new(queue_receiver), - wallet: StdMutex::new(None), - logger, - } + Self { queue: BroadcastQueue::new(), logger } } - /// Installs the [`Wallet`] handle used to classify funding broadcasts (channel opens and - /// splices) into payment records. Called once the builder has constructed both the - /// broadcaster and the wallet. - pub(crate) fn set_wallet(&self, wallet: Weak) { - *self.wallet.lock().expect("lock") = Some(wallet); + /// The next queued package to broadcast, waiting for one when none is queued. + pub(crate) async fn next_package(&self) -> BroadcastPackage { + self.queue.next().await } - pub(crate) async fn get_broadcast_queue( - &self, - ) -> MutexGuard<'_, mpsc::Receiver> { - self.queue_receiver.lock().await + /// Queues a transaction the wallet broadcasts on its own behalf. + pub(crate) fn broadcast(&self, tx: Transaction) { + self.queue_package(BroadcastPackage(vec![tx])); } - /// Classifies a queued package into payment records and returns the package ready for the - /// chain client. Returns `Err` if any classification fails; callers must not broadcast the - /// package in that case, since a crash would leave the transaction on-chain without a record. - pub(crate) async fn classify_package( - &self, package: BroadcastPackage, - ) -> Result { - let wallet_opt = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade); - if let Some(wallet) = wallet_opt { - for (tx, tx_type) in package.transactions() { - if let Some(tx_type) = tx_type { - wallet.classify_broadcast(tx, tx_type).await?; - } - } - } - Ok(package) - } - - pub(crate) fn broadcast_unclassified_transaction(&self, tx: Transaction) { - self.queue_sender.try_send(BroadcastPackage::unclassified(tx)).unwrap_or_else(|e| { - log_error!(self.logger, "Failed to broadcast transactions: {}", e); - }); + fn queue_package(&self, package: BroadcastPackage) { + log_trace!(self.logger, "Queuing package for broadcast: {:?}", package.txids()); + self.queue.push(package); } } @@ -161,10 +149,8 @@ impl BroadcasterInterface for TransactionBroadcaster where L::Target: LdkLogger, { - fn broadcast_transactions(&self, txs: &[(&Transaction, LdkTransactionType)]) { - self.queue_sender.try_send(BroadcastPackage::new(txs)).unwrap_or_else(|e| { - log_error!(self.logger, "Failed to broadcast transactions: {}", e); - }); + fn broadcast_transactions(&self, txs: &[&Transaction]) { + self.queue_package(BroadcastPackage(txs.iter().map(|tx| (*tx).clone()).collect())); } } @@ -173,7 +159,7 @@ mod tests { use bitcoin::hashes::Hash; use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; - use super::SortedTransactions; + use super::{BroadcastPackage, BroadcastQueue, SortedTransactions}; fn txin(txid: Txid, vout: u32) -> TxIn { TxIn { @@ -314,4 +300,56 @@ mod tests { fn topological_sort_accepts_empty_vec() { SortedTransactions::sort_parents_child_package_topologically(Vec::new()); } + + /// Everything `next` hands out before the queue goes quiet, in order. + async fn drain(queue: &BroadcastQueue) -> Vec { + let mut txids = Vec::new(); + while let Ok(package) = + tokio::time::timeout(std::time::Duration::from_millis(200), queue.next()).await + { + txids.extend(package.into_sorted_transactions().iter().map(Transaction::compute_txid)); + } + txids + } + + /// Every queued package is handed out, in arrival order, however often the same transaction + /// arrives. + #[tokio::test] + async fn packages_are_handed_out_in_arrival_order() { + let (tx_a, tx_b) = (parent_tx(1), parent_tx(2)); + let queue = BroadcastQueue::new(); + + queue.push(BroadcastPackage(vec![tx_a.clone()])); + queue.push(BroadcastPackage(vec![tx_b.clone()])); + queue.push(BroadcastPackage(vec![tx_a.clone()])); + + assert_eq!( + drain(&queue).await, + vec![tx_a.compute_txid(), tx_b.compute_txid(), tx_a.compute_txid()] + ); + } + + /// `next` waits for a package when none is queued and wakes when one is pushed. + #[tokio::test] + async fn next_wakes_on_a_push() { + let tx = parent_tx(1); + let queue = BroadcastQueue::new(); + + assert!(tokio::time::timeout(std::time::Duration::from_millis(100), queue.next()) + .await + .is_err()); + + let (_, next) = tokio::join!( + async { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + queue.push(BroadcastPackage(vec![tx.clone()])); + }, + tokio::time::timeout(std::time::Duration::from_secs(5), queue.next()), + ); + let handed_out = next.expect("woken by the push").into_sorted_transactions(); + assert_eq!( + handed_out.iter().map(Transaction::compute_txid).collect::>(), + vec![tx.compute_txid()], + ); + } } diff --git a/src/types.rs b/src/types.rs index fd86d1bcd8..26049e8a92 100644 --- a/src/types.rs +++ b/src/types.rs @@ -46,6 +46,7 @@ use crate::payment::{ ChannelPairForwardingStats, ForwardedPaymentDetails, PaymentDetails, PendingPaymentDetails, }; use crate::runtime::RuntimeSpawner; +use crate::wallet::provenance::ChannelTxFacts; #[cfg(feature = "uniffi")] type ChannelTypeFeatures = Arc; @@ -341,6 +342,7 @@ pub(crate) type ChannelForwardingStatsStore = DataStore>; pub(crate) type ChannelPairForwardingStatsStore = DataStore, KeepNoEntries>; +pub(crate) type ChannelTxFactsStore = DataStore, KeepLeastRecentlyUsed>; /// A local, potentially user-provided, identifier of a channel. /// diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 13a8ef4e00..0190152c85 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -5,13 +5,14 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::future::Future; use std::ops::Deref; use std::str::FromStr; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; +use bdk_chain::ChainPosition; use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; #[allow(deprecated)] @@ -31,13 +32,13 @@ use bitcoin::{ Address, Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, Transaction, TxOut, Txid, WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; -use lightning::chain::chaininterface::{ - FundingCandidate, TransactionType as LdkTransactionType, - INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, -}; +use lightning::chain::chaininterface::INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::chain::{BlockLocator, ClaimId, Listen}; +use lightning::ln::channel_state::{SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails}; use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::FundingContribution; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; use lightning::ln::script::ShutdownScript; @@ -51,22 +52,31 @@ use lightning::util::wallet_utils::{ CoinSelection, CoinSelectionSource, ConfirmedUtxo, Input, Utxo, WalletSource, }; use lightning_invoice::RawBolt11Invoice; +use payment_stores::{PaymentStores, PaymentStoresGuard}; use persist::KVStoreWalletPersister; -use crate::config::{Config, ADDRESS_POOL_SIZE}; -use crate::data_store::UpdatableObject; +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, +}; #[cfg(test)] use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed}; +use crate::data_store::{StorableObject, UpdatableObject}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; -use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; -use crate::payment::pending_payment_store::PendingPaymentDetailsUpdate; -use crate::payment::store::{ConfirmationStatus, PaymentDetailsUpdate}; +use crate::logger::{log_debug, log_error, log_info, log_trace, log_warn, LdkLogger, Logger}; +use crate::payment::pending_payment_store::SpliceIntent; +use crate::payment::store::{Channel, ConfirmationStatus, PaymentDetailsUpdate}; use crate::payment::{ FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, PendingPaymentDetails, TransactionType, }; use crate::runtime::Runtime; -use crate::types::{Broadcaster, PaymentStore, PendingPaymentStore}; +use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; +use crate::wallet::provenance::{ + ChannelLiveness, ChannelTxFacts, ChannelTxFactsRejection, FactsAdmission, FactsRecordOutcome, + FactsRetention, LocalFundingFigures, RetentionCheck, TxProvenance, +}; use crate::{ChainSource, Error}; pub(crate) enum OnchainSendAmount { @@ -80,7 +90,23 @@ pub(crate) enum FundingAmount { Max, } +/// What recording an interactive funding round this node is about to sign came to, as it decides +/// whether the round may be signed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SignedFundingRecord { + /// This node's share of the round is on record, or the round needs nothing recorded. The + /// round may be signed. + Recorded, + /// This node's share of the round is not on record, and nothing this node does later puts it + /// there. Signing would release a transaction whoever first observes it records with the + /// wallet's view of a funding output both parties own — the whole of it read as this node's + /// spend, a figure nothing later corrects — so the round is cancelled instead. + Unmeasurable, +} + +mod payment_stores; pub(crate) mod persist; +pub(crate) mod provenance; pub(crate) mod ser; const DUST_LIMIT_SATS: u64 = 546; @@ -154,22 +180,19 @@ pub(crate) struct Wallet { broadcaster: Arc, fee_estimator: Arc, chain_source: Arc, - payment_store: Arc, runtime: Arc, config: Arc, logger: Arc, - pending_payment_store: Arc, - // Serializes the writers that must observe the payment record and its pending-store entry - // (candidate history included) as one consistent unit: classification holds it across its - // two-store write pair, and wallet sync's event arms hold it from payment-id resolution - // through their last write. Without it, a confirmation landing between classification's two - // writes sees the record classified but the candidate history absent — resolving the wrong - // payment id or stamping the confirmed candidate with another candidate's figures — and a - // classification landing inside an arm's decision sequence gets overwritten by the arm's - // stale generic fallback. Graduation stays off this lock: it decides from the live record - // under the payment store's mutation lock and writes only the status, so it carries nothing - // a concurrent classification could lose. - funding_payment_update_lock: tokio::sync::Mutex<()>, + // The wallet's payment stores; see the type for the lock serializing their writers. + payment_stores: PaymentStores, + // What this node's channels reported about the transactions they produced, keyed by + // transaction id. + channel_tx_facts_store: Arc, + // Where to ask which channels the node still holds on-chain state for, set once that state + // exists. Recorded facts are kept while it is unset. + channel_liveness: OnceLock>, + // How far the dropping of recorded facts has walked the store, and how many records it holds. + facts_retention: FactsRetention, } impl Wallet { @@ -179,6 +202,7 @@ impl Wallet { broadcaster: Arc, fee_estimator: Arc, chain_source: Arc, payment_store: Arc, runtime: Arc, config: Arc, logger: Arc, pending_payment_store: Arc, + channel_tx_facts_store: Arc, ) -> Self { let address_pool = Mutex::new(AddressPool::new(persisted_pool_indices, &wallet, &logger)); let inner = Mutex::new(wallet); @@ -192,12 +216,150 @@ impl Wallet { broadcaster, fee_estimator, chain_source, - payment_store, runtime, config, logger, - pending_payment_store, - funding_payment_update_lock: tokio::sync::Mutex::new(()), + payment_stores: PaymentStores::new(payment_store, pending_payment_store), + channel_tx_facts_store, + channel_liveness: OnceLock::new(), + facts_retention: FactsRetention::new(), + } + } + + /// Tells the wallet where to ask which channels the node still holds on-chain state for, so + /// that the facts recorded for a channel can be dropped once nothing holds it anymore. + /// + /// The node's channel state is built on top of the wallet, so it can only be handed over + /// afterwards; until it is, no recorded fact is dropped. + pub(crate) fn set_channel_liveness(&self, liveness: Arc) { + if self.channel_liveness.set(liveness).is_err() { + debug_assert!(false, "The wallet is told where to find the node's channels once"); + } + } + + /// Records what a producer reported about the transaction `facts` describes, merging it into + /// whatever this node already knows about that transaction. + /// + /// Re-recording facts already known writes nothing, so a producer may safely replay its + /// event. Facts that contradict what is recorded are rejected and logged rather than + /// overwriting it: one of the two producers is wrong, and the recorded facts came first. + /// + /// A report the store has no room for is likewise refused, and reported as + /// [`FactsRecordOutcome::Incomplete`] rather than as a failure: nothing was lost, and what + /// the refusal costs the reporting producer is a transaction this node cannot say anything + /// about. Only what this node has no record of at all is refused that way — a transaction it + /// already describes goes on being described, however full the store is. + pub(crate) async fn record_channel_tx_facts( + &self, facts: ChannelTxFacts, + ) -> Result { + self.record_channel_tx_facts_admitted(facts, FactsAdmission::Capped).await + } + + /// Records what a producer reported, as [`Self::record_channel_tx_facts`] does, with + /// `admission` deciding whether the number of records the store may hold applies to a + /// transaction it holds no record of at all. + async fn record_channel_tx_facts_admitted( + &self, facts: ChannelTxFacts, admission: FactsAdmission, + ) -> Result { + let txid = facts.txid; + // Dated by the chain tip the report arrives at, which is what retention measures from. + let facts = facts.reported_at_height(self.latest_checkpoint_height()); + // The rejection is reported out of the closure rather than through it, so that the read, + // the merge and the write stay one critical section of the store's mutation lock. + let mut rejection = None; + let mut created = false; + self.channel_tx_facts_store + .mutate(&txid, |current| match current { + Some(recorded) => match recorded.clone().merged_with(&facts) { + Ok(merged) => merged, + Err(e) => { + rejection = Some(e); + None + }, + }, + // A transaction nothing is recorded of yet needs room of its own, unless the + // producer is exempt from the cap; one already on record is merged into above + // however full the store is, so an obligation this node took on is never + // half-kept. + None if admission == FactsAdmission::Capped && !self.facts_retention.has_room() => { + rejection = Some(ChannelTxFactsRejection::NoRoom { + limit: CHANNEL_TX_FACTS_MAX_RECORDS, + }); + None + }, + None => match facts.clone().size_checked() { + Ok(checked) => { + created = true; + Some(checked) + }, + Err(e) => { + rejection = Some(e); + None + }, + }, + }) + .await?; + if created { + self.facts_retention.record_created(); + } + + match rejection { + Some(e) if e.is_resource_limit() => { + log_error!(self.logger, "Not recording what transaction {} is: {}", txid, e,); + Ok(FactsRecordOutcome::Incomplete) + }, + Some(e) => { + log_error!( + self.logger, + "Rejected facts contradicting what is recorded for transaction {}: {}", + txid, + e, + ); + Err(Error::PersistenceFailed) + }, + None => Ok(FactsRecordOutcome::Recorded), + } + } + + /// The height of the chain tip the wallet has seen. + fn latest_checkpoint_height(&self) -> u32 { + self.inner.lock().expect("lock").latest_checkpoint().height() + } + + /// Everything this node recorded about `tx` and about the transactions its inputs spend, as + /// classifying `tx` needs it. + /// + /// Facts that cannot be read are logged and left out, leaving the transaction less + /// classifiable rather than failing the caller: a transaction whose record says nothing about + /// what it is remains a correct record of the funds it moved, and is picked up again on a + /// later chain tip. + async fn tx_provenance(&self, txid: Txid, tx: &Transaction) -> TxProvenance { + let self_facts = self.channel_tx_facts(&txid).await; + let parents: HashSet = + tx.input.iter().map(|input| input.previous_output.txid).collect(); + let mut parent_facts = HashMap::new(); + for parent in parents { + if let Some(facts) = self.channel_tx_facts(&parent).await { + parent_facts.insert(parent, facts); + } + } + TxProvenance::new(self_facts, parent_facts) + } + + /// What this node's channels reported about the transaction `txid`, or nothing when they + /// reported nothing or the report cannot be read. + async fn channel_tx_facts(&self, txid: &Txid) -> Option { + match self.channel_tx_facts_store.get(txid).await { + Ok(facts) => facts, + Err(e) => { + log_error!( + self.logger, + "Failed to read what this node recorded about transaction {}: {}", + txid, + e, + ); + None + }, } } @@ -341,28 +503,46 @@ impl Wallet { }; // Hold the cross-store lock from payment-id resolution through the last write: - // a classification landing in between would leave the id resolved against a - // torn candidate index and the generic fallback below overwriting (or - // duplicating) the record classification just wrote. - let guard = self.funding_payment_update_lock.lock().await; + // a funding-record write landing in between would leave the id resolved + // against a torn candidate index and the generic fallback below overwriting + // (or duplicating) the record that write had just made. + let stores = self.payment_stores.lock().await; - let payment_id = self + let mut payment_id = self .find_payment_by_txid(txid) .await? .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self + match self .apply_funding_status_update_locked( - &guard, + &stores, payment_id, txid, confirmation_status, ) .await? { - continue; + FundingStatusUpdate::Applied => continue, + FundingStatusUpdate::NotFunding => {}, + // Not part of the funding payment's history (e.g. a close spending the + // funding outpoint): record it under its own id below instead, unless a + // settled funding payment sits there already. + FundingStatusUpdate::Foreign => { + match self.foreign_transaction_payment_id(payment_id, txid).await? { + Some(fallback_id) => payment_id = fallback_id, + None => { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); + continue; + }, + } + }, } + let provenance = self.tx_provenance(txid, &tx).await; let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -370,47 +550,75 @@ impl Wallet { txid, payment_id, &tx, + &provenance, payment_status, confirmation_status, ) }; - self.payment_store.insert_or_update(payment.clone()).await?; + stores.insert_or_update_payment(payment.clone()).await?; if payment_status == PaymentStatus::Pending { - let pending_payment = - self.create_pending_payment_from_tx(payment, Vec::new()); - - self.pending_payment_store.insert_or_update(pending_payment).await?; + self.upsert_pending_payment(&stores, payment, Vec::new()).await?; } }, WalletEvent::ChainTipChanged { new_tip, .. } => { let pending_payments: Vec = self - .pending_payment_store - .list_filter(|p| { - debug_assert!( - p.details.status == PaymentStatus::Pending, - "Non-pending payment {:?} found in pending store", - p.details.id, - ); - p.details.status == PaymentStatus::Pending - && matches!(p.details.kind, PaymentKind::Onchain { .. }) + .payment_stores + .pending_payments(|p| match p.details() { + // A pre-broadcast splice intent carries no payment yet and cannot + // graduate. + None => false, + Some(details) => { + debug_assert!( + details.status == PaymentStatus::Pending, + "Non-pending payment {:?} found in pending store", + details.id, + ); + details.status == PaymentStatus::Pending + && matches!(details.kind, PaymentKind::Onchain { .. }) + }, }) .await; let mut unconfirmed_outbound_txids: Vec = Vec::new(); + let mut unnamed_transactions: Vec<(PaymentId, Txid)> = Vec::new(); for payment in pending_payments { - match payment.details.kind { + // The filter admits only Tracked funding payments. A splice intent such a + // record carries — there is one record per splice, so only the intent of + // the round it tracks or of a fee bump of it — goes with the entry when + // the payment graduates: the lock and the graduation both follow the + // confirmation of the round the record tracks, so a lock handled after + // the graduation finds no intent to settle, and one handled before it + // leaves a record whose intent is already cleared. + // TODO(#1037): once inputs are locked, the graduated round's locks sit on + // spent outpoints: #1037 releases nothing for a splice at broadcast, since + // it prepares only `Funding`-typed packages, until the `InteractiveFunding` + // broadcast arm applies the round and unlocks its inputs. A bump's extra + // inputs return through the `DiscardFunding` LDK queues at the lock, once + // that handler passes the inputs to `cancel_tx`. + let Some(details) = payment.details() else { + continue; + }; + + // A record written before the channel that produced its transaction + // reported what the transaction is says nothing about it yet. The report + // may have arrived since, so try again while the record is in hand. + if let PaymentKind::Onchain { txid, tx_type: None, .. } = details.kind { + unnamed_transactions.push((details.id, txid)); + } + + match details.kind { PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { height, .. }, .. } => { - let payment_id = payment.details.id; + let payment_id = details.id; if new_tip.height >= height + ANTI_REORG_DELAY - 1 { // Graduate from the live record, not the snapshot listed - // above: a classification landing since then must not have - // its figures rolled back. The status-only update carries + // above: a write landing since then must not have its + // figures rolled back. The status-only update carries // no figures/txid/confirmation, so nothing a concurrent // writer wrote can be clobbered; the update machinery bumps // `latest_update_timestamp` and no-ops when the record is @@ -418,8 +626,11 @@ impl Wallet { // snapshot (or was removed) declines, leaving future // events to drive it. let mut graduated = false; - self.payment_store - .mutate(&payment_id, |existing| { + // Taken per payment: the conflict check on unconfirmed payments below + // takes the lock itself. + let stores = self.payment_stores.lock().await; + stores + .mutate_payment(&payment_id, |existing| { let current = existing?; match current.kind { PaymentKind::Onchain { @@ -441,7 +652,7 @@ impl Wallet { }) .await?; if graduated { - self.pending_payment_store.remove(&payment_id).await?; + stores.remove_pending_payment(&payment_id).await?; } } }, @@ -449,13 +660,27 @@ impl Wallet { txid, status: ConfirmationStatus::Unconfirmed, .. - } if payment.details.direction == PaymentDirection::Outbound => { - unconfirmed_outbound_txids.push(txid); + } => { + if self + .fail_funding_payment_lost_to_conflict(&payment, new_tip.height) + .await? + { + continue; + } + if details.direction == PaymentDirection::Outbound { + unconfirmed_outbound_txids.push(txid); + } }, _ => {}, } } + self.name_recorded_transactions(unnamed_transactions).await?; + + // After the naming above, so that nothing is dropped before the records it + // could still name have had it. + self.prune_channel_tx_facts(new_tip.height).await; + if !unconfirmed_outbound_txids.is_empty() { let txs_to_broadcast: Vec = { let locked_wallet = self.inner.lock().expect("lock"); @@ -472,7 +697,7 @@ impl Wallet { if !txs_to_broadcast.is_empty() { let tx_count = txs_to_broadcast.len(); for tx in txs_to_broadcast { - self.broadcaster.broadcast_unclassified_transaction(tx); + self.broadcaster.broadcast(tx); } log_info!( self.logger, @@ -484,26 +709,44 @@ impl Wallet { }, WalletEvent::TxUnconfirmed { txid, tx, .. } => { // See `TxConfirmed`: id resolution and the writes below must not interleave - // with classification. - let guard = self.funding_payment_update_lock.lock().await; + // with the funding-record writers. + let stores = self.payment_stores.lock().await; - let payment_id = self + let mut payment_id = self .find_payment_by_txid(txid) .await? .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self + match self .apply_funding_status_update_locked( - &guard, + &stores, payment_id, txid, ConfirmationStatus::Unconfirmed, ) .await? { - continue; + FundingStatusUpdate::Applied => continue, + FundingStatusUpdate::NotFunding => {}, + // Not part of the funding payment's history (e.g. a close spending the + // funding outpoint): record it under its own id below instead, unless a + // settled funding payment sits there already. + FundingStatusUpdate::Foreign => { + match self.foreign_transaction_payment_id(payment_id, txid).await? { + Some(fallback_id) => payment_id = fallback_id, + None => { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); + continue; + }, + } + }, } + let provenance = self.tx_provenance(txid, &tx).await; let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -511,21 +754,20 @@ impl Wallet { txid, payment_id, &tx, + &provenance, PaymentStatus::Pending, ConfirmationStatus::Unconfirmed, ) }; - let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.payment_store.insert_or_update(payment).await?; - self.pending_payment_store.insert_or_update(pending_payment).await?; + stores.insert_or_update_payment(payment.clone()).await?; + self.upsert_pending_payment(&stores, payment, Vec::new()).await?; }, WalletEvent::TxReplaced { txid, conflicts, .. } => { // See `TxConfirmed`: id resolution and the writes below must not interleave - // with classification. The pending entry written below embeds a read of the - // payment record, which must not go stale against a concurrent - // classification either. - let _guard = self.funding_payment_update_lock.lock().await; + // with the funding-record writers. The pending entry written below embeds a + // read of the payment record, which must not go stale against a concurrent + // write either. + let stores = self.payment_stores.lock().await; let Some(payment_id) = self.find_payment_by_txid(txid).await? else { log_error!( @@ -541,45 +783,74 @@ impl Wallet { conflicts.iter().map(|(_, conflict_txid)| *conflict_txid).collect(); conflict_txids.push(txid); - // The payment already exists in the store at this point: `bump_fee_rbf` - // updates the payment store with the replacement txid before the next sync - // cycle, and an id resolved through the candidate history comes from a - // classification whose payment-store write strictly precedes the candidate - // history it was resolved from. So we can safely fetch it here. - let stored_payment = self.payment_store.get(&payment_id).await?; - debug_assert!( - stored_payment.is_some(), - "Payment {:?} expected in store during WalletEvent::TxReplaced but not found", - payment_id, - ); - let payment = stored_payment.ok_or(Error::InvalidPaymentId)?; - let pending_payment_details = - self.create_pending_payment_from_tx(payment, conflict_txids.clone()); + // An id outlives its record: the facts recorded when a round was signed + // name its payment before anything has created it, and go on naming it + // once `remove_payment` has taken it away. Neither leaves anything to + // update here, and failing would abandon the rest of the batch and the + // wallet's own view of the chain with it. + let Some(payment) = stores.payment(&payment_id).await? else { + log_debug!( + self.logger, + "No payment {} on record for replaced transaction {}. Skipping.", + payment_id, + txid, + ); + continue; + }; + + // A terminal record means the entry is the leftover of an interrupted settle + // — the record write landed, the entry removal was lost to a crash — and this + // event is the restart's replay of the same transition. Re-embedding the + // record would stamp the terminal status into the entry and hide it from the + // pending listing that repairs such leftovers; finish the interrupted removal + // instead. + if payment.status != PaymentStatus::Pending { + stores.remove_pending_payment(&payment_id).await?; + continue; + } - self.pending_payment_store.insert_or_update(pending_payment_details).await?; + self.upsert_pending_payment(&stores, payment, conflict_txids).await?; }, WalletEvent::TxDropped { txid, tx } => { // See `TxConfirmed`: id resolution and the writes below must not interleave - // with classification. - let guard = self.funding_payment_update_lock.lock().await; + // with the funding-record writers. + let stores = self.payment_stores.lock().await; - let payment_id = self + let mut payment_id = self .find_payment_by_txid(txid) .await? .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self + match self .apply_funding_status_update_locked( - &guard, + &stores, payment_id, txid, ConfirmationStatus::Unconfirmed, ) .await? { - continue; + FundingStatusUpdate::Applied => continue, + FundingStatusUpdate::NotFunding => {}, + // Not part of the funding payment's history (e.g. a close spending the + // funding outpoint): record it under its own id below instead, unless a + // settled funding payment sits there already. + FundingStatusUpdate::Foreign => { + match self.foreign_transaction_payment_id(payment_id, txid).await? { + Some(fallback_id) => payment_id = fallback_id, + None => { + log_debug!( + self.logger, + "Skipping wallet event for transaction {} of a settled funding payment", + txid, + ); + continue; + }, + } + }, } + let provenance = self.tx_provenance(txid, &tx).await; let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -587,14 +858,13 @@ impl Wallet { txid, payment_id, &tx, + &provenance, PaymentStatus::Pending, ConfirmationStatus::Unconfirmed, ) }; - let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.payment_store.insert_or_update(payment).await?; - self.pending_payment_store.insert_or_update(pending_payment).await?; + stores.insert_or_update_payment(payment.clone()).await?; + self.upsert_pending_payment(&stores, payment, Vec::new()).await?; }, _ => { continue; @@ -605,592 +875,698 @@ impl Wallet { Ok(()) } - #[allow(deprecated)] - pub(crate) async fn create_funding_transaction( - &self, output_script: ScriptBuf, amount: Amount, confirmation_target: ConfirmationTarget, - locktime: LockTime, - ) -> Result { - let fee_rate = self.fee_estimator.estimate_fee_rate(confirmation_target); - let mut locked_persister = self.persister.lock().await; - let (psbt, change_set) = { - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut tx_builder = locked_wallet.build_tx(); - tx_builder.add_recipient(output_script, amount).fee_rate(fee_rate).nlocktime(locktime); - - let mut psbt = match tx_builder.finish() { - Ok(psbt) => { - log_trace!(self.logger, "Created funding PSBT: {:?}", psbt); - psbt - }, - Err(err) => { - log_error!(self.logger, "Failed to create funding transaction: {}", err); - return Err(err.into()); - }, + /// Names the transactions of the given payments from the facts this node has recorded about + /// them, for records that do not say what their transaction is. + /// + /// This is how a record written before the producing channel reported its transaction picks + /// that report up: the facts are durable, so a report arriving after the record does reach it + /// on a later chain tip. A transaction the facts still cannot account for leaves its record + /// as it is, and so does a record that names its transaction already: whoever named it knew + /// more than the facts alone say. + async fn name_recorded_transactions( + &self, payments: Vec<(PaymentId, Txid)>, + ) -> Result<(), Error> { + for (payment_id, txid) in payments { + let tx = { + let locked_wallet = self.inner.lock().expect("lock"); + locked_wallet.get_tx(txid).map(|tx| tx.tx_node.tx.as_ref().clone()) + }; + let Some(tx) = tx else { + continue; + }; + let Some(tx_type) = self.tx_provenance(txid, &tx).await.classify(&tx) else { + continue; }; - match locked_wallet.sign(&mut psbt, SignOptions::default()) { - Ok(finalized) => { - if !finalized { - return Err(Error::OnchainTxCreationFailed); + let mut update = PaymentDetailsUpdate::new(payment_id); + update.tx_type = Some(Some(tx_type)); + // Taken per payment, like the graduation above: the write touches one record and + // leaves its pending entry alone. + let stores = self.payment_stores.lock().await; + let named = stores + .mutate_payment(&payment_id, |existing| { + let current = existing?; + // Whether the record is still unnamed is decided inside the store's + // critical section, where the answer cannot go stale against a name + // written since this payment was listed. + if !matches!(current.kind, PaymentKind::Onchain { tx_type: None, .. }) { + return None; } - }, - Err(err) => { - log_error!(self.logger, "Failed to create funding transaction: {}", err); - return Err(err.into()); - }, + let mut updated = current.clone(); + updated.update(update).then_some(updated) + }) + .await?; + if named.is_some() { + log_debug!(self.logger, "Named transaction {} from what is recorded of it", txid); } - - (psbt, 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 - })?; - - let tx = psbt.extract_tx().map_err(|e| { - log_error!(self.logger, "Failed to extract transaction: {}", e); - e - })?; - - Ok(tx) + } + Ok(()) } - /// Returns a fresh address, served from the address pool so that external handouts consume - /// the oldest revealed index first. + /// Drops the facts this node has no use for anymore, a bounded batch of the store at a time. /// - /// Allocating in reveal order keeps the window of revealed-but-unused scripts compact: as - /// soon as a handed-out address is used on-chain, everything before it no longer counts - /// towards a from-seed restore's full-scan stop gap. Minting a fresh index here instead - /// would strand the pooled indices as an ever-growing unused tail in front of every address - /// a restore must discover. The order has one exception: a handout that fails while a - /// concurrent one proceeds can return its address to the pool below an index already handed - /// out. + /// A transaction's facts go only once all of it holds: nothing has been learned about the + /// transaction for [`CHANNEL_TX_FACTS_RETENTION_BLOCKS`], none of the channels the facts name + /// is still held by the node's channel manager, chain monitor or output sweeper, no pending + /// payment still refers to the transaction, and whatever funding the facts record has been + /// spent by a settled transaction buried past twice [`ANTI_REORG_DELAY`]. Each of those is a + /// way the facts could still be needed, so any one of them keeps them. /// - /// Unlike [`Wallet::pop_pooled_address`], this may wait on persistence, so the handout is - /// made durable before the address is returned: the awaited refill rewrites the pool record - /// (no longer containing the popped index) before topping the pool back up, so a restart - /// never hands the returned address out again. On failure the address instead returns to - /// the pool unhanded-out, with a compensating record write covering the case where the - /// failed refill had already rewritten the record. - pub(crate) async fn get_new_address(&self) -> Result { - let (index, address) = loop { - if let Some(entry) = self.address_pool.lock().expect("lock").available.pop_front() { - break entry; - } - // Another caller may pop what this refill publishes before the re-check, so loop - // rather than assuming a successful refill leaves the pool non-empty. - self.refill_address_pool().await?; + /// The walk of the store resumes where the previous tip left it, so a batch costs one page + /// however large the store is, and a full walk doubles as the census the admission of new + /// records is bounded by. + /// + /// Nothing here is reported to the caller: dropping records is housekeeping, and failing the + /// chain tip pass over it would cost the payment graduations it shares the pass with. + async fn prune_channel_tx_facts(&self, tip_height: u32) { + let Some(live_channels) = self.channel_liveness.get().and_then(|l| l.live_channels()) + else { + return; }; + let pending_txids = self.pending_referenced_txids().await; - // Force the record rewrite: a failed handout's push-back can leave the pool over its - // target size, and an early-returning refill would then leave the just-popped index - // durably recorded, handing the address out again after a restart. - match self.refill_address_pool_inner(true).await { - Ok(()) => Ok(address), - Err(e) => { - // The address was never handed out, so return it for the next caller rather - // than leaving its index revealed but unreachable. Reinsert by index: - // concurrent failed handouts complete in pop order, so pushing to the front - // would reverse their segment and let the next successful handout skip past a - // lower index, stranding it behind a used address in a from-seed restore's scan. - { - let mut locked_pool = self.address_pool.lock().expect("lock"); - let position = locked_pool.available.partition_point(|(i, _)| *i < index); - locked_pool.available.insert(position, (index, address)); + let mut walk = self.facts_retention.walk().await; + for _ in 0..CHANNEL_TX_FACTS_PRUNE_PAGES_PER_TIP { + let page = match self.channel_tx_facts_store.list_page(walk.cursor.clone()).await { + Ok(page) => page, + Err(e) => { + // Including a token the backend will not take back, which would otherwise + // fail every tip from here on: start the walk over instead. + log_error!(self.logger, "Failed to list recorded channel facts: {}", e); + walk.cursor = None; + walk.seen = 0; + return; + }, + }; + walk.seen = walk.seen.saturating_add(page.objects.len()); + + for facts in page.objects { + let check = RetentionCheck { + tip_height, + retention_blocks: CHANNEL_TX_FACTS_RETENTION_BLOCKS, + live_channels: &live_channels, + pending_txids: &pending_txids, + funding_spends_settled: self.funding_spends_settled( + &facts, + tip_height, + &pending_txids, + ), + }; + if !facts.is_prunable(&check) { + continue; } - // The refill may have failed after rewriting the record, which then durably - // excludes the pushed-back index; rewrite it from the restored pool so a crash - // before the next successful refill doesn't strand the index outside the pool. - self.rewrite_pool_record().await; - Err(e) - }, + let txid = facts.txid; + match self.drop_recorded_facts(facts).await { + Ok(true) => { + walk.seen = walk.seen.saturating_sub(1); + self.facts_retention.record_dropped(); + log_debug!( + self.logger, + "Dropped what was recorded about transaction {}: nothing needs it anymore", + txid, + ); + }, + Ok(false) => {}, + Err(e) => log_error!( + self.logger, + "Failed to drop what was recorded about transaction {}: {}", + txid, + e, + ), + } + } + + match page.next_page_token { + Some(token) => walk.cursor = Some(token), + None => { + // The walk has been all the way round, so what it counted is what the store + // holds. Start the next one from the beginning. + self.facts_retention.walk_completed(walk.seen); + walk.cursor = None; + walk.seen = 0; + break; + }, + } } } - /// Returns an address whose reveal is already durably persisted, or `None` if the pool is - /// exhausted. - /// - /// This is safe to call from sync callbacks (e.g., [`SignerProvider`]) that LDK invokes on - /// runtime worker threads while holding channel locks: it never waits on persistence, only - /// popping from the pre-persisted pool and scheduling a background refill. Blocking such a - /// callback on persistence can deadlock the runtime, as other tasks blocking synchronously on - /// the same channel locks may capture the remaining workers, leaving none to drive the - /// persistence future the callback would wait on. - /// - /// Failing closed on an empty pool (rather than revealing an unpersisted address) ensures we - /// never hand out a script that would go unwatched if the node crashed before its reveal - /// landed: incremental chain syncs only query scripts the persisted wallet has revealed. + /// Drops the recorded facts `facts` was read as, and reports whether anything was dropped. /// - /// The handout itself is not persisted: if the node restarts before the refill scheduled here - /// rewrites the pool record, the popped address may be handed out again after the restart. - /// Its reveal is durable either way, so the script always stays watched — the cost is bounded - /// address reuse, not fund visibility. - pub(crate) fn pop_pooled_address(self: &Arc) -> Option { - let popped = self.address_pool.lock().expect("lock").available.pop_front(); - - // Spawning cancellable lets shutdown abort an in-flight refill rather than wait on it. - // Aborting mid-refill (or dropping a refill spawned during shutdown) is safe: the reveals - // are staged with the persister in the same critical section that takes them from the - // wallet, and nothing is published whose persistence the refill did not see complete. - let wallet = Arc::clone(self); - self.runtime.spawn_cancellable_background_task(async move { - if let Err(e) = wallet.refill_address_pool().await { - log_error!(wallet.logger, "Failed to refill the address pool: {}", e); - } - }); - - popped.map(|(_, address)| address) - } + /// The record goes only while it still is the one that was read: retention is decided from a + /// record in hand, and a producer merging a report into it since may have named a channel + /// that would have kept it. The store's own critical section is what makes that check and the + /// removal one step, which a read followed by a removal would not be. + async fn drop_recorded_facts(&self, facts: ChannelTxFacts) -> Result { + let txid = facts.txid; + self.channel_tx_facts_store.remove_if(&txid, |recorded| *recorded == facts).await + } + + /// Whether every funding output `facts` records has been spent by a transaction that is + /// buried past twice [`ANTI_REORG_DELAY`] and whose own payment has settled, so that nothing + /// is left to classify from these facts. Facts recording no funding output have no such + /// spend to wait for. + fn funding_spends_settled( + &self, facts: &ChannelTxFacts, tip_height: u32, pending_txids: &HashSet, + ) -> bool { + let mut funding_vouts = facts.funding_vouts().peekable(); + if funding_vouts.peek().is_none() { + return true; + } - /// Tops the address pool up to [`ADDRESS_POOL_TARGET_SIZE`], publishing newly revealed - /// addresses only after their reveal has been durably persisted. - pub(crate) async fn refill_address_pool(&self) -> Result<(), Error> { - self.refill_address_pool_inner(false).await + let locked_wallet = self.inner.lock().expect("lock"); + funding_vouts.all(|vout| { + let outpoint = OutPoint { txid: facts.txid, vout }; + locked_wallet.tx_graph().outspends(outpoint).iter().any(|spender| { + // A spender still pending has yet to be told what it is, so the facts that would + // tell it must stay. `get_tx` is canonical-only, so a spend that lost a conflict + // closes nothing. + !pending_txids.contains(spender) + && match locked_wallet.get_tx(*spender).map(|tx| tx.chain_position) { + Some(ChainPosition::Confirmed { anchor, .. }) => { + tip_height + >= anchor.block_id.height.saturating_add(2 * ANTI_REORG_DELAY) + }, + _ => false, + } + }) + }) } - /// [`Wallet::refill_address_pool`], where `force_record_rewrite` makes the pool-record - /// rewrite unconditional: a pool at or over its target size otherwise skips it, which after - /// a pop would leave the popped index in the record. - async fn refill_address_pool_inner(&self, force_record_rewrite: bool) -> Result<(), Error> { - let _refill_guard = self.address_pool_refill_lock.lock().await; - - if !force_record_rewrite { - let locked_pool = self.address_pool.lock().expect("lock"); - if locked_pool.unpublished.is_empty() - && locked_pool.available.len() >= ADDRESS_POOL_TARGET_SIZE + /// Every transaction the pending payment store still refers to: each entry's own + /// transaction, the interactive-funding rounds it lists as candidates or as locked, and the + /// conflicts wallet sync recorded against it. + async fn pending_referenced_txids(&self) -> HashSet { + let mut txids = HashSet::new(); + for entry in self.payment_stores.pending_payments(|_| true).await { + if let Some(PaymentKind::Onchain { txid, .. }) = + entry.details().map(|details| &details.kind) { - return Ok(()); + txids.insert(*txid); } + txids.extend(entry.candidates().iter().map(|candidate| candidate.txid)); + txids.extend(entry.conflicting_txids().iter().copied()); + txids.extend(entry.locked_rounds().iter().copied()); + } + txids + } + + /// The id to record a transaction under that the funding-status check found foreign to the + /// funding record resolved for it as `resolved_id`: its own txid-derived id, or `None` when a + /// funding record sits there already. A funding record wallet sync created for a round it + /// could not attribute keeps the txid-derived id of that transaction, so a wallet event for it + /// falls back to this id whenever the pending entry no longer maps it — which only happens + /// once the negotiation settled and the entry was removed. The generic event handling must + /// then skip its write: merging a wallet-view `Pending` payment into the settled record would + /// resurrect it with figures the negotiation never reported. When `resolved_id` is the txid-derived + /// id already, the funding-status check has read that record, and finding the transaction + /// foreign to it is this very case; only a fallback from a different id needs a read. + async fn foreign_transaction_payment_id( + &self, resolved_id: PaymentId, txid: Txid, + ) -> Result, Error> { + let fallback_id = PaymentId(txid.to_byte_array()); + if resolved_id == fallback_id { + return Ok(None); } + let has_funding_record = + self.payment_stores.payment(&fallback_id).await?.is_some_and(|payment| { + matches!( + payment.kind, + PaymentKind::Onchain { + tx_type: Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. } + ), + .. + } + ) + }); + Ok(if has_funding_record { None } else { Some(fallback_id) }) + } - let mut locked_persister = self.persister.lock().await; - let indices = { - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut locked_pool = self.address_pool.lock().expect("lock"); - let needed = ADDRESS_POOL_TARGET_SIZE - .saturating_sub(locked_pool.available.len() + locked_pool.unpublished.len()); - for _ in 0..needed { - let address_info = locked_wallet.reveal_next_address(KeychainKind::External); - locked_pool.unpublished.push((address_info.index, address_info.address)); - } - // Hand the reveals straight to the persister: this refill may run as a task the - // runtime aborts at shutdown, and holding the taken change set across an await - // would lose the reveals if the abort lands there — a later refill run would then - // publish addresses no persisted wallet state covers. - locked_persister.stage(locked_wallet.take_staged().unwrap_or_default()); - locked_pool - .available - .iter() - .chain(locked_pool.unpublished.iter()) - .map(|(index, _)| *index) - .collect::>() + /// Fails a funding payment whose transaction has irrevocably lost a conflict: a transaction + /// outside the record's candidate history — e.g. a channel close double-spending a pending + /// splice's shared input — has confirmed through [`ANTI_REORG_DELAY`] while neither the + /// record's transaction nor any candidate is canonical anymore. Returns whether the payment + /// was failed; failing also removes the pending entry, dropping the dead record from the + /// tip-change pass. (Its transaction was already excluded from rebroadcast by the same + /// canonical-only `get_tx` gate used below.) + /// + /// Only funding-classified records are considered: nothing re-submits a replaced funding + /// transaction under the same record (an RBF round is a new candidate), so a buried foreign + /// conflict is final for them. The liveness check guards the case where the conflict + /// double-spent only one round of the negotiation: as long as some candidate — any recorded + /// round, or the record's own transaction should wallet sync have rotated it to an + /// unrecorded one — can still confirm, the record must stay pending. + async fn fail_funding_payment_lost_to_conflict( + &self, payment: &PendingPaymentDetails, tip_height: u32, + ) -> Result { + let payment_id = match payment.details() { + Some(details) => match details.kind { + PaymentKind::Onchain { + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + .. + } => details.id, + _ => return Ok(false), + }, + None => return Ok(false), }; + if payment.conflicting_txids().is_empty() { + return Ok(false); + } - // Persist the pool record before the reveals. A crash between the two writes then leaves - // record entries the persisted wallet doesn't cover, which reloading drops and the next - // refill re-derives to the same indices — rather than durably revealed indices missing - // from the record, which no path would ever pool or hand out again (burning them). - // Writing the record first also drops popped indices from it as early as possible, - // narrowing the restart window in which a handed-out address is handed out again. - // Skip the reveal flush when the record write fails: reveals made durable without - // record coverage would, after a crash, be indices no path ever pools or hands out - // again — permanently skipped in the keychain, widening the gap a restore from seed - // must scan across. Retained in the persister instead, they either flush with a later - // persist call or die with the process, in which case the next run re-derives the same - // indices. (An unrelated persist call can still flush them before the record retry - // succeeds, so the window is narrowed, not closed.) - locked_persister.persist_address_pool(indices).await.map_err(|e| { - log_error!(self.logger, "Failed to persist address pool: {}", e); - Error::PersistenceFailed - })?; - // On failure the reveals stay in `unpublished` (never handed out) and the persister - // retains the change set, so the next refill run retries both. - locked_persister.persist_staged().await.map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - })?; - - // Both writes are durable, so the addresses may be handed out. - let mut locked_pool = self.address_pool.lock().expect("lock"); - let unpublished = core::mem::take(&mut locked_pool.unpublished); - locked_pool.available.extend(unpublished); - Ok(()) - } + // Serialize with the funding-record writers, which extend the candidate history: the + // decision below must see that history in its settled form, and holding the lock keeps a + // concurrent write from resurrecting the entry removed at the end. + let stores = self.payment_stores.lock().await; - /// Best-effort rewrite of the pool record from the pool's current contents, used to - /// re-include a pushed-back index whose handout's record write succeeded before the handout - /// failed. Failures are only logged: the pool still covers the index in memory and the next - /// successful refill rewrites the record anyway, so only a crash before then strands the - /// index outside the pool. - async fn rewrite_pool_record(&self) { - let mut locked_persister = self.persister.lock().await; - let indices: Vec = { - let locked_pool = self.address_pool.lock().expect("lock"); - locked_pool - .available - .iter() - .chain(locked_pool.unpublished.iter()) - .map(|(index, _)| *index) - .collect() + // Re-read the entry under the lock; the listing snapshot may predate a record write. + let entry = match stores.pending_payment(&payment_id).await? { + Some(entry) => entry, + None => return Ok(false), }; - let _ = locked_persister.persist_address_pool(indices).await; - } - - pub(crate) async fn get_new_internal_address(&self) -> Result { - let mut locked_persister = self.persister.lock().await; - let (address_info, change_set) = { - let mut locked_wallet = self.inner.lock().expect("lock"); - let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); - (address_info, locked_wallet.take_staged().unwrap_or_default()) + let Some(details) = entry.details() else { + return Ok(false); }; - locked_persister.persist_changeset(change_set).await.map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - })?; - Ok(address_info.address) - } - - pub(crate) async fn cancel_tx(&self, tx: Transaction) -> Result<(), Error> { - let mut locked_persister = self.persister.lock().await; - let change_set = { - let mut locked_wallet = self.inner.lock().expect("lock"); - Self::cancel_tx_inner(&mut locked_wallet, tx); - locked_wallet.take_staged().unwrap_or_default() + let (conflicting_txids, candidates) = (&entry.conflicting_txids, &entry.candidates); + let record_txid = match details.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + } => txid, + _ => return Ok(false), }; - locked_persister.persist_changeset(change_set).await.map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - })?; - - Ok(()) - } - fn cancel_tx_inner( - locked_wallet: &mut PersistedWallet, tx: Transaction, - ) { - for txout in tx.output { - if let Some((keychain, index)) = locked_wallet.derivation_of_spk(txout.script_pubkey) { - // This mirrors the removed BDK helper: it only frees superficial usage marks. - locked_wallet.unmark_used(keychain, index); - } + let foreign_conflicts: Vec = conflicting_txids + .iter() + .copied() + .filter(|conflict| *conflict != record_txid && entry.candidate(*conflict).is_none()) + .collect(); + if foreign_conflicts.is_empty() { + return Ok(false); } - } - - pub(crate) fn get_balances( - &self, total_anchor_channels_reserve_sats: u64, - ) -> Result<(u64, u64), Error> { - let balance = self.inner.lock().expect("lock").balance(); - // Make sure `list_confirmed_utxos` returns at least one `Utxo` we could use to spend/bump - // Anchors if we have any confirmed amounts. - #[cfg(debug_assertions)] - if balance.confirmed != Amount::ZERO { - debug_assert!( - self.list_confirmed_utxos_inner().map_or(false, |v| !v.is_empty()), - "Confirmed amounts should always be available for Anchor spending" - ); + let lost = { + let locked_wallet = self.inner.lock().expect("lock"); + // `get_tx` is canonical-only: a transaction that lost to a confirmed conflict + // returns `None`, while one that can still confirm is `Some`. + let a_candidate_is_live = locked_wallet.get_tx(record_txid).is_some() + || candidates.iter().any(|c| locked_wallet.get_tx(c.txid).is_some()); + !a_candidate_is_live + && foreign_conflicts.iter().any(|conflict| { + match locked_wallet.get_tx(*conflict).map(|tx| tx.chain_position) { + Some(ChainPosition::Confirmed { anchor, .. }) => { + tip_height >= anchor.block_id.height + ANTI_REORG_DELAY - 1 + }, + _ => false, + } + }) + }; + if !lost { + return Ok(false); } - self.get_balances_inner(balance, total_anchor_channels_reserve_sats) + let payment_id = entry.id(); + let outcome = + self.fail_unconfirmed_funding_payment_locked(&stores, payment_id, record_txid).await?; + match outcome { + FundingPaymentFailure::Failed => log_info!( + self.logger, + "Failed funding payment {}: transaction {} lost to a conflicting transaction confirmed beyond the reorg depth", + payment_id, + record_txid, + ), + FundingPaymentFailure::EntryRemoved => log_info!( + self.logger, + "Removed the lingering entry of failed funding payment {}: transaction {} lost to \ + a conflicting transaction confirmed beyond the reorg depth", + payment_id, + record_txid, + ), + FundingPaymentFailure::MovedOn => {}, + } + Ok(outcome != FundingPaymentFailure::MovedOn) + } + + /// Fails the funding payment `payment_id` while its record still waits on the unconfirmed + /// funding transaction `record_txid`, and removes its pending entry — 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 { + let mut outcome = FundingPaymentFailure::MovedOn; + stores + .mutate_payment(&payment_id, |existing| { + let current = existing?; + match current.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + } if txid == record_txid => { + let mut update = PaymentDetailsUpdate::new(payment_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = current.clone(); + if updated.update(update) { + outcome = FundingPaymentFailure::Failed; + Some(updated) + } else { + outcome = FundingPaymentFailure::EntryRemoved; + None + } + }, + _ => None, + } + }) + .await?; + if outcome != FundingPaymentFailure::MovedOn { + // A splice intent the entry carries outlives the record as a bare intent, for the + // splice tracker to settle at the lock or the close that failed the payment, or to + // re-anchor when LDK carries a queued fee bump the promoted round does not overlap + // across the lock and begins a fresh splice from it. The intent moves to an id of its + // own: the fresh round adopts the id of the bare intent carrying its contribution + // (`find_splice_payment_id`), and under this record's id it would take a `Failed` + // record and go untracked. The drop pass (`drop_abandoned_splice_rounds_locked`) keeps + // the intent under the record's id instead, having removed the record. Keeping the + // intent before removing the entry loses nothing to a crash in between: the replay + // finds the intent kept and adds no second copy. A settlement the splice tracker has + // under way, one that read the intent before this routine ran or that lands between the + // read above and the insert, leaves a bare copy of a settled intent behind; the + // channel's next lock, close or startup reconciliation settles the copy. + let intent = match stores.pending_payment(&payment_id).await? { + Some(entry) if entry.details().is_some() => entry.splice_intent, + _ => None, + }; + if let Some(intent) = intent { + let kept_already = stores + .pending_payments(|p| { + p.details().is_none() && p.splice_intent() == Some(&intent) + }) + .await; + if kept_already.is_empty() { + let kept_id = random_payment_id(); + stores + .insert_pending_payment(PendingPaymentDetails::pending_splice( + kept_id, intent, + )) + .await?; + log_debug!( + self.logger, + "Kept the splice intent of failed funding payment {} as bare intent {} for \ + the splice tracker to settle", + payment_id, + kept_id, + ); + } + } + stores.remove_pending_payment(&payment_id).await?; + } + Ok(outcome) + } + + /// Resolves the funding payments of the closed channel `channel_id`, whose monitor settled on + /// and still watches `held_rounds` (as [`closed_channel_held_rounds`] lists them): a round + /// nothing ever broadcast is dropped from its record, as [`Self::drop_abandoned_splice_rounds`] + /// does, and every payment left waiting on an unconfirmed splice round with no round of ours + /// among `held_rounds`, and none LDK promoted to the channel's funding before, is failed. The + /// monitor watches every pending round of ours that can still confirm, and a round that was + /// the funding once — a zero-conf splice locks before its transaction confirms — can confirm + /// still, every later splice building on it, so such a payment waits for a transaction that + /// cannot. + /// + /// In the usual order the monitor still watches every pending round when the channel closes, + /// and the `DiscardFunding` events it queues once the close matures find the channel no longer + /// listed and resolve the payments the same way, by what the monitor holds then. The order + /// flips when one sync delivers the close and its maturity while the background processor is + /// between the channel manager's event pass and the chain monitor's: the monitor's events then + /// find the channel still listed, and an event for a listed channel resolves no payment — the + /// promotion of a sibling round does, when there is one, and here there is none. This settles + /// what those events left behind. + pub(crate) async fn resolve_closed_channel_splice_rounds( + &self, channel_id: ChannelId, held_rounds: &[Txid], + ) -> Result<(), Error> { + // Serialize with the other funding-record writers, which all hold this lock from their + // reads through their last write. + let stores = self.payment_stores.lock().await; + self.resolve_closed_channel_splice_rounds_locked(&stores, channel_id, held_rounds).await } - fn get_balances_inner( - &self, balance: Balance, total_anchor_channels_reserve_sats: u64, - ) -> Result<(u64, u64), Error> { - let (total, spendable) = ( - balance.total().to_sat(), - balance.trusted_spendable().to_sat().saturating_sub(total_anchor_channels_reserve_sats), + /// [`Self::resolve_closed_channel_splice_rounds`] for a caller already holding the + /// funding-record writers' lock. + async fn resolve_closed_channel_splice_rounds_locked( + &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], + ) -> Result<(), Error> { + self.drop_abandoned_splice_rounds_locked(stores, channel_id, held_rounds).await?; + self.fail_funding_payments_without_held_round_locked( + stores, + channel_id, + held_rounds, + FundingResolution::Close, + ) + .await?; + // Logged whatever the two passes found: a payment graduated by a sync running alongside + // leaves them nothing to log, and the decision should still show. + log_debug!( + self.logger, + "Resolved the funding payments of channel {} after its close by the {} round(s) its \ + monitor holds", + channel_id, + held_rounds.len(), ); - - Ok((total, spendable)) + Ok(()) } - pub(crate) fn get_spendable_amount_sats( - &self, total_anchor_channels_reserve_sats: u64, - ) -> Result { - self.get_balances(total_anchor_channels_reserve_sats).map(|(_, s)| s) + /// Fails every funding payment of `channel_id` still waiting on an unconfirmed splice round + /// while no round of ours in its record is among `held_rounds` or was promoted to the channel's + /// funding (see [`Self::resolve_promoted_splice_round`]), removing its pending entry and + /// keeping a splice intent it carries as a bare intent of its own; a payment with such a round + /// is left as it is. The rounds of ours are the candidates recorded with a stake, and the + /// record's own transaction only when no candidate records it, as for a record from before + /// candidates were tracked: a recorded candidate counts by its stake alone, whichever round + /// the record names. A payment that moved on — its round confirmed, or it was failed already — + /// is not touched beyond the entry a failure cut short left behind. `resolution` names the + /// occasion in what is logged. + async fn fail_funding_payments_without_held_round_locked( + &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], + resolution: FundingResolution, + ) -> Result<(), Error> { + let occasion = match resolution { + FundingResolution::Close => format!("of closed channel {}", channel_id), + FundingResolution::Promotion(promoted) => { + format!("of channel {} once splice round {} locked", channel_id, promoted) + }, + }; + let entries = stores.pending_payments(|entry| tracks_channel(entry, channel_id)).await; + for entry in entries { + let details = match entry.details() { + Some(details) => details, + None => continue, + }; + let payment_id = details.id; + let record_txid = match &details.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } => *txid, + _ => { + log_debug!( + self.logger, + "Funding payment {} {} no longer waits on an unconfirmed round", + payment_id, + occasion, + ); + continue; + }, + }; + // Wallet sync moves the record onto whichever of its candidates it sees, ours or not, + // so a recorded candidate counts by its stake alone; the record's transaction counts + // only where no candidate records it. + let recorded_round = entry.candidate(record_txid).is_none().then_some(record_txid); + let mut rounds_of_ours = entry + .candidates() + .iter() + .filter(|candidate| candidate.amount_msat.is_some()) + .map(|candidate| candidate.txid) + .chain(recorded_round); + if let Some(kept) = rounds_of_ours + .find(|txid| held_rounds.contains(txid) || entry.locked_rounds().contains(txid)) + { + log_info!( + self.logger, + "Splice round {} of ours can still confirm: keeping funding payment {} {}", + kept, + payment_id, + occasion, + ); + continue; + } + match self + .fail_unconfirmed_funding_payment_locked(stores, payment_id, record_txid) + .await? + { + FundingPaymentFailure::Failed => log_info!( + self.logger, + "Failed funding payment {} {}: no round of ours can confirm", + payment_id, + occasion, + ), + FundingPaymentFailure::EntryRemoved => log_info!( + self.logger, + "Removed the lingering entry of failed funding payment {} {}", + payment_id, + occasion, + ), + FundingPaymentFailure::MovedOn => log_warn!( + self.logger, + "Funding payment {} {} moved on from transaction {}: leaving it as it is", + payment_id, + occasion, + record_txid, + ), + } + } + Ok(()) } - fn build_drain_psbt( - &self, locked_wallet: &mut PersistedWallet, - drain_script: ScriptBuf, cur_anchor_reserve_sats: u64, fee_rate: FeeRate, - shared_input: Option<&Input>, - ) -> Result { - let anchor_address = if cur_anchor_reserve_sats > DUST_LIMIT_SATS { - Some(locked_wallet.peek_address(KeychainKind::Internal, 0)) - } else { - None + /// Resolves what LDK's promotion of the splice round `promoted` to the funding of `channel_id`, + /// as its `ChannelReady` reports, means for the channel's funding payments. `held_rounds` lists + /// the rounds LDK holds for the channel once promoted, as [`held_splice_rounds`] does — the + /// promoted round alone, unless a contribution queued behind it was negotiated already — or is + /// `None` for a channel the manager no longer lists, whose close settles its payments. + /// + /// The promotion is recorded first, in the funding payment whose record holds the round. A + /// zero-conf splice is promoted as soon as `splice_locked` is exchanged, before its transaction + /// confirms, and every later splice builds on it, so the round can still confirm once the + /// channel's funding has moved on from it and once the channel has closed — when neither the + /// channel manager nor the monitor holds it anymore — and its payment is kept then. Nothing is + /// recorded for a round no funding payment holds — this node did not contribute to it, or its + /// record graduated already — or recorded as promoted already (a replayed event). + /// + /// LDK discards the round's siblings as it promotes the round, queuing a `DiscardFunding` for + /// each contribution of ours it returns — one naming the contribution, not the round — so the + /// channel's other payments are resolved here, from the rounds LDK holds: a round nothing ever + /// broadcast is dropped from its record, as [`Self::drop_abandoned_splice_rounds`] does, and + /// every payment left waiting on an unconfirmed round with no round of ours among `held_rounds` + /// and none promoted before is failed: no round of ours can confirm anymore, a round this node + /// did not contribute to having locked. A replayed event finds the promoted round recorded and + /// keeps its payment whatever LDK holds by then. + pub(crate) async fn resolve_promoted_splice_round( + &self, channel_id: ChannelId, promoted: Txid, held_rounds: Option<&[Txid]>, + ) -> Result<(), Error> { + // Serialize with the other funding-record writers, which all hold this lock from their + // reads through their last write. + let stores = self.payment_stores.lock().await; + self.record_locked_splice_round_locked(&stores, channel_id, promoted).await?; + let held_rounds = match held_rounds { + Some(held_rounds) => held_rounds, + None => { + log_debug!( + self.logger, + "Channel {} is no longer listed as splice round {} locks: leaving its funding \ + payments to its close", + channel_id, + promoted, + ); + return Ok(()); + }, }; + // The drop goes first: a round nothing broadcast is taken back rather than failed, and + // the payment recorded for it alone goes with it. + self.drop_abandoned_splice_rounds_locked(&stores, channel_id, held_rounds).await?; + self.fail_funding_payments_without_held_round_locked( + &stores, + channel_id, + held_rounds, + FundingResolution::Promotion(promoted), + ) + .await?; + log_debug!( + self.logger, + "Resolved the funding payments of channel {} as splice round {} locked, by the {} \ + round(s) LDK holds", + channel_id, + promoted, + held_rounds.len(), + ); + Ok(()) + } - let mut tx_builder = locked_wallet.build_tx(); - tx_builder.drain_wallet().drain_to(drain_script).fee_rate(fee_rate); - - if let Some(address_info) = anchor_address { - tx_builder.add_recipient( - address_info.address.script_pubkey(), - Amount::from_sat(cur_anchor_reserve_sats), + /// Records that LDK promoted the splice round `txid` to the funding of `channel_id` in the + /// funding payment whose record holds the round, for a caller holding the funding-record + /// writers' lock (see [`Self::resolve_promoted_splice_round`]). + async fn record_locked_splice_round_locked( + &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, txid: Txid, + ) -> Result<(), Error> { + let entries = stores + .pending_payments(|entry| { + tracks_channel(entry, channel_id) + && entry.candidate(txid).is_some() + && !entry.locked_rounds().contains(&txid) + }) + .await; + for entry in entries { + let payment_id = entry.id(); + stores + .mutate_pending_payment(&payment_id, |existing| { + let mut entry = existing?.clone(); + if !entry.record_locked_round(txid) { + return None; + } + Some(entry) + }) + .await?; + log_info!( + self.logger, + "Splice round {} of funding payment {} locked as the funding of channel {}", + txid, + payment_id, + channel_id, ); } - - if let Some(input) = shared_input { - let psbt_input = psbt::Input { - witness_utxo: Some(input.previous_utxo.clone()), - ..Default::default() - }; - let weight = ldk_to_bdk_satisfaction_weight(input.satisfaction_weight); - tx_builder.only_witness_utxo().exclude_unconfirmed(); - tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|e| { - log_error!(self.logger, "Failed to add shared input for fee estimation: {e}"); - Error::ChannelSplicingFailed - })?; - } - - let psbt = tx_builder.finish().map_err(|err| { - log_error!(self.logger, "Failed to create temporary drain transaction: {err}"); - err - })?; - - Ok(psbt) - } - - /// Builds a temporary drain transaction and returns the maximum amount that would be sent to - /// the drain output, along with the PSBT for further inspection. - /// - /// The returned PSBT needs no cleanup. Draining to a fixed script means BDK neither reserves a - /// change address nor locks inputs for it. Cancelling it via `cancel_tx_inner` would instead - /// clear the usage mark on the anchor reserve placeholder address, which a pending transaction - /// may have reserved as its change address. - fn get_max_drain_amount( - &self, locked_wallet: &mut PersistedWallet, - drain_script: ScriptBuf, cur_anchor_reserve_sats: u64, fee_rate: FeeRate, - shared_input: Option<&Input>, - ) -> Result<(u64, Psbt), Error> { - let balance = locked_wallet.balance(); - let spendable_amount_sats = - self.get_balances_inner(balance, cur_anchor_reserve_sats).map(|(_, s)| s).unwrap_or(0); - - if spendable_amount_sats == 0 { - log_error!( - self.logger, - "Unable to determine max amount: no spendable funds available." - ); - return Err(Error::InsufficientFunds); - } - - let tmp_psbt = self.build_drain_psbt( - locked_wallet, - drain_script.clone(), - cur_anchor_reserve_sats, - fee_rate, - shared_input, - )?; - - let drain_output_value = tmp_psbt - .unsigned_tx - .output - .iter() - .find(|o| o.script_pubkey == drain_script) - .map(|o| o.value) - .ok_or_else(|| { - log_error!(self.logger, "Failed to find drain output in temporary transaction"); - Error::InsufficientFunds - })?; - - let shared_input_value = shared_input.map(|i| i.previous_utxo.value.to_sat()).unwrap_or(0); - - let max_amount = drain_output_value.to_sat().saturating_sub(shared_input_value); - - if max_amount < DUST_LIMIT_SATS { - log_error!( - self.logger, - "Unable to proceed: available funds would be consumed entirely by fees. \ - Available: {spendable_amount_sats}sats, drain output: {}sats.", - drain_output_value.to_sat(), - ); - return Err(Error::InsufficientFunds); - } - - Ok((max_amount, tmp_psbt)) - } - - /// Returns the maximum amount available for funding a channel, accounting for on-chain fees - /// and anchor reserves. - pub(crate) fn get_max_funding_amount( - &self, cur_anchor_reserve_sats: u64, fee_rate: FeeRate, - ) -> Result { - let mut locked_wallet = self.inner.lock().expect("lock"); - - // Use a dummy P2WSH script (34 bytes) to match the size of a real funding output. - let dummy_p2wsh_script = ScriptBuf::new().to_p2wsh(); - - let (max_amount, _) = self.get_max_drain_amount( - &mut locked_wallet, - dummy_p2wsh_script, - cur_anchor_reserve_sats, - fee_rate, - None, - )?; - - Ok(max_amount) - } - - /// Returns the maximum amount available for splicing into an existing channel, accounting for - /// on-chain fees and anchor reserves, along with the wallet UTXOs to use as inputs. - pub(crate) fn get_max_splice_in_amount( - &self, shared_input: Input, shared_output_script: ScriptBuf, cur_anchor_reserve_sats: u64, - fee_rate: FeeRate, - ) -> Result { - let mut locked_wallet = self.inner.lock().expect("lock"); - - debug_assert!(matches!( - locked_wallet.public_descriptor(KeychainKind::External), - ExtendedDescriptor::Wpkh(_) - )); - debug_assert!(matches!( - locked_wallet.public_descriptor(KeychainKind::Internal), - ExtendedDescriptor::Wpkh(_) - )); - - let (splice_amount, _) = self.get_max_drain_amount( - &mut locked_wallet, - shared_output_script, - cur_anchor_reserve_sats, - fee_rate, - Some(&shared_input), - )?; - - Ok(splice_amount) - } - - pub(crate) fn parse_and_validate_address(&self, address: &Address) -> Result { - Address::::from_str(address.to_string().as_str()) - .map_err(|_| Error::InvalidAddress)? - .require_network(self.config.network) - .map_err(|_| Error::InvalidAddress) - } + Ok(()) + } #[allow(deprecated)] - pub(crate) async fn send_to_address( - &self, address: &bitcoin::Address, send_amount: OnchainSendAmount, - fee_rate: Option, - ) -> Result { - self.parse_and_validate_address(&address)?; - - // Use the set fee_rate or default to fee estimation. - let confirmation_target = ConfirmationTarget::OnchainPayment; - let fee_rate = - fee_rate.unwrap_or_else(|| self.fee_estimator.estimate_fee_rate(confirmation_target)); - + pub(crate) async fn create_funding_transaction( + &self, output_script: ScriptBuf, amount: Amount, confirmation_target: ConfirmationTarget, + locktime: LockTime, + ) -> Result { + let fee_rate = self.fee_estimator.estimate_fee_rate(confirmation_target); let mut locked_persister = self.persister.lock().await; let (psbt, change_set) = { let mut locked_wallet = self.inner.lock().expect("lock"); - - // Prepare the tx_builder. We properly check the reserve requirements (again) further down. - let tx_builder = match send_amount { - OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => { - let mut tx_builder = locked_wallet.build_tx(); - let amount = Amount::from_sat(amount_sats); - tx_builder.add_recipient(address.script_pubkey(), amount).fee_rate(fee_rate); - tx_builder - }, - OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats } - if cur_anchor_reserve_sats > DUST_LIMIT_SATS => - { - let (max_amount, tmp_psbt) = self.get_max_drain_amount( - &mut locked_wallet, - address.script_pubkey(), - cur_anchor_reserve_sats, - fee_rate, - None, - )?; - - let estimated_tx_fee = - locked_wallet.calculate_fee(&tmp_psbt.unsigned_tx).map_err(|e| { - log_error!( - self.logger, - "Failed to calculate fee of temporary transaction: {}", - e - ); - e - })?; - - let mut tx_builder = locked_wallet.build_tx(); - tx_builder - .add_recipient(address.script_pubkey(), Amount::from_sat(max_amount)) - .fee_absolute(estimated_tx_fee); - tx_builder - }, - OnchainSendAmount::AllDrainingReserve - | OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats: _ } => { - let mut tx_builder = locked_wallet.build_tx(); - tx_builder.drain_wallet().drain_to(address.script_pubkey()).fee_rate(fee_rate); - tx_builder - }, - }; + let mut tx_builder = locked_wallet.build_tx(); + tx_builder.add_recipient(output_script, amount).fee_rate(fee_rate).nlocktime(locktime); let mut psbt = match tx_builder.finish() { Ok(psbt) => { - log_trace!(self.logger, "Created PSBT: {:?}", psbt); + log_trace!(self.logger, "Created funding PSBT: {:?}", psbt); psbt }, Err(err) => { - log_error!(self.logger, "Failed to create transaction: {}", err); + log_error!(self.logger, "Failed to create funding transaction: {}", err); return Err(err.into()); }, }; - // Check the reserve requirements (again) and return an error if they aren't met. - match send_amount { - OnchainSendAmount::ExactRetainingReserve { - amount_sats, - cur_anchor_reserve_sats, - } => { - let balance = locked_wallet.balance(); - let spendable_amount_sats = self - .get_balances_inner(balance, cur_anchor_reserve_sats) - .map(|(_, s)| s) - .unwrap_or(0); - let tx_fee_sats = locked_wallet - .calculate_fee(&psbt.unsigned_tx) - .map_err(|e| { - log_error!( - self.logger, - "Failed to calculate fee of candidate transaction: {}", - e - ); - e - })? - .to_sat(); - if spendable_amount_sats < amount_sats.saturating_add(tx_fee_sats) { - log_error!(self.logger, - "Unable to send payment due to insufficient funds. Available: {}sats, Required: {}sats + {}sats fee", - spendable_amount_sats, - amount_sats, - tx_fee_sats, - ); - return Err(Error::InsufficientFunds); - } - }, - OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats } => { - let balance = locked_wallet.balance(); - let spendable_amount_sats = self - .get_balances_inner(balance, cur_anchor_reserve_sats) - .map(|(_, s)| s) - .unwrap_or(0); - let (sent, received) = locked_wallet.sent_and_received(&psbt.unsigned_tx); - let drain_amount = sent - received; - if spendable_amount_sats < drain_amount.to_sat() { - log_error!(self.logger, - "Unable to send payment due to insufficient funds. Available: {}sats, Required: {}", - spendable_amount_sats, - drain_amount, - ); - return Err(Error::InsufficientFunds); - } - }, - _ => {}, - } - match locked_wallet.sign(&mut psbt, SignOptions::default()) { Ok(finalized) => { if !finalized { @@ -1198,7 +1574,7 @@ impl Wallet { } }, Err(err) => { - log_error!(self.logger, "Failed to create transaction: {}", err); + log_error!(self.logger, "Failed to create funding transaction: {}", err); return Err(err.into()); }, } @@ -1215,223 +1591,191 @@ impl Wallet { e })?; - let txid = tx.compute_txid(); - self.broadcaster.broadcast_unclassified_transaction(tx); + Ok(tx) + } - match send_amount { - OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => { - log_info!( - self.logger, - "Created new transaction {} sending {}sats on-chain to address {}", - txid, - amount_sats, - address - ); - }, - OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats } => { - log_info!( - self.logger, - "Created new transaction {} sending available on-chain funds retaining a reserve of {}sats to address {}", - txid, - cur_anchor_reserve_sats, - address, - ); - }, - OnchainSendAmount::AllDrainingReserve => { - log_info!( - self.logger, - "Created new transaction {} sending all available on-chain funds to address {}", - txid, - address - ); + /// Returns a fresh address, served from the address pool so that external handouts consume + /// the oldest revealed index first. + /// + /// Allocating in reveal order keeps the window of revealed-but-unused scripts compact: as + /// soon as a handed-out address is used on-chain, everything before it no longer counts + /// towards a from-seed restore's full-scan stop gap. Minting a fresh index here instead + /// would strand the pooled indices as an ever-growing unused tail in front of every address + /// a restore must discover. The order has one exception: a handout that fails while a + /// concurrent one proceeds can return its address to the pool below an index already handed + /// out. + /// + /// Unlike [`Wallet::pop_pooled_address`], this may wait on persistence, so the handout is + /// made durable before the address is returned: the awaited refill rewrites the pool record + /// (no longer containing the popped index) before topping the pool back up, so a restart + /// never hands the returned address out again. On failure the address instead returns to + /// the pool unhanded-out, with a compensating record write covering the case where the + /// failed refill had already rewritten the record. + pub(crate) async fn get_new_address(&self) -> Result { + let (index, address) = loop { + if let Some(entry) = self.address_pool.lock().expect("lock").available.pop_front() { + break entry; + } + // Another caller may pop what this refill publishes before the re-check, so loop + // rather than assuming a successful refill leaves the pool non-empty. + self.refill_address_pool().await?; + }; + + // Force the record rewrite: a failed handout's push-back can leave the pool over its + // target size, and an early-returning refill would then leave the just-popped index + // durably recorded, handing the address out again after a restart. + match self.refill_address_pool_inner(true).await { + Ok(()) => Ok(address), + Err(e) => { + // The address was never handed out, so return it for the next caller rather + // than leaving its index revealed but unreachable. Reinsert by index: + // concurrent failed handouts complete in pop order, so pushing to the front + // would reverse their segment and let the next successful handout skip past a + // lower index, stranding it behind a used address in a from-seed restore's scan. + { + let mut locked_pool = self.address_pool.lock().expect("lock"); + let position = locked_pool.available.partition_point(|(i, _)| *i < index); + locked_pool.available.insert(position, (index, address)); + } + // The refill may have failed after rewriting the record, which then durably + // excludes the pushed-back index; rewrite it from the restored pool so a crash + // before the next successful refill doesn't strand the index outside the pool. + self.rewrite_pool_record().await; + Err(e) }, } - - Ok(txid) } - pub(crate) async fn select_confirmed_utxos( - &self, must_spend: Vec, must_pay_to: &[TxOut], fee_rate: FeeRate, - ) -> Result { - let mut locked_persister = self.persister.lock().await; - let (coin_selection, change_set) = { - let mut locked_wallet = self.inner.lock().expect("lock"); - - debug_assert!(matches!( - locked_wallet.public_descriptor(KeychainKind::External), - ExtendedDescriptor::Wpkh(_) - )); - debug_assert!(matches!( - locked_wallet.public_descriptor(KeychainKind::Internal), - ExtendedDescriptor::Wpkh(_) - )); - - let mut tx_builder = locked_wallet.build_tx(); - tx_builder.only_witness_utxo(); - - for input in &must_spend { - let psbt_input = psbt::Input { - witness_utxo: Some(input.previous_utxo.clone()), - ..Default::default() - }; - let weight = ldk_to_bdk_satisfaction_weight(input.satisfaction_weight); - tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|_| ())?; - } + /// Returns an address whose reveal is already durably persisted, or `None` if the pool is + /// exhausted. + /// + /// This is safe to call from sync callbacks (e.g., [`SignerProvider`]) that LDK invokes on + /// runtime worker threads while holding channel locks: it never waits on persistence, only + /// popping from the pre-persisted pool and scheduling a background refill. Blocking such a + /// callback on persistence can deadlock the runtime, as other tasks blocking synchronously on + /// the same channel locks may capture the remaining workers, leaving none to drive the + /// persistence future the callback would wait on. + /// + /// Failing closed on an empty pool (rather than revealing an unpersisted address) ensures we + /// never hand out a script that would go unwatched if the node crashed before its reveal + /// landed: incremental chain syncs only query scripts the persisted wallet has revealed. + /// + /// The handout itself is not persisted: if the node restarts before the refill scheduled here + /// rewrites the pool record, the popped address may be handed out again after the restart. + /// Its reveal is durable either way, so the script always stays watched — the cost is bounded + /// address reuse, not fund visibility. + pub(crate) fn pop_pooled_address(self: &Arc) -> Option { + let popped = self.address_pool.lock().expect("lock").available.pop_front(); - for output in must_pay_to { - tx_builder.add_recipient(output.script_pubkey.clone(), output.value); + // Spawning cancellable lets shutdown abort an in-flight refill rather than wait on it. + // Aborting mid-refill (or dropping a refill spawned during shutdown) is safe: the reveals + // are staged with the persister in the same critical section that takes them from the + // wallet, and nothing is published whose persistence the refill did not see complete. + let wallet = Arc::clone(self); + self.runtime.spawn_cancellable_background_task(async move { + if let Err(e) = wallet.refill_address_pool().await { + log_error!(wallet.logger, "Failed to refill the address pool: {}", e); } + }); - tx_builder.fee_rate(fee_rate); - tx_builder.exclude_unconfirmed(); + popped.map(|(_, address)| address) + } - let unsigned_tx = tx_builder - .finish() - .map_err(|e| { - log_error!(self.logger, "Failed to select confirmed UTXOs: {}", e); - })? - .unsigned_tx; + /// Tops the address pool up to [`ADDRESS_POOL_TARGET_SIZE`], publishing newly revealed + /// addresses only after their reveal has been durably persisted. + pub(crate) async fn refill_address_pool(&self) -> Result<(), Error> { + self.refill_address_pool_inner(false).await + } - let confirmed_utxos = unsigned_tx - .input - .iter() - .filter(|txin| { - must_spend.iter().all(|input| input.outpoint != txin.previous_output) - }) - .filter_map(|txin| { - locked_wallet - .tx_details(txin.previous_output.txid) - .map(|tx_details| tx_details.tx.deref().clone()) - .map(|prevtx| ConfirmedUtxo::new_p2wpkh(prevtx, txin.previous_output.vout)) - }) - .collect::, ()>>()?; + /// [`Wallet::refill_address_pool`], where `force_record_rewrite` makes the pool-record + /// rewrite unconditional: a pool at or over its target size otherwise skips it, which after + /// a pop would leave the popped index in the record. + async fn refill_address_pool_inner(&self, force_record_rewrite: bool) -> Result<(), Error> { + let _refill_guard = self.address_pool_refill_lock.lock().await; - if unsigned_tx.output.len() > must_pay_to.len() + 1 { - log_error!( - self.logger, - "Unexpected number of change outputs during coin selection: {}", - unsigned_tx.output.len() - must_pay_to.len(), - ); - return Err(()); + if !force_record_rewrite { + let locked_pool = self.address_pool.lock().expect("lock"); + if locked_pool.unpublished.is_empty() + && locked_pool.available.len() >= ADDRESS_POOL_TARGET_SIZE + { + return Ok(()); } + } - let change_output = unsigned_tx - .output - .into_iter() - .find(|txout| must_pay_to.iter().all(|output| output != txout)); - let change_set = if change_output.is_some() { - Some(locked_wallet.take_staged().unwrap_or_default()) - } else { - None - }; - - (CoinSelection { confirmed_utxos, change_output }, change_set) + let mut locked_persister = self.persister.lock().await; + let indices = { + let mut locked_wallet = self.inner.lock().expect("lock"); + let mut locked_pool = self.address_pool.lock().expect("lock"); + let needed = ADDRESS_POOL_TARGET_SIZE + .saturating_sub(locked_pool.available.len() + locked_pool.unpublished.len()); + for _ in 0..needed { + let address_info = locked_wallet.reveal_next_address(KeychainKind::External); + locked_pool.unpublished.push((address_info.index, address_info.address)); + } + // Hand the reveals straight to the persister: this refill may run as a task the + // runtime aborts at shutdown, and holding the taken change set across an await + // would lose the reveals if the abort lands there — a later refill run would then + // publish addresses no persisted wallet state covers. + locked_persister.stage(locked_wallet.take_staged().unwrap_or_default()); + locked_pool + .available + .iter() + .chain(locked_pool.unpublished.iter()) + .map(|(index, _)| *index) + .collect::>() }; - if let Some(change_set) = change_set { - locked_persister.persist_changeset(change_set).await.map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - })?; - } + // Persist the pool record before the reveals. A crash between the two writes then leaves + // record entries the persisted wallet doesn't cover, which reloading drops and the next + // refill re-derives to the same indices — rather than durably revealed indices missing + // from the record, which no path would ever pool or hand out again (burning them). + // Writing the record first also drops popped indices from it as early as possible, + // narrowing the restart window in which a handed-out address is handed out again. + // Skip the reveal flush when the record write fails: reveals made durable without + // record coverage would, after a crash, be indices no path ever pools or hands out + // again — permanently skipped in the keychain, widening the gap a restore from seed + // must scan across. Retained in the persister instead, they either flush with a later + // persist call or die with the process, in which case the next run re-derives the same + // indices. (An unrelated persist call can still flush them before the record retry + // succeeds, so the window is narrowed, not closed.) + locked_persister.persist_address_pool(indices).await.map_err(|e| { + log_error!(self.logger, "Failed to persist address pool: {}", e); + Error::PersistenceFailed + })?; + // On failure the reveals stay in `unpublished` (never handed out) and the persister + // retains the change set, so the next refill run retries both. + locked_persister.persist_staged().await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + })?; - Ok(coin_selection) + // Both writes are durable, so the addresses may be handed out. + let mut locked_pool = self.address_pool.lock().expect("lock"); + let unpublished = core::mem::take(&mut locked_pool.unpublished); + locked_pool.available.extend(unpublished); + Ok(()) } - fn list_confirmed_utxos_inner(&self) -> Result, ()> { - let locked_wallet = self.inner.lock().expect("lock"); - let mut utxos = Vec::new(); - let confirmed_txs: Vec = locked_wallet - .transactions() - .filter(|t| t.chain_position.is_confirmed()) - .map(|t| t.tx_node.txid) - .collect(); - let unspent_confirmed_utxos = - locked_wallet.list_unspent().filter(|u| confirmed_txs.contains(&u.outpoint.txid)); - - for u in unspent_confirmed_utxos { - let script_pubkey = u.txout.script_pubkey; - match script_pubkey.witness_version() { - Some(version @ WitnessVersion::V0) => { - // According to the SegWit rules of [BIP 141] a witness program is defined as: - // > A scriptPubKey (or redeemScript as defined in BIP16/P2SH) that consists of - // > a 1-byte push opcode (one of OP_0,OP_1,OP_2,.. .,OP_16) followed by a direct - // > data push between 2 and 40 bytes gets a new special meaning. The value of - // > the first push is called the "version byte". The following byte vector - // > pushed is called the "witness program"." - // - // We therefore skip the first byte we just read via `witness_version` and use - // the rest (i.e., the data push) as the raw bytes to construct the - // `WitnessProgram` below. - // - // [BIP 141]: https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#witness-program - let witness_bytes = &script_pubkey.as_bytes()[2..]; - let witness_program = - WitnessProgram::new(version, witness_bytes).map_err(|e| { - log_error!(self.logger, "Failed to retrieve script payload: {}", e); - })?; - - let wpkh = WPubkeyHash::from_slice(&witness_program.program().as_bytes()) - .map_err(|e| { - log_error!(self.logger, "Failed to retrieve script payload: {}", e); - })?; - let utxo = Utxo::new_v0_p2wpkh(u.outpoint, u.txout.value, &wpkh); - utxos.push(utxo); - }, - Some(version @ WitnessVersion::V1) => { - // According to the SegWit rules of [BIP 141] a witness program is defined as: - // > A scriptPubKey (or redeemScript as defined in BIP16/P2SH) that consists of - // > a 1-byte push opcode (one of OP_0,OP_1,OP_2,.. .,OP_16) followed by a direct - // > data push between 2 and 40 bytes gets a new special meaning. The value of - // > the first push is called the "version byte". The following byte vector - // > pushed is called the "witness program"." - // - // We therefore skip the first byte we just read via `witness_version` and use - // the rest (i.e., the data push) as the raw bytes to construct the - // `WitnessProgram` below. - // - // [BIP 141]: https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#witness-program - let witness_bytes = &script_pubkey.as_bytes()[2..]; - let witness_program = - WitnessProgram::new(version, witness_bytes).map_err(|e| { - log_error!(self.logger, "Failed to retrieve script payload: {}", e); - })?; - - XOnlyPublicKey::from_slice(&witness_program.program().as_bytes()).map_err( - |e| { - log_error!(self.logger, "Failed to retrieve script payload: {}", e); - }, - )?; - - let utxo = Utxo { - outpoint: u.outpoint, - output: TxOut { - value: u.txout.value, - script_pubkey: ScriptBuf::new_witness_program(&witness_program), - }, - satisfaction_weight: 1 /* empty script_sig */ * WITNESS_SCALE_FACTOR as u64 + - 1 /* witness items */ + 1 /* schnorr sig len */ + 64, // schnorr sig - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - }; - utxos.push(utxo); - }, - Some(version) => { - log_error!(self.logger, "Unexpected witness version: {}", version,); - }, - None => { - log_error!( - self.logger, - "Tried to use a non-witness script. This must never happen." - ); - panic!("Tried to use a non-witness script. This must never happen."); - }, - } - } - - Ok(utxos) + /// Best-effort rewrite of the pool record from the pool's current contents, used to + /// re-include a pushed-back index whose handout's record write succeeded before the handout + /// failed. Failures are only logged: the pool still covers the index in memory and the next + /// successful refill rewrites the record anyway, so only a crash before then strands the + /// index outside the pool. + async fn rewrite_pool_record(&self) { + let mut locked_persister = self.persister.lock().await; + let indices: Vec = { + let locked_pool = self.address_pool.lock().expect("lock"); + locked_pool + .available + .iter() + .chain(locked_pool.unpublished.iter()) + .map(|(index, _)| *index) + .collect() + }; + let _ = locked_persister.persist_address_pool(indices).await; } - #[allow(deprecated)] - async fn get_change_script_inner(&self) -> Result { + pub(crate) async fn get_new_internal_address(&self) -> Result { let mut locked_persister = self.persister.lock().await; let (address_info, change_set) = { let mut locked_wallet = self.inner.lock().expect("lock"); @@ -1440,2660 +1784,9149 @@ impl Wallet { }; locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed })?; - Ok(address_info.address.script_pubkey()) + Ok(address_info.address) } - #[allow(deprecated)] - pub(crate) fn sign_owned_inputs(&self, unsigned_tx: Transaction) -> Result { - let locked_wallet = self.inner.lock().expect("lock"); - - let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).map_err(|e| { - log_error!(self.logger, "Failed to construct PSBT: {}", e); + pub(crate) async fn cancel_tx(&self, tx: Transaction) -> Result<(), Error> { + let mut locked_persister = self.persister.lock().await; + let change_set = { + let mut locked_wallet = self.inner.lock().expect("lock"); + Self::cancel_tx_inner(&mut locked_wallet, tx); + 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 })?; - // Use list_output rather than get_utxo to include outputs spent by unconfirmed - // transactions (e.g., a prior splice being replaced via RBF), which a synced wallet would - // otherwise no longer treat as an owned UTXO. - let mut wallet_outputs: HashMap = - locked_wallet.list_output().map(|output| (output.outpoint, output)).collect(); - for (i, txin) in psbt.unsigned_tx.input.iter().enumerate() { - if let Some(utxo) = wallet_outputs.remove(&txin.previous_output) { - psbt.inputs[i] = locked_wallet.get_psbt_input(utxo, None, true).map_err(|e| { - log_error!(self.logger, "Failed to construct PSBT input: {}", e); - })?; - } - } - - let mut sign_options = SignOptions::default(); - sign_options.trust_witness_utxo = true; - - match locked_wallet.sign(&mut psbt, sign_options) { - Ok(finalized) => debug_assert!(!finalized), - Err(e) => { - log_error!(self.logger, "Failed to sign owned inputs: {}", e); - return Err(()); - }, - } - match psbt.extract_tx() { - Ok(tx) => Ok(tx), - Err(bitcoin::psbt::ExtractTxError::MissingInputValue { tx }) => Ok(tx), - Err(e) => { - log_error!(self.logger, "Failed to extract transaction: {}", e); - Err(()) - }, - } + Ok(()) } - #[allow(deprecated)] - fn sign_psbt_inner(&self, mut psbt: Psbt) -> Result { - let locked_wallet = self.inner.lock().expect("lock"); - - // While BDK populates both `witness_utxo` and `non_witness_utxo` fields, LDK does not. As - // BDK by default doesn't trust the witness UTXO to account for the Segwit bug, we must - // disable it here as otherwise we fail to sign. - let mut sign_options = SignOptions::default(); - sign_options.trust_witness_utxo = true; - - match locked_wallet.sign(&mut psbt, sign_options) { - Ok(_finalized) => { - // BDK will fail to finalize for all LDK-provided inputs of the PSBT. Unfortunately - // we can't check more fine grained if it succeeded for all the other inputs here, - // so we just ignore the returned `finalized` bool. - }, - Err(err) => { - log_error!(self.logger, "Failed to sign transaction: {}", err); - return Err(()); - }, + fn cancel_tx_inner( + locked_wallet: &mut PersistedWallet, tx: Transaction, + ) { + for txout in tx.output { + if let Some((keychain, index)) = locked_wallet.derivation_of_spk(txout.script_pubkey) { + // This mirrors the removed BDK helper: it only frees superficial usage marks. + locked_wallet.unmark_used(keychain, index); + } } - - let tx = psbt.extract_tx().map_err(|e| { - log_error!(self.logger, "Failed to extract transaction: {}", e); - () - })?; - - Ok(tx) } - /// Classifies an on-chain broadcast handed to the broadcaster by LDK, recording a payment for it - /// before it is sent when it affects this node's wallet. - pub(crate) async fn classify_broadcast( - &self, tx: &Transaction, tx_type: &LdkTransactionType, - ) -> Result<(), Error> { - match tx_type { - LdkTransactionType::Funding { channels } => { - self.classify_funding(tx, channels, tx_type.clone().into()).await - }, - LdkTransactionType::InteractiveFunding { candidates } => { - self.classify_interactive_funding(tx, candidates, tx_type.clone().into()).await - }, - LdkTransactionType::UnilateralClose { .. } => Ok(()), - LdkTransactionType::CooperativeClose { .. } - | LdkTransactionType::AnchorBump { .. } - | LdkTransactionType::Claim { .. } - | LdkTransactionType::Sweep { .. } => { - self.classify_regular_broadcast(tx, tx_type.clone().into()).await - }, - } + /// 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 + }) } - /// Records a single-channel funding (channel open) broadcast as a pending on-chain payment, - /// tagged with its transaction type. Amount and fee come from the wallet's view of the - /// transaction. Batched funding is left for wallet sync. - async fn classify_funding( - &self, tx: &Transaction, channels: &[(PublicKey, ChannelId)], tx_type: TransactionType, - ) -> Result<(), Error> { - if channels.len() != 1 { - if channels.len() > 1 { - log_trace!( - self.logger, - "Skipping funding classification for batched broadcast ({} channels)", - channels.len() - ); - } + /// 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 + }) + } - let (_counterparty_node_id, channel_id) = channels[0]; - let txid = tx.compute_txid(); - let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); - - // A funding transaction that moves no wallet funds carries nothing to record — e.g. LDK - // re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding path, - // including splices the interactive-funding classification deliberately declined (no - // local contribution, or a splice-out moving no wallet funds). Recording it here would - // mint a zero-amount payment that nothing ever confirms. Skip on the wallet-derived - // amount alone — the condition `classify_interactive_funding` declines on; anything - // declined there must be skipped here, or its re-broadcast resurrects the record. The fee - // is no participation signal: the wallet resolves a splice's shared input whenever the - // previous funding transaction touched it (e.g. it funded the original channel open). - // - // TODO(https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4878): The - // re-typed re-broadcasts are upstream behavior that should be fixed in `rust-lightning`: - // the re-offer ought to keep its `InteractiveFunding` classification, or not recur at - // all. `zero_conf_splice_out_funding_rebroadcast_canary` pins the current behavior by - // asserting the log line below; when it fails against a newer LDK, re-evaluate whether - // this skip still sees traffic. - if amount_msat == Some(0) { - log_trace!( - self.logger, - "Not recording channel-funding broadcast {} as a payment: no wallet-level activity", - txid, + pub(crate) fn get_balances( + &self, total_anchor_channels_reserve_sats: u64, + ) -> Result<(u64, u64), Error> { + let balance = self.inner.lock().expect("lock").balance(); + + // Make sure `list_confirmed_utxos` returns at least one `Utxo` we could use to spend/bump + // Anchors if we have any confirmed amounts. + #[cfg(debug_assertions)] + if balance.confirmed != Amount::ZERO { + debug_assert!( + self.list_confirmed_utxos_inner().map_or(false, |v| !v.is_empty()), + "Confirmed amounts should always be available for Anchor spending" ); - return Ok(()); } - let payment_id = PaymentId(txid.to_byte_array()); + self.get_balances_inner(balance, total_anchor_channels_reserve_sats) + } - // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed - // and carrying wallet-view figures; `funding_reclassification_update` declines the - // downgrade, leaving no trace that a re-broadcast arrived. Log the arrival so tests can - // observe the traffic. The read cannot go stale: only the broadcast loop writes - // interactive-funding classifications, and it runs this classification too. - if let Some(current) = self.payment_store.get(&payment_id).await? { - if matches!( - current.kind, - PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } - ) { - log_trace!( - self.logger, - "Keeping interactive-funding classification over funding-typed rebroadcast {}", - txid, - ); - } - } + fn get_balances_inner( + &self, balance: Balance, total_anchor_channels_reserve_sats: u64, + ) -> Result<(u64, u64), Error> { + let (total, spendable) = ( + balance.total().to_sat(), + balance.trusted_spendable().to_sat().saturating_sub(total_anchor_channels_reserve_sats), + ); - let details = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(tx_type), - }, - amount_msat, - fee_paid_msat, - direction, - PaymentStatus::Pending, - ); - self.persist_funding_payment(details, Vec::new()).await?; - log_debug!( - self.logger, - "Recorded channel-funding broadcast {} for channel {}", - txid, - channel_id, - ); - Ok(()) + Ok((total, spendable)) } - /// Records an interactive-funding broadcast (splice, or a V2 dual-funded open) as a pending - /// on-chain payment, tagged with its transaction type. Amount and fee are this node's share, - /// derived from the active candidate's contributions; broadcasts we didn't contribute to, or - /// that don't move wallet funds, are left for wallet sync. - async fn classify_interactive_funding( - &self, tx: &Transaction, candidates: &[FundingCandidate], tx_type: TransactionType, - ) -> Result<(), Error> { - // `InteractiveFunding` carries the full negotiated history; the currently-broadcast - // candidate is the last entry, earlier entries are RBF predecessors. - let active = match candidates.last() { - Some(c) => c, - None => return Ok(()), - }; - let first = match candidates.first() { - Some(c) => c, - None => return Ok(()), - }; - - let txid = tx.compute_txid(); - debug_assert_eq!(active.txid, txid, "broadcast tx must match the active candidate"); + pub(crate) fn get_spendable_amount_sats( + &self, total_anchor_channels_reserve_sats: u64, + ) -> Result { + self.get_balances(total_anchor_channels_reserve_sats).map(|(_, s)| s) + } - let aggregate = aggregate_local_stakes(active); - let amount_msat = match aggregate.amount_msat { - Some(amt) => Some(amt), - None => { - log_trace!( - self.logger, - "Not recording interactive-funding broadcast {} as a payment: no local contribution", - txid, - ); - return Ok(()); - }, + fn build_drain_psbt( + &self, locked_wallet: &mut PersistedWallet, + drain_script: ScriptBuf, cur_anchor_reserve_sats: u64, fee_rate: FeeRate, + shared_input: Option<&Input>, + ) -> Result { + let anchor_address = if cur_anchor_reserve_sats > DUST_LIMIT_SATS { + Some(locked_wallet.peek_address(KeychainKind::Internal, 0)) + } else { + None }; - let fee_paid_msat = aggregate.fee_paid_msat; - let direction = aggregate.direction; - // A contribution doesn't mean the tx touches our on-chain wallet: a splice-out to an - // external address sends channel funds to a third party, which BDK sees as zero wallet - // movement. Nothing for the on-chain payment store to record, so skip it. - let (wallet_amount_msat, _wallet_fee_msat, _wallet_direction) = - self.onchain_payment_fields(tx); - if wallet_amount_msat == Some(0) { - log_trace!( - self.logger, - "Not recording interactive-funding broadcast {} as a payment: no wallet-level activity", - txid, + let mut tx_builder = locked_wallet.build_tx(); + tx_builder.drain_wallet().drain_to(drain_script).fee_rate(fee_rate); + + if let Some(address_info) = anchor_address { + tx_builder.add_recipient( + address_info.address.script_pubkey(), + Amount::from_sat(cur_anchor_reserve_sats), ); - return Ok(()); } - // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable - // across RBF replacements. - let payment_id = PaymentId(first.txid.to_byte_array()); + if let Some(input) = shared_input { + let psbt_input = psbt::Input { + witness_utxo: Some(input.previous_utxo.clone()), + ..Default::default() + }; + let weight = ldk_to_bdk_satisfaction_weight(input.satisfaction_weight); + tx_builder.only_witness_utxo().exclude_unconfirmed(); + tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|e| { + log_error!(self.logger, "Failed to add shared input for fee estimation: {e}"); + Error::ChannelSplicingFailed + })?; + } - // Record every candidate's figures (`None` for any round we didn't contribute to, e.g. a - // counterparty-initiated splice our `splice_in` later joined via RBF) so the confirmed - // candidate's amount/fee can be applied on confirmation, even if it isn't the last one - // broadcast or one we contributed to. - let candidate_records: Vec = candidates - .iter() - .map(|candidate| { - let aggregate = aggregate_local_stakes(candidate); - FundingTxCandidate { - txid: candidate.txid, - amount_msat: aggregate.amount_msat, - fee_paid_msat: aggregate.fee_paid_msat, - } - }) - .collect(); + let psbt = tx_builder.finish().map_err(|err| { + log_error!(self.logger, "Failed to create temporary drain transaction: {err}"); + err + })?; - let details = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(tx_type), - }, - amount_msat, - fee_paid_msat, - direction, - PaymentStatus::Pending, - ); - self.persist_funding_payment(details, candidate_records).await?; - log_debug!( - self.logger, - "Recorded interactive-funding broadcast {} ({} candidates, {} channels)", - txid, - candidates.len(), - active.channels.len(), - ); - Ok(()) + Ok(psbt) } - /// Records a non-funding LDK broadcast as an on-chain payment, tagged with its transaction type. - /// Wallet sync later refreshes confirmation status while preserving the type. - async fn classify_regular_broadcast( - &self, tx: &Transaction, tx_type: TransactionType, - ) -> Result<(), Error> { - let txid = tx.compute_txid(); - let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); + /// Builds a temporary drain transaction and returns the maximum amount that would be sent to + /// the drain output, along with the PSBT for further inspection. + /// + /// The returned PSBT needs no cleanup. Draining to a fixed script means BDK neither reserves a + /// change address nor locks inputs for it. Cancelling it via `cancel_tx_inner` would instead + /// clear the usage mark on the anchor reserve placeholder address, which a pending transaction + /// may have reserved as its change address. + fn get_max_drain_amount( + &self, locked_wallet: &mut PersistedWallet, + drain_script: ScriptBuf, cur_anchor_reserve_sats: u64, fee_rate: FeeRate, + shared_input: Option<&Input>, + ) -> Result<(u64, Psbt), Error> { + let balance = locked_wallet.balance(); + let spendable_amount_sats = + self.get_balances_inner(balance, cur_anchor_reserve_sats).map(|(_, s)| s).unwrap_or(0); - if amount_msat == Some(0) && fee_paid_msat == Some(0) { - log_trace!( + if spendable_amount_sats == 0 { + log_error!( self.logger, - "Not recording classified broadcast {} as a payment: no wallet-level activity", - txid, + "Unable to determine max amount: no spendable funds available." ); - return Ok(()); + return Err(Error::InsufficientFunds); } - let details = PaymentDetails::new( - PaymentId(txid.to_byte_array()), - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(tx_type), - }, - amount_msat, - fee_paid_msat, - direction, - PaymentStatus::Pending, - ); - self.payment_store.insert_or_update(details).await?; - log_debug!(self.logger, "Recorded classified on-chain broadcast {}", txid); - Ok(()) - } + let tmp_psbt = self.build_drain_psbt( + locked_wallet, + drain_script.clone(), + cur_anchor_reserve_sats, + fee_rate, + shared_input, + )?; - /// Writes a freshly-classified funding payment to the authoritative payment store and adds a - /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. - async fn persist_funding_payment( - &self, details: PaymentDetails, candidates: Vec, - ) -> Result<(), Error> { - // Hold the cross-store lock across both writes so a funding confirmation never observes - // the record classified but the candidate history it needs still missing. - let _guard = self.funding_payment_update_lock.lock().await; - - // Everything this write does depends on the record's current state, so all of it must be - // decided inside the store's critical section. When a record exists — no matter when it - // appeared — only the classification (`tx_type`) and the figures of whichever candidate - // the record's state makes authoritative are merged: a full merge of the fresh - // Pending/Unconfirmed details would downgrade the confirmation state the wallet-sync - // events own. Which candidate is authoritative is equally stateful: substituting the - // confirmed candidate's figures requires seeing the confirmation. Selected from a read - // taken before the lock, the choice goes stale when a confirmation lands in between — - // the update still names the actively-broadcast candidate, the confirmed-figures guard - // then rightly refuses it, and the record is left with figures no classification derived. - let id = details.id; - let mut update = None; - self.payment_store - .mutate(&id, |existing| { - let reclassification = - funding_reclassification_update(details.clone(), &candidates, existing); - update = Some(reclassification.clone()); - match existing { - None => Some(details.clone()), - Some(current) => { - let mut updated = current.clone(); - updated.update(reclassification).then_some(updated) - }, - } - }) - .await?; - let update = update.expect("the mutate closure always runs"); - - // The pending index must exist exactly while the authoritative record is Pending: - // graduation and rebroadcast read it, and a graduated payment must not be re-indexed. - // Deciding by the post-write status rather than by whether the write inserted also - // repairs a missing index — a crash or failed write between the two stores leaves a - // Pending record with no entry, and a merge alone would never recreate it, leaving the - // payment unable to graduate and its txids unmapped. - // - // The status must be read inside the pending store's critical section. Graduation writes - // `Succeeded` before removing the entry, so a read there that still observes `Pending` - // is ordered before the removal, which then also deletes anything inserted here. A - // status read taken before this write goes stale when graduation lands in between, and - // would re-index the graduated payment. - let payment_store = Arc::clone(&self.payment_store); - self.pending_payment_store - .mutate_async(&id, move |existing| async move { - // The record was written above and removal serializes on the cross-store lock held - // here, so absence means the write failed out; fall back to the fresh details. - let recorded = payment_store.get(&id).await?.unwrap_or(details); - Ok(match existing { - // The inserted entry embeds the post-write record rather than the fresh - // details, so a confirmation wallet sync already recorded keeps driving - // graduation. - None if recorded.status == PaymentStatus::Pending => { - Some(PendingPaymentDetails::new(recorded, Vec::new(), candidates)) - }, - // The payment already advanced beyond Pending: the graduation path removed - // the entry and it must not be re-created. - None => None, - // The entry predates this classification — wallet sync recorded the - // transaction before it was classified (its arms and this write pair - // serialize on the cross-store lock, so nothing lands in between): merge - // only the classification into the existing entry. - Some(mut entry) => { - let pending_update = PendingPaymentDetailsUpdate { - id, - payment_update: Some(update), - conflicting_txids: None, - candidates, - }; - entry.update(pending_update).then_some(entry) - }, - }) - }) - .await?; - Ok(()) - } + let drain_output_value = tmp_psbt + .unsigned_tx + .output + .iter() + .find(|o| o.script_pubkey == drain_script) + .map(|o| o.value) + .ok_or_else(|| { + log_error!(self.logger, "Failed to find drain output in temporary transaction"); + Error::InsufficientFunds + })?; - /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. - pub(crate) fn onchain_payment_fields( - &self, tx: &Transaction, - ) -> (Option, Option, PaymentDirection) { - let locked_wallet = self.inner.lock().expect("lock"); - self.onchain_payment_fields_locked(&locked_wallet, tx) - } + let shared_input_value = shared_input.map(|i| i.previous_utxo.value.to_sat()).unwrap_or(0); - /// [`Self::onchain_payment_fields`] against an already-locked wallet, so callers that hold the - /// lock (e.g. [`Self::create_payment_from_tx`]) can reuse the derivation without re-locking. - fn onchain_payment_fields_locked( - &self, locked_wallet: &PersistedWallet, tx: &Transaction, - ) -> (Option, Option, PaymentDirection) { - let fee = locked_wallet.calculate_fee(tx).unwrap_or(Amount::ZERO); - let (sent, received) = locked_wallet.sent_and_received(tx); - let fee_sat = fee.to_sat(); + let max_amount = drain_output_value.to_sat().saturating_sub(shared_input_value); - let (direction, amount_msat) = if sent > received { - ( - PaymentDirection::Outbound, - Some( - (sent.to_sat().saturating_sub(fee_sat).saturating_sub(received.to_sat())) - * 1000, - ), - ) - } else { - ( - PaymentDirection::Inbound, - Some( - received.to_sat().saturating_sub(sent.to_sat().saturating_sub(fee_sat)) * 1000, - ), - ) - }; + if max_amount < DUST_LIMIT_SATS { + log_error!( + self.logger, + "Unable to proceed: available funds would be consumed entirely by fees. \ + Available: {spendable_amount_sats}sats, drain output: {}sats.", + drain_output_value.to_sat(), + ); + return Err(Error::InsufficientFunds); + } - (amount_msat, Some(fee_sat * 1000), direction) + Ok((max_amount, tmp_psbt)) } - fn create_payment_from_tx( - &self, locked_wallet: &PersistedWallet, txid: Txid, - payment_id: PaymentId, tx: &Transaction, payment_status: PaymentStatus, - confirmation_status: ConfirmationStatus, - ) -> PaymentDetails { - // TODO: It would be great to introduce additional variants for - // `ChannelFunding` and `ChannelClosing`. For the former, we could just - // take a reference to `ChannelManager` here and check against - // `list_channels`. But for the latter the best approach is much less - // clear: for force-closes/HTLC spends we should be good querying - // `OutputSweeper::tracked_spendable_outputs`, but regular channel closes - // (i.e., `SpendableOutputDescriptor::StaticOutput` variants) are directly - // spent to a wallet address. The only solution I can come up with is to - // create and persist a list of 'static pending outputs' that we could use - // here to determine the `PaymentKind`, but that's not really satisfactory, so - // we're punting on it until we can come up with a better solution. - - let kind = PaymentKind::Onchain { txid, status: confirmation_status, tx_type: None }; - - let (amount_msat, fee_paid_msat, direction) = - self.onchain_payment_fields_locked(locked_wallet, tx); + /// Returns the maximum amount available for funding a channel, accounting for on-chain fees + /// and anchor reserves. + pub(crate) fn get_max_funding_amount( + &self, cur_anchor_reserve_sats: u64, fee_rate: FeeRate, + ) -> Result { + let mut locked_wallet = self.inner.lock().expect("lock"); - PaymentDetails::new(payment_id, kind, amount_msat, fee_paid_msat, direction, payment_status) - } + // Use a dummy P2WSH script (34 bytes) to match the size of a real funding output. + let dummy_p2wsh_script = ScriptBuf::new().to_p2wsh(); - fn create_pending_payment_from_tx( - &self, payment: PaymentDetails, conflicting_txids: Vec, - ) -> PendingPaymentDetails { - PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()) - } + let (max_amount, _) = self.get_max_drain_amount( + &mut locked_wallet, + dummy_p2wsh_script, + cur_anchor_reserve_sats, + fee_rate, + None, + )?; - /// Removes the payment with the given id from the payment store, along with any pending-store - /// entry indexing its txids. An orphaned entry would keep resolving those txids to the removed - /// record — routing later wallet-sync events to a payment that no longer exists — and nothing - /// would ever clean it up, since graduation only removes entries whose record is still live. - pub(crate) async fn remove_payment(&self, payment_id: &PaymentId) -> Result<(), Error> { - // Hold the cross-store lock so the two-store removal cannot interleave with a sync arm's - // or classification's resolve-then-write sequence. The pending entry goes first: a failure - // in between then leaves an unindexed record (benign, and the retry removes it) rather - // than an entry indexing a removed record. - let _guard = self.funding_payment_update_lock.lock().await; - self.pending_payment_store.remove(payment_id).await?; - self.payment_store.remove(payment_id).await + Ok(max_amount) } - async fn find_payment_by_txid(&self, target_txid: Txid) -> Result, Error> { + /// Returns the maximum amount available for splicing into an existing channel, accounting for + /// on-chain fees and anchor reserves, along with the wallet UTXOs to use as inputs. + pub(crate) fn get_max_splice_in_amount( + &self, shared_input: Input, shared_output_script: ScriptBuf, cur_anchor_reserve_sats: u64, + fee_rate: FeeRate, + ) -> Result { + let mut locked_wallet = self.inner.lock().expect("lock"); + + debug_assert!(matches!( + locked_wallet.public_descriptor(KeychainKind::External), + ExtendedDescriptor::Wpkh(_) + )); + debug_assert!(matches!( + locked_wallet.public_descriptor(KeychainKind::Internal), + ExtendedDescriptor::Wpkh(_) + )); + + let (splice_amount, _) = self.get_max_drain_amount( + &mut locked_wallet, + shared_output_script, + cur_anchor_reserve_sats, + fee_rate, + Some(&shared_input), + )?; + + Ok(splice_amount) + } + + pub(crate) fn parse_and_validate_address(&self, address: &Address) -> Result { + Address::::from_str(address.to_string().as_str()) + .map_err(|_| Error::InvalidAddress)? + .require_network(self.config.network) + .map_err(|_| Error::InvalidAddress) + } + + #[allow(deprecated)] + pub(crate) async fn send_to_address( + &self, address: &bitcoin::Address, send_amount: OnchainSendAmount, + fee_rate: Option, + ) -> Result { + self.parse_and_validate_address(&address)?; + + // Use the set fee_rate or default to fee estimation. + let confirmation_target = ConfirmationTarget::OnchainPayment; + let fee_rate = + fee_rate.unwrap_or_else(|| self.fee_estimator.estimate_fee_rate(confirmation_target)); + + let mut locked_persister = self.persister.lock().await; + let (psbt, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); + + // Prepare the tx_builder. We properly check the reserve requirements (again) further down. + let tx_builder = match send_amount { + OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => { + let mut tx_builder = locked_wallet.build_tx(); + let amount = Amount::from_sat(amount_sats); + tx_builder.add_recipient(address.script_pubkey(), amount).fee_rate(fee_rate); + tx_builder + }, + OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats } + if cur_anchor_reserve_sats > DUST_LIMIT_SATS => + { + let (max_amount, tmp_psbt) = self.get_max_drain_amount( + &mut locked_wallet, + address.script_pubkey(), + cur_anchor_reserve_sats, + fee_rate, + None, + )?; + + let estimated_tx_fee = + locked_wallet.calculate_fee(&tmp_psbt.unsigned_tx).map_err(|e| { + log_error!( + self.logger, + "Failed to calculate fee of temporary transaction: {}", + e + ); + e + })?; + + let mut tx_builder = locked_wallet.build_tx(); + tx_builder + .add_recipient(address.script_pubkey(), Amount::from_sat(max_amount)) + .fee_absolute(estimated_tx_fee); + tx_builder + }, + OnchainSendAmount::AllDrainingReserve + | OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats: _ } => { + let mut tx_builder = locked_wallet.build_tx(); + tx_builder.drain_wallet().drain_to(address.script_pubkey()).fee_rate(fee_rate); + tx_builder + }, + }; + + let mut psbt = match tx_builder.finish() { + Ok(psbt) => { + log_trace!(self.logger, "Created PSBT: {:?}", psbt); + psbt + }, + Err(err) => { + log_error!(self.logger, "Failed to create transaction: {}", err); + return Err(err.into()); + }, + }; + + // Check the reserve requirements (again) and return an error if they aren't met. + match send_amount { + OnchainSendAmount::ExactRetainingReserve { + amount_sats, + cur_anchor_reserve_sats, + } => { + let balance = locked_wallet.balance(); + let spendable_amount_sats = self + .get_balances_inner(balance, cur_anchor_reserve_sats) + .map(|(_, s)| s) + .unwrap_or(0); + let tx_fee_sats = locked_wallet + .calculate_fee(&psbt.unsigned_tx) + .map_err(|e| { + log_error!( + self.logger, + "Failed to calculate fee of candidate transaction: {}", + e + ); + e + })? + .to_sat(); + if spendable_amount_sats < amount_sats.saturating_add(tx_fee_sats) { + log_error!(self.logger, + "Unable to send payment due to insufficient funds. Available: {}sats, Required: {}sats + {}sats fee", + spendable_amount_sats, + amount_sats, + tx_fee_sats, + ); + return Err(Error::InsufficientFunds); + } + }, + OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats } => { + let balance = locked_wallet.balance(); + let spendable_amount_sats = self + .get_balances_inner(balance, cur_anchor_reserve_sats) + .map(|(_, s)| s) + .unwrap_or(0); + let (sent, received) = locked_wallet.sent_and_received(&psbt.unsigned_tx); + let drain_amount = sent - received; + if spendable_amount_sats < drain_amount.to_sat() { + log_error!(self.logger, + "Unable to send payment due to insufficient funds. Available: {}sats, Required: {}", + spendable_amount_sats, + drain_amount, + ); + return Err(Error::InsufficientFunds); + } + }, + _ => {}, + } + + match locked_wallet.sign(&mut psbt, SignOptions::default()) { + Ok(finalized) => { + if !finalized { + return Err(Error::OnchainTxCreationFailed); + } + }, + Err(err) => { + log_error!(self.logger, "Failed to create transaction: {}", err); + return Err(err.into()); + }, + } + + (psbt, 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 + })?; + + let tx = psbt.extract_tx().map_err(|e| { + log_error!(self.logger, "Failed to extract transaction: {}", e); + e + })?; + + let txid = tx.compute_txid(); + self.broadcaster.broadcast(tx); + + match send_amount { + OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => { + log_info!( + self.logger, + "Created new transaction {} sending {}sats on-chain to address {}", + txid, + amount_sats, + address + ); + }, + OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats } => { + log_info!( + self.logger, + "Created new transaction {} sending available on-chain funds retaining a reserve of {}sats to address {}", + txid, + cur_anchor_reserve_sats, + address, + ); + }, + OnchainSendAmount::AllDrainingReserve => { + log_info!( + self.logger, + "Created new transaction {} sending all available on-chain funds to address {}", + txid, + address + ); + }, + } + + Ok(txid) + } + + pub(crate) async fn select_confirmed_utxos( + &self, must_spend: Vec, must_pay_to: &[TxOut], fee_rate: FeeRate, + ) -> Result { + let mut locked_persister = self.persister.lock().await; + let (coin_selection, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); + + debug_assert!(matches!( + locked_wallet.public_descriptor(KeychainKind::External), + ExtendedDescriptor::Wpkh(_) + )); + debug_assert!(matches!( + locked_wallet.public_descriptor(KeychainKind::Internal), + ExtendedDescriptor::Wpkh(_) + )); + + let mut tx_builder = locked_wallet.build_tx(); + tx_builder.only_witness_utxo(); + + for input in &must_spend { + let psbt_input = psbt::Input { + witness_utxo: Some(input.previous_utxo.clone()), + ..Default::default() + }; + let weight = ldk_to_bdk_satisfaction_weight(input.satisfaction_weight); + tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|_| ())?; + } + + for output in must_pay_to { + tx_builder.add_recipient(output.script_pubkey.clone(), output.value); + } + + tx_builder.fee_rate(fee_rate); + tx_builder.exclude_unconfirmed(); + + let unsigned_tx = tx_builder + .finish() + .map_err(|e| { + log_error!(self.logger, "Failed to select confirmed UTXOs: {}", e); + })? + .unsigned_tx; + + let confirmed_utxos = unsigned_tx + .input + .iter() + .filter(|txin| { + must_spend.iter().all(|input| input.outpoint != txin.previous_output) + }) + .filter_map(|txin| { + locked_wallet + .tx_details(txin.previous_output.txid) + .map(|tx_details| tx_details.tx.deref().clone()) + .map(|prevtx| ConfirmedUtxo::new_p2wpkh(prevtx, txin.previous_output.vout)) + }) + .collect::, ()>>()?; + + if unsigned_tx.output.len() > must_pay_to.len() + 1 { + log_error!( + self.logger, + "Unexpected number of change outputs during coin selection: {}", + unsigned_tx.output.len() - must_pay_to.len(), + ); + return Err(()); + } + + let change_output = unsigned_tx + .output + .into_iter() + .find(|txout| must_pay_to.iter().all(|output| output != txout)); + let change_set = if change_output.is_some() { + Some(locked_wallet.take_staged().unwrap_or_default()) + } else { + None + }; + + (CoinSelection { confirmed_utxos, change_output }, change_set) + }; + + if let Some(change_set) = change_set { + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + })?; + } + + Ok(coin_selection) + } + + fn list_confirmed_utxos_inner(&self) -> Result, ()> { + let locked_wallet = self.inner.lock().expect("lock"); + let mut utxos = Vec::new(); + let confirmed_txs: Vec = locked_wallet + .transactions() + .filter(|t| t.chain_position.is_confirmed()) + .map(|t| t.tx_node.txid) + .collect(); + let unspent_confirmed_utxos = + locked_wallet.list_unspent().filter(|u| confirmed_txs.contains(&u.outpoint.txid)); + + for u in unspent_confirmed_utxos { + let script_pubkey = u.txout.script_pubkey; + match script_pubkey.witness_version() { + Some(version @ WitnessVersion::V0) => { + // According to the SegWit rules of [BIP 141] a witness program is defined as: + // > A scriptPubKey (or redeemScript as defined in BIP16/P2SH) that consists of + // > a 1-byte push opcode (one of OP_0,OP_1,OP_2,.. .,OP_16) followed by a direct + // > data push between 2 and 40 bytes gets a new special meaning. The value of + // > the first push is called the "version byte". The following byte vector + // > pushed is called the "witness program"." + // + // We therefore skip the first byte we just read via `witness_version` and use + // the rest (i.e., the data push) as the raw bytes to construct the + // `WitnessProgram` below. + // + // [BIP 141]: https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#witness-program + let witness_bytes = &script_pubkey.as_bytes()[2..]; + let witness_program = + WitnessProgram::new(version, witness_bytes).map_err(|e| { + log_error!(self.logger, "Failed to retrieve script payload: {}", e); + })?; + + let wpkh = WPubkeyHash::from_slice(&witness_program.program().as_bytes()) + .map_err(|e| { + log_error!(self.logger, "Failed to retrieve script payload: {}", e); + })?; + let utxo = Utxo::new_v0_p2wpkh(u.outpoint, u.txout.value, &wpkh); + utxos.push(utxo); + }, + Some(version @ WitnessVersion::V1) => { + // According to the SegWit rules of [BIP 141] a witness program is defined as: + // > A scriptPubKey (or redeemScript as defined in BIP16/P2SH) that consists of + // > a 1-byte push opcode (one of OP_0,OP_1,OP_2,.. .,OP_16) followed by a direct + // > data push between 2 and 40 bytes gets a new special meaning. The value of + // > the first push is called the "version byte". The following byte vector + // > pushed is called the "witness program"." + // + // We therefore skip the first byte we just read via `witness_version` and use + // the rest (i.e., the data push) as the raw bytes to construct the + // `WitnessProgram` below. + // + // [BIP 141]: https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki#witness-program + let witness_bytes = &script_pubkey.as_bytes()[2..]; + let witness_program = + WitnessProgram::new(version, witness_bytes).map_err(|e| { + log_error!(self.logger, "Failed to retrieve script payload: {}", e); + })?; + + XOnlyPublicKey::from_slice(&witness_program.program().as_bytes()).map_err( + |e| { + log_error!(self.logger, "Failed to retrieve script payload: {}", e); + }, + )?; + + let utxo = Utxo { + outpoint: u.outpoint, + output: TxOut { + value: u.txout.value, + script_pubkey: ScriptBuf::new_witness_program(&witness_program), + }, + satisfaction_weight: 1 /* empty script_sig */ * WITNESS_SCALE_FACTOR as u64 + + 1 /* witness items */ + 1 /* schnorr sig len */ + 64, // schnorr sig + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + }; + utxos.push(utxo); + }, + Some(version) => { + log_error!(self.logger, "Unexpected witness version: {}", version,); + }, + None => { + log_error!( + self.logger, + "Tried to use a non-witness script. This must never happen." + ); + panic!("Tried to use a non-witness script. This must never happen."); + }, + } + } + + Ok(utxos) + } + + #[allow(deprecated)] + async fn get_change_script_inner(&self) -> Result { + let mut locked_persister = self.persister.lock().await; + let (address_info, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); + let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); + (address_info, 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); + })?; + Ok(address_info.address.script_pubkey()) + } + + #[allow(deprecated)] + pub(crate) fn sign_owned_inputs(&self, unsigned_tx: Transaction) -> Result { + let locked_wallet = self.inner.lock().expect("lock"); + + let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).map_err(|e| { + log_error!(self.logger, "Failed to construct PSBT: {}", e); + })?; + // Use list_output rather than get_utxo to include outputs spent by unconfirmed + // transactions (e.g., a prior splice being replaced via RBF), which a synced wallet would + // otherwise no longer treat as an owned UTXO. + let mut wallet_outputs: HashMap = + locked_wallet.list_output().map(|output| (output.outpoint, output)).collect(); + for (i, txin) in psbt.unsigned_tx.input.iter().enumerate() { + if let Some(utxo) = wallet_outputs.remove(&txin.previous_output) { + psbt.inputs[i] = locked_wallet.get_psbt_input(utxo, None, true).map_err(|e| { + log_error!(self.logger, "Failed to construct PSBT input: {}", e); + })?; + } + } + + let mut sign_options = SignOptions::default(); + sign_options.trust_witness_utxo = true; + + match locked_wallet.sign(&mut psbt, sign_options) { + Ok(finalized) => debug_assert!(!finalized), + Err(e) => { + log_error!(self.logger, "Failed to sign owned inputs: {}", e); + return Err(()); + }, + } + + match psbt.extract_tx() { + Ok(tx) => Ok(tx), + Err(bitcoin::psbt::ExtractTxError::MissingInputValue { tx }) => Ok(tx), + Err(e) => { + log_error!(self.logger, "Failed to extract transaction: {}", e); + Err(()) + }, + } + } + + #[allow(deprecated)] + fn sign_psbt_inner(&self, mut psbt: Psbt) -> Result { + let locked_wallet = self.inner.lock().expect("lock"); + + // While BDK populates both `witness_utxo` and `non_witness_utxo` fields, LDK does not. As + // BDK by default doesn't trust the witness UTXO to account for the Segwit bug, we must + // disable it here as otherwise we fail to sign. + let mut sign_options = SignOptions::default(); + sign_options.trust_witness_utxo = true; + + match locked_wallet.sign(&mut psbt, sign_options) { + Ok(_finalized) => { + // BDK will fail to finalize for all LDK-provided inputs of the PSBT. Unfortunately + // we can't check more fine grained if it succeeded for all the other inputs here, + // so we just ignore the returned `finalized` bool. + }, + Err(err) => { + log_error!(self.logger, "Failed to sign transaction: {}", err); + return Err(()); + }, + } + + let tx = psbt.extract_tx().map_err(|e| { + log_error!(self.logger, "Failed to extract transaction: {}", e); + () + })?; + + Ok(tx) + } + + /// Returns the `PaymentId` of the user-initiated splice intent the round `candidate` belongs + /// to, if any, so the first recorded round of a splice adopts the id chosen at splice time + /// rather than a fresh one. Only a history no record tracks yet gets here + /// ([`Self::resolve_interactive_funding_id`]), so only the channel's bare intent records — + /// those of its splices with no round on record — can be the round's: a tracked record already + /// has its rounds, and a channel carries one record per splice in flight. Among the bare + /// records, the round's is the one whose intent carries the round's contribution + /// ([`is_same_splice`]; LDK may adjust a contribution's fee fields, not its inputs or outputs) + /// or, when none does, the channel's only bare record. Several bare records and no match + /// identify nothing, and the round gets a fresh id. + async fn find_splice_payment_id(&self, candidate: &FundingCandidate) -> Option { + let channel_of = |intent: &SpliceIntent| { + candidate.channels.iter().find(|channel| { + channel.channel_id == intent.channel_id + && channel.counterparty_node_id == intent.counterparty_node_id + }) + }; + let bare = self + .payment_stores + .pending_payments(|p| { + p.details().is_none() + && p.candidates().is_empty() + && p.splice_intent().is_some_and(|i| channel_of(i).is_some()) + }) + .await; + let carries_contribution = |p: &&PendingPaymentDetails| { + p.splice_intent().is_some_and(|intent| { + channel_of(intent) + .and_then(|channel| channel.contribution.as_ref()) + .is_some_and(|contribution| is_same_splice(contribution, &intent.contribution)) + }) + }; + match (bare.iter().find(carries_contribution), bare.as_slice()) { + (Some(record), _) => Some(record.id()), + (None, [only]) => Some(only.id()), + (None, _) => None, + } + } + + /// Resolves the id under which the `active` round of the interactive funding with negotiated + /// history `candidates` is recorded. A round already on record keeps its record: the id of the + /// first round of the history any record tracks is adopted (wallet sync may record a round + /// before this node does), so a replacement, a replayed signing and a sync-created record + /// converge on one record. A record already failed is passed over: wallet sync fails a payment + /// whose round lost to a conflicting spend confirmed while the channel stays open, LDK still + /// holds the round and a fee bump of it is signed with the round among its candidates, and + /// nothing revisits a failed record's status, so the bump filed under it would go untracked. + /// Only a history no live record tracks falls back to the channel's splice intents: a + /// user-initiated splice adopts the `PaymentId` generated when it was initiated, so its intent, + /// funding payment and candidate history share one record. A channel carries one intent per + /// splice in flight, and only a bare one — of a splice with no round on record — can be a first + /// round's ([`Self::find_splice_payment_id`]), which is why the intents must not decide the id + /// of a round already on record: a fee bump this node signs of a round wallet sync recorded + /// first must converge on the record sync created, not be filed under the bump's intent as a + /// second record. Otherwise a fresh id is generated — an id derived from a txid would tie the + /// record's identity to one round of a replaceable transaction, and resolution through the + /// record's txid history is what keeps its identity stable across RBF replacements. The caller + /// holds the cross-store lock: resolved outside it, the id could go stale against a record + /// wallet sync creates for the same transaction before the caller's write. + async fn resolve_interactive_funding_id( + &self, stores: &PaymentStoresGuard<'_>, candidates: &[FundingCandidate], + active: &FundingCandidate, + ) -> Result { + for candidate in candidates.iter() { + if let Some(id) = self.find_payment_by_txid(candidate.txid).await? { + let failed = stores + .payment(&id) + .await? + .is_some_and(|payment| payment.status == PaymentStatus::Failed); + if !failed { + return Ok(id); + } + } + } + if let Some(id) = self.find_splice_payment_id(active).await { + return Ok(id); + } + Ok(random_payment_id()) + } + + /// Builds this node's share of the `active` round of an interactive funding whose negotiated + /// history is `candidates`, and the per-candidate figures of that history, for recording the + /// round under `payment_id`. Returns `None` when there is nothing to record: no local + /// contribution to the round, or no wallet-level activity. + fn interactive_funding_figures( + &self, payment_id: PaymentId, candidates: &[FundingCandidate], active: &FundingCandidate, + tx: &Transaction, + ) -> Option<(LocalFundingFigures, Vec)> { + let txid = active.txid; + + let aggregate = aggregate_local_stakes(active); + let amount_msat = match aggregate.amount_msat { + Some(amt) => Some(amt), + None => { + log_trace!( + self.logger, + "Not recording signed funding {} as a payment: no local contribution", + txid, + ); + return None; + }, + }; + + // A contribution doesn't mean the tx touches our on-chain wallet: a splice-out to an + // external address sends channel funds to a third party, which BDK sees as zero wallet + // movement. Nothing for the on-chain payment store to record, so skip it. + let (wallet_amount_msat, _wallet_fee_msat, _wallet_direction) = + self.onchain_payment_fields(tx); + if wallet_amount_msat == Some(0) { + log_trace!( + self.logger, + "Not recording signed funding {} as a payment: no wallet-level activity", + txid, + ); + return None; + } + + // Record every candidate's figures (`None` for any round we didn't contribute to, e.g. a + // counterparty-initiated splice our `splice_in` later joined via RBF) so the confirmed + // candidate's amount/fee can be applied on confirmation, even if it isn't the last one + // broadcast or one we contributed to. + let candidate_records: Vec = candidates + .iter() + .map(|candidate| { + let aggregate = aggregate_local_stakes(candidate); + FundingTxCandidate { + txid: candidate.txid, + amount_msat: aggregate.amount_msat, + fee_paid_msat: aggregate.fee_paid_msat, + awaiting_broadcast: false, + } + }) + .collect(); + + let figures = LocalFundingFigures { + funding_payment_id: payment_id, + amount_msat, + fee_paid_msat: aggregate.fee_paid_msat, + direction: aggregate.direction, + }; + Some((figures, candidate_records)) + } + + /// Records what this node knows about a splice round it is about to sign, before + /// [`ChannelManager::funding_transaction_signed`] releases our signatures: without them the + /// counterparty cannot broadcast, so the record precedes anything wallet sync could observe. + /// + /// Two things are written. The round's transaction gets a provenance fact naming it an + /// interactive funding of the round's channels and carrying this node's share of it along with + /// the funding payment the round belongs to, so that whoever first observes the transaction — + /// wallet sync, whichever party broadcast it — records it under that payment rather than as a + /// payment of its own, with this node's figures rather than the wallet's view of a funding + /// output both parties own. The pending store gets the round's place in the channel's splice + /// history, marked as awaiting broadcast until LDK reports the splice negotiated and + /// [`Self::record_broadcast_splice_round`] clears the mark: only such a round can be abandoned + /// without a trace, and [`Self::drop_abandoned_splice_rounds`] takes it back once LDK no longer + /// holds it. No payment record is written here — wallet sync creates it when it observes the + /// transaction, and resolves its identity through the fact. + /// + /// `candidates` is the channel's pending splice history as [`funding_candidates`] lists it from + /// the channel's [`SpliceDetails`], so the history is written in full, under the id + /// [`Self::resolve_interactive_funding_id`] resolves (that of a record already tracking any + /// round of the history, else the channel's splice intent, else a fresh one). + /// + /// Nothing is recorded for a round missing from the history (reset between the event's + /// emission and its handling, so LDK will refuse the signed transaction), already recorded (a + /// replayed event), or without a local contribution or wallet-level activity. A failed write + /// leaves the caller to replay: a losing RBF candidate's contribution figures exist only while + /// the candidate is live in the channel's splice details, and both writes are idempotent, so + /// the replay completes whichever of them was lost. + /// + /// The round's facts are admitted however many records the store holds, so that a store full + /// of other transactions cannot leave this node signing a round it has no measure of. A + /// refusal that a replay would only meet again reports the round + /// [`SignedFundingRecord::Unmeasurable`], having written nothing, for the caller to cancel + /// the round rather than sign it. + /// + /// [`ChannelManager::funding_transaction_signed`]: lightning::ln::channelmanager::ChannelManager::funding_transaction_signed + pub(crate) async fn record_signed_funding( + &self, tx: &Transaction, candidates: &[FundingCandidate], + ) -> Result { + let txid = tx.compute_txid(); + let signed_round = match candidates.iter().find(|candidate| candidate.txid == txid) { + Some(round) => round, + None => { + log_trace!( + self.logger, + "Not recording signed funding {}: not among the channel's pending splice rounds", + txid, + ); + return Ok(SignedFundingRecord::Recorded); + }, + }; + let funding_channels: Vec = signed_round + .channels + .iter() + .map(|channel| Channel { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + .collect(); + + // Resolution, the reads and the writes below must share one lock acquisition, as in every + // funding-record write: done outside it, the id could go stale against a record wallet + // sync creates for the same transaction before the write. + let stores = self.payment_stores.lock().await; + // A round whose facts are on record already names its payment and this node's share of + // it. Those facts are immutable, so a replay adopts them rather than deriving figures + // afresh: LDK may have adjusted the contribution's fee fields since, and a second answer + // would be refused rather than recorded, leaving the event replaying forever. + let recorded_figures = + self.channel_tx_facts(&txid).await.and_then(|facts| facts.local_figures); + let payment_id = match &recorded_figures { + Some(figures) => figures.funding_payment_id, + None => self.resolve_interactive_funding_id(&stores, candidates, signed_round).await?, + }; + let (figures, mut history) = + match self.interactive_funding_figures(payment_id, candidates, signed_round, tx) { + Some(record) => record, + None => return Ok(SignedFundingRecord::Recorded), + }; + let figures = recorded_figures.unwrap_or(figures); + // Only the signed round awaits broadcast: LDK broadcast the others once their signatures + // were exchanged. + if let Some(signed) = history.iter_mut().find(|candidate| candidate.txid == txid) { + signed.awaiting_broadcast = true; + } + + 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(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 + // abandoned, whose removal is `drop_abandoned_splice_rounds`' job once LDK reports the + // failure, so a recorded round LDK no longer lists must survive the write. + // + // Refreshing an earlier round clears its awaiting-broadcast mark, which is right only + // because LDK refuses a new negotiation while one awaits signatures and handles events in + // order, stopping at the first failure: the earlier round's `SpliceNegotiated` event was + // pushed before this signing event and has been handled by now. Should LDK ever reorder + // them, this would clear the mark of a round whose event has not been handled yet. + let mut recorded = + prior_pending.as_ref().map(|entry| entry.candidates().to_vec()).unwrap_or_default(); + for candidate in history { + match recorded.iter_mut().find(|stored| stored.txid == candidate.txid) { + Some(stored) => *stored = candidate, + None => recorded.push(candidate), + } + } + + // The fact goes first: it is what ties the transaction to this payment, so a failure + // afterwards leaves the round attributable rather than a history pointing at a payment + // nothing would ever file the transaction under. + // + // The round is admitted whatever the store's count: this node decides whether to sign + // it, and a record naming a round and this node's share of it carries no outputs, so + // it is among the smallest the store holds. What can still refuse it is a record + // already as large as one record may be, which nothing shrinks — so the round is + // reported unmeasurable for the caller to cancel, rather than released with the + // wallet's view of a funding output both parties own standing in for this node's + // share. + let facts = ChannelTxFacts::new(txid) + .with_self_role(TransactionType::InteractiveFunding { + channels: funding_channels.clone(), + }) + .with_local_figures(figures); + 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); + } + + stores + .mutate_pending_payment(&payment_id, |existing| { + let mut changed = existing.is_none(); + let mut entry = existing.cloned().unwrap_or_else(|| { + PendingPaymentDetails::signed_rounds(payment_id, Vec::new(), Vec::new(), None) + }); + if entry.funding_channels.is_empty() && !funding_channels.is_empty() { + entry.funding_channels = funding_channels.clone(); + changed = true; + } + if entry.candidates != recorded { + entry.candidates = recorded.clone(); + changed = true; + } + changed.then_some(entry) + }) + .await?; + log_debug!( + self.logger, + "Recorded signed splice funding {} ({} candidates)", + txid, + candidates.len(), + ); + + // The history is complete; merging the duplicates wallet sync created for earlier rounds + // is a courtesy. The signed round can have no duplicate yet, as our signatures have not + // left the node, and the round's `SpliceNegotiated` event re-runs the merge, replaying on + // failure, so a failure here is logged rather than replaying the signing. + if let Err(e) = self.merge_duplicate_candidate_records(&stores, payment_id, &recorded).await + { + log_error!( + self.logger, + "Failed to merge duplicate records into funding payment {}: {}", + payment_id, + e, + ); + } + Ok(SignedFundingRecord::Recorded) + } + + /// Marks a splice round recorded when signing ([`Self::record_signed_funding`]) as broadcast + /// once LDK reports the splice negotiated: `SpliceNegotiated` is emitted only once our + /// `tx_signatures` for the round are ready to send, so the counterparty may hold them by then + /// and may broadcast the round, which is therefore no longer dropped as abandoned. Then merges + /// the duplicate records wallet sync created for the record's candidates + /// ([`Self::merge_duplicate_candidate_records`]), completing a merge the signing left + /// unfinished. Nothing is written for a round no funding payment of `channel_id` tracks (no + /// local contribution, or no wallet-level activity); a replayed event finds the round marked + /// already and only re-runs the merge. + pub(crate) async fn record_broadcast_splice_round( + &self, channel_id: ChannelId, txid: Txid, + ) -> Result<(), Error> { + // Serialize with the other funding-record writers, which all hold this lock from their + // reads through their last write. + let stores = self.payment_stores.lock().await; + + let entries = stores + .pending_payments(|entry| { + tracks_channel(entry, channel_id) && entry.candidate(txid).is_some() + }) + .await; + for entry in entries { + let payment_id = entry.id(); + let marked = stores + .mutate_pending_payment(&payment_id, |existing| { + let mut entry = existing?.clone(); + let round = entry + .candidates + .iter_mut() + .find(|candidate| candidate.txid == txid && candidate.awaiting_broadcast)?; + round.awaiting_broadcast = false; + Some(entry) + }) + .await?; + if marked.is_some() { + log_debug!( + self.logger, + "Marked splice round {} of channel {} as broadcast in funding payment {}", + txid, + channel_id, + payment_id, + ); + } + // The round's record is complete, so the duplicates wallet sync created for earlier + // rounds can be folded in. A failure replays the event, which re-runs the merge + // idempotently. + self.merge_duplicate_candidate_records(&stores, payment_id, entry.candidates()).await?; + } + Ok(()) + } + + /// Drops from a channel's funding records the splice rounds LDK abandoned before they could be + /// broadcast. A round this node signed is recorded before our signatures leave the node + /// ([`Self::record_signed_funding`]) and marked as awaiting broadcast until its + /// `SpliceNegotiated` event clears the mark ([`Self::record_broadcast_splice_round`]). Should + /// LDK drop the round in between — the counterparty aborts before the signatures are exchanged, + /// or the channel closes — nothing can broadcast it anymore, and left in place the record would + /// wait forever on a payment nothing can confirm. + /// + /// `held_rounds` lists the rounds LDK still holds for the channel, as [`held_splice_rounds`] + /// reads them (for a closed channel, its last funding and the rounds its monitor still watches, + /// as [`closed_channel_held_rounds`] reads them). A recorded round is dropped if it awaits + /// broadcast, LDK no longer holds it, and the wallet has not seen its transaction either — the + /// counterparty may broadcast a round it received our signatures for while LDK still waits on + /// its own. A round LDK handed the broadcaster keeps its place once its `SpliceNegotiated` + /// event has cleared the mark, whether wallet sync has seen it yet or not; one whose event is + /// still unhandled when the channel closes is listed in `held_rounds` because the channel's + /// monitor, which saw the counterparty commit to it, still watches it, and so keeps its place + /// as well, as does a round LDK promoted to the channel's funding (recorded by + /// [`Self::resolve_promoted_splice_round`]), broadcast with its signatures exchanged whether + /// or not its `SpliceNegotiated` event has cleared the mark yet. Dropping the record's current + /// round hands the record back to the last remaining round this node contributed to, figures + /// included; dropping the last such round removes the record, as whatever rounds remain are not + /// this node's payment (LDK keeps this node's contributions to a suffix of the rounds), while a + /// splice intent the record carried stays behind as a bare intent, for the failure LDK reports + /// to be described from and for its settlement to remove. A record that no longer waits on the + /// dropped round — wallet sync moved it on, or an earlier drop was cut short after moving it — + /// keeps its state and only loses the round from its history. + pub(crate) async fn drop_abandoned_splice_rounds( + &self, channel_id: ChannelId, held_rounds: &[Txid], + ) -> Result<(), Error> { + // Serialize with the other funding-record writers, which all hold this lock from their + // reads through their last write. + let stores = self.payment_stores.lock().await; + self.drop_abandoned_splice_rounds_locked(&stores, channel_id, held_rounds).await + } + + /// [`Self::drop_abandoned_splice_rounds`] for a caller already holding the funding-record + /// writers' lock. + async fn drop_abandoned_splice_rounds_locked( + &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], + ) -> Result<(), Error> { + let entries = stores + .pending_payments(|entry| { + tracks_channel(entry, channel_id) + && entry.candidates().iter().any(|candidate| candidate.awaiting_broadcast) + }) + .await; + + for entry in entries { + let payment_id = entry.id(); + let (abandoned, remaining): (Vec, Vec) = { + let locked_wallet = self.inner.lock().expect("lock"); + // TODO(#1037): the graph learns a round LDK broadcast from wallet sync alone + // today, so this check only adds what a sync has already seen to `held_rounds`. + // It catches every broadcast round by itself, whichever caller — the startup + // sweep or a live event — runs the drop, only once the `InteractiveFunding` + // broadcast arm applies the round to the graph, which #1037 does not do: it + // prepares only `Funding`-typed packages. + entry.candidates().iter().cloned().partition(|candidate| { + candidate.awaiting_broadcast + && !held_rounds.contains(&candidate.txid) + && !entry.locked_rounds().contains(&candidate.txid) + && locked_wallet.tx_graph().get_tx(candidate.txid).is_none() + }) + }; + if abandoned.is_empty() { + continue; + } + let abandoned_txids: Vec = abandoned.iter().map(|c| c.txid).collect(); + // The record's transaction and figures are only handed back while they still describe + // an abandoned round; a record wallet sync has since moved on is left as it stands, + // and only its history shrinks. + let waits_on_abandoned = |record: &PaymentDetails| { + record.status == PaymentStatus::Pending + && matches!( + &record.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } + if abandoned_txids.contains(txid) + ) + }; + // A last remaining round without a contribution of ours means no remaining round has + // one. + let handed_back = remaining.last().filter(|round| round.amount_msat.is_some()); + + // An entry with no payment record yet — nothing has observed a transaction of this + // splice — has no record to hand back or remove: only its history shrinks, and with + // the last round of ours it loses the rest of the history too, leaving a splice + // intent it carries behind as a bare intent for the failure LDK reports to be + // described from. An entry left tracking nothing goes. + if entry.details().is_none() { + stores + .mutate_pending_payment(&payment_id, |existing| { + let mut entry = existing?.clone(); + if handed_back.is_some() { + entry.candidates.retain(|c| !abandoned_txids.contains(&c.txid)); + } else { + entry.candidates.clear(); + entry.locked_rounds.clear(); + entry.funding_channels.clear(); + } + Some(entry) + }) + .await?; + stores.remove_pending_payment_if(&payment_id, |entry| entry.is_empty()).await?; + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} of unobserved funding payment {}", + abandoned_txids, + payment_id, + ); + continue; + } + + let mut mirrored = None; + let mut history_only = false; + match handed_back { + Some(active) => { + // Whether the record still waits on the dropped rounds is decided inside the + // write's critical section, from the record found there. + stores + .mutate_payment(&payment_id, |existing| { + let current = existing?; + if !waits_on_abandoned(current) { + history_only = true; + mirrored = Some(current.clone()) + .filter(|current| current.status == PaymentStatus::Pending); + return None; + } + let mut update = PaymentDetailsUpdate::new(payment_id); + update.txid = Some(active.txid); + update.confirmation_status = Some(ConfirmationStatus::Unconfirmed); + update.amount_msat = Some(active.amount_msat); + update.fee_paid_msat = Some(active.fee_paid_msat); + let mut updated = current.clone(); + updated.update(update); + mirrored = Some(updated.clone()); + Some(updated) + }) + .await?; + }, + None => { + // 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 went first: the entry keeps resolving the rounds' txids, + // so a removal that fails midway is finished by the replayed event. A + // splice intent the entry carries outlives the record as a bare intent: + // the failure LDK reports for the round is described from it, and its + // settlement removes it (`SpliceTracker::on_negotiation_failed`); one left + // behind by a node that stopped in between is found by + // `SpliceTracker::reconcile` at the next startup, which settles it once LDK + // holds no round of ours, or by whatever next concerns the channel's + // splice. The intent is read from the entry as it stands, not as listed + // above: a fee bump submitted since may have replaced it, and that intent + // must stay just the same. + stores.remove_payment(&payment_id).await?; + let kept_intent = stores + .mutate_pending_payment(&payment_id, |existing| match existing { + 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 {}: nothing of ours \ + was broadcast under it; the splice's intent stays until its failure is surfaced", + abandoned_txids, + payment_id, + ); + } else { + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} and removed funding payment {}: nothing of ours \ + was broadcast under it", + abandoned_txids, + payment_id, + ); + } + continue; + } + }, + } + if history_only { + // The record does not wait on the dropped rounds: wallet sync moved it on, or an + // earlier drop was cut short between the two stores. Only its history shrinks, and + // the entry's copy of the record catches up with the record while the record is + // still pending. + log_warn!( + self.logger, + "Funding payment {} does not wait on abandoned splice round(s) {:?}: \ + dropping them from its history only", + payment_id, + abandoned_txids, + ); + } + stores + .mutate_pending_payment(&payment_id, |existing| { + let mut entry = existing?.clone(); + entry.candidates.retain(|c| !abandoned_txids.contains(&c.txid)); + if let Some(mirrored) = mirrored { + entry.details = Some(mirrored); + } + Some(entry) + }) + .await?; + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} from funding payment {}", + abandoned_txids, + payment_id, + ); + } + Ok(()) + } + + /// Drops the splice rounds recorded when signing that LDK does not hold once the node restarts. + /// LDK reports the loss of a negotiation its last channel manager write carried mid-way, but a + /// round committed, negotiated and signed since that write is gone without a report if the + /// node stopped before the next one. `held_rounds` yields the rounds LDK holds for a channel, + /// as [`held_splice_rounds`] lists them, or `None` for a channel LDK no longer lists, which is + /// left to its `ChannelClosed` event: LDK queues one for every channel it drops, and handling + /// it takes back what neither the closed channel's funding nor its monitor holds. Runs before + /// events are processed again, so no round is recorded while LDK's view is being read. + pub(crate) async fn drop_splice_rounds_lost_across_restart( + &self, held_rounds: impl Fn(ChannelId) -> Option>, + ) -> Result<(), Error> { + let channels: HashSet = self + .payment_stores + .pending_payments(|entry| { + entry.candidates().iter().any(|candidate| candidate.awaiting_broadcast) + }) + .await + .iter() + .flat_map(|entry| { + entry + .funding_channels() + .iter() + .map(|channel| channel.channel_id) + .collect::>() + }) + .collect(); + for channel_id in channels { + let Some(held) = held_rounds(channel_id) else { + log_debug!( + self.logger, + "Leaving the signed splice rounds of channel {} to its ChannelClosed event", + channel_id, + ); + continue; + }; + self.drop_abandoned_splice_rounds(channel_id, &held).await?; + } + Ok(()) + } + + /// Removes the payment record under `payment_id` — the id of a bare splice intent whose splice + /// settled — when it is the first half of a funding-record write that never completed. + /// + /// Wallet sync files a funding payment under the id the round's recorded facts name, which for + /// a user-initiated splice is the id its intent was created with, and indexes it in the pending + /// store in the same write. A record found under a bare intent therefore lost that index to a + /// write that failed in between, and no entry would ever drive it: it neither graduates nor + /// maps its transaction back to itself. A record an entry does index stays, as does one that is + /// not a pending, unconfirmed interactive funding — a record that confirmed or succeeded was + /// driven to that state and is a payment of its own. The caller removes the bare entry + /// afterwards. + pub(crate) async fn drop_unindexed_record_of_settled_intent( + &self, payment_id: PaymentId, + ) -> Result<(), Error> { + // Serialize with the funding-record writers, so that the check and the removal cannot + // interleave with a write completing the record. + let stores = self.payment_stores.lock().await; + let indexed = stores + .pending_payment(&payment_id) + .await? + .is_some_and(|entry| entry.details().is_some()); + if indexed { + log_debug!( + self.logger, + "Keeping the funding record of payment {}: its pending entry indexes it", + payment_id, + ); + return Ok(()); + } + let half_written = + stores.payment(&payment_id).await?.and_then(|record| match &record.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } if record.status == PaymentStatus::Pending => Some(*txid), + _ => None, + }); + match half_written { + Some(recorded) => { + stores.remove_payment(&payment_id).await?; + log_info!( + self.logger, + "Dropped the half-written funding record of splice round {}", + recorded, + ); + }, + None => log_debug!( + self.logger, + "No half-written funding record to drop under payment {}", + payment_id, + ), + } + Ok(()) + } + + /// Records a funding payment the way the node does: the rounds this node signed supply the + /// candidate history, wallet sync writes the payment record and its pending-store entry once + /// it observes the transaction, and the duplicates sync created for those rounds are merged + /// into the record. Composes that sequence for tests that need a recorded funding payment to + /// act on. + #[cfg(test)] + async fn record_funding_payment( + &self, details: PaymentDetails, candidates: Vec, + ) -> Result<(), Error> { + let stores = self.payment_stores.lock().await; + let id = details.id; + if !candidates.is_empty() { + stores + .mutate_pending_payment(&id, |existing| { + let mut entry = existing.cloned().unwrap_or_else(|| { + PendingPaymentDetails::signed_rounds(id, Vec::new(), Vec::new(), None) + }); + entry.candidates = candidates.clone(); + Some(entry) + }) + .await?; + } + stores.insert_or_update_payment(details.clone()).await?; + self.upsert_pending_payment(&stores, details, Vec::new()).await?; + self.merge_duplicate_candidate_records(&stores, id, &candidates).await + } + + /// Merges duplicate records wallet sync created for this funding payment's candidates before + /// they were recorded as such. Sync re-keys an event for a round it cannot attribute to the + /// funding record — not yet a candidate, so the funding-status gate reports it foreign — to + /// the round's txid-derived id, creating an untyped duplicate whose pending entry then + /// shadows the funding record in [`Self::find_payment_by_txid`]'s direct probe. Once the + /// round is a recorded candidate, the duplicate's confirmation (if any) belongs on the + /// funding record: adopt it, then remove the duplicate and its pending entry. + /// + /// Runs once a record's candidate history is written, so the funding-status gate accepts the + /// candidates it adopts, and under the writer's lock acquisition, so sync cannot interleave. + /// It is idempotent: a failure at signing time ([`Self::record_signed_funding`]) is left to the + /// signed round's `SpliceNegotiated` event ([`Self::record_broadcast_splice_round`]), which + /// re-runs the merge and replays on failure. The caller must hold the [`PaymentStores`] lock, + /// per [`Self::apply_funding_status_update_locked`]'s contract. + async fn merge_duplicate_candidate_records( + &self, stores: &PaymentStoresGuard<'_>, id: PaymentId, candidates: &[FundingTxCandidate], + ) -> Result<(), Error> { + for candidate in candidates { + let duplicate_id = PaymentId(candidate.txid.to_byte_array()); + if duplicate_id == id { + continue; + } + let duplicate = match stores.payment(&duplicate_id).await? { + Some(duplicate) => duplicate, + None => continue, + }; + // Only a duplicate view of this candidate's transaction qualifies: a record wallet + // sync created for the round before it was a candidate, left untyped or named a plain + // funding. Anything else keyed by the txid-derived id is left alone. + let status = match &duplicate.kind { + PaymentKind::Onchain { + txid, + status, + tx_type: None | Some(TransactionType::Funding { .. }), + } if *txid == candidate.txid => status.clone(), + _ => continue, + }; + // Only a confirmation is worth adopting; an unconfirmed duplicate carries nothing the + // record needs — the actively-broadcast candidate stays the record's current txid. + if matches!(status, ConfirmationStatus::Confirmed { .. }) { + let outcome = self + .apply_funding_status_update_locked(stores, id, candidate.txid, status) + .await?; + debug_assert!(matches!(outcome, FundingStatusUpdate::Applied)); + if !matches!(outcome, FundingStatusUpdate::Applied) { + // Adoption declined; keep the duplicate rather than discard its confirmation. + continue; + } + } + log_debug!( + self.logger, + "Merging duplicate payment record for funding transaction {}", + candidate.txid, + ); + // Pending entry first: the retry of a failure between these two removals rediscovers + // the duplicate through its payment record. Removed the other way around, the + // leftover pending entry would be unreachable to the retry yet keep shadowing the + // funding record in `find_payment_by_txid`'s direct probe. + stores.remove_pending_payment(&duplicate_id).await?; + stores.remove_payment(&duplicate_id).await?; + } + Ok(()) + } + + /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. + pub(crate) fn onchain_payment_fields( + &self, tx: &Transaction, + ) -> (Option, Option, PaymentDirection) { + let locked_wallet = self.inner.lock().expect("lock"); + self.onchain_payment_fields_locked(&locked_wallet, tx) + } + + /// [`Self::onchain_payment_fields`] against an already-locked wallet, so callers that hold the + /// lock (e.g. [`Self::create_payment_from_tx`]) can reuse the derivation without re-locking. + fn onchain_payment_fields_locked( + &self, locked_wallet: &PersistedWallet, tx: &Transaction, + ) -> (Option, Option, PaymentDirection) { + let fee = locked_wallet.calculate_fee(tx).unwrap_or(Amount::ZERO); + let (sent, received) = locked_wallet.sent_and_received(tx); + let fee_sat = fee.to_sat(); + + let (direction, amount_msat) = if sent > received { + ( + PaymentDirection::Outbound, + Some( + (sent.to_sat().saturating_sub(fee_sat).saturating_sub(received.to_sat())) + * 1000, + ), + ) + } else { + ( + PaymentDirection::Inbound, + Some( + received.to_sat().saturating_sub(sent.to_sat().saturating_sub(fee_sat)) * 1000, + ), + ) + }; + + (amount_msat, Some(fee_sat * 1000), direction) + } + + /// Builds the payment record for `tx`, naming what the transaction is from `provenance`. + /// + /// The provenance is read by the caller rather than here, because reading it awaits the facts + /// store while this runs under the wallet lock. + fn create_payment_from_tx( + &self, locked_wallet: &PersistedWallet, txid: Txid, + payment_id: PaymentId, tx: &Transaction, provenance: &TxProvenance, + payment_status: PaymentStatus, confirmation_status: ConfirmationStatus, + ) -> PaymentDetails { + let kind = PaymentKind::Onchain { + txid, + status: confirmation_status, + tx_type: provenance.classify(tx), + }; + + // The figures a producer reported take precedence over the wallet's view: an + // interactively negotiated funding spends an output both parties own, which the wallet + // reads as this node having spent all of it. + let (amount_msat, fee_paid_msat, direction) = match provenance.local_figures() { + Some(figures) => (figures.amount_msat, figures.fee_paid_msat, figures.direction), + None => self.onchain_payment_fields_locked(locked_wallet, tx), + }; + + PaymentDetails::new(payment_id, kind, amount_msat, fee_paid_msat, direction, payment_status) + } + + /// Inserts or refreshes the pending-store entry tracking `payment` toward graduation, + /// atomically with reading the entry's current state. + async fn upsert_pending_payment( + &self, stores: &PaymentStoresGuard<'_>, payment: PaymentDetails, + conflicting_txids: Vec, + ) -> Result<(), Error> { + let id = payment.id; + 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 + // status is re-read inside the store's critical section, where it cannot go + // stale against graduation. + let is_pending = stores + .payment(&id) + .await? + .map_or(payment.status == PaymentStatus::Pending, |recorded| { + recorded.status == PaymentStatus::Pending + }); + if !is_pending { + // A bare splice intent under an advanced payment's id is the leftover of the + // splice that payment settles. Taking it back is left to the removal below, + // so that it happens under a check of what the entry still is; leaving it + // would have the next restart act on a splice that is long over. + if let Some(entry) = existing { + if entry.details().is_none() { + *leftover = entry.splice_intent; + } + } + return Ok(None); + } + Ok(match existing { + None => { + Some(PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())) + }, + // Promote an entry that has no record yet: wallet sync saw the splice + // transaction before this node recorded a payment for it. The entry keeps + // the splice intent and the rounds signed under it, and gains the record. + Some(mut entry) if entry.details().is_none() => { + entry.details = Some(payment); + entry.conflicting_txids = conflicting_txids; + Some(entry) + }, + Some(mut tracked) => { + let fresh = + PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()); + tracked.update(fresh.to_update()).then_some(tracked) + }, + }) + }) + .await?; + + if let Some(intent) = leftover_intent_to_remove { + // Only while the entry still is the bare intent the closure saw: a round signed or a + // fee bump submitted in between joins the entry, and what those track must stay. + stores + .remove_pending_payment_if(&id, |entry| { + entry.details().is_none() + && entry.candidates().is_empty() + && entry.locked_rounds().is_empty() + && entry.splice_intent() == Some(&intent) + }) + .await?; + } + Ok(()) + } + + /// Removes the payment with the given id from the payment store, along with any pending-store + /// entry indexing its txids. An orphaned entry would keep resolving those txids to the removed + /// record — routing later wallet-sync events to a payment that no longer exists — and nothing + /// would ever clean it up, since graduation only removes entries whose record is still live. + /// + /// What this node recorded about the transactions themselves stays behind: those facts + /// describe transactions that happened, and classifying a later transaction — a close + /// spending a funding output, say — still reads them. + pub(crate) async fn remove_payment(&self, payment_id: &PaymentId) -> Result<(), Error> { + // Hold the cross-store lock so the two-store removal cannot interleave with a sync arm's + // or a funding-record writer's resolve-then-write sequence. The pending entry goes first: a failure + // 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?; + stores.remove_payment(payment_id).await + } + + /// The payment the transaction `target_txid` belongs to, as far as anything on record says. + /// + /// A transaction this node signed a round of an interactive funding for names its payment + /// outright, in the facts the signing recorded about it; that is the only answer that holds + /// before the payment record exists. Otherwise the pending store is asked, by the record's + /// own transaction, by its candidate history and by the conflicts wallet sync listed for it, + /// and finally the payment store itself, for a record that graduated out of the pending store. + async fn find_payment_by_txid(&self, target_txid: Txid) -> Result, Error> { + if let Some(figures) = + self.channel_tx_facts(&target_txid).await.and_then(|facts| facts.local_figures) + { + return Ok(Some(figures.funding_payment_id)); + } + let direct_payment_id = PaymentId(target_txid.to_byte_array()); - if self.pending_payment_store.contains_key(&direct_payment_id).await? { + if self.payment_stores.has_pending_payment(&direct_payment_id).await? { return Ok(Some(direct_payment_id)); } - if let Some(replaced_details) = self - .pending_payment_store - .list_filter(|p| { - matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid) - || p.conflicting_txids.contains(&target_txid) - // A middle RBF round is not the record's current txid and may never have - // received a `TxReplaced` event of its own, so map any of its candidate - // txids (an earlier RBF round may confirm) back to the record. - || p.candidate(target_txid).is_some() - }) - .await - .first() - { - return Ok(Some(replaced_details.details.id)); - } + let owns = |p: &PendingPaymentDetails| { + p.details().is_some_and( + |d| matches!(d.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), + ) + // A middle RBF round is not the record's current txid and may never have + // received a `TxReplaced` event of its own, and a splice keyed by a generated + // PaymentId is not found by the txid-derived id above: map any of the + // candidate txids (an earlier RBF round may confirm) back to the record. + || p.candidate(target_txid).is_some() + }; + let matches = self + .payment_stores + .pending_payments(|p| owns(p) || p.conflicting_txids().contains(&target_txid)) + .await; + // An entry lists the transactions that replaced its own, so a transaction another entry + // records as its own (a splice round that replaced a close, say) matches both. The entry + // that owns it is its record; the conflict listing is only how a replaced round of a + // record with no candidates (an ordinary payment's RBF history) maps back to its record. + if let Some(entry) = matches.iter().find(|p| owns(p)).or(matches.first()) { + return Ok(Some(entry.id())); + } + + // The pending store only indexes in-flight records — graduation removes the entry — so a + // graduated record's transaction resolves through the payment store itself. Without this, + // a wallet event naming a graduated record's transaction — a post-graduation reorg, or + // the first sight of a transaction whose confirmation landed while the node was offline — + // would miss the record and create a duplicate under the transaction's own id. + let mut page_token = None; + loop { + let page = self.payment_stores.payments_page(page_token).await?; + if let Some(payment) = page.objects.iter().find( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), + ) { + return Ok(Some(payment.id)); + } + match page.next_page_token { + Some(token) => page_token = Some(token), + None => break, + } + } + + Ok(None) + } + + /// If `payment_id` refers to a classified funding payment, refreshes its confirmation status + /// and the candidate txid the event refers to, while preserving the contribution-derived + /// amount/fee and `tx_type` that wallet sync must not recompute from its own view: the wallet's + /// `sent`/`received` don't capture our contribution to a shared funding output. Returns + /// [`FundingStatusUpdate::Applied`] when it handled the payment, so the caller skips the + /// default on-chain path — or [`FundingStatusUpdate::Foreign`] when the transaction is not + /// part of the payment's funding history, so the caller records it under its own id. + /// Graduation to `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. + /// + /// The caller must hold the [`PaymentStores`] lock — from resolving `payment_id` + /// through its own last write, not just across this call — so that a funding-record writer's + /// two-store write pair cannot interleave with the caller's decision sequence. The `stores` guard + /// proves the lock is held across this call; the rest of that contract is the caller's. + async fn apply_funding_status_update_locked( + &self, stores: &PaymentStoresGuard<'_>, payment_id: PaymentId, event_txid: Txid, + confirmation_status: ConfirmationStatus, + ) -> Result { + // The caller's wallet-level lock keeps the candidate history stable while we await its + // read. The funding-type gate, the candidate lookup, and the write then share the payment + // store's mutation lock: against a separate payment `get`, a funding-record write merging + // in between would have its `tx_type` and contribution figures clobbered by this stale + // snapshot. + let pending_payment = stores.pending_payment(&payment_id).await?; + let mut outcome = FundingStatusUpdate::NotFunding; + let mut handled = None; + stores + .mutate_payment(&payment_id, |existing| { + let payment = existing?; + let (current_txid, tx_type) = match &payment.kind { + PaymentKind::Onchain { + txid, + tx_type: + tx_type @ Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + .. + } => (*txid, tx_type.clone()), + _ => return None, + }; + // Adopt the event's txid only when the transaction is part of this payment's + // funding history: its current txid or a classified candidate. A conflicting + // transaction that is neither — a close also spends the funding outpoint — must + // not overwrite the record. + let owns_event_tx = event_txid == current_txid + || pending_payment.as_ref().is_some_and(|p| p.candidate(event_txid).is_some()); + if !owns_event_tx { + outcome = FundingStatusUpdate::Foreign; + return None; + } + // Report the figures of the candidate that actually confirmed, which need not be + // the last one broadcast (an earlier, lower-fee candidate may win) and may carry + // no figures at all (`None`) for a round we didn't contribute to. (`direction` is + // invariant across a splice's candidates and cannot be changed through the store + // anyway.) + let mut target = payment.clone(); + if let Some(candidate) = + pending_payment.as_ref().and_then(|p| p.candidate(event_txid)) + { + target.amount_msat = candidate.amount_msat; + target.fee_paid_msat = candidate.fee_paid_msat; + } + target.kind = + PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; + + // Merge through the update machinery so its rules (e.g. which fields a merge may + // touch) keep applying, and skip the write when nothing changed. + let mut merged = payment.clone(); + if merged.update(target.to_update()) { + handled = Some(merged.clone()); + Some(merged) + } else { + handled = Some(payment.clone()); + None + } + }) + .await?; + let Some(payment) = handled else { + return Ok(outcome); + }; + // Mirror the refreshed confirmation status onto the pending entry: `ChainTipChanged` + // graduates by reading the pending entry's details, so it must see the new status. This is + // the same dual-write the default `TxConfirmed` path performs; an empty conflicting-txids + // list leaves any stored conflicts intact (the update treats absent as "unchanged"). + if payment.status == PaymentStatus::Pending { + self.upsert_pending_payment(stores, payment, Vec::new()).await?; + } + Ok(FundingStatusUpdate::Applied) + } + + #[allow(deprecated)] + pub(crate) async fn bump_fee_rbf( + &self, payment_id: PaymentId, fee_rate: Option, cur_anchor_reserve_sats: u64, + ) -> Result { + let payment = self.payment_stores.payment(&payment_id).await?.ok_or_else(|| { + log_error!(self.logger, "Payment {} not found in payment store", payment_id); + Error::InvalidPaymentId + })?; + + let txid = match &payment.kind { + PaymentKind::Onchain { txid, .. } => *txid, + _ => { + log_error!( + self.logger, + "Payment {} is not an on-chain payment, cannot be replaced via RBF", + payment_id + ); + return Err(Error::InvalidPaymentId); + }, + }; + + // The transaction and whether the wallet owns every input it spends, read before the + // persister lock so what this node recorded about the transaction can be consulted + // without holding it. `list_output` rather than `get_utxo`, so an output this very + // transaction spends still counts as the wallet's. + let owned_inputs = { + let locked_wallet = self.inner.lock().expect("lock"); + let tx = locked_wallet.tx_details(txid).map(|details| details.tx.deref().clone()); + tx.map(|tx| { + let owned: HashSet = + locked_wallet.list_output().map(|output| output.outpoint).collect(); + let all_owned = tx.input.iter().all(|input| owned.contains(&input.previous_output)); + (tx, all_owned) + }) + }; + let Some((old_tx, all_inputs_owned)) = owned_inputs else { + log_error!(self.logger, "Transaction {} not found in wallet", txid); + return Err(Error::InvalidPaymentId); + }; + + // Only an ordinary payment of this wallet's may be replaced, decided positively rather + // than by exclusion: what this node recorded must make nothing of the transaction, and + // every input must be an output this wallet owns and can re-sign. A transaction no + // recorded fact names is therefore still refused when it reaches beyond the wallet's own + // coins, rather than passing for want of a reason to reject it. + // + // Anything a channel of this node's has a claim on is driven by LDK's funding, splice and + // close lifecycle rather than by the on-chain wallet: replacing it would broadcast a + // transaction LDK isn't tracking, and an interactively negotiated funding cannot be + // re-signed by this node alone. Fee-bumping a pending splice goes through + // `bump_channel_funding_fee` instead. + if let Some(tx_type) = self.tx_provenance(txid, &old_tx).await.classify(&old_tx) { + log_error!( + self.logger, + "Cannot RBF payment {} via bump_fee_rbf: {} is {:?}; a pending splice is fee-bumped with bump_channel_funding_fee", + payment_id, + txid, + tx_type, + ); + return Err(Error::InvalidPaymentId); + } + if !all_inputs_owned { + log_error!( + self.logger, + "Cannot RBF payment {}: transaction {} spends inputs this wallet does not own", + payment_id, + txid, + ); + return Err(Error::InvalidPaymentId); + } + + if let PaymentKind::Onchain { status, .. } = &payment.kind { + match status { + ConfirmationStatus::Confirmed { .. } => { + log_error!( + self.logger, + "Transaction {} is already confirmed and cannot be replaced via RBF", + payment_id + ); + return Err(Error::InvalidPaymentId); + }, + ConfirmationStatus::Unconfirmed => {}, + } + } + + if payment.direction != PaymentDirection::Outbound { + log_error!( + self.logger, + "Cannot RBF payment {}: only outbound payments can be replaced", + payment_id + ); + return Err(Error::InvalidPaymentId); + } + + let mut locked_persister = self.persister.lock().await; + let mut locked_wallet = self.inner.lock().expect("lock"); + + let old_fee_rate = locked_wallet.calculate_fee_rate(&old_tx).map_err(|e| { + log_error!(self.logger, "Failed to calculate fee rate of transaction {}: {}", txid, e); + Error::WalletOperationFailed + })?; + + // BIP 125 requires the replacement to pay a higher fee rate than the original. + // The minimum increase is the incremental relay fee. + let min_required_fee_rate_sat_per_kwu = + old_fee_rate.to_sat_per_kwu() + INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT as u64; + + let confirmation_target = ConfirmationTarget::OnchainPayment; + let estimated_fee_rate = self.fee_estimator.estimate_fee_rate(confirmation_target); + + // Use the higher of minimum RBF requirement or current network estimate + let final_fee_rate_sat_per_kwu = + min_required_fee_rate_sat_per_kwu.max(estimated_fee_rate.to_sat_per_kwu()); + let final_fee_rate = + fee_rate.unwrap_or_else(|| FeeRate::from_sat_per_kwu(final_fee_rate_sat_per_kwu)); + + let mut psbt = { + let mut builder = locked_wallet.build_fee_bump(txid).map_err(|e| { + log_error!(self.logger, "BDK fee bump failed for {}: {:?}", txid, e); + match e { + BuildFeeBumpError::TransactionNotFound(_) => Error::InvalidPaymentId, + BuildFeeBumpError::TransactionConfirmed(_) => { + log_error!(self.logger, "Payment {} is already confirmed", payment_id); + Error::InvalidPaymentId + }, + BuildFeeBumpError::IrreplaceableTransaction(_) => { + Error::OnchainTxCreationFailed + }, + BuildFeeBumpError::FeeRateUnavailable => Error::FeerateEstimationUpdateFailed, + BuildFeeBumpError::UnknownUtxo(_) => Error::OnchainTxCreationFailed, + BuildFeeBumpError::InvalidOutputIndex(_) => Error::OnchainTxCreationFailed, + } + })?; + + builder.fee_rate(final_fee_rate); + + match builder.finish() { + Ok(psbt) => Ok(psbt), + Err(CreateTxError::FeeRateTooLow { required: required_fee_rate }) => { + if fee_rate.is_some() { + log_error!( + self.logger, + "Provided fee rate {} is too low for RBF fee bump of txid {}, required minimum fee rate: {}", + fee_rate.expect("fee rate is set"), + txid, + required_fee_rate + ); + return Err(Error::InvalidFeeRate); + } + + log_info!(self.logger, "BDK requires higher fee rate: {}", required_fee_rate); + + // BDK may require a higher fee rate than our estimate due to + // differences in UTXO selection or transaction weight calculations. + // We cap the retry at 1.5x our target fee rate as a safety bound + // to avoid overpaying. + let max_allowed_fee_rate = FeeRate::from_sat_per_kwu( + final_fee_rate_sat_per_kwu.saturating_mul(3).saturating_div(2), + ); + if required_fee_rate > max_allowed_fee_rate { + log_error!( self.logger, "BDK required fee rate {} exceeds sanity cap {} (1.5x our estimate) for tx {}", required_fee_rate, max_allowed_fee_rate, txid ); + return Err(Error::InvalidFeeRate); + } + + let mut builder = locked_wallet.build_fee_bump(txid).map_err(|e| { + log_error!(self.logger, "BDK fee bump retry failed for {}: {:?}", txid, e); + Error::InvalidFeeRate + })?; + + builder.fee_rate(required_fee_rate); + builder.finish().map_err(|e| { + log_error!( + self.logger, + "Failed to finish PSBT with required fee rate: {:?}", + e + ); + Error::InvalidFeeRate + }) + }, + Err(e) => { + log_error!(self.logger, "Failed to create fee bump PSBT: {:?}", e); + Err(Error::InvalidFeeRate) + }, + }? + }; + + let old_fee_sats = locked_wallet + .calculate_fee(&old_tx) + .map_err(|e| { + log_error!(self.logger, "Failed to calculate fee of transaction {}: {}", txid, e); + Error::WalletOperationFailed + })? + .to_sat(); + let replacement_fee_sats = locked_wallet + .calculate_fee(&psbt.unsigned_tx) + .map_err(|e| { + log_error!( + self.logger, + "Failed to calculate fee of replacement transaction for {}: {}", + txid, + e + ); + Error::WalletOperationFailed + })? + .to_sat(); + let additional_fee_sats = replacement_fee_sats.saturating_sub(old_fee_sats); + let balance = locked_wallet.balance(); + let spendable_amount_sats = + self.get_balances_inner(balance, cur_anchor_reserve_sats).map(|(_, s)| s).unwrap_or(0); + if spendable_amount_sats < additional_fee_sats { + log_error!( + self.logger, + "Unable to bump fee due to insufficient reserve-preserving funds. \ + Available: {}sats, required additional fee: {}sats, reserve: {}sats", + spendable_amount_sats, + additional_fee_sats, + cur_anchor_reserve_sats, + ); + return Err(Error::InsufficientFunds); + } + + match locked_wallet.sign(&mut psbt, SignOptions::default()) { + Ok(finalized) => { + if !finalized { + log_error!(self.logger, "Failed to finalize signing for fee bump of {}", txid); + return Err(Error::OnchainTxCreationFailed); + } + }, + Err(err) => { + log_error!( + self.logger, + "Failed to sign fee bump transaction for {}: {}", + txid, + err + ); + return Err(err.into()); + }, + } + + let fee_bumped_tx = psbt.extract_tx().map_err(|e| { + log_error!(self.logger, "Failed to extract fee bump transaction for {}: {}", txid, e); + e + })?; + + let new_txid = fee_bumped_tx.compute_txid(); + + let change_set = locked_wallet.take_staged().unwrap_or_default(); + drop(locked_wallet); + + // The replacement's provenance is only readable once the wallet lock is released, and + // only knowable once the replacement exists: its inputs are what decides which facts the + // classification rests on. + let provenance = self.tx_provenance(new_txid, &fee_bumped_tx).await; + let new_payment = { + let locked_wallet = self.inner.lock().expect("lock"); + self.create_payment_from_tx( + &locked_wallet, + new_txid, + payment.id, + &fee_bumped_tx, + &provenance, + PaymentStatus::Pending, + ConfirmationStatus::Unconfirmed, + ) + }; + + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet after fee bump of {}: {}", txid, e); + Error::PersistenceFailed + })?; + + // Taken after the persister, the order wallet sync takes the two locks in. + let stores = self.payment_stores.lock().await; + stores.insert_or_update_payment(new_payment.clone()).await?; + self.upsert_pending_payment(&stores, new_payment, Vec::new()).await?; + + self.broadcaster.broadcast(fee_bumped_tx); + + log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid); + + Ok(new_txid) + } +} + +struct LocalStakeAggregate { + amount_msat: Option, + fee_paid_msat: Option, + direction: PaymentDirection, +} + +/// Aggregates our net stake across the channels of a single [`FundingCandidate`] by summing each +/// channel's signed [`FundingContribution::net_value`]. Returns no amount if we contributed to none +/// of them. +fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { + let mut net_stake = SignedAmount::ZERO; + let mut fee = Amount::ZERO; + let mut have_contribution = false; + for channel in &candidate.channels { + if let Some(contribution) = channel.contribution.as_ref() { + have_contribution = true; + net_stake += contribution.net_value(); + // `estimated_fee` is our per-contributor share, so summing across channels is correct. + fee += contribution.estimated_fee(); + } + } + if !have_contribution { + return LocalStakeAggregate { + amount_msat: None, + fee_paid_msat: None, + direction: PaymentDirection::Outbound, + }; + } + // Direction is from our on-chain wallet's perspective: a positive net stake funds the channel + // (Outbound), while a negative one is a splice-out that returns funds to the wallet (Inbound). + let direction = if net_stake >= SignedAmount::ZERO { + PaymentDirection::Outbound + } else { + PaymentDirection::Inbound + }; + LocalStakeAggregate { + amount_msat: Some(net_stake.unsigned_abs().to_sat() * 1000), + fee_paid_msat: Some(fee.to_sat() * 1000), + direction, + } +} + +/// Whether `entry` tracks the funding payment of a splice into `channel_id`. +fn tracks_channel(entry: &PendingPaymentDetails, channel_id: ChannelId) -> bool { + entry.funding_channels().iter().any(|channel| channel.channel_id == channel_id) +} + +/// A round of an interactive funding negotiation that has a transaction, and the channels that +/// transaction funds. +#[derive(Clone, Debug)] +pub(crate) struct FundingCandidate { + /// The txid of this round. + pub txid: Txid, + /// The channels participating in this round. + pub channels: Vec, +} + +/// A single channel's participation in a [`FundingCandidate`]. +#[derive(Clone, Debug)] +pub(crate) struct ChannelFunding { + /// The `node_id` of the channel counterparty. + pub counterparty_node_id: PublicKey, + /// The ID of the channel. + pub channel_id: ChannelId, + /// This node's contribution to this channel in this round, or `None` where it contributed + /// nothing — a pure acceptor adding no value, or a leading RBF round before it began + /// contributing. + pub contribution: Option, +} + +/// Lists a channel's pending splice rounds that have a transaction — the negotiated predecessors +/// and the round awaiting signatures, in LDK's order, each with this node's contribution to it — +/// for recording the round when signing it. A contribution still queued behind the pending rounds +/// has no transaction and is left out; a channel with no pending splice yields nothing. +pub(crate) fn funding_candidates( + details: Option<&SpliceDetails>, counterparty_node_id: PublicKey, channel_id: ChannelId, +) -> Vec { + details + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]) + .iter() + .filter_map(|candidate| { + let txid = round_txid(candidate)?; + Some(FundingCandidate { + txid, + channels: vec![ChannelFunding { + counterparty_node_id, + channel_id, + contribution: candidate.contribution.clone(), + }], + }) + }) + .collect() +} + +/// The transaction of a pending splice round, once it has one: a negotiated round's, or the +/// round awaiting signatures'. +fn round_txid(candidate: &SpliceCandidateDetails) -> Option { + match &candidate.status { + SpliceCandidateStatus::Negotiated { txid, .. } + | SpliceCandidateStatus::AwaitingSignatures { txid, .. } => Some(*txid), + _ => None, + } +} + +/// The splice rounds LDK holds for a channel, as [`Wallet::drop_abandoned_splice_rounds`] takes +/// them: the pending rounds with a transaction, as [`funding_candidates`] lists them, and the +/// channel's current funding. A zero-conf splice is promoted to the funding as soon as +/// `splice_locked` is exchanged, before its transaction confirms, so it leaves the pending rounds +/// while its record may still await the `SpliceNegotiated` event that marks it broadcast. +pub(crate) fn held_splice_rounds( + details: Option<&SpliceDetails>, funding_txo: Option, +) -> Vec { + let mut held: Vec = details + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]) + .iter() + .filter_map(round_txid) + .collect(); + held.extend(funding_txo.map(|funding| funding.txid)); + held +} + +/// The splice rounds LDK still holds for a closed channel, as +/// [`Wallet::drop_abandoned_splice_rounds`] takes them: the channel's last funding — which a +/// zero-conf splice may have become before its transaction confirmed — and every transaction the +/// channel's monitor still watches. The channel manager forgets a pending round with the channel, +/// and what it reports for one awaiting the counterparty's signatures is queued after +/// `ChannelClosed`, but the monitor keeps watching every pending round the counterparty's +/// `commitment_signed` reached and the background processor has flushed to it — the monitor's +/// updates land after the manager's, deferred to that flush — until a sibling locks or the close +/// matures, and our signatures cannot have left the node before that update was persisted: such a +/// round may yet confirm and is left to wallet sync or `DiscardFunding` to resolve, while a round +/// the monitor never watched never had our signatures released. A round whose `commitment_signed` +/// the manager processed since the last flush therefore still looks unwatched here, and is dropped +/// from its record as one nothing broadcast. That is the right outcome for the record: our +/// `tx_signatures` for a splice round are released only once the monitor update its +/// `commitment_signed` produced has been persisted, whichever side sends first, so the counterparty +/// holds nothing it could broadcast. The watched transactions also include the funding and whatever +/// spent it on chain, which no recorded round is. A funding the channel moved on from before it +/// confirmed — a zero-conf splice a later splice built on — is held by neither and can confirm +/// still; the funding payments keep such rounds themselves (see +/// [`Wallet::resolve_promoted_splice_round`]). +pub(crate) fn closed_channel_held_rounds( + funding_txo: Option, watched_txids: impl IntoIterator, +) -> Vec { + let mut held: Vec = funding_txo.map(|funding| funding.txid).into_iter().collect(); + for txid in watched_txids { + if !held.contains(&txid) { + held.push(txid); + } + } + held +} + +/// The occasion on which [`Wallet::fail_funding_payments_without_held_round_locked`] resolves a +/// channel's funding payments by the rounds LDK holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FundingResolution { + /// The channel closed. + Close, + /// LDK promoted the given splice round to the channel's funding. + Promotion(Txid), +} + +/// The outcome of [`Wallet::fail_unconfirmed_funding_payment_locked`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FundingPaymentFailure { + /// The payment was failed and its pending entry removed, 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, its intent kept likewise. + EntryRemoved, + /// The record no longer waits on the transaction; nothing was touched. + MovedOn, +} + +/// Generates a fresh funding-record [`PaymentId`] from the OS entropy source. A funding record's id +/// carries no meaning beyond uniqueness: the record is found through its transaction history +/// ([`Wallet::find_payment_by_txid`]), never re-derived from a txid. +pub(crate) fn random_payment_id() -> PaymentId { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("getrandom failed"); + PaymentId(bytes) +} + +/// The outcome of [`Wallet::apply_funding_status_update_locked`]. +enum FundingStatusUpdate { + /// The event's transaction belongs to the funding payment; its refreshed confirmation status + /// was applied (or was already current). + Applied, + /// The resolved payment is not a classified funding payment; the caller's default on-chain + /// handling applies under the resolved id. + NotFunding, + /// The event's transaction is not part of the funding payment's history — e.g. a close + /// spending the same funding outpoint — so the funding record must not adopt it; the caller + /// should record the transaction under its own txid-derived id. + Foreign, +} + +impl Listen for Wallet { + fn filtered_block_connected( + &self, _header: &bitcoin::block::Header, + _txdata: &lightning::chain::transaction::TransactionData, _height: u32, + ) { + debug_assert!(false, "Syncing filtered blocks is currently not supported"); + // As far as we can tell this would be a no-op anyways as we don't have to tell BDK about + // the header chain of intermediate blocks. According to the BDK team, it's sufficient to + // only connect full blocks starting from the last point of disagreement. + } + + fn block_connected(&self, block: &bitcoin::Block, height: u32) { + self.runtime.block_on(async { + let mut locked_persister = self.persister.lock().await; + let events = { + let mut locked_wallet = self.inner.lock().expect("lock"); + + let pre_checkpoint = locked_wallet.latest_checkpoint(); + if pre_checkpoint.height() != height - 1 + || pre_checkpoint.hash() != block.header.prev_blockhash + { + log_debug!( + self.logger, + "Detected reorg while applying a connected block to on-chain wallet: new block with hash {} at height {}", + block.header.block_hash(), + height + ); + } + + // In order to be able to reliably calculate fees the `Wallet` needs access to the previous + // ouput data. To this end, we here insert any ouputs of transactions that LDK is intersted + // in (e.g., funding transaction ouputs) into the wallet's transaction graph when we see + // them, so it is reliably able to calculate fees for subsequent spends. + // + // FIXME: technically, we should also do this for mempool transactions. However, at the + // current time fixing the edge case doesn't seem worth the additional conplexity / + // additional overhead.. + let registered_txids = self.chain_source.registered_txids(); + for tx in &block.txdata { + let txid = tx.compute_txid(); + if registered_txids.contains(&txid) { + for (vout, txout) in tx.output.iter().enumerate() { + let outpoint = OutPoint { txid, vout: vout as u32 }; + locked_wallet.insert_txout(outpoint, txout.clone()); + } + } + } + + match locked_wallet.apply_block_events(block, height) { + Ok(events) => events, + Err(e) => { + log_error!( + self.logger, + "Failed to apply connected block to on-chain wallet: {}", + e + ); + return; + }, + } + }; + + if let Err(e) = self.update_payment_store(events).await { + log_error!(self.logger, "Failed to update payment store: {}", e); + return; + } + + let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); + if let Err(e) = locked_persister.persist_changeset(change_set).await { + log_error!(self.logger, "Failed to persist on-chain wallet: {}", e); + return; + } + }); + } + + fn blocks_disconnected(&self, _fork_point_block: BlockLocator) { + // This is a no-op as we don't have to tell BDK about disconnections. According to the BDK + // team, it's sufficient in case of a reorg to always connect blocks starting from the last + // point of disagreement. + } +} + +impl WalletSource for Wallet { + fn list_confirmed_utxos<'a>( + &'a self, + ) -> impl Future, ()>> + Send + 'a { + async move { self.list_confirmed_utxos_inner() } + } + + fn get_change_script<'a>(&'a self) -> impl Future> + Send + 'a { + async move { self.get_change_script_inner().await } + } + + fn get_prevtx<'a>( + &'a self, outpoint: OutPoint, + ) -> impl Future> + Send + 'a { + async move { + let locked_wallet = self.inner.lock().expect("lock"); + locked_wallet + .tx_details(outpoint.txid) + .map(|tx_details| tx_details.tx.deref().clone()) + .ok_or_else(|| { + log_error!( + self.logger, + "Failed to get previous transaction for {}", + outpoint.txid + ); + }) + } + } + + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + Send + 'a { + async move { self.sign_psbt_inner(psbt) } + } +} + +// Anchor bumping uses LdkWallet for coin selection, which wraps a WalletSource to implement +// CoinSelectionSource. Splicing uses this implementation of coin selection instead. +impl CoinSelectionSource for Wallet { + fn select_confirmed_utxos<'a>( + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], + target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64, + ) -> impl Future> + Send + 'a { + debug_assert!(claim_id.is_none()); + let fee_rate = FeeRate::from_sat_per_kwu(target_feerate_sat_per_1000_weight as u64); + async move { self.select_confirmed_utxos(must_spend, must_pay_to, fee_rate).await } + } + + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + Send + 'a { + debug_assert!(false); + async move { self.sign_psbt_inner(psbt) } + } +} + +/// Similar to [`KeysManager`], but overrides the destination and shutdown scripts so they are +/// directly spendable by the BDK wallet. +pub(crate) struct WalletKeysManager { + inner: KeysManager, + wallet: Arc, + logger: Arc, +} + +impl WalletKeysManager { + /// Constructs a `WalletKeysManager` that overrides the destination and shutdown scripts. + /// + /// See [`KeysManager::new`] for more information on `seed`, `starting_time_secs`, and + /// `starting_time_nanos`. + pub fn new( + seed: &[u8; 32], starting_time_secs: u64, starting_time_nanos: u32, wallet: Arc, + logger: Arc, + ) -> Self { + let inner = KeysManager::new(seed, starting_time_secs, starting_time_nanos, true); + Self { inner, wallet, logger } + } + + pub fn sign_message(&self, msg: &[u8]) -> String { + message_signing::sign(msg, &self.inner.get_node_secret_key()) + } + + pub fn get_node_secret_key(&self) -> SecretKey { + self.inner.get_node_secret_key() + } + + pub fn verify_signature(&self, msg: &[u8], sig: &str, pkey: &PublicKey) -> bool { + message_signing::verify(msg, sig, pkey) + } +} + +impl NodeSigner for WalletKeysManager { + fn get_node_id(&self, recipient: Recipient) -> Result { + self.inner.get_node_id(recipient) + } + + fn ecdh( + &self, recipient: Recipient, other_key: &PublicKey, tweak: Option<&Scalar>, + ) -> Result { + self.inner.ecdh(recipient, other_key, tweak) + } + + fn get_expanded_key(&self) -> ExpandedKey { + self.inner.get_expanded_key() + } + + fn get_peer_storage_key(&self) -> PeerStorageKey { + self.inner.get_peer_storage_key() + } + + fn get_receive_auth_key(&self) -> lightning::sign::ReceiveAuthKey { + self.inner.get_receive_auth_key() + } + + fn sign_invoice( + &self, invoice: &RawBolt11Invoice, recipient: Recipient, + ) -> Result { + self.inner.sign_invoice(invoice, recipient) + } + + fn sign_gossip_message(&self, msg: UnsignedGossipMessage<'_>) -> Result { + self.inner.sign_gossip_message(msg) + } + + fn sign_bolt12_invoice( + &self, invoice: &lightning::offers::invoice::UnsignedBolt12Invoice, + ) -> Result { + self.inner.sign_bolt12_invoice(invoice) + } + fn sign_message(&self, msg: &[u8]) -> Result { + self.inner.sign_message(msg) + } +} + +impl OutputSpender for WalletKeysManager { + /// See [`KeysManager::spend_spendable_outputs`] for documentation on this method. + fn spend_spendable_outputs( + &self, descriptors: &[&SpendableOutputDescriptor], outputs: Vec, + change_destination_script: ScriptBuf, feerate_sat_per_1000_weight: u32, + locktime: Option, secp_ctx: &Secp256k1, + ) -> Result { + self.inner.spend_spendable_outputs( + descriptors, + outputs, + change_destination_script, + feerate_sat_per_1000_weight, + locktime, + secp_ctx, + ) + } +} + +impl EntropySource for WalletKeysManager { + fn get_secure_random_bytes(&self) -> [u8; 32] { + self.inner.get_secure_random_bytes() + } +} + +impl SignerProvider for WalletKeysManager { + type EcdsaSigner = InMemorySigner; + + fn generate_channel_keys_id(&self, inbound: bool, user_channel_id: u128) -> [u8; 32] { + self.inner.generate_channel_keys_id(inbound, user_channel_id) + } + + fn derive_channel_signer(&self, channel_keys_id: [u8; 32]) -> Self::EcdsaSigner { + self.inner.derive_channel_signer(channel_keys_id) + } + + fn get_destination_script(&self, _channel_keys_id: [u8; 32]) -> Result { + // LDK may invoke this callback on a runtime worker thread while holding channel locks. + // It must not block on the runtime, or the runtime can deadlock. + let address = self.wallet.pop_pooled_address().ok_or_else(|| { + log_error!(self.logger, "Failed to retrieve a destination script: address pool empty"); + })?; + Ok(address.script_pubkey()) + } + + fn get_shutdown_scriptpubkey(&self) -> Result { + // LDK may invoke this callback on a runtime worker thread while holding channel locks. + // It must not block on the runtime, or the runtime can deadlock. + let address = self.wallet.pop_pooled_address().ok_or_else(|| { + log_error!(self.logger, "Failed to retrieve a shutdown script: address pool empty"); + })?; + + match address.witness_program() { + Some(program) => ShutdownScript::new_witness_program(&program).map_err(|e| { + log_error!(self.logger, "Invalid shutdown script: {:?}", e); + }), + _ => { + log_error!( + self.logger, + "Tried to use a non-witness address. This must never happen." + ); + panic!("Tried to use a non-witness address. This must never happen."); + }, + } + } +} + +impl ChangeDestinationSource for WalletKeysManager { + fn get_change_destination_script<'a>( + &'a self, + ) -> impl Future> + Send + 'a { + async move { + self.wallet + .get_new_internal_address() + .await + .map_err(|e| { + log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e); + }) + .map(|addr| addr.script_pubkey()) + .map_err(|_| ()) + } + } +} + +/// Convert LDK's `Input::satisfaction_weight` to the value BDK's +/// [`bdk_wallet::TxBuilder::add_foreign_utxo`] expects. +/// +/// LDK and BDK disagree on what `satisfaction_weight` includes for a SegWit input. LDK +/// treats it as the full weight of the spent input's `script_sig` and `witness` *each +/// with their lengths included* — i.e., the empty `script_sig` length byte (4 WU) and +/// the witness-elements-count varint (1 WU) are part of the value. BDK adds +/// `TxIn::default().segwit_weight()` internally, which already accounts for those same +/// 5 WU (an empty TxIn has a 1-byte empty `script_sig` length and a 1-byte empty +/// witness-count varint). Passing LDK's value directly to BDK therefore double-counts +/// 5 WU per foreign input, which inflates BDK's fee estimate and ultimately funnels the +/// surplus into the new funding output during splice negotiation. +fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { + const EMPTY_SCRIPT_SIG_WEIGHT: u64 = + 1 /* empty script_sig length byte */ * WITNESS_SCALE_FACTOR as u64; + const EMPTY_WITNESS_COUNT_WEIGHT: u64 = 1 /* witness elements count varint */; + Weight::from_wu( + ldk_satisfaction_weight + .saturating_sub(EMPTY_SCRIPT_SIG_WEIGHT + EMPTY_WITNESS_COUNT_WEIGHT), + ) +} + +#[cfg(all(test, any(feature = "chain-esplora", feature = "chain-electrum")))] +mod tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate}; + use bdk_wallet::Wallet as BdkWallet; + use bitcoin::hashes::Hash; + use bitcoin::Network; + use lightning::io; + use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; + + use super::*; + #[cfg(all(not(feature = "chain-esplora"), feature = "chain-electrum"))] + use crate::config::ElectrumSyncConfig; + #[cfg(feature = "chain-esplora")] + use crate::config::EsploraSyncConfig; + 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, + BDK_WALLET_ADDRESS_POOL_SECONDARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + }; + use crate::payment::pending_payment_store::{ + test_funding_contribution_with_outputs, test_funding_contribution_with_parts, + PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind, + }; + + use crate::types::{DynStore, DynStoreWrapper}; + use crate::wallet::provenance::{live_channels_of, ChannelOutputRole, LocalFundingFigures}; + use crate::{NodeMetrics, PersistedNodeMetrics}; + + const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; + const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; + + /// An in-memory store counting the reads it serves, by primary namespace, so tests can pin + /// how many backend reads an operation costs. + #[derive(Clone)] + struct ReadCountingStore { + inner: Arc, + reads: Arc>>, + } + + impl ReadCountingStore { + fn new() -> Self { + Self { inner: Arc::new(InMemoryStore::new()), reads: Arc::new(Mutex::new(Vec::new())) } + } + + /// The number of reads served from `primary_namespace` so far. + fn reads(&self, primary_namespace: &str) -> usize { + self.reads + .lock() + .unwrap() + .iter() + .filter(|namespace| *namespace == primary_namespace) + .count() + } + } + + impl KVStore for ReadCountingStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + self.reads.lock().unwrap().push(primary_namespace.to_string()); + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + KVStore::write(&*self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for ReadCountingStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + + /// An in-memory store whose writes can be made to fail on demand, counting the failures so + /// tests can wait for a write to have actually failed rather than guessing with a sleep. + #[derive(Clone)] + struct FailSwitchStore { + inner: Arc, + fail_writes: Arc, + failed_writes: Arc, + /// When set, only writes to this primary namespace fail while `fail_writes` is on. + failing_namespace: Option, + } + + impl FailSwitchStore { + fn new() -> Self { + Self { + inner: Arc::new(InMemoryStore::new()), + fail_writes: Arc::new(AtomicBool::new(false)), + failed_writes: Arc::new(AtomicUsize::new(0)), + failing_namespace: None, + } + } + + /// Like [`Self::new`], but only writes to `primary_namespace` fail. + fn failing_only(primary_namespace: &str) -> Self { + Self { failing_namespace: Some(primary_namespace.to_string()), ..Self::new() } + } + } + + impl KVStore for FailSwitchStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + let inner = Arc::clone(&self.inner); + let fail_writes = Arc::clone(&self.fail_writes); + let failed_writes = Arc::clone(&self.failed_writes); + let may_fail = + self.failing_namespace.as_deref().map_or(true, |ns| ns == primary_namespace); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + async move { + if may_fail && fail_writes.load(Ordering::Acquire) { + failed_writes.fetch_add(1, Ordering::AcqRel); + return Err(io::Error::new(io::ErrorKind::Other, "writes disabled")); + } + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + } + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for FailSwitchStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + + /// An in-memory store that fails the next remove issued against an armed namespace, for + /// exercising cleanup paths that must survive a failure between two removals. + #[derive(Clone)] + struct FailRemoveStore { + inner: Arc, + fail_remove_in: Arc>>, + } + + impl FailRemoveStore { + fn new() -> Self { + Self { + inner: Arc::new(InMemoryStore::new()), + fail_remove_in: Arc::new(std::sync::Mutex::new(None)), + } + } + + fn fail_next_remove_in(&self, primary_namespace: &str) { + *self.fail_remove_in.lock().unwrap() = Some(primary_namespace.to_string()); + } + } + + impl KVStore for FailRemoveStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + KVStore::write(&*self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + let inner = Arc::clone(&self.inner); + let armed = Arc::clone(&self.fail_remove_in); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + async move { + let fail = { + let mut armed = armed.lock().unwrap(); + if armed.as_deref() == Some(primary_namespace.as_str()) { + *armed = None; + true + } else { + false + } + }; + if fail { + return Err(io::Error::new(io::ErrorKind::Other, "removes disabled")); + } + KVStore::remove(&*inner, &primary_namespace, &secondary_namespace, &key, lazy).await + } + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for FailRemoveStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + + /// Constructs a `Wallet` around the given store, either creating a fresh BDK wallet or + /// loading the one the store already holds. + async fn new_test_wallet(store: Arc, load_existing: bool) -> Arc { + let logger = Arc::new(Logger::new_log_facade()); + let mut config = Config::default(); + config.network = Network::Regtest; + let config = Arc::new(config); + + let mut wallet_persister = + KVStoreWalletPersister::new(Arc::clone(&store), Arc::clone(&logger)); + #[allow(deprecated)] + let bdk_wallet = if load_existing { + BdkWallet::load() + .descriptor(KeychainKind::External, Some(EXTERNAL_DESCRIPTOR)) + .descriptor(KeychainKind::Internal, Some(INTERNAL_DESCRIPTOR)) + .extract_keys() + .check_network(Network::Regtest) + .load_wallet_async(&mut wallet_persister) + .await + .unwrap() + .unwrap() + } else { + BdkWallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR) + .network(Network::Regtest) + .create_wallet_async(&mut wallet_persister) + .await + .unwrap() + }; + + let fee_estimator = Arc::new(OnchainFeeEstimator::new()); + let broadcaster = Arc::new(Broadcaster::new(Arc::clone(&logger))); + let node_metrics = Arc::new(PersistedNodeMetrics::new(NodeMetrics::default())); + #[cfg(feature = "chain-esplora")] + let (chain_source, _) = ChainSource::new_esplora( + "http://localhost:1".to_string(), + HashMap::new(), + EsploraSyncConfig::default(), + Arc::clone(&fee_estimator), + Arc::clone(&broadcaster), + Arc::clone(&store), + Arc::clone(&config), + Arc::clone(&logger), + node_metrics, + ) + .unwrap(); + #[cfg(all(not(feature = "chain-esplora"), feature = "chain-electrum"))] + let (chain_source, _) = ChainSource::new_electrum( + "tcp://localhost:1".to_string(), + ElectrumSyncConfig::default(), + Arc::clone(&fee_estimator), + Arc::clone(&broadcaster), + Arc::clone(&store), + Arc::clone(&config), + Arc::clone(&logger), + node_metrics, + ); + let payment_store = Arc::new(PaymentStore::new( + Vec::new(), + KeepLeastRecentlyUsed::new(PAYMENT_CACHE_CAPACITY), + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&store), + Arc::clone(&logger), + )); + let pending_payment_store = Arc::new(PendingPaymentStore::new( + Vec::new(), + KeepAllEntries, + PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&store), + Arc::clone(&logger), + )); + let channel_tx_facts_store = Arc::new(ChannelTxFactsStore::new( + Vec::new(), + KeepLeastRecentlyUsed::new(CHANNEL_TX_FACTS_CACHE_CAPACITY), + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&store), + Arc::clone(&logger), + )); + let runtime = Arc::new(Runtime::new(Arc::clone(&logger)).unwrap()); + + let persisted_pool_indices = persist::read_address_pool(&*store, &*logger).await.unwrap(); + + Arc::new(Wallet::new( + bdk_wallet, + wallet_persister, + persisted_pool_indices, + broadcaster, + fee_estimator, + Arc::new(chain_source), + payment_store, + runtime, + config, + logger, + pending_payment_store, + channel_tx_facts_store, + )) + } + + fn pooled_indices(wallet: &Wallet) -> Vec { + wallet.address_pool.lock().unwrap().available.iter().map(|(index, _)| *index).collect() + } + + fn test_splice_intent() -> crate::payment::pending_payment_store::SpliceIntent { + use crate::payment::pending_payment_store::{SpliceIntent, SpliceKind}; + + SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([13u8; 32]), + pre_splice_funding_txo: lightning::chain::transaction::OutPoint { + txid: Txid::from_byte_array([3u8; 32]), + index: 0, + }, + contribution: crate::payment::pending_payment_store::test_funding_contribution(), + kind: SpliceKind::In { amount_sats: 10_000 }, + } + } + + fn funding_payment(id: PaymentId, txid: Txid, status: PaymentStatus) -> PaymentDetails { + PaymentDetails::new( + id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels: Vec::new() }), + }, + Some(1_000_000), + Some(500), + PaymentDirection::Outbound, + status, + ) + } + + #[tokio::test] + async fn recording_a_round_promotes_a_pre_broadcast_intent_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([21u8; 32]); + let txid = Txid::from_byte_array([22u8; 32]); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, test_splice_intent())) + .await + .unwrap(); + + let candidates = vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + wallet + .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(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + wallet.refill_address_pool().await.unwrap(); + assert_eq!(pooled_indices(&wallet).len(), ADDRESS_POOL_TARGET_SIZE); + + // Simulate a handout, then make wallet writes fail: the refill must not publish the + // address it revealed, as a crash would leave its script unwatched by incremental syncs. + wallet.address_pool.lock().unwrap().available.pop_front().unwrap(); + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.refill_address_pool().await.is_err()); + let unpersisted_index = ADDRESS_POOL_TARGET_SIZE as u32; + let indices = pooled_indices(&wallet); + assert_eq!(indices.len(), ADDRESS_POOL_TARGET_SIZE - 1); + assert!(!indices.contains(&unpersisted_index)); + + // Once persistence recovers, the next refill publishes the retained reveal without + // burning another derivation index. + fail_store.fail_writes.store(false, Ordering::Release); + wallet.refill_address_pool().await.unwrap(); + let indices = pooled_indices(&wallet); + assert_eq!(indices.len(), ADDRESS_POOL_TARGET_SIZE); + assert!(indices.contains(&unpersisted_index)); + let last_revealed = wallet.inner.lock().unwrap().derivation_index(KeychainKind::External); + assert_eq!(last_revealed, Some(unpersisted_index)); + } + + #[tokio::test] + async fn pool_reloads_across_restarts_without_burning_indices() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + + let (popped_address, indices_before) = { + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.refill_address_pool().await.unwrap(); + // Simulate a handout and a completed refill before the restart. + let (_, popped_address) = + wallet.address_pool.lock().unwrap().available.pop_front().unwrap(); + wallet.refill_address_pool().await.unwrap(); + (popped_address, pooled_indices(&wallet)) + }; + + let wallet = new_test_wallet(Arc::clone(&store), true).await; + wallet.refill_address_pool().await.unwrap(); + + // The pool is rebuilt from the persisted record: the restart neither reveals fresh + // indices (widening what incremental syncs must watch) nor re-hands-out the address + // popped before the restart. + assert_eq!(pooled_indices(&wallet), indices_before); + let last_revealed = wallet.inner.lock().unwrap().derivation_index(KeychainKind::External); + assert_eq!(last_revealed, Some(ADDRESS_POOL_TARGET_SIZE as u32)); + let pool = wallet.address_pool.lock().unwrap(); + assert!(!pool.available.iter().any(|(_, address)| *address == popped_address)); + } + + #[tokio::test] + async fn loading_drops_pool_indices_the_wallet_never_revealed() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + { + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.refill_address_pool().await.unwrap(); + } + + // Corrupt the persisted record with an index the wallet never revealed. + let logger = Arc::new(Logger::new_log_facade()); + let mut persister = KVStoreWalletPersister::new(Arc::clone(&store), logger); + persister.persist_address_pool(vec![5, 100]).await.unwrap(); + + let wallet = new_test_wallet(Arc::clone(&store), true).await; + wallet.refill_address_pool().await.unwrap(); + + // Index 5 was revealed before the restart and is kept; the never-revealed index 100 + // must be dropped, as no sync path would watch its script. The initial refill then + // tops the pool back up with fresh reveals. + let indices = pooled_indices(&wallet); + assert_eq!(indices.len(), ADDRESS_POOL_TARGET_SIZE); + assert!(indices.contains(&5)); + assert!(!indices.contains(&100)); + } + + #[tokio::test] + async fn signer_provider_callbacks_fail_closed_when_pool_is_empty() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let logger = Arc::new(Logger::new_log_facade()); + let keys_manager = WalletKeysManager::new(&[7u8; 32], 42, 42, Arc::clone(&wallet), logger); + + // Before the pool is filled it is empty: the sync callbacks must fail closed rather + // than hand out an address whose reveal was never persisted. + assert!(keys_manager.get_destination_script([0u8; 32]).is_err()); + assert!(keys_manager.get_shutdown_scriptpubkey().is_err()); + + wallet.refill_address_pool().await.unwrap(); + assert!(keys_manager.get_destination_script([0u8; 32]).is_ok()); + assert!(keys_manager.get_shutdown_scriptpubkey().is_ok()); + } + + /// An in-memory store that snapshots its full contents after every completed write, letting + /// tests reload the wallet from any crash point. + #[derive(Clone)] + struct SnapshotStore { + data: Arc>>>, + snapshots: Arc>>>>, + } + + impl SnapshotStore { + fn new() -> Self { + Self { + data: Arc::new(Mutex::new(HashMap::new())), + snapshots: Arc::new(Mutex::new(Vec::new())), + } + } + + fn from_contents(data: HashMap<(String, String, String), Vec>) -> Self { + Self { data: Arc::new(Mutex::new(data)), snapshots: Arc::new(Mutex::new(Vec::new())) } + } + } + + impl KVStore for SnapshotStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + let res = self + .data + .lock() + .unwrap() + .get(&( + primary_namespace.to_string(), + secondary_namespace.to_string(), + key.to_string(), + )) + .cloned() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "not found")); + async move { res } + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + let mut data = self.data.lock().unwrap(); + data.insert( + (primary_namespace.to_string(), secondary_namespace.to_string(), key.to_string()), + buf, + ); + self.snapshots.lock().unwrap().push(data.clone()); + async move { Ok(()) } + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, _lazy: bool, + ) -> impl Future> + 'static + Send { + let mut data = self.data.lock().unwrap(); + data.remove(&( + primary_namespace.to_string(), + secondary_namespace.to_string(), + key.to_string(), + )); + self.snapshots.lock().unwrap().push(data.clone()); + async move { Ok(()) } + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + let keys = self + .data + .lock() + .unwrap() + .keys() + .filter(|(primary, secondary, _)| { + primary == primary_namespace && secondary == secondary_namespace + }) + .map(|(_, _, key)| key.clone()) + .collect::>(); + async move { Ok(keys) } + } + } + + impl PaginatedKVStore for SnapshotStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + _page_token: Option, + ) -> impl Future> + 'static + Send { + let keys = self + .data + .lock() + .unwrap() + .keys() + .filter(|(primary, secondary, _)| { + primary == primary_namespace && secondary == secondary_namespace + }) + .map(|(_, _, key)| key.clone()) + .collect::>(); + async move { Ok(PaginatedListResponse { keys, next_page_token: None }) } + } + } + + #[tokio::test] + async fn pool_survives_a_crash_at_any_point_during_refill() { + let snapshot_store = SnapshotStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(snapshot_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + // Only replay crash points from wallet creation onwards; earlier snapshots hold a + // half-created wallet, which is the builder's concern rather than the pool's. + let baseline = snapshot_store.snapshots.lock().unwrap().len(); + + wallet.refill_address_pool().await.unwrap(); + // Simulate a handout plus the refill it schedules. + wallet.address_pool.lock().unwrap().available.pop_front().unwrap(); + wallet.refill_address_pool().await.unwrap(); + let final_derivation = + wallet.inner.lock().unwrap().derivation_index(KeychainKind::External).unwrap(); + + // Reload the wallet from every intermediate store state. No crash point may leave the + // pool unfillable or burn indices: a reload revealing past `final_derivation` means some + // reveal was durable while absent from the pool record, stranding its index as + // revealed-but-unused forever. + let snapshots = snapshot_store.snapshots.lock().unwrap().clone(); + assert!(snapshots.len() > baseline); + for snapshot in snapshots.into_iter().skip(baseline) { + let store: Arc = + Arc::new(DynStoreWrapper(SnapshotStore::from_contents(snapshot))); + let wallet = new_test_wallet(Arc::clone(&store), true).await; + wallet.refill_address_pool().await.unwrap(); + assert_eq!(pooled_indices(&wallet).len(), ADDRESS_POOL_TARGET_SIZE); + let derivation = + wallet.inner.lock().unwrap().derivation_index(KeychainKind::External).unwrap(); + assert!(derivation <= final_derivation); + } + } + + /// An in-memory store whose writes can be made to park until aborted or released, + /// signalling when a write has entered the gate, and whose writes can be made to fail, + /// counting the failures. + #[derive(Clone)] + struct GatedStore { + inner: Arc, + gate_writes: Arc, + fail_writes: Arc, + failed_writes: Arc, + write_entered: Arc, + release: Arc, + } + + impl GatedStore { + fn new() -> Self { + Self { + inner: Arc::new(InMemoryStore::new()), + gate_writes: Arc::new(AtomicBool::new(false)), + fail_writes: Arc::new(AtomicBool::new(false)), + failed_writes: Arc::new(AtomicUsize::new(0)), + write_entered: Arc::new(tokio::sync::Notify::new()), + release: Arc::new(tokio::sync::Notify::new()), + } + } + } + + impl KVStore for GatedStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + let inner = Arc::clone(&self.inner); + let gate_writes = Arc::clone(&self.gate_writes); + let fail_writes = Arc::clone(&self.fail_writes); + let failed_writes = Arc::clone(&self.failed_writes); + let write_entered = Arc::clone(&self.write_entered); + let release = Arc::clone(&self.release); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + async move { + if gate_writes.load(Ordering::Acquire) { + write_entered.notify_one(); + release.notified().await; + } + if fail_writes.load(Ordering::Acquire) { + failed_writes.fetch_add(1, Ordering::AcqRel); + return Err(io::Error::new(io::ErrorKind::Other, "write failed")); + } + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + } + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for GatedStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + + #[tokio::test] + async fn aborting_a_refill_mid_persist_loses_no_reveals() { + let gated_store = GatedStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(gated_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.refill_address_pool().await.unwrap(); + + // Simulate two handouts, then a refill that is aborted (as node shutdown aborts + // cancellable tasks) while parked on its first store write. + wallet.address_pool.lock().unwrap().available.pop_front().unwrap(); + wallet.address_pool.lock().unwrap().available.pop_front().unwrap(); + gated_store.gate_writes.store(true, Ordering::Release); + let refill_wallet = Arc::clone(&wallet); + let refill_task = tokio::spawn(async move { + let _ = refill_wallet.refill_address_pool().await; + }); + gated_store.write_entered.notified().await; + refill_task.abort(); + assert!(refill_task.await.unwrap_err().is_cancelled()); + gated_store.gate_writes.store(false, Ordering::Release); + + // The aborted refill had already revealed replacements and taken them out of the + // wallet's staged change set. Those reveals must survive the abort: everything a later + // refill publishes has to be covered by persisted wallet state, or a crash would leave + // handed-out scripts unwatched by incremental syncs. + wallet.refill_address_pool().await.unwrap(); + let indices = pooled_indices(&wallet); + assert_eq!(indices.len(), ADDRESS_POOL_TARGET_SIZE); + let max_pooled = *indices.iter().max().unwrap(); + + let reloaded = new_test_wallet(Arc::clone(&store), true).await; + let persisted_last_revealed = + reloaded.inner.lock().unwrap().derivation_index(KeychainKind::External).unwrap(); + assert!( + persisted_last_revealed >= max_pooled, + "pooled index {} exceeds the persisted last revealed index {}", + max_pooled, + persisted_last_revealed + ); + } + + #[tokio::test] + async fn get_new_address_pops_the_oldest_pooled_address_and_persists_the_dequeue() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.refill_address_pool().await.unwrap(); + + let (front_index, front_address) = + wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); + assert_eq!(front_index, 0); + + // The handout comes from the pool front (the oldest revealed index) rather than minting + // a fresh index past the pool's unused tail, keeping the window of revealed-but-unused + // scripts compact for a from-seed restore's full scan. + let address = wallet.get_new_address().await.unwrap(); + assert_eq!(address, front_address); + let indices = pooled_indices(&wallet); + assert_eq!(indices.len(), ADDRESS_POOL_TARGET_SIZE); + assert!(!indices.contains(&front_index)); + + // The dequeue must be durable before the address is returned: a wallet reloaded from + // the store may not pool (and later re-hand-out) the returned address. + let reloaded = new_test_wallet(Arc::clone(&store), true).await; + reloaded.refill_address_pool().await.unwrap(); + let reloaded_indices = pooled_indices(&reloaded); + assert!(!reloaded_indices.contains(&front_index)); + assert_eq!(reloaded_indices, pooled_indices(&wallet)); + } + + #[tokio::test] + async fn get_new_address_fails_closed_and_returns_the_address_to_the_pool() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.refill_address_pool().await.unwrap(); + + let (front_index, front_address) = + wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); + + // While persistence is unavailable no address is handed out, and the popped address + // returns to the pool front: its index is neither skipped nor left unreachable. + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.get_new_address().await.is_err()); + let (index, address) = + wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); + assert_eq!(index, front_index); + assert_eq!(address, front_address); + assert_eq!(pooled_indices(&wallet).len(), ADDRESS_POOL_TARGET_SIZE); + + // Once persistence recovers, the very address the failed call popped is handed out. + fail_store.fail_writes.store(false, Ordering::Release); + assert_eq!(wallet.get_new_address().await.unwrap(), front_address); + } + + #[tokio::test] + async fn get_new_address_refills_an_empty_pool_before_handing_out() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + // With the pool empty and persistence down, the call must fail closed rather than hand + // out an address whose reveal isn't durable. + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.get_new_address().await.is_err()); + + // With persistence available it fills the pool inline and serves from it. + fail_store.fail_writes.store(false, Ordering::Release); + let address = wallet.get_new_address().await.unwrap(); + let expected = wallet.inner.lock().unwrap().peek_address(KeychainKind::External, 0).address; + assert_eq!(address, expected); + assert_eq!(pooled_indices(&wallet).len(), ADDRESS_POOL_TARGET_SIZE); + } + + #[tokio::test] + async fn get_new_address_never_reuses_across_restarts_after_an_overfull_pool() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.refill_address_pool().await.unwrap(); + + // A failed handout returns the popped address to the pool while the refill retains its + // unpublished reveal; the next successful refill then records and publishes all + // seventeen indices, filling the pool past its target size. + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.get_new_address().await.is_err()); + fail_store.fail_writes.store(false, Ordering::Release); + wallet.refill_address_pool().await.unwrap(); + assert!(pooled_indices(&wallet).len() > ADDRESS_POOL_TARGET_SIZE); + + // Handing out from the overfull pool must still durably exclude the returned address + // from the pool record before returning: a wallet reloaded from the store may never + // hand it out again. + let address = wallet.get_new_address().await.unwrap(); + + let reloaded = new_test_wallet(Arc::clone(&store), true).await; + reloaded.refill_address_pool().await.unwrap(); + let reloaded_pool = reloaded.address_pool.lock().unwrap(); + assert!(!reloaded_pool.available.iter().any(|(_, pooled)| *pooled == address)); + } + + /// An in-memory store that can fail all writes except the address-pool record's. + #[derive(Clone)] + struct RecordOnlyStore { + inner: Arc, + fail_non_record_writes: Arc, + } + + impl RecordOnlyStore { + fn new() -> Self { + Self { + inner: Arc::new(InMemoryStore::new()), + fail_non_record_writes: Arc::new(AtomicBool::new(false)), + } + } + } + + impl KVStore for RecordOnlyStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + let inner = Arc::clone(&self.inner); + let fail_non_record_writes = Arc::clone(&self.fail_non_record_writes); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + async move { + if fail_non_record_writes.load(Ordering::Acquire) + && key != BDK_WALLET_ADDRESS_POOL_KEY + { + return Err(io::Error::new(io::ErrorKind::Other, "writes disabled")); + } + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + } + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for RecordOnlyStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + + #[tokio::test] + async fn failed_get_new_address_leaves_the_pool_record_covering_the_pool() { + let record_store = RecordOnlyStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(record_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.refill_address_pool().await.unwrap(); + let (front_index, _) = + wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); + + // Fail everything but the pool record: the handout's record write succeeds (durably + // excluding the popped index) while the reveal flush fails, so the call fails and the + // address goes back into the pool. Its index must not be stranded by that partial + // failure: a crash right here reloads the pool from the record, and a durably revealed + // index missing from it would never be pooled or handed out again. + record_store.fail_non_record_writes.store(true, Ordering::Release); + assert!(wallet.get_new_address().await.is_err()); + record_store.fail_non_record_writes.store(false, Ordering::Release); + + let reloaded = new_test_wallet(Arc::clone(&store), true).await; + reloaded.refill_address_pool().await.unwrap(); + assert!(pooled_indices(&reloaded).contains(&front_index)); + } + + #[tokio::test] + async fn loading_survives_an_undecodable_pool_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + { + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.refill_address_pool().await.unwrap(); + } + + // Corrupt the record itself: the pool is a reconstructible cache, so an undecodable + // record must not prevent the node from starting. + KVStore::write( + &*store, + BDK_WALLET_ADDRESS_POOL_PRIMARY_NAMESPACE, + BDK_WALLET_ADDRESS_POOL_SECONDARY_NAMESPACE, + BDK_WALLET_ADDRESS_POOL_KEY, + vec![0x00, 0xff], + ) + .await + .unwrap(); + + let wallet = new_test_wallet(Arc::clone(&store), true).await; + wallet.refill_address_pool().await.unwrap(); + assert_eq!(pooled_indices(&wallet).len(), ADDRESS_POOL_TARGET_SIZE); + } + + /// An in-memory store whose pool-record writes can be made to fail while wallet-changeset + /// writes succeed. + #[derive(Clone)] + struct RecordFailStore { + inner: Arc, + fail_record_writes: Arc, + } + + impl RecordFailStore { + fn new() -> Self { + Self { + inner: Arc::new(InMemoryStore::new()), + fail_record_writes: Arc::new(AtomicBool::new(false)), + } + } + } + + impl KVStore for RecordFailStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + let inner = Arc::clone(&self.inner); + let fail_record_writes = Arc::clone(&self.fail_record_writes); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + async move { + if fail_record_writes.load(Ordering::Acquire) && key == BDK_WALLET_ADDRESS_POOL_KEY + { + return Err(io::Error::new(io::ErrorKind::Other, "writes disabled")); + } + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + } + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for RecordFailStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + + #[tokio::test] + async fn crash_after_a_failed_record_write_re_derives_the_same_indices() { + let record_store = RecordFailStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(record_store.clone())); + { + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + // Fail only the record write: the fill's reveals must not become durable without + // record coverage, as a crash would then leave indices that no path ever pools or + // hands out again — permanently skipping them in the keychain. + record_store.fail_record_writes.store(true, Ordering::Release); + assert!(wallet.refill_address_pool().await.is_err()); + } + + record_store.fail_record_writes.store(false, Ordering::Release); + let reloaded = new_test_wallet(Arc::clone(&store), true).await; + reloaded.refill_address_pool().await.unwrap(); + let indices = pooled_indices(&reloaded); + assert_eq!(indices.len(), ADDRESS_POOL_TARGET_SIZE); + assert!( + indices.contains(&0), + "the failed fill's indices must be re-derived, not skipped: {:?}", + indices + ); + } + + #[tokio::test] + async fn oldest_address_still_leads_the_pool_after_concurrent_failed_handouts() { + let gated_store = GatedStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(gated_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.refill_address_pool().await.unwrap(); + let (_, oldest_address) = + wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); + + // First handout pops index 0 and parks inside its refill's record write, holding the + // refill lock. + gated_store.gate_writes.store(true, Ordering::Release); + gated_store.fail_writes.store(true, Ordering::Release); + let first_wallet = Arc::clone(&wallet); + let first_handout = tokio::spawn(async move { first_wallet.get_new_address().await }); + gated_store.write_entered.notified().await; + + // Second handout pops index 1 while the first is parked, then queues on the refill lock. + let second_wallet = Arc::clone(&wallet); + let second_handout = tokio::spawn(async move { second_wallet.get_new_address().await }); + while wallet.address_pool.lock().unwrap().available.len() > ADDRESS_POOL_TARGET_SIZE - 2 { + tokio::task::yield_now().await; + } + + // Both handouts now fail and return their indices to the pool, completing out of pop + // order: index 0 first, index 1 second. + gated_store.gate_writes.store(false, Ordering::Release); + gated_store.release.notify_one(); + assert!(first_handout.await.unwrap().is_err()); + assert!(second_handout.await.unwrap().is_err()); + gated_store.fail_writes.store(false, Ordering::Release); + + // The pushed-back indices must not swap the pool out of index order: the next handout + // has to serve the oldest revealed index, or a lower unused index would be left sitting + // behind a handed-out (potentially funded) one, where a from-seed restore's stop gap + // could strand it. + let handed_out = wallet.get_new_address().await.unwrap(); + assert_eq!( + handed_out, + oldest_address, + "the oldest pooled address must be handed out first, pool: {:?}", + pooled_indices(&wallet) + ); + } + + fn dummy_tx() -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: Vec::new(), + } + } + + fn confirmed_block_time(height: u32) -> ConfirmationBlockTime { + ConfirmationBlockTime { + block_id: BlockId { height, hash: bitcoin::BlockHash::from_byte_array([9u8; 32]) }, + confirmation_time: 100, + } + } + + fn interactive_funding_details( + id: PaymentId, txid: Txid, amount_msat: Option, fee_paid_msat: Option, + ) -> PaymentDetails { + let kind = PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + PaymentDetails::new( + id, + kind, + amount_msat, + fee_paid_msat, + PaymentDirection::Outbound, + PaymentStatus::Pending, + ) + } + + fn confirmed_status() -> ConfirmationStatus { + ConfirmationStatus::Confirmed { + block_hash: bitcoin::BlockHash::from_byte_array([8u8; 32]), + height: 100, + timestamp: 1, + } + } + + /// Inserts `tx` into the BDK wallet as canonically confirmed at `height`, extending the + /// local chain to that height. + fn insert_confirmed_tx(wallet: &Wallet, tx: Transaction, height: u32) { + let txid = tx.compute_txid(); + let mut locked = wallet.inner.lock().unwrap(); + let block = + BlockId { height, hash: bitcoin::BlockHash::from_byte_array([height as u8; 32]) }; + let chain = locked.latest_checkpoint().insert(block); + let mut tx_update = bdk_chain::TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.anchors = + [(ConfirmationBlockTime { block_id: block, confirmation_time: 100 }, txid)].into(); + locked + .apply_update(Update { tx_update, chain: Some(chain), ..Default::default() }) + .unwrap(); + } + + /// Inserts `tx` into the BDK wallet as canonically unconfirmed (seen in the mempool). + fn insert_unconfirmed_tx(wallet: &Wallet, tx: Transaction) { + let txid = tx.compute_txid(); + let mut locked = wallet.inner.lock().unwrap(); + let mut tx_update = bdk_chain::TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.seen_ats = [(txid, 100)].into(); + locked.apply_update(Update { tx_update, ..Default::default() }).unwrap(); + } + + /// Builds a transaction paying a wallet address, spending an outpoint derived from + /// `input_byte` (distinct bytes yield non-conflicting transactions). + fn wallet_paying_tx(wallet: &Wallet, input_byte: u8) -> Transaction { + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([input_byte; 32]), + vout: 0, + }, + ..Default::default() + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + } + } + + /// A counterparty and channel for splice rounds in tests. + fn test_counterparty_and_channel() -> (PublicKey, ChannelId) { + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + (counterparty_node_id, ChannelId([7u8; 32])) + } + + /// Builds one [`FundingCandidate`] per `(txid, contribution)` round of a single channel, in + /// the given order — the shape [`funding_candidates`] produces for the signing-time + /// recording. + fn splice_candidates( + counterparty_node_id: PublicKey, channel_id: ChannelId, + rounds: &[(Txid, Option)], + ) -> Vec { + rounds + .iter() + .map(|(txid, contribution)| FundingCandidate { + txid: *txid, + channels: vec![ChannelFunding { + counterparty_node_id, + channel_id, + contribution: contribution.clone(), + }], + }) + .collect() + } + + /// Lets wallet sync observe `tx` as an unconfirmed wallet transaction: the wallet takes it in + /// and the sync event it yields is handled. + async fn observe_unconfirmed(wallet: &Wallet, tx: &Transaction) { + insert_unconfirmed_tx(wallet, tx.clone()); + let event = WalletEvent::TxUnconfirmed { + txid: tx.compute_txid(), + tx: Arc::new(tx.clone()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + } + + /// Records the payment wallet sync creates for `tx` without the wallet taking the transaction + /// in, for the tests that need the record of a round while the wallet's graph must not hold + /// it: a round the wallet has seen is a round no drop may take back. + async fn record_unseen_round(wallet: &Wallet, tx: &Transaction) { + let event = WalletEvent::TxUnconfirmed { + txid: tx.compute_txid(), + tx: Arc::new(tx.clone()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + } + + /// Signs a splice round and lets wallet sync observe its transaction, as the node does: the + /// signing records what the round is and this node's share of it, and the transaction's + /// arrival is what creates the payment record. + async fn sign_and_observe_round( + wallet: &Wallet, tx: &Transaction, candidates: &[FundingCandidate], + ) { + wallet.record_signed_funding(tx, candidates).await.unwrap(); + observe_unconfirmed(wallet, tx).await; + } + + /// Marks `txid` as evicted from the mempool after it was seen, so the BDK wallet still holds + /// the transaction but no longer considers it canonical. + fn evict_tx(wallet: &Wallet, txid: Txid) { + let mut locked = wallet.inner.lock().unwrap(); + let mut tx_update = bdk_chain::TxUpdate::default(); + tx_update.evicted_ats = [(txid, 101)].into(); + locked.apply_update(Update { tx_update, ..Default::default() }).unwrap(); + } + + /// A splice-out round returning `value_sat` to an external address at an estimated fee of + /// `fee_sat`, so `value_sat + fee_sat` leaves the channel: the contribution as LDK would + /// negotiate it, and the transaction carrying it, + /// which also pays a wallet address so the wallet sees movement (spending an outpoint derived + /// from `input_byte`). + fn splice_out_round( + wallet: &Wallet, input_byte: u8, value_sat: u64, fee_sat: u64, + ) -> (Transaction, FundingContribution) { + let splice_out = + TxOut { value: Amount::from_sat(value_sat), script_pubkey: ScriptBuf::new() }; + let contribution = + test_funding_contribution_with_outputs(fee_sat, 253, std::slice::from_ref(&splice_out)); + let mut tx = wallet_paying_tx(wallet, input_byte); + tx.output.push(splice_out); + (tx, contribution) + } + + /// The intent of a user-initiated splice of `channel_id` with `counterparty_node_id`, anchored + /// at the channel's funding `pre_splice_funding` when the splice was submitted. + fn splice_intent_for( + counterparty_node_id: PublicKey, channel_id: ChannelId, pre_splice_funding: LdkOutPoint, + ) -> SpliceIntent { + SpliceIntent { + counterparty_node_id, + channel_id, + pre_splice_funding_txo: pre_splice_funding, + contribution: test_funding_contribution_with_outputs(300, 253, &[]), + kind: SpliceKind::Out { outputs: Vec::new() }, + } + } + + /// The pending entries carrying `intent` as a bare intent — of a splice with no round on + /// record. + async fn bare_entries_carrying( + wallet: &Wallet, intent: &SpliceIntent, + ) -> Vec { + wallet + .payment_stores + .pending_payment_store() + .list_filter(|p| p.details().is_none() && p.splice_intent() == Some(intent)) + .await + } + + /// A round signed under the channel's splice intent that has since locked with zero + /// confirmations — clearing its intent — with a second splice submitted against the locked + /// funding before the round's `SpliceNegotiated` event was handled: the channel's intent no + /// longer belongs to the recorded round. + struct LockedRoundWithNewerIntent { + first_id: PaymentId, + tx: Transaction, + candidates: Vec, + second_id: PaymentId, + second_intent: SpliceIntent, + } + + async fn lock_a_signed_round_and_submit_another_splice( + wallet: &Wallet, + ) -> LockedRoundWithNewerIntent { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let first_id = PaymentId([31u8; 32]); + let first_intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(first_id, first_intent)) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + // The round locks with zero confirmations, which clears its intent... + let cleared = PendingPaymentDetailsUpdate { + id: first_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + wallet.payment_stores.pending_payment_store().update(cleared).await.unwrap(); + // ...and a second splice of the channel is submitted against the new funding. + let second_id = PaymentId([32u8; 32]); + let second_intent = + splice_intent_for(counterparty_node_id, channel_id, LdkOutPoint { txid, index: 0 }); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(second_id, second_intent.clone())) + .await + .unwrap(); + + LockedRoundWithNewerIntent { first_id, tx, candidates, second_id, second_intent } + } + + /// A recorded round is marked broadcast in its own record once the channel carries the intent + /// of a newer splice: after a zero-conf lock, the user may submit a second splice before the + /// locked round's `SpliceNegotiated` event is handled, and the event must mark the round in + /// its own record without touching the new splice's intent. + #[tokio::test] + async fn negotiation_marks_a_recorded_round_broadcast_under_a_newer_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + let txid = setup.tx.compute_txid(); + + let channel_id = setup.candidates[0].channels[0].channel_id; + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&setup.first_id) + .await + .unwrap() + .expect("entry"); + assert!(!entry.candidate(txid).expect("candidate").awaiting_broadcast); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&setup.second_id).await.unwrap(), + Some(PendingPaymentDetails::pending_splice(setup.second_id, setup.second_intent)), + "the newer splice's intent must be left untouched" + ); + } + + /// The signing event of a recorded round, replayed once the channel carries the intent of a + /// newer splice, writes nothing: the round is on record, so the newer intent is not consulted. + #[tokio::test] + async fn a_replayed_signing_writes_nothing_under_a_newer_intent() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + + fail_store.fail_writes.store(true, Ordering::Release); + wallet.record_signed_funding(&setup.tx, &setup.candidates).await.unwrap(); + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 0, + "a replayed signing must produce no new write" + ); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&setup.second_id).await.unwrap(), + Some(PendingPaymentDetails::pending_splice(setup.second_id, setup.second_intent)), + ); + } + + /// Signing a round writes no payment record. It records what the round is, this node's share + /// of it and the funding payment it belongs to, and leaves the record itself to whoever first + /// observes the transaction. + #[tokio::test] + async fn signing_a_round_writes_no_payment_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + let id = + wallet.find_payment_by_txid(txid).await.unwrap().expect("the round names its payment"); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(entry.details().is_none()); + assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); + assert_eq!( + entry.funding_channels(), + &[Channel { counterparty_node_id, channel_id }], + "the entry names the channel whose splice it tracks, with no record to name it", + ); + let facts = wallet.channel_tx_facts(&txid).await.expect("the round's facts are on record"); + assert_eq!( + facts.self_role, + Some(TransactionType::InteractiveFunding { + channels: vec![Channel { counterparty_node_id, channel_id }], + }), + ); + let figures = facts.local_figures.expect("this node's share is on record"); + assert_eq!(figures.funding_payment_id, id); + assert_eq!(figures.amount_msat, Some(500_300_000)); + assert_eq!(figures.fee_paid_msat, Some(300_000)); + assert_eq!( + figures.direction, + PaymentDirection::Inbound, + "a splice-out returns funds to the wallet" + ); + } + + /// A round this node is about to sign is measured however full the store is. The number of + /// records the store admits bounds what a counterparty drives; a round this node chose to + /// sign is admitted beside them, because without this node's share on record whoever first + /// observes the transaction records it with the wallet's view of a funding output both + /// parties own — the whole of it read as this node's spend, a figure nothing later corrects. + #[tokio::test] + async fn a_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(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + + // The store holds as many records as it may, so a transaction a capped producer reports + // on is refused room. + wallet.facts_retention.walk_completed(CHANNEL_TX_FACTS_MAX_RECORDS); + assert_eq!( + wallet + .record_channel_tx_facts(ChannelTxFacts::new(Txid::from_byte_array([77u8; 32]))) + .await + .unwrap(), + FactsRecordOutcome::Incomplete, + ); + + assert_eq!( + wallet.record_signed_funding(&tx, &candidates).await.unwrap(), + SignedFundingRecord::Recorded, + "a round this node signs is measured however full the store is", + ); + let id = + wallet.find_payment_by_txid(txid).await.unwrap().expect("the round names its payment"); + assert!( + wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("entry") + .candidate(txid) + .is_some(), + "the signed round is in the channel's candidate history", + ); + + // Our signatures leave the node and the counterparty broadcasts: wallet sync is the first + // to see the transaction, and files it under this node's share of the round rather than + // under the wallet's view of a funding output both parties own. + observe_unconfirmed(&wallet, &tx).await; + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + assert_eq!(payments[0].id, id); + assert_eq!(payments[0].amount_msat, Some(500_300_000)); + assert_eq!(payments[0].fee_paid_msat, Some(300_000)); + } + + /// A round whose facts are refused for a reason nothing later undoes is not signed, and is + /// reported unmeasurable so the caller cancels it. The record this round would merge into is + /// already as large as one record may be, which no later report shrinks, so replaying the + /// signing event would meet the same refusal forever while every event queued behind it + /// waited. + #[tokio::test] + async fn a_round_this_node_cannot_measure_is_not_signed() { + use lightning::util::ser::Writeable; + + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + + // How large the transaction's record would be once the signing added what the round is + // and this node's share of it. + let signed_length = |facts: &ChannelTxFacts| { + facts + .clone() + .with_self_role(TransactionType::InteractiveFunding { + channels: vec![channel.clone()], + }) + .with_local_figures(LocalFundingFigures { + funding_payment_id: PaymentId([0u8; 32]), + amount_msat: Some(500_300_000), + fee_paid_msat: Some(300_000), + direction: PaymentDirection::Inbound, + }) + .serialized_length() + }; + // Grow the transaction's record to the largest one that may be stored, in coarse steps + // first and single outputs after so the search stays cheap. + let mut crowded = ChannelTxFacts::new(txid); + let mut vout = 0u32; + for step in [128u32, 1] { + loop { + let grown = crowded.clone().with_outputs( + &channel, + None, + ChannelOutputRole::Htlc, + vout..vout + step, + ); + if grown.serialized_length() > CHANNEL_TX_FACTS_MAX_RECORD_BYTES { + break; + } + crowded = grown; + vout += step; + } + } + assert!( + 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(), + "nothing was recorded, which is what the refusal means", + ); + assert!( + wallet + .payment_stores + .pending_payment_store() + .list_filter(|entry| entry.candidate(txid).is_some()) + .await + .is_empty(), + "a round the signing refused must not be left in a candidate history", + ); + } + + /// A signing event replayed after its pending-store write was lost re-derives the round's + /// figures, from a contribution LDK may have adjusted the fee fields of since. The round's + /// facts are immutable, so the replay adopts what is on record instead of offering a second + /// answer the facts would refuse — which would leave the event replaying forever. + #[tokio::test] + async fn a_replayed_signing_adopts_the_recorded_figures() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.record_signed_funding(&tx, &candidates).await.is_err()); + fail_store.fail_writes.store(false, Ordering::Release); + + // LDK re-offers the event with the round's contribution carrying a different estimated + // fee, which would derive a different share of the same transaction. + let splice_out = tx.output.last().expect("the splice-out output").clone(); + let adjusted = test_funding_contribution_with_outputs(900, 253, &[splice_out]); + let adjusted_candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(adjusted))]); + wallet.record_signed_funding(&tx, &adjusted_candidates).await.unwrap(); + + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let figures = wallet + .channel_tx_facts(&txid) + .await + .expect("facts") + .local_figures + .expect("this node\'s share"); + assert_eq!(figures.funding_payment_id, id); + assert_eq!(figures.fee_paid_msat, Some(300_000), "the recorded share stands"); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); + } + + /// The counterparty broadcasts the round it holds our signatures for before this node has any + /// payment record for it — the guarantee the signing-time recording exists for. Wallet sync + /// must file the transaction under the funding payment the signing named, resolved through the + /// round's recorded facts, instead of minting a second record under the transaction's own id. + #[tokio::test] + async fn a_counterparty_broadcast_does_not_duplicate_the_funding_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent)) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + // Our signatures leave the node and the counterparty broadcasts: wallet sync is the first + // to see the transaction. + observe_unconfirmed(&wallet, &tx).await; + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the broadcast must not mint a second record"); + assert_eq!(payments[0].id, id); + assert!(matches!( + payments[0].kind, + PaymentKind::Onchain { + txid: t, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } if t == txid + )); + // This node's share of the round, not the wallet's view of a funding output both parties + // own. + assert_eq!(payments[0].amount_msat, Some(500_300_000)); + assert_eq!(payments[0].fee_paid_msat, Some(300_000)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details().map(|details| details.id), Some(id)); + assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); + } + + /// An event about an earlier round of a funding payment that has graduated out of the pending + /// store still reaches the record. Neither store can say so by then — the entry that held the + /// candidate history is gone, and the record names the round that confirmed — but the round's + /// facts still name the payment it belonged to. + #[tokio::test] + async fn a_graduated_records_earlier_round_still_names_its_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; + let id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("id"); + + // Graduation: the record settles and its pending entry, with the candidate history, goes. + let mut graduated = PaymentDetailsUpdate::new(id); + graduated.status = Some(PaymentStatus::Succeeded); + wallet.payment_stores.payment_store().update(graduated).await.unwrap(); + wallet.payment_stores.pending_payment_store().remove(&id).await.unwrap(); + + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), Some(id)); + assert_eq!( + wallet.find_payment_by_txid(txid).await.unwrap(), + Some(id), + "the replaced round still names the payment it was a candidate of", + ); + } + + /// The first round of a user-initiated splice is on no record when it is signed, so it adopts + /// the id of the channel's splice intent: the bare intent entry becomes the round's record and + /// keeps carrying the intent. + #[tokio::test] + async fn signing_a_first_round_adopts_the_intent_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::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 payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + let txid_derived_id = PaymentId(txid.to_byte_array()); + assert!(wallet + .payment_stores + .payment_store() + .get(&txid_derived_id) + .await + .unwrap() + .is_none()); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); + } + + /// A fee bump of a round whose payment wallet sync failed — the round lost to a conflicting + /// spend confirmed while the channel stayed open — adopts the channel's splice intent rather + /// than the failed record: the failed round in its history decides nothing, so the bump is + /// recorded under the intent's id, its entry carrying the intent. + #[tokio::test] + async fn signing_a_bump_of_a_failed_round_adopts_the_channels_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_stores + .payment_store() + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); + + // The bump's intent, recorded at submission with no record left to join. + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let id = PaymentId([31u8; 32]); + let intent = SpliceIntent { + contribution: bump_contribution.clone(), + kind: SpliceKind::Rbf {}, + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) + .await + .unwrap(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; + + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let failed = wallet + .payment_stores + .payment_store() + .get(&failed_id) + .await + .unwrap() + .expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&failed_id) + .await + .unwrap() + .is_none()); + } + + /// A fee bump signed while the channel's intent is still live joins the record of the round + /// it replaces: that round is on record, so the history decides the id, and the intent the + /// bump shares with the first round stays on the record. + #[tokio::test] + async fn signing_a_bump_joins_the_replaced_rounds_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::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.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the bump must join the first round's record"); + assert_eq!(payments[0].id, id); + assert!(matches!(payments[0].kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!( + entry.candidates().iter().map(|c| c.txid).collect::>(), + vec![txid, bump_txid] + ); + assert_eq!(entry.splice_intent(), Some(&intent)); + } + + /// 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 holds it and offers the bump — + /// is signed with the failed round among its candidates. The failed record takes no round: + /// nothing revisits its status, so the bump would go untracked under it. The bump gets a + /// record of its own. + #[tokio::test] + async fn signing_a_bump_of_a_failed_round_gets_a_record_of_its_own() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_stores + .payment_store() + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; + + let bump_id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("a record"); + assert_ne!(bump_id, failed_id); + let payment = + wallet.payment_stores.payment_store().get(&bump_id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&bump_id) + .await + .unwrap() + .expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let failed = wallet + .payment_stores + .payment_store() + .get(&failed_id) + .await + .unwrap() + .expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&failed_id) + .await + .unwrap() + .is_none()); + } + + /// Once LDK reports a round recorded at signing negotiated, there is nothing to add but the + /// broadcast itself: the round's awaiting-broadcast mark is cleared and the record left as + /// written. + #[tokio::test] + async fn negotiation_of_a_signed_round_marks_it_broadcast() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let prior_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert!(record.candidate(txid).unwrap().awaiting_broadcast); + + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(payment)); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!( + record.candidates().iter().map(|c| c.txid).collect::>(), + vec![prior_txid, txid] + ); + assert!(!record.candidate(txid).unwrap().awaiting_broadcast); + assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); + } + + /// A replayed `SpliceNegotiated` event names a round already marked broadcast; nothing is + /// written. + #[tokio::test] + async fn marking_a_broadcast_round_again_writes_nothing() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + fail_store.fail_writes.store(true, Ordering::Release); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 0, + "marking a round broadcast again must produce no new write" + ); + } + + /// A round no funding payment tracks — this node contributed nothing to it, so signing never + /// recorded it — has no mark to clear; nothing is written. + #[tokio::test] + async fn marking_an_unrecorded_round_broadcast_writes_nothing() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + fail_store.fail_writes.store(true, Ordering::Release); + let txid = Txid::from_byte_array([0xAA; 32]); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 0); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + } + + /// A replayed signing event re-offers a transaction already recorded; nothing is written. + #[tokio::test] + async fn signing_a_recorded_round_again_writes_nothing() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + fail_store.fail_writes.store(true, Ordering::Release); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 0, + "a replayed signing must produce no new write" + ); + } + + /// A signed round absent from the channel's pending splice history was reset between the + /// event's emission and its handling (the counterparty aborted): LDK will refuse the signed + /// transaction, so nothing is recorded for it — not even when the history holds another round + /// this node contributed to. + #[tokio::test] + async fn signing_skips_a_round_missing_from_the_splice_history() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let other_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(other_txid, Some(contribution))], + ); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_signed_funding(&tx, &[]).await.unwrap(); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + } + + /// A round this node did not contribute to is not its payment: the signing-time recording + /// declines it. + #[tokio::test] + async fn signing_skips_a_round_without_a_local_contribution() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(tx.compute_txid(), None)]); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + } + + /// A splice-out to an external address moves no wallet funds; the signing-time recording + /// declines it — wallet sync cannot observe it either, so there is no race to close. + #[tokio::test] + async fn signing_skips_a_wallet_untouched_transaction() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let splice_out = + TxOut { value: Amount::from_sat(500_000), script_pubkey: ScriptBuf::new() }; + let contribution = + test_funding_contribution_with_outputs(300, 253, std::slice::from_ref(&splice_out)); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: OutPoint { txid: Txid::from_byte_array([1u8; 32]), vout: 0 }, + ..Default::default() + }], + output: vec![splice_out], + }; + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(tx.compute_txid(), Some(contribution))], + ); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + } + + /// The signing write merges LDK's history into the recorded one instead of replacing it: a + /// recorded round LDK no longer lists survives the write, since dropping the rounds LDK + /// abandoned is [`Wallet::drop_abandoned_splice_rounds`]'s job, once LDK reports the failure. + #[tokio::test] + async fn signing_merges_ldk_history_into_the_recorded_one() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let prior_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + + let (next_tx, next_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let next_txid = next_tx.compute_txid(); + let next_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (next_txid, Some(next_contribution))], + ); + sign_and_observe_round(&wallet, &next_tx, &next_candidates).await; + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let id = payments[0].id; + assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); + assert!( + matches!(&payments[0].kind, PaymentKind::Onchain { txid: t, .. } if *t == next_txid) + ); + assert_eq!(payments[0].amount_msat, Some(400_700_000)); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!( + record.candidates().iter().map(|c| c.txid).collect::>(), + vec![prior_txid, txid, next_txid] + ); + assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); + assert_eq!(record.candidate(next_txid).unwrap().amount_msat, Some(400_700_000)); + } + + /// LDK abandoned a signed first round (the counterparty aborted before the signatures were + /// exchanged) and reports the failure: nothing was ever broadcast under it, so its entry goes, + /// leaving nothing behind to wait on a transaction that will never exist — while another + /// channel's entry is left alone. + #[tokio::test] + async fn dropping_an_abandoned_first_round_removes_its_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 no drop unsays — so the txid still names the payment it belonged to, + // and nothing is recorded under that payment anymore. + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + let other = wallet + .payment_stores + .pending_payment_store() + .get(&other_id) + .await + .unwrap() + .expect("the other channel's entry stays"); + assert!(other.candidate(other_txid).is_some()); + assert_eq!(wallet.find_payment_by_txid(other_txid).await.unwrap(), Some(other_id)); + } + + /// 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(); - Ok(None) + 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) + ); } - /// If `payment_id` refers to a classified funding payment, refreshes its confirmation status - /// and the candidate txid the event refers to, while preserving the contribution-derived - /// amount/fee and `tx_type` that wallet sync must not recompute from its own view: the wallet's - /// `sent`/`received` don't capture our contribution to a shared funding output. Returns `true` - /// when it handled the payment, so the caller skips the default on-chain path. Graduation to - /// `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. - /// - /// The caller must hold [`Self::funding_payment_update_lock`] — from resolving `payment_id` - /// through its own last write, not just across this call — so that classification's two-store - /// write pair cannot interleave with the caller's decision sequence. The `_guard` parameter - /// serves as a reminder of that contract. - async fn apply_funding_status_update_locked( - &self, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, event_txid: Txid, - confirmation_status: ConfirmationStatus, - ) -> Result { - // The caller's wallet-level lock keeps the candidate history stable while we await its - // read. The funding-type gate and write then share the payment store's mutation lock: - // against a separate payment `get`, a classification merging in between would have its - // `tx_type` and contribution figures clobbered by this stale snapshot. - let pending_payment = self.pending_payment_store.get(&payment_id).await?; - let mut handled = None; - self.payment_store - .mutate(&payment_id, |existing| { - let payment = existing?; - let tx_type = match &payment.kind { - PaymentKind::Onchain { - tx_type: - tx_type @ Some( - TransactionType::Funding { .. } - | TransactionType::InteractiveFunding { .. }, - ), - .. - } => tx_type.clone(), - _ => return None, - }; - // Report the figures of the candidate that actually confirmed, which need not be - // the last one broadcast (an earlier, lower-fee candidate may win) and may carry - // no figures at all (`None`) for a round we didn't contribute to. (`direction` is - // invariant across a splice's candidates and cannot be changed through the store - // anyway.) - let mut target = payment.clone(); - if let Some(pending) = pending_payment.as_ref() { - if let Some(candidate) = pending.candidate(event_txid) { - target.amount_msat = candidate.amount_msat; - target.fee_paid_msat = candidate.fee_paid_msat; - } - } - target.kind = - PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; + /// The intent was already settled off the record when the abandoned first round is dropped — + /// a lock or the channel's close settled it first — so nothing is left to keep: the record and + /// its entry both go. + #[tokio::test] + async fn dropping_an_abandoned_first_round_whose_intent_settled_removes_its_entry() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - // Merge through the update machinery so its rules (e.g. which fields a merge may - // touch) keep applying, and skip the write when nothing changed. - let mut merged = payment.clone(); - if merged.update(target.to_update()) { - handled = Some(merged.clone()); - Some(merged) - } else { - handled = Some(payment.clone()); - None - } - }) - .await?; - let Some(payment) = handled else { - return Ok(false); + 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), }; - // Mirror the refreshed confirmation status onto the pending entry: `ChainTipChanged` - // graduates by reading the pending entry's details, so it must see the new status. This is - // the same dual-write the default `TxConfirmed` path performs; an empty conflicting-txids - // list leaves any stored conflicts intact (the update treats absent as "unchanged"). - if payment.status == PaymentStatus::Pending { - let pending = self.create_pending_payment_from_tx(payment, Vec::new()); - self.pending_payment_store.insert_or_update(pending).await?; - } - Ok(true) + 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()); } - #[allow(deprecated)] - pub(crate) async fn bump_fee_rbf( - &self, payment_id: PaymentId, fee_rate: Option, cur_anchor_reserve_sats: u64, - ) -> Result { - let payment = self.payment_store.get(&payment_id).await?.ok_or_else(|| { - log_error!(self.logger, "Payment {} not found in payment store", payment_id); - Error::InvalidPaymentId - })?; + /// Settling a bare splice intent removes the unindexed funding record under its id, if any: + /// it is the first half of a write that never completed, and no entry would ever drive it. The + /// bare entry itself is left to the settlement, and a record an entry indexes stays. + #[tokio::test] + async fn settling_a_bare_intent_drops_its_half_written_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - // Funding transactions (channel opens and splices) are driven by LDK's funding/splice - // lifecycle, not the on-chain wallet. Replacing one via on-chain RBF would broadcast a - // transaction LDK isn't tracking (and, for splices, can't sign). Fee-bumping a pending - // splice goes through `bump_channel_funding_fee` instead. - if let PaymentKind::Onchain { - tx_type: - Some(TransactionType::Funding { .. } | TransactionType::InteractiveFunding { .. }), - .. - } = &payment.kind - { - log_error!( - self.logger, - "Cannot RBF funding payment {} via bump_fee_rbf; use bump_channel_funding_fee instead", - payment_id, + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::pending_splice(id, intent); + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let txid = Txid::from_byte_array([0xBB; 32]); + let half_written = interactive_funding_details(id, txid, Some(500_300_000), Some(300_000)); + wallet.payment_stores.payment_store().insert_or_update(half_written).await.unwrap(); + + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare) + ); + + // The same record with an entry indexing it is a funding payment wallet sync recorded in + // full, and stays. + let indexed_txid = Txid::from_byte_array([0xCC; 32]); + let record = + interactive_funding_details(id, indexed_txid, Some(400_700_000), Some(700_000)); + wallet.payment_stores.payment_store().insert_or_update(record.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::new(record.clone(), Vec::new(), Vec::new())) + .await + .unwrap(); + + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); + + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(record)); + assert_eq!(wallet.find_payment_by_txid(indexed_txid).await.unwrap(), Some(id)); + } + + /// Settling a bare splice intent leaves alone a record under its id that is not the + /// half-written record of a funding round: a payment that succeeded, or whose transaction + /// confirmed, was broadcast and driven to that state, and is a payment of its own. + #[tokio::test] + async fn settling_a_bare_intent_leaves_a_settled_record_alone() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::pending_splice(id, intent); + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let txid = Txid::from_byte_array([0xBB; 32]); + let settled = [ + (confirmed_status(), PaymentStatus::Succeeded), + (confirmed_status(), PaymentStatus::Pending), + (ConfirmationStatus::Unconfirmed, PaymentStatus::Succeeded), + ]; + for (confirmation, status) in settled { + let kind = PaymentKind::Onchain { + txid, + status: confirmation, + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + let record = PaymentDetails::new( + id, + kind, + Some(500_300_000), + Some(300_000), + PaymentDirection::Outbound, + status, ); - return Err(Error::InvalidPaymentId); - } + wallet.payment_stores.payment_store().insert_or_update(record.clone()).await.unwrap(); - if let PaymentKind::Onchain { status, .. } = &payment.kind { - match status { - ConfirmationStatus::Confirmed { .. } => { - log_error!( - self.logger, - "Transaction {} is already confirmed and cannot be replaced via RBF", - payment_id - ); - return Err(Error::InvalidPaymentId); - }, - ConfirmationStatus::Unconfirmed => {}, - } - } + wallet.drop_unindexed_record_of_settled_intent(id).await.unwrap(); - if payment.direction != PaymentDirection::Outbound { - log_error!( - self.logger, - "Cannot RBF payment {}: only outbound payments can be replaced", - payment_id + 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()) ); - return Err(Error::InvalidPaymentId); + wallet.payment_stores.payment_store().remove(&id).await.unwrap(); } + } - let txid = match &payment.kind { - PaymentKind::Onchain { txid, .. } => *txid, - _ => { - log_error!( - self.logger, - "Payment {} is not an on-chain payment, cannot be replaced via RBF", - payment_id - ); - return Err(Error::InvalidPaymentId); - }, - }; + /// LDK abandoned a signed fee bump of a counterparty-initiated round this node did not + /// contribute to: no remaining round is this node's payment, so the record goes as a first + /// round's does, and the bump's intent stays behind as a bare intent. + #[tokio::test] + async fn dropping_an_abandoned_bump_of_a_counterparty_round_keeps_its_bare_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + let bare = PendingPaymentDetails::pending_splice(id, intent); + wallet.payment_stores.pending_payment_store().insert(bare.clone()).await.unwrap(); + let prior_txid = Txid::from_byte_array([9u8; 32]); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let bump_txid = bump_tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &candidates).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(bump_txid).await.unwrap(), Some(id)); - let mut locked_persister = self.persister.lock().await; - let mut locked_wallet = self.inner.lock().expect("lock"); + wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(bare) + ); + } + + /// LDK abandoned a signed fee bump while the round it replaces stays pending: the bump leaves + /// the recorded history and the record tracks the original round again, figures included. + #[tokio::test] + async fn dropping_an_abandoned_bump_restores_the_prior_round() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid), + "the original round must be the actively-tracked transaction again" + ); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(payment.fee_paid_msat, Some(300_000)); + assert_eq!(record.details(), Some(&payment)); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + } + + /// A round awaiting broadcast that the wallet has nonetheless seen — the counterparty broadcast + /// it with our signatures while LDK still waited on its own, and the channel then closed — may + /// still confirm and keeps its place, even once evicted from the mempool: the lookup is not + /// canonical-only. + #[tokio::test] + async fn dropping_keeps_a_round_the_wallet_has_seen() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + sign_and_observe_round(&wallet, &tx, &candidates).await; + evict_tx(&wallet, txid); + assert!(wallet.inner.lock().unwrap().get_tx(txid).is_none(), "evicted: not canonical"); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(payment.status, PaymentStatus::Pending); + } + + /// The channel force-closed with a negotiated round unconfirmed and a fee bump of it signed + /// but never exchanged, before wallet sync picked the negotiated round up: LDK lists neither + /// anymore, but the negotiated round was handed to the broadcaster and may still confirm, so + /// only the bump is dropped. + #[tokio::test] + async fn dropping_keeps_rounds_handed_to_the_broadcaster() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let kept = record.candidate(txid).expect("the negotiated round keeps its place"); + assert_eq!(kept.amount_msat, Some(500_300_000)); + assert_eq!(kept.fee_paid_msat, Some(300_000)); + assert!(!kept.awaiting_broadcast); + // Wallet sync has not picked the round up, so there is no payment record either way. + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + } + + /// LDK abandoned the only round this node contributed to, an RBF of a counterparty-initiated + /// round it did not: what remains is not this node's payment, so the record goes instead of + /// being handed to a round the wallet will never observe. + #[tokio::test] + async fn dropping_the_last_contributed_round_removes_the_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let prior_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); + + wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + /// The record moved on before the drop: wallet sync confirmed the original round while its + /// bump awaited signatures, then LDK abandoned the bump. The confirmed record is left as it + /// stands; only the bump leaves the recorded history. + #[tokio::test] + async fn dropping_leaves_a_record_that_moved_on() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); - debug_assert!( - locked_wallet.tx_details(txid).is_some(), - "Transaction {} expected in wallet but not found", + insert_confirmed_tx(&wallet, tx.clone(), 105); + let event = WalletEvent::TxConfirmed { txid, + tx: Arc::new(tx), + block_time: confirmed_block_time(105), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } + if t == txid + )); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + assert_eq!( + wallet.payment_stores.payment_store().get(&id).await.unwrap(), + Some(payment.clone()) ); - let old_tx = locked_wallet - .tx_details(txid) - .ok_or_else(|| { - log_error!(self.logger, "Transaction {} not found in wallet", txid); - Error::InvalidPaymentId - })? - .tx - .deref() - .clone(); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.details(), Some(&payment)); + assert!(record.candidate(bump_txid).is_none()); + } - let old_fee_rate = locked_wallet.calculate_fee_rate(&old_tx).map_err(|e| { - log_error!(self.logger, "Failed to calculate fee rate of transaction {}: {}", txid, e); - Error::WalletOperationFailed - })?; + /// The record moved on to a bump the entry does not list, so the entry still lists only the + /// original round. Abandoning that round, with no round of ours remaining, leaves the record + /// as it stands and only shrinks the entry's history, its copy of the record catching up. + #[tokio::test] + async fn dropping_the_last_round_leaves_a_record_that_moved_on() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - // BIP 125 requires the replacement to pay a higher fee rate than the original. - // The minimum increase is the incremental relay fee. - let min_required_fee_rate_sat_per_kwu = - old_fee_rate.to_sat_per_kwu() + INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT as u64; + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + // Wallet sync moved the record onto a bump the entry does not list. + let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let mut moved_on = PaymentDetailsUpdate::new(id); + moved_on.txid = Some(bump_txid); + wallet.payment_stores.payment_store().update(moved_on).await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - let confirmation_target = ConfirmationTarget::OnchainPayment; - let estimated_fee_rate = self.fee_estimator.estimate_fee_rate(confirmation_target); + assert_eq!( + wallet.payment_stores.payment_store().get(&id).await.unwrap(), + Some(payment.clone()) + ); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert!(record.candidates().is_empty()); + assert_eq!(record.details(), Some(&payment)); + } - // Use the higher of minimum RBF requirement or current network estimate - let final_fee_rate_sat_per_kwu = - min_required_fee_rate_sat_per_kwu.max(estimated_fee_rate.to_sat_per_kwu()); - let final_fee_rate = - fee_rate.unwrap_or_else(|| FeeRate::from_sat_per_kwu(final_fee_rate_sat_per_kwu)); + /// A removal that was cut short between the two stores — the payment record went, the pending + /// entry stayed — is finished by the replayed drop: the entry alone still resolves the round's + /// txid, so it is what the replayed event finds and removes. + #[tokio::test] + async fn a_cut_short_removal_is_finished_by_the_replayed_drop() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let mut psbt = { - let mut builder = locked_wallet.build_fee_bump(txid).map_err(|e| { - log_error!(self.logger, "BDK fee bump failed for {}: {:?}", txid, e); - match e { - BuildFeeBumpError::TransactionNotFound(_) => Error::InvalidPaymentId, - BuildFeeBumpError::TransactionConfirmed(_) => { - log_error!(self.logger, "Payment {} is already confirmed", payment_id); - Error::InvalidPaymentId - }, - BuildFeeBumpError::IrreplaceableTransaction(_) => { - Error::OnchainTxCreationFailed - }, - BuildFeeBumpError::FeeRateUnavailable => Error::FeerateEstimationUpdateFailed, - BuildFeeBumpError::UnknownUtxo(_) => Error::OnchainTxCreationFailed, - BuildFeeBumpError::InvalidOutputIndex(_) => Error::OnchainTxCreationFailed, - } - })?; + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + wallet.payment_stores.payment_store().remove(&id).await.unwrap(); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); - builder.fee_rate(final_fee_rate); + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - match builder.finish() { - Ok(psbt) => Ok(psbt), - Err(CreateTxError::FeeRateTooLow { required: required_fee_rate }) => { - if fee_rate.is_some() { - log_error!( - self.logger, - "Provided fee rate {} is too low for RBF fee bump of txid {}, required minimum fee rate: {}", - fee_rate.expect("fee rate is set"), - txid, - required_fee_rate - ); - return Err(Error::InvalidFeeRate); - } + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } - log_info!(self.logger, "BDK requires higher fee rate: {}", required_fee_rate); + /// A hand-back that was cut short between the two stores — the payment record tracks the + /// original round again, the pending entry still lists the bump and mirrors the record as it + /// was — is finished by the replayed drop: the bump leaves the history and the entry's copy of + /// the record catches up with the record. + #[tokio::test] + async fn a_cut_short_hand_back_is_finished_by_the_replayed_drop() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - // BDK may require a higher fee rate than our estimate due to - // differences in UTXO selection or transaction weight calculations. - // We cap the retry at 1.5x our target fee rate as a safety bound - // to avoid overpaying. - let max_allowed_fee_rate = FeeRate::from_sat_per_kwu( - final_fee_rate_sat_per_kwu.saturating_mul(3).saturating_div(2), - ); - if required_fee_rate > max_allowed_fee_rate { - log_error!( self.logger, "BDK required fee rate {} exceeds sanity cap {} (1.5x our estimate) for tx {}", required_fee_rate, max_allowed_fee_rate, txid ); - return Err(Error::InvalidFeeRate); - } + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; + + // The first half of the hand-back: the payment record alone tracks the original round. + let mut update = PaymentDetailsUpdate::new(id); + update.txid = Some(txid); + update.confirmation_status = Some(ConfirmationStatus::Unconfirmed); + update.amount_msat = Some(Some(500_300_000)); + update.fee_paid_msat = Some(Some(300_000)); + wallet.payment_stores.payment_store().update(update).await.unwrap(); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(matches!( + entry.details().map(|details| &details.kind), + Some(PaymentKind::Onchain { txid: t, .. }) if *t == bump_txid + )); - let mut builder = locked_wallet.build_fee_bump(txid).map_err(|e| { - log_error!(self.logger, "BDK fee bump retry failed for {}: {:?}", txid, e); - Error::InvalidFeeRate - })?; + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); - builder.fee_rate(required_fee_rate); - builder.finish().map_err(|e| { - log_error!( - self.logger, - "Failed to finish PSBT with required fee rate: {:?}", - e - ); - Error::InvalidFeeRate - }) + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(entry.details(), Some(&payment)); + assert!(entry.candidate(bump_txid).is_none()); + } + + /// The rounds LDK holds for a channel are its pending rounds with a transaction and its current + /// funding, which a zero-conf splice becomes before its transaction confirms. + #[test] + fn held_splice_rounds_include_the_current_funding() { + let pending_txid = Txid::from_byte_array([0xAA; 32]); + let funding_txid = Txid::from_byte_array([0xBB; 32]); + let details = SpliceDetails { + candidates: vec![ + SpliceCandidateDetails { + status: SpliceCandidateStatus::AwaitingSignatures { + is_initiator: true, + funding_feerate_sat_per_1000_weight: 253, + new_channel_value_satoshis: 110_000, + txid: pending_txid, + }, + contribution: None, }, - Err(e) => { - log_error!(self.logger, "Failed to create fee bump PSBT: {:?}", e); - Err(Error::InvalidFeeRate) + SpliceCandidateDetails { + status: SpliceCandidateStatus::WaitingOnLock, + contribution: None, }, - }? + ], + confirmed_candidate: None, + received_splice_locked_txid: None, }; + let funding = LdkOutPoint { txid: funding_txid, index: 0 }; - let old_fee_sats = locked_wallet - .calculate_fee(&old_tx) - .map_err(|e| { - log_error!(self.logger, "Failed to calculate fee of transaction {}: {}", txid, e); - Error::WalletOperationFailed - })? - .to_sat(); - let replacement_fee_sats = locked_wallet - .calculate_fee(&psbt.unsigned_tx) - .map_err(|e| { - log_error!( - self.logger, - "Failed to calculate fee of replacement transaction for {}: {}", - txid, - e - ); - Error::WalletOperationFailed - })? - .to_sat(); - let additional_fee_sats = replacement_fee_sats.saturating_sub(old_fee_sats); - let balance = locked_wallet.balance(); - let spendable_amount_sats = - self.get_balances_inner(balance, cur_anchor_reserve_sats).map(|(_, s)| s).unwrap_or(0); - if spendable_amount_sats < additional_fee_sats { - log_error!( - self.logger, - "Unable to bump fee due to insufficient reserve-preserving funds. \ - Available: {}sats, required additional fee: {}sats, reserve: {}sats", - spendable_amount_sats, - additional_fee_sats, - cur_anchor_reserve_sats, - ); - return Err(Error::InsufficientFunds); - } + assert_eq!( + held_splice_rounds(Some(&details), Some(funding)), + vec![pending_txid, funding_txid] + ); + assert_eq!(held_splice_rounds(None, Some(funding)), vec![funding_txid]); + assert!(held_splice_rounds(None, None).is_empty()); + } - match locked_wallet.sign(&mut psbt, SignOptions::default()) { - Ok(finalized) => { - if !finalized { - log_error!(self.logger, "Failed to finalize signing for fee bump of {}", txid); - return Err(Error::OnchainTxCreationFailed); - } - }, - Err(err) => { - log_error!( - self.logger, - "Failed to sign fee bump transaction for {}: {}", - txid, - err - ); - return Err(err.into()); - }, - } + /// The rounds a closed channel may still see confirm are its last funding and every transaction + /// its monitor still watches: a splice round the counterparty committed to stays watched once + /// the channel manager has forgotten it with the channel. Without a monitor, only the funding + /// is held. + #[test] + fn closed_channel_held_rounds_include_the_watched_transactions() { + let funding_txid = Txid::from_byte_array([0xBB; 32]); + let watched_txid = Txid::from_byte_array([0xCC; 32]); + let funding = LdkOutPoint { txid: funding_txid, index: 0 }; - let fee_bumped_tx = psbt.extract_tx().map_err(|e| { - log_error!(self.logger, "Failed to extract fee bump transaction for {}: {}", txid, e); - e - })?; + assert_eq!( + closed_channel_held_rounds(Some(funding), [funding_txid, watched_txid]), + vec![funding_txid, watched_txid] + ); + assert_eq!(closed_channel_held_rounds(Some(funding), []), vec![funding_txid]); + assert_eq!(closed_channel_held_rounds(None, [watched_txid]), vec![watched_txid]); + assert!(closed_channel_held_rounds(None, []).is_empty()); + } - let new_txid = fee_bumped_tx.compute_txid(); + /// The node restarted with a signed round LDK never wrote out — it stopped between LDK handing + /// the round out for signing and its next channel manager write, and the round was committed + /// after the last one — so LDK holds nothing for it and reports no failure: the startup sweep + /// drops it, while a round LDK still holds stays, and so does the round of a channel LDK no + /// longer lists, which is left to the channel's `ChannelClosed` event. + #[tokio::test] + async fn startup_drops_the_rounds_ldk_no_longer_holds() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let other_channel_id = ChannelId([8u8; 32]); + let closed_channel_id = ChannelId([9u8; 32]); - let new_payment = self.create_payment_from_tx( - &locked_wallet, - new_txid, - payment.id, - &fee_bumped_tx, - PaymentStatus::Pending, - ConfirmationStatus::Unconfirmed, + 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))], + ); + sign_and_observe_round(&wallet, &other_tx, &other_candidates).await; + let (closed_tx, closed_contribution) = splice_out_round(&wallet, 3, 300_000, 500); + let closed_txid = closed_tx.compute_txid(); + let closed_candidates = splice_candidates( + counterparty_node_id, + closed_channel_id, + &[(closed_txid, Some(closed_contribution))], ); + sign_and_observe_round(&wallet, &closed_tx, &closed_candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let other_id = wallet.find_payment_by_txid(other_txid).await.unwrap().expect("other id"); + let closed_id = wallet.find_payment_by_txid(closed_txid).await.unwrap().expect("closed id"); - let pending_payment_store = - self.create_pending_payment_from_tx(new_payment.clone(), Vec::new()); - let change_set = locked_wallet.take_staged().unwrap_or_default(); - drop(locked_wallet); - locked_persister.persist_changeset(change_set).await.map_err(|e| { - log_error!(self.logger, "Failed to persist wallet after fee bump of {}: {}", txid, e); - Error::PersistenceFailed - })?; + wallet + .drop_splice_rounds_lost_across_restart(|channel| { + if channel == other_channel_id { + Some(vec![other_txid]) + } else if channel == closed_channel_id { + None + } else { + Some(Vec::new()) + } + }) + .await + .unwrap(); - self.payment_store.insert_or_update(new_payment).await?; - self.pending_payment_store.insert_or_update(pending_payment_store).await?; + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.payment_store().get(&other_id).await.unwrap().is_some()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&other_id) + .await + .unwrap() + .is_some()); + assert!(wallet.payment_stores.payment_store().get(&closed_id).await.unwrap().is_some()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&closed_id) + .await + .unwrap() + .is_some()); + } - self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx); + /// A record that graduated while its pending entry lingers — the entry's removal is still + /// owed — loses the dropped round from its history but keeps the entry's pending copy of the + /// record: the pass that cleans up lingering entries goes by that copy, and a graduated one + /// would leave the entry behind for good. + #[tokio::test] + async fn dropping_leaves_the_entry_of_a_graduated_record_pending() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; - Ok(new_txid) - } -} + let mut update = PaymentDetailsUpdate::new(id); + update.status = Some(PaymentStatus::Succeeded); + wallet.payment_stores.payment_store().update(update).await.unwrap(); -struct LocalStakeAggregate { - amount_msat: Option, - fee_paid_msat: Option, - direction: PaymentDirection, -} + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); -/// Aggregates our net stake across the channels of a single [`FundingCandidate`] by summing each -/// channel's signed [`FundingContribution::net_value`]. Returns no amount if we contributed to none -/// of them. -fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { - let mut net_stake = SignedAmount::ZERO; - let mut fee = Amount::ZERO; - let mut have_contribution = false; - for channel in &candidate.channels { - if let Some(contribution) = channel.contribution.as_ref() { - have_contribution = true; - net_stake += contribution.net_value(); - // `estimated_fee` is our per-contributor share, so summing across channels is correct. - fee += contribution.estimated_fee(); - } - } - if !have_contribution { - return LocalStakeAggregate { - amount_msat: None, - fee_paid_msat: None, - direction: PaymentDirection::Outbound, - }; - } - // Direction is from our on-chain wallet's perspective: a positive net stake funds the channel - // (Outbound), while a negative one is a splice-out that returns funds to the wallet (Inbound). - let direction = if net_stake >= SignedAmount::ZERO { - PaymentDirection::Outbound - } else { - PaymentDirection::Inbound - }; - LocalStakeAggregate { - amount_msat: Some(net_stake.unsigned_abs().to_sat() * 1000), - fee_paid_msat: Some(fee.to_sat() * 1000), - direction, + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(entry.details().map(|details| details.status), Some(PaymentStatus::Pending)); } -} -impl Listen for Wallet { - fn filtered_block_connected( - &self, _header: &bitcoin::block::Header, - _txdata: &lightning::chain::transaction::TransactionData, _height: u32, - ) { - debug_assert!(false, "Syncing filtered blocks is currently not supported"); - // As far as we can tell this would be a no-op anyways as we don't have to tell BDK about - // the header chain of intermediate blocks. According to the BDK team, it's sufficient to - // only connect full blocks starting from the last point of disagreement. + /// 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"); } - fn block_connected(&self, block: &bitcoin::Block, height: u32) { - self.runtime.block_on(async { - let mut locked_persister = self.persister.lock().await; - let events = { - let mut locked_wallet = self.inner.lock().expect("lock"); + /// The signing write fails at the pending store: no payment record is minted for a round + /// nothing may broadcast, no entry is left half-written, and the replayed event records the + /// round in full once the store recovers. + #[tokio::test] + async fn a_failed_first_round_signing_write_leaves_no_half_written_record() { + let fail_store = + 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 pre_checkpoint = locked_wallet.latest_checkpoint(); - if pre_checkpoint.height() != height - 1 - || pre_checkpoint.hash() != block.header.prev_blockhash - { - log_debug!( - self.logger, - "Detected reorg while applying a connected block to on-chain wallet: new block with hash {} at height {}", - block.header.block_hash(), - height - ); - } + 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))]); - // In order to be able to reliably calculate fees the `Wallet` needs access to the previous - // ouput data. To this end, we here insert any ouputs of transactions that LDK is intersted - // in (e.g., funding transaction ouputs) into the wallet's transaction graph when we see - // them, so it is reliably able to calculate fees for subsequent spends. - // - // FIXME: technically, we should also do this for mempool transactions. However, at the - // current time fixing the edge case doesn't seem worth the additional conplexity / - // additional overhead.. - let registered_txids = self.chain_source.registered_txids(); - for tx in &block.txdata { - let txid = tx.compute_txid(); - if registered_txids.contains(&txid) { - for (vout, txout) in tx.output.iter().enumerate() { - let outpoint = OutPoint { txid, vout: vout as u32 }; - locked_wallet.insert_txout(outpoint, txout.clone()); - } - } - } + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.record_signed_funding(&tx, &candidates).await.is_err()); + assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 1); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + // The round's facts landed before the entry, so the transaction names its payment + // already; nothing tracks it yet. + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + + fail_store.fail_writes.store(false, Ordering::Release); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert!(record.candidate(txid).expect("candidate").awaiting_broadcast); + // Wallet sync creates the payment record when it observes the transaction. + observe_unconfirmed(&wallet, &tx).await; + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + } + + /// The same failure while signing a fee bump: the record of the round it replaces is left + /// exactly as it stands, and the recorded history still ends at that round. + #[tokio::test] + async fn a_failed_bump_signing_write_leaves_the_prior_round_tracked() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - match locked_wallet.apply_block_events(block, height) { - Ok(events) => events, - Err(e) => { - log_error!( - self.logger, - "Failed to apply connected block to on-chain wallet: {}", - e - ); - return; + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let prior = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_id = PaymentId(bump_txid.to_byte_array()); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.record_signed_funding(&bump_tx, &bump_candidates).await.is_err()); + assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 1); + + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(prior)); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert!(wallet.payment_stores.payment_store().get(&bump_id).await.unwrap().is_none()); + } + + /// The candidates handed to the signing-time recording are the channel's pending splice + /// rounds that have a transaction — negotiated predecessors and the round awaiting + /// signatures, in LDK's order, each with this node's contribution to it. A contribution + /// still queued behind the pending rounds has no transaction and is left out. + #[test] + fn funding_candidates_list_the_rounds_with_a_transaction() { + use lightning::ln::channel_state::{ + SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails, + }; + + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let prior_txid = Txid::from_byte_array([9u8; 32]); + let signing_txid = Txid::from_byte_array([10u8; 32]); + let contribution = test_funding_contribution_with_outputs(0, 253, &[]); + let details = SpliceDetails { + candidates: vec![ + SpliceCandidateDetails { + contribution: None, + status: SpliceCandidateStatus::Negotiated { + txid: prior_txid, + new_channel_value_satoshis: 100_000, }, - } - }; + }, + SpliceCandidateDetails { + contribution: Some(contribution.clone()), + status: SpliceCandidateStatus::AwaitingSignatures { + is_initiator: true, + funding_feerate_sat_per_1000_weight: 253, + new_channel_value_satoshis: 110_000, + txid: signing_txid, + }, + }, + SpliceCandidateDetails { + contribution: Some(test_funding_contribution_with_outputs(0, 500, &[])), + status: SpliceCandidateStatus::WaitingOnLock, + }, + ], + confirmed_candidate: None, + received_splice_locked_txid: None, + }; - if let Err(e) = self.update_payment_store(events).await { - log_error!(self.logger, "Failed to update payment store: {}", e); - return; - } + let candidates = funding_candidates(Some(&details), counterparty_node_id, channel_id); - let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); - if let Err(e) = locked_persister.persist_changeset(change_set).await { - log_error!(self.logger, "Failed to persist on-chain wallet: {}", e); - return; - } - }); - } + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].txid, prior_txid); + assert_eq!(candidates[0].channels.len(), 1); + assert_eq!(candidates[0].channels[0].contribution, None); + assert_eq!(candidates[1].txid, signing_txid); + assert_eq!(candidates[1].channels.len(), 1); + assert_eq!(candidates[1].channels[0].counterparty_node_id, counterparty_node_id); + assert_eq!(candidates[1].channels[0].channel_id, channel_id); + assert_eq!(candidates[1].channels[0].contribution, Some(contribution)); - fn blocks_disconnected(&self, _fork_point_block: BlockLocator) { - // This is a no-op as we don't have to tell BDK about disconnections. According to the BDK - // team, it's sufficient in case of a reorg to always connect blocks starting from the last - // point of disagreement. + assert!(funding_candidates(None, counterparty_node_id, channel_id).is_empty()); } -} -impl WalletSource for Wallet { - fn list_confirmed_utxos<'a>( - &'a self, - ) -> impl Future, ()>> + Send + 'a { - async move { self.list_confirmed_utxos_inner() } - } + /// Graduation must decide from the live record and write only the status: a pending-store + /// snapshot taken before a concurrent classification landed must not roll the record's + /// figures back when the payment graduates to `Succeeded`. + #[tokio::test] + async fn graduation_preserves_classified_figures() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; - fn get_change_script<'a>(&'a self) -> impl Future> + Send + 'a { - async move { self.get_change_script_inner().await } - } + let txid = Txid::from_byte_array([4u8; 32]); + let payment_id = PaymentId(txid.to_byte_array()); + let confirmed = ConfirmationStatus::Confirmed { + block_hash: bitcoin::BlockHash::from_byte_array([9u8; 32]), + height: 5, + timestamp: 100, + }; + let tx_type = Some(TransactionType::InteractiveFunding { channels: vec![] }); - fn get_prevtx<'a>( - &'a self, outpoint: OutPoint, - ) -> impl Future> + Send + 'a { - async move { - let locked_wallet = self.inner.lock().expect("lock"); - locked_wallet - .tx_details(outpoint.txid) - .map(|tx_details| tx_details.tx.deref().clone()) - .ok_or_else(|| { - log_error!( - self.logger, - "Failed to get previous transaction for {}", - outpoint.txid - ); - }) - } - } + // The live record carries the classification: contribution-derived figures, confirmed. + let mut recorded = + interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); + recorded.kind = PaymentKind::Onchain { txid, status: confirmed, tx_type: tx_type.clone() }; + recorded.latest_update_timestamp = 0; + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); - fn sign_psbt<'a>( - &'a self, psbt: Psbt, - ) -> impl Future> + Send + 'a { - async move { self.sign_psbt_inner(psbt) } - } -} + // The pending entry embeds a stale snapshot: wallet-derived figures recorded before the + // classification above landed. + let mut stale = interactive_funding_details(payment_id, txid, Some(0), Some(0)); + stale.kind = PaymentKind::Onchain { txid, status: confirmed, tx_type }; + let entry = PendingPaymentDetails::new(stale, Vec::new(), Vec::new()); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); -// Anchor bumping uses LdkWallet for coin selection, which wraps a WalletSource to implement -// CoinSelectionSource. Splicing uses this implementation of coin selection instead. -impl CoinSelectionSource for Wallet { - fn select_confirmed_utxos<'a>( - &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], - target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64, - ) -> impl Future> + Send + 'a { - debug_assert!(claim_id.is_none()); - let fee_rate = FeeRate::from_sat_per_kwu(target_feerate_sat_per_1000_weight as u64); - async move { self.select_confirmed_utxos(must_spend, must_pay_to, fee_rate).await } + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { old_tip: block_id(9), new_tip: block_id(10) }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert_eq!( + payment.amount_msat, + Some(2_000_000), + "graduation must not roll figures back to the snapshot's" + ); + assert_eq!(payment.fee_paid_msat, Some(999)); + assert!(payment.latest_update_timestamp > 0, "the graduation write must timestamp"); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); } - fn sign_psbt<'a>( - &'a self, psbt: Psbt, - ) -> impl Future> + Send + 'a { - debug_assert!(false); - async move { self.sign_psbt_inner(psbt) } + /// When the live record has diverged from the pending-store snapshot — here the snapshot + /// says Confirmed at graduation depth while the record says Unconfirmed — graduation must + /// decline and keep the entry rather than force-writing `Succeeded` from stale state. The + /// seeded divergence is synthetic (no current production writer downgrades a record's + /// confirmation); the test pins the hardening that comes with deciding from the live record. + #[tokio::test] + async fn graduation_declines_on_diverged_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let txid = Txid::from_byte_array([5u8; 32]); + let payment_id = PaymentId(txid.to_byte_array()); + let confirmed = ConfirmationStatus::Confirmed { + block_hash: bitcoin::BlockHash::from_byte_array([9u8; 32]), + height: 5, + timestamp: 100, + }; + + // The live record is Unconfirmed... + let recorded = interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + // ...while the pending entry's snapshot claims a graduation-deep confirmation. + let mut snapshot = + interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); + snapshot.kind = PaymentKind::Onchain { + txid, + status: confirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + let entry = PendingPaymentDetails::new(snapshot, Vec::new(), Vec::new()); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { old_tip: block_id(9), new_tip: block_id(10) }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!( + payment.status, + PaymentStatus::Pending, + "a diverged snapshot must not force-graduate the record" + ); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } + )); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), + "the entry must survive for future events to drive" + ); } -} -/// Similar to [`KeysManager`], but overrides the destination and shutdown scripts so they are -/// directly spendable by the BDK wallet. -pub(crate) struct WalletKeysManager { - inner: KeysManager, - wallet: Arc, - logger: Arc, -} + /// A middle RBF candidate must map back to the funding record: it is neither the record's + /// id (here the txid-derived id of the first candidate), nor its current txid (the active + /// candidate), nor in `conflicting_txids` (it never got a `TxReplaced` event of its own). + #[tokio::test] + async fn find_payment_by_txid_maps_candidate_txids() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; -impl WalletKeysManager { - /// Constructs a `WalletKeysManager` that overrides the destination and shutdown scripts. - /// - /// See [`KeysManager::new`] for more information on `seed`, `starting_time_secs`, and - /// `starting_time_nanos`. - pub fn new( - seed: &[u8; 32], starting_time_secs: u64, starting_time_nanos: u32, wallet: Arc, - logger: Arc, - ) -> Self { - let inner = KeysManager::new(seed, starting_time_secs, starting_time_nanos, true); - Self { inner, wallet, logger } + 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)); } - pub fn sign_message(&self, msg: &[u8]) -> String { - message_signing::sign(msg, &self.inner.get_node_secret_key()) - } + /// Removing a payment must also drop its pending-store entry. The entry indexes the + /// payment's txids (current, conflicting, and candidates), so leaving it behind keeps + /// resolving those txids to the removed record — routing later wallet events to a payment + /// that no longer exists — and nothing else ever cleans it up, since graduation only + /// removes entries whose record is still live. + #[tokio::test] + async fn remove_payment_drops_pending_entry() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let txid = Txid::from_byte_array([1u8; 32]); + let conflicting_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId(txid.to_byte_array()); + + // A Pending outbound on-chain payment with a recorded conflict (e.g. an RBF round). + let details = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type: None }, + Some(1_000), + Some(100), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(details.clone()).await.unwrap(); + let entry = PendingPaymentDetails::new(details, vec![conflicting_txid], Vec::new()); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + wallet.remove_payment(&payment_id).await.unwrap(); - pub fn get_node_secret_key(&self) -> SecretKey { - self.inner.get_node_secret_key() - } + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + assert_eq!(wallet.find_payment_by_txid(conflicting_txid).await.unwrap(), None); - pub fn verify_signature(&self, msg: &[u8], sig: &str, pkey: &PublicKey) -> bool { - message_signing::verify(msg, sig, pkey) + // A replacement event for the removed transaction must skip rather than resolve to the + // removed record: the `TxReplaced` arm asserts the resolved record exists. + let event = WalletEvent::TxReplaced { + txid, + tx: Arc::new(dummy_tx()), + conflicts: vec![(0, conflicting_txid)], + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); } -} -impl NodeSigner for WalletKeysManager { - fn get_node_id(&self, recipient: Recipient) -> Result { - self.inner.get_node_id(recipient) - } + /// A round's facts name its payment for as long as they are kept, which outlasts the record: + /// they describe a transaction that happened, so `remove_payment` leaves them behind. A later + /// wallet event naming that transaction therefore resolves an id whose record is gone, and + /// has to skip — failing would abandon the rest of the batch and the wallet's own view of the + /// chain with it. + #[tokio::test] + async fn a_replacement_of_a_removed_payments_transaction_is_skipped() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - fn ecdh( - &self, recipient: Recipient, other_key: &PublicKey, tweak: Option<&Scalar>, - ) -> Result { - self.inner.ecdh(recipient, other_key, tweak) - } + // A signed splice round the wallet has observed: the facts name its payment and sync has + // created the record. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let payment_id = + wallet.find_payment_by_txid(txid).await.unwrap().expect("the round names its payment"); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); - fn get_expanded_key(&self) -> ExpandedKey { - self.inner.get_expanded_key() - } + wallet.remove_payment(&payment_id).await.unwrap(); + assert_eq!( + wallet.find_payment_by_txid(txid).await.unwrap(), + Some(payment_id), + "the round's facts go on naming the payment the user removed", + ); - fn get_peer_storage_key(&self) -> PeerStorageKey { - self.inner.get_peer_storage_key() - } + // The user fee-bumps the splice, so the wallet reports the signed round replaced. A + // second event in the same batch pins that the batch goes on being handled. + let other = wallet_paying_tx(&wallet, 2); + let other_txid = other.compute_txid(); + insert_unconfirmed_tx(&wallet, other.clone()); + let events = vec![ + WalletEvent::TxReplaced { + txid, + tx: Arc::new(tx.clone()), + conflicts: vec![(0, Txid::from_byte_array([0xB1; 32]))], + }, + WalletEvent::TxUnconfirmed { + txid: other_txid, + tx: Arc::new(other), + old_block_time: None, + }, + ]; + wallet.update_payment_store(events).await.unwrap(); - fn get_receive_auth_key(&self) -> lightning::sign::ReceiveAuthKey { - self.inner.get_receive_auth_key() + assert!( + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none(), + "a removed payment must not come back", + ); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + assert!( + wallet + .payment_stores + .payment_store() + .get(&PaymentId(other_txid.to_byte_array())) + .await + .unwrap() + .is_some(), + "the rest of the batch must still be handled", + ); } - fn sign_invoice( - &self, invoice: &RawBolt11Invoice, recipient: Recipient, - ) -> Result { - self.inner.sign_invoice(invoice, recipient) - } + /// Payments without a pending-store entry — lightning payments, and on-chain payments that + /// already graduated — must remove cleanly: the unconditional pending-store removal relies + /// on removing a missing key being a no-op. + #[tokio::test] + async fn remove_payment_without_pending_entry() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; - fn sign_gossip_message(&self, msg: UnsignedGossipMessage<'_>) -> Result { - self.inner.sign_gossip_message(msg) - } + let payment_id = PaymentId([9u8; 32]); + let details = PaymentDetails::new( + payment_id, + PaymentKind::Bolt11 { + hash: lightning_types::payment::PaymentHash([0u8; 32]), + preimage: None, + secret: None, + counterparty_skimmed_fee_msat: None, + }, + Some(1_000), + None, + PaymentDirection::Outbound, + PaymentStatus::Succeeded, + ); + wallet.payment_stores.payment_store().insert_or_update(details).await.unwrap(); - fn sign_bolt12_invoice( - &self, invoice: &lightning::offers::invoice::UnsignedBolt12Invoice, - ) -> Result { - self.inner.sign_bolt12_invoice(invoice) - } - fn sign_message(&self, msg: &[u8]) -> Result { - self.inner.sign_message(msg) - } -} + wallet.remove_payment(&payment_id).await.unwrap(); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); -impl OutputSpender for WalletKeysManager { - /// See [`KeysManager::spend_spendable_outputs`] for documentation on this method. - fn spend_spendable_outputs( - &self, descriptors: &[&SpendableOutputDescriptor], outputs: Vec, - change_destination_script: ScriptBuf, feerate_sat_per_1000_weight: u32, - locktime: Option, secp_ctx: &Secp256k1, - ) -> Result { - self.inner.spend_spendable_outputs( - descriptors, - outputs, - change_destination_script, - feerate_sat_per_1000_weight, - locktime, - secp_ctx, - ) + // Removing an id known to neither store is also a no-op rather than an error. + wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap(); } -} -impl EntropySource for WalletKeysManager { - fn get_secure_random_bytes(&self) -> [u8; 32] { - self.inner.get_secure_random_bytes() - } -} + /// A graduated funding record has no pending entry — graduation removes it — so its txid must + /// resolve through the payment store itself. Without that fallback, a funding-typed broadcast + /// classified after graduation (e.g. LDK re-broadcasting a promoted 0conf splice whose + /// confirmation landed while the node was offline) would miss the record and create a duplicate + /// under a fresh id. + #[tokio::test] + async fn find_payment_by_txid_resolves_graduated_records() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; -impl SignerProvider for WalletKeysManager { - type EcdsaSigner = InMemorySigner; + let txid = Txid::from_byte_array([6u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut graduated = + interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); + graduated.kind = PaymentKind::Onchain { + txid, + status: confirmed_status(), + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + graduated.status = PaymentStatus::Succeeded; + wallet.payment_stores.payment_store().insert_or_update(graduated).await.unwrap(); - fn generate_channel_keys_id(&self, inbound: bool, user_channel_id: u128) -> [u8; 32] { - self.inner.generate_channel_keys_id(inbound, user_channel_id) + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(payment_id)); } - fn derive_channel_signer(&self, channel_keys_id: [u8; 32]) -> Self::EcdsaSigner { - self.inner.derive_channel_signer(channel_keys_id) - } + /// A cooperative close conflicts with a pending splice's funding transaction — both spend the + /// pre-splice funding outpoint — so sync records the close among the splice record's + /// conflicting txids, and the close's confirmation then resolves to the splice's PaymentId. + /// The funding record must not adopt the close's txid and confirmation as its own: the close + /// is not a round of the splice. It must land on a record keyed by the close's own id. + #[tokio::test] + async fn funding_record_does_not_adopt_a_conflicting_close() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; - fn get_destination_script(&self, _channel_keys_id: [u8; 32]) -> Result { - // LDK may invoke this callback on a runtime worker thread while holding channel locks. - // It must not block on the runtime, or the runtime can deadlock. - let address = self.wallet.pop_pooled_address().ok_or_else(|| { - log_error!(self.logger, "Failed to retrieve a destination script: address pool empty"); - })?; - Ok(address.script_pubkey()) - } + let funding_outpoint = + bitcoin::OutPoint { txid: Txid::from_byte_array([3u8; 32]), vout: 0 }; - fn get_shutdown_scriptpubkey(&self) -> Result { - // LDK may invoke this callback on a runtime worker thread while holding channel locks. - // It must not block on the runtime, or the runtime can deadlock. - let address = self.wallet.pop_pooled_address().ok_or_else(|| { - log_error!(self.logger, "Failed to retrieve a shutdown script: address pool empty"); - })?; + // The close pays the shutdown script, which is a wallet address. + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let close_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: funding_outpoint, + script_sig: bitcoin::ScriptBuf::new(), + sequence: bitcoin::Sequence::MAX, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + }; + let close_txid = close_tx.compute_txid(); - match address.witness_program() { - Some(program) => ShutdownScript::new_witness_program(&program).map_err(|e| { - log_error!(self.logger, "Invalid shutdown script: {:?}", e); - }), - _ => { - log_error!( - self.logger, - "Tried to use a non-witness address. This must never happen." - ); - panic!("Tried to use a non-witness address. This must never happen."); + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + + // Sync saw the close double-spend the splice's funding transaction. + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + + let event = WalletEvent::TxConfirmed { + txid: close_txid, + tx: Arc::new(close_tx), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let funding = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + match &funding.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid, "the record must not adopt the close's txid"); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); }, + kind => panic!("unexpected kind {:?}", kind), } - } -} + assert_eq!(funding.amount_msat, Some(1_000_000)); + assert_eq!(funding.fee_paid_msat, Some(500)); -impl ChangeDestinationSource for WalletKeysManager { - fn get_change_destination_script<'a>( - &'a self, - ) -> impl Future> + Send + 'a { - async move { - self.wallet - .get_new_internal_address() - .await - .map_err(|e| { - log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e); - }) - .map(|addr| addr.script_pubkey()) - .map_err(|_| ()) + let close = wallet + .payment_stores + .payment_store() + .get(&PaymentId(close_txid.to_byte_array())) + .await + .unwrap() + .unwrap(); + match &close.kind { + PaymentKind::Onchain { txid, status, .. } => { + assert_eq!(*txid, close_txid); + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + kind => panic!("unexpected kind {:?}", kind), } } -} -/// Convert LDK's `Input::satisfaction_weight` to the value BDK's -/// [`bdk_wallet::TxBuilder::add_foreign_utxo`] expects. -/// -/// LDK and BDK disagree on what `satisfaction_weight` includes for a SegWit input. LDK -/// treats it as the full weight of the spent input's `script_sig` and `witness` *each -/// with their lengths included* — i.e., the empty `script_sig` length byte (4 WU) and -/// the witness-elements-count varint (1 WU) are part of the value. BDK adds -/// `TxIn::default().segwit_weight()` internally, which already accounts for those same -/// 5 WU (an empty TxIn has a 1-byte empty `script_sig` length and a 1-byte empty -/// witness-count varint). Passing LDK's value directly to BDK therefore double-counts -/// 5 WU per foreign input, which inflates BDK's fee estimate and ultimately funnels the -/// surplus into the new funding output during splice negotiation. -fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { - const EMPTY_SCRIPT_SIG_WEIGHT: u64 = - 1 /* empty script_sig length byte */ * WITNESS_SCALE_FACTOR as u64; - const EMPTY_WITNESS_COUNT_WEIGHT: u64 = 1 /* witness elements count varint */; - Weight::from_wu( - ldk_satisfaction_weight - .saturating_sub(EMPTY_SCRIPT_SIG_WEIGHT + EMPTY_WITNESS_COUNT_WEIGHT), - ) -} + /// The mirror image of [`funding_record_does_not_adopt_a_conflicting_close`]: a cooperative + /// close that a splice round replaces lists the round among its conflicting txids, so the + /// round's confirmation resolves to the close's entry as readily as to the splice's, which + /// records the round as its own. It must land on the splice's record whichever entry the + /// pending cache lists first: the close's record is not the round's, and merging the round + /// into it leaves the splice's payment pending for good. Several closes and several fresh + /// wallets, each with its own cache order, make the splice's entry unlikely to come first + /// every time. + #[tokio::test] + async fn close_record_does_not_adopt_a_conflicting_splice_round() { + let secp = bitcoin::secp256k1::Secp256k1::new(); + let counterparty_node_id = bitcoin::secp256k1::PublicKey::from_secret_key( + &secp, + &bitcoin::secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(), + ); + let channel_id = lightning::ln::types::ChannelId::from_bytes([4u8; 32]); -/// Builds the payment-store update for a freshly classified funding payment. `details` describes -/// the actively broadcast candidate, but when the record already confirmed a *different* -/// candidate — wallet sync saw it win before this classification ran — the update instead carries -/// the confirmed candidate's txid and figures from the candidate history, mirroring what -/// [`Wallet::apply_funding_status_update_locked`] reports when confirmation arrives after -/// classification. -/// -/// `current` is the record as observed inside the payment store's `mutate` critical section — its -/// sole caller, [`Wallet::persist_funding_payment`], builds and applies the update within one -/// closure — so the candidate choice cannot go stale against a concurrent confirmation before the -/// update lands. [`PaymentDetails::update`]'s confirmed-figures rule still arbitrates which -/// figures may land on the record. -fn funding_reclassification_update( - details: PaymentDetails, candidates: &[FundingTxCandidate], current: Option<&PaymentDetails>, -) -> PaymentDetailsUpdate { - // A funding-typed classification of a record already classified as interactive funding is a - // downgrade, not news: LDK re-broadcasts a promoted-but-unconfirmed splice through its - // generic funding path, where the figures are wallet-view rather than contribution-derived. - // Keep the record as classified; wallet-sync events own its confirmation state. - // - // TODO(https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4878): The - // re-typed re-broadcasts are upstream behavior that should be fixed in `rust-lightning`: - // the re-offer ought to keep its `InteractiveFunding` classification, or not recur at all. - // `zero_conf_splice_in_funding_rebroadcast_canary` pins the current behavior via the - // arrival log in `classify_funding`; when it fails against a newer LDK, re-evaluate - // whether this guard still sees traffic. - if let ( - Some(PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - }), - PaymentKind::Onchain { tx_type: Some(TransactionType::Funding { .. }), .. }, - ) = (current.map(|payment| &payment.kind), &details.kind) - { - return PaymentDetailsUpdate::new(details.id); - } - - let mut update = PaymentDetailsUpdate::funding_reclassification(details); - if let Some(PaymentKind::Onchain { - txid: confirmed_txid, - status: ConfirmationStatus::Confirmed { .. }, - .. - }) = current.map(|payment| &payment.kind) - { - if update.txid != Some(*confirmed_txid) { - if let Some(candidate) = candidates.iter().find(|c| c.txid == *confirmed_txid) { - update.txid = Some(candidate.txid); - update.amount_msat = Some(candidate.amount_msat); - update.fee_paid_msat = Some(candidate.fee_paid_msat); + for _ in 0..12 { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // The round pays a wallet address, so the wallet's view of it carries figures of its own. + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let splice_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: Txid::from_byte_array([3u8; 32]), + vout: 0, + }, + script_sig: bitcoin::ScriptBuf::new(), + sequence: bitcoin::Sequence::MAX, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + }; + let splice_txid = splice_tx.compute_txid(); + // Keyed away from the round's txid, as a later round of a splice is. + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + + // Each close was recorded at broadcast, seen unconfirmed, then replaced by the round: + // what the `TxReplaced` arm leaves behind. + let close_txids: Vec = + (7u8..11).map(|byte| Txid::from_byte_array([byte; 32])).collect(); + for close_txid in &close_txids { + let close_details = PaymentDetails::new( + PaymentId(close_txid.to_byte_array()), + PaymentKind::Onchain { + txid: *close_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::CooperativeClose { + counterparty_node_id, + channel_id, + }), + }, + Some(50_000_000), + Some(1_000), + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + wallet + .payment_stores + .payment_store() + .insert_or_update(close_details.clone()) + .await + .unwrap(); + let entry = + PendingPaymentDetails::new(close_details, vec![splice_txid], Vec::new()); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(entry) + .await + .unwrap(); + } + + assert_eq!( + wallet.find_payment_by_txid(splice_txid).await.unwrap(), + Some(payment_id), + "the round resolved to a record that only lists it as a conflict" + ); + + let event = WalletEvent::TxConfirmed { + txid: splice_txid, + tx: Arc::new(splice_tx), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let funding = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + match &funding.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid); + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(funding.amount_msat, Some(1_000_000)); + assert_eq!(funding.fee_paid_msat, Some(500)); + + for close_txid in &close_txids { + let close = wallet + .payment_stores + .payment_store() + .get(&PaymentId(close_txid.to_byte_array())) + .await + .unwrap() + .unwrap(); + match &close.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!( + *txid, *close_txid, + "the close's record adopted the round's txid" + ); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::CooperativeClose { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(close.amount_msat, Some(50_000_000)); + assert_eq!(close.fee_paid_msat, Some(1_000)); } } } - update -} -#[cfg(all(test, any(feature = "chain-esplora", feature = "chain-electrum")))] -mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; - use std::time::Duration; + /// Continues the story above: once the conflicting close confirms through the anti-reorg + /// depth, the splice's funding transaction can never confirm — its shared input is spent for + /// good. The record must fail rather than stay `Pending` forever, and removing the pending + /// entry stops the dead transaction's rebroadcast on every tip change. + #[tokio::test] + async fn funding_payment_fails_once_a_foreign_conflict_confirms_to_depth() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; - use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate}; - use bdk_wallet::Wallet as BdkWallet; - use bitcoin::hashes::Hash; - use bitcoin::Network; - use lightning::io; - use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); - use super::*; - #[cfg(all(not(feature = "chain-esplora"), feature = "chain-electrum"))] - use crate::config::ElectrumSyncConfig; - #[cfg(feature = "chain-esplora")] - use crate::config::EsploraSyncConfig; - use crate::config::PAYMENT_CACHE_CAPACITY; - use crate::io::test_utils::InMemoryStore; - use crate::io::{ - BDK_WALLET_ADDRESS_POOL_KEY, BDK_WALLET_ADDRESS_POOL_PRIMARY_NAMESPACE, - BDK_WALLET_ADDRESS_POOL_SECONDARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, - PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, - }; - use crate::types::{DynStore, DynStoreWrapper}; - use crate::{NodeMetrics, PersistedNodeMetrics}; + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); - const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; - const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; + // The close is canonically confirmed; the splice transaction, having lost the conflict, + // is no longer canonical (here: never inserted at all). + insert_confirmed_tx(&wallet, close_tx, 5); - /// An in-memory store whose writes can be made to fail on demand. - #[derive(Clone)] - struct FailSwitchStore { - inner: Arc, - fail_writes: Arc, - } + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); - impl FailSwitchStore { - fn new() -> Self { - Self { - inner: Arc::new(InMemoryStore::new()), - fail_writes: Arc::new(AtomicBool::new(false)), - } + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + match &payment.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid, "failing must not adopt the conflict's txid"); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), } + assert_eq!(payment.amount_msat, Some(1_000_000)); + assert_eq!(payment.fee_paid_msat, Some(500)); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), + "the entry must go so the dead transaction stops being rebroadcast" + ); } - impl KVStore for FailSwitchStore { - fn read( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) - } + /// A confirmed conflict that is one of the record's own candidates is RBF resolution, not a + /// loss: classification adopts it into the record, so the failure pass must leave the record + /// alone. + #[tokio::test] + async fn funding_payment_survives_a_confirmed_conflict_that_is_a_candidate() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; - fn write( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, - ) -> impl Future> + 'static + Send { - let inner = Arc::clone(&self.inner); - let fail_writes = Arc::clone(&self.fail_writes); - let primary_namespace = primary_namespace.to_string(); - let secondary_namespace = secondary_namespace.to_string(); - let key = key.to_string(); - async move { - if fail_writes.load(Ordering::Acquire) { - return Err(io::Error::new(io::ErrorKind::Other, "writes disabled")); - } - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await - } - } + let bumped_tx = wallet_paying_tx(&wallet, 3); + let bumped_txid = bumped_tx.compute_txid(); - fn remove( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, - ) -> impl Future> + 'static + Send { - KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) - } + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: bumped_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + ]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![bumped_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); - fn list( - &self, primary_namespace: &str, secondary_namespace: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::list(&*self.inner, primary_namespace, secondary_namespace) - } + insert_confirmed_tx(&wallet, bumped_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), + "the entry must survive for classification to adopt the confirmed candidate" + ); } - impl PaginatedKVStore for FailSwitchStore { - fn list_paginated( - &self, primary_namespace: &str, secondary_namespace: &str, - page_token: Option, - ) -> impl Future> + 'static + Send { - PaginatedKVStore::list_paginated( - &*self.inner, - primary_namespace, - secondary_namespace, - page_token, - ) - } + /// A foreign conflict that has confirmed but not yet through the anti-reorg depth may still + /// be reorged out, letting the funding transaction confirm after all; the record must stay + /// pending until the conflict's confirmation is final. + #[tokio::test] + async fn funding_payment_survives_a_foreign_conflict_short_of_depth() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 2), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_some()); } - /// Constructs a `Wallet` around the given store, either creating a fresh BDK wallet or - /// loading the one the store already holds. - async fn new_test_wallet(store: Arc, load_existing: bool) -> Arc { - let logger = Arc::new(Logger::new_log_facade()); - let mut config = Config::default(); - config.network = Network::Regtest; - let config = Arc::new(config); + /// A conflict may double-spend only one round of the negotiation — e.g. it shares an input + /// with an RBF attempt but not with the original candidate. While any candidate is still + /// canonical it can still confirm, so the record must stay pending. + #[tokio::test] + async fn funding_payment_survives_while_a_candidate_can_still_confirm() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; - let mut wallet_persister = - KVStoreWalletPersister::new(Arc::clone(&store), Arc::clone(&logger)); - #[allow(deprecated)] - let bdk_wallet = if load_existing { - BdkWallet::load() - .descriptor(KeychainKind::External, Some(EXTERNAL_DESCRIPTOR)) - .descriptor(KeychainKind::Internal, Some(INTERNAL_DESCRIPTOR)) - .extract_keys() - .check_network(Network::Regtest) - .load_wallet_async(&mut wallet_persister) - .await - .unwrap() - .unwrap() - } else { - BdkWallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR) - .network(Network::Regtest) - .create_wallet_async(&mut wallet_persister) - .await - .unwrap() + let conflict_tx = wallet_paying_tx(&wallet, 3); + let conflict_txid = conflict_tx.compute_txid(); + // A live candidate: spends a different outpoint, so the conflict didn't kill it. + let live_candidate_tx = wallet_paying_tx(&wallet, 4); + let live_candidate_txid = live_candidate_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: live_candidate_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + ]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![conflict_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, conflict_tx, 5); + insert_unconfirmed_tx(&wallet, live_candidate_tx); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), }; + wallet.update_payment_store(vec![event]).await.unwrap(); - let fee_estimator = Arc::new(OnchainFeeEstimator::new()); - let broadcaster = Arc::new(Broadcaster::new(Arc::clone(&logger))); - let node_metrics = Arc::new(PersistedNodeMetrics::new(NodeMetrics::default())); - #[cfg(feature = "chain-esplora")] - let (chain_source, _) = ChainSource::new_esplora( - "http://localhost:1".to_string(), - HashMap::new(), - EsploraSyncConfig::default(), - Arc::clone(&fee_estimator), - Arc::clone(&broadcaster), - Arc::clone(&store), - Arc::clone(&config), - Arc::clone(&logger), - node_metrics, - ) - .unwrap(); - #[cfg(all(not(feature = "chain-esplora"), feature = "chain-electrum"))] - let (chain_source, _) = ChainSource::new_electrum( - "tcp://localhost:1".to_string(), - ElectrumSyncConfig::default(), - Arc::clone(&fee_estimator), - Arc::clone(&broadcaster), - Arc::clone(&store), - Arc::clone(&config), - Arc::clone(&logger), - node_metrics, + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), + "a candidate can still confirm, so the record must stay pending" ); - let payment_store = Arc::new(PaymentStore::new( - Vec::new(), - KeepLeastRecentlyUsed::new(PAYMENT_CACHE_CAPACITY), - PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), - Arc::clone(&store), - Arc::clone(&logger), - )); - let pending_payment_store = Arc::new(PendingPaymentStore::new( - Vec::new(), - KeepAllEntries, - PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), - PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), - Arc::clone(&store), - Arc::clone(&logger), - )); - let runtime = Arc::new(Runtime::new(Arc::clone(&logger)).unwrap()); + } + + /// The failure write pair is record first, entry second: a crash in between leaves a + /// `Failed` record with a lingering entry. The next tip pass must finish the job — remove + /// the entry without disturbing the record. + #[tokio::test] + async fn a_failed_funding_payment_with_a_lingering_entry_is_cleaned_up() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut recorded = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + // The entry embeds the pre-failure snapshot, as a crash between the two writes leaves it. + let snapshot = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); - let persisted_pool_indices = persist::read_address_pool(&*store, &*logger).await.unwrap(); + insert_confirmed_tx(&wallet, close_tx, 5); - Arc::new(Wallet::new( - bdk_wallet, - wallet_persister, - persisted_pool_indices, - broadcaster, - fee_estimator, - Arc::new(chain_source), - payment_store, - runtime, - config, - logger, - pending_payment_store, - )) + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the repair pass must not rewrite"); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), + "the lingering entry must be removed" + ); } - fn pooled_indices(wallet: &Wallet) -> Vec { - wallet.address_pool.lock().unwrap().available.iter().map(|(index, _)| *index).collect() + /// A crash between the failure's record write and its entry removal loses the wallet + /// changeset too, so the restart's catch-up sync replays the same events: `TxReplaced` for + /// the dead funding transaction resolves through the lingering entry to the already-`Failed` + /// record. Re-embedding that record would stamp `Failed` into the entry and hide it from the + /// pending listing that repairs it; the replay must instead finish the interrupted removal. + #[tokio::test] + async fn replayed_replacement_finishes_an_interrupted_failure() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut recorded = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + let snapshot = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let events = vec![ + WalletEvent::TxReplaced { + txid: splice_txid, + tx: Arc::new(dummy_tx()), + conflicts: vec![(0, close_txid)], + }, + WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }, + ]; + wallet.update_payment_store(events).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the replay must not rewrite the record"); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), + "the replay must finish the interrupted entry removal" + ); } + /// Recording a transaction the payment store does not know costs two reads of it: the + /// funding-status check looks the resolved id up, and the generic write merges against the + /// store. Nothing in between re-reads what the funding-status check has already seen. #[tokio::test] - async fn refill_publishes_addresses_only_after_their_reveal_is_persisted() { - let fail_store = FailSwitchStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + async fn unknown_transaction_is_recorded_after_two_payment_store_reads() { + let counting_store = ReadCountingStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.refill_address_pool().await.unwrap(); - assert_eq!(pooled_indices(&wallet).len(), ADDRESS_POOL_TARGET_SIZE); - - // Simulate a handout, then make wallet writes fail: the refill must not publish the - // address it revealed, as a crash would leave its script unwatched by incremental syncs. - wallet.address_pool.lock().unwrap().available.pop_front().unwrap(); - fail_store.fail_writes.store(true, Ordering::Release); - assert!(wallet.refill_address_pool().await.is_err()); - let unpersisted_index = ADDRESS_POOL_TARGET_SIZE as u32; - let indices = pooled_indices(&wallet); - assert_eq!(indices.len(), ADDRESS_POOL_TARGET_SIZE - 1); - assert!(!indices.contains(&unpersisted_index)); + let tx = wallet_paying_tx(&wallet, 1); + let txid = tx.compute_txid(); + let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let event = WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; - // Once persistence recovers, the next refill publishes the retained reveal without - // burning another derivation index. - fail_store.fail_writes.store(false, Ordering::Release); - wallet.refill_address_pool().await.unwrap(); - let indices = pooled_indices(&wallet); - assert_eq!(indices.len(), ADDRESS_POOL_TARGET_SIZE); - assert!(indices.contains(&unpersisted_index)); - let last_revealed = wallet.inner.lock().unwrap().derivation_index(KeychainKind::External); - assert_eq!(last_revealed, Some(unpersisted_index)); + let payment_id = PaymentId(txid.to_byte_array()); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); + assert_eq!(reads, 2, "recording an unknown transaction re-read the payment store"); } + /// A funding record wallet sync created before classification keeps the txid-derived id of + /// its first candidate. Once the payment settles and its entry is removed, a wallet event for + /// that candidate no longer resolves through the candidate history — the fallback keys it by + /// its own txid, colliding with the record's id. Recording the event there would merge a fresh + /// wallet-view `Pending` payment into the terminal record; such events must be skipped. #[tokio::test] - async fn pool_reloads_across_restarts_without_burning_indices() { + async fn candidate_event_does_not_resurrect_a_settled_funding_payment() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; - let (popped_address, indices_before) = { - let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.refill_address_pool().await.unwrap(); - // Simulate a handout and a completed refill before the restart. - let (_, popped_address) = - wallet.address_pool.lock().unwrap().available.pop_front().unwrap(); - wallet.refill_address_pool().await.unwrap(); - (popped_address, pooled_indices(&wallet)) - }; + // The record keeps the txid-derived id of its first candidate r1, as one wallet sync + // created first does; its txid rotated to the RBF round r2. The payment failed and its + // pending entry is gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let r2 = Txid::from_byte_array([4u8; 32]); + let payment_id = PaymentId(r1.to_byte_array()); + let mut recorded = interactive_funding_details(payment_id, r2, Some(1_000_000), Some(600)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + // r1 reappears in the mempool after the failure... + let event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); - let wallet = new_test_wallet(Arc::clone(&store), true).await; - wallet.refill_address_pool().await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); - // The pool is rebuilt from the persisted record: the restart neither reveals fresh - // indices (widening what incremental syncs must watch) nor re-hands-out the address - // popped before the restart. - assert_eq!(pooled_indices(&wallet), indices_before); - let last_revealed = wallet.inner.lock().unwrap().derivation_index(KeychainKind::External); - assert_eq!(last_revealed, Some(ADDRESS_POOL_TARGET_SIZE as u32)); - let pool = wallet.address_pool.lock().unwrap(); - assert!(!pool.available.iter().any(|(_, address)| *address == popped_address)); + // ...and even confirms: the record settled as `Failed` and must stay that way. + let event = WalletEvent::TxConfirmed { + txid: r1, + tx: Arc::new(dummy_tx()), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); } + /// The same collision through a conflict list: a pending entry naming a settled funding + /// record's transaction as a conflict of its own round resolves an event for that transaction + /// to the entry's record, which finds it foreign, and the fallback to the transaction's own id + /// lands on the settled record. That id is read before anything is written under it. #[tokio::test] - async fn loading_drops_pool_indices_the_wallet_never_revealed() { + async fn conflict_listed_event_does_not_resurrect_a_settled_funding_payment() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - { - let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.refill_address_pool().await.unwrap(); - } + let wallet = new_test_wallet(store, false).await; - // Corrupt the persisted record with an index the wallet never revealed. - let logger = Arc::new(Logger::new_log_facade()); - let mut persister = KVStoreWalletPersister::new(Arc::clone(&store), logger); - persister.persist_address_pool(vec![5, 100]).await.unwrap(); + // A settled funding record under the txid-derived id of r1, its pending entry gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let settled_id = PaymentId(r1.to_byte_array()); + let mut settled = interactive_funding_details(settled_id, r1, Some(1_000_000), Some(600)); + settled.status = PaymentStatus::Failed; + settled.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(settled).await.unwrap(); + + // A live funding record whose entry lists r1 as a conflict of its round r2. + let r2 = Txid::from_byte_array([4u8; 32]); + let live_id = PaymentId(r2.to_byte_array()); + let live = interactive_funding_details(live_id, r2, Some(2_000_000), Some(700)); + wallet.payment_stores.payment_store().insert_or_update(live.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(live.clone(), vec![r1], Vec::new())) + .await + .unwrap(); + assert_eq!(wallet.find_payment_by_txid(r1).await.unwrap(), Some(live_id)); - let wallet = new_test_wallet(Arc::clone(&store), true).await; - wallet.refill_address_pool().await.unwrap(); + let event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); - // Index 5 was revealed before the restart and is kept; the never-revealed index 100 - // must be dropped, as no sync path would watch its script. The initial refill then - // tops the pool back up with fresh reveals. - let indices = pooled_indices(&wallet); - assert_eq!(indices.len(), ADDRESS_POOL_TARGET_SIZE); - assert!(indices.contains(&5)); - assert!(!indices.contains(&100)); + let payment = + wallet.payment_stores.payment_store().get(&settled_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the settled record must not resurrect"); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&settled_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.payment_stores.payment_store().get(&live_id).await.unwrap(), Some(live)); } + /// The failure transition must apply regardless of the payment's direction: a splice-out + /// records as `Inbound` (funds return to the wallet) and dies to a conflicting close the + /// same way an outbound one does. #[tokio::test] - async fn signer_provider_callbacks_fail_closed_when_pool_is_empty() { + async fn inbound_funding_payment_fails_once_a_foreign_conflict_confirms_to_depth() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let logger = Arc::new(Logger::new_log_facade()); - let keys_manager = WalletKeysManager::new(&[7u8; 32], 42, 42, Arc::clone(&wallet), logger); + let wallet = new_test_wallet(store, false).await; - // Before the pool is filled it is empty: the sync callbacks must fail closed rather - // than hand out an address whose reveal was never persisted. - assert!(keys_manager.get_destination_script([0u8; 32]).is_err()); - assert!(keys_manager.get_shutdown_scriptpubkey().is_err()); + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); - wallet.refill_address_pool().await.unwrap(); - assert!(keys_manager.get_destination_script([0u8; 32]).is_ok()); - assert!(keys_manager.get_shutdown_scriptpubkey().is_ok()); - } + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let mut details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + details.direction = PaymentDirection::Inbound; + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); - /// An in-memory store that snapshots its full contents after every completed write, letting - /// tests reload the wallet from any crash point. - #[derive(Clone)] - struct SnapshotStore { - data: Arc>>>, - snapshots: Arc>>>>, - } + insert_confirmed_tx(&wallet, close_tx, 5); - impl SnapshotStore { - fn new() -> Self { - Self { - data: Arc::new(Mutex::new(HashMap::new())), - snapshots: Arc::new(Mutex::new(Vec::new())), - } - } + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); - fn from_contents(data: HashMap<(String, String, String), Vec>) -> Self { - Self { data: Arc::new(Mutex::new(data)), snapshots: Arc::new(Mutex::new(Vec::new())) } - } + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); } - impl KVStore for SnapshotStore { - fn read( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, - ) -> impl Future, io::Error>> + 'static + Send { - let res = self - .data - .lock() - .unwrap() - .get(&( - primary_namespace.to_string(), - secondary_namespace.to_string(), - key.to_string(), - )) - .cloned() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "not found")); - async move { res } - } + /// Wallet sync can record a genuine replacement round before it is recorded as a candidate: + /// the counterparty broadcast a round this node did not contribute to, which is recorded only + /// when this node signs a later round of the splice. The funding-status gate then routes the + /// round's confirmation to a duplicate record keyed by the round's txid, whose pending entry + /// shadows the funding record in `find_payment_by_txid`'s direct probe. Once the round is + /// recorded as a candidate, the write must merge the duplicate — adopt its confirmation and + /// remove it — so a single record tracks the splice. + #[tokio::test] + async fn recording_a_round_merges_duplicate_records_for_its_candidates() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; - fn write( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, - ) -> impl Future> + 'static + Send { - let mut data = self.data.lock().unwrap(); - data.insert( - (primary_namespace.to_string(), secondary_namespace.to_string(), key.to_string()), - buf, - ); - self.snapshots.lock().unwrap().push(data.clone()); - async move { Ok(()) } - } + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); - fn remove( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, _lazy: bool, - ) -> impl Future> + 'static + Send { - let mut data = self.data.lock().unwrap(); - data.remove(&( - primary_namespace.to_string(), - secondary_namespace.to_string(), - key.to_string(), - )); - self.snapshots.lock().unwrap().push(data.clone()); - async move { Ok(()) } - } + // Round 1 recorded normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate: a + // duplicate untyped record under the txid-derived id, plus its pending entry. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(duplicate_id)); - fn list( - &self, primary_namespace: &str, secondary_namespace: &str, - ) -> impl Future, io::Error>> + 'static + Send { - let keys = self - .data - .lock() - .unwrap() - .keys() - .filter(|(primary, secondary, _)| { - primary == primary_namespace && secondary == secondary_namespace - }) - .map(|(_, _, key)| key.clone()) - .collect::>(); - async move { Ok(keys) } + // Round 2 is recorded as a candidate, with the history of a later round this node signs. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.record_funding_payment(details, rounds).await.unwrap(); + + // One record: the funding record carries the duplicate's confirmation and the confirmed + // candidate's figures; the duplicate and its pending entry are gone, so the round's txid + // resolves to the funding record again. + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + assert_eq!(payment.amount_msat, Some(1_000_000)); + assert_eq!(payment.fee_paid_msat, Some(400)); + match &payment.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } => assert_eq!(*txid, txid2), + kind => panic!("unexpected kind {:?}", kind), } + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); } - impl PaginatedKVStore for SnapshotStore { - fn list_paginated( - &self, primary_namespace: &str, secondary_namespace: &str, - _page_token: Option, - ) -> impl Future> + 'static + Send { - let keys = self - .data - .lock() - .unwrap() - .keys() - .filter(|(primary, secondary, _)| { - primary == primary_namespace && secondary == secondary_namespace - }) - .map(|(_, _, key)| key.clone()) - .collect::>(); - async move { Ok(PaginatedListResponse { keys, next_page_token: None }) } + /// A duplicate for an *unconfirmed* round carries no state the funding record needs: the + /// merge removes it without touching the record's active txid or figures, and the round's + /// txid maps back to the funding record through its candidate history. + #[tokio::test] + async fn recording_drops_unconfirmed_duplicates_without_adopting_their_txid() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); + + // Wallet sync saw round 1 — still unconfirmed — before any round was recorded. + let duplicate_id = PaymentId(txid1.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: txid1, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + + // Round 2 is the active broadcast; its record lists both rounds. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.record_funding_payment(details, rounds).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + // The record keeps tracking the actively-broadcast round; a duplicate that never confirmed + // has nothing to adopt. + match &payment.kind { + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } => { + assert_eq!(*txid, txid2) + }, + kind => panic!("unexpected kind {:?}", kind), } + assert_eq!(payment.fee_paid_msat, Some(400)); + assert_eq!(wallet.find_payment_by_txid(txid1).await.unwrap(), Some(funding_id)); } + /// Removing the duplicate is two store writes, and the failure between them must leave a + /// state a re-run of the merge (a replayed `SpliceNegotiated` event) can finish cleaning up. + /// If the payment record went first, a failure on the pending-entry removal would orphan that + /// entry where the re-run can no longer discover it (the record lookup misses), and it would + /// keep shadowing the funding record in `find_payment_by_txid`'s direct probe — re-creating + /// the duplicate problem with no further merge coming to fix it. #[tokio::test] - async fn pool_survives_a_crash_at_any_point_during_refill() { - let snapshot_store = SnapshotStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(snapshot_store.clone())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - // Only replay crash points from wallet creation onwards; earlier snapshots hold a - // half-created wallet, which is the builder's concern rather than the pool's. - let baseline = snapshot_store.snapshots.lock().unwrap().len(); + async fn a_rerun_merge_completes_a_partially_failed_duplicate_removal() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; - wallet.refill_address_pool().await.unwrap(); - // Simulate a handout plus the refill it schedules. - wallet.address_pool.lock().unwrap().available.pop_front().unwrap(); - wallet.refill_address_pool().await.unwrap(); - let final_derivation = - wallet.inner.lock().unwrap().derivation_index(KeychainKind::External).unwrap(); + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); - // Reload the wallet from every intermediate store state. No crash point may leave the - // pool unfillable or burn indices: a reload revealing past `final_derivation` means some - // reveal was durable while absent from the pool record, stranding its index as - // revealed-but-unused forever. - let snapshots = snapshot_store.snapshots.lock().unwrap().clone(); - assert!(snapshots.len() > baseline); - for snapshot in snapshots.into_iter().skip(baseline) { - let store: Arc = - Arc::new(DynStoreWrapper(SnapshotStore::from_contents(snapshot))); - let wallet = new_test_wallet(Arc::clone(&store), true).await; - wallet.refill_address_pool().await.unwrap(); - assert_eq!(pooled_indices(&wallet).len(), ADDRESS_POOL_TARGET_SIZE); - let derivation = - wallet.inner.lock().unwrap().derivation_index(KeychainKind::External).unwrap(); - assert!(derivation <= final_derivation); - } + // Round 1 recorded normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + + // Round 2 is recorded as a candidate, but one of the duplicate's two removals fails. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let res = wallet.record_funding_payment(details.clone(), rounds.clone()).await; + assert!(res.is_err(), "the injected remove failure must surface"); + + // The merge re-runs with the record's next write; it must finish the cleanup. + wallet.record_funding_payment(details, rounds).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, funding_id); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); } - /// An in-memory store whose writes can be made to park until aborted or released, - /// signalling when a write has entered the gate, and whose writes can be made to fail. - #[derive(Clone)] - struct GatedStore { - inner: Arc, - gate_writes: Arc, - fail_writes: Arc, - write_entered: Arc, - release: Arc, + /// Signing a later round merges the duplicates of earlier rounds as a courtesy: the signed + /// round itself can have no duplicate yet, as our signatures have not left the node, and the + /// round's own `SpliceNegotiated` event re-runs the merge, replaying on failure. A merge + /// failure must therefore not fail the signing, whose record is complete once both stores are + /// written, and must not leave the record half rolled back. + #[tokio::test] + async fn signing_survives_a_failed_duplicate_merge() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // Round 1 is recorded at signing; round 2 is a counterparty-initiated replacement the + // wallet observed before it was recorded as a candidate, filed as an untyped duplicate. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new())) + .await + .unwrap(); + + // This node signs round 3, a bump of the replacement, but the duplicate's removal fails. + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + // The signing is recorded in full and the duplicate is left as it was. + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!( + entry.candidates().iter().map(|c| c.txid).collect::>(), + vec![txid, replacement_txid, bump_txid] + ); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + // The signing writes no payment record: the one wallet sync made for the first round + // still describes that round, and the entry still mirrors it. + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_some()); + + // The bump's `SpliceNegotiated` event merges the duplicate away. + wallet.record_broadcast_splice_round(channel_id, bump_txid).await.unwrap(); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); } - impl GatedStore { - fn new() -> Self { - Self { - inner: Arc::new(InMemoryStore::new()), - gate_writes: Arc::new(AtomicBool::new(false)), - fail_writes: Arc::new(AtomicBool::new(false)), - write_entered: Arc::new(tokio::sync::Notify::new()), - release: Arc::new(tokio::sync::Notify::new()), - } - } + /// A duplicate merge failing under the `SpliceNegotiated` write must fail that write: the + /// replay it triggers is the merge's only re-run. The mark, cleared before the merge, stays + /// cleared and the duplicate is left as it was; the replayed write finds the round marked + /// already and merges the duplicate away. + #[tokio::test] + async fn a_failed_duplicate_merge_fails_the_negotiation_write_until_its_replay() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // Round 1 is recorded at signing; round 2 is a counterparty-initiated replacement the + // wallet observed before it was recorded as a candidate, filed as an untyped duplicate; + // round 3, a bump this node signs, records round 2 as a candidate, but the signing's + // merge of the duplicate fails and is left to the bump's `SpliceNegotiated` event. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new())) + .await + .unwrap(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + // The event's write meets the same failure: it must surface, so the event is replayed, + // with the mark cleared and the duplicate untouched. + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let res = wallet.record_broadcast_splice_round(channel_id, bump_txid).await; + assert!(res.is_err(), "a failed merge must fail the write"); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(!entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_some()); + + // The replayed write finds the round marked already and merges the duplicate away. + wallet.record_broadcast_splice_round(channel_id, bump_txid).await.unwrap(); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); } - impl KVStore for GatedStore { - fn read( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) - } + /// A merge cut short between adopting a confirmed duplicate's confirmation and removing the + /// duplicate leaves the funding record confirmed on the duplicate's transaction, the pending + /// entry at its prior status and the duplicate untouched, and a re-run completes the removal: + /// the merge is idempotent, so the record's next write or a replayed `SpliceNegotiated` event + /// can finish what a failure cut short. The failure injected is the pending store's, which the + /// adoption writes after the payment store. + #[tokio::test] + async fn a_torn_duplicate_merge_is_completed_by_a_rerun() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - fn write( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, - ) -> impl Future> + 'static + Send { - let inner = Arc::clone(&self.inner); - let gate_writes = Arc::clone(&self.gate_writes); - let fail_writes = Arc::clone(&self.fail_writes); - let write_entered = Arc::clone(&self.write_entered); - let release = Arc::clone(&self.release); - let primary_namespace = primary_namespace.to_string(); - let secondary_namespace = secondary_namespace.to_string(); - let key = key.to_string(); - async move { - if gate_writes.load(Ordering::Acquire) { - write_entered.notify_one(); - release.notified().await; - } - if fail_writes.load(Ordering::Acquire) { - return Err(io::Error::new(io::ErrorKind::Other, "write failed")); - } - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await - } - } + // Round 1 is recorded at signing, round 2 is a counterparty-initiated replacement, and + // round 3 is this node's bump of it, recorded with the channel's history when signed. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); - fn remove( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, - ) -> impl Future> + 'static + Send { - KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) - } + // Wallet sync filed the replacement's confirmation under an untyped record of its own, a + // duplicate of the funding record that already lists the replacement as a candidate. + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: confirmed_status(), + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + let duplicate_entry = PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new()); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(duplicate_entry.clone()) + .await + .unwrap(); + let entry_before = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + let rounds = entry_before.candidates().to_vec(); - fn list( - &self, primary_namespace: &str, secondary_namespace: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + // The merge adopts the confirmation onto the payment record, then fails to mirror it onto + // the pending entry and stops short of removing the duplicate. + fail_store.fail_writes.store(true, Ordering::Release); + { + let guard = wallet.payment_stores.lock().await; + let res = wallet.merge_duplicate_candidate_records(&guard, id, &rounds).await; + assert!(res.is_err(), "the injected pending-store failure must surface"); } - } + fail_store.fail_writes.store(false, Ordering::Release); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } + if t == replacement_txid + )); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(entry_before) + ); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate_entry) + ); - impl PaginatedKVStore for GatedStore { - fn list_paginated( - &self, primary_namespace: &str, secondary_namespace: &str, - page_token: Option, - ) -> impl Future> + 'static + Send { - PaginatedKVStore::list_paginated( - &*self.inner, - primary_namespace, - secondary_namespace, - page_token, - ) + // A re-run finds the confirmation adopted, mirrors it, and removes the duplicate. + { + let guard = wallet.payment_stores.lock().await; + wallet.merge_duplicate_candidate_records(&guard, id, &rounds).await.unwrap(); } + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); } #[tokio::test] - async fn aborting_a_refill_mid_persist_loses_no_reveals() { - let gated_store = GatedStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(gated_store.clone())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.refill_address_pool().await.unwrap(); + async fn max_funding_estimate_keeps_reserved_change_address_used() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + let (funding_tx, block_id) = { + let mut locked_wallet = wallet.inner.lock().unwrap(); + let outputs = vec![TxOut { + value: Amount::from_sat(200_000), + script_pubkey: locked_wallet + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(), + }]; + let funding_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: Vec::new(), + output: outputs, + }; + let block_id = BlockId { + height: locked_wallet.latest_checkpoint().height() + 1, + hash: bitcoin::BlockHash::from_byte_array([42; 32]), + }; + (funding_tx, block_id) + }; + let funding_txid = funding_tx.compute_txid(); + let mut tx_update = TxUpdate::default(); + tx_update.txs = vec![Arc::new(funding_tx)]; + tx_update.anchors = + [(ConfirmationBlockTime { block_id, confirmation_time: 1 }, funding_txid)].into(); + let chain = CheckPoint::from_block_ids([ + wallet.inner.lock().unwrap().latest_checkpoint().block_id(), + block_id, + ]) + .unwrap(); + wallet + .apply_update(Update { tx_update, chain: Some(chain), ..Default::default() }) + .await + .unwrap(); - // Simulate two handouts, then a refill that is aborted (as node shutdown aborts - // cancellable tasks) while parked on its first store write. - wallet.address_pool.lock().unwrap().available.pop_front().unwrap(); - wallet.address_pool.lock().unwrap().available.pop_front().unwrap(); - gated_store.gate_writes.store(true, Ordering::Release); - let refill_wallet = Arc::clone(&wallet); - let refill_task = tokio::spawn(async move { - let _ = refill_wallet.refill_address_pool().await; - }); - gated_store.write_entered.notified().await; - refill_task.abort(); - assert!(refill_task.await.unwrap_err().is_cancelled()); - gated_store.gate_writes.store(false, Ordering::Release); + // Reserve the first change address the way BDK does for a pending transaction whose + // change output the wallet has not indexed yet. + { + let mut locked_wallet = wallet.inner.lock().unwrap(); + assert_eq!(locked_wallet.reveal_next_address(KeychainKind::Internal).index, 0); + assert!(locked_wallet.mark_used(KeychainKind::Internal, 0)); + } - // The aborted refill had already revealed replacements and taken them out of the - // wallet's staged change set. Those reveals must survive the abort: everything a later - // refill publishes has to be covered by persisted wallet state, or a crash would leave - // handed-out scripts unwatched by incremental syncs. - wallet.refill_address_pool().await.unwrap(); - let indices = pooled_indices(&wallet); - assert_eq!(indices.len(), ADDRESS_POOL_TARGET_SIZE); - let max_pooled = *indices.iter().max().unwrap(); + // The reserve must exceed the dust limit so the estimate includes the anchor reserve + // output, which is what pays to the reserved change address. + let anchor_reserve_sats = 25_000; + assert!(anchor_reserve_sats > DUST_LIMIT_SATS); + wallet.get_max_funding_amount(anchor_reserve_sats, FeeRate::from_sat_per_kwu(250)).unwrap(); - let reloaded = new_test_wallet(Arc::clone(&store), true).await; - let persisted_last_revealed = - reloaded.inner.lock().unwrap().derivation_index(KeychainKind::External).unwrap(); + let mut locked_wallet = wallet.inner.lock().unwrap(); assert!( - persisted_last_revealed >= max_pooled, - "pooled index {} exceeds the persisted last revealed index {}", - max_pooled, - persisted_last_revealed + locked_wallet.spk_index().is_used(KeychainKind::Internal, 0), + "estimating the max funding amount must not free a reserved change address", ); + assert_ne!(locked_wallet.next_unused_address(KeychainKind::Internal).index, 0); } - #[tokio::test] - async fn get_new_address_pops_the_oldest_pooled_address_and_persists_the_dequeue() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.refill_address_pool().await.unwrap(); - - let (front_index, front_address) = - wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); - assert_eq!(front_index, 0); - - // The handout comes from the pool front (the oldest revealed index) rather than minting - // a fresh index past the pool's unused tail, keeping the window of revealed-but-unused - // scripts compact for a from-seed restore's full scan. - let address = wallet.get_new_address().await.unwrap(); - assert_eq!(address, front_address); - let indices = pooled_indices(&wallet); - assert_eq!(indices.len(), ADDRESS_POOL_TARGET_SIZE); - assert!(!indices.contains(&front_index)); + /// A previous transaction with a P2WPKH output at index 0 for a contribution input to spend; + /// `seed` varies the output script, and with it the txid. + fn test_prevtx(seed: u8) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + } + } - // The dequeue must be durable before the address is returned: a wallet reloaded from - // the store may not pool (and later re-hand-out) the returned address. - let reloaded = new_test_wallet(Arc::clone(&store), true).await; - reloaded.refill_address_pool().await.unwrap(); - let reloaded_indices = pooled_indices(&reloaded); - assert!(!reloaded_indices.contains(&front_index)); - assert_eq!(reloaded_indices, pooled_indices(&wallet)); + /// Records `rounds` as their signing did — the last round signed, the others negotiated + /// before — then marks the signed round as broadcast, as its `SpliceNegotiated` event would. + /// Returns the record's id. + async fn record_broadcast_rounds( + wallet: &Wallet, tx: &Transaction, rounds: &[(Txid, Option)], + ) -> PaymentId { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let candidates = splice_candidates(counterparty_node_id, channel_id, rounds); + sign_and_observe_round(wallet, tx, &candidates).await; + wallet.record_broadcast_splice_round(channel_id, tx.compute_txid()).await.unwrap(); + wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap().expect("recorded") } + /// The close finds no round of ours held — the channel closed on a commitment transaction and + /// the monitor watches the round no longer — so the only round's payment is failed and its + /// entry removed. The record keeps describing the round. #[tokio::test] - async fn get_new_address_fails_closed_and_returns_the_address_to_the_pool() { - let fail_store = FailSwitchStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + async fn closing_without_a_round_of_ours_held_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.refill_address_pool().await.unwrap(); - - let (front_index, front_address) = - wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); + let (_, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let id = record_broadcast_rounds(&wallet, &tx, &[(txid, Some(contribution))]).await; - // While persistence is unavailable no address is handed out, and the popped address - // returns to the pool front: its index is neither skipped nor left unreachable. - fail_store.fail_writes.store(true, Ordering::Release); - assert!(wallet.get_new_address().await.is_err()); - let (index, address) = - wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); - assert_eq!(index, front_index); - assert_eq!(address, front_address); - assert_eq!(pooled_indices(&wallet).len(), ADDRESS_POOL_TARGET_SIZE); + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); - // Once persistence recovers, the very address the failed call popped is handed out. - fail_store.fail_writes.store(false, Ordering::Release); - assert_eq!(wallet.get_new_address().await.unwrap(), front_address); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: recorded, status: ConfirmationStatus::Unconfirmed, .. } + if recorded == txid + )); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } + /// LDK promoted a round of ours and discarded the counterparty's round it replaced with the + /// promotion, so the payment stays as it is, the promotion recorded and the discarded round + /// still in its history. #[tokio::test] - async fn get_new_address_refills_an_empty_pool_before_handing_out() { - let fail_store = FailSwitchStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + async fn promoting_a_round_of_ours_keeps_its_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; - // With the pool empty and persistence down, the call must fail closed rather than hand - // out an address whose reveal isn't durable. - fail_store.fail_writes.store(true, Ordering::Release); - assert!(wallet.get_new_address().await.is_err()); + wallet.resolve_promoted_splice_round(channel_id, txid, Some(&[txid])).await.unwrap(); - // With persistence available it fills the pool inline and serves from it. - fail_store.fail_writes.store(false, Ordering::Release); - let address = wallet.get_new_address().await.unwrap(); - let expected = wallet.inner.lock().unwrap().peek_address(KeychainKind::External, 0).address; - assert_eq!(address, expected); - assert_eq!(pooled_indices(&wallet).len(), ADDRESS_POOL_TARGET_SIZE); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert_eq!(entry.candidates().len(), 2); + assert_eq!(entry.locked_rounds(), &[txid]); } + /// LDK promoted a sibling this node did not contribute to — the counterparty's round locked on + /// a channel that stays open, and the channel manager holds it as the funding and no pending + /// round by the time the event is handled — so no round of ours can confirm anymore and the + /// payment is failed, although the channel holds a round of the splice. The channel's monitor, + /// updated only later, may still watch our round; it is not consulted. The record keeps + /// describing our round. #[tokio::test] - async fn get_new_address_never_reuses_across_restarts_after_an_overfull_pool() { - let fail_store = FailSwitchStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + async fn promoting_a_round_not_ours_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.refill_address_pool().await.unwrap(); - - // A failed handout returns the popped address to the pool while the refill retains its - // unpublished reveal; the next successful refill then records and publishes all - // seventeen indices, filling the pool past its target size. - fail_store.fail_writes.store(true, Ordering::Release); - assert!(wallet.get_new_address().await.is_err()); - fail_store.fail_writes.store(false, Ordering::Release); - wallet.refill_address_pool().await.unwrap(); - assert!(pooled_indices(&wallet).len() > ADDRESS_POOL_TARGET_SIZE); - - // Handing out from the overfull pool must still durably exclude the returned address - // from the pool record before returning: a wallet reloaded from the store may never - // hand it out again. - let address = wallet.get_new_address().await.unwrap(); - - let reloaded = new_test_wallet(Arc::clone(&store), true).await; - reloaded.refill_address_pool().await.unwrap(); - let reloaded_pool = reloaded.address_pool.lock().unwrap(); - assert!(!reloaded_pool.available.iter().any(|(_, pooled)| *pooled == address)); - } + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; - /// An in-memory store that can fail all writes except the address-pool record's. - #[derive(Clone)] - struct RecordOnlyStore { - inner: Arc, - fail_non_record_writes: Arc, - } + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); - impl RecordOnlyStore { - fn new() -> Self { - Self { - inner: Arc::new(InMemoryStore::new()), - fail_non_record_writes: Arc::new(AtomicBool::new(false)), - } - } + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: recorded, status: ConfirmationStatus::Unconfirmed, .. } + if recorded == txid + )); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } - impl KVStore for RecordOnlyStore { - fn read( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) - } - - fn write( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, - ) -> impl Future> + 'static + Send { - let inner = Arc::clone(&self.inner); - let fail_non_record_writes = Arc::clone(&self.fail_non_record_writes); - let primary_namespace = primary_namespace.to_string(); - let secondary_namespace = secondary_namespace.to_string(); - let key = key.to_string(); - async move { - if fail_non_record_writes.load(Ordering::Acquire) - && key != BDK_WALLET_ADDRESS_POOL_KEY - { - return Err(io::Error::new(io::ErrorKind::Other, "writes disabled")); - } - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await - } - } - - fn remove( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, - ) -> impl Future> + 'static + Send { - KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) - } - - fn list( - &self, primary_namespace: &str, secondary_namespace: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::list(&*self.inner, primary_namespace, secondary_namespace) - } - } + /// Wallet sync moved the record onto the counterparty's round before LDK promoted it, so the + /// promoted round is the record's own transaction. It is recorded without a stake all the + /// same, so it is no round of ours, and the payment is failed as it is when the record still + /// names our round. + #[tokio::test] + async fn promoting_a_round_not_ours_the_record_adopted_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let rounds = [(counterparty_txid, None), (tx.compute_txid(), Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + let event = WalletEvent::TxUnconfirmed { + txid: counterparty_txid, + tx: Arc::new(dummy_tx()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().unwrap(); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) + ); - impl PaginatedKVStore for RecordOnlyStore { - fn list_paginated( - &self, primary_namespace: &str, secondary_namespace: &str, - page_token: Option, - ) -> impl Future> + 'static + Send { - PaginatedKVStore::list_paginated( - &*self.inner, - primary_namespace, - secondary_namespace, - page_token, + 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!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } + if txid == counterparty_txid + )); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } + /// The same at a close whose monitor holds the counterparty's round the record moved onto: + /// the record naming a round recorded without a stake does not make it a round of ours. #[tokio::test] - async fn failed_get_new_address_leaves_the_pool_record_covering_the_pool() { - let record_store = RecordOnlyStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(record_store.clone())); + async fn closing_with_a_held_round_not_ours_the_record_adopted_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.refill_address_pool().await.unwrap(); - let (front_index, _) = - wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let rounds = [(counterparty_txid, None), (tx.compute_txid(), Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + let event = WalletEvent::TxUnconfirmed { + txid: counterparty_txid, + tx: Arc::new(dummy_tx()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().unwrap(); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) + ); - // Fail everything but the pool record: the handout's record write succeeds (durably - // excluding the popped index) while the reveal flush fails, so the call fails and the - // address goes back into the pool. Its index must not be stranded by that partial - // failure: a crash right here reloads the pool from the record, and a durably revealed - // index missing from it would never be pooled or handed out again. - record_store.fail_non_record_writes.store(true, Ordering::Release); - assert!(wallet.get_new_address().await.is_err()); - record_store.fail_non_record_writes.store(false, Ordering::Release); + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) + .await + .unwrap(); - let reloaded = new_test_wallet(Arc::clone(&store), true).await; - reloaded.refill_address_pool().await.unwrap(); - assert!(pooled_indices(&reloaded).contains(&front_index)); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } + if txid == counterparty_txid + )); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } + /// A record failed as a round this node did not contribute to locked keeps the splice intent + /// it carries as a bare intent under an id of its own: the intent is the splice tracker's to + /// settle — at this lock, or from the failure LDK reports for the contribution — and the + /// record's removal must not take it along, nor may it stay under the failed record's id, + /// which the fresh round of a fee bump LDK carries across the lock would adopt. #[tokio::test] - async fn loading_survives_an_undecodable_pool_record() { + 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; - wallet.refill_address_pool().await.unwrap(); - } + 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(); - // Corrupt the record itself: the pool is a reconstructible cache, so an undecodable - // record must not prevent the node from starting. - KVStore::write( - &*store, - BDK_WALLET_ADDRESS_POOL_PRIMARY_NAMESPACE, - BDK_WALLET_ADDRESS_POOL_SECONDARY_NAMESPACE, - BDK_WALLET_ADDRESS_POOL_KEY, - vec![0x00, 0xff], - ) - .await - .unwrap(); + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); - let wallet = new_test_wallet(Arc::clone(&store), true).await; - wallet.refill_address_pool().await.unwrap(); - assert_eq!(pooled_indices(&wallet).len(), ADDRESS_POOL_TARGET_SIZE); + 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); } - /// An in-memory store whose pool-record writes can be made to fail while wallet-changeset - /// writes succeed. - #[derive(Clone)] - struct RecordFailStore { - inner: Arc, - fail_record_writes: Arc, - } + /// 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(); - impl RecordFailStore { - fn new() -> Self { - Self { - inner: Arc::new(InMemoryStore::new()), - fail_record_writes: Arc::new(AtomicBool::new(false)), - } - } - } + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); - impl KVStore for RecordFailStore { - fn read( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) - } + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + let kept = bare_entries_carrying(&wallet, &intent).await; + assert_eq!(kept.len(), 1, "one bare entry carries the intent: {kept:?}"); + assert_ne!(kept[0].id(), id); + } + + /// A fee bump LDK carries across the lock of a round it does not overlap begins a fresh + /// splice, and the fresh round adopts the id of the bare intent carrying its contribution. + /// The intent kept from the failed record must therefore sit under an id of its own: the + /// fresh round then gets a `Pending` record whose entry carries the intent, where under the + /// failed record's id it would take that record — left `Failed` with the fresh round's txid — + /// and go untracked. + #[tokio::test] + async fn a_kept_intent_signs_its_fresh_round_under_an_id_of_its_own() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + // The bump's intent joined the record of the round it was to replace. + let id = PaymentId([31u8; 32]); + let intent = SpliceIntent { + contribution: bump_contribution.clone(), + kind: SpliceKind::Rbf {}, + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::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(); - fn write( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, - ) -> impl Future> + 'static + Send { - let inner = Arc::clone(&self.inner); - let fail_record_writes = Arc::clone(&self.fail_record_writes); - let primary_namespace = primary_namespace.to_string(); - let secondary_namespace = secondary_namespace.to_string(); - let key = key.to_string(); - async move { - if fail_record_writes.load(Ordering::Acquire) && key == BDK_WALLET_ADDRESS_POOL_KEY - { - return Err(io::Error::new(io::ErrorKind::Other, "writes disabled")); - } - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await - } - } + // 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()); + } - fn remove( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, - ) -> impl Future> + 'static + Send { - KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) - } + /// 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(); - fn list( - &self, primary_namespace: &str, secondary_namespace: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::list(&*self.inner, primary_namespace, secondary_namespace) - } + 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)], + ); } - impl PaginatedKVStore for RecordFailStore { - fn list_paginated( - &self, primary_namespace: &str, secondary_namespace: &str, - page_token: Option, - ) -> impl Future> + 'static + Send { - PaginatedKVStore::list_paginated( - &*self.inner, - primary_namespace, - secondary_namespace, - page_token, + /// A round of ours nothing had broadcast when the counterparty's round locked — our + /// signatures were never exchanged — is dropped with the promotion, and its record with it, + /// rather than failed: no transaction of ours ever existed to fail a payment for. + #[tokio::test] + async fn promoting_a_round_drops_a_round_nothing_broadcast() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + assert!( + wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some(), + "the round was recorded" + ); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_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()); } + /// Failing the payment writes the record before it removes the entry; a replay after the + /// removal was lost finds the record failed already and finishes the removal. #[tokio::test] - async fn crash_after_a_failed_record_write_re_derives_the_same_indices() { - let record_store = RecordFailStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(record_store.clone())); - { - let wallet = new_test_wallet(Arc::clone(&store), false).await; + async fn promoting_a_round_finishes_a_failure_cut_short() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + wallet + .payment_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(); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); - // Fail only the record write: the fill's reveals must not become durable without - // record coverage, as a crash would then leave indices that no path ever pools or - // hands out again — permanently skipping them in the keychain. - record_store.fail_record_writes.store(true, Ordering::Release); - assert!(wallet.refill_address_pool().await.is_err()); - } + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); - record_store.fail_record_writes.store(false, Ordering::Release); - let reloaded = new_test_wallet(Arc::clone(&store), true).await; - reloaded.refill_address_pool().await.unwrap(); - let indices = pooled_indices(&reloaded); - assert_eq!(indices.len(), ADDRESS_POOL_TARGET_SIZE); - assert!( - indices.contains(&0), - "the failed fill's indices must be re-derived, not skipped: {:?}", - indices - ); + 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()); + } + + /// A promotion reported for a channel the manager no longer lists — the channel closed before + /// the event was handled — records the round and leaves the payments to the close, which + /// resolves them by what the monitor holds: nothing of ours here, the promoted round being the + /// counterparty's, so the payment is failed then. Recording the counterparty's round does not + /// keep it. + #[tokio::test] + async fn promoting_a_round_on_an_unlisted_channel_records_it_alone() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + + wallet.resolve_promoted_splice_round(channel_id, counterparty_txid, None).await.unwrap(); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert_eq!(entry.locked_rounds(), &[counterparty_txid]); + + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) + .await + .unwrap(); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } + /// A payment whose round LDK promoted before is kept when a later splice's round is promoted + /// — the round can still confirm, the later one descending from it — while the later round's + /// payment is kept for the round LDK holds. The close after that keeps both as well. #[tokio::test] - async fn oldest_address_still_leads_the_pool_after_concurrent_failed_handouts() { - let gated_store = GatedStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(gated_store.clone())); + async fn a_later_promotion_keeps_a_payment_whose_round_locked_before() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.refill_address_pool().await.unwrap(); - let (_, oldest_address) = - wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); + let (_, channel_id) = test_counterparty_and_channel(); + let (first_tx, first) = splice_in_round(&wallet, 1); + let first_txid = first_tx.compute_txid(); + let first_id = + record_broadcast_rounds(&wallet, &first_tx, &[(first_txid, Some(first))]).await; + wallet + .resolve_promoted_splice_round(channel_id, first_txid, Some(&[first_txid])) + .await + .unwrap(); - // First handout pops index 0 and parks inside its refill's record write, holding the - // refill lock. - gated_store.gate_writes.store(true, Ordering::Release); - gated_store.fail_writes.store(true, Ordering::Release); - let first_wallet = Arc::clone(&wallet); - let first_handout = tokio::spawn(async move { first_wallet.get_new_address().await }); - gated_store.write_entered.notified().await; + let (second_tx, second) = splice_in_round(&wallet, 2); + let second_txid = second_tx.compute_txid(); + let second_id = + record_broadcast_rounds(&wallet, &second_tx, &[(second_txid, Some(second))]).await; + wallet + .resolve_promoted_splice_round(channel_id, second_txid, Some(&[second_txid])) + .await + .unwrap(); - // Second handout pops index 1 while the first is parked, then queues on the refill lock. - let second_wallet = Arc::clone(&wallet); - let second_handout = tokio::spawn(async move { second_wallet.get_new_address().await }); - while wallet.address_pool.lock().unwrap().available.len() > ADDRESS_POOL_TARGET_SIZE - 2 { - tokio::task::yield_now().await; + for (id, locked) in [(first_id, first_txid), (second_id, second_txid)] { + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert_eq!(entry.locked_rounds(), &[locked]); } - // Both handouts now fail and return their indices to the pool, completing out of pop - // order: index 0 first, index 1 second. - gated_store.gate_writes.store(false, Ordering::Release); - gated_store.release.notify_one(); - assert!(first_handout.await.unwrap().is_err()); - assert!(second_handout.await.unwrap().is_err()); - gated_store.fail_writes.store(false, Ordering::Release); + wallet.resolve_closed_channel_splice_rounds(channel_id, &[second_txid]).await.unwrap(); + for id in [first_id, second_id] { + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .is_some()); + } + } - // The pushed-back indices must not swap the pool out of index order: the next handout - // has to serve the oldest revealed index, or a lower unused index would be left sitting - // behind a handed-out (potentially funded) one, where a from-seed restore's stop gap - // could strand it. - let handed_out = wallet.get_new_address().await.unwrap(); - assert_eq!( - handed_out, - oldest_address, - "the oldest pooled address must be handed out first, pool: {:?}", - pooled_indices(&wallet) + /// A fee bump nothing broadcast is dropped when the round it was to replace is promoted — the + /// counterparty's `splice_locked` for the round arrived as the bump was signed — and the + /// record is handed back to the promoted round, figures included, with the promotion recorded. + #[tokio::test] + async fn promoting_a_round_drops_an_abandoned_bump_and_hands_the_record_back() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (first_tx, first) = splice_out_round(&wallet, 1, 500_000, 300); + let (bump_tx, bump) = splice_out_round(&wallet, 2, 500_000, 600); + let (first_txid, bump_txid) = (first_tx.compute_txid(), bump_tx.compute_txid()); + let id = + record_broadcast_rounds(&wallet, &first_tx, &[(first_txid, Some(first.clone()))]).await; + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record exists"); + let first_figures = (payment.amount_msat, payment.fee_paid_msat); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(first_txid, Some(first)), (bump_txid, Some(bump))], ); - } + wallet.record_signed_funding(&bump_tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record exists"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == bump_txid)); + assert_ne!((payment.amount_msat, payment.fee_paid_msat), first_figures); - /// A pass-through [`KVStore`] that parks writes to one namespace: a matching writer first - /// signals `parked`, then waits until the test drops its `gate` write guard. Writes to every - /// other namespace pass straight through. - #[derive(Clone)] - struct NamespaceGatedStore { - inner: Arc, - gated_namespace: String, - parked: Arc, - gate: Arc>, - } + wallet + .resolve_promoted_splice_round(channel_id, first_txid, Some(&[first_txid])) + .await + .unwrap(); - impl NamespaceGatedStore { - fn new(gated_namespace: &str) -> Self { - Self { - inner: Arc::new(InMemoryStore::new()), - gated_namespace: gated_namespace.to_string(), - parked: Arc::new(tokio::sync::Notify::new()), - gate: Arc::new(tokio::sync::RwLock::new(())), - } - } + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == first_txid)); + assert_eq!((payment.amount_msat, payment.fee_paid_msat), first_figures); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![first_txid]); + assert_eq!(entry.locked_rounds(), &[first_txid]); } - impl KVStore for NamespaceGatedStore { - fn read( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) - } - - fn write( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, - ) -> impl Future> + 'static + Send { - let inner = Arc::clone(&self.inner); - let gated = primary_namespace == self.gated_namespace; - let parked = Arc::clone(&self.parked); - let gate = Arc::clone(&self.gate); - let primary_namespace = primary_namespace.to_string(); - let secondary_namespace = secondary_namespace.to_string(); - let key = key.to_string(); - async move { - if gated { - parked.notify_one(); - let _guard = gate.read().await; - } - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await - } - } - - fn remove( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, - ) -> impl Future> + 'static + Send { - KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + /// A zero-conf splice round of ours locked before its transaction confirmed and a later splice + /// built on it, so at the close the monitor holds the later round as the funding and watches + /// neither. The promotion LDK reported keeps the payment: the round can still confirm, the + /// later round descending from it. Reporting the promotion again — a replayed `ChannelReady` — + /// records it once and keeps the payment, and reporting one for a round no funding payment + /// holds records nothing and keeps the payment for the round recorded before. + #[tokio::test] + async fn closing_keeps_a_payment_whose_round_locked() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_in_round(&wallet, 1); + let txid = tx.compute_txid(); + let id = record_broadcast_rounds(&wallet, &tx, &[(txid, Some(contribution))]).await; + let later_funding_txid = Txid::from_byte_array([0xF1; 32]); + for locked in [txid, txid, later_funding_txid] { + wallet + .resolve_promoted_splice_round(channel_id, locked, Some(&[locked])) + .await + .unwrap(); } + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert_eq!(entry.locked_rounds(), &[txid]); - fn list( - &self, primary_namespace: &str, secondary_namespace: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::list(&*self.inner, primary_namespace, secondary_namespace) - } + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[later_funding_txid]) + .await + .unwrap(); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some(), + "the entry stays" + ); } - impl PaginatedKVStore for NamespaceGatedStore { - fn list_paginated( - &self, primary_namespace: &str, secondary_namespace: &str, - page_token: Option, - ) -> impl Future> + 'static + Send { - PaginatedKVStore::list_paginated( - &*self.inner, - primary_namespace, - secondary_namespace, - page_token, - ) - } - } + /// A promoted round whose `SpliceNegotiated` event is still unhandled when the channel closes + /// is not taken back as abandoned: LDK broadcast it as the signatures were exchanged, before it + /// locked. + #[tokio::test] + async fn closing_keeps_a_locked_round_whose_negotiation_event_is_unhandled() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_in_round(&wallet, 1); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + wallet.resolve_promoted_splice_round(channel_id, txid, Some(&[txid])).await.unwrap(); - fn dummy_tx() -> Transaction { - Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: Vec::new(), - } + let later_funding_txid = Txid::from_byte_array([0xF1; 32]); + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[later_funding_txid]) + .await + .unwrap(); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert!(entry.candidate(txid).is_some_and(|round| round.awaiting_broadcast)); + } + + /// A splice-in round spending output 0 of `test_prevtx(seed)`: the contribution as LDK would + /// negotiate it, its input its only part, and the transaction carrying it, which also pays a + /// wallet address so the wallet sees movement. Rounds with distinct seeds have distinct parts, + /// as a fee bump that had to select other inputs has. + fn splice_in_round(wallet: &Wallet, seed: u8) -> (Transaction, FundingContribution) { + let prevtx = test_prevtx(seed); + let contribution = test_funding_contribution_with_parts( + 300, + 253, + std::slice::from_ref(&prevtx), + &[], + None, + ); + (wallet_paying_tx(wallet, seed), contribution) } - fn confirmed_block_time(height: u32) -> ConfirmationBlockTime { - ConfirmationBlockTime { - block_id: BlockId { height, hash: bitcoin::BlockHash::from_byte_array([9u8; 32]) }, - confirmation_time: 100, + /// Both broadcast rounds of ours were discarded while the channel manager still listed the + /// channel — the monitor's events reached the handler ahead of the channel's close — and an + /// event for a listed channel only drops the rounds nothing broadcast, so the payment is left. + /// The close that follows finds no round of ours the monitor watches and fails it. + #[tokio::test] + async fn rounds_discarded_while_the_channel_is_listed_fail_at_close() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (first_tx, first) = splice_in_round(&wallet, 1); + let (bump_tx, bump) = splice_in_round(&wallet, 2); + let (first_txid, bump_txid) = (first_tx.compute_txid(), bump_tx.compute_txid()); + let rounds = [(first_txid, Some(first)), (bump_txid, Some(bump))]; + let id = record_broadcast_rounds(&wallet, &bump_tx, &rounds).await; + let funding_txid = Txid::from_byte_array([0xF0; 32]); + // The listed channel's pending rounds and funding, as LDK still reports them. + let held = [first_txid, bump_txid, funding_txid]; + for _ in 0..2 { + wallet.drop_abandoned_splice_rounds(channel_id, &held).await.unwrap(); } + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert_eq!(entry.candidates().len(), 2); + + // At the close the monitor has settled on the funding and watches neither round. + wallet.resolve_closed_channel_splice_rounds(channel_id, &[funding_txid]).await.unwrap(); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } if txid == bump_txid + )); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } - fn interactive_funding_details( - id: PaymentId, txid: Txid, amount_msat: Option, fee_paid_msat: Option, - ) -> PaymentDetails { - let kind = PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), - }; - PaymentDetails::new( - id, - kind, - amount_msat, - fee_paid_msat, - PaymentDirection::Outbound, - PaymentStatus::Pending, - ) + /// The close leaves a payment alone while the monitor watches a round of ours in its record: + /// the round may yet confirm, and wallet sync or the monitor's `DiscardFunding` resolves it. + #[tokio::test] + async fn closing_keeps_a_payment_whose_round_the_monitor_watches() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (first_tx, first) = splice_in_round(&wallet, 1); + let (bump_tx, bump) = splice_in_round(&wallet, 2); + let (first_txid, bump_txid) = (first_tx.compute_txid(), bump_tx.compute_txid()); + let rounds = [(first_txid, Some(first)), (bump_txid, Some(bump))]; + let id = record_broadcast_rounds(&wallet, &bump_tx, &rounds).await; + let funding_txid = Txid::from_byte_array([0xF0; 32]); + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[funding_txid, bump_txid]) + .await + .unwrap(); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert_eq!(entry.candidates().len(), 2); } - fn onchain_details(txid: Txid, status: ConfirmationStatus) -> PaymentDetails { - PaymentDetails::new( - PaymentId([42u8; 32]), - PaymentKind::Onchain { txid, status, tx_type: None }, - Some(1_000_000), - Some(500), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ) + /// The close does not touch a payment that no longer waits on an unconfirmed round: one whose + /// round confirmed keeps its state, and the entry a graduation cut short left behind is left + /// to the replayed graduation. + #[tokio::test] + async fn closing_leaves_a_confirmed_payment_alone() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_in_round(&wallet, 1); + let txid = tx.compute_txid(); + let id = record_broadcast_rounds(&wallet, &tx, &[(txid, Some(contribution))]).await; + let confirmed = ConfirmationStatus::Confirmed { + block_hash: bitcoin::BlockHash::all_zeros(), + height: 100, + timestamp: 1_700_000_000, + }; + wallet + .payment_stores + .payment_store() + .mutate(&id, |existing| { + let mut updated = existing?.clone(); + if let PaymentKind::Onchain { status, .. } = &mut updated.kind { + *status = confirmed; + } + updated.status = PaymentStatus::Succeeded; + Some(updated) + }) + .await + .unwrap(); + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); } - fn confirmed_status() -> ConfirmationStatus { - ConfirmationStatus::Confirmed { - block_hash: bitcoin::BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, - } + /// Failing the payment writes the record before it removes the entry; the close replayed after + /// the removal was lost finds the record failed already and finishes the removal. + #[tokio::test] + async fn closing_finishes_a_failure_cut_short() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_in_round(&wallet, 1); + let txid = tx.compute_txid(); + let id = record_broadcast_rounds(&wallet, &tx, &[(txid, Some(contribution))]).await; + wallet + .payment_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(); + 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()); } - #[test] - fn funding_reclassification_update_substitutes_the_confirmed_candidate() { - let confirmed_txid = Txid::from_byte_array([1u8; 32]); - let active_txid = Txid::from_byte_array([2u8; 32]); - let candidates = vec![ - FundingTxCandidate { - txid: confirmed_txid, - amount_msat: Some(2_000_000), - fee_paid_msat: Some(999), - }, - FundingTxCandidate { - txid: active_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - }, - ]; - let details = onchain_details(active_txid, ConfirmationStatus::Unconfirmed); - - // The record confirmed an earlier candidate: the update reports that candidate, not the - // active one. - let current = onchain_details(confirmed_txid, confirmed_status()); - let update = funding_reclassification_update(details.clone(), &candidates, Some(¤t)); - assert_eq!(update.txid, Some(confirmed_txid)); - assert_eq!(update.amount_msat, Some(Some(2_000_000))); - assert_eq!(update.fee_paid_msat, Some(Some(999))); - - // A confirmed candidate we did not contribute to still substitutes, with empty figures — - // the same figures a confirmation arriving after classification would report. - let uncontributed = vec![FundingTxCandidate { - txid: confirmed_txid, - amount_msat: None, - fee_paid_msat: None, - }]; - let update = - funding_reclassification_update(details.clone(), &uncontributed, Some(¤t)); - assert_eq!(update.txid, Some(confirmed_txid)); - assert_eq!(update.amount_msat, Some(None)); - assert_eq!(update.fee_paid_msat, Some(None)); + /// The close resolves every record of the channel — two splices signed under different + /// first-candidate ids, as two negotiations from the same coins are — each by the rounds the + /// monitor holds: nothing of ours here, so both are failed. + #[tokio::test] + async fn closing_resolves_every_record_of_the_channel() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (first_tx, contribution) = splice_in_round(&wallet, 1); + let (second_tx, _) = splice_in_round(&wallet, 2); + let (first_txid, second_txid) = (first_tx.compute_txid(), second_tx.compute_txid()); + let first_id = record_broadcast_rounds( + &wallet, + &first_tx, + &[(first_txid, Some(contribution.clone()))], + ) + .await; + let second_id = + record_broadcast_rounds(&wallet, &second_tx, &[(second_txid, Some(contribution))]) + .await; + let funding_txid = Txid::from_byte_array([0xF0; 32]); + wallet.resolve_closed_channel_splice_rounds(channel_id, &[funding_txid]).await.unwrap(); + for id in [first_id, second_id] { + let payment = wallet + .payment_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()); + } } - #[test] - fn funding_reclassification_update_keeps_the_active_candidate() { - let active_txid = Txid::from_byte_array([2u8; 32]); - let candidates = vec![FundingTxCandidate { - txid: active_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - }]; - let details = onchain_details(active_txid, ConfirmationStatus::Unconfirmed); - - // No record yet: the update describes the active candidate. - let update = funding_reclassification_update(details.clone(), &candidates, None); - assert_eq!(update.txid, Some(active_txid)); - assert_eq!(update.amount_msat, Some(Some(1_000_000))); - - // An unconfirmed record: still the active candidate (RBF rotation). - let unconfirmed = - onchain_details(Txid::from_byte_array([1u8; 32]), ConfirmationStatus::Unconfirmed); - let update = - funding_reclassification_update(details.clone(), &candidates, Some(&unconfirmed)); - assert_eq!(update.txid, Some(active_txid)); - - // The record confirmed the active candidate itself: nothing to substitute. - let current = onchain_details(active_txid, confirmed_status()); - let update = funding_reclassification_update(details.clone(), &candidates, Some(¤t)); - assert_eq!(update.txid, Some(active_txid)); - assert_eq!(update.amount_msat, Some(Some(1_000_000))); - - // A confirmed txid outside the candidate history (e.g. the record is an unrelated - // same-id payment): fall back to the active candidate; `PaymentDetails::update` keeps - // the confirmed figures in place on mismatch. - let foreign = onchain_details(Txid::from_byte_array([9u8; 32]), confirmed_status()); - let update = funding_reclassification_update(details, &candidates, Some(&foreign)); - assert_eq!(update.txid, Some(active_txid)); - } - - /// A funding-typed (re)classification of a record already classified as interactive funding - /// carries nothing the record doesn't have — LDK re-broadcasts a promoted-but-unconfirmed - /// splice through its generic funding path with wallet-view figures — so the update must - /// move nothing. - #[test] - fn funding_reclassification_update_skips_funding_over_interactive_funding() { - let txid = Txid::from_byte_array([1u8; 32]); - let payment_id = PaymentId(txid.to_byte_array()); - let current = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); - - let rebroadcast = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(TransactionType::Funding { channels: vec![] }), - }, - Some(10_000_000), - Some(0), - PaymentDirection::Inbound, - PaymentStatus::Pending, + /// The facts a channel would record for a splice candidate: the pre-splice funding output it + /// spends, the new funding output it creates, and this node's share of it. + fn splice_candidate_facts( + txid: Txid, spends: Txid, channel: &Channel, figures: LocalFundingFigures, + ) -> (ChannelTxFacts, ChannelTxFacts) { + let spent = ChannelTxFacts::new(spends).with_outputs( + channel, + None, + ChannelOutputRole::Funding, + [0], ); - - let update = funding_reclassification_update(rebroadcast, &[], Some(¤t)); - let mut updated = current.clone(); - assert!(!updated.update(update), "the rebroadcast must not move the record"); - assert_eq!(updated, current); + let created = + ChannelTxFacts::new(txid).with_outputs(channel, None, ChannelOutputRole::Funding, [0]); + (spent, ChannelTxFacts { local_figures: Some(figures), ..created }) } - /// Graduation must decide from the live record and write only the status: a pending-store - /// snapshot taken before a concurrent classification landed must not roll the record's - /// figures back when the payment graduates to `Succeeded`. #[tokio::test] - async fn graduation_preserves_classified_figures() { + async fn a_reported_share_of_a_transaction_outranks_the_wallets_view() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; + let wallet = new_test_wallet(Arc::clone(&store), false).await; - let txid = Txid::from_byte_array([4u8; 32]); - let payment_id = PaymentId(txid.to_byte_array()); - let confirmed = ConfirmationStatus::Confirmed { - block_hash: bitcoin::BlockHash::from_byte_array([9u8; 32]), - height: 5, - timestamp: 100, - }; - let tx_type = Some(TransactionType::InteractiveFunding { channels: vec![] }); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let tx = wallet_paying_tx(&wallet, 4); + let txid = tx.compute_txid(); + insert_unconfirmed_tx(&wallet, tx.clone()); - // The live record carries the classification: contribution-derived figures, confirmed. - let mut recorded = - interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); - recorded.kind = PaymentKind::Onchain { txid, status: confirmed, tx_type: tx_type.clone() }; - recorded.latest_update_timestamp = 0; - wallet.payment_store.insert_or_update(recorded).await.unwrap(); + let figures = LocalFundingFigures { + funding_payment_id: PaymentId([31u8; 32]), + amount_msat: Some(77_000), + fee_paid_msat: Some(1_100), + direction: PaymentDirection::Outbound, + }; + // The wallet reads a shared funding input as wholly this node's, so its view of the + // transaction is a different one, which is the point of preferring the reported share. + assert_ne!( + wallet.onchain_payment_fields(&tx), + (figures.amount_msat, figures.fee_paid_msat, figures.direction), + ); - // The pending entry embeds a stale snapshot: wallet-derived figures recorded before the - // classification above landed. - let mut stale = interactive_funding_details(payment_id, txid, Some(0), Some(0)); - stale.kind = PaymentKind::Onchain { txid, status: confirmed, tx_type }; - let entry = PendingPaymentDetails::new(stale, Vec::new(), Vec::new()); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + let (spent, created) = splice_candidate_facts( + txid, + tx.input[0].previous_output.txid, + &channel, + figures.clone(), + ); + wallet.record_channel_tx_facts(spent).await.unwrap(); + wallet.record_channel_tx_facts(created).await.unwrap(); - let block_id = - |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; - let event = WalletEvent::ChainTipChanged { old_tip: block_id(9), new_tip: block_id(10) }; + let event = + WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx.clone()), old_block_time: None }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Succeeded); - assert_eq!( - payment.amount_msat, - Some(2_000_000), - "graduation must not roll figures back to the snapshot's" - ); - assert_eq!(payment.fee_paid_msat, Some(999)); - assert!(payment.latest_update_timestamp > 0, "the graduation write must timestamp"); - assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + // The reported share names the funding payment the transaction belongs to, so the record + // is filed under that payment rather than under the transaction's own id. + assert!(wallet + .payment_stores + .payment_store() + .get(&PaymentId(txid.to_byte_array())) + .await + .unwrap() + .is_none()); + let payment = wallet + .payment_stores + .payment_store() + .get(&figures.funding_payment_id) + .await + .unwrap() + .expect("wallet sync records the transaction"); + assert_eq!(payment.amount_msat, figures.amount_msat); + assert_eq!(payment.fee_paid_msat, figures.fee_paid_msat); + assert_eq!(payment.direction, figures.direction); + match payment.kind { + PaymentKind::Onchain { + tx_type: Some(TransactionType::InteractiveFunding { channels }), + .. + } => { + assert_eq!(channels, vec![channel]); + }, + kind => panic!("unexpected kind {:?}", kind), + } } - /// When the live record has diverged from the pending-store snapshot — here the snapshot - /// says Confirmed at graduation depth while the record says Unconfirmed — graduation must - /// decline and keep the entry rather than force-writing `Succeeded` from stale state. The - /// seeded divergence is synthetic (no current production writer downgrades a record's - /// confirmation); the test pins the hardening that comes with deciding from the live record. #[tokio::test] - async fn graduation_declines_on_diverged_record() { + async fn an_unnamed_transaction_is_named_once_its_facts_arrive() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; + let wallet = new_test_wallet(Arc::clone(&store), false).await; - let txid = Txid::from_byte_array([5u8; 32]); - let payment_id = PaymentId(txid.to_byte_array()); - let confirmed = ConfirmationStatus::Confirmed { - block_hash: bitcoin::BlockHash::from_byte_array([9u8; 32]), - height: 5, - timestamp: 100, + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let sweep = wallet_paying_tx(&wallet, 3); + let sweep_txid = sweep.compute_txid(); + let swept = sweep.input[0].previous_output.txid; + insert_unconfirmed_tx(&wallet, sweep.clone()); + + // Wallet sync sees the sweep before the channel gets to report what it resolved. + let event = WalletEvent::TxUnconfirmed { + txid: sweep_txid, + tx: Arc::new(sweep.clone()), + old_block_time: None, }; + wallet.update_payment_store(vec![event]).await.unwrap(); - // The live record is Unconfirmed... - let recorded = interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); - wallet.payment_store.insert_or_update(recorded).await.unwrap(); + let payment_id = PaymentId(sweep_txid.to_byte_array()); + let unnamed = wallet + .payment_stores + .payment_store() + .get(&payment_id) + .await + .unwrap() + .expect("wallet sync records the transaction"); + assert!( + matches!(unnamed.kind, PaymentKind::Onchain { tx_type: None, .. }), + "nothing is recorded about the transaction yet, so it cannot be named: {:?}", + unnamed.kind, + ); - // ...while the pending entry's snapshot claims a graduation-deep confirmation. - let mut snapshot = - interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); - snapshot.kind = PaymentKind::Onchain { - txid, - status: confirmed, - tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), - }; - let entry = PendingPaymentDetails::new(snapshot, Vec::new(), Vec::new()); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + wallet + .record_channel_tx_facts(ChannelTxFacts::new(swept).with_outputs( + &channel, + None, + ChannelOutputRole::Spendable, + [0], + )) + .await + .unwrap(); let block_id = |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; - let event = WalletEvent::ChainTipChanged { old_tip: block_id(9), new_tip: block_id(10) }; + let event = WalletEvent::ChainTipChanged { old_tip: block_id(1), new_tip: block_id(2) }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); - assert_eq!( - payment.status, - PaymentStatus::Pending, - "a diverged snapshot must not force-graduate the record" - ); - assert!(matches!( - payment.kind, - PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } - )); - assert!( - wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some(), - "the entry must survive for future events to drive" - ); + let named = wallet + .payment_stores + .payment_store() + .get(&payment_id) + .await + .unwrap() + .expect("the record stays"); + match named.kind { + PaymentKind::Onchain { tx_type: Some(TransactionType::Sweep { channels }), .. } => { + assert_eq!(channels, vec![channel]); + }, + kind => panic!("unexpected kind {:?}", kind), + } } - /// A middle RBF candidate must map back to the funding record: it is neither the record's - /// id (derived from the first candidate), nor its current txid (the active candidate), nor - /// in `conflicting_txids` (it never got a `TxReplaced` event of its own). + /// The funding output a channel reports is what names the transaction that opens it: nothing + /// else has to say so. #[tokio::test] - async fn find_payment_by_txid_maps_candidate_txids() { + async fn a_funding_transaction_is_named_by_the_output_it_creates() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; + let wallet = new_test_wallet(Arc::clone(&store), false).await; - let txid1 = Txid::from_byte_array([1u8; 32]); - let txid2 = Txid::from_byte_array([2u8; 32]); - let 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), - }, - FundingTxCandidate { - txid: txid2, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(600), - }, - FundingTxCandidate { - txid: txid3, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(700), - }, - ]; - let details = interactive_funding_details(payment_id, txid3, Some(1_000_000), Some(700)); - let entry = PendingPaymentDetails::new(details, Vec::new(), candidates); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + 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()); - // 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 + .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), + } } - /// Removing a payment must also drop its pending-store entry. The entry indexes the - /// payment's txids (current, conflicting, and candidates), so leaving it behind keeps - /// resolving those txids to the removed record — routing later wallet events to a payment - /// that no longer exists — and nothing else ever cleans it up, since graduation only - /// removes entries whose record is still live. + /// One transaction can open several channels, each of which reports only the output that + /// funds it. The reports of one transaction are held together, so the payment names every + /// channel opened — which a report naming the transaction outright could not do, as the + /// second channel's would contradict the first's. #[tokio::test] - async fn remove_payment_drops_pending_entry() { + async fn a_batched_funding_names_every_channel_it_opens() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - let txid = Txid::from_byte_array([1u8; 32]); - let conflicting_txid = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId(txid.to_byte_array()); - - // A Pending outbound on-chain payment with a recorded conflict (e.g. an RBF round). - let details = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type: None }, - Some(1_000), - Some(100), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - wallet.payment_store.insert_or_update(details.clone()).await.unwrap(); - let entry = PendingPaymentDetails::new(details, vec![conflicting_txid], Vec::new()); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.remove_payment(&payment_id).await.unwrap(); + 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; - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none()); - assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); - assert_eq!(wallet.find_payment_by_txid(conflicting_txid).await.unwrap(), None); + 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), + } + } - // A replacement event for the removed transaction must skip rather than resolve to the - // removed record: the `TxReplaced` arm asserts the resolved record exists. - let event = WalletEvent::TxReplaced { - txid, - tx: Arc::new(dummy_tx()), - conflicts: vec![(0, conflicting_txid)], - }; - wallet.update_payment_store(vec![event]).await.unwrap(); - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none()); + /// The node's channel state as a test dictates it: the channels its channel manager lists, + /// the monitors its chain monitor holds, and the outputs its sweeper tracks. + #[derive(Default)] + struct TestChannelState { + channels: Vec, + monitors: Vec, + tracked_outputs: Vec>, } - /// Payments without a pending-store entry — lightning payments, and on-chain payments that - /// already graduated — must remove cleanly: the unconditional pending-store removal relies - /// on removing a missing key being a no-op. - #[tokio::test] - async fn remove_payment_without_pending_entry() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; + /// A stand-in for the node's channel state. `None` stands for the state being unreachable, + /// as it is while the node is built and while it is torn down. + struct TestLiveness(Mutex>); - let payment_id = PaymentId([9u8; 32]); - let details = PaymentDetails::new( - payment_id, - PaymentKind::Bolt11 { - hash: lightning_types::payment::PaymentHash([0u8; 32]), - preimage: None, - secret: None, - counterparty_skimmed_fee_msat: None, - }, - Some(1_000), - None, - PaymentDirection::Outbound, - PaymentStatus::Succeeded, - ); - wallet.payment_store.insert_or_update(details).await.unwrap(); + impl TestLiveness { + fn holding(state: TestChannelState) -> Arc { + Arc::new(Self(Mutex::new(Some(state)))) + } - wallet.remove_payment(&payment_id).await.unwrap(); - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none()); + fn holding_nothing() -> Arc { + Self::holding(TestChannelState::default()) + } - // Removing an id known to neither store is also a no-op rather than an error. - wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap(); + fn unreachable() -> Arc { + Arc::new(Self(Mutex::new(None))) + } } - /// A funding-typed broadcast that doesn't touch the on-chain wallet must not be recorded. - /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding - /// path, so a splice the interactive-funding classification deliberately declined — no local - /// contribution, or none of the moved funds are the wallet's — would otherwise come back as - /// a spurious zero-amount record that nothing ever confirms. - #[tokio::test] - async fn funding_broadcast_without_wallet_activity_is_not_recorded() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; - let tx_type = TransactionType::Funding { channels: vec![] }; - - // No inputs or outputs involve the wallet: nothing to record. - wallet.classify_funding(&dummy_tx(), &channels, tx_type.clone()).await.unwrap(); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); - assert!(wallet.pending_payment_store.list_filter(|_| true).await.is_empty()); - - // A computable fee is not wallet participation. The wallet can resolve a splice's shared - // input whenever the previous funding transaction touched it (e.g. it funded the original - // channel open), so it derives the splice's fee even when no wallet funds move. - let prev_funding_outpoint = OutPoint { txid: Txid::from_byte_array([8u8; 32]), vout: 0 }; - wallet.inner.lock().unwrap().insert_txout( - prev_funding_outpoint, - TxOut { value: Amount::from_sat(100_000), script_pubkey: ScriptBuf::new() }, - ); - let splice_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: vec![bitcoin::TxIn { - previous_output: prev_funding_outpoint, - ..Default::default() - }], - output: vec![TxOut { - value: Amount::from_sat(99_000), - script_pubkey: ScriptBuf::new(), - }], - }; - wallet.classify_funding(&splice_tx, &channels, tx_type.clone()).await.unwrap(); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); + impl ChannelLiveness for TestLiveness { + fn live_channels(&self) -> Option> { + let locked = self.0.lock().unwrap(); + let state = locked.as_ref()?; + Some(live_channels_of( + state.channels.iter().copied(), + state.monitors.iter().copied(), + state.tracked_outputs.iter().copied(), + )) + } + } + + fn block_id_at(height: u32) -> BlockId { + let mut hash = [0u8; 32]; + hash[..4].copy_from_slice(&height.to_le_bytes()); + BlockId { height, hash: bitcoin::BlockHash::from_byte_array(hash) } + } - // Control: a funding transaction the wallet participates in is still recorded. + /// Builds a transaction spending `outpoint` into the wallet. + fn tx_spending(wallet: &Wallet, outpoint: OutPoint) -> Transaction { let script_pubkey = wallet .inner .lock() @@ -4101,317 +10934,407 @@ mod tests { .reveal_next_address(KeychainKind::External) .address .script_pubkey(); - let funded_tx = Transaction { + Transaction { version: bitcoin::transaction::Version::TWO, lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], + input: vec![bitcoin::TxIn { previous_output: outpoint, ..Default::default() }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + } + } + + /// A wallet that recorded a channel's funding transaction and has since seen that funding + /// spent by a transaction confirmed at height 10, which no pending payment refers to. + /// Everything but the node's channel state and the chain tip is then in the state that lets + /// the recorded facts go. + async fn wallet_with_a_spent_funding( + store: Arc, channel: &Channel, + ) -> (Arc, Txid) { + let wallet = new_test_wallet(store, false).await; + let funding_txid = Txid::from_byte_array([41u8; 32]); + wallet + .record_channel_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + channel, + None, + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(); + let close = tx_spending(&wallet, OutPoint { txid: funding_txid, vout: 0 }); + insert_confirmed_tx(&wallet, close, 10); + (wallet, funding_txid) + } + + /// Runs the chain tip pass at `height`, which is where recorded facts are dropped. + async fn chain_tip_changed(wallet: &Wallet, height: u32) { + { + let mut locked = wallet.inner.lock().unwrap(); + let chain = locked.latest_checkpoint().insert(block_id_at(height)); + locked.apply_update(Update { chain: Some(chain), ..Default::default() }).unwrap(); + } + let event = WalletEvent::ChainTipChanged { + old_tip: block_id_at(height - 1), + new_tip: block_id_at(height), }; - wallet.classify_funding(&funded_tx, &channels, tx_type).await.unwrap(); - let payments = wallet.payment_store.list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1); - assert_eq!(payments[0].id, PaymentId(funded_tx.compute_txid().to_byte_array())); + wallet.update_payment_store(vec![event]).await.unwrap(); } - /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding - /// path: same txid, but typed as a plain funding transaction with wallet-view figures and no - /// contribution metadata. The rebroadcast must not overwrite the contribution-derived - /// figures or the interactive-funding classification — neither while the record is - /// unconfirmed nor once it confirmed under that same txid, where updates naming the - /// confirmed txid may otherwise move figures. + /// A chain tip far enough past both the age cap and the burial of the spend above. + const LONG_AFTER: u32 = 100_000; + #[tokio::test] - async fn funding_rebroadcast_keeps_interactive_funding_classification() { + async fn the_facts_of_a_resolved_channel_are_reclaimed() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::holding_nothing()); - // The rebroadcast passes the wallet-activity guard: a splice-in funds the new channel - // output partly from the wallet, so the wallet sees movement. - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - }; - let txid = tx.compute_txid(); - let payment_id = PaymentId(txid.to_byte_array()); + chain_tip_changed(&wallet, LONG_AFTER).await; - let candidates = vec![FundingTxCandidate { - txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - }]; - let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_none(), + "nothing holds the channel and its funding is long spent", + ); + // The walk went all the way round, so the store's size is known from here on. + assert_eq!(wallet.facts_retention.counted(), Some(0)); + } - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; - let tx_type = TransactionType::Funding { channels: vec![] }; - - async fn assert_unchanged(wallet: &Wallet, payment_id: PaymentId, confirmed: bool) { - let payments = wallet.payment_store.list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1, "the rebroadcast must not mint a second record"); - let payment = &payments[0]; - assert_eq!(payment.id, payment_id); - assert_eq!(payment.amount_msat, Some(1_000_000)); - assert_eq!(payment.fee_paid_msat, Some(500)); - match &payment.kind { - PaymentKind::Onchain { - status, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } => assert_eq!(matches!(status, ConfirmationStatus::Confirmed { .. }), confirmed), - kind => panic!("unexpected kind {:?}", kind), - } + #[tokio::test] + async fn the_facts_of_a_channel_the_node_still_holds_are_kept() { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + + let still_held = [ + ( + "the channel manager lists it", + TestChannelState { channels: vec![channel_id], ..Default::default() }, + ), + ( + "the chain monitor holds its monitor", + TestChannelState { monitors: vec![channel_id], ..Default::default() }, + ), + ( + "the sweeper tracks an output of it", + TestChannelState { tracked_outputs: vec![Some(channel_id)], ..Default::default() }, + ), + ]; + + for (why, state) in still_held { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::holding(state)); + + chain_tip_changed(&wallet, LONG_AFTER).await; + + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_some(), + "the facts are still needed: {}", + why, + ); } + } - wallet.classify_funding(&tx, &channels, tx_type.clone()).await.unwrap(); - assert_unchanged(&wallet, payment_id, false).await; + #[tokio::test] + async fn nothing_is_dropped_while_the_nodes_channels_cannot_be_consulted() { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; - // Confirm the record, then replay the rebroadcast: a monitor-update completion can race - // wallet sync around confirmation. - let event = WalletEvent::TxConfirmed { - txid, - tx: Arc::new(tx.clone()), - block_time: confirmed_block_time(5), - old_block_time: None, - }; - wallet.update_payment_store(vec![event]).await.unwrap(); - wallet.classify_funding(&tx, &channels, tx_type).await.unwrap(); - assert_unchanged(&wallet, payment_id, true).await; + // Before the node's channel state is handed over, which is how the wallet starts out. + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); + + // And once it can no longer be reached, as while the node is torn down. + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::unreachable()); + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); + } + + #[tokio::test] + async fn the_facts_of_a_channel_are_kept_until_the_age_cap() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::holding_nothing()); + + // The facts were recorded at height 0, before the spend moved the wallet's tip. + chain_tip_changed(&wallet, CHANNEL_TX_FACTS_RETENTION_BLOCKS - 1).await; + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_some(), + "a block short of the cap is short of it", + ); + + chain_tip_changed(&wallet, CHANNEL_TX_FACTS_RETENTION_BLOCKS).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_none()); } - /// Barrier test, classification-first ordering: wallet sync's confirmation handling must - /// wait for classification's two-store write pair. Classification is parked between its - /// payment-store and pending-store writes (the torn window) and only then is the - /// confirmation of the replacement candidate dispatched; unless the sync arm holds the - /// cross-store lock from payment-id resolution onwards, it resolves the id against the - /// still-missing pending index and mints a duplicate record keyed by the event txid. #[tokio::test] - async fn funding_confirmation_waits_for_classification() { - let gated = NamespaceGatedStore::new(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); - let store: Arc = Arc::new(DynStoreWrapper(gated.clone())); + async fn the_facts_of_an_unspent_funding_are_kept() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.set_channel_liveness(TestLiveness::holding_nothing()); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; - let txid1 = Txid::from_byte_array([1u8; 32]); - let txid2 = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId(txid1.to_byte_array()); - let candidates = vec![ - FundingTxCandidate { - txid: txid1, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - }, - FundingTxCandidate { - txid: txid2, - amount_msat: Some(2_000_000), - fee_paid_msat: Some(999), - }, - ]; - let details = interactive_funding_details(payment_id, txid2, Some(2_000_000), Some(999)); - - // Hold the gate so classification parks on its pending-store write: the payment record - // is persisted, the pending entry is not — the torn window a concurrent confirmation - // must not observe. - let gate_guard = gated.gate.write().await; - let classification = tokio::spawn({ - let wallet = Arc::clone(&wallet); - let candidates = candidates.clone(); - async move { wallet.persist_funding_payment(details, candidates).await } - }); - gated.parked.notified().await; + let funding_txid = Txid::from_byte_array([43u8; 32]); + wallet + .record_channel_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + &channel, + None, + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(); - // Only now dispatch the confirmation of the candidate that won. - let event = WalletEvent::TxConfirmed { - txid: txid2, - tx: Arc::new(dummy_tx()), - block_time: confirmed_block_time(5), - old_block_time: None, - }; - let sync = tokio::spawn({ - let wallet = Arc::clone(&wallet); - async move { wallet.update_payment_store(vec![event]).await } - }); + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_some(), + "a funding output nothing has been seen to spend can still be spent", + ); - // Liveness sanity only (both pre- and post-fix stall here): while classification is - // parked, no second record may have been committed. - tokio::time::sleep(Duration::from_millis(250)).await; - let payment_keys = KVStore::list( - &*store, - PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, - ) - .await - .unwrap(); - assert!(payment_keys.len() <= 1); + // A spend that has yet to be buried twice over does not settle it either. + let close = tx_spending(&wallet, OutPoint { txid: funding_txid, vout: 0 }); + insert_confirmed_tx(&wallet, close, LONG_AFTER - 2 * ANTI_REORG_DELAY + 1); + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); + } - drop(gate_guard); - classification.await.unwrap().unwrap(); - sync.await.unwrap().unwrap(); + #[tokio::test] + async fn the_facts_a_pending_payment_still_needs_are_kept() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::holding_nothing()); + + // A pending record listing the funding transaction among its candidates: its + // classification can still be written, and these facts are what would write it. + let id = PaymentId([44u8; 32]); + let entry = PendingPaymentDetails::new( + funding_payment(id, Txid::from_byte_array([45u8; 32]), PaymentStatus::Pending), + Vec::new(), + vec![FundingTxCandidate { + txid: funding_txid, + amount_msat: Some(1_000), + fee_paid_msat: Some(10), + awaiting_broadcast: false, + }], + ); + wallet.payment_stores.pending_payment_store().insert(entry).await.unwrap(); - // Both writers converge on the classified record: the confirmation refreshes it in - // place with the confirmed candidate's figures rather than minting a second record - // keyed by the event txid. - let payment_keys = KVStore::list( - &*store, - PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, - ) - .await - .unwrap(); - assert_eq!(payment_keys.len(), 1, "the confirmation must not mint a duplicate record"); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.id, payment_id); - assert_eq!(payment.amount_msat, Some(2_000_000)); - assert_eq!(payment.fee_paid_msat, Some(999)); - match &payment.kind { - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Confirmed { .. }, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - } => assert_eq!(*txid, txid2), - kind => panic!("unexpected kind {:?}", kind), - } + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); + + // Once the payment is no longer pending, nothing refers to the transaction anymore. + wallet.payment_stores.pending_payment_store().remove(&id).await.unwrap(); + chain_tip_changed(&wallet, LONG_AFTER + 1).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_none()); + } + + #[tokio::test] + async fn a_record_a_producer_changed_since_the_check_is_not_dropped() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + + let evaluated = wallet.channel_tx_facts(&funding_txid).await.expect("recorded above"); + + // A producer reports a further output of the same transaction between the decision and + // the removal — the way a channel comes back into play for a record already judged + // disposable, since whatever makes it live again reports what it resolved. + let reopened = Channel { counterparty_node_id, channel_id: ChannelId([9u8; 32]) }; + wallet + .record_channel_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + &reopened, + None, + ChannelOutputRole::Spendable, + [1], + )) + .await + .unwrap(); + + assert!(!wallet.drop_recorded_facts(evaluated).await.unwrap()); + let kept = wallet.channel_tx_facts(&funding_txid).await.expect("the record stays"); + assert_eq!(kept.outputs.len(), 2); } - /// Barrier test, sync-first ordering: classification must wait for wallet sync's complete - /// decision-plus-write sequence. Wallet sync is parked inside its generic-fallback window — - /// past the funding-status check that found no record, before its writes — by holding the - /// BDK wallet lock the fallback needs. Unless the sync arm holds the cross-store lock - /// across that window, classification lands in between and the fallback's stale merge - /// overwrites the contribution-derived figures with wallet-derived ones. - #[tokio::test(flavor = "multi_thread")] - async fn funding_classification_waits_for_wallet_sync() { + #[tokio::test] + async fn a_full_store_leaves_a_new_transaction_undescribed() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; - let txid = Txid::from_byte_array([3u8; 32]); - let payment_id = PaymentId(txid.to_byte_array()); - let candidates = vec![FundingTxCandidate { - txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - }]; - let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); - - // Park wallet sync inside its fallback window: the TxUnconfirmed arm reads no wallet - // state before that point, so it passes the funding-status check (no record exists yet) - // and then blocks on the wallet lock held here. The sleeps give the tasks time to reach - // their parking spots; they make the pre-fix failure deterministic, while the fixed - // code converges to the same final state under any arrival order. - let inner_guard = wallet.inner.lock().unwrap(); - let sync = tokio::spawn({ - let wallet = Arc::clone(&wallet); - let event = - WalletEvent::TxUnconfirmed { txid, tx: Arc::new(dummy_tx()), old_block_time: None }; - async move { wallet.update_payment_store(vec![event]).await } - }); - tokio::time::sleep(Duration::from_millis(250)).await; + let admitted = Txid::from_byte_array([46u8; 32]); + assert_eq!( + wallet + .record_channel_tx_facts(ChannelTxFacts::new(admitted).with_outputs( + &channel, + None, + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(), + FactsRecordOutcome::Recorded, + ); - let classification = tokio::spawn({ - let wallet = Arc::clone(&wallet); - let candidates = candidates.clone(); - async move { wallet.persist_funding_payment(details, candidates).await } - }); - tokio::time::sleep(Duration::from_millis(250)).await; + // A walk of the store found it as full as it may get. + wallet.facts_retention.walk_completed(CHANNEL_TX_FACTS_MAX_RECORDS); - drop(inner_guard); - sync.await.unwrap().unwrap(); - classification.await.unwrap().unwrap(); + // What the node already took on is still kept up to date... + assert_eq!( + wallet + .record_channel_tx_facts(ChannelTxFacts::new(admitted).with_outputs( + &channel, + None, + ChannelOutputRole::Anchor, + [1], + )) + .await + .unwrap(), + FactsRecordOutcome::Recorded, + ); + let kept = wallet.channel_tx_facts(&admitted).await.expect("the record stays"); + assert_eq!(kept.outputs.len(), 2); - // Both writers converge on one record carrying the classification: the generic - // fallback must not clobber the contribution-derived figures with its wallet-derived - // view of the transaction. - let payment_keys = KVStore::list( - &*store, - PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, - ) - .await - .unwrap(); - assert_eq!(payment_keys.len(), 1); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.id, payment_id); + // ...while a transaction it holds no record of is refused, and said to be refused. + let refused = Txid::from_byte_array([47u8; 32]); assert_eq!( - payment.amount_msat, - Some(1_000_000), - "wallet sync's fallback must not overwrite contribution figures" + wallet + .record_channel_tx_facts(ChannelTxFacts::new(refused).with_outputs( + &channel, + None, + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(), + FactsRecordOutcome::Incomplete, + ); + assert!(wallet.channel_tx_facts(&refused).await.is_none()); + + // The cost of the refusal is a transaction reported without a classification, rather + // than one reported as something it may not be. + let close = tx_spending(&wallet, OutPoint { txid: refused, vout: 0 }); + let close_txid = close.compute_txid(); + insert_unconfirmed_tx(&wallet, close.clone()); + wallet + .update_payment_store(vec![WalletEvent::TxUnconfirmed { + txid: close_txid, + tx: Arc::new(close), + old_block_time: None, + }]) + .await + .unwrap(); + let payment = wallet + .payment_stores + .payment_store() + .get(&PaymentId(close_txid.to_byte_array())) + .await + .unwrap() + .expect("wallet sync records the transaction"); + assert!( + matches!(payment.kind, PaymentKind::Onchain { tx_type: None, .. }), + "unexpected kind {:?}", + payment.kind, ); - assert_eq!(payment.fee_paid_msat, Some(500)); - assert!(matches!( - &payment.kind, - PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } - )); } #[tokio::test] - async fn max_funding_estimate_keeps_reserved_change_address_used() { + 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(store, false).await; - let (funding_tx, block_id) = { - let mut locked_wallet = wallet.inner.lock().unwrap(); - let outputs = vec![TxOut { - value: Amount::from_sat(200_000), - script_pubkey: locked_wallet - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(), - }]; - let funding_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: outputs, - }; - let block_id = BlockId { - height: locked_wallet.latest_checkpoint().height() + 1, - hash: bitcoin::BlockHash::from_byte_array([42; 32]), - }; - (funding_tx, block_id) - }; - let funding_txid = funding_tx.compute_txid(); - let mut tx_update = TxUpdate::default(); - tx_update.txs = vec![Arc::new(funding_tx)]; - tx_update.anchors = - [(ConfirmationBlockTime { block_id, confirmation_time: 1 }, funding_txid)].into(); - let chain = CheckPoint::from_block_ids([ - wallet.inner.lock().unwrap().latest_checkpoint().block_id(), - block_id, - ]) - .unwrap(); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([23u8; 32]); + let txid = Txid::from_byte_array([24u8; 32]); wallet - .apply_update(Update { tx_update, chain: Some(chain), ..Default::default() }) + .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(); - // Reserve the first change address the way BDK does for a pending transaction whose - // change output the wallet has not indexed yet. - { - let mut locked_wallet = wallet.inner.lock().unwrap(); - assert_eq!(locked_wallet.reveal_next_address(KeychainKind::Internal).index, 0); - assert!(locked_wallet.mark_used(KeychainKind::Internal, 0)); - } + let stores = wallet.payment_stores.lock().await; + wallet + .upsert_pending_payment( + &stores, + funding_payment(id, txid, PaymentStatus::Pending), + Vec::new(), + ) + .await + .unwrap(); + drop(stores); - // The reserve must exceed the dust limit so the estimate includes the anchor reserve - // output, which is what pays to the reserved change address. - let anchor_reserve_sats = 25_000; - assert!(anchor_reserve_sats > DUST_LIMIT_SATS); - wallet.get_max_funding_amount(anchor_reserve_sats, FeeRate::from_sat_per_kwu(250)).unwrap(); + // ...and the splice behind the intent confirmed, so the leftover intent record is removed + // rather than left to look like a splice still in flight after a restart. + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } - let mut locked_wallet = wallet.inner.lock().unwrap(); - assert!( - locked_wallet.spk_index().is_used(KeychainKind::Internal, 0), - "estimating the max funding amount must not free a reserved change address", - ); - assert_ne!(locked_wallet.next_unused_address(KeychainKind::Internal).index, 0); + #[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); } } diff --git a/src/wallet/payment_stores.rs b/src/wallet/payment_stores.rs new file mode 100644 index 0000000000..7a17d124d8 --- /dev/null +++ b/src/wallet/payment_stores.rs @@ -0,0 +1,207 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! The wallet's payment stores behind one API, so that every write the wallet makes to them +//! happens under the lock that keeps a payment record and its pending-store entry consistent. + +use std::future::Future; +use std::ops::Deref; +use std::sync::Arc; + +use lightning::ln::channelmanager::PaymentId; +use lightning::util::persist::PageToken; + +use crate::data_store::DataStorePage; +use crate::payment::{PaymentDetails, PendingPaymentDetails}; +use crate::types::{PaymentStore, PendingPaymentStore}; +use crate::Error; + +/// The wallet's payment store and pending payment store, with the lock serializing their writers. +/// +/// The writers must observe the payment record and its pending-store entry (candidate history +/// included) as one consistent unit: wallet sync's event arms and the funding-record writers each +/// hold the lock from payment-id resolution through their last write. Without the lock, a +/// confirmation landing between a writer's two store writes sees the record but not the candidate +/// history — resolving the wrong payment id or stamping the confirmed candidate with another +/// candidate's figures — and a funding-record write landing inside an arm's decision sequence gets +/// overwritten by the arm's stale generic fallback. +/// +/// The writes are methods of [`PaymentStoresGuard`], which only [`Self::lock`] hands out, so a +/// write compiles only for a holder of the lock. The reads are methods of this type and take no +/// lock; a caller whose write depends on what it read takes the lock first and reads through the +/// guard. +pub(super) struct PaymentStores { + payment_store: Arc, + pending_payment_store: Arc, + update_lock: tokio::sync::Mutex<()>, +} + +/// Exclusive access to the writers of a [`PaymentStores`], held by the holder of its lock and by +/// no one else. It dereferences to the stores, so their reads are available under the lock too. +#[must_use = "dropping the guard releases the lock at once"] +pub(super) struct PaymentStoresGuard<'a> { + stores: &'a PaymentStores, + _guard: tokio::sync::MutexGuard<'a, ()>, +} + +impl PaymentStores { + pub(super) fn new( + payment_store: Arc, pending_payment_store: Arc, + ) -> Self { + Self { payment_store, pending_payment_store, update_lock: tokio::sync::Mutex::new(()) } + } + + /// Takes the lock for as long as the returned guard lives. + pub(super) async fn lock(&self) -> PaymentStoresGuard<'_> { + PaymentStoresGuard { stores: self, _guard: self.update_lock.lock().await } + } + + /// The payment record stored under `id`, if any. + pub(super) async fn payment(&self, id: &PaymentId) -> Result, Error> { + self.payment_store.get(id).await + } + + /// The pending-store entry stored under `id`, if any. + pub(super) async fn pending_payment( + &self, id: &PaymentId, + ) -> Result, Error> { + self.pending_payment_store.get(id).await + } + + /// A page of payment records, ordered from most recently created to least recently created; + /// see [`DataStore::list_page`](crate::data_store::DataStore::list_page). + pub(super) async fn payments_page( + &self, page_token: Option, + ) -> Result, Error> { + self.payment_store.list_page(page_token).await + } + + /// Whether the pending store has an entry under `id`. + pub(super) async fn has_pending_payment(&self, id: &PaymentId) -> Result { + self.pending_payment_store.contains_key(id).await + } + + /// The pending-store entries matching `f`. + pub(super) async fn pending_payments bool>( + &self, f: F, + ) -> Vec { + self.pending_payment_store.list_filter(f).await + } +} + +#[cfg(test)] +impl PaymentStores { + /// The payment store itself, for tests to set up and inspect records around the wallet's API. + pub(super) fn payment_store(&self) -> &PaymentStore { + &self.payment_store + } + + /// The pending payment store itself, for tests to set up and inspect entries around the + /// wallet's API. + pub(super) fn pending_payment_store(&self) -> &PendingPaymentStore { + &self.pending_payment_store + } +} + +impl Deref for PaymentStoresGuard<'_> { + type Target = PaymentStores; + + fn deref(&self) -> &Self::Target { + self.stores + } +} + +impl PaymentStoresGuard<'_> { + /// Stores `details`, merging its update into the record already stored under its id, if any. + /// Returns whether anything was written. + pub(super) async fn insert_or_update_payment( + &self, details: PaymentDetails, + ) -> Result { + self.stores.payment_store.insert_or_update(details).await + } + + /// Removes the payment record stored under `id`, if any. + pub(super) async fn remove_payment(&self, id: &PaymentId) -> Result<(), Error> { + self.stores.payment_store.remove(id).await + } + + /// 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). + pub(super) async fn mutate_payment( + &self, id: &PaymentId, f: F, + ) -> Result, Error> + where + F: FnOnce(Option<&PaymentDetails>) -> Option, + { + self.stores.payment_store.mutate(id, f).await + } + + /// Stores `entry`, merging its update into the entry already stored under its id, if any. + /// Returns whether anything was written. + pub(super) async fn insert_or_update_pending_payment( + &self, entry: PendingPaymentDetails, + ) -> Result { + self.stores.pending_payment_store.insert_or_update(entry).await + } + + /// 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). + pub(super) async fn mutate_pending_payment( + &self, id: &PaymentId, f: F, + ) -> Result, Error> + where + F: FnOnce(Option<&PendingPaymentDetails>) -> Option, + { + self.stores.pending_payment_store.mutate(id, f).await + } + + /// [`Self::mutate_pending_payment`] with a transformation that awaits fallible reads; see + /// [`DataStore::mutate_async`](crate::data_store::DataStore::mutate_async). + pub(super) async fn mutate_pending_payment_async( + &self, id: &PaymentId, f: F, + ) -> Result, Error> + where + F: FnOnce(Option) -> Fut, + Fut: Future, Error>>, + { + self.stores.pending_payment_store.mutate_async(id, f).await + } +} diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs new file mode 100644 index 0000000000..e2eb2e20d0 --- /dev/null +++ b/src/wallet/provenance.rs @@ -0,0 +1,1499 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Durable facts about the transactions a channel produces, as the producers of those +//! transactions reported them. +//! +//! A fact is immutable: it records what one producer knew at the moment it handed a transaction +//! over, keyed by that transaction's id. Several producers may describe the same transaction — +//! a funding transaction is reported when it is built and again when the channel reaches +//! pending — so records are merged rather than replaced, and a producer reporting a different +//! value for something already recorded is rejected instead of overwriting it. + +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::sync::{Arc, Mutex, Weak}; + +use bitcoin::hashes::Hash; +use bitcoin::secp256k1::PublicKey; +use bitcoin::{Sequence, Transaction, Txid}; +use lightning::ln::channelmanager::PaymentId; +use lightning::ln::types::ChannelId; +use lightning::util::persist::PageToken; +use lightning::util::ser::Writeable; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; + +use crate::config::{CHANNEL_TX_FACTS_MAX_RECORDS, CHANNEL_TX_FACTS_MAX_RECORD_BYTES}; +use crate::data_store::{StorableObject, StorableObjectId}; +use crate::hex_utils; +use crate::payment::store::{Channel, TransactionType}; +use crate::payment::PaymentDirection; +use crate::types::{ChainMonitor, ChannelManager, Sweeper, UserChannelId}; + +/// The part a transaction output plays in a channel. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ChannelOutputRole { + /// The output holding a channel's funds, spendable only by the channel's commitment and + /// closing transactions. + Funding, + /// An anchor output of a commitment transaction, spendable to fee-bump that transaction. + Anchor, + /// An HTLC output of a commitment transaction. + Htlc, + /// An output a channel resolved to this node, spendable by the on-chain wallet. + Spendable, +} + +impl_writeable_tlv_based_enum!(ChannelOutputRole, + (0, Funding) => {}, + (2, Anchor) => {}, + (4, Htlc) => {}, + (6, Spendable) => {}, +); + +/// One output of a transaction that a channel controls, and the channel controlling it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ChannelOutputFact { + /// The index of the output within its transaction. + pub vout: u32, + /// What the output is for. + pub role: ChannelOutputRole, + /// The `node_id` of the channel's counterparty. + pub counterparty_node_id: PublicKey, + /// The channel controlling the output. + pub channel_id: ChannelId, + /// The channel's local identifier, when the producer of this fact knew it. It survives the + /// temporary-to-final `channel_id` transition, unlike `channel_id` itself. + pub user_channel_id: Option, +} + +impl_writeable_tlv_based!(ChannelOutputFact, { + (0, vout, required), + (2, role, required), + (4, counterparty_node_id, required), + (6, channel_id, required), + (8, user_channel_id, option), +}); + +/// This node's share of an interactively negotiated funding transaction, and the funding payment +/// the transaction belongs to. +/// +/// The amount and the fee are `None` for a candidate this node contributed nothing to, e.g. a +/// counterparty-initiated round before one of ours replaced it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocalFundingFigures { + /// The funding payment this transaction is a candidate of. + pub funding_payment_id: PaymentId, + /// This node's share of the funding amount, in millisatoshis. + pub amount_msat: Option, + /// This node's share of the transaction's on-chain fee, in millisatoshis. + pub fee_paid_msat: Option, + /// Whether this node's share moves funds into or out of its on-chain wallet. + pub direction: PaymentDirection, +} + +impl_writeable_tlv_based!(LocalFundingFigures, { + (0, funding_payment_id, required), + (2, amount_msat, option), + (4, fee_paid_msat, option), + (6, direction, required), +}); + +/// What this node's producers reported about one transaction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ChannelTxFacts { + /// The transaction these facts are about. + pub txid: Txid, + /// Outputs of this transaction controlled by a channel rather than by the wallet. + pub outputs: Vec, + /// What this transaction is, when a producer identified it directly. + pub self_role: Option, + /// This node's share of an interactive-funding candidate, and the funding record it belongs + /// to. + pub local_figures: Option, + /// The chain tip this node was at when it last learned something new about the transaction. + /// It dates the record for retention; it is not a fact about the transaction, and so is the + /// one part of a record a later report may move. + pub recorded_at_height: u32, +} + +impl_writeable_tlv_based!(ChannelTxFacts, { + (0, txid, required), + (2, outputs, optional_vec), + (4, self_role, option), + (6, local_figures, option), + (8, recorded_at_height, required), +}); + +impl ChannelTxFacts { + /// Facts about the transaction `txid`, to be filled in with what a producer reported. + pub(crate) fn new(txid: Txid) -> Self { + Self { + txid, + outputs: Vec::new(), + self_role: None, + local_figures: None, + recorded_at_height: 0, + } + } + + /// Dates these facts at the chain tip the node is at while reporting them. + pub(crate) fn reported_at_height(mut self, height: u32) -> Self { + self.recorded_at_height = height; + self + } + + /// Records `vouts` of this transaction as controlled by `channel` in `role`. + pub(crate) fn with_outputs( + mut self, channel: &Channel, user_channel_id: Option, + role: ChannelOutputRole, vouts: impl IntoIterator, + ) -> Self { + self.outputs.extend(vouts.into_iter().map(|vout| ChannelOutputFact { + vout, + role, + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + user_channel_id, + })); + self + } + + /// Facts about `outpoints`, all controlled by `channel` in `role`, as one record per + /// transaction they belong to. + pub(crate) fn per_transaction( + channel: &Channel, user_channel_id: Option, role: ChannelOutputRole, + outpoints: impl IntoIterator, + ) -> Vec { + let mut grouped: Vec<(Txid, Vec)> = Vec::new(); + for (txid, vout) in outpoints { + match grouped.iter_mut().find(|(recorded, _)| *recorded == txid) { + Some((_, vouts)) => { + if !vouts.contains(&vout) { + vouts.push(vout); + } + }, + None => grouped.push((txid, vec![vout])), + } + } + grouped + .into_iter() + .map(|(txid, vouts)| { + Self::new(txid).with_outputs(channel, user_channel_id, role, vouts) + }) + .collect() + } + + /// Records what this transaction is. + pub(crate) fn with_self_role(mut self, self_role: TransactionType) -> Self { + self.self_role = Some(self_role); + self + } + + /// Records this node's share of an interactively negotiated funding candidate, and the funding + /// payment the candidate belongs to. + pub(crate) fn with_local_figures(mut self, local_figures: LocalFundingFigures) -> Self { + self.local_figures = Some(local_figures); + self + } + + /// Merges `incoming` into these facts, returning the result, or `None` when `incoming` adds + /// nothing to what is already recorded. + /// + /// Outputs are unioned by `vout`, while `self_role` and `local_figures` are filled in only + /// where they are still absent. Re-reporting a fact is therefore a no-op, which is what lets + /// a producer replay its event without consequence. Reporting a *different* value for + /// something already recorded is rejected, leaving the recorded facts as they were, and so is + /// a report that would take the record past the size a single record is allowed. + /// + /// A merge that changes something dates the record at the incoming report's height, so that + /// retention measures how long ago this node last learned anything about the transaction. + pub(crate) fn merged_with( + mut self, incoming: &ChannelTxFacts, + ) -> Result, ChannelTxFactsRejection> { + if self.txid != incoming.txid { + return Err(ChannelTxFactsRejection::Txid { + recorded: self.txid, + incoming: incoming.txid, + }); + } + + let mut changed = false; + for output in &incoming.outputs { + match self.outputs.iter().find(|recorded| recorded.vout == output.vout) { + Some(recorded) if recorded == output => {}, + Some(recorded) => { + return Err(ChannelTxFactsRejection::Output { + recorded: recorded.clone(), + incoming: output.clone(), + }) + }, + None => { + self.outputs.push(output.clone()); + changed = true; + }, + } + } + + match (&self.self_role, &incoming.self_role) { + (Some(recorded), Some(incoming)) if recorded != incoming => { + return Err(ChannelTxFactsRejection::SelfRole { + recorded: recorded.clone(), + incoming: incoming.clone(), + }) + }, + (None, Some(incoming)) => { + self.self_role = Some(incoming.clone()); + changed = true; + }, + _ => {}, + } + + match (&self.local_figures, &incoming.local_figures) { + (Some(recorded), Some(incoming)) if recorded != incoming => { + return Err(ChannelTxFactsRejection::LocalFigures { + recorded: recorded.clone(), + incoming: incoming.clone(), + }) + }, + (None, Some(incoming)) => { + self.local_figures = Some(incoming.clone()); + changed = true; + }, + _ => {}, + } + + if !changed { + return Ok(None); + } + self.recorded_at_height = self.recorded_at_height.max(incoming.recorded_at_height); + self.size_checked().map(Some) + } + + /// These facts, or a rejection when storing them would take one record past the size a + /// record is allowed. + /// + /// A record is written whole, so its size is the one resource a producer drives without + /// creating a record of its own: every channel-controlled output of a transaction lands on + /// that transaction's record, and a counterparty decides how many HTLCs a commitment + /// transaction carries. What a refused report would have described stays 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 { + return Err(ChannelTxFactsRejection::TooLarge { + bytes, + limit: CHANNEL_TX_FACTS_MAX_RECORD_BYTES, + }); + } + Ok(self) + } +} + +/// What became of a producer's report of what a transaction is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FactsRecordOutcome { + /// Everything reported is on record. + Recorded, + /// Part of what was reported is not on record, because recording it would have taken the + /// facts past the resources they are allowed. 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 holds no record of at all is subject to the +/// number of records the store may hold. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FactsAdmission { + /// Refused once the store holds as many records as it is allowed to. + /// + /// This is what bounds the store: a counterparty opening and closing channels, or replacing + /// a negotiated funding again and again, drives records about transactions this node only + /// reports on, and each of them costs nothing to refuse beyond a transaction going + /// unclassified. + Capped, + /// Admitted beside however many records the store holds, and counted like any other, so that + /// capped reports are refused the sooner. + /// + /// This is for the one report whose refusal costs more than the record: a round this node is + /// about to sign, which it will not release without its own share of it on record. How many + /// such records there can be is a question of how many rounds this node signs, which is its + /// own decision, and each carries no outputs. + Exempt, +} + +/// The channels this node still holds on-chain state for, as the retention of recorded facts +/// consults them. +pub(crate) trait ChannelLiveness: Send + Sync { + /// The channels the node's channel manager, chain monitor or output sweeper still knows + /// about, or `None` when that state cannot be consulted at all. Nothing is dropped while the + /// answer is `None`: without it there is no way to tell which facts are still needed. + fn live_channels(&self) -> Option>; +} + +/// The node's own channel state, as [`ChannelLiveness`]. +/// +/// The handles are weak because the node's channel state holds the wallet in turn, through the +/// keys manager, so strong ones here would keep both alive for good. A handle that no longer +/// upgrades means the node is being torn down, which is no time to be dropping records. +pub(crate) struct NodeChannelLiveness { + channel_manager: Weak, + chain_monitor: Weak, + output_sweeper: Weak, +} + +impl NodeChannelLiveness { + pub(crate) fn new( + channel_manager: &Arc, chain_monitor: &Arc, + output_sweeper: &Arc, + ) -> Self { + Self { + channel_manager: Arc::downgrade(channel_manager), + chain_monitor: Arc::downgrade(chain_monitor), + output_sweeper: Arc::downgrade(output_sweeper), + } + } +} + +impl ChannelLiveness for NodeChannelLiveness { + fn live_channels(&self) -> Option> { + let channel_manager = self.channel_manager.upgrade()?; + let chain_monitor = self.chain_monitor.upgrade()?; + let output_sweeper = self.output_sweeper.upgrade()?; + + Some(live_channels_of( + channel_manager.list_channels().into_iter().map(|channel| channel.channel_id), + chain_monitor.list_monitors(), + output_sweeper.tracked_spendable_outputs().into_iter().map(|output| output.channel_id), + )) + } +} + +/// The channels named by a node's open channels, by the monitors it holds and by the spendable +/// outputs its sweeper tracks, each named once. +/// +/// A channel counts as held if any one of the three names it: an open channel can still produce +/// transactions, a monitor can still claim from one, and a tracked output has yet to be swept. +/// A tracked output that names no channel — one the sweeper was given without one — says nothing +/// about which channel is held and is left out. +pub(crate) fn live_channels_of( + channels: impl IntoIterator, monitors: impl IntoIterator, + tracked_outputs: impl IntoIterator>, +) -> HashSet { + let mut live: HashSet = channels.into_iter().collect(); + live.extend(monitors); + live.extend(tracked_outputs.into_iter().flatten()); + live +} + +/// How far the pruning of recorded facts has walked the store, and how many records that walk +/// found there. +/// +/// The walk is what keeps the store's size known: it visits every record over consecutive chain +/// tips, so the count it arrives at is the store's own, without a second pass over it and without +/// holding an index of its keys in memory. Between walks the count follows the records created +/// and dropped, so it is exact except for records created during a walk that the walk had already +/// gone past — those are counted by the walk after, which bounds how far the store can run past +/// its limit at one walk's worth of growth. +pub(crate) struct FactsRetention { + /// Where the walk resumes and what it has counted, held by the pruning pass alone. + walk: tokio::sync::Mutex, + /// How many records the store holds. `None` until a walk has completed, until when nothing + /// is refused for want of room. + count: Mutex>, +} + +/// The pruning pass's place in its walk of the store. +pub(crate) struct FactsWalk { + /// Where the next batch resumes, or `None` to walk the store from the start. + pub cursor: Option, + /// How many records this walk has counted so far. + pub seen: usize, +} + +impl FactsRetention { + pub(crate) fn new() -> Self { + Self { + walk: tokio::sync::Mutex::new(FactsWalk { cursor: None, seen: 0 }), + count: Mutex::new(None), + } + } + + /// Takes the pruning pass's place in its walk, for as long as the guard lives. + pub(crate) async fn walk(&self) -> tokio::sync::MutexGuard<'_, FactsWalk> { + self.walk.lock().await + } + + /// Whether the store has room for a record it does not hold yet. + pub(crate) fn has_room(&self) -> bool { + self.count.lock().expect("lock").map_or(true, |count| count < CHANNEL_TX_FACTS_MAX_RECORDS) + } + + /// Notes that a record was created. + pub(crate) fn record_created(&self) { + if let Some(count) = self.count.lock().expect("lock").as_mut() { + *count = count.saturating_add(1); + } + } + + /// Notes that a record was dropped. + pub(crate) fn record_dropped(&self) { + if let Some(count) = self.count.lock().expect("lock").as_mut() { + *count = count.saturating_sub(1); + } + } + + /// Notes that a walk of the whole store ended having counted `seen` records. + pub(crate) fn walk_completed(&self, seen: usize) { + *self.count.lock().expect("lock") = Some(seen); + } + + #[cfg(test)] + pub(crate) fn counted(&self) -> Option { + *self.count.lock().expect("lock") + } +} + +/// What deciding whether a transaction's facts are still needed takes, beyond the facts +/// themselves. +pub(crate) struct RetentionCheck<'a> { + /// The height of the chain tip the decision is taken at. + pub tip_height: u32, + /// How many blocks a record outlives the last thing this node learned about its transaction. + pub retention_blocks: u32, + /// The channels this node still holds on-chain state for. + pub live_channels: &'a HashSet, + /// The transactions the pending payment store still refers to — its records' own + /// transactions, their interactive-funding candidates, the rounds that locked and the + /// conflicts wallet sync listed. A payment is pending exactly while its classification can + /// still be written onto it, so a transaction named here has yet to reach its record. + pub pending_txids: &'a HashSet, + /// Whether every funding output the facts record has been spent by a transaction confirmed + /// at least `2 * ANTI_REORG_DELAY` deep whose own payment has settled. `true` for facts + /// recording no funding output, which nothing closes. + pub funding_spends_settled: bool, +} + +impl ChannelTxFacts { + /// Whether these facts have outlived every use this node has for them. + /// + /// All of it must hold at once, and the age cap is what makes the answer bounded for facts + /// the other checks are blind to — a transaction for a channel that never reached the + /// channel manager, the chain monitor or the sweeper satisfies them vacuously. + pub(crate) fn is_prunable(&self, check: &RetentionCheck<'_>) -> bool { + if check.tip_height < self.recorded_at_height.saturating_add(check.retention_blocks) { + return false; + } + if self.outputs.iter().any(|output| check.live_channels.contains(&output.channel_id)) { + return false; + } + if check.pending_txids.contains(&self.txid) { + return false; + } + check.funding_spends_settled + } + + /// The outputs of this transaction a channel holds its funds in. + pub(crate) fn funding_vouts(&self) -> impl Iterator + '_ { + self.outputs + .iter() + .filter(|output| output.role == ChannelOutputRole::Funding) + .map(|output| output.vout) + } +} + +impl StorableObjectId for Txid { + fn encode_to_hex_str(&self) -> String { + hex_utils::to_string(self.as_byte_array()) + } + + fn decode_from_hex_str(s: &str) -> Option { + let bytes: [u8; 32] = hex_utils::to_vec(s)?.try_into().ok()?; + Some(Txid::from_byte_array(bytes)) + } +} + +impl StorableObject for ChannelTxFacts { + type Id = Txid; + + fn id(&self) -> Self::Id { + self.txid + } +} + +/// A reported fact that was not recorded, leaving what is on record as it was. +/// +/// Most of these mean two producers disagree about the same transaction, which they cannot both +/// be right about: facts are immutable, so the recorded value stands and the reported one is +/// dropped. The remaining one is a report the record has no room for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ChannelTxFactsRejection { + /// The reported facts are about a different transaction altogether. + Txid { recorded: Txid, incoming: Txid }, + /// The same output is reported with a different role or a different channel. + Output { recorded: ChannelOutputFact, incoming: ChannelOutputFact }, + /// The transaction is reported as being something else than it is recorded as. + SelfRole { recorded: TransactionType, incoming: TransactionType }, + /// This node's share of the transaction is reported differently than it is recorded. + LocalFigures { recorded: LocalFundingFigures, incoming: LocalFundingFigures }, + /// Recording the report would take the transaction's record past the size one record is + /// allowed. + TooLarge { bytes: usize, limit: usize }, + /// The store holds as many records as it is allowed to, and this report is about a + /// transaction it holds no record of. + NoRoom { limit: usize }, +} + +impl ChannelTxFactsRejection { + /// Whether the report was refused for want of room rather than because it contradicts what is + /// on record. Both leave the recorded facts as they were, but only a contradiction says a + /// producer is wrong about something. + pub(crate) fn is_resource_limit(&self) -> bool { + matches!(self, Self::TooLarge { .. } | Self::NoRoom { .. }) + } +} + +impl fmt::Display for ChannelTxFactsRejection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Txid { recorded, incoming } => { + write!(f, "transaction {} reported as {}", recorded, incoming) + }, + Self::Output { recorded, incoming } => { + write!(f, "output {:?} reported as {:?}", recorded, incoming) + }, + Self::SelfRole { recorded, incoming } => { + write!(f, "transaction type {:?} reported as {:?}", recorded, incoming) + }, + Self::LocalFigures { recorded, incoming } => { + write!(f, "local funding figures {:?} reported as {:?}", recorded, incoming) + }, + Self::TooLarge { bytes, limit } => { + write!(f, "record of {} bytes exceeds the {} bytes allowed", bytes, limit) + }, + Self::NoRoom { limit } => { + write!(f, "no room for a further record beside the {} already held", limit) + }, + } + } +} + +/// The recorded facts a transaction's classification rests on: what this node's channels reported +/// about the transaction itself, and what they reported about the transactions its inputs spend. +#[derive(Clone, Debug, Default)] +pub(crate) struct TxProvenance { + /// What was reported about the transaction itself, if anything. + self_facts: Option, + /// What was reported about the transactions the inputs spend, keyed by transaction id. Only + /// the transactions the inputs actually reference are represented. + parent_facts: HashMap, +} + +impl TxProvenance { + /// The provenance assembled from the facts recorded for a transaction and for the + /// transactions its inputs spend. + pub(crate) fn new( + self_facts: Option, parent_facts: HashMap, + ) -> Self { + Self { self_facts, parent_facts } + } + + /// What `tx` is, as far as these facts can tell; see [`classify`]. + pub(crate) fn classify(&self, tx: &Transaction) -> Option { + classify(tx, self.self_facts.as_ref(), &self.parent_facts) + } + + /// This node's share of the transaction, for a candidate of an interactively negotiated + /// funding a producer reported the figures of. + pub(crate) fn local_figures(&self) -> Option<&LocalFundingFigures> { + self.self_facts.as_ref()?.local_figures.as_ref() + } +} + +/// What a transaction is, derived from what this node's channels recorded about it and about the +/// transactions its inputs spend. +/// +/// `self_facts` are the facts recorded for `tx`, `parent_facts` those recorded for the +/// transactions `tx` spends from, keyed by transaction id. Anything these cannot account for is +/// left unclassified rather than guessed: without a channel of this node's laying claim to an +/// output, a transaction is an ordinary on-chain payment. +pub(crate) fn classify( + tx: &Transaction, self_facts: Option<&ChannelTxFacts>, + parent_facts: &HashMap, +) -> Option { + // A producer that named the transaction outright is the most reliable answer there is, and + // the only one that stays put across a re-broadcast or a replacement of the transaction. + if let Some(self_role) = self_facts.and_then(|facts| facts.self_role.as_ref()) { + return Some(self_role.clone()); + } + + let spent: Vec<&ChannelOutputFact> = tx + .input + .iter() + .filter_map(|input| { + parent_facts.get(&input.previous_output.txid).and_then(|parent| { + parent.outputs.iter().find(|output| output.vout == input.previous_output.vout) + }) + }) + .collect(); + let created: &[ChannelOutputFact] = self_facts.map_or(&[], |facts| facts.outputs.as_slice()); + let funds: Vec<&ChannelOutputFact> = + created.iter().filter(|output| output.role == ChannelOutputRole::Funding).collect(); + + let spent_funding = in_role(&spent, ChannelOutputRole::Funding); + if let Some(funding) = spent_funding.first() { + // Moving a channel's funds into a new funding output is what an interactive negotiation + // produces, whichever side of it this node is on. + if !funds.is_empty() { + let channels = channels_of(spent_funding.iter().copied().chain(funds.iter().copied())); + return Some(TransactionType::InteractiveFunding { channels }); + } + if is_cooperative_close(tx) { + return Some(TransactionType::CooperativeClose { + counterparty_node_id: funding.counterparty_node_id, + channel_id: funding.channel_id, + }); + } + if is_commitment(tx) { + return Some(TransactionType::UnilateralClose { + counterparty_node_id: funding.counterparty_node_id, + channel_id: funding.channel_id, + }); + } + // The funding output is gone in a shape none of the transactions a channel produces has. + // Naming it anyway would put a guess on a payment record that nothing later corrects. + return None; + } + + let spent_anchors = in_role(&spent, ChannelOutputRole::Anchor); + if let Some(anchor) = spent_anchors.first() { + return Some(TransactionType::AnchorBump { + counterparty_node_id: anchor.counterparty_node_id, + channel_id: anchor.channel_id, + }); + } + + let spent_htlcs = in_role(&spent, ChannelOutputRole::Htlc); + if let Some(htlc) = spent_htlcs.first() { + return Some(TransactionType::Claim { + counterparty_node_id: htlc.counterparty_node_id, + channel_id: htlc.channel_id, + }); + } + + let spent_spendable = in_role(&spent, ChannelOutputRole::Spendable); + if !spent_spendable.is_empty() { + return Some(TransactionType::Sweep { channels: channels_of(spent_spendable) }); + } + + if !funds.is_empty() { + return Some(TransactionType::Funding { channels: channels_of(funds) }); + } + + None +} + +/// The outputs among `outputs` a channel controls in `role`. +fn in_role<'a>( + outputs: &[&'a ChannelOutputFact], role: ChannelOutputRole, +) -> Vec<&'a ChannelOutputFact> { + outputs.iter().copied().filter(|output| output.role == role).collect() +} + +/// The channels controlling `outputs`, each named once, in the order the outputs name them. +fn channels_of<'a>(outputs: impl IntoIterator) -> Vec { + let mut channels: Vec = Vec::new(); + for output in outputs { + let channel = Channel { + counterparty_node_id: output.counterparty_node_id, + channel_id: output.channel_id, + }; + if !channels.contains(&channel) { + channels.push(channel); + } + } + channels +} + +/// Whether `tx` has the shape BOLT 2 gives a cooperative closing transaction: the sole spend of +/// the funding output, final and valid from the moment it is signed. +fn is_cooperative_close(tx: &Transaction) -> bool { + tx.input.len() == 1 + && tx.input[0].sequence == Sequence::MAX + && tx.lock_time.to_consensus_u32() == 0 +} + +/// Whether `tx` has the shape BOLT 3 gives a commitment transaction: the sole spend of the +/// funding output, with the upper byte of its sequence and of its locktime set to the constants +/// that mark the remainder of both as the obscured commitment number. +fn is_commitment(tx: &Transaction) -> bool { + tx.input.len() == 1 + && (tx.input[0].sequence.0 >> 24) as u8 == 0x80 + && (tx.lock_time.to_consensus_u32() >> 24) as u8 == 0x20 +} + +#[cfg(test)] +mod tests { + use bitcoin::absolute::LockTime; + use bitcoin::transaction::Version; + use bitcoin::{Amount, OutPoint, ScriptBuf, TxIn, TxOut, Witness}; + use lightning::util::ser::{Readable, Writeable}; + + use super::*; + + fn test_txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + fn test_channel(byte: u8) -> Channel { + let counterparty_node_id = PublicKey::from_slice(&[ + 0x02, 0xc6, 0x04, 0x7f, 0x94, 0x41, 0xed, 0x7d, 0x6d, 0x30, 0x45, 0x40, 0x6e, 0x95, + 0xc0, 0x7c, 0xd8, 0x5c, 0x77, 0x8e, 0x4b, 0x8c, 0xef, 0x3c, 0xa7, 0xab, 0xac, 0x09, + 0xb9, 0x5c, 0x70, 0x9e, 0xe5, + ]) + .expect("static test key is valid"); + Channel { counterparty_node_id, channel_id: ChannelId([byte; 32]) } + } + + fn other_counterparty() -> PublicKey { + PublicKey::from_slice(&[ + 0x02, 0x4d, 0x4b, 0x6c, 0xd1, 0x36, 0x10, 0x32, 0xca, 0x9b, 0xd2, 0xae, 0xb9, 0xd9, + 0x00, 0xaa, 0x4d, 0x45, 0xd9, 0xea, 0xd8, 0x0a, 0xc9, 0x42, 0x33, 0x74, 0xc4, 0x51, + 0xa7, 0x25, 0x4d, 0x07, 0x66, + ]) + .expect("static test key is valid") + } + + fn with_local_figures(txid: Txid, local_figures: LocalFundingFigures) -> ChannelTxFacts { + ChannelTxFacts { local_figures: Some(local_figures), ..ChannelTxFacts::new(txid) } + } + + fn round_trip(object: &T) { + let encoded = object.encode(); + let decoded: T = Readable::read(&mut &encoded[..]).expect("round trip"); + assert_eq!(&decoded, object); + } + + fn full_facts() -> ChannelTxFacts { + let channel = test_channel(1); + let facts = ChannelTxFacts::new(test_txid(7)) + .with_outputs(&channel, Some(UserChannelId(42)), ChannelOutputRole::Funding, [0]) + .with_outputs(&channel, None, ChannelOutputRole::Anchor, [1]) + .with_outputs(&channel, None, ChannelOutputRole::Htlc, [2, 3]) + .with_outputs(&channel, None, ChannelOutputRole::Spendable, [4]) + .with_self_role(TransactionType::InteractiveFunding { + channels: vec![channel.clone()], + }); + ChannelTxFacts { + local_figures: Some(LocalFundingFigures { + funding_payment_id: PaymentId([9u8; 32]), + amount_msat: Some(1_000_000), + fee_paid_msat: Some(2_500), + direction: PaymentDirection::Outbound, + }), + ..facts + } + } + + #[test] + fn facts_round_trip_through_tlv() { + let facts = full_facts(); + round_trip(&facts); + for output in &facts.outputs { + round_trip(output); + round_trip(&output.role); + } + round_trip(facts.local_figures.as_ref().expect("figures are set")); + + // A record a producer only partially filled in round-trips as such rather than picking up + // defaults for what it left out. + let sparse = ChannelTxFacts::new(test_txid(8)); + round_trip(&sparse); + let decoded: ChannelTxFacts = + Readable::read(&mut &sparse.encode()[..]).expect("round trip"); + assert!(decoded.outputs.is_empty()); + assert_eq!(decoded.self_role, None); + assert_eq!(decoded.local_figures, None); + } + + #[test] + fn outpoints_group_into_one_record_per_transaction() { + let channel = test_channel(1); + let records = ChannelTxFacts::per_transaction( + &channel, + Some(UserChannelId(7)), + ChannelOutputRole::Spendable, + [ + (test_txid(1), 0), + (test_txid(2), 4), + (test_txid(1), 3), + // A producer reporting the same outpoint twice contributes it once. + (test_txid(2), 4), + ], + ); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].txid, test_txid(1)); + assert_eq!( + records[0].outputs.iter().map(|output| output.vout).collect::>(), + vec![0, 3] + ); + assert_eq!(records[1].txid, test_txid(2)); + assert_eq!( + records[1].outputs.iter().map(|output| output.vout).collect::>(), + vec![4] + ); + assert!(records.iter().flat_map(|facts| &facts.outputs).all(|output| { + output.role == ChannelOutputRole::Spendable + && output.channel_id == channel.channel_id + && output.user_channel_id == Some(UserChannelId(7)) + })); + } + + #[test] + fn facts_key_round_trips_through_its_hex_encoding() { + let txid = test_txid(3); + let encoded = txid.encode_to_hex_str(); + assert_eq!(encoded.len(), 64); + assert_eq!(Txid::decode_from_hex_str(&encoded), Some(txid)); + assert_eq!(Txid::decode_from_hex_str("not hex"), None); + assert_eq!(Txid::decode_from_hex_str("00"), None); + } + + #[test] + fn replaying_a_fact_changes_nothing() { + let facts = full_facts(); + assert_eq!(facts.clone().merged_with(&facts), Ok(None)); + + // A producer that reports only part of what is already recorded is likewise a no-op, which + // is what a replay of an earlier event looks like once a later one has filled the record + // in. + let channel = test_channel(1); + let partial = ChannelTxFacts::new(test_txid(7)).with_outputs( + &channel, + Some(UserChannelId(42)), + ChannelOutputRole::Funding, + [0], + ); + assert_eq!(facts.clone().merged_with(&partial), Ok(None)); + } + + #[test] + fn outputs_of_two_producers_merge_into_one_record() { + let channel = test_channel(1); + let other = test_channel(2); + let txid = test_txid(7); + + // A batched sweep resolves outputs of two different channels; each producer reports only + // its own. + let first = ChannelTxFacts::new(txid).with_outputs( + &channel, + None, + ChannelOutputRole::Spendable, + [0, 2], + ); + let second = + ChannelTxFacts::new(txid).with_outputs(&other, None, ChannelOutputRole::Spendable, [1]); + + let merged = first.merged_with(&second).expect("disjoint outputs merge").expect("changed"); + assert_eq!(merged.outputs.len(), 3); + let mut vouts: Vec = merged.outputs.iter().map(|output| output.vout).collect(); + vouts.sort_unstable(); + assert_eq!(vouts, vec![0, 1, 2]); + assert_eq!( + merged.outputs.iter().find(|output| output.vout == 1).map(|output| output.channel_id), + Some(other.channel_id) + ); + } + + #[test] + fn a_second_role_for_one_output_is_rejected() { + let channel = test_channel(1); + let txid = test_txid(7); + let recorded = + ChannelTxFacts::new(txid).with_outputs(&channel, None, ChannelOutputRole::Funding, [0]); + let conflicting = + ChannelTxFacts::new(txid).with_outputs(&channel, None, ChannelOutputRole::Anchor, [0]); + + match recorded.clone().merged_with(&conflicting) { + Err(ChannelTxFactsRejection::Output { recorded, incoming }) => { + assert_eq!(recorded.role, ChannelOutputRole::Funding); + assert_eq!(incoming.role, ChannelOutputRole::Anchor); + }, + other => panic!("expected an output conflict, got {:?}", other), + } + + // The same output attributed to a different channel is a conflict too, rather than the + // later producer's channel silently winning. + let other_channel = Channel { + counterparty_node_id: other_counterparty(), + channel_id: ChannelId([2u8; 32]), + }; + let reattributed = ChannelTxFacts::new(txid).with_outputs( + &other_channel, + None, + ChannelOutputRole::Funding, + [0], + ); + assert!(matches!( + recorded.merged_with(&reattributed), + Err(ChannelTxFactsRejection::Output { .. }) + )); + } + + #[test] + fn a_second_transaction_type_is_rejected() { + let channel = test_channel(1); + let txid = test_txid(7); + let recorded = ChannelTxFacts::new(txid) + .with_self_role(TransactionType::Funding { channels: vec![channel.clone()] }); + let conflicting = + ChannelTxFacts::new(txid).with_self_role(TransactionType::InteractiveFunding { + channels: vec![channel.clone()], + }); + + match recorded.clone().merged_with(&conflicting) { + Err(ChannelTxFactsRejection::SelfRole { recorded, incoming }) => { + assert_eq!(recorded, TransactionType::Funding { channels: vec![channel.clone()] }); + assert_eq!( + incoming, + TransactionType::InteractiveFunding { channels: vec![channel] } + ); + }, + other => panic!("expected a transaction type conflict, got {:?}", other), + } + } + + #[test] + fn a_second_set_of_local_figures_is_rejected() { + let txid = test_txid(7); + let figures = LocalFundingFigures { + funding_payment_id: PaymentId([9u8; 32]), + amount_msat: Some(1_000_000), + fee_paid_msat: Some(2_500), + direction: PaymentDirection::Outbound, + }; + let recorded = with_local_figures(txid, figures.clone()); + let conflicting = + with_local_figures(txid, LocalFundingFigures { fee_paid_msat: Some(5_000), ..figures }); + + assert!(matches!( + recorded.merged_with(&conflicting), + Err(ChannelTxFactsRejection::LocalFigures { .. }) + )); + } + + #[test] + fn facts_about_another_transaction_are_rejected() { + let recorded = ChannelTxFacts::new(test_txid(7)); + let other = ChannelTxFacts::new(test_txid(8)); + assert_eq!( + recorded.merged_with(&other), + Err(ChannelTxFactsRejection::Txid { recorded: test_txid(7), incoming: test_txid(8) }) + ); + } + + #[test] + fn a_rejected_merge_leaves_the_record_untouched() { + let channel = test_channel(1); + let txid = test_txid(7); + let recorded = + ChannelTxFacts::new(txid).with_outputs(&channel, None, ChannelOutputRole::Funding, [0]); + + // The addition the producer got right comes with one it got wrong; neither lands. + let conflicting = ChannelTxFacts::new(txid) + .with_outputs(&channel, None, ChannelOutputRole::Htlc, [1]) + .with_outputs(&channel, None, ChannelOutputRole::Anchor, [0]); + + assert!(recorded.clone().merged_with(&conflicting).is_err()); + assert_eq!(recorded.outputs.len(), 1); + assert_eq!(recorded.outputs[0].role, ChannelOutputRole::Funding); + } + + #[test] + fn a_transaction_type_fills_in_only_while_absent() { + let channel = test_channel(1); + let txid = test_txid(7); + let role = TransactionType::UnilateralClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }; + + let empty = ChannelTxFacts::new(txid); + let filled = empty + .merged_with(&ChannelTxFacts::new(txid).with_self_role(role.clone())) + .expect("fills in") + .expect("changed"); + assert_eq!(filled.self_role, Some(role.clone())); + + // A producer reporting the same type again adds nothing, so nothing is written. + assert_eq!( + filled.clone().merged_with(&ChannelTxFacts::new(txid).with_self_role(role)), + Ok(None) + ); + } + + #[test] + fn local_figures_fill_in_only_while_absent() { + let txid = test_txid(7); + let figures = LocalFundingFigures { + funding_payment_id: PaymentId([9u8; 32]), + amount_msat: None, + fee_paid_msat: None, + direction: PaymentDirection::Inbound, + }; + + let filled = ChannelTxFacts::new(txid) + .merged_with(&with_local_figures(txid, figures.clone())) + .expect("fills in") + .expect("changed"); + assert_eq!(filled.local_figures, Some(figures.clone())); + + assert_eq!(filled.merged_with(&with_local_figures(txid, figures)), Ok(None)); + } + /// The transaction whose outputs the classification cases below spend. + const PARENT: u8 = 0x11; + + /// A transaction spending `inputs`, each input carrying `sequence`. + fn spending_tx(inputs: &[(Txid, u32)], sequence: Sequence, lock_time: u32) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(lock_time), + input: inputs + .iter() + .map(|(txid, vout)| TxIn { + previous_output: OutPoint { txid: *txid, vout: *vout }, + script_sig: ScriptBuf::new(), + sequence, + witness: Witness::new(), + }) + .collect(), + output: vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: ScriptBuf::new() }], + } + } + + /// The single spend of `PARENT`'s first output, in the shape BOLT 2 gives a cooperative + /// closing transaction. + fn cooperative_close_shaped() -> Transaction { + spending_tx(&[(test_txid(PARENT), 0)], Sequence::MAX, 0) + } + + /// The single spend of `PARENT`'s first output, in the shape BOLT 3 gives a commitment + /// transaction: the obscured commitment number split across sequence and locktime. + fn commitment_shaped() -> Transaction { + spending_tx(&[(test_txid(PARENT), 0)], Sequence(0x80_12_34_56), 0x20_ab_cd_ef) + } + + /// The single spend of `PARENT`'s first output in no shape a channel produces: replaceable, + /// and without a commitment number. + fn unrecognised_shaped() -> Transaction { + spending_tx(&[(test_txid(PARENT), 0)], Sequence(0xff_ff_ff_fd), 0) + } + + fn parents(facts: impl IntoIterator) -> HashMap { + facts.into_iter().map(|facts| (facts.txid, facts)).collect() + } + + /// Facts recording `PARENT`'s outputs `vouts` as controlled by `channel` in `role`. + fn parent_outputs( + channel: &Channel, role: ChannelOutputRole, vouts: impl IntoIterator, + ) -> ChannelTxFacts { + ChannelTxFacts::new(test_txid(PARENT)).with_outputs(channel, None, role, vouts) + } + + /// Facts recording `tx`'s first output as `channel`'s funding output. + fn funds(tx: &Transaction, channel: &Channel, vout: u32) -> ChannelTxFacts { + ChannelTxFacts::new(tx.compute_txid()).with_outputs( + channel, + Some(UserChannelId(42)), + ChannelOutputRole::Funding, + [vout], + ) + } + + #[test] + fn a_reported_role_settles_what_a_transaction_is() { + let channel = test_channel(1); + let tx = cooperative_close_shaped(); + let recorded = parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]); + + // Left to its shape alone, the transaction is a cooperative close. + assert_eq!( + classify(&tx, None, &recorded), + Some(TransactionType::CooperativeClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + + // The channel that produced it says otherwise, and it is the one that knows. + let reported = TransactionType::UnilateralClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }; + let self_facts = ChannelTxFacts::new(tx.compute_txid()).with_self_role(reported.clone()); + assert_eq!(classify(&tx, Some(&self_facts), &recorded), Some(reported)); + } + + #[test] + fn spending_and_creating_a_funding_output_is_an_interactive_funding() { + let channel = test_channel(1); + let tx = unrecognised_shaped(); + let self_facts = funds(&tx, &channel, 0); + + assert_eq!( + classify( + &tx, + Some(&self_facts), + &parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]), + ), + Some(TransactionType::InteractiveFunding { channels: vec![channel] }) + ); + } + + #[test] + fn a_final_single_spend_of_a_funding_output_is_a_cooperative_close() { + let channel = test_channel(1); + assert_eq!( + classify( + &cooperative_close_shaped(), + None, + &parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]), + ), + Some(TransactionType::CooperativeClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn a_commitment_shaped_spend_of_a_funding_output_is_a_unilateral_close() { + let channel = test_channel(1); + assert_eq!( + classify( + &commitment_shaped(), + None, + &parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]), + ), + Some(TransactionType::UnilateralClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn an_unrecognised_spend_of_a_funding_output_is_left_unnamed() { + let channel = test_channel(1); + let recorded = parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]); + + // Neither template matches and nothing reported the transaction, so there is no answer + // to give. A close of either kind would be a guess. + assert_eq!(classify(&unrecognised_shaped(), None, &recorded), None); + + // A second input rules both templates out as well, whatever the first input looks like. + let two_inputs = + spending_tx(&[(test_txid(PARENT), 0), (test_txid(PARENT + 1), 0)], Sequence::MAX, 0); + assert_eq!(classify(&two_inputs, None, &recorded), None); + } + + #[test] + fn spending_an_anchor_output_is_an_anchor_bump() { + let channel = test_channel(1); + let tx = spending_tx( + &[(test_txid(PARENT), 1), (test_txid(PARENT + 9), 0)], + Sequence(0xff_ff_ff_fd), + 0, + ); + + assert_eq!( + classify( + &tx, + None, + &parents([parent_outputs(&channel, ChannelOutputRole::Anchor, [1])]), + ), + Some(TransactionType::AnchorBump { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn spending_an_htlc_output_is_a_claim() { + let channel = test_channel(1); + let tx = spending_tx(&[(test_txid(PARENT), 2)], Sequence(0xff_ff_ff_fd), 0); + + assert_eq!( + classify(&tx, None, &parents([parent_outputs(&channel, ChannelOutputRole::Htlc, [2])]),), + Some(TransactionType::Claim { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn spending_resolved_outputs_is_a_sweep_naming_every_channel() { + let channel = test_channel(1); + let other = test_channel(2); + let tx = spending_tx( + &[(test_txid(PARENT), 0), (test_txid(PARENT), 1), (test_txid(PARENT + 1), 0)], + Sequence(0xff_ff_ff_fd), + 0, + ); + + // One sweep resolving outputs of two channels is associated with both of them. + let recorded = parents([ + parent_outputs(&channel, ChannelOutputRole::Spendable, [0, 1]), + ChannelTxFacts::new(test_txid(PARENT + 1)).with_outputs( + &other, + None, + ChannelOutputRole::Spendable, + [0], + ), + ]); + assert_eq!( + classify(&tx, None, &recorded), + Some(TransactionType::Sweep { channels: vec![channel, other] }) + ); + } + + #[test] + fn creating_a_funding_output_alone_is_a_funding_naming_every_channel() { + let channel = test_channel(1); + let other = test_channel(2); + // Nothing channel-controlled is spent: the wallet pays for both funding outputs. + let tx = spending_tx(&[(test_txid(PARENT + 20), 0)], Sequence(0xff_ff_ff_fd), 0); + let self_facts = funds(&tx, &channel, 0).with_outputs( + &other, + Some(UserChannelId(43)), + ChannelOutputRole::Funding, + [1], + ); + + assert_eq!( + classify(&tx, Some(&self_facts), &HashMap::new()), + Some(TransactionType::Funding { channels: vec![channel, other] }) + ); + } + + #[test] + fn an_ordinary_wallet_spend_is_left_unnamed() { + let tx = spending_tx(&[(test_txid(PARENT), 0)], Sequence(0xff_ff_ff_fd), 0); + + // Nothing was ever reported about the transaction or about what it spends. + assert_eq!(classify(&tx, None, &HashMap::new()), None); + + // Nor does spending an output a channel left alone make the transaction a channel's. + let channel = test_channel(1); + let recorded = parents([parent_outputs(&channel, ChannelOutputRole::Spendable, [7])]); + assert_eq!(classify(&tx, None, &recorded), None); + } + + #[test] + fn provenance_answers_from_the_facts_it_holds() { + let channel = test_channel(1); + let tx = cooperative_close_shaped(); + let figures = LocalFundingFigures { + funding_payment_id: PaymentId([9u8; 32]), + amount_msat: Some(1_000_000), + fee_paid_msat: Some(2_500), + direction: PaymentDirection::Outbound, + }; + + let empty = TxProvenance::default(); + assert_eq!(empty.classify(&tx), None); + assert_eq!(empty.local_figures(), None); + + let provenance = TxProvenance::new( + Some(with_local_figures(tx.compute_txid(), figures.clone())), + parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]), + ); + assert_eq!( + provenance.classify(&tx), + Some(TransactionType::CooperativeClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + assert_eq!(provenance.local_figures(), Some(&figures)); + } + + /// Facts about a funding transaction of `channel` whose age is measured from `height`. + fn funding_facts(channel: &Channel, height: u32) -> ChannelTxFacts { + ChannelTxFacts::new(test_txid(20)) + .with_outputs(channel, None, ChannelOutputRole::Funding, [0]) + .reported_at_height(height) + } + + /// A retention check that would drop the facts it is given: nothing is held, nothing is + /// pending, the funding is spent and settled, and the age cap has long passed. + fn everything_resolved<'a>( + live_channels: &'a HashSet, pending_txids: &'a HashSet, + ) -> RetentionCheck<'a> { + RetentionCheck { + tip_height: 100_000, + retention_blocks: 52_560, + live_channels, + pending_txids, + funding_spends_settled: true, + } + } + + #[test] + fn facts_of_a_resolved_channel_are_prunable() { + let channel = test_channel(1); + let (live, pending) = (HashSet::new(), HashSet::new()); + assert!(funding_facts(&channel, 10).is_prunable(&everything_resolved(&live, &pending))); + } + + #[test] + fn facts_are_kept_until_the_age_cap_has_passed() { + let channel = test_channel(1); + let (live, pending) = (HashSet::new(), HashSet::new()); + let facts = funding_facts(&channel, 50_000); + + let mut check = everything_resolved(&live, &pending); + check.tip_height = 50_000 + 52_560 - 1; + assert!(!facts.is_prunable(&check), "a block short of the cap is short of it"); + + check.tip_height = 50_000 + 52_560; + assert!(facts.is_prunable(&check)); + } + + #[test] + fn facts_are_kept_while_the_node_still_holds_their_channel() { + let channel = test_channel(1); + let pending = HashSet::new(); + let live: HashSet = [channel.channel_id].into_iter().collect(); + assert!(!funding_facts(&channel, 10).is_prunable(&everything_resolved(&live, &pending))); + + // Another channel being held says nothing about this one. + let other: HashSet = [test_channel(2).channel_id].into_iter().collect(); + assert!(funding_facts(&channel, 10).is_prunable(&everything_resolved(&other, &pending))); + } + + #[test] + fn facts_are_kept_while_a_pending_payment_names_their_transaction() { + let channel = test_channel(1); + let facts = funding_facts(&channel, 10); + let live = HashSet::new(); + let pending: HashSet = [facts.txid].into_iter().collect(); + assert!(!facts.is_prunable(&everything_resolved(&live, &pending))); + } + + #[test] + fn facts_are_kept_until_the_funding_they_record_is_spent_and_settled() { + let channel = test_channel(1); + let (live, pending) = (HashSet::new(), HashSet::new()); + let mut check = everything_resolved(&live, &pending); + check.funding_spends_settled = false; + + assert!(!funding_facts(&channel, 10).is_prunable(&check)); + + // Facts recording no funding of their own have no spend of one to wait for: what a + // commitment transaction's anchors and HTLCs say is answered by the age cap and by + // whether the channel is still held. + let no_funding = ChannelTxFacts::new(test_txid(21)) + .with_outputs(&channel, None, ChannelOutputRole::Anchor, [0]) + .reported_at_height(10); + let mut settled = check; + settled.funding_spends_settled = true; + assert!(no_funding.is_prunable(&settled)); + } + + #[test] + fn a_channel_any_of_the_three_sources_names_counts_as_held() { + let (open, monitored, swept) = (ChannelId([1; 32]), ChannelId([2; 32]), ChannelId([3; 32])); + + assert_eq!(live_channels_of([], [], []), HashSet::new()); + assert_eq!(live_channels_of([open], [], []), [open].into_iter().collect()); + assert_eq!(live_channels_of([], [monitored], []), [monitored].into_iter().collect()); + assert_eq!(live_channels_of([], [], [Some(swept)]), [swept].into_iter().collect()); + + // A tracked output without a channel names none, and a channel several sources name is + // named once. + assert_eq!( + live_channels_of([open], [open, monitored], [Some(swept), None]), + [open, monitored, swept].into_iter().collect(), + ); + } + + #[test] + fn a_report_that_would_outgrow_one_record_is_refused() { + let channel = test_channel(1); + let recorded = ChannelTxFacts::new(test_txid(30)).with_outputs( + &channel, + None, + ChannelOutputRole::Htlc, + 0..8, + ); + + // One output costs well under a hundred bytes, so a report of this many cannot fit. + let oversized = ChannelTxFacts::new(test_txid(30)).with_outputs( + &channel, + None, + ChannelOutputRole::Htlc, + 8..40_000, + ); + match recorded.clone().merged_with(&oversized) { + Err(ChannelTxFactsRejection::TooLarge { bytes, limit }) => { + assert!(bytes > limit, "{} is not past {}", bytes, limit); + assert_eq!(limit, CHANNEL_TX_FACTS_MAX_RECORD_BYTES); + }, + Ok(merged) => panic!( + "unexpected merge outcome: {} outputs recorded", + merged.map_or(0, |facts| facts.outputs.len()), + ), + Err(e) => panic!("unexpected rejection {:?}", e), + } + // The refusal is what the caller sees; what is on record is untouched, as it is for a + // contradiction. + assert_eq!(recorded.clone().merged_with(&recorded).unwrap(), None); + assert!(oversized.size_checked().is_err()); + assert!(recorded.size_checked().is_ok()); + } + + #[test] + fn a_record_is_dated_at_the_last_report_that_added_to_it() { + let channel = test_channel(1); + let first = ChannelTxFacts::new(test_txid(31)) + .with_outputs(&channel, None, ChannelOutputRole::Funding, [0]) + .reported_at_height(700); + + // A replay adds nothing, so it writes nothing and cannot refresh the record's age. + let replay = first.clone().reported_at_height(900); + assert_eq!(first.clone().merged_with(&replay).unwrap(), None); + + let later = ChannelTxFacts::new(test_txid(31)) + .with_outputs(&channel, None, ChannelOutputRole::Anchor, [1]) + .reported_at_height(900); + let merged = first.merged_with(&later).unwrap().expect("the anchor is new"); + assert_eq!(merged.recorded_at_height, 900); + } + + #[test] + fn the_census_bounds_admission_only_once_a_walk_has_counted_the_store() { + let retention = FactsRetention::new(); + assert_eq!(retention.counted(), None); + // Nothing is refused while the store's size is unknown, however much is created. + for _ in 0..CHANNEL_TX_FACTS_MAX_RECORDS + 1 { + retention.record_created(); + } + assert!(retention.has_room()); + + retention.walk_completed(CHANNEL_TX_FACTS_MAX_RECORDS - 1); + assert!(retention.has_room()); + retention.record_created(); + assert!(!retention.has_room(), "the store is full"); + retention.record_dropped(); + assert!(retention.has_room(), "dropping a record makes room"); + } +} diff --git a/tests/common/logging.rs b/tests/common/logging.rs index 3b231b3cd0..1f667aae4d 100644 --- a/tests/common/logging.rs +++ b/tests/common/logging.rs @@ -192,17 +192,26 @@ impl CollectingLogWriter { self.logs.lock().unwrap().iter().filter(|message| message.contains(text)).count() } - /// Waits up to ten seconds for a logged message containing `text`, returning whether one - /// arrived. Polling beats a fixed sleep: it returns as soon as the line lands and only pays - /// the full timeout when the line never comes. + /// Every message logged so far, in order. + pub(crate) fn lines(&self) -> Vec { + self.logs.lock().unwrap().clone() + } + + /// Waits up to [`INTEROP_TIMEOUT_SECS`] for a logged message containing `text`, returning + /// whether one arrived. Polling beats a fixed sleep: it returns as soon as the line lands and + /// only pays the full timeout when the line never comes. + /// + /// [`INTEROP_TIMEOUT_SECS`]: super::INTEROP_TIMEOUT_SECS pub(crate) async fn wait_for(&self, text: &str) -> bool { self.wait_for_count(text, 1).await } - /// Waits up to ten seconds for `occurrences` logged messages containing `text`, returning - /// whether they arrived. + /// Waits up to [`INTEROP_TIMEOUT_SECS`] for `occurrences` logged messages containing `text`, + /// returning whether they arrived. + /// + /// [`INTEROP_TIMEOUT_SECS`]: super::INTEROP_TIMEOUT_SECS pub(crate) async fn wait_for_count(&self, text: &str, occurrences: usize) -> bool { - for _ in 0..100 { + for _ in 0..(super::INTEROP_TIMEOUT_SECS * 10) { if self.count(text) >= occurrences { return true; } @@ -217,3 +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 3da60800c6..b1f32af1df 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -15,11 +15,13 @@ use std::sync::{mpsc, Arc}; use std::time::Duration; use bitcoin::address::NetworkUnchecked; +use bitcoin::hashes::hex::FromHex; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; -use bitcoin::{Address, Amount, ScriptBuf, Txid}; +use bitcoin::{Address, Amount, ScriptBuf, Transaction, Txid}; use common::logging::{ - init_log_logger, validate_log_entry, CollectingLogWriter, MultiNodeLogger, TestLogWriter, + init_log_logger, validate_log_entry, CollectingLogWriter, MarkerLogWriter, MultiNodeLogger, + TestLogWriter, }; use common::{ bump_fee_and_broadcast, configure_chain_source, distribute_funds_unconfirmed, @@ -45,8 +47,12 @@ use ldk_node::payment::{ ConfirmationStatus, ForwardedPaymentId, PayerProofOptions, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType}; -use lightning::ln::channelmanager::PaymentId; +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}; use lightning::routing::router::RouteParametersConfig; use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; @@ -55,15 +61,51 @@ use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; use serde_json::json; -/// Waits until `node` has classified the funding broadcast `funding_txid` (a channel open or splice -/// candidate) into a payment record carrying a `tx_type`. Classification runs off the broadcaster's -/// queue, which can lag a `sync_wallets` call under load — and for a splice the counterparty also -/// broadcasts the same tx, so a racing sync can see it before this node classifies. Waiting here -/// keeps the next sync on the funding short-circuit instead of recording a generic on-chain payment -/// that clobbers the classification. +/// Pops the next event, panicking unless it is a `SpliceNegotiationFailed` from the given +/// counterparty, and returns its reason and parameters. +macro_rules! expect_splice_negotiation_failed_event { + ($node:expr, $counterparty_node_id:expr) => {{ + let event = tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + $node.next_event_async(), + ) + .await + .unwrap_or_else(|_| { + panic!("{} timed out waiting for SpliceNegotiationFailed event", $node.node_id()) + }); + match event { + ref e @ Event::SpliceNegotiationFailed { + counterparty_node_id, + ref reason, + ref parameters, + .. + } => { + println!("{} got event {:?}", $node.node_id(), e); + assert_eq!(counterparty_node_id, $counterparty_node_id); + let reason = reason.clone(); + let parameters = parameters.clone(); + $node.event_handled().unwrap(); + (reason, parameters) + }, + ref e => { + panic!("{} got unexpected event!: {:?}", std::stringify!($node), e); + }, + } + }}; +} + +/// Waits until `node` has recorded the funding broadcast `funding_txid` (a channel open or splice +/// candidate) as a payment carrying a `tx_type`, syncing its wallet until it has. A splice +/// candidate's payment is recorded when wallet sync first observes the transaction, so the sync is +/// what settles this; a channel open is classified off the broadcaster's queue, which can lag a +/// `sync_wallets` call under load, and waiting keeps the next sync on the funding short-circuit +/// instead of recording a generic on-chain payment that clobbers the classification. async fn wait_for_classified_funding_payment(node: &Node, funding_txid: Txid) { let poll = async { loop { + // A sync that cannot reach the transaction yet is retried rather than reported: the + // timeout below is what turns a transaction that never arrives into a failure. + let _ = node.sync_wallets(); let classified = node.list_all_payments().into_iter().any(|p| { matches!( p.kind, @@ -89,6 +131,26 @@ struct ContendedStore { serializer: Arc>, block_writes: Arc, wallet_write_started: Arc, + /// When set, only writes to this primary namespace — and, when one is named, to this key — go + /// through `serializer`; the rest bypass it. + serialized: Option<(String, Option)>, + /// The writes going through `serializer` that have not returned yet, those held back included. + serialized_in_flight: Arc, +} + +impl ContendedStore { + /// Waits for a write going through `serializer` to start — one a test holds back by holding + /// the write lock, or one on its way through. + async fn wait_for_serialized_write(&self) { + let poll = async { + while self.serialized_in_flight.load(Ordering::Acquire) == 0 { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }; + tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), poll) + .await + .expect("timed out waiting for a serialized write to start"); + } } impl KVStore for ContendedStore { @@ -105,6 +167,10 @@ impl KVStore for ContendedStore { let serializer = Arc::clone(&self.serializer); let block_writes = Arc::clone(&self.block_writes); let wallet_write_started = Arc::clone(&self.wallet_write_started); + let serialized_in_flight = Arc::clone(&self.serialized_in_flight); + let serialized = self.serialized.as_ref().map_or(true, |(namespace, only_key)| { + namespace == primary_namespace && only_key.as_deref().map_or(true, |k| k == key) + }); let primary_namespace = primary_namespace.to_string(); let secondary_namespace = secondary_namespace.to_string(); let key = key.to_string(); @@ -112,8 +178,18 @@ impl KVStore for ContendedStore { if block_writes.load(Ordering::Acquire) { wallet_write_started.notify_one(); } - let _guard = serializer.read().await; - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + let _guard = if serialized { + serialized_in_flight.fetch_add(1, Ordering::AcqRel); + Some(serializer.read().await) + } else { + None + }; + let result = + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await; + if serialized { + serialized_in_flight.fetch_sub(1, Ordering::AcqRel); + } + result } } @@ -162,6 +238,8 @@ fn wallet_store_contention_does_not_stall_runtime() { serializer: Arc::new(tokio::sync::RwLock::new(())), block_writes: Arc::new(AtomicBool::new(false)), wallet_write_started: Arc::new(tokio::sync::Notify::new()), + serialized: None, + serialized_in_flight: Arc::new(AtomicUsize::new(0)), }; let node = builder .build_with_store(test_config.node_entropy.into(), store.clone()) @@ -2282,8 +2360,6 @@ async fn splice_channel() { let txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); - // Node B contributed to this splice, so wait for its funding broadcast to be classified before - // syncing — otherwise a sync racing the broadcaster's queue records a generic on-chain payment. wait_for_classified_funding_payment(&node_b, txo.txid).await; wait_for_tx(&electrsd.client, txo.txid).await; @@ -2304,9 +2380,7 @@ async fn splice_channel() { // them to the channel balance since there may not be a change output. let expected_splice_in_lightning_balance_sat = 4_000_002; - let payments = node_b.list_all_payments(); - let payment = - payments.into_iter().find(|p| p.id == PaymentId(txo.txid.to_byte_array())).unwrap(); + let payment = funding_payment(&node_b, txo.txid); assert_eq!(payment.fee_paid_msat, Some(expected_splice_in_fee_sat * 1_000)); assert_eq!( @@ -2342,8 +2416,6 @@ async fn splice_channel() { let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); - // Node A contributed to this splice, so wait for its funding broadcast to be classified before - // syncing — otherwise a sync racing the broadcaster's queue records a generic on-chain payment. wait_for_classified_funding_payment(&node_a, txo.txid).await; wait_for_tx(&electrsd.client, txo.txid).await; @@ -2357,9 +2429,7 @@ async fn splice_channel() { let expected_splice_out_fee_sat = 183; - let payments = node_a.list_all_payments(); - let payment = - payments.into_iter().find(|p| p.id == PaymentId(txo.txid.to_byte_array())).unwrap(); + let payment = funding_payment(&node_a, txo.txid); assert_eq!(payment.fee_paid_msat, Some(expected_splice_out_fee_sat * 1_000)); // The splice-out graduated to a confirmed interactive-funding payment. Its `direction` is left // unasserted on purpose: the destination is our own address, so it is a self-transfer (channel @@ -2384,194 +2454,73 @@ async fn splice_channel() { ); } -/// Canary for the upstream behavior the zero-activity skip in `classify_funding` works around: -/// after a 0conf splice is promoted, LDK re-broadcasts the still-unconfirmed funding transaction -/// through its generic funding path — re-typed as a plain funding transaction without its -/// contribution metadata — on every monitor-update completion until it confirms. A splice-out -/// paying an external address moves no wallet funds, so the interactive-funding classification -/// declines to record it and each re-offer then arrives with nothing to record. The re-typing is -/// tracked upstream at . +/// 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. /// -/// If this test fails, upstream likely stopped re-offering the transaction that way (or now -/// preserves its interactive-funding classification): re-evaluate whether the skip still sees -/// traffic. +/// 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_splice_out_funding_rebroadcast_canary() { +async fn zero_conf_queued_splice_is_recorded_as_its_own_payment() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = random_chain_source(&bitcoind, &electrsd); - - // The skip leaves no trace in the payment stores — that is its point — so observe it through - // Node A's logs. `setup_two_nodes` wires file loggers, so build the pair manually with a - // collector, Node B trusting Node A for 0conf. - let logger_a = Arc::new(CollectingLogWriter::new()); - let mut config_a = random_config(); - config_a.log_writer = TestLogWriter::Custom(logger_a.clone()); - let node_a = setup_node(&chain_source, config_a); + let 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 premine_amount_sat = 5_000_000; + let second_address_a = node_a.onchain_payment().new_address().unwrap(); premine_and_distribute_funds( &bitcoind.client, &electrsd.client, - vec![address_a], - Amount::from_sat(premine_amount_sat), + 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; - - // 0conf: the channel is ready without any confirmations. let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); - - // Confirm the original funding so the splice below is the only unconfirmed funding. generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); - // Splice out to a third-party address: channel funds leave without touching Node A's - // on-chain wallet, so no classification path records the transaction. - let external_address = bitcoind.client.new_address().unwrap(); - node_a.splice_out(&user_channel_id_a, node_b.node_id(), &external_address, 500_000).unwrap(); - let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); - - // The 0conf splice locks without confirmations, re-signaled as `ChannelReady`. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000).unwrap(); + let first = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, first.txid).await; + // The zero-conf splice locks without confirmations, re-signaled as `ChannelReady`. expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); - - // Locking the splice completed monitor updates that re-offered the unconfirmed funding - // transaction; a payment drives further monitor updates and thus further re-broadcasts. - let amount_msat = 1_000_000; - let payment_id = - node_a.spontaneous_payment().send(amount_msat, node_b.node_id(), None).unwrap(); - expect_payment_successful_event!(node_a, payment_id, None); - expect_payment_received_event!(node_b, amount_msat); - - // Canary: the skip saw a re-offer. When this stops firing, LDK no longer re-offers the - // promoted-but-unconfirmed splice through the generic funding path. The line is also the - // synchronization point: it is the terminal action of classifying a re-offer, so once it - // appears the classification pipeline has demonstrably processed one. - let skipped = format!("Not recording channel-funding broadcast {}", txo.txid); - assert!( - logger_a.wait_for(&skipped).await, - "Node A never skipped a generic-funding re-broadcast of the promoted 0conf splice-out; if \ - upstream stopped re-offering it, re-evaluate the zero-activity skip in classify_funding" - ); - - // The re-offers must not have minted a record for a transaction the wallet has no stake in. - let splice_records = node_a.list_payments_matching( - |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == txo.txid), - ); - assert!( - splice_records.is_empty(), - "a zero-activity funding re-broadcast minted a record: {:?}", - splice_records - ); -} - -/// Canary for the upstream behavior the funding-over-interactive-funding guard in -/// `funding_reclassification_update` works around: LDK re-broadcasts a promoted-but-unconfirmed -/// 0conf splice through its generic funding path — re-typed as a plain funding transaction with -/// wallet-view figures and no contribution metadata — on every monitor-update completion until it -/// confirms. On the contributing side those re-offers target the interactive-funding record, -/// which must come through unchanged. The re-typing is tracked upstream at -/// . -/// -/// If this test fails, upstream likely stopped re-offering the transaction that way (or now -/// preserves its interactive-funding classification): re-evaluate whether the guard still sees -/// traffic. -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn zero_conf_splice_in_funding_rebroadcast_canary() { - let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = random_chain_source(&bitcoind, &electrsd); - - // The guard leaves no trace in the stores, so observe the re-offers through Node A's logs. - // `setup_two_nodes` wires file loggers, so build the pair manually with a collector, Node B - // trusting Node A for 0conf. - let logger_a = Arc::new(CollectingLogWriter::new()); - let mut config_a = random_config(); - config_a.log_writer = TestLogWriter::Custom(logger_a.clone()); - let node_a = setup_node(&chain_source, config_a); - - let mut config_b = random_config(); - config_b.node_config.trusted_peers_0conf.push(node_a.node_id()); - let node_b = setup_node(&chain_source, config_b); - - let address_a = node_a.onchain_payment().new_address().unwrap(); - let premine_amount_sat = 5_000_000; - premine_and_distribute_funds( - &bitcoind.client, - &electrsd.client, - vec![address_a], - Amount::from_sat(premine_amount_sat), - ) - .await; - node_a.sync_wallets().unwrap(); - - open_channel(&node_a, &node_b, 2_000_000, false, &electrsd).await; - - // 0conf: the channel is ready without any confirmations. - let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); - expect_channel_ready_event!(node_b, node_a.node_id()); - - // Confirm the original funding so the splice below is the only unconfirmed funding and Node - // A's change from the open is spendable for the splice contribution. - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000).unwrap(); - let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); - wait_for_classified_funding_payment(&node_a, txo.txid).await; - - // The 0conf splice locks without confirmations, re-signaled as `ChannelReady`. + 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 splice_payments = |node: &Node| { - node.list_payments_matching( - |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == txo.txid), - ) - }; - let payments = splice_payments(&node_a); - assert_eq!(payments.len(), 1); - let recorded_amount_msat = payments[0].amount_msat; - let recorded_fee_paid_msat = payments[0].fee_paid_msat; - - // Locking the splice completed monitor updates that re-offered the unconfirmed funding - // transaction; a payment drives further monitor updates and thus further re-broadcasts. - let amount_msat = 1_000_000; - let payment_id = - node_a.spontaneous_payment().send(amount_msat, node_b.node_id(), None).unwrap(); - expect_payment_successful_event!(node_a, payment_id, None); - expect_payment_received_event!(node_b, amount_msat); - - // Canary: generic re-offers of the splice reached classification while its record held the - // interactive-funding classification. Waiting for the second occurrence also makes the - // record assertions below deterministic — the broadcast loop classifies sequentially, so by - // the second arrival the first re-offer's store write has completed. - let rebroadcast = format!("funding-typed rebroadcast {}", txo.txid); - assert!( - logger_a.wait_for_count(&rebroadcast, 2).await, - "Node A saw no generic-funding re-broadcast targeting the interactive-funding record; if \ - upstream stopped re-offering it, re-evaluate the guard in funding_reclassification_update" - ); + 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 { .. }), .. } + )); + } - // The re-offers must not have disturbed the record's classification or figures. - let payments = splice_payments(&node_a); - assert_eq!(payments.len(), 1); - let payment = &payments[0]; - assert_eq!(payment.amount_msat, recorded_amount_msat); - assert_eq!(payment.fee_paid_msat, recorded_fee_paid_msat); - assert!(matches!( - payment.kind, - PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } - )); + node_a.stop().unwrap(); + node_b.stop().unwrap(); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -2651,18 +2600,21 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // replaced (a `WalletEvent::TxReplaced`), which must not drop the payment's durable funding // classification — the `tx_type` assertion below catches a regression deterministically. wait_for_tx(&electrsd.client, original_txo.txid).await; - // Node B contributed to this splice; wait for its classification before syncing so the sync - // takes the funding short-circuit rather than racing the broadcaster's queue. wait_for_classified_funding_payment(&node_b, original_txo.txid).await; + // The record's random id is fixed at creation; capture it while the original candidate is + // current so its stability can be asserted across the RBF rounds below. + let splice_payment_id = funding_payment(&node_b, original_txo.txid).id; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); // For `confirm_original`, capture the original candidate's fee and raw transaction now, before // the RBF replaces it, so it can be force-confirmed (instead of the RBF) further below. let original_candidate: Option<(Option, String)> = if confirm_original { - let payment_id = PaymentId(original_txo.txid.to_byte_array()); - let fee = - node_b.payment(&payment_id).unwrap().expect("splice payment exists").fee_paid_msat; + let fee = node_b + .payment(&splice_payment_id) + .unwrap() + .expect("splice payment exists") + .fee_paid_msat; let raw_tx: String = bitcoind .client .call("getrawtransaction", &[json!(original_txo.txid.to_string())]) @@ -2688,19 +2640,16 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // Wait for the RBF transaction to replace the original in the mempool. wait_for_tx(&electrsd.client, rbf_txo.txid).await; - // Wait for node_b's re-classification of the RBF candidate before syncing, so the recorded - // candidate figures reflect the replacement rather than racing the broadcaster's queue. wait_for_classified_funding_payment(&node_b, rbf_txo.txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); // After RBF but before confirmation, node_b (the initiator) should have a single on-chain - // payment covering both candidates: id anchored to the first broadcast, `kind.txid` pointing - // at the latest (RBF) candidate, and the durable interactive-funding `tx_type` preserved across - // the replacement. + // payment covering both candidates: still under the id it was created with, `kind.txid` + // pointing at the latest (RBF) candidate, and the durable interactive-funding `tx_type` + // preserved across the replacement. let rbf_candidate_fee = { - let payment_id = PaymentId(original_txo.txid.to_byte_array()); - let payment = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + let payment = node_b.payment(&splice_payment_id).unwrap().expect("splice payment exists"); match payment.kind { PaymentKind::Onchain { txid, @@ -2774,8 +2723,8 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // channel-lifecycle signal, not what drives payment status. Its `kind.txid` reflects the // winning RBF candidate, and `fee_paid_msat` carries this node's `FundingContribution` fee. { - let payment_id = PaymentId(original_txo.txid.to_byte_array()); - let payment = node_b.payment(&payment_id).unwrap().expect("splice payment graduated"); + let payment = + node_b.payment(&splice_payment_id).unwrap().expect("splice payment graduated"); assert_eq!(payment.status, PaymentStatus::Succeeded); match payment.kind { PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } => { @@ -2835,8 +2784,7 @@ async fn funding_payment_graduates_without_channel_ready() { // The funding payment is `Succeeded` purely from wallet sync reaching `ANTI_REORG_DELAY` // confirmations, asserted before draining any LDK event — so graduation is not driven by the // Lightning `ChannelReady` signal. - let payment_id = PaymentId(funding_txo.txid.to_byte_array()); - let payment = node_a.payment(&payment_id).unwrap().expect("funding payment exists"); + let payment = funding_payment(&node_a, funding_txo.txid); assert_eq!(payment.status, PaymentStatus::Succeeded); match payment.kind { PaymentKind::Onchain { @@ -2889,8 +2837,8 @@ async fn splice_payment_reorged_to_unconfirmed() { node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); let splice_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); wait_for_tx(&electrsd.client, splice_txo.txid).await; - // Ensure node_b classified the splice before syncing so the test exercises a funding payment's - // reorg rather than a generic on-chain payment's. + // node_b recorded the splice's funding payment when signing it, so the sync below exercises a + // funding payment's reorg rather than a generic on-chain payment's. wait_for_classified_funding_payment(&node_b, splice_txo.txid).await; // Confirm the splice with a single block — confirmed, but short of `ANTI_REORG_DELAY`, so the @@ -2898,8 +2846,8 @@ async fn splice_payment_reorged_to_unconfirmed() { generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; node_b.sync_wallets().unwrap(); - let payment_id = PaymentId(splice_txo.txid.to_byte_array()); - let payment = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + let payment = funding_payment(&node_b, splice_txo.txid); + let payment_id = payment.id; assert_eq!(payment.status, PaymentStatus::Pending); assert!(matches!( payment.kind, @@ -2981,6 +2929,1521 @@ async fn splice_in_rbf_joins_counterparty_splice() { node_b.stop().unwrap(); } +/// Builds and starts a node over a [`ContendedStore`], whose writes — all of them, or only those +/// to the primary namespace `serialized` names and, when it names one, its key — a test holds back +/// by taking the store's `serializer` write lock, logging into a [`CollectingLogWriter`]. +fn setup_contended_node( + chain_source: &TestChainSource, mut config: TestConfig, + serialized: Option<(&str, Option<&str>)>, +) -> (TestNode, ContendedStore, Arc) { + let logs = Arc::new(CollectingLogWriter::new()); + config.log_writer = TestLogWriter::Custom(logs.clone()); + let store = ContendedStore { + inner: Arc::new(InMemoryStore::new()), + serializer: Arc::new(tokio::sync::RwLock::new(())), + block_writes: Arc::new(AtomicBool::new(false)), + wallet_write_started: Arc::new(tokio::sync::Notify::new()), + serialized: serialized + .map(|(namespace, key)| (namespace.to_string(), key.map(str::to_string))), + serialized_in_flight: Arc::new(AtomicUsize::new(0)), + }; + setup_builder!(builder, config.node_config); + common::configure_chain_source(chain_source, &mut builder, &config); + if let TestLogWriter::Custom(writer) = &config.log_writer { + builder.set_custom_logger(Arc::clone(writer)); + } + let node = builder.build_with_store(config.node_entropy.into(), store.clone()).unwrap(); + node.start().unwrap(); + (node, store, logs) +} + +/// Has `node_b` fund a channel to `node_a` and a splice into it, leaving `node_a` to join that +/// pending splice. `node_a` gets one small UTXO and `node_b` one large one; `node_b` opens the +/// channel and splices in from its change. A `splice_in` by `node_a` then joins the pending splice +/// as an RBF round it initiates, whose contributed input value — the shared funding, which the +/// initiator counts as its own, plus `node_a`'s UTXO — is the smaller, so `node_a` sends its +/// `tx_signatures` first. Returns `node_a`'s id for the channel and the txid of `node_b`'s round. +async fn open_and_splice_from_counterparty( + bitcoind: &BitcoinD, electrsd: &ElectrsD, node_a: &TestNode, node_b: &TestNode, +) -> (UserChannelId, Txid) { + let address_a = node_a.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(1_000_000), + ) + .await; + let address_b = node_b.onchain_payment().new_address().unwrap(); + distribute_funds_unconfirmed( + &bitcoind.client, + &electrsd.client, + vec![address_b], + Amount::from_sat(10_000_000), + ) + .await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(node_b, node_a, 500_000, false, electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + let counterparty_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, counterparty_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + (user_channel_id_a, counterparty_txo.txid) +} + +/// The transaction of the only splice round `logs` show the node having recorded at signing. A +/// round is recorded before it is signed, so this names the round while nothing has broadcast it +/// and no wallet has seen it — before there is a payment record to read it from. A round the node +/// contributed nothing to records nothing and is not named here. +fn only_signed_round_txid(logs: &CollectingLogWriter) -> Txid { + let prefix = format!("{} ", RECORDED_SIGNED_ROUND); + let mut txids = logs.lines().into_iter().filter_map(|line| { + let rest = line.strip_prefix(&prefix)?; + Txid::from_str(rest.split(' ').next()?).ok() + }); + let txid = txids.next().expect("no signed splice round recorded"); + assert_eq!(txids.next(), None, "more than one signed splice round recorded"); + txid +} + +/// `node`'s payment for the funding transaction `funding_txid`, which it must have recorded. +/// Funding records are keyed by a random id generated at creation, so they are found through their +/// transaction history rather than by deriving an id from a txid. +fn funding_payment(node: &TestNode, funding_txid: Txid) -> PaymentDetails { + node.list_all_payments() + .into_iter() + .find(|p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == funding_txid)) + .unwrap_or_else(|| panic!("no payment recorded for funding transaction {}", funding_txid)) +} + +/// The transaction `txid` once bitcoind accepted it and electrs serves it. +async fn wait_for_transaction(bitcoind: &BitcoinD, electrsd: &ElectrsD, txid: Txid) -> Transaction { + wait_for_tx(&electrsd.client, txid).await; + decode_transaction(&raw_transaction_hex(bitcoind, txid)) + .expect("bitcoind served bytes that do not encode a transaction") +} + +/// The transaction `hex` encodes, if it encodes one. +fn decode_transaction(hex: &str) -> Option { + Vec::::from_hex(hex) + .ok() + .and_then(|bytes| bitcoin::consensus::encode::deserialize::(&bytes).ok()) +} + +/// A commitment transaction of the channel funded by `funding_txo`, once bitcoind holds it in its +/// mempool. A splice round spending the same funding may sit there, which the commitment replaces +/// only once that round is deprioritised, see [`deprioritise_transaction`]. +async fn wait_for_commitment(bitcoind: &BitcoinD, funding_txo: bitcoin::OutPoint) -> Transaction { + let poll = async { + loop { + let mempool: Vec = + bitcoind.client.call("getrawmempool", &[]).expect("failed to list the mempool"); + for txid in mempool { + // The transaction may leave the mempool between the two calls. + let hex: Result = + bitcoind.client.call("getrawtransaction", &[json!(txid)]); + if let Some(tx) = hex + .ok() + .and_then(|hex| decode_transaction(&hex)) + .filter(|tx| is_commitment(tx, funding_txo)) + { + return tx; + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }; + tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), poll) + .await + .unwrap_or_else(|_| panic!("timed out waiting for the commitment to be broadcast")) +} + +/// Has bitcoind count the fee of the mempool transaction `txid` as far below zero, so that a +/// transaction conflicting with it replaces it however little it pays: the replacement checks +/// compare against the modified fee. Lets a test take a transaction from the mempool that bitcoind +/// would otherwise refuse — a commitment transaction while a splice round spending the same funding +/// sits there, or a splice round paying little more than the round it joins. +fn deprioritise_transaction(bitcoind: &BitcoinD, txid: Txid) { + let _: bool = bitcoind + .client + .call("prioritisetransaction", &[json!(txid.to_string()), json!(0), json!(-100_000_000i64)]) + .expect("failed to deprioritise the transaction"); +} + +/// Whether `tx` spends `outpoint`. +fn spends(tx: &Transaction, outpoint: bitcoin::OutPoint) -> bool { + tx.input.iter().any(|input| input.previous_output == outpoint) +} + +/// Whether `tx` is a commitment transaction of the channel funded by `funding_txo`: it spends the +/// funding, and the upper byte of its locktime is the 0x20 BOLT 3 prescribes, where a splice round +/// spending the same funding carries a block height. +fn is_commitment(tx: &Transaction, funding_txo: bitcoin::OutPoint) -> bool { + spends(tx, funding_txo) && tx.lock_time.to_consensus_u32() >> 24 == 0x20 +} + +/// Mines a block holding `tx`, whatever the mempool holds — a transaction conflicting with it may +/// sit there, which the block then evicts. +fn mine_transaction(bitcoind: &BitcoinD, tx: &Transaction) { + let address = bitcoind.client.new_address().expect("failed to get new address"); + let hex = bitcoin::consensus::encode::serialize_hex(tx); + let _: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(address.to_string()), json!([hex])]) + .expect("failed to mine the transaction"); +} + +/// The raw transaction `txid`, as bitcoind holds it. +fn raw_transaction_hex(bitcoind: &BitcoinD, txid: Txid) -> String { + bitcoind + .client + .call("getrawtransaction", &[json!(txid.to_string())]) + .expect("failed to fetch the transaction") +} + +/// Mines a block holding the transactions `hexes` encode and nothing else — an empty block for +/// none — whatever the mempool holds, and waits for electrs to see it. +async fn mine_block_with(bitcoind: &BitcoinD, electrsd: &ElectrsD, hexes: &[String]) { + let height = + bitcoind.client.get_blockchain_info().expect("failed to get blockchain info").blocks + as usize; + let address = bitcoind.client.new_address().expect("failed to get new address"); + let _: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(address.to_string()), json!(hexes)]) + .expect("failed to mine the block"); + wait_for_block(&bitcoind.client, &electrsd.client, height + 1).await; +} + +/// Waits for `node` to have no peer left, connected or known: a peer's leaving is handled after +/// the connection drops. +async fn wait_for_no_peers(node: &TestNode) { + let poll = async { + while !node.list_peers().is_empty() { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }; + tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), poll) + .await + .expect("timed out waiting for the node's peers to leave"); +} + +/// A channel with two broadcast rounds of one splice, as [`open_and_join_counterparty_splice`] +/// leaves it. +struct TwoRoundSplice { + user_channel_id_a: UserChannelId, + /// The round node B initiated, which node A did not contribute to. + first_txid: Txid, + first_tx: Transaction, + /// The round node A initiated to join the splice, replacing the first. + rbf_txid: Txid, + rbf_tx: Transaction, +} + +/// Funds both nodes, has `node_a` open a channel to `node_b`, `node_b` splice into it, and `node_a` +/// join that splice with a fee-bumping round of its own, as +/// [`splice_in_rbf_joins_counterparty_splice`] does. Both rounds are broadcast, so both are in +/// `node_a`'s record of the splice, and both are returned in full so either can be mined: the first +/// round is deprioritised so that the mempool takes the joining round, which pays little more. +async fn open_and_join_counterparty_splice( + bitcoind: &BitcoinD, electrsd: &ElectrsD, node_a: &TestNode, node_b: &TestNode, +) -> TwoRoundSplice { + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(5_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(node_a, node_b, 4_000_000, false, electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + let first_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); + let first_tx = wait_for_transaction(bitcoind, electrsd, first_txo.txid).await; + wait_for_classified_funding_payment(node_b, first_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + deprioritise_transaction(bitcoind, first_txo.txid); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 100_000).unwrap(); + let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + assert_ne!(first_txo, rbf_txo, "node A's round should replace node B's"); + let rbf_tx = wait_for_transaction(bitcoind, electrsd, rbf_txo.txid).await; + wait_for_classified_funding_payment(node_a, rbf_txo.txid).await; + wait_for_classified_funding_payment(node_b, rbf_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + TwoRoundSplice { + user_channel_id_a, + first_txid: first_txo.txid, + first_tx, + rbf_txid: rbf_txo.txid, + rbf_tx, + } +} + +/// Builds and starts a node logging into a [`CollectingLogWriter`]. +fn setup_logged_node( + chain_source: &TestChainSource, mut config: TestConfig, +) -> (TestNode, Arc) { + let logs = Arc::new(CollectingLogWriter::new()); + config.log_writer = TestLogWriter::Custom(logs.clone()); + (setup_node(chain_source, config), logs) +} + +/// Logged by a node once it has signed a splice round of its own. +const SIGNED_FUNDING: &str = "Signed funding transaction for channel"; +/// Logged by a node as it records a splice round it is about to sign, naming the round's +/// transaction. +const RECORDED_SIGNED_ROUND: &str = "Recorded signed splice funding"; +/// Logged by a node once LDK reports a splice round it recorded when signing negotiated, and the +/// round's funding payment no longer awaits its broadcast. +const ROUND_MARKED_BROADCAST: &str = "Marked splice round"; +/// Logged by LDK's channel manager as it hands a fully signed splice round to the broadcaster. +const BROADCAST_FUNDING: &str = "Broadcasting interactively funded transaction with txid"; +/// Logged by LDK's peer handler when the counterparty's `tx_signatures` arrive. +const RECEIVED_TX_SIGNATURES: &str = "Received message TxSignatures"; +/// Logged by LDK's peer handler when the counterparty's `commitment_signed` arrives. +const RECEIVED_COMMITMENT_SIGNED: &str = "Received message CommitmentSigned"; +/// Logged by a node as it leaves a funding payment on a round of its own that can still confirm +/// while resolving the channel's funding payments, at a promotion or at the close. +const ROUND_CAN_STILL_CONFIRM: &str = "of ours can still confirm"; +/// Logged by a node as it fails a funding payment none of whose rounds can confirm anymore. +const NO_ROUND_CAN_CONFIRM: &str = "no round of ours can confirm"; +/// Logged by a node as it drops a signed round nothing ever broadcast. +const DROPPED_ABANDONED_ROUND: &str = "Dropped abandoned splice round(s)"; +/// Logged by a node as it resolves a funding payment of a closed channel by the rounds the +/// channel's monitor holds, however it does: at `ChannelClosed`, and for a round LDK discards after +/// the close. +const CLOSED_CHANNEL_PAYMENT_RESOLVED: &str = "of closed channel"; +/// Logged by a node as it resolves a funding payment of an open channel by the rounds LDK holds +/// once it promoted a splice round to the channel's funding, however it does. +const PROMOTED_ROUND_PAYMENT_RESOLVED: &str = "once splice round"; +/// Logged by a node as it returns the addresses of a contribution LDK discarded to the wallet. +const RECLAIMED_ADDRESSES: &str = "Reclaiming unused addresses from channel"; +/// Logged by a node once it has decided the funding payments of a closed channel by the rounds the +/// channel's monitor holds, at `ChannelClosed` and for a round LDK discards after the close. Unlike +/// [`CLOSED_CHANNEL_PAYMENT_RESOLVED`], logged whatever was found, so also when no payment of the +/// channel is left to resolve. +const CLOSED_CHANNEL_ROUNDS_RESOLVED: &str = "round(s) its monitor holds"; +/// Logged by a node as it records that LDK promoted a splice round of ours to the channel's +/// funding. +const ROUND_LOCKED: &str = "locked as the funding of channel"; + +/// A splice round this node signed keeps its place in the channel's recorded splice history when +/// the channel closes before the counterparty's `tx_signatures` arrive, if the channel's monitor +/// watches the round. The monitor does so from the counterparty's `commitment_signed` on, and this +/// node's signatures cannot have left before that message, so the counterparty may hold the fully +/// signed transaction and broadcast it. Taking the round back at `ChannelClosed` — as the handler +/// did for every round but the channel's last funding — left such a broadcast to resurface as an +/// untyped payment. The sibling +/// [`signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close`] shows the same close +/// dropping a round the monitor never watched, so this is a decision the close makes, not one it +/// never reaches. +/// +/// The state is reached by holding back store writes, which each node's event handler makes +/// before it signs: node A's provenance writes first, so it signs only after node B has +/// signed and sent its `commitment_signed` — its other writes go through, so a pending monitor +/// update cannot freeze the channel's own messages; then all of node B's, so the monitor update +/// its copy of node A's `commitment_signed` needs never completes and node B withholds its +/// `tx_signatures` on receiving node A's. Node A sends its `tx_signatures` first, see +/// [`open_and_splice_from_counterparty`]. Pinned to Esplora so node A's wallet syncs only on +/// demand. +/// +/// The round survives the close settling too: node A's commitment transaction confirms and its +/// `to_self_delay` passes, and the monitor stops watching the round and reports it discarded. LDK +/// reports `SpliceNegotiated` for this round after `ChannelClosed`, node A having sent its +/// `tx_signatures`, so the node clears the round's awaiting-broadcast mark and the round is not +/// dropped at maturity as one nothing broadcast. The test's own tail shows node B does broadcast +/// the round, which is why keeping it is right. No payment record is written for a round nothing +/// has broadcast — wallet sync creates one when it observes the transaction — so what is kept is +/// the round's place in the record, which the mark cleared after the close reports. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, store_a, logs_a) = + setup_contended_node(&chain_source, random_config(), Some(("channel_tx_facts", None))); + let (node_b, store_b, logs_b) = setup_contended_node(&chain_source, random_config(), None); + let (user_channel_id_a, counterparty_round) = + open_and_splice_from_counterparty(&bitcoind, &electrsd, &node_a, &node_b).await; + + // Both nodes signed and exchanged signatures for node B's splice already; count from here. + let signed_a = logs_a.count(SIGNED_FUNDING); + let signed_b = logs_b.count(SIGNED_FUNDING); + let received_a = logs_a.count(RECEIVED_TX_SIGNATURES); + let received_b = logs_b.count(RECEIVED_TX_SIGNATURES); + let broadcast_b = logs_b.count(BROADCAST_FUNDING); + let resolved_a = logs_a.count(CLOSED_CHANNEL_ROUNDS_RESOLVED); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000).unwrap(); + // Recording the round writes what the transaction is before the round is signed, so node A does + // not sign while those writes are held, and node B's `commitment_signed` is stashed until it + // has. + let hold_a = Arc::clone(&store_a.serializer).write_owned().await; + assert!(logs_b.wait_for_count(SIGNED_FUNDING, signed_b + 1).await, "node B never signed"); + // Node B has sent its `commitment_signed`. Its next write is the monitor update for node A's, + // which it needs before it releases its own `tx_signatures`. + let hold_b = Arc::clone(&store_b.serializer).write_owned().await; + drop(hold_a); + assert!(logs_a.wait_for_count(SIGNED_FUNDING, signed_a + 1).await, "node A never signed"); + assert!( + logs_b.wait_for_count(RECEIVED_TX_SIGNATURES, received_b + 1).await, + "node A's signatures never reached node B" + ); + assert_eq!( + logs_a.count(RECEIVED_TX_SIGNATURES), + received_a, + "node B did not withhold its signatures" + ); + let rbf_txid = only_signed_round_txid(&logs_a); + let funding_txo = node_a + .list_channels() + .into_iter() + .find(|channel| channel.user_channel_id == user_channel_id_a) + .and_then(|channel| channel.funding_txo) + .expect("the channel has a funding"); + + // Node B's round, which spends the same funding, sits in the mempool: let the commitment + // replace it rather than be refused. + deprioritise_transaction(&bitcoind, counterparty_round); + node_a.disconnect(node_b.node_id()).unwrap(); + node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + expect_event!(node_a, ChannelClosed); + let new_funding_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + assert_eq!(new_funding_txo.txid, rbf_txid, "LDK reported a different round negotiated"); + // The mark is cleared in the record that holds the round, so clearing it is itself evidence + // that the closed channel's record still holds the round. + let round_marked = format!("{} {} of channel", ROUND_MARKED_BROADCAST, rbf_txid); + assert!( + logs_a.wait_for(&round_marked).await, + "the round's awaiting-broadcast mark was not cleared" + ); + + // The close resolves the channel's rounds by the ones its monitor holds, and leaves this one + // where it is: the monitor watches it, so the counterparty can still release it. + let round_dropped = format!("{} [{}]", DROPPED_ABANDONED_ROUND, rbf_txid); + assert!( + logs_a.wait_for_count(CLOSED_CHANNEL_ROUNDS_RESOLVED, resolved_a + 1).await, + "the close did not resolve the channel's splice rounds" + ); + assert!(!logs_a.contains(&round_dropped), "the signed round was taken back with the channel"); + + // The close settles next: node A's commitment transaction, which replaced node B's first + // round in the mempool, is mined. The monitor settles a close by node A's own commitment only + // once the `to_self_delay` on its balance has passed, not after the six blocks that settle a + // counterparty's; it then reports the rounds it watched as discarded and the channel's rounds + // are resolved once more — and the round stays, its awaiting-broadcast mark having been + // cleared, so it is not one nothing ever broadcast. + let commitment = wait_for_commitment(&bitcoind, funding_txo).await; + mine_transaction(&bitcoind, &commitment); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, BREAKDOWN_TIMEOUT as usize).await; + node_a.sync_wallets().unwrap(); + assert!( + logs_a.wait_for_count(CLOSED_CHANNEL_ROUNDS_RESOLVED, resolved_a + 2).await, + "the matured close did not resolve the channel's splice rounds again" + ); + assert!(!logs_a.contains(&round_dropped), "a round node B could broadcast was dropped"); + + // With its monitor update through, node B holds both signature sets and hands the round to its + // broadcaster on its own — too late to confirm, the commitment having spent the funding — so + // the kept record described a round the counterparty could release without this node. + drop(hold_b); + assert!( + logs_b.wait_for_count(BROADCAST_FUNDING, broadcast_b + 1).await, + "node B never broadcast the round it held both signature sets for" + ); + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice round this node broadcast dies with the channel when the close confirms instead: once +/// the close settles — for a commitment of the node's own, when its `to_self_delay` has passed — +/// the channel's monitor reports the round discarded, and its funding payment is failed: a +/// transaction that existed and lost, unlike a round nothing ever broadcast, whose record is +/// dropped. Pinned to Esplora so the wallet syncs only on demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn broadcast_splice_round_lost_to_a_close_fails_its_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, logs_a) = setup_logged_node(&chain_source, random_config()); + let node_b = setup_node(&chain_source, random_config()); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(5_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + let funding_txo = node_a + .list_channels() + .into_iter() + .find(|channel| channel.user_channel_id == user_channel_id_a) + .and_then(|channel| channel.funding_txo) + .expect("the channel has a funding"); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap(); + let splice_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_tx(&electrsd.client, splice_txo.txid).await; + wait_for_classified_funding_payment(&node_a, splice_txo.txid).await; + node_a.sync_wallets().unwrap(); + assert_eq!(funding_payment(&node_a, splice_txo.txid).status, PaymentStatus::Pending); + + // The splice round spends the funding too and sits in the mempool: let the commitment replace + // it rather than be refused. The close settles once the `to_self_delay` on node A's balance + // passes. + deprioritise_transaction(&bitcoind, splice_txo.txid); + node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + expect_event!(node_a, ChannelClosed); + let commitment = wait_for_commitment(&bitcoind, funding_txo).await; + mine_transaction(&bitcoind, &commitment); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, BREAKDOWN_TIMEOUT as usize).await; + node_a.sync_wallets().unwrap(); + + assert!(logs_a.wait_for(NO_ROUND_CAN_CONFIRM).await, "the lost round's payment was not failed"); + let payment = funding_payment(&node_a, splice_txo.txid); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } + )); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice round of ours that confirms after the channel closed keeps its payment when the +/// monitor discards the splice's other rounds: the confirmed round became the closed channel's +/// funding, and the payment reports it. Node A joined node B's splice with a fee-bumping round, +/// then force-closed; its round is mined ahead of the commitment transaction. Pinned to Esplora so +/// the wallet syncs only on demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_round_confirmed_after_a_close_keeps_its_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, logs_a) = setup_logged_node(&chain_source, random_config()); + let node_b = setup_node(&chain_source, random_config()); + let splice = open_and_join_counterparty_splice(&bitcoind, &electrsd, &node_a, &node_b).await; + assert_eq!(funding_payment(&node_a, splice.rbf_txid).status, PaymentStatus::Pending); + + node_a.force_close_channel(&splice.user_channel_id_a, node_b.node_id(), None).unwrap(); + expect_event!(node_a, ChannelClosed); + mine_transaction(&bitcoind, &splice.rbf_tx); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + node_a.sync_wallets().unwrap(); + + // The close kept the payment, its round watched; the other round's discard, which the monitor + // queues as the round of ours settles, is handled while the sync graduates the payment: before + // the sync records the confirmation, between that and the graduation, or once the graduation + // has removed the pending entry, when the handler finds no payment to leave a line for. The + // decision is logged in every case, once at the close and once for the discard. + assert!( + logs_a.wait_for_count(CLOSED_CHANNEL_ROUNDS_RESOLVED, 2).await, + "the other round's discard was not handled" + ); + assert!(!logs_a.contains(NO_ROUND_CAN_CONFIRM), "the confirmed round's payment was failed"); + let payment = funding_payment(&node_a, splice.rbf_txid); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + assert!( + !node_a.list_all_payments().iter().any( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == splice.first_txid) + ), + "a round node A did not contribute to got a payment of its own" + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice round of ours that loses to a sibling round on a channel that stays open has its +/// payment failed as the sibling's lock is handled: LDK holds the sibling alone by then, so no +/// round we contributed to can confirm anymore, and the discard LDK queues with the lock returns +/// what our round reserved. Node A joined node B's splice with a fee-bumping round; node B's round +/// is mined instead. Node B, which contributed to both rounds, keeps its payment, which reports the +/// round that confirmed. Pinned to Esplora so the wallets sync only on demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_round_superseded_on_an_open_channel_fails_its_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, logs_a) = setup_logged_node(&chain_source, random_config()); + let node_b = setup_node(&chain_source, random_config()); + let splice = open_and_join_counterparty_splice(&bitcoind, &electrsd, &node_a, &node_b).await; + + mine_transaction(&bitcoind, &splice.first_tx); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + assert!(logs_a.wait_for(NO_ROUND_CAN_CONFIRM).await, "the superseded round was not failed"); + assert!( + logs_a.lines().iter().any(|line| line.contains(NO_ROUND_CAN_CONFIRM) + && line.contains(PROMOTED_ROUND_PAYMENT_RESOLVED)), + "the promotion did not fail the payment" + ); + assert!( + logs_a.wait_for(RECLAIMED_ADDRESSES).await, + "the discarded round's addresses were not reclaimed" + ); + let payment = funding_payment(&node_a, splice.rbf_txid); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } + )); + let channel = node_a + .list_channels() + .into_iter() + .find(|channel| channel.user_channel_id == splice.user_channel_id_a) + .expect("the channel stays open"); + assert_eq!(channel.funding_txo.map(|txo| txo.txid), Some(splice.first_txid)); + + let payment_b = funding_payment(&node_b, splice.first_txid); + assert_eq!(payment_b.status, PaymentStatus::Succeeded); + assert!(matches!( + payment_b.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice round this node signed is taken back at `ChannelClosed` when the counterparty's +/// `commitment_signed` never arrived. The round is recorded at signing, which LDK triggers at +/// `tx_complete`, before that message, and the monitor watches no round that message never +/// reached; this node's signatures cannot have left for such a round, so nothing can broadcast +/// it. Node B's writes are held from before the join: recording a round precedes signing it, so +/// node B never signs, never sends its `commitment_signed`, and node A's monitor never learns of +/// the round. +/// +/// LDK reports the round itself after `ChannelClosed`: a `DiscardFunding` for node A's +/// contribution, whose handling reclaims its addresses, and a `SpliceNegotiationFailed` the node +/// 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() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, _store_a, logs_a) = setup_contended_node(&chain_source, random_config(), None); + let (node_b, store_b, logs_b) = setup_contended_node(&chain_source, random_config(), None); + let (user_channel_id_a, _) = + open_and_splice_from_counterparty(&bitcoind, &electrsd, &node_a, &node_b).await; + + let signed_a = logs_a.count(SIGNED_FUNDING); + let signed_b = logs_b.count(SIGNED_FUNDING); + let committed_a = logs_a.count(RECEIVED_COMMITMENT_SIGNED); + let reclaimed_a = logs_a.count(RECLAIMED_ADDRESSES); + + let hold_b = Arc::clone(&store_b.serializer).write_owned().await; + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000).unwrap(); + assert!(logs_a.wait_for_count(SIGNED_FUNDING, signed_a + 1).await, "node A never signed"); + let rbf_txid = only_signed_round_txid(&logs_a); + assert_eq!(logs_b.count(SIGNED_FUNDING), signed_b, "node B signed with its writes held"); + assert_eq!( + logs_a.count(RECEIVED_COMMITMENT_SIGNED), + committed_a, + "node B's commitment_signed reached node A" + ); + + node_a.disconnect(node_b.node_id()).unwrap(); + node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + expect_event!(node_a, ChannelClosed); + 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" + ); + + // The round is taken back from the channel's record: the monitor never watched it, so node + // B's `commitment_signed` never arrived, this node's signatures never left it, and nothing + // can broadcast it. + assert!( + logs_a.wait_for(&format!("{} [{}]", DROPPED_ABANDONED_ROUND, rbf_txid)).await, + "the round the monitor never watched was kept" + ); + assert!( + node_a + .list_all_payments() + .iter() + .all(|p| !matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == rbf_txid)), + "a payment was left behind for a round nothing can broadcast" + ); + + drop(hold_b); + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A zero-conf splice round of ours stays recorded when the channel closes after a later splice +/// built on it. LDK promoted the round to the funding as `splice_locked` was exchanged, before its +/// transaction confirmed, and moved on again as the later splice locked, so at the close neither +/// the channel manager nor the monitor holds the round — although it can still confirm, the later +/// round and the commitment transaction both descending from it. Node A splices into its zero-conf +/// channel with node B, then splices out of it, and force-closes before either round confirms; the +/// first round's payment is kept, and both graduate once the rounds confirm. Pinned to Esplora so +/// the wallet syncs only on demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn superseded_zero_conf_splice_round_keeps_its_payment_at_close() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, logs_a) = setup_logged_node(&chain_source, random_config()); + let mut config_b = random_config(); + config_b.node_config.trusted_peers_0conf.push(node_a.node_id()); + let node_b = setup_node(&chain_source, config_b); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(5_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 2_000_000, false, &electrsd).await; + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + // Confirm the original funding so the splices below are the only unconfirmed rounds and node + // A's change from the open is spendable for the splice-in. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000).unwrap(); + let first = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, first.txid).await; + // The zero-conf splice locks without confirmations, re-signaled as `ChannelReady`, and node A + // records the promotion as it handles it. + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + assert_eq!(logs_a.count(ROUND_LOCKED), 1, "the promotion of the first round was not recorded"); + + let address = node_a.onchain_payment().new_address().unwrap(); + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 500_000).unwrap(); + let second = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, second.txid).await; + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + assert_eq!(logs_a.count(ROUND_LOCKED), 2, "the promotion of the second round was not recorded"); + assert_eq!(funding_payment(&node_a, first.txid).status, PaymentStatus::Pending); + assert_eq!(funding_payment(&node_a, second.txid).status, PaymentStatus::Pending); + + node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + expect_event!(node_a, ChannelClosed); + assert!( + logs_a.wait_for_count(CLOSED_CHANNEL_PAYMENT_RESOLVED, 2).await, + "the close did not resolve both funding payments" + ); + assert!(!logs_a.contains(NO_ROUND_CAN_CONFIRM), "the superseded round's payment was failed"); + assert_eq!(funding_payment(&node_a, first.txid).status, PaymentStatus::Pending); + assert_eq!(funding_payment(&node_a, second.txid).status, PaymentStatus::Pending); + + // Both rounds confirm, the second spending the first, and the payments graduate. Six blocks are + // the exact minimum, so wait for the rounds to reach the chain source before mining them. + wait_for_tx(&electrsd.client, second.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + for txid in [first.txid, second.txid] { + let payment = funding_payment(&node_a, txid); + assert_eq!(payment.status, PaymentStatus::Succeeded, "round {} did not graduate", txid); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// The monitor's `DiscardFunding` events for the rounds of a closed channel's splice reach the +/// handler ahead of the channel's `ChannelClosed` when one sync delivers the close and its +/// maturity: the channel manager polls the monitor's report of the close at the start of each event +/// pass and on peer traffic, and the monitor's own events are handled right after the manager's. +/// Each event then finds the channel listed and, both rounds having been broadcast, only returns +/// the round's contribution, leaving the payment to the `ChannelClosed` that follows, which fails +/// it, no round of ours being watched anymore. Node A splices into its channel with node B and +/// bumps the round's fee from another coin, so the two rounds are contributions of their own; node +/// B closes while node A's event handler sits in a held event-queue write — for a channel node C +/// opened to it — until the close and its maturity are synced. Pinned to Esplora so the wallet +/// syncs only on demand. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let node_b = setup_node(&chain_source, random_config()); + // Keeping no anchor reserve back from node B, node A's splice-in takes its whole balance and + // leaves no change for a fee bump to draw on. + let mut config_a = random_config(); + config_a.node_config.anchor_channels_config.trusted_peers_no_reserve.push(node_b.node_id()); + let (node_a, store_a, logs_a) = + setup_contended_node(&chain_source, config_a, Some(("", Some("events")))); + let node_c = setup_node(&chain_source, random_config()); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let address_c = node_c.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b, address_c], + Amount::from_sat(1_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + let funding_txo = open_channel(&node_a, &node_b, 600_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + // Node B contributes nothing to either round, so only node A hears of them. + node_a.splice_in_with_all(&user_channel_id_a, node_b.node_id()).unwrap(); + let first_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + let first_round = wait_for_transaction(&bitcoind, &electrsd, first_txo.txid).await; + wait_for_classified_funding_payment(&node_a, first_txo.txid).await; + assert_eq!(first_round.output.len(), 1, "the splice-in left change"); + // The wallet learns the round from the sync and gets a fresh coin for the bump, which then + // spends nothing of the first round's but the funding. + node_a.sync_wallets().unwrap(); + let coin_address = node_a.onchain_payment().new_address().unwrap(); + let coin_txid = distribute_funds_unconfirmed( + &bitcoind.client, + &electrsd.client, + vec![coin_address], + Amount::from_sat(3_000_000), + ) + .await; + mine_block_with(&bitcoind, &electrsd, &[raw_transaction_hex(&bitcoind, coin_txid)]).await; + node_a.sync_wallets().unwrap(); + + // The bump pays little more than the first round, which bitcoind may refuse to replace for it: + // have it replaced, so the mempool serves the bump. + deprioritise_transaction(&bitcoind, first_txo.txid); + node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id()).unwrap(); + let bump_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + assert_ne!(first_txo, bump_txo, "the bump produced the same funding"); + wait_for_classified_funding_payment(&node_a, bump_txo.txid).await; + let bump_round = wait_for_transaction(&bitcoind, &electrsd, bump_txo.txid).await; + let shared: Vec<_> = bump_round + .input + .iter() + .map(|input| input.previous_output) + .filter(|outpoint| spends(&first_round, *outpoint)) + .collect(); + assert_eq!(shared, vec![funding_txo], "the bump reused an input of the first round"); + let payment = funding_payment(&node_a, bump_txo.txid); + assert_eq!(payment.status, PaymentStatus::Pending); + let payment_id = payment.id; + + // Neither node reconnects to the other: node B closes on its own and node A learns of the + // close from the chain alone. The commitment conflicts with the bump in the mempool: let it + // replace the bump rather than be refused; it is mined in a block of the test's own, below. + deprioritise_transaction(&bitcoind, bump_txo.txid); + node_a.disconnect(node_b.node_id()).unwrap(); + node_b.disconnect(node_a.node_id()).unwrap(); + node_b.force_close_channel(&user_channel_id_b, node_a.node_id(), None).unwrap(); + expect_event!(node_b, ChannelClosed); + let commitment = wait_for_commitment(&bitcoind, funding_txo).await; + node_b.stop().unwrap(); + + // Node A's event handler is held in the write queueing node C's channel for the user, so + // nothing polls the monitor's report of the close until it is released. Node C leaves before + // the close is mined: a peer's messages, or its leaving, would have node A poll too. + let hold_a = Arc::clone(&store_a.serializer).write_owned().await; + let listening_address = node_a.listening_addresses().unwrap().first().unwrap().clone(); + node_c.open_channel(node_a.node_id(), listening_address, 500_000, None, None).unwrap(); + expect_channel_pending_event!(node_c, node_a.node_id()); + store_a.wait_for_serialized_write().await; + node_c.stop().unwrap(); + wait_for_no_peers(&node_a).await; + let kept_before = logs_a.count(ROUND_CAN_STILL_CONFIRM); + let commitment_hex = bitcoin::consensus::encode::serialize_hex(&commitment); + mine_block_with(&bitcoind, &electrsd, &[commitment_hex]).await; + for _ in 1..ANTI_REORG_DELAY { + mine_block_with(&bitcoind, &electrsd, &[]).await; + } + node_a.sync_wallets().unwrap(); + drop(hold_a); + + expect_channel_pending_event!(node_a, node_c.node_id()); + expect_event!(node_a, ChannelClosed); + assert!(logs_a.wait_for(NO_ROUND_CAN_CONFIRM).await, "the payment was not failed"); + assert!( + logs_a.lines().iter().any(|line| line.contains(NO_ROUND_CAN_CONFIRM) + && line.contains(CLOSED_CHANNEL_PAYMENT_RESOLVED)), + "the close did not fail the payment" + ); + assert_eq!( + logs_a.count(ROUND_CAN_STILL_CONFIRM), + kept_before, + "a discard while the channel was listed resolved the payment" + ); + assert_eq!( + logs_a.count(RECLAIMED_ADDRESSES), + 2, + "the monitor's events did not each return the round's contribution" + ); + // The record names the round the wallet last heard of: the sync that delivered the close + // saw the mempool drop the first round, and moved the record from the bump to it. + let payment = node_a.payment(&payment_id).unwrap().expect("the splice has a payment"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!( + matches!( + payment.kind, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } if txid == first_txo.txid || txid == bump_txo.txid + ), + "unexpected kind {:?} for rounds {} and {}", + payment.kind, + first_txo.txid, + bump_txo.txid + ); + node_a.stop().unwrap(); +} + +/// A mid-negotiation failure is surfaced to the user exactly once: the initiator disconnects +/// while the interactive negotiation is in flight, LDK fails the splice with `PeerDisconnected`, +/// and one `SpliceNegotiationFailed` — carrying the reason and the originating request's +/// parameters — reports it. The splice is not retried automatically; the application initiates a +/// new one, which completes. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_failure_surfaced_after_disconnect_mid_negotiation() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // The negotiation is synchronized through a log marker: LDK's peer handler logs every received + // message, and the counterparty's `splice_ack` is the earliest point where a disconnect fails + // the splice — any sooner and the contribution is still queued, which LDK resumes on reconnect + // by itself and no failure occurs. + let logger_a = Arc::new(CollectingLogWriter::new()); + let splice_ack_seen = Arc::new(tokio::sync::Notify::new()); + let mut config_a = random_config(); + config_a.log_writer = TestLogWriter::Custom(Arc::new(MarkerLogWriter::new( + logger_a.clone(), + "Received message SpliceAck", + splice_ack_seen.clone(), + ))); + // `Node::disconnect` persists a peer-store removal before severing the connection, and the + // negotiation keeps running during that write. The default composite test store turns it into + // several fsyncs plus a cross-store comparison, wide enough to lose the race below; a plain + // SQLite store keeps it to a single quick write. + config_a.store_type = TestStoreType::Sqlite; + let node_a = setup_node(&chain_source, config_a); + let node_b = setup_node(&chain_source, random_config()); + + // Fund Node A with many small UTXOs: every input the splice contributes adds an interactive-tx + // round trip, stretching the negotiation so the disconnect below reliably lands inside it. + let addresses_a: Vec
= + (0..240).map(|_| node_a.onchain_payment().new_address().unwrap()).collect(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + addresses_a, + Amount::from_sat(32_000), + ) + .await; + node_a.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 1_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // The 3M target forces roughly 95 of the 32k-sat UTXOs into the contribution. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 3_000_000).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 fail nothing (a signed round is + // resumed on reconnect rather than failed) and the failure-event assert below would trip. The + // ~95 remaining per-input round trips make that window wide enough to survive a heavily + // loaded machine; if this ever flakes, widen the contribution further. + tokio::time::timeout(std::time::Duration::from_secs(10), splice_ack_seen.notified()) + .await + .expect("node A never received splice_ack"); + node_a.disconnect(node_b.node_id()).unwrap(); + + // ... which fails it with `PeerDisconnected`. The failure is surfaced with the reason and the + // originating request's parameters, and is not retried automatically. + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, Some(SpliceParameters::In { amount_sats: 3_000_000 })); + + let node_addr_b = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_addr_b, false).unwrap(); + + // The failed splice's inputs were released; the application initiates a new splice, which + // completes. A second copy of the failure event would pop here instead and panic: the failure + // is reported exactly once. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 3_000_000).unwrap(); + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + + wait_for_classified_funding_payment(&node_a, txo.txid).await; + wait_for_tx(&electrsd.client, txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let payment = funding_payment(&node_a, txo.txid); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } + )); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice LDK dropped without ever persisting it — initiated while disconnected, then the node +/// restarts — is recovered silently by startup reconciliation: the persisted intent's +/// reservations are released and its record dropped, with no fabricated failure event. What the +/// user does see, once, is the failure LDK itself persisted at shutdown and replays at startup — +/// with `PeerDisconnected` and no parameters, since the record is already gone. A further restart +/// stays silent, and a new splice initiated by the application completes. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_loss_surfaced_after_restart() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let config_b = random_config(); + let node_b = setup_node(&chain_source, config_b); + + let (onchain_balance_before_sat, splice_out_address, user_channel_id_a) = { + let node_a = setup_node(&chain_source, config_a.clone()); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Initiate a splice-out while disconnected: LDK accepts the contribution but cannot make + // progress before the restart below drops it, having neither negotiated nor persisted + // the splice itself — only the failure event it queues for it at shutdown. + node_a.disconnect(node_b.node_id()).unwrap(); + let address = node_a.onchain_payment().new_address().unwrap(); + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 500_000).unwrap(); + + let onchain_balance_before_sat = node_a.list_balances().total_onchain_balance_sats; + node_a.stop().unwrap(); + (onchain_balance_before_sat, address, user_channel_id_a) + }; + + // A signing write cut short after its payment-store half leaves a payment record under the + // splice's intent that no pending entry indexes. Plant one while the node is down: the + // intent's settlement at startup must take it along rather than leave a payment nothing + // would ever drive. + let half_written_txid = { + use bitcoin::hashes::hex::FromHex; + use ldk_node::io::sqlite_store::{SqliteStore, KV_TABLE_NAME, SQLITE_DB_FILE_NAME}; + use lightning::util::ser::Writeable; + + let store = SqliteStore::new( + config_a.node_config.storage_dir_path.clone().into(), + Some(SQLITE_DB_FILE_NAME.to_string()), + Some(KV_TABLE_NAME.to_string()), + ) + .unwrap(); + let payment_keys: HashSet = + store.list("payments", "").await.unwrap().into_iter().collect(); + let bare_intent_keys: Vec = store + .list("pending_payments", "") + .await + .unwrap() + .into_iter() + .filter(|key| !payment_keys.contains(key)) + .collect(); + assert_eq!(bare_intent_keys.len(), 1, "the dropped splice must have left one bare intent"); + let key = &bare_intent_keys[0]; + let id = PaymentId(<[u8; 32]>::from_hex(key).unwrap()); + let txid = Txid::from_byte_array([0xEE; 32]); + let half_written = PaymentDetails { + id, + kind: PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels: Vec::new() }), + }, + amount_msat: Some(500_000_000), + fee_paid_msat: Some(300_000), + direction: PaymentDirection::Outbound, + status: PaymentStatus::Pending, + latest_update_timestamp: 0, + }; + store.write("payments", "", key, half_written.encode()).await.unwrap(); + txid + }; + + // On restart, reconciliation finds nothing behind the intent in LDK, releases whatever the + // wallet still reserved for it, and drops the record — with the half-written payment under + // its id — without an event of its own. The one failure surfaced is LDK's replay of the + // event it persisted at shutdown for the dropped contribution — carrying no parameters, + // since the record it would match is already gone. + let node_a = setup_node(&chain_source, config_a.clone()); + node_a.sync_wallets().unwrap(); + + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, None); + assert!( + node_a + .list_payments_matching( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == half_written_txid) + ) + .is_empty(), + "the half-written record under the dropped splice's intent must go with it", + ); + + // The replayed failure was consumed, so another restart must not report it again. + node_a.stop().unwrap(); + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a consumed splice failure must not be reported again"); + + // The application initiates a new splice-out, which completes. + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &splice_out_address, 500_000).unwrap(); + + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + + wait_for_tx(&electrsd.client, txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + assert!( + node_a.list_balances().total_onchain_balance_sats > onchain_balance_before_sat + 400_000, + "the new splice-out should have moved ~500k sats to the on-chain balance", + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A fee bump initiated while disconnected and dropped by a restart leaves LDK holding the +/// negotiated splice at the original feerate, so startup reconciliation keeps the recorded +/// intent. The failure LDK persisted at shutdown for the dropped bump is replayed at startup, +/// matches the kept intent, and surfaces with the intent's parameters. A new bump initiated by +/// the application replaces the funding transaction, and once the negotiated splice carries the +/// bump, further restarts stay silent. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_rbf_loss_surfaced_after_restart() { + // Use a custom bitcoind config with a lower incrementalrelayfee so that the +25 sat/kwu + // (0.1 sat/vB) RBF feerate bump satisfies BIP125's absolute fee increase requirement. + let bitcoind_exe = std::env::var("BITCOIND_EXE") + .ok() + .or_else(|| corepc_node::downloaded_exe_path().ok()) + .expect( + "you need to provide an env var BITCOIND_EXE or specify a bitcoind version feature", + ); + let mut bitcoind_conf = corepc_node::Conf::default(); + bitcoind_conf.network = "regtest"; + bitcoind_conf.args.push("-rest"); + bitcoind_conf.args.push("-incrementalrelayfee=0.00000100"); + let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap(); + + let electrs_exe = std::env::var("ELECTRS_EXE") + .ok() + .or_else(electrsd::downloaded_exe_path) + .expect("you need to provide env var ELECTRS_EXE or specify an electrsd version feature"); + let mut electrsd_conf = electrsd::Conf::default(); + electrsd_conf.http_enabled = true; + electrsd_conf.network = "regtest"; + let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let config_b = random_config(); + let node_b = setup_node(&chain_source, config_b); + + let (original_txo, user_channel_id_a) = { + let node_a = setup_node(&chain_source, config_a.clone()); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Negotiate a splice but leave its transaction unconfirmed so it can be fee-bumped. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap(); + let original_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_tx(&electrsd.client, original_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // Bump the fee while disconnected and restart before anything could be negotiated: LDK + // drops the queued bump, keeping the negotiated splice at the original feerate, while + // the persisted intent records the bump. + node_a.disconnect(node_b.node_id()).unwrap(); + node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id()).unwrap(); + node_a.stop().unwrap(); + (original_txo, user_channel_id_a) + }; + + // On restart, reconciliation keeps the record — LDK still holds the negotiated splice, so + // the wallet's reservations may yet be claimed. The failure LDK persisted at shutdown for + // the dropped bump is replayed, matches the kept intent, and surfaces with its parameters. + let node_a = setup_node(&chain_source, config_a.clone()); + node_a.sync_wallets().unwrap(); + + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, Some(SpliceParameters::FeeBump)); + + // The application initiates a new fee bump, which replaces the funding transaction. + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_b_addr.clone(), false).unwrap(); + node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id()).unwrap(); + + let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + assert_ne!(original_txo, rbf_txo, "the new fee bump should produce a different funding txo"); + + // Restarting again must stay silent: the negotiated splice now carries the bump at the + // intended feerate. + node_a.stop().unwrap(); + let node_a = setup_node(&chain_source, config_a.clone()); + node_a.sync_wallets().unwrap(); + node_a.connect(node_b.node_id(), node_b_addr.clone(), false).unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a carried fee bump must not be reported as lost"); + + wait_for_tx(&electrsd.client, rbf_txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // The locked fee bump cleared its intent, so a further restart must stay silent. + node_a.stop().unwrap(); + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + assert!(node_a.next_event().is_none(), "a locked fee bump must produce no events"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice queued behind a pending splice of this node on a confirmed channel — accepted once +/// the pending round has a confirmation — is a splice of its own, with an intent record of its +/// own. Graduating the pending splice's payment removes that splice's record, and the queued +/// splice's survives it: a restart fails the queued contribution LDK never got to negotiate, and +/// the replayed failure is described from the queued splice's own intent. Before one record per +/// splice, the queued intent rode on the pending splice's record and was lost with it, so the +/// failure carried no parameters. +/// +/// Pinned to Esplora so the nodes sync only when told to: the pending splice's lock needs the +/// counterparty's `splice_locked`, which it sends only once it has seen the confirmations. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn queued_splice_failure_surfaced_after_restart() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let node_b = setup_node(&chain_source, random_config()); + + let (pending_txid, pending_payment_id, node_b_addr) = { + let node_a = setup_node(&chain_source, config_a.clone()); + + // Two coins for node_a: the queued splice-in cannot spend the pending one's unconfirmed + // change. + let address_a = node_a.onchain_payment().new_address().unwrap(); + let second_address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, second_address_a, address_b], + Amount::from_sat(5_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap(); + let pending = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, pending.txid).await; + + // With one confirmation, seen by node_a alone, LDK takes a further splice-in as a splice + // of its own, queued until the pending one locks. Queueing it starts a quiescence + // handshake LDK breaks off with a warning until then, disconnecting the peers. + wait_for_tx(&electrsd.client, pending.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 300_000).unwrap(); + + // Five more confirmations graduate the pending splice's payment on node_a, removing its + // record, while its lock still waits on node_b. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + node_a.sync_wallets().unwrap(); + let pending_payment = funding_payment(&node_a, pending.txid); + assert_eq!(pending_payment.status, PaymentStatus::Succeeded); + + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.stop().unwrap(); + (pending.txid, pending_payment.id, node_b_addr) + }; + + // LDK failed the queued contribution when it was last persisted and replays the failure at + // startup. The queued splice's own record survived the pending splice's graduation, so the + // failure is described from it. + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + let (reason, parameters) = expect_splice_negotiation_failed_event!(node_a, node_b.node_id()); + assert_eq!(reason, Some(SpliceFailureReason::PeerDisconnected)); + assert_eq!(parameters, Some(SpliceParameters::In { amount_sats: 300_000 })); + + // The pending splice locks once node_b catches up, under its one record: the one that + // graduated before the restart, not a second one the lock or the sync created. + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + node_b.sync_wallets().unwrap(); + node_a.sync_wallets().unwrap(); + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + let pending_payments = node_a.list_payments_matching( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == pending_txid), + ); + assert_eq!(pending_payments.len(), 1); + assert_eq!(pending_payments[0].id, pending_payment_id); + assert_eq!(pending_payments[0].status, PaymentStatus::Succeeded); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +/// A splice confirmed while its node was offline keeps exactly one payment record under its +/// splice-time id across the restart, no matter whether wallet sync or classification sees the +/// confirmed transaction first. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_payment_tracked_across_restart_before_lock() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // Set up node_a manually so it can be restarted with the same config. + let mut config_a = random_config(); + config_a.store_type = TestStoreType::Sqlite; + let config_b = random_config(); + let node_b = setup_node(&chain_source, config_b); + + let splice_txid = { + let node_a = setup_node(&chain_source, config_a.clone()); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap(); + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + + // Stop node_a as soon as the splice is negotiated. node_b broadcasts the transaction + // either way, so it reaches the chain while node_a is offline. node_a recorded the + // payment when it signed the funding transaction; depending on timing, its own broadcast + // classification may or may not also have run before stopping — the assertions below + // must hold in both cases. + node_a.stop().unwrap(); + txo.txid + }; + + // Confirm the splice while node_a is offline, but keep it short of the depth at which it + // locks, so node_a restarts with its splice intent still live. + wait_for_tx(&electrsd.client, splice_txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + + // After the restart, wallet sync and classification must agree on the splice-time + // `PaymentId` no matter which of them sees the confirmed transaction first: exactly one + // payment record, and not one keyed by a txid-derived id. + let node_a = setup_node(&chain_source, config_a); + node_a.sync_wallets().unwrap(); + + let splice_payments = |node: &Node| { + node.list_payments_matching( + |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == splice_txid), + ) + }; + let payments = splice_payments(&node_a); + assert_eq!( + payments.len(), + 1, + "expected exactly one payment record for the splice, got {}: {:#?}", + payments.len(), + payments, + ); + assert_ne!( + payments[0].id, + PaymentId(splice_txid.to_byte_array()), + "the splice payment must keep its splice-time id, not a txid-derived fallback", + ); + assert_eq!(payments[0].status, PaymentStatus::Pending); + + // Reconnect and let the splice lock: the single record graduates instead of gaining a + // duplicate. + let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_b_addr, false).unwrap(); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let payments = splice_payments(&node_a); + assert_eq!( + payments.len(), + 1, + "expected exactly one payment record after the splice locked, got {}: {:#?}", + payments.len(), + payments, + ); + assert_eq!(payments[0].status, PaymentStatus::Succeeded); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();