diff --git a/src/builder.rs b/src/builder.rs index ff7626ae26..05db960b72 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -58,6 +58,7 @@ use crate::config::BitcoindRestClientConfig; use crate::config::{ default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig, TorConfig, + CHANNEL_TX_FACTS_CACHE_CAPACITY, CHANNEL_TX_FACTS_CACHE_WARMUP_COUNT, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL, DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, PAYMENT_CACHE_CAPACITY, PAYMENT_CACHE_WARMUP_COUNT, @@ -83,6 +84,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 +107,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}; @@ -1564,6 +1568,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!( @@ -1587,6 +1592,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), ) }); @@ -1918,6 +1930,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) => { @@ -1938,6 +1968,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 @@ -1947,8 +1978,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); @@ -2429,6 +2458,15 @@ fn build_with_store_internal( }, }; + // The wallet drops the facts it recorded for a channel once nothing holds that channel + // anymore, and records on start what a held channel's producers never reported; both it can + // only ask now that the node's channel state exists. + wallet.set_channel_liveness(Arc::new(NodeChannelLiveness::new( + &channel_manager, + &chain_monitor, + &output_sweeper, + ))); + let event_queue = match event_queue_res { Ok(event_queue) => Arc::new(event_queue), Err(e) => { diff --git a/src/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/config.rs b/src/config.rs index cb74b55c80..6375ed2e54 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,6 +65,59 @@ 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 +// it would have described go unclassified, which is bounded loss of detail rather than unbounded +// storage. +pub(crate) const CHANNEL_TX_FACTS_MAX_RECORDS: usize = 100_000; + +// The number of pages of channel transaction provenance records one chain tip change examines. +// +// Pruning shares the pass that graduates payments, so it has to leave promptly; it resumes where +// 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 730a682dd1..d629c9f8e4 100644 --- a/src/event.rs +++ b/src/event.rs @@ -13,8 +13,10 @@ use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, OutPoint}; +use bitcoin::{Amount, OutPoint, Txid}; use lightning::blinded_path::message::NextMessageHop; +use lightning::chain::chaininterface::FundingCandidate; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::events::bump_transaction::BumpTransactionEvent; #[cfg(not(feature = "uniffi"))] use lightning::events::PaidBolt12Invoice; @@ -26,7 +28,7 @@ use lightning::events::{ use lightning::ln::channelmanager::{PaymentId, TrustedChannelFeatures}; use lightning::ln::types::ChannelId; use lightning::routing::gossip::NodeId; -use lightning::sign::EntropySource; +use lightning::sign::{EntropySource, SpendableOutputDescriptor}; use lightning::util::config::{ChannelConfigOverrides, ChannelConfigUpdate}; use lightning::util::errors::APIError; use lightning::util::persist::KVStore; @@ -51,14 +53,18 @@ 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::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}; use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, UserChannelId, @@ -559,6 +565,7 @@ where wallet: Arc, bump_tx_event_handler: Arc, channel_manager: Arc, + chain_monitor: Arc, connection_manager: Arc>, output_sweeper: Arc, network_graph: Arc, @@ -583,19 +590,21 @@ where pub fn new( event_queue: Arc>, wallet: Arc, bump_tx_event_handler: Arc, - channel_manager: Arc, connection_manager: Arc>, - output_sweeper: Arc, network_graph: Arc, - liquidity_source: Arc>>, payment_store: Arc, - forwarding_store: Arc, peer_store: Arc>, - keys_manager: Arc, static_invoice_store: Option, - onion_messenger: Arc, om_mailbox: Option>, - prober: Option>, runtime: Arc, logger: L, config: Arc, + channel_manager: Arc, chain_monitor: Arc, + connection_manager: Arc>, output_sweeper: Arc, + network_graph: Arc, liquidity_source: Arc>>, + payment_store: Arc, forwarding_store: Arc, + peer_store: Arc>, keys_manager: Arc, + static_invoice_store: Option, onion_messenger: Arc, + om_mailbox: Option>, prober: Option>, + runtime: Arc, logger: L, config: Arc, ) -> Self { Self { event_queue, wallet, bump_tx_event_handler, channel_manager, + chain_monitor, connection_manager, output_sweeper, network_graph, @@ -733,6 +742,55 @@ where Ok((payment_id, None)) } + /// The channel's pending splice rounds that have a transaction, as LDK currently holds them. + fn pending_splice_rounds( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Vec { + let splice_details = self + .channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .and_then(|channel| channel.splice_details); + funding_candidates(splice_details.as_ref(), counterparty_node_id, channel_id) + } + + /// The splice rounds LDK holds for the channel, as [`held_splice_rounds`] lists them, or + /// `None` once the channel is gone. + fn held_splice_rounds( + &self, counterparty_node_id: PublicKey, channel_id: ChannelId, + ) -> Option> { + self.channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .map(|channel| held_splice_rounds(channel.splice_details.as_ref(), channel.funding_txo)) + } + + /// Records what one of this node's channels reported about a transaction it produced, and + /// names the transaction's payment record from it if wallet sync wrote that record first. + /// + /// A failure is logged rather than reported: these facts accompany a transaction this node + /// has already released or a claim it has already made, so there is nothing left to withhold, + /// and the producing event is re-offered until the claim resolves. + async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) { + let txid = facts.txid; + match self.wallet.record_channel_tx_facts(facts).await { + Ok(FactsRecordOutcome::Recorded) => self.wallet.name_recorded_transaction(txid).await, + // Refused for lack of room, which the wallet has logged: nothing was recorded that + // could name the transaction. + Ok(FactsRecordOutcome::Incomplete) => {}, + 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 +813,7 @@ where let funding_transaction = self .wallet .create_funding_transaction( - output_script, + output_script.clone(), channel_amount, confirmation_target, locktime, @@ -763,6 +821,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 +946,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 +1672,47 @@ where .await; }, LdkEvent::SpendableOutputs { outputs, channel_id, counterparty_node_id } => { + let spendable_outpoints = sweepable_outpoints(&outputs); + let directly_paid_outpoints = direct_outpoints(&outputs); + + // Static outputs are excluded from the sweeper; `sweepable_outpoints` leaves them + // out of the spendable record below, and `direct_outpoints` has them recorded as + // paid straight to the wallet instead. 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 }; + let spendable = ChannelTxFacts::per_transaction( + &channel, + None, + ChannelOutputRole::Spendable, + spendable_outpoints, + ); + let directly_paid = ChannelTxFacts::per_transaction( + &channel, + None, + ChannelOutputRole::Direct, + directly_paid_outpoints, + ); + for facts in spendable.into_iter().chain(directly_paid) { + self.record_channel_tx_facts(facts).await; + } + } }, LdkEvent::OpenChannelRequest { temporary_channel_id, @@ -1834,6 +1991,17 @@ where "LDK Node has only ever persisted ChannelPending events from rust-lightning 0.0.115 or later", ); + let channel = Channel { counterparty_node_id, channel_id }; + let facts = ChannelTxFacts::new(funding_txo.txid) + .with_outputs( + &channel, + Some(UserChannelId(user_channel_id)), + ChannelOutputRole::Funding, + [funding_txo.vout], + ) + .with_self_role(TransactionType::Funding { channels: vec![channel.clone()] }); + self.record_channel_tx_facts(facts).await; + let event = Event::ChannelPending { channel_id, user_channel_id: UserChannelId(user_channel_id), @@ -1907,6 +2075,53 @@ 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) @@ -1931,10 +2146,45 @@ 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"); @@ -1992,6 +2242,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, @@ -2087,6 +2389,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 { @@ -2192,6 +2552,26 @@ where .. } => match self.wallet.sign_owned_inputs(unsigned_transaction) { Ok(partially_signed_tx) => { + // Record the splice round before handing our signatures to LDK: + // `funding_transaction_signed` releases them to the counterparty, after which + // either party may broadcast — and wallet sync could observe the transaction + // before this node has recorded what it is. The round's place in the channel's + // splice history is written from that history, and the round's broadcast adds + // nothing to it. On a failed write, replay rather than proceed unrecorded: LDK + // re-offers the event in-session and regenerates it across restarts while the + // transaction is unsigned. + let candidates = self.pending_splice_rounds(counterparty_node_id, channel_id); + if let Err(e) = + self.wallet.record_signed_funding(&partially_signed_tx, &candidates).await + { + log_error!( + self.logger, + "Failed to record the signed splice round for channel {}: {}", + channel_id, + e, + ); + return Err(ReplayEvent()); + } match self.channel_manager.funding_transaction_signed( &channel_id, &counterparty_node_id, @@ -2206,9 +2586,18 @@ where ); }, Err(e) => { - // TODO(splicing): Abort splice once supported in LDK 0.3 - debug_assert!(false, "Failed signing funding transaction: {:?}", e); - log_error!(self.logger, "Failed signing funding transaction: {:?}", e); + // Either the round was reset after its history was read above — LDK + // then reports the failure through `SpliceNegotiationFailed`, whose + // handling takes the record back — or LDK rejected the witnesses, in + // which case the round stays pending in LDK, and the record with it. + // TODO(splicing): cancel the contribution here through + // `ChannelManager::cancel_funding_contributed`; a follow-up wires it. + log_error!( + self.logger, + "LDK refused the signed funding transaction for channel {}: {:?}", + channel_id, + e, + ); }, } }, @@ -2231,6 +2620,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, recorded when it 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), @@ -2259,6 +2681,30 @@ where counterparty_node_id, ); + // A round this node signed was recorded when signing; if the failed round was + // among them, nothing can broadcast it anymore, so take its record back. The + // rounds LDK still holds tell which recorded ones it abandoned (a contribution + // can fail while an earlier signed round still awaits its signatures). A closed + // channel is left to its `ChannelClosed` event: LDK queues one for every channel it + // removes — before the failures a force-close reports, after the one a cooperative + // close reports — and that event carries the channel's last funding, which this + // handler can no longer read from the channel. + if let Some(held_rounds) = self.held_splice_rounds(counterparty_node_id, channel_id) + { + if let Err(e) = + self.wallet.drop_abandoned_splice_rounds(channel_id, &held_rounds).await + { + log_error!( + self.logger, + "Failed to drop the abandoned splice round of channel {} from its \ + funding payment: {}", + channel_id, + e, + ); + return Err(ReplayEvent()); + } + } + let event = Event::SpliceNegotiationFailed { channel_id, user_channel_id: UserChannelId(user_channel_id), @@ -2278,6 +2724,38 @@ where } } +/// The outpoints among `outputs` that the sweeper takes charge of, which are the ones a sweep +/// will spend. LDK reports an output paying a script of this wallet's own — its destination +/// script, or the shutdown script of a cooperative close — as a `StaticOutput`; the sweeper is +/// told to leave those alone, and the record does not call them spendable: whatever spends such +/// an output next is an ordinary wallet transaction, not a sweep. See `direct_outpoints` for +/// what is recorded about them instead. +fn sweepable_outpoints(outputs: &[SpendableOutputDescriptor]) -> Vec<(Txid, u32)> { + outputs + .iter() + .filter(|output| !matches!(output, SpendableOutputDescriptor::StaticOutput { .. })) + .map(|output| { + let outpoint = output.spendable_outpoint(); + (outpoint.txid, outpoint.index as u32) + }) + .collect() +} + +/// The outpoints among `outputs` that LDK reports as `StaticOutput`s: those paying a script of +/// this wallet's own, which are the proceeds of a claim or the shutdown output of a cooperative +/// close. They are recorded as the channel's payment straight to the wallet, which is what names +/// a claim. +fn direct_outpoints(outputs: &[SpendableOutputDescriptor]) -> Vec<(Txid, u32)> { + outputs + .iter() + .filter(|output| matches!(output, SpendableOutputDescriptor::StaticOutput { .. })) + .map(|output| { + let outpoint = output.spendable_outpoint(); + (outpoint.txid, outpoint.index as u32) + }) + .collect() +} + #[cfg(test)] mod tests { use std::collections::VecDeque; @@ -2635,4 +3113,38 @@ mod tests { } assert_eq!(event_queue.next_event(), None); } + + /// A `StaticOutput` pays a script of this wallet's own, so the sweeper is told to leave it + /// alone and it is recorded as the channel's payment straight to the wallet rather than as + /// spendable: the transaction that spends it next is an ordinary wallet transaction, not a + /// sweep. The outputs the sweeper does take are the ones recorded as spendable. + #[test] + fn static_outputs_are_not_recorded_as_spendable() { + use bitcoin::hashes::Hash; + use lightning::sign::StaticPaymentOutputDescriptor; + + let outpoint = + |byte: u8| LdkOutPoint { txid: Txid::from_byte_array([byte; 32]), index: byte as u16 }; + let output = bitcoin::TxOut { + value: Amount::from_sat(1_000), + script_pubkey: bitcoin::ScriptBuf::new(), + }; + let outputs = vec![ + SpendableOutputDescriptor::StaticOutput { + outpoint: outpoint(1), + output: output.clone(), + channel_keys_id: Some([1u8; 32]), + }, + SpendableOutputDescriptor::StaticPaymentOutput(StaticPaymentOutputDescriptor { + outpoint: outpoint(2), + output, + channel_keys_id: [2u8; 32], + channel_value_satoshis: 100_000, + channel_transaction_parameters: None, + }), + ]; + + assert_eq!(sweepable_outpoints(&outputs), vec![(outpoint(2).txid, 2)]); + assert_eq!(direct_outpoints(&outputs), vec![(outpoint(1).txid, 1)]); + } } 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 1c88de2f7d..d05a1e8eba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -369,6 +369,28 @@ impl Node { ) })?; + // Record the outputs the node's channel state holds, for channels no producer reported: + // ones opened before this node recorded channel facts, and ones whose report failed in an + // earlier session. Before anything syncs, so that a close or a sweep the first sync finds + // is classified against them. + self.runtime.block_on(self.wallet.record_held_channel_outputs()); + + // 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); @@ -688,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), diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index e14f64c380..43a22983e5 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. Persisted at splice initiation and cleared once the splice locks or its failure + /// is surfaced, it 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(), + } + } + + #[cfg(test)] + 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,190 @@ 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 writers that persist and + // settle splices, never by a payment-tracking merge. Emitting the current value + // here would let an `insert_or_update` of a payment record (e.g. from wallet sync, + // built without an intent) clobber a live intent to `None`. + 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, &[]) +} + #[cfg(test)] mod tests { use bitcoin::hashes::Hash; + use lightning::util::ser::{Readable, Writeable}; use super::*; use crate::payment::store::ConfirmationStatus; @@ -163,16 +563,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 +647,217 @@ 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 splice_kind_round_trips() { + for kind in [ + SpliceKind::In { amount_sats: 500_000 }, + SpliceKind::Out { + outputs: vec![TxOut { + value: bitcoin::Amount::from_sat(400_000), + script_pubkey: bitcoin::ScriptBuf::new(), + }], + }, + SpliceKind::Rbf {}, + ] { + let encoded = kind.encode(); + let decoded = SpliceKind::read(&mut &encoded[..]).unwrap(); + assert_eq!(kind, decoded); + } + } - use crate::payment::store::PaymentDetailsUpdate; + #[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 txid = test_txid(7); - let payment_id = PaymentId(txid.to_byte_array()); + 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 pending entry wallet sync has already mirrored a confirmation into (via - // `apply_funding_status_update_locked`) before classification ran. - let confirmed_details = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Confirmed { - block_hash: BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, - }, - tx_type: None, - }, - Some(2_000_000), - Some(999), - PaymentDirection::Outbound, - PaymentStatus::Pending, + /// 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) + } + + /// An entry written by a node from before the rounds LDK promoted and the splice's intent were + /// kept on it reads as an entry with neither, everything else intact. + #[test] + fn an_entry_written_without_locked_rounds_or_an_intent_reads_with_neither() { + let mut stored = entry(vec![candidate(2, false), candidate(3, true)]); + stored.conflicting_txids = vec![test_txid(9)]; + + let write_as_before = || -> Result, lightning::io::Error> { + let mut written = Vec::new(); + lightning::write_tlv_fields!(&mut written, { + (0, stored.id, required), + (2, stored.details, option), + (4, stored.conflicting_txids, optional_vec), + (6, stored.funding_channels, optional_vec), + (8, stored.candidates, optional_vec), + }); + Ok(written) + }; + let written_before = write_as_before().unwrap(); + + let decoded: PendingPaymentDetails = Readable::read(&mut &written_before[..]) + .expect("an entry written before the rounds and the intent were kept still reads"); + assert!(decoded.locked_rounds().is_empty()); + assert!(decoded.splice_intent.is_none()); + assert_eq!(decoded, stored); + } + + /// 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..cb459ab7ab 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,12 @@ 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, for transactions of channels opened + /// before this node began recording what its channels' transactions are, for a + /// transaction whose channel's report of it could not be recorded, 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 +729,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 +996,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..f544cd13bc 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -5,47 +5,70 @@ // 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 bitcoin::{Transaction, Txid}; use lightning::chain::chaininterface::{ BroadcasterInterface, TransactionType as LdkTransactionType, }; -use tokio::sync::{mpsc, Mutex, MutexGuard}; +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 +119,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 +128,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); } } @@ -162,9 +152,7 @@ where L::Target: LdkLogger, { fn broadcast_transactions(&self, txs: &[(&Transaction, LdkTransactionType)]) { - self.queue_sender.try_send(BroadcastPackage::new(txs)).unwrap_or_else(|e| { - log_error!(self.logger, "Failed to broadcast transactions: {}", e); - }); + self.queue_package(BroadcastPackage(txs.iter().map(|(tx, _)| (*tx).clone()).collect())); } } @@ -173,7 +161,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 +302,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..e5eca23f92 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)] @@ -32,11 +33,12 @@ use bitcoin::{ WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::{ - FundingCandidate, TransactionType as LdkTransactionType, - INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, + ChannelFunding, FundingCandidate, FundingPurpose, INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::chain::{BlockLocator, ClaimId, Listen}; +use lightning::ln::channel_state::{SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; @@ -51,22 +53,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::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::logger::{log_debug, log_error, log_info, log_trace, log_warn, LdkLogger, Logger}; +#[cfg(test)] use crate::payment::pending_payment_store::PendingPaymentDetailsUpdate; -use crate::payment::store::{ConfirmationStatus, PaymentDetailsUpdate}; +use crate::payment::store::{Channel, ConfirmationStatus, PaymentDetailsUpdate}; use crate::payment::{ FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, PendingPaymentDetails, TransactionType, }; use crate::runtime::Runtime; -use crate::types::{Broadcaster, PaymentStore, PendingPaymentStore}; +use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; +use crate::wallet::provenance::{ + ChannelLiveness, ChannelTxFacts, ChannelTxFactsRejection, FactsRecordOutcome, FactsRetention, + LocalFundingFigures, RetentionCheck, TxProvenance, +}; use crate::{ChainSource, Error}; pub(crate) enum OnchainSendAmount { @@ -80,7 +91,9 @@ pub(crate) enum FundingAmount { Max, } +mod payment_stores; pub(crate) mod persist; +pub(crate) mod provenance; pub(crate) mod ser; const DUST_LIMIT_SATS: u64 = 546; @@ -154,22 +167,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 +189,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 +203,224 @@ 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 the outputs the node's channel state holds, for channels no producer reported: + /// ones opened before this node recorded channel facts at all, and ones whose report failed + /// in an earlier session. What that state holds for a channel is its funding output and the + /// outputs the sweeper has yet to spend, so a close or a sweep of such a channel is + /// classified like any other. + /// + /// A node whose producers reported everything finds each held output on record already and + /// writes nothing. An output this pass fails to record stays unclassified until the next + /// start repeats the pass, which is no reason to fail this one. + pub(crate) async fn record_held_channel_outputs(&self) { + let Some(held) = self.channel_liveness.get().and_then(|liveness| liveness.held_outputs()) + else { + log_error!( + self.logger, + "Failed to consult the node's channel state for the outputs it holds; what no producer reported stays unclassified until the next start" + ); + return; + }; + for facts in ChannelTxFacts::of_held_outputs(held) { + let txid = facts.txid; + if let Err(e) = self.record_channel_tx_facts(facts).await { + log_error!( + self.logger, + "Failed to record what channel transaction {} is from the node's channel state: {}", + txid, + e + ); + } + } + } + + /// 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: there is nothing to retry, + /// and the consequence is a transaction this node cannot say anything about, not a lost + /// write. Only what this node has no record of at all is refused that way — a transaction it + /// already describes goes on being described, however full the store is. + pub(crate) async fn record_channel_tx_facts( + &self, facts: ChannelTxFacts, + ) -> Result { + let txid = facts.txid; + // Dated by the chain tip the report arrives at, which is what retention measures from. + let facts = facts.reported_at_height(self.latest_checkpoint_height()); + // The rejection is reported out of the closure rather than through it, so that the read, + // the merge and the write stay one critical section of the store's mutation lock. + let mut 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; one already on + // record is merged into above however full the store is, so an obligation this + // node took on is never half-kept. + None if !self.facts_retention.has_room() => { + rejection = Some(ChannelTxFactsRejection::NoRoom { + limit: CHANNEL_TX_FACTS_MAX_RECORDS, + }); + None + }, + None => match facts.clone().size_checked() { + Ok(checked) => { + created = true; + Some(checked) + }, + Err(e) => { + rejection = Some(e); + None + }, + }, + }) + .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: {}. It will be reported without a classification", + 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), + } + } + + /// Names the record of `txid`, if one exists and is still unnamed, from the facts recorded + /// about it. The event handler calls this once a channel's report is recorded: the chain-tip + /// pass reaches only records still pending, and a report can land after the record of its + /// transaction graduated, as a claim's does: LDK reports the outputs a claim paid this wallet + /// at the very depth the claim's record graduates at. Not for the wallet's own writers, which + /// hold its locks: the naming takes them. A failure costs the name and is logged. + pub(crate) async fn name_recorded_transaction(&self, txid: Txid) { + let payment_id = match self.find_payment_by_txid(txid).await { + Ok(Some(payment_id)) => payment_id, + Ok(None) => PaymentId(txid.to_byte_array()), + Err(e) => { + log_error!( + self.logger, + "Failed to look up the payment of transaction {} to name it: {}", + txid, + e + ); + return; + }, + }; + if let Err(e) = self.name_recorded_transactions(vec![(payment_id, txid)]).await { + log_error!( + self.logger, + "Failed to name transaction {} from what was recorded of it: {}", + txid, + e + ); + } + } + + /// 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. A decision that must not be taken on a partial answer reads through + /// [`Self::read_tx_provenance`] instead. + 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) + } + + /// [`Self::tx_provenance`] for a decision that must not be taken on a partial answer: a fact + /// that cannot be read fails the caller rather than being left out. + async fn read_tx_provenance( + &self, txid: Txid, tx: &Transaction, + ) -> Result { + let self_facts = self.channel_tx_facts_store.get(&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_store.get(&parent).await? { + parent_facts.insert(parent, facts); + } + } + Ok(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 +564,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 +611,71 @@ 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?; + } else { + // The transaction was first observed already confirmed through the reorg + // depth, so the record is written settled and never graduates. An entry + // under its id that no record promoted yet -- the signed rounds of a splice + // whose transaction nothing had observed before -- tracked this payment + // alone, and with the payment settled tracks nothing further, as the entry + // of a graduated record does. + stores.remove_pending_payment(&payment_id).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 entries with a record. + 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 +683,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 +709,7 @@ impl Wallet { }) .await?; if graduated { - self.pending_payment_store.remove(&payment_id).await?; + stores.remove_pending_payment(&payment_id).await?; } } }, @@ -449,13 +717,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 +754,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 +766,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 +811,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 +840,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 +915,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,355 +932,1080 @@ 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 the next chain tip while the record is pending and as the report is recorded otherwise. + /// 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); } + } + Ok(()) + } - (psbt, locked_wallet.take_staged().unwrap_or_default()) + /// Drops the facts this node has no use for anymore, a bounded batch of the store at a time. + /// + /// A transaction's facts go only once all of it holds: nothing has been learned about the + /// transaction for [`CHANNEL_TX_FACTS_RETENTION_BLOCKS`], none of the channels the facts name + /// is still held by the node's channel manager, chain monitor or output sweeper, no pending + /// payment still refers to the transaction, and whatever spend of a recorded funding the + /// wallet holds has settled, buried past twice [`ANTI_REORG_DELAY`]. Each of those is a way + /// the facts could still be needed, so any one of them keeps them. + /// + /// The walk of the store resumes where the previous tip left it, so a batch costs one page + /// however large the store is, 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; }; - locked_persister.persist_changeset(change_set).await.map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - })?; + let pending_txids = self.pending_referenced_txids().await; - let tx = psbt.extract_tx().map_err(|e| { - log_error!(self.logger, "Failed to extract transaction: {}", e); - e - })?; + 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; + } + 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, + ), + } + } - Ok(tx) + 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 a fresh address, served from the address pool so that external handouts consume - /// the oldest revealed index first. + /// Drops the recorded facts `facts` was read as, and reports whether anything was dropped. /// - /// 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. + /// 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 spend the wallet holds of a funding output `facts` records is buried past + /// twice [`ANTI_REORG_DELAY`] and has its own payment settled, so that nothing is left to + /// classify from these facts. /// - /// 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?; - }; + /// A funding output the wallet holds no spend of counts as settled: a commitment transaction + /// that pays none of the wallet's scripts never enters its graph, so to the wallet a channel + /// closed that way looks unspent for good, and there is no transaction of it the facts would + /// classify. Whether the channel may still produce one is the liveness check's question, + /// which keeps the facts for as long as the node holds the channel. + 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; + } - // 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 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().all(|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 + // is neither something to classify nor something to wait for. + match locked_wallet.get_tx(*spender).map(|tx| tx.chain_position) { + None => true, + Some(ChainPosition::Confirmed { anchor, .. }) => { + !pending_txids.contains(spender) + && tip_height + >= anchor.block_id.height.saturating_add(2 * ANTI_REORG_DELAY) + }, + Some(ChainPosition::Unconfirmed { .. }) => false, } - // 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) - }, + }) + }) + } + + /// Every transaction the pending payment store still refers to: each entry's own + /// transaction, the interactive-funding rounds it lists as candidates or as locked, and the + /// conflicts wallet sync recorded against it. + async fn pending_referenced_txids(&self) -> HashSet { + let mut txids = HashSet::new(); + for entry in self.payment_stores.pending_payments(|_| true).await { + if let Some(PaymentKind::Onchain { txid, .. }) = + entry.details().map(|details| &details.kind) + { + txids.insert(*txid); + } + txids.extend(entry.candidates().iter().map(|candidate| candidate.txid)); + txids.extend(entry.conflicting_txids().iter().copied()); + txids.extend(entry.locked_rounds().iter().copied()); } + txids + } + + /// The id to record a transaction under that the funding-status check found foreign to the + /// funding record resolved for it as `resolved_id`: its own txid-derived id, or `None` when a + /// funding record sits there already. A funding record wallet sync created for a round it + /// 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) }) } - /// 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. + /// 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`], and a transaction + /// confirmed that deep spends an input of the record's transaction and of every candidate. + /// Returns whether the payment was failed; failing also removes the pending entry, dropping + /// the failed record from the tip-change pass. /// - /// 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(); - - // 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) - } + /// 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 foreign conflict + /// confirmed to that depth is final for them. Each round is judged by its own inputs because + /// the conflict may have 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. Whether + /// a round is still canonical does not answer that: BDK also drops a round from the canonical + /// set when the mempool evicts it, and an evicted round can be rebroadcast and confirm. + 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); + } - /// 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 - } + // 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; - /// [`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; + // 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 Some(details) = entry.details() else { + return Ok(false); + }; + let (conflicting_txids, candidates) = (&entry.conflicting_txids, &entry.candidates); + let record_txid = match details.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + } => txid, + _ => return Ok(false), + }; - 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 foreign_conflicts: Vec = conflicting_txids + .iter() + .copied() + .filter(|conflict| *conflict != record_txid && entry.candidate(*conflict).is_none()) + .collect(); + if foreign_conflicts.is_empty() { + return Ok(false); } - let 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::>() + let lost = { + let locked_wallet = self.inner.lock().expect("lock"); + let confirmed_to_depth = + |txid: Txid| match locked_wallet.get_tx(txid).map(|tx| tx.chain_position) { + Some(ChainPosition::Confirmed { anchor, .. }) => { + tip_height >= anchor.block_id.height + ANTI_REORG_DELAY - 1 + }, + _ => false, + }; + // A round can no longer confirm once a transaction confirmed to depth spends one of + // its inputs. The graph keeps evicted transactions, so an evicted round is still + // judged by its inputs; a round the wallet never saw cannot be rebroadcast and counts + // the same. + let graph = locked_wallet.tx_graph(); + let cannot_confirm = |txid: Txid| match graph.get_tx(txid) { + Some(tx) => { + graph.direct_conflicts(&tx).any(|(_, spender)| confirmed_to_depth(spender)) + }, + None => true, + }; + cannot_confirm(record_txid) + && candidates.iter().all(|c| cannot_confirm(c.txid)) + && foreign_conflicts.iter().any(|conflict| confirmed_to_depth(*conflict)) }; + if !lost { + 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 - })?; + 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, reporting what it did. As + /// with graduation, the decision is made from the live record and only the status is written. + /// A record already `Failed` — a prior pass whose entry removal was lost to a crash — still + /// matches, no-ops the update, and gets its lingering entry removed. + async fn fail_unconfirmed_funding_payment_locked( + &self, 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 { + 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 + } - // 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); + /// [`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(()) } - /// 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() + /// Fails every funding payment of `channel_id` still waiting on an unconfirmed splice round + /// while no round of ours in its record is among `held_rounds` or was promoted to the channel's + /// funding (see [`Self::resolve_promoted_splice_round`]), removing its pending entry; a payment + /// with such a round is left as it is. The rounds of ours are the candidates recorded with a + /// stake, and the record's own transaction only when no candidate records it, as for a record + /// from before candidates were tracked: a recorded candidate counts by its stake alone, + /// whichever round the record names. A payment that moved on — its round confirmed, or it was + /// failed already — is not touched beyond the entry a failure cut short left behind. + /// An entry no record has reached yet — wallet sync never observed a transaction of its + /// splice — is failed on the same terms, under a record written for it then from the share of + /// its newest round of ours the signing recorded, and removed; it is removed without one when + /// that round moved no wallet funds and so has no share on record. `resolution` 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 _ = locked_persister.persist_address_pool(indices).await; + let entries = stores.pending_payments(|entry| tracks_channel(entry, channel_id)).await; + for entry in entries { + let details = match entry.details() { + Some(details) => details, + // An entry with no payment record yet — nothing has observed a transaction of + // this splice — has no payment to fail. The drop pass has taken back the rounds + // of its nothing broadcast, so the rounds left are ones LDK released: with no + // round of ours among them held or locked, none can confirm anymore. The attempt + // is failed under a record written for it now, from the share of its newest round + // of ours the signing recorded, so that it shows in the payment list as one wallet + // sync had observed would; the entry tracks nothing further and goes. + None => { + let payment_id = entry.id(); + let rounds_of_ours: Vec = entry + .candidates() + .iter() + .filter(|candidate| candidate.amount_msat.is_some()) + .map(|candidate| candidate.txid) + .collect(); + if let Some(kept) = rounds_of_ours.iter().find(|txid| { + held_rounds.contains(txid) || entry.locked_rounds().contains(txid) + }) { + log_info!( + self.logger, + "Splice round {} of ours can still confirm: keeping the entry of unobserved funding payment {} {}", + kept, + payment_id, + occasion, + ); + continue; + } + // The share of the newest round of ours, as the signing recorded it. A round of + // ours may have none on record: the signing records nothing for a round that + // moves no wallet funds (`interactive_funding_figures`), such as a splice-out to + // an external address, yet a later round signed with it in the history lists it + // as ours by its contribution, and the drop of that later round leaves it the + // newest. Such a round was never a payment of the wallet's, so there is nothing + // to fail: the entry goes without a record. A failed read is no answer about + // the round, and fails the pass for the event to be replayed. + let newest_round = rounds_of_ours.last().copied(); + let figures = match newest_round { + Some(txid) => self + .channel_tx_facts_store + .get(&txid) + .await? + .and_then(|facts| facts.local_figures), + None => None, + }; + let (Some(newest_round), Some(figures)) = (newest_round, figures) else { + stores.remove_pending_payment(&payment_id).await?; + log_info!( + self.logger, + "Removed the entry of unobserved funding payment {} {} without a record: no round of ours with a share on record", + payment_id, + occasion, + ); + continue; + }; + let failed = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid: newest_round, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { + channels: entry.funding_channels().to_vec(), + }), + }, + figures.amount_msat, + figures.fee_paid_msat, + figures.direction, + PaymentStatus::Failed, + ); + stores.insert_or_update_payment(failed).await?; + stores.remove_pending_payment(&payment_id).await?; + log_info!( + self.logger, + "Failed unobserved funding payment {} {} under splice round {}: no round of ours can confirm", + payment_id, + occasion, + newest_round, + ); + 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(()) } - 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()) + /// 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(()); + }, }; - 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) + // 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(()) } - 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 - })?; - + /// 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, + ); + } 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); + #[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()); + }, + }; + + 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 funding transaction: {}", err); + return Err(err.into()); + }, } - } - } - pub(crate) fn get_balances( - &self, total_anchor_channels_reserve_sats: u64, - ) -> Result<(u64, u64), Error> { - let balance = self.inner.lock().expect("lock").balance(); + (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 + })?; - // 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 tx = psbt.extract_tx().map_err(|e| { + log_error!(self.logger, "Failed to extract transaction: {}", e); + e + })?; - self.get_balances_inner(balance, total_anchor_channels_reserve_sats) + Ok(tx) } - 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), - ); + /// 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?; + }; - Ok((total, spendable)) + // 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) + }, + } } - 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) + /// 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(); + + // 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) } - 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 - }; + /// 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 mut tx_builder = locked_wallet.build_tx(); - tx_builder.drain_wallet().drain_to(drain_script).fee_rate(fee_rate); + /// [`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 let Some(address_info) = anchor_address { - tx_builder.add_recipient( - address_info.address.script_pubkey(), - Amount::from_sat(cur_anchor_reserve_sats), - ); + 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(()); + } } - 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 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::>() + }; - let psbt = tx_builder.finish().map_err(|err| { - log_error!(self.logger, "Failed to create temporary drain transaction: {err}"); - err + // 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(psbt) + // 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(()) } - /// Builds a temporary drain transaction and returns the maximum amount that would be sent to + /// 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; + } + + 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()) + }; + 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() + }; + 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); + } + } + } + + 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" + ); + } + + self.get_balances_inner(balance, total_anchor_channels_reserve_sats) + } + + 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), + ); + + Ok((total, spendable)) + } + + 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) + } + + 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 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), + ); + } + + 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 @@ -1216,7 +2268,7 @@ impl Wallet { })?; let txid = tx.compute_txid(); - self.broadcaster.broadcast_unclassified_transaction(tx); + self.broadcaster.broadcast(tx); match send_amount { OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => { @@ -1515,139 +2567,76 @@ impl Wallet { Ok(tx) } - /// Classifies an on-chain broadcast handed to the broadcaster by LDK, recording a payment for it - /// before it is sent when it affects this node's wallet. - pub(crate) async fn classify_broadcast( - &self, tx: &Transaction, tx_type: &LdkTransactionType, - ) -> Result<(), Error> { - match tx_type { - LdkTransactionType::Funding { channels } => { - self.classify_funding(tx, channels, tx_type.clone().into()).await - }, - 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 - }, - } - } - - /// Records a single-channel funding (channel open) broadcast as a pending on-chain payment, - /// tagged with its transaction type. Amount and fee come from the wallet's view of the - /// transaction. Batched funding is left for wallet sync. - async fn classify_funding( - &self, tx: &Transaction, channels: &[(PublicKey, ChannelId)], tx_type: TransactionType, - ) -> Result<(), Error> { - if channels.len() != 1 { - if channels.len() > 1 { - log_trace!( - self.logger, - "Skipping funding classification for batched broadcast ({} channels)", - channels.len() - ); - } - return Ok(()); - } - - let (_counterparty_node_id, channel_id) = channels[0]; - let txid = tx.compute_txid(); - let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); - - // A funding transaction that moves no wallet funds carries nothing to record — e.g. LDK - // re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding path, - // including splices the 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, - ); - return Ok(()); - } - - let payment_id = PaymentId(txid.to_byte_array()); - - // A promoted-but-unconfirmed 0conf splice comes back through this generic path re-typed - // and carrying wallet-view figures; `funding_reclassification_update` declines the - // downgrade, leaving no trace that a re-broadcast arrived. Log the arrival so tests can - // observe the traffic. The read 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 { .. }), - .. + /// Returns the `PaymentId` of a user-initiated splice intent for one of the channels in + /// `candidate`, if any, so the first recorded round of a splice adopts the id chosen at splice + /// time rather than a fresh one. The intent identifies the channel, not the round, so it + /// decides the id only for a history no record tracks yet + /// ([`Self::resolve_interactive_funding_id`]). A fee bump reuses the channel's existing intent, + /// so at most one in-flight intent matches and the first is unambiguous. + async fn find_splice_payment_id(&self, candidate: &FundingCandidate) -> Option { + self.payment_stores + .pending_payments(|p| { + p.splice_intent().is_some_and(|intent| { + candidate.channels.iter().any(|channel| { + channel.channel_id == intent.channel_id + && channel.counterparty_node_id == intent.counterparty_node_id + }) + }) + }) + .await + .first() + .map(|p| p.id()) + } + + /// Resolves the id under which the `active` round of the interactive funding with negotiated + /// history `candidates` is recorded. A round already on record keeps its record: the id of the + /// first round of the history any record tracks is adopted (wallet sync may record a round + /// before this node does), so a replacement, a replayed signing and a sync-created record + /// converge on one record. A record already failed is passed over: wallet sync fails a payment + /// whose round lost to a conflicting spend confirmed while the channel stays open, LDK still + /// holds the round and a fee bump of it is signed with the round among its candidates, and + /// nothing revisits a failed record's status, so the bump filed under it would go untracked. + /// Only a history no live record tracks falls back to the channel's splice intent: a + /// user-initiated splice adopts the `PaymentId` generated when it was initiated, so its intent, + /// funding payment and candidate history share one record. The intent identifies the channel, + /// not the round, which is why it must not decide the id of a round already on record: a fee + /// bump this node signs of a round wallet sync recorded first must converge on the record sync + /// created, not be filed under the bump's intent as a second record. Otherwise a fresh id is + /// generated — an id derived from a txid would tie the record's identity to one round of a + /// replaceable transaction, and resolution through the record's txid history is what keeps its + /// identity stable across RBF replacements. The caller holds the cross-store lock: resolved + /// outside it, the id could go stale against a record wallet sync creates for the same + /// transaction before the caller's write. + async fn resolve_interactive_funding_id( + &self, stores: &PaymentStoresGuard<'_>, candidates: &[FundingCandidate], + active: &FundingCandidate, + ) -> Result { + for candidate in candidates.iter() { + if let Some(id) = self.find_payment_by_txid(candidate.txid).await? { + let failed = stores + .payment(&id) + .await? + .is_some_and(|payment| payment.status == PaymentStatus::Failed); + if !failed { + return Ok(id); } - ) { - log_trace!( - self.logger, - "Keeping interactive-funding classification over funding-typed rebroadcast {}", - txid, - ); } } - - 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(()) + if let Some(id) = self.find_splice_payment_id(active).await { + return Ok(id); + } + Ok(random_payment_id()) } - /// Records an interactive-funding broadcast (splice, or a V2 dual-funded open) as a pending - /// on-chain payment, tagged with its transaction type. Amount and fee are this node's share, - /// derived from the active candidate's contributions; broadcasts we didn't contribute to, or - /// that don't move wallet funds, are left for wallet sync. - async fn classify_interactive_funding( - &self, tx: &Transaction, candidates: &[FundingCandidate], tx_type: TransactionType, - ) -> Result<(), Error> { - // `InteractiveFunding` carries the full negotiated history; the currently-broadcast - // candidate is the last entry, earlier entries are RBF predecessors. - let active = match candidates.last() { - Some(c) => c, - None => return Ok(()), - }; - let first = match candidates.first() { - Some(c) => c, - None => return Ok(()), - }; - - let txid = tx.compute_txid(); - debug_assert_eq!(active.txid, txid, "broadcast tx must match the active candidate"); + /// Builds 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 { @@ -1655,14 +2644,12 @@ impl Wallet { None => { log_trace!( self.logger, - "Not recording interactive-funding broadcast {} as a payment: no local contribution", + "Not recording signed funding {} as a payment: no local contribution", txid, ); - return Ok(()); + return None; }, }; - let fee_paid_msat = aggregate.fee_paid_msat; - 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 @@ -1672,16 +2659,12 @@ impl Wallet { if wallet_amount_msat == Some(0) { log_trace!( self.logger, - "Not recording interactive-funding broadcast {} as a payment: no wallet-level activity", + "Not recording signed funding {} as a payment: no wallet-level activity", txid, ); - return Ok(()); + return None; } - // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable - // across RBF replacements. - let payment_id = PaymentId(first.txid.to_byte_array()); - // Record every candidate's figures (`None` for any round we didn't contribute to, e.g. a // counterparty-initiated splice our `splice_in` later joined via RBF) so the confirmed // candidate's amount/fee can be applied on confirmation, even if it isn't the last one @@ -1694,148 +2677,551 @@ impl Wallet { txid: candidate.txid, amount_msat: aggregate.amount_msat, fee_paid_msat: aggregate.fee_paid_msat, + awaiting_broadcast: false, } }) .collect(); - let details = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(tx_type), - }, + let figures = LocalFundingFigures { + funding_payment_id: payment_id, amount_msat, - fee_paid_msat, - direction, - PaymentStatus::Pending, - ); - self.persist_funding_payment(details, candidate_records).await?; + 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. + /// + /// [`ChannelManager::funding_transaction_signed`]: lightning::ln::channelmanager::ChannelManager::funding_transaction_signed + pub(crate) async fn record_signed_funding( + &self, tx: &Transaction, candidates: &[FundingCandidate], + ) -> Result<(), Error> { + let txid = tx.compute_txid(); + let signed_round = match candidates.iter().find(|candidate| candidate.txid == txid) { + Some(round) => round, + None => { + log_trace!( + self.logger, + "Not recording signed funding {}: not among the channel's pending splice rounds", + txid, + ); + return Ok(()); + }, + }; + 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(()), + }; + 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(()); + } + // 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. + self.record_channel_tx_facts( + ChannelTxFacts::new(txid) + .with_self_role(TransactionType::InteractiveFunding { + channels: funding_channels.clone(), + }) + .with_local_figures(figures), + ) + .await?; + + stores + .mutate_pending_payment(&payment_id, |existing| { + let mut changed = existing.is_none(); + let mut entry = existing.cloned().unwrap_or_else(|| { + PendingPaymentDetails::signed_rounds(payment_id, Vec::new(), Vec::new(), None) + }); + if entry.funding_channels.is_empty() && !funding_channels.is_empty() { + entry.funding_channels = funding_channels.clone(); + changed = true; + } + if entry.candidates != recorded { + entry.candidates = recorded.clone(); + changed = true; + } + changed.then_some(entry) + }) + .await?; log_debug!( self.logger, - "Recorded interactive-funding broadcast {} ({} candidates, {} channels)", + "Recorded signed splice funding {} ({} candidates)", txid, candidates.len(), - active.channels.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(()) } - /// 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, + /// 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> { - let txid = tx.compute_txid(); - let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); + // 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; - if amount_msat == Some(0) && fee_paid_msat == Some(0) { - log_trace!( + 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). 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. An entry left tracking + // nothing goes. + if entry.details().is_none() { + let mut emptied = false; + 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(); + } + emptied = entry.is_empty(); + (!emptied).then_some(entry) + }) + .await?; + if emptied { + stores.remove_pending_payment(&payment_id).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 => { + // A removal has no critical section to decide in, so the record is read first. + let record = stores.payment(&payment_id).await?; + if record.as_ref().map_or(true, waits_on_abandoned) { + // Nothing of this node's was ever broadcast under the record, so it goes + // rather than fail a payment for a transaction that never existed. The + // payment record goes first: the entry keeps resolving the rounds' txids, + // so a removal that fails midway is finished by the replayed event. + stores.remove_payment(&payment_id).await?; + stores.remove_pending_payment(&payment_id).await?; + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} and removed funding payment {}: nothing of ours \ + was broadcast under it", + abandoned_txids, + payment_id, + ); + continue; + } + history_only = true; + mirrored = record.filter(|current| current.status == PaymentStatus::Pending); + }, + } + if history_only { + // The record does not wait on the dropped rounds: wallet sync moved it on, or an + // earlier drop was cut short between the two stores. Only its history shrinks, and + // the entry's copy of the record catches up with the record while the record is + // still pending. + log_warn!( + self.logger, + "Funding payment {} does not wait on abandoned splice round(s) {:?}: \ + dropping them from its history only", + payment_id, + abandoned_txids, + ); + } + 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, - "Not recording classified broadcast {} as a payment: no wallet-level activity", - txid, + "Dropped abandoned splice round(s) {:?} from funding payment {}", + abandoned_txids, + payment_id, ); - return Ok(()); } + Ok(()) + } - let details = PaymentDetails::new( - PaymentId(txid.to_byte_array()), - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(tx_type), - }, - amount_msat, - fee_paid_msat, - direction, - PaymentStatus::Pending, - ); - self.payment_store.insert_or_update(details).await?; - log_debug!(self.logger, "Recorded classified on-chain broadcast {}", txid); + /// 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(()) } - /// 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( + /// 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> { - // 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 stores = self.payment_stores.lock().await; 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) - }, + 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?; + .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(()) } @@ -1876,71 +3262,152 @@ impl Wallet { (amount_msat, Some(fee_sat * 1000), direction) } + /// Builds the payment record for `tx`, naming what the transaction is from `provenance`. + /// + /// The provenance is read by the caller rather than here, because reading it awaits the facts + /// store while this runs under the wallet lock. fn create_payment_from_tx( &self, locked_wallet: &PersistedWallet, txid: Txid, - payment_id: PaymentId, tx: &Transaction, payment_status: PaymentStatus, - confirmation_status: ConfirmationStatus, + payment_id: PaymentId, tx: &Transaction, provenance: &TxProvenance, + payment_status: PaymentStatus, confirmation_status: ConfirmationStatus, ) -> PaymentDetails { - // TODO: It would be great to introduce additional variants for - // `ChannelFunding` and `ChannelClosing`. For the former, we could just - // take a reference to `ChannelManager` here and check against - // `list_channels`. But for the latter the best approach is much less - // clear: for force-closes/HTLC spends we should be good querying - // `OutputSweeper::tracked_spendable_outputs`, but regular channel closes - // (i.e., `SpendableOutputDescriptor::StaticOutput` variants) are directly - // spent to a wallet address. The only solution I can come up with is to - // create and persist a list of 'static pending outputs' that we could use - // here to determine the `PaymentKind`, but that's not really satisfactory, so - // we're punting on it until we can come up with a better solution. - - let kind = PaymentKind::Onchain { txid, status: confirmation_status, tx_type: None }; - - let (amount_msat, fee_paid_msat, direction) = - self.onchain_payment_fields_locked(locked_wallet, tx); + let kind = PaymentKind::Onchain { + txid, + status: confirmation_status, + tx_type: provenance.classify(tx), + }; + + // The figures a producer reported take precedence over the wallet's view: an + // interactively negotiated funding spends an output both parties own, which the wallet + // reads as this node having spent all of it. + let (amount_msat, fee_paid_msat, direction) = match provenance.local_figures() { + Some(figures) => (figures.amount_msat, figures.fee_paid_msat, figures.direction), + None => self.onchain_payment_fields_locked(locked_wallet, tx), + }; PaymentDetails::new(payment_id, kind, amount_msat, fee_paid_msat, direction, payment_status) } - fn create_pending_payment_from_tx( - &self, payment: PaymentDetails, conflicting_txids: Vec, - ) -> PendingPaymentDetails { - PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()) + /// Inserts or refreshes the pending-store entry tracking `payment` toward graduation, + /// atomically with reading the entry's current state. + async fn upsert_pending_payment( + &self, stores: &PaymentStoresGuard<'_>, payment: PaymentDetails, + conflicting_txids: Vec, + ) -> Result<(), Error> { + let id = payment.id; + stores + .mutate_pending_payment_async(&id, move |existing| async move { + // Only `Pending` payments belong in the pending store. The authoritative + // status is re-read inside the store's critical section, where it cannot go + // stale against graduation. + let is_pending = stores + .payment(&id) + .await? + .map_or(payment.status == PaymentStatus::Pending, |recorded| { + recorded.status == PaymentStatus::Pending + }); + if !is_pending { + return Ok(None); + } + Ok(match existing { + None => { + Some(PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())) + }, + // Promote 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?; + 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 classification's resolve-then-write sequence. The pending entry goes first: a failure + // or a funding-record writer's resolve-then-write sequence. The pending entry goes first: a failure // in between then leaves an unindexed record (benign, and the retry removes it) rather // than an entry indexing a removed record. - let _guard = self.funding_payment_update_lock.lock().await; - self.pending_payment_store.remove(payment_id).await?; - self.payment_store.remove(payment_id).await + let stores = self.payment_stores.lock().await; + stores.remove_pending_payment(payment_id).await?; + stores.remove_payment(payment_id).await } + /// 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) @@ -1949,49 +3416,64 @@ impl Wallet { /// If `payment_id` refers to a classified funding payment, refreshes its confirmation status /// and the candidate txid the event refers to, while preserving the contribution-derived /// amount/fee and `tx_type` that wallet sync must not recompute from its own view: the wallet's - /// `sent`/`received` don't capture our contribution to a shared funding output. Returns `true` - /// when it handled the payment, so the caller skips the default on-chain path. Graduation to - /// `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. + /// `sent`/`received` don't capture our contribution to a shared funding output. Returns + /// [`FundingStatusUpdate::Applied`] when it handled the payment, so the caller skips the + /// default on-chain path — or [`FundingStatusUpdate::Foreign`] when the transaction is not + /// part of the payment's funding history, so the caller records it under its own id. + /// Graduation to `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. /// - /// The caller must hold [`Self::funding_payment_update_lock`] — from resolving `payment_id` - /// through its own last write, not just across this call — so that classification's two-store - /// write pair cannot interleave with the caller's decision sequence. The `_guard` parameter - /// serves as a reminder of that contract. + /// 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, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, event_txid: Txid, + &self, stores: &PaymentStoresGuard<'_>, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, - ) -> Result { + ) -> Result { // The caller's wallet-level lock keeps the candidate history stable while we await its - // read. The funding-type gate and write then share the payment store's mutation lock: - // against a separate payment `get`, a classification merging in between would have its - // `tx_type` and contribution figures clobbered by this stale snapshot. - let pending_payment = self.pending_payment_store.get(&payment_id).await?; + // 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; - self.payment_store - .mutate(&payment_id, |existing| { + stores + .mutate_payment(&payment_id, |existing| { let payment = existing?; - let tx_type = match &payment.kind { + let (current_txid, tx_type) = match &payment.kind { PaymentKind::Onchain { + txid, tx_type: tx_type @ Some( TransactionType::Funding { .. } | TransactionType::InteractiveFunding { .. }, ), .. - } => tx_type.clone(), + } => (*txid, tx_type.clone()), _ => return None, }; + // Adopt the event's txid only when the transaction is part of this payment's + // funding history: its current txid or a classified candidate. A conflicting + // transaction that is neither — a close also spends the funding outpoint — must + // not overwrite the record. + let owns_event_tx = event_txid == current_txid + || pending_payment.as_ref().is_some_and(|p| p.candidate(event_txid).is_some()); + if !owns_event_tx { + outcome = FundingStatusUpdate::Foreign; + return None; + } // Report the figures of the candidate that actually confirmed, which need not be // the last one broadcast (an earlier, lower-fee candidate may win) and may carry // no figures at all (`None`) for a round we didn't contribute to. (`direction` is // invariant across a splice's candidates and cannot be changed through the store // anyway.) let mut target = payment.clone(); - if let Some(pending) = pending_payment.as_ref() { - if let Some(candidate) = pending.candidate(event_txid) { - target.amount_msat = candidate.amount_msat; - target.fee_paid_msat = candidate.fee_paid_msat; - } + if let Some(candidate) = + pending_payment.as_ref().and_then(|p| p.candidate(event_txid)) + { + target.amount_msat = candidate.amount_msat; + target.fee_paid_msat = candidate.fee_paid_msat; } target.kind = PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; @@ -2009,42 +3491,87 @@ impl Wallet { }) .await?; let Some(payment) = handled else { - return Ok(false); + return Ok(outcome); }; // Mirror the refreshed confirmation status onto the pending entry: `ChainTipChanged` // graduates by reading the pending entry's details, so it must see the new status. This is // the same dual-write the default `TxConfirmed` path performs; an empty conflicting-txids // list leaves any stored conflicts intact (the update treats absent as "unchanged"). if payment.status == PaymentStatus::Pending { - let pending = self.create_pending_payment_from_tx(payment, Vec::new()); - self.pending_payment_store.insert_or_update(pending).await?; + self.upsert_pending_payment(stores, payment, Vec::new()).await?; } - Ok(true) + 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_store.get(&payment_id).await?.ok_or_else(|| { + let payment = self.payment_stores.payment(&payment_id).await?.ok_or_else(|| { log_error!(self.logger, "Payment {} not found in payment store", payment_id); Error::InvalidPaymentId })?; - // Funding transactions (channel opens and splices) are driven by LDK's funding/splice - // lifecycle, not the on-chain wallet. Replacing one via on-chain RBF would broadcast a - // transaction LDK isn't tracking (and, for splices, can't sign). Fee-bumping a pending - // splice goes through `bump_channel_funding_fee` instead. - if let PaymentKind::Onchain { - tx_type: - Some(TransactionType::Funding { .. } | TransactionType::InteractiveFunding { .. }), - .. - } = &payment.kind - { + let txid = match &payment.kind { + PaymentKind::Onchain { txid, .. } => *txid, + _ => { + log_error!( + self.logger, + "Payment {} is not an on-chain payment, cannot be replaced via RBF", + payment_id + ); + return Err(Error::InvalidPaymentId); + }, + }; + + // The transaction and whether the wallet owns every input it spends, read before the + // persister lock so what this node recorded about the transaction can be consulted + // without holding it. `list_output` rather than `get_utxo`, so an output this very + // transaction spends still counts as the wallet's. + let owned_inputs = { + let locked_wallet = self.inner.lock().expect("lock"); + let tx = locked_wallet.tx_details(txid).map(|details| details.tx.deref().clone()); + tx.map(|tx| { + let owned: HashSet = + locked_wallet.list_output().map(|output| output.outpoint).collect(); + let all_owned = tx.input.iter().all(|input| owned.contains(&input.previous_output)); + (tx, all_owned) + }) + }; + let Some((old_tx, all_inputs_owned)) = owned_inputs else { + log_error!(self.logger, "Transaction {} not found in wallet", txid); + return Err(Error::InvalidPaymentId); + }; + + // Only an ordinary payment of this wallet's may be replaced, decided positively rather + // than by exclusion: what this node recorded must make nothing of the transaction, and + // every input must be an output this wallet owns and can re-sign. A transaction no + // recorded fact names is therefore still refused when it reaches beyond the wallet's own + // coins, rather than passing for want of a reason to reject it. + // + // Anything a channel of this node's has a claim on is driven by LDK's funding, splice and + // close lifecycle rather than by the on-chain wallet: replacing it would broadcast a + // transaction LDK isn't tracking, and an interactively negotiated funding cannot be + // re-signed by this node alone. Fee-bumping a pending splice goes through + // `bump_channel_funding_fee` instead. A fact that cannot be read is no answer about the + // transaction, and refuses the replacement with the error. + let provenance = self.read_tx_provenance(txid, &old_tx).await?; + if let Some(tx_type) = provenance.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 funding payment {} via bump_fee_rbf; use bump_channel_funding_fee instead", + "Cannot RBF payment {}: transaction {} spends inputs this wallet does not own", payment_id, + txid, ); return Err(Error::InvalidPaymentId); } @@ -2072,36 +3599,9 @@ impl Wallet { return Err(Error::InvalidPaymentId); } - let txid = match &payment.kind { - PaymentKind::Onchain { txid, .. } => *txid, - _ => { - log_error!( - self.logger, - "Payment {} is not an on-chain payment, cannot be replaced via RBF", - payment_id - ); - return Err(Error::InvalidPaymentId); - }, - }; - let mut locked_persister = self.persister.lock().await; let mut locked_wallet = self.inner.lock().expect("lock"); - debug_assert!( - locked_wallet.tx_details(txid).is_some(), - "Transaction {} expected in wallet but not found", - txid, - ); - let old_tx = locked_wallet - .tx_details(txid) - .ok_or_else(|| { - log_error!(self.logger, "Transaction {} not found in wallet", txid); - Error::InvalidPaymentId - })? - .tx - .deref() - .clone(); - let old_fee_rate = locked_wallet.calculate_fee_rate(&old_tx).map_err(|e| { log_error!(self.logger, "Failed to calculate fee rate of transaction {}: {}", txid, e); Error::WalletOperationFailed @@ -2251,28 +3751,37 @@ impl Wallet { let new_txid = fee_bumped_tx.compute_txid(); - let new_payment = self.create_payment_from_tx( - &locked_wallet, - new_txid, - payment.id, - &fee_bumped_tx, - PaymentStatus::Pending, - ConfirmationStatus::Unconfirmed, - ); - - let 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); + + // 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 })?; - self.payment_store.insert_or_update(new_payment).await?; - self.pending_payment_store.insert_or_update(pending_payment_store).await?; + // Taken after the persister, the order wallet sync takes the two locks in. + let stores = self.payment_stores.lock().await; + stores.insert_or_update_payment(new_payment.clone()).await?; + self.upsert_pending_payment(&stores, new_payment, Vec::new()).await?; - self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx); + self.broadcaster.broadcast(fee_bumped_tx); log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid); @@ -2322,6 +3831,143 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { } } +/// 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) +} + +/// Lists a channel's pending splice rounds that have a transaction — the negotiated predecessors +/// and the round awaiting signatures, in LDK's order, each with this node's contribution to it — +/// as the [`FundingCandidate`]s LDK hands the broadcaster for the round, for recording the round +/// when signing it. A contribution still queued behind the pending rounds has no transaction and +/// is left out; a channel with no pending splice yields nothing. +pub(crate) fn funding_candidates( + details: Option<&SpliceDetails>, counterparty_node_id: PublicKey, channel_id: ChannelId, +) -> Vec { + details + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]) + .iter() + .filter_map(|candidate| { + let txid = round_txid(candidate)?; + Some(FundingCandidate { + txid, + channels: vec![ChannelFunding { + counterparty_node_id, + channel_id, + purpose: FundingPurpose::Splice, + contribution: candidate.contribution.clone(), + }], + }) + }) + .collect() +} + +/// The transaction of a pending splice round, once it has one: a negotiated round's, or the +/// round awaiting signatures'. +fn round_txid(candidate: &SpliceCandidateDetails) -> Option { + match &candidate.status { + SpliceCandidateStatus::Negotiated { txid, .. } + | SpliceCandidateStatus::AwaitingSignatures { txid, .. } => Some(*txid), + _ => None, + } +} + +/// The splice rounds LDK holds for a channel, as [`Wallet::drop_abandoned_splice_rounds`] takes +/// them: the pending rounds with a transaction, as [`funding_candidates`] lists them, and the +/// channel's current funding. A zero-conf splice is promoted to the funding as soon as +/// `splice_locked` is exchanged, before its transaction confirms, so it leaves the pending rounds +/// while its record may still await the `SpliceNegotiated` event that marks it broadcast. +pub(crate) fn held_splice_rounds( + details: Option<&SpliceDetails>, funding_txo: Option, +) -> Vec { + let mut held: Vec = details + .map(|details| details.candidates.as_slice()) + .unwrap_or(&[]) + .iter() + .filter_map(round_txid) + .collect(); + held.extend(funding_txo.map(|funding| funding.txid)); + held +} + +/// The splice rounds 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. + Failed, + /// The payment was failed already — by a pass whose entry removal was lost to a crash — and + /// only the lingering entry was removed. + EntryRemoved, + /// The record no longer waits on the transaction; nothing was touched. + MovedOn, +} + +/// Generates a fresh funding-record [`PaymentId`] from the OS entropy source. A funding record's id +/// carries no meaning beyond uniqueness: the record is found through its transaction history +/// ([`Wallet::find_payment_by_txid`]), never re-derived from a txid. +fn random_payment_id() -> PaymentId { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("getrandom failed"); + PaymentId(bytes) +} + +/// The outcome of [`Wallet::apply_funding_status_update_locked`]. +enum FundingStatusUpdate { + /// The event's transaction belongs to the funding payment; its refreshed confirmation status + /// 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, @@ -2641,71 +4287,16 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { ) } -/// Builds the payment-store update for a freshly classified funding payment. `details` describes -/// the actively broadcast candidate, but when the record already confirmed a *different* -/// candidate — wallet sync saw it win before this classification ran — the update instead carries -/// the confirmed candidate's txid and figures from the candidate history, mirroring what -/// [`Wallet::apply_funding_status_update_locked`] reports when confirmation arrives after -/// classification. -/// -/// `current` is the record as observed inside the payment store's `mutate` critical section — its -/// sole caller, [`Wallet::persist_funding_payment`], builds and applies the update within one -/// closure — so the candidate choice cannot go stale against a concurrent confirmation before the -/// update lands. [`PaymentDetails::update`]'s confirmed-figures rule still arbitrates which -/// figures may land on the record. -fn funding_reclassification_update( - details: PaymentDetails, candidates: &[FundingTxCandidate], current: Option<&PaymentDetails>, -) -> PaymentDetailsUpdate { - // A funding-typed classification of a record already classified as interactive funding is a - // downgrade, not news: LDK re-broadcasts a promoted-but-unconfirmed splice through its - // generic funding path, where the figures are wallet-view rather than contribution-derived. - // Keep the record as classified; wallet-sync events own its confirmation state. - // - // TODO(https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/4878): The - // re-typed re-broadcasts are upstream behavior that should be fixed in `rust-lightning`: - // the re-offer ought to keep its `InteractiveFunding` classification, or not recur at all. - // `zero_conf_splice_in_funding_rebroadcast_canary` pins the current behavior via the - // arrival log in `classify_funding`; when it fails against a newer LDK, re-evaluate - // whether this guard still sees traffic. - if let ( - Some(PaymentKind::Onchain { - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - }), - PaymentKind::Onchain { tx_type: Some(TransactionType::Funding { .. }), .. }, - ) = (current.map(|payment| &payment.kind), &details.kind) - { - return PaymentDetailsUpdate::new(details.id); - } - - let mut update = PaymentDetailsUpdate::funding_reclassification(details); - if let Some(PaymentKind::Onchain { - txid: confirmed_txid, - status: ConfirmationStatus::Confirmed { .. }, - .. - }) = current.map(|payment| &payment.kind) - { - if update.txid != Some(*confirmed_txid) { - if let Some(candidate) = candidates.iter().find(|c| c.txid == *confirmed_txid) { - update.txid = Some(candidate.txid); - update.amount_msat = Some(candidate.amount_msat); - update.fee_paid_msat = Some(candidate.fee_paid_msat); - } - } - } - update -} - #[cfg(all(test, any(feature = "chain-esplora", feature = "chain-electrum")))] mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; - use std::time::Duration; + 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::ln::funding::FundingContribution; use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; use super::*; @@ -2713,58 +4304,67 @@ mod tests { use crate::config::ElectrumSyncConfig; #[cfg(feature = "chain-esplora")] use crate::config::EsploraSyncConfig; - use crate::config::PAYMENT_CACHE_CAPACITY; + use crate::config::{CHANNEL_TX_FACTS_CACHE_CAPACITY, PAYMENT_CACHE_CAPACITY}; use crate::io::test_utils::InMemoryStore; use crate::io::{ BDK_WALLET_ADDRESS_POOL_KEY, BDK_WALLET_ADDRESS_POOL_PRIMARY_NAMESPACE, - BDK_WALLET_ADDRESS_POOL_SECONDARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + BDK_WALLET_ADDRESS_POOL_SECONDARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; - use crate::types::{DynStore, DynStoreWrapper}; + use crate::payment::pending_payment_store::{ + test_funding_contribution_with_outputs, test_funding_contribution_with_parts, SpliceIntent, + SpliceKind, + }; + use crate::types::{DynStore, DynStoreWrapper, UserChannelId}; + use crate::wallet::provenance::{ + live_channels_of, ChannelOutputFact, ChannelOutputRole, HeldChannelOutput, + 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 whose writes can be made to fail on demand. + /// 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 FailSwitchStore { + struct ReadCountingStore { inner: Arc, - fail_writes: Arc, + reads: Arc>>, } - impl FailSwitchStore { + impl ReadCountingStore { fn new() -> Self { - Self { - inner: Arc::new(InMemoryStore::new()), - fail_writes: Arc::new(AtomicBool::new(false)), - } + 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 FailSwitchStore { + 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 { - 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 - } + KVStore::write(&*self.inner, primary_namespace, secondary_namespace, key, buf) } fn remove( @@ -2780,7 +4380,7 @@ mod tests { } } - impl PaginatedKVStore for FailSwitchStore { + impl PaginatedKVStore for ReadCountingStore { fn list_paginated( &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, @@ -2794,13 +4394,191 @@ mod tests { } } - /// Constructs a `Wallet` around the given store, either creating a fresh BDK wallet or - /// loading the one the store already holds. - async fn new_test_wallet(store: Arc, load_existing: bool) -> Arc { - let logger = Arc::new(Logger::new_log_facade()); - let mut config = Config::default(); - config.network = Network::Regtest; - let config = Arc::new(config); + /// An in-memory store whose writes and reads can be made to fail on demand, all of them or + /// those of one primary namespace, counting the failed writes 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, + /// Whether reads fail, within the same namespace as the writes. + fail_reads: Arc, + /// When set, only writes and reads of this primary namespace fail while their switch 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)), + fail_reads: Arc::new(AtomicBool::new(false)), + failing_namespace: None, + } + } + + /// Like [`Self::new`], but only writes to and reads of `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 { + let inner = Arc::clone(&self.inner); + let fail_reads = Arc::clone(&self.fail_reads); + 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_reads.load(Ordering::Acquire) { + return Err(io::Error::new(io::ErrorKind::Other, "reads disabled")); + } + KVStore::read(&*inner, &primary_namespace, &secondary_namespace, &key).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 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)); @@ -2866,1552 +4644,6379 @@ mod tests { Arc::clone(&store), Arc::clone(&logger), )); + let channel_tx_facts_store = Arc::new(ChannelTxFactsStore::new( + Vec::new(), + KeepLeastRecentlyUsed::new(CHANNEL_TX_FACTS_CACHE_CAPACITY), + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&store), + Arc::clone(&logger), + )); let runtime = Arc::new(Runtime::new(Arc::clone(&logger)).unwrap()); - let persisted_pool_indices = persist::read_address_pool(&*store, &*logger).await.unwrap(); + 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 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 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(); + } + + /// 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(); + } + + /// 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 LDK hands both the signing-time recording and the broadcaster. + fn splice_candidates( + counterparty_node_id: PublicKey, channel_id: ChannelId, + rounds: &[(Txid, Option)], + ) -> Vec { + use lightning::chain::chaininterface::{ChannelFunding, FundingPurpose}; + rounds + .iter() + .map(|(txid, contribution)| FundingCandidate { + txid: *txid, + channels: vec![ChannelFunding { + counterparty_node_id, + channel_id, + purpose: FundingPurpose::Splice, + contribution: contribution.clone(), + }], + }) + .collect() + } + + /// 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; + } + + /// 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) + } + + /// A splice-out round of ours to an external address: a contribution of this node's that + /// moves no wallet funds, which the signing declines to record. + fn external_splice_out_round( + 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 tx = 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![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() }, + } + } + + /// 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 neither file the + /// round under the new splice as a second record nor touch the new splice's intent. + #[tokio::test] + async fn negotiation_marks_a_recorded_round_broadcast_under_a_newer_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + let txid = setup.tx.compute_txid(); + + let channel_id = setup.candidates[0].channels[0].channel_id; + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + let 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 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); + } + + /// The counterparty's broadcast may also be observed late: after an offline stretch, the round + /// is already confirmed through [`ANTI_REORG_DELAY`] the first time wallet sync sees it. The + /// record is then written settled and never graduates, so the entry that tracked the signed + /// round has to go with it rather than outlive the payment it tracked. + #[tokio::test] + async fn a_round_first_observed_confirmed_to_depth_settles_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 (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(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + // The wallet takes the transaction in with the chain already past the reorg depth. + let confirmation_height = 100; + insert_confirmed_tx(&wallet, tx.clone(), confirmation_height); + { + let mut locked = wallet.inner.lock().unwrap(); + let height = confirmation_height + ANTI_REORG_DELAY; + let block = + BlockId { height, hash: bitcoin::BlockHash::from_byte_array([height as u8; 32]) }; + let chain = locked.latest_checkpoint().insert(block); + locked.apply_update(Update { chain: Some(chain), ..Default::default() }).unwrap(); + } + let event = WalletEvent::TxConfirmed { + txid, + tx: Arc::new(tx), + block_time: confirmed_block_time(confirmation_height), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { + txid: t, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } if t == txid + )); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none(), + "a settled payment leaves the entry nothing to track", + ); + } + + /// 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 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)); + } + + /// LDK abandoned a signed fee bump while the round it replaces stays pending: the bump leaves + /// the recorded history and the record tracks the original round again, figures included. + #[tokio::test] + async fn dropping_an_abandoned_bump_restores_the_prior_round() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid), + "the original round must be the actively-tracked transaction again" + ); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(payment.fee_paid_msat, Some(300_000)); + assert_eq!(record.details(), Some(&payment)); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + } + + /// A round awaiting broadcast that the wallet has nonetheless seen — the counterparty broadcast + /// it with our signatures while LDK still waited on its own, and the channel then closed — may + /// still confirm and keeps its place, even once evicted from the mempool: the lookup is not + /// canonical-only. + #[tokio::test] + async fn dropping_keeps_a_round_the_wallet_has_seen() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + sign_and_observe_round(&wallet, &tx, &candidates).await; + evict_tx(&wallet, txid); + assert!(wallet.inner.lock().unwrap().get_tx(txid).is_none(), "evicted: not canonical"); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(payment.status, PaymentStatus::Pending); + } + + /// The channel force-closed with a negotiated round unconfirmed and a fee bump of it signed + /// but never exchanged, before wallet sync picked the negotiated round up: LDK lists neither + /// anymore, but the negotiated round was handed to the broadcaster and may still confirm, so + /// only the bump is dropped. + #[tokio::test] + async fn dropping_keeps_rounds_handed_to_the_broadcaster() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let kept = record.candidate(txid).expect("the negotiated round keeps its place"); + assert_eq!(kept.amount_msat, Some(500_300_000)); + assert_eq!(kept.fee_paid_msat, Some(300_000)); + assert!(!kept.awaiting_broadcast); + // Wallet sync has not picked the round up, so there is no payment record either way. + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + } + + /// LDK abandoned the only round this node contributed to, an RBF of a counterparty-initiated + /// round it did not: what remains is not this node's payment, so the record goes instead of + /// being handed to a round the wallet will never observe. + #[tokio::test] + async fn dropping_the_last_contributed_round_removes_the_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let prior_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); + + wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + /// The record moved on before the drop: wallet sync confirmed the original round while its + /// bump awaited signatures, then LDK abandoned the bump. The confirmed record is left as it + /// stands; only the bump leaves the recorded history. + #[tokio::test] + async fn dropping_leaves_a_record_that_moved_on() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + + insert_confirmed_tx(&wallet, tx.clone(), 105); + let event = WalletEvent::TxConfirmed { + txid, + tx: Arc::new(tx), + block_time: confirmed_block_time(105), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } + if t == txid + )); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + assert_eq!( + wallet.payment_stores.payment_store().get(&id).await.unwrap(), + Some(payment.clone()) + ); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.details(), Some(&payment)); + assert!(record.candidate(bump_txid).is_none()); + } + + /// The record moved on to a bump the entry does not list, so the entry still lists only the + /// original round. Abandoning that round, with no round of ours remaining, leaves the record + /// as it stands and only shrinks the entry's history, its copy of the record catching up. + #[tokio::test] + async fn dropping_the_last_round_leaves_a_record_that_moved_on() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + // Wallet sync moved the record onto a bump the entry does not list. + let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let mut moved_on = PaymentDetailsUpdate::new(id); + moved_on.txid = Some(bump_txid); + wallet.payment_stores.payment_store().update(moved_on).await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert_eq!( + wallet.payment_stores.payment_store().get(&id).await.unwrap(), + Some(payment.clone()) + ); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert!(record.candidates().is_empty()); + assert_eq!(record.details(), Some(&payment)); + } + + /// A removal that was cut short between the two stores — the payment record went, the pending + /// entry stayed — is finished by the replayed drop: the entry alone still resolves the round's + /// txid, so it is what the replayed event finds and removes. + #[tokio::test] + async fn a_cut_short_removal_is_finished_by_the_replayed_drop() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + wallet.payment_stores.payment_store().remove(&id).await.unwrap(); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + /// A hand-back that was cut short between the two stores — the payment record tracks the + /// original round again, the pending entry still lists the bump and mirrors the record as it + /// was — is finished by the replayed drop: the bump leaves the history and the entry's copy of + /// the record catches up with the record. + #[tokio::test] + async fn a_cut_short_hand_back_is_finished_by_the_replayed_drop() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + 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 + )); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + 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, + }, + SpliceCandidateDetails { + status: SpliceCandidateStatus::WaitingOnLock, + contribution: None, + }, + ], + confirmed_candidate: None, + received_splice_locked_txid: None, + }; + let funding = LdkOutPoint { txid: funding_txid, index: 0 }; + + assert_eq!( + held_splice_rounds(Some(&details), Some(funding)), + vec![pending_txid, funding_txid] + ); + assert_eq!(held_splice_rounds(None, Some(funding)), vec![funding_txid]); + assert!(held_splice_rounds(None, None).is_empty()); + } + + /// The rounds a closed channel may still see confirm are its last funding and every transaction + /// its monitor still watches: a splice round the counterparty committed to stays watched once + /// the channel manager has forgotten it with the channel. Without a monitor, only the funding + /// is held. + #[test] + fn closed_channel_held_rounds_include_the_watched_transactions() { + let funding_txid = Txid::from_byte_array([0xBB; 32]); + let watched_txid = Txid::from_byte_array([0xCC; 32]); + let funding = LdkOutPoint { txid: funding_txid, index: 0 }; + + assert_eq!( + closed_channel_held_rounds(Some(funding), [funding_txid, watched_txid]), + vec![funding_txid, watched_txid] + ); + assert_eq!(closed_channel_held_rounds(Some(funding), []), vec![funding_txid]); + assert_eq!(closed_channel_held_rounds(None, [watched_txid]), vec![watched_txid]); + assert!(closed_channel_held_rounds(None, []).is_empty()); + } + + /// The node restarted with a signed round LDK never wrote out — it stopped between LDK handing + /// the round out for signing and its next channel manager write, and the round was committed + /// after the last one — so LDK holds nothing for it and reports no failure: the startup sweep + /// drops it, while a round LDK still holds stays, and so does the round of a channel LDK no + /// longer lists, which is left to the channel's `ChannelClosed` event. + #[tokio::test] + async fn startup_drops_the_rounds_ldk_no_longer_holds() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let other_channel_id = ChannelId([8u8; 32]); + let closed_channel_id = ChannelId([9u8; 32]); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let (other_tx, other_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let other_txid = other_tx.compute_txid(); + let other_candidates = splice_candidates( + counterparty_node_id, + other_channel_id, + &[(other_txid, Some(other_contribution))], + ); + 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"); + + 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(); + + 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()); + } + + /// A record that graduated while its pending entry lingers — the entry's removal is still + /// owed — loses the dropped round from its history but keeps the entry's pending copy of the + /// record: the pass that cleans up lingering entries goes by that copy, and a graduated one + /// would leave the entry behind for good. + #[tokio::test] + async fn dropping_leaves_the_entry_of_a_graduated_record_pending() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = 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 mut update = PaymentDetailsUpdate::new(id); + update.status = Some(PaymentStatus::Succeeded); + wallet.payment_stores.payment_store().update(update).await.unwrap(); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + 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)); + } + + /// 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 (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()); + 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(); + + 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::chain::chaininterface::FundingPurpose; + use lightning::ln::channel_state::{ + SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails, + }; + + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let prior_txid = Txid::from_byte_array([9u8; 32]); + let signing_txid = Txid::from_byte_array([10u8; 32]); + let contribution = test_funding_contribution_with_outputs(0, 253, &[]); + let details = SpliceDetails { + candidates: vec![ + SpliceCandidateDetails { + contribution: None, + status: SpliceCandidateStatus::Negotiated { + txid: prior_txid, + new_channel_value_satoshis: 100_000, + }, + }, + SpliceCandidateDetails { + contribution: Some(contribution.clone()), + status: SpliceCandidateStatus::AwaitingSignatures { + is_initiator: true, + funding_feerate_sat_per_1000_weight: 253, + new_channel_value_satoshis: 110_000, + txid: signing_txid, + }, + }, + SpliceCandidateDetails { + contribution: Some(test_funding_contribution_with_outputs(0, 500, &[])), + status: SpliceCandidateStatus::WaitingOnLock, + }, + ], + confirmed_candidate: None, + received_splice_locked_txid: None, + }; + + let candidates = funding_candidates(Some(&details), counterparty_node_id, channel_id); + + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].txid, prior_txid); + assert_eq!(candidates[0].channels.len(), 1); + assert_eq!(candidates[0].channels[0].contribution, None); + assert_eq!(candidates[1].txid, signing_txid); + assert_eq!(candidates[1].channels.len(), 1); + assert_eq!(candidates[1].channels[0].counterparty_node_id, counterparty_node_id); + assert_eq!(candidates[1].channels[0].channel_id, channel_id); + assert_eq!(candidates[1].channels[0].purpose, FundingPurpose::Splice); + assert_eq!(candidates[1].channels[0].contribution, Some(contribution)); + + assert!(funding_candidates(None, counterparty_node_id, channel_id).is_empty()); + } + + /// 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; + + 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![] }); + + // 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(); + + // 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(); + + 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()); + } + + /// 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" + ); + } + + /// 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; + + 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)); + } + + /// 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(); + + 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); + + // 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()); + } + + /// A round's facts name its payment for as long as they are kept, which outlasts the record: + /// they describe a transaction that happened, so `remove_payment` leaves them behind. A later + /// wallet event naming that transaction therefore resolves an id whose record is gone, and + /// has to skip — failing would abandon the rest of the batch and the wallet's own view of the + /// chain with it. + #[tokio::test] + async fn a_replacement_of_a_removed_payments_transaction_is_skipped() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // A signed splice round the wallet has observed: the facts name its payment and sync has + // created the record. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let payment_id = + wallet.find_payment_by_txid(txid).await.unwrap().expect("the round names its payment"); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); + + wallet.remove_payment(&payment_id).await.unwrap(); + assert_eq!( + wallet.find_payment_by_txid(txid).await.unwrap(), + Some(payment_id), + "the round's facts go on naming the payment the user removed", + ); + + // The user fee-bumps the splice, so the wallet reports the signed round replaced. A + // second event in the same batch pins that the batch goes on being handled. + let other = wallet_paying_tx(&wallet, 2); + let other_txid = other.compute_txid(); + insert_unconfirmed_tx(&wallet, other.clone()); + let events = vec![ + WalletEvent::TxReplaced { + txid, + tx: Arc::new(tx.clone()), + conflicts: vec![(0, Txid::from_byte_array([0xB1; 32]))], + }, + WalletEvent::TxUnconfirmed { + txid: other_txid, + tx: Arc::new(other), + old_block_time: None, + }, + ]; + wallet.update_payment_store(events).await.unwrap(); + + assert!( + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none(), + "a removed payment must not come back", + ); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + assert!( + wallet + .payment_stores + .payment_store() + .get(&PaymentId(other_txid.to_byte_array())) + .await + .unwrap() + .is_some(), + "the rest of the batch must still be handled", + ); + } + + /// Payments without a pending-store entry — lightning payments, and on-chain payments that + /// already graduated — must remove cleanly: the unconditional pending-store removal relies + /// on removing a missing key being a no-op. + #[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; + + 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(); + + wallet.remove_payment(&payment_id).await.unwrap(); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); + + // Removing an id known to neither store is also a no-op rather than an error. + wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap(); + } + + /// 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 wallet event for the round + /// after graduation (e.g. LDK re-broadcasting a promoted 0conf splice whose confirmation landed + /// while the node was offline) would miss the record and create a duplicate under a fresh id. + #[tokio::test] + async fn find_payment_by_txid_resolves_graduated_records() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let txid = Txid::from_byte_array([6u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut graduated = + interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); + graduated.kind = PaymentKind::Onchain { + txid, + status: confirmed_status(), + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + graduated.status = PaymentStatus::Succeeded; + wallet.payment_stores.payment_store().insert_or_update(graduated).await.unwrap(); + + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(payment_id)); + } + + /// A cooperative close conflicts with a pending splice's funding transaction — both spend the + /// pre-splice funding outpoint — so sync records the close among the splice record's + /// conflicting txids, and the close's confirmation then resolves to the splice's PaymentId. + /// The funding record must not adopt the close's txid and confirmation as its own: the close + /// is not a round of the splice. It must land on a record keyed by the close's own id. + #[tokio::test] + async fn funding_record_does_not_adopt_a_conflicting_close() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let funding_outpoint = + bitcoin::OutPoint { txid: Txid::from_byte_array([3u8; 32]), vout: 0 }; + + // The close pays the shutdown script, which is a wallet address. + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let close_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: funding_outpoint, + script_sig: bitcoin::ScriptBuf::new(), + sequence: bitcoin::Sequence::MAX, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + }; + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + 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)); + + 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), + } + } + + /// The mirror image of [`funding_record_does_not_adopt_a_conflicting_close`]: a cooperative + /// close that a splice round replaces lists the round among its conflicting txids, so the + /// round's confirmation resolves to the close's entry as readily as to the splice's, which + /// records the round as its own. It must land on the splice's record whichever entry the + /// pending cache lists first: the close's record is not the round's, and merging the round + /// into it leaves the splice's payment pending for good. Several closes and several fresh + /// wallets, each with its own cache order, make the splice's entry unlikely to come first + /// every time. + #[tokio::test] + async fn close_record_does_not_adopt_a_conflicting_splice_round() { + let secp = bitcoin::secp256k1::Secp256k1::new(); + let counterparty_node_id = bitcoin::secp256k1::PublicKey::from_secret_key( + &secp, + &bitcoin::secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(), + ); + let channel_id = lightning::ln::types::ChannelId::from_bytes([4u8; 32]); + + for _ in 0..12 { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // The round pays a wallet address, so the wallet's view of it carries figures of its own. + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let splice_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: Txid::from_byte_array([3u8; 32]), + vout: 0, + }, + script_sig: bitcoin::ScriptBuf::new(), + sequence: bitcoin::Sequence::MAX, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + }; + let splice_txid = splice_tx.compute_txid(); + // Keyed away from the round's txid, as a later round of a splice is. + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.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)); + } + } + } + + /// 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 lost transaction's rebroadcast on every tip change. + #[tokio::test] + async fn funding_payment_fails_once_a_foreign_conflict_confirms_to_depth() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + 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(); + + // The close is canonically confirmed; the splice transaction, having lost the conflict, + // is no longer canonical (here: never inserted at all). + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_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 lost transaction stops being rebroadcast" + ); + } + + /// A round that fell out of the mempool has not lost: BDK drops an evicted transaction from + /// the canonical set just as it drops one displaced by a confirmed conflict, but an evicted + /// round can be rebroadcast and confirm. With one round double-spent by a close confirmed to + /// depth and the other merely evicted, the record must stay `Pending`; it is lost only once + /// a transaction confirmed to depth spends an input of every round. + #[tokio::test] + async fn funding_payment_survives_while_an_evicted_candidate_can_still_confirm() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // Two rounds of one negotiation, both seen in the mempool. + let first_round = wallet_paying_tx(&wallet, 1); + let first_txid = first_round.compute_txid(); + let second_round = wallet_paying_tx(&wallet, 2); + let second_txid = second_round.compute_txid(); + insert_unconfirmed_tx(&wallet, first_round); + insert_unconfirmed_tx(&wallet, second_round); + + let payment_id = PaymentId([22u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: first_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: second_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + ]; + let details = + interactive_funding_details(payment_id, second_txid, Some(1_000_000), Some(600)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + + // A close double-spends the first round's input and confirms through the anti-reorg + // depth, while the second round merely drops out of the mempool. + let close_tx = wallet_paying_tx(&wallet, 1); + let close_txid = close_tx.compute_txid(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + insert_confirmed_tx(&wallet, close_tx, 5); + evict_tx(&wallet, second_txid); + + 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, "the evicted round can still confirm"); + let entry = + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(entry.candidates().len(), 2, "both rounds stay on record"); + + // A second close spends the evicted round's input and confirms to depth too: no round + // can confirm now. It never displaced a canonical round, so the conflict list does not + // name it; the loss is read from the wallet's transaction graph. + let second_close = wallet_paying_tx(&wallet, 2); + insert_confirmed_tx(&wallet, second_close, 6); + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(5 + ANTI_REORG_DELAY - 1), + new_tip: block_id(6 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + } + + /// A confirmed conflict that is one of the record's own candidates is RBF resolution, not a + /// loss: classification adopts it into the record, so the failure pass must leave the record + /// alone. + #[tokio::test] + async fn funding_payment_survives_a_confirmed_conflict_that_is_a_candidate() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let bumped_tx = wallet_paying_tx(&wallet, 3); + let bumped_txid = bumped_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: bumped_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + ]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![bumped_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, bumped_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), + "the entry must survive for classification to adopt the confirmed candidate" + ); + } + + /// A foreign conflict that has confirmed but not yet through the anti-reorg depth may still + /// be reorged out, letting the funding transaction confirm after all; the record must stay + /// pending until the conflict's confirmation is final. + #[tokio::test] + async fn funding_payment_survives_a_foreign_conflict_short_of_depth() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(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()); + } + + /// A conflict may double-spend only one round of the negotiation — e.g. it shares an input + /// with an RBF attempt but not with the original candidate. While any candidate is still + /// canonical it can still confirm, so the record must stay pending. + #[tokio::test] + async fn funding_payment_survives_while_a_candidate_can_still_confirm() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let conflict_tx = wallet_paying_tx(&wallet, 3); + let conflict_txid = conflict_tx.compute_txid(); + // A live candidate: spends a different outpoint, so the conflict didn't kill it. + let live_candidate_tx = wallet_paying_tx(&wallet, 4); + let live_candidate_txid = live_candidate_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + 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 payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), + "a candidate can still confirm, so the record must stay pending" + ); + } + + /// The failure write pair is record first, entry second: a crash in between leaves a + /// `Failed` record with a lingering entry. The next tip pass must finish the job — remove + /// the entry without disturbing the record. + #[tokio::test] + async fn a_failed_funding_payment_with_a_lingering_entry_is_cleaned_up() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut recorded = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + // The entry embeds the pre-failure snapshot, as a crash between the two writes leaves it. + let snapshot = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the repair pass must not rewrite"); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), + "the lingering entry must be removed" + ); + } + + /// A crash between the failure's record write and its entry removal loses the wallet + /// changeset too, so the restart's catch-up sync replays the same events: `TxReplaced` for + /// the lost funding transaction resolves through the lingering entry to the already-`Failed` + /// record. Re-embedding that record would stamp `Failed` into the entry and hide it from the + /// pending listing that repairs it; the replay must instead finish the interrupted removal. + #[tokio::test] + async fn replayed_replacement_finishes_an_interrupted_failure() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut recorded = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + let snapshot = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let events = vec![ + WalletEvent::TxReplaced { + txid: splice_txid, + tx: Arc::new(dummy_tx()), + conflicts: vec![(0, close_txid)], + }, + 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 unknown_transaction_is_recorded_after_two_payment_store_reads() { + let counting_store = ReadCountingStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let tx = wallet_paying_tx(&wallet, 1); + let txid = tx.compute_txid(); + let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let event = WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; + + let payment_id = PaymentId(txid.to_byte_array()); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); + assert_eq!(reads, 2, "recording an unknown transaction re-read the payment store"); + } + + /// A funding record wallet sync created for a round this node recorded nothing about keeps the + /// txid-derived id of its first candidate. Once the payment settles and its entry is removed, a wallet event for + /// that candidate no longer resolves through the candidate history — the fallback keys it by + /// its own txid, colliding with the record's id. Recording the event there would merge a fresh + /// wallet-view `Pending` payment into the terminal record; such events must be skipped. + #[tokio::test] + async fn candidate_event_does_not_resurrect_a_settled_funding_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // The record keeps the txid-derived id of its first candidate r1, as one created for a + // round nothing had recorded does; its txid rotated to the RBF round r2. The payment failed and its + // pending entry is gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let r2 = Txid::from_byte_array([4u8; 32]); + let payment_id = PaymentId(r1.to_byte_array()); + let mut recorded = interactive_funding_details(payment_id, r2, Some(1_000_000), Some(600)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + // r1 reappears in the mempool after the failure... + let event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + + // ...and even confirms: the record settled as `Failed` and must stay that way. + let event = WalletEvent::TxConfirmed { + txid: r1, + tx: Arc::new(dummy_tx()), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + } + + /// The same collision through a conflict list: a pending entry naming a settled funding + /// record's transaction as a conflict of its own round resolves an event for that transaction + /// to the entry's record, which finds it foreign, and the fallback to the transaction's own id + /// lands on the settled record. That id is read before anything is written under it. + #[tokio::test] + async fn conflict_listed_event_does_not_resurrect_a_settled_funding_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // A settled funding record under the txid-derived id of r1, its pending entry gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let settled_id = PaymentId(r1.to_byte_array()); + let mut settled = interactive_funding_details(settled_id, r1, Some(1_000_000), Some(600)); + settled.status = PaymentStatus::Failed; + settled.latest_update_timestamp = 7; + wallet.payment_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 event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&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 inbound_funding_payment_fails_once_a_foreign_conflict_confirms_to_depth() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + 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(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + } + + /// Wallet sync can record a genuine replacement round before it is recorded as a candidate: + /// the counterparty broadcast a round this node did not contribute to, which is recorded only + /// when this node signs a later round of the splice. The funding-status gate then routes the + /// round's confirmation to a duplicate record keyed by the round's txid, whose pending entry + /// shadows the funding record in `find_payment_by_txid`'s direct probe. Once the round is + /// recorded as a candidate, the write must merge the duplicate — adopt its confirmation and + /// remove it — so a single record tracks the splice. + #[tokio::test] + async fn recording_a_round_merges_duplicate_records_for_its_candidates() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); + + // Round 1 recorded normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate: a + // duplicate untyped record under the txid-derived id, plus its pending entry. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(duplicate_id)); + + // Round 2 is recorded as a candidate, with the history of a later round this node signs. + let rounds = vec![ + FundingTxCandidate { + txid: 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)); + } + + /// 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 a_rerun_merge_completes_a_partially_failed_duplicate_removal() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); + + // Round 1 recorded normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.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)); + } + + /// 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)); + } + + /// 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)); + } + + /// A merge cut short between adopting a confirmed duplicate's confirmation and removing the + /// duplicate leaves the funding record confirmed on the duplicate's transaction, the pending + /// entry at its prior status and the duplicate untouched, and a re-run completes the removal: + /// the merge is idempotent, so the record's next write or a replayed `SpliceNegotiated` event + /// can finish what a failure cut short. The failure injected is the pending store's, which the + /// adoption writes after the payment store. + #[tokio::test] + async fn a_torn_duplicate_merge_is_completed_by_a_rerun() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + // Round 1 is recorded at signing, round 2 is a counterparty-initiated replacement, and + // round 3 is this node's bump of it, recorded with the channel's history when signed. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + 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(); + + // Wallet sync filed the replacement's confirmation under an untyped record of its own, a + // duplicate of the funding record that already lists the replacement as a candidate. + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: confirmed_status(), + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + let duplicate_entry = PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new()); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(duplicate_entry.clone()) + .await + .unwrap(); + let entry_before = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + let rounds = entry_before.candidates().to_vec(); + + // The merge adopts the confirmation onto the payment record, then fails to mirror it onto + // the pending entry and stops short of removing the duplicate. + fail_store.fail_writes.store(true, Ordering::Release); + { + let guard = wallet.payment_stores.lock().await; + let res = wallet.merge_duplicate_candidate_records(&guard, id, &rounds).await; + assert!(res.is_err(), "the injected pending-store failure must surface"); + } + fail_store.fail_writes.store(false, Ordering::Release); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } + if t == replacement_txid + )); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(entry_before) + ); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate_entry) + ); + + // A re-run finds the confirmation adopted, mirrors it, and removes the duplicate. + { + let guard = wallet.payment_stores.lock().await; + wallet.merge_duplicate_candidate_records(&guard, id, &rounds).await.unwrap(); + } + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); + } + + #[tokio::test] + 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(); + + // 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 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(); - 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 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); } - fn pooled_indices(wallet: &Wallet) -> Vec { - wallet.address_pool.lock().unwrap().available.iter().map(|(index, _)| *index).collect() + /// A previous transaction with a P2WPKH output at index 0 for a contribution input to spend; + /// `seed` varies the output script, and with it the txid. + fn test_prevtx(seed: u8) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + } + } + + /// Records `rounds` as their signing did — the last round signed, the others negotiated + /// before — then marks the signed round as broadcast, as its `SpliceNegotiated` event would. + /// Returns the record's id. + async fn record_broadcast_rounds( + wallet: &Wallet, tx: &Transaction, rounds: &[(Txid, Option)], + ) -> PaymentId { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let candidates = splice_candidates(counterparty_node_id, channel_id, rounds); + 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 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 closing_without_a_round_of_ours_held_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let id = record_broadcast_rounds(&wallet, &tx, &[(txid, Some(contribution))]).await; - wallet.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)); + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); - // 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 = 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()); + } + + /// Signs a round and records its broadcast without wallet sync ever observing its + /// transaction, so the entry tracking it carries no payment record. + async fn record_unobserved_broadcast_round( + wallet: &Wallet, tx: &Transaction, rounds: &[(Txid, Option)], + ) -> PaymentId { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let candidates = splice_candidates(counterparty_node_id, channel_id, rounds); + wallet.record_signed_funding(tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, tx.compute_txid()).await.unwrap(); + wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap().expect("recorded") + } + + /// Asserts that the attempt the unobserved round `txid` of ours belonged to is on record under + /// `id` as a failed payment carrying the share of the round the signing recorded. + async fn assert_failed_unobserved_round(wallet: &Wallet, id: PaymentId, txid: Txid) { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the attempt is on record"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!( + payment.kind, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { + channels: vec![Channel { counterparty_node_id, channel_id }], + }), + } + ); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(payment.fee_paid_msat, Some(300_000)); + assert_eq!(payment.direction, PaymentDirection::Inbound); } + /// The close may find the entry of a round of ours nothing has observed: LDK released our + /// signatures, but wallet sync never saw the transaction before the channel closed on a + /// commitment transaction and the monitor stopped watching the round. The round can no longer + /// confirm, so the attempt is failed under a record written for it now, from the share of the + /// round the signing recorded, and the entry goes. #[tokio::test] - async fn pool_reloads_across_restarts_without_burning_indices() { + async fn closing_without_a_round_of_ours_held_fails_an_unobserved_round() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let id = + record_unobserved_broadcast_round(&wallet, &tx, &[(txid, Some(contribution))]).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)) - }; - - let wallet = new_test_wallet(Arc::clone(&store), true).await; - wallet.refill_address_pool().await.unwrap(); + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).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)); + assert_failed_unobserved_round(&wallet, id, txid).await; + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } + /// The entry of an unobserved round stays while the monitor watches the round: the + /// counterparty may hold our signatures and broadcast it, and wallet sync resolves it should + /// it confirm. #[tokio::test] - async fn loading_drops_pool_indices_the_wallet_never_revealed() { + async fn closing_with_the_round_held_keeps_an_unobserved_entry() { 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), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let id = + record_unobserved_broadcast_round(&wallet, &tx, &[(txid, Some(contribution))]).await; - let wallet = new_test_wallet(Arc::clone(&store), true).await; - wallet.refill_address_pool().await.unwrap(); + wallet.resolve_closed_channel_splice_rounds(channel_id, &[txid]).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)); + assert!( + wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none(), + "the round can still confirm, so nothing is failed", + ); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert!(entry.details().is_none()); + assert!(entry.candidate(txid).is_some()); } + /// The entry of an unobserved round stays once LDK promoted the round to the channel's + /// funding, whatever the monitor holds by the close: a zero-conf splice locks before its + /// transaction confirms, and the round can still confirm once the channel has closed. #[tokio::test] - async fn signer_provider_callbacks_fail_closed_when_pool_is_empty() { + async fn closing_keeps_an_unobserved_entry_whose_round_locked() { 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 (_, 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_unobserved_broadcast_round(&wallet, &tx, &[(txid, Some(contribution))]).await; + wallet.resolve_promoted_splice_round(channel_id, txid, Some(&[txid])).await.unwrap(); - // 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.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); - wallet.refill_address_pool().await.unwrap(); - assert!(keys_manager.get_destination_script([0u8; 32]).is_ok()); - assert!(keys_manager.get_shutdown_scriptpubkey().is_ok()); + assert!( + wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none(), + "the locked round can still confirm, so nothing is failed", + ); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + assert!(entry.details().is_none()); + assert!(entry.candidate(txid).is_some()); + assert_eq!(entry.locked_rounds(), &[txid]); + } + + /// Signs an external splice-out round of ours, which the signing declines to record, then a + /// bump of it that pays the wallet, recorded with the external round in its history as a + /// round of ours by its contribution. Returns the external round's txid and the entry's id. + async fn record_unobserved_bump_of_an_external_splice_out( + wallet: &Wallet, + ) -> (Txid, PaymentId) { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (external_tx, external_contribution) = external_splice_out_round(1, 500_000, 300); + let external_txid = external_tx.compute_txid(); + let (tx, contribution) = splice_out_round(wallet, 2, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(external_txid, Some(external_contribution)), (txid, Some(contribution))]; + let candidates = splice_candidates(counterparty_node_id, channel_id, &rounds); + + wallet.record_signed_funding(&external_tx, &candidates[..1]).await.unwrap(); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!( + wallet.channel_tx_facts_store.get(&external_txid).await.unwrap().is_none(), + "no facts for the round" + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("recorded"); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry"); + assert!(entry.details().is_none()); + assert!(entry.candidate(external_txid).is_some_and(|round| round.amount_msat.is_some())); + (external_txid, id) } - /// 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>>>>, - } + /// A round of ours may have no share on record: the signing records nothing for a round that + /// moves no wallet funds, a splice-out to an external address, yet a later round signed with + /// it in the history lists it as ours by its contribution. Left the newest round of ours once + /// that later round is dropped, it was never a payment of the wallet's, so at the close there + /// is nothing to fail: the entry goes without a record. + #[tokio::test] + async fn closing_drops_an_unobserved_round_that_moved_no_wallet_funds_without_a_record() { + 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 (external_txid, id) = record_unobserved_bump_of_an_external_splice_out(&wallet).await; - impl SnapshotStore { - fn new() -> Self { - Self { - data: Arc::new(Mutex::new(HashMap::new())), - snapshots: Arc::new(Mutex::new(Vec::new())), - } - } + // The bump is abandoned before broadcast and the channel closes on a commitment + // transaction; the external round is all the entry has left. + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).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())) } - } + assert!( + wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none(), + "round {} was never a payment of the wallet's, so there is nothing to fail", + external_txid, + ); + assert!(wallet.payment_stores.pending_payment_store().get(&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 } - } - - 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(()) } - } + /// A failed read of the round's facts is no answer about its share: the close fails for the + /// event to be replayed, and the entry keeps the round whose share could not be read rather + /// than going without a record. The drop pass, which reads no facts, has taken the abandoned + /// bump out of the entry before the read, so the replay finds the external round alone and + /// drops it without a record, as the close would have at the first attempt. + #[tokio::test] + async fn closing_leaves_an_unobserved_round_whose_facts_cannot_be_read_for_a_replay() { + let fail_store = + FailSwitchStore::failing_only(CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (external_txid, id) = record_unobserved_bump_of_an_external_splice_out(&wallet).await; + + fail_store.fail_reads.store(true, Ordering::Release); + let result = wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await; + assert!(matches!(result, Err(Error::PersistenceFailed)), "{:?}", result); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + fail_store.fail_reads.store(false, Ordering::Release); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); + let listed: Vec = entry.candidates().iter().map(|round| round.txid).collect(); + assert_eq!(listed, vec![external_txid], "the abandoned bump is dropped before the read"); + assert!(entry.candidate(external_txid).is_some_and(|round| round.amount_msat.is_some())); + + // The replay, with the reads back: the external round alone is left, and it goes without + // a record. + wallet.resolve_closed_channel_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()); + } + + /// A sibling round this node did not contribute to, in the entry's history from the signing + /// of the round of ours that bumped it, locks before wallet sync observed the round of ours + /// LDK released, so no round of ours can confirm anymore. The promotion is recorded on the + /// entry, and a locked round not ours keeps nothing: the attempt is failed under a record + /// written for it now, and the entry goes. + #[tokio::test] + async fn promoting_a_round_not_ours_fails_an_unobserved_round() { + 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 sibling_txid = Txid::from_byte_array([0xBB; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(sibling_txid, None), (txid, Some(contribution))]; + let id = record_unobserved_broadcast_round(&wallet, &tx, &rounds).await; - 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(()) } - } + wallet + .resolve_promoted_splice_round(channel_id, sibling_txid, Some(&[sibling_txid])) + .await + .unwrap(); - 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) } - } + assert_failed_unobserved_round(&wallet, id, txid).await; + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } - 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 }) } - } + /// LDK promoted a round of ours and discarded the counterparty's round it replaced with the + /// promotion, so the payment stays as it is, the promotion recorded and the discarded round + /// still in its history. + #[tokio::test] + async fn promoting_a_round_of_ours_keeps_its_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + + wallet.resolve_promoted_splice_round(channel_id, txid, Some(&[txid])).await.unwrap(); + + let payment = wallet + .payment_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 pool_survives_a_crash_at_any_point_during_refill() { - let snapshot_store = SnapshotStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(snapshot_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; - // 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(); + 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.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(); + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .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); - } + 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()); } - /// 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, - } + /// 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 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()), - } - } - } + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); - 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) - } + 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()); + } - 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 - } - } + /// The same at a close whose monitor holds the counterparty's round the record moved onto: + /// the record naming a round recorded without a stake does not make it a round of ours. + #[tokio::test] + async fn closing_with_a_held_round_not_ours_the_record_adopted_fails_the_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let rounds = [(counterparty_txid, None), (tx.compute_txid(), Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + let event = WalletEvent::TxUnconfirmed { + txid: counterparty_txid, + tx: Arc::new(dummy_tx()), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().unwrap(); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_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) - } + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) + .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) - } + 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()); } - 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 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 aborting_a_refill_mid_persist_loses_no_reveals() { - let gated_store = GatedStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(gated_store.clone())); + 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; - 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); + 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()); - // 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(); + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .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 - ); + 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 get_new_address_pops_the_oldest_pooled_address_and_persists_the_dequeue() { + 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; - wallet.refill_address_pool().await.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 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]); - let (front_index, front_address) = - wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); - assert_eq!(front_index, 0); + 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()); + } - // 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 payment whose round LDK promoted before is kept when a later splice's round is promoted + /// — the round can still confirm, the later one descending from it — while the later round's + /// payment is kept for the round LDK holds. The close after that keeps both as well. + #[tokio::test] + async fn a_later_promotion_keeps_a_payment_whose_round_locked_before() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (first_tx, first) = splice_in_round(&wallet, 1); + let first_txid = first_tx.compute_txid(); + let first_id = + record_broadcast_rounds(&wallet, &first_tx, &[(first_txid, Some(first))]).await; + wallet + .resolve_promoted_splice_round(channel_id, first_txid, Some(&[first_txid])) + .await + .unwrap(); - // 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)); + let (second_tx, second) = splice_in_round(&wallet, 2); + let second_txid = second_tx.compute_txid(); + let second_id = + record_broadcast_rounds(&wallet, &second_tx, &[(second_txid, Some(second))]).await; + wallet + .resolve_promoted_splice_round(channel_id, second_txid, Some(&[second_txid])) + .await + .unwrap(); + + for (id, locked) in [(first_id, first_txid), (second_id, second_txid)] { + let payment = wallet + .payment_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]); + } + + 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()); + } } + /// 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 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 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; - wallet.refill_address_pool().await.unwrap(); - - let (front_index, front_address) = - wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); + 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); - // 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_promoted_splice_round(channel_id, first_txid, Some(&[first_txid])) + .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::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]); } + /// 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 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 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]); - // 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); + 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" + ); } + /// 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 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 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; - 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 (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(); - /// 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, + 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) } - impl RecordOnlyStore { - fn new() -> Self { - Self { - inner: Arc::new(InMemoryStore::new()), - fail_non_record_writes: Arc::new(AtomicBool::new(false)), - } + /// 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()); } - 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) - } + /// 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 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")); + /// 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; } - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await - } - } + 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 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) - } + /// 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()); + } - fn list( - &self, primary_namespace: &str, secondary_namespace: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + /// 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()); } } - 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, - ) - } + /// The facts a channel would record for a splice candidate: the pre-splice funding output it + /// spends, the new funding output it creates, and this node's share of it. + fn splice_candidate_facts( + txid: Txid, spends: Txid, channel: &Channel, figures: LocalFundingFigures, + ) -> (ChannelTxFacts, ChannelTxFacts) { + let spent = ChannelTxFacts::new(spends).with_outputs( + channel, + None, + ChannelOutputRole::Funding, + [0], + ); + let created = + ChannelTxFacts::new(txid).with_outputs(channel, None, ChannelOutputRole::Funding, [0]); + (spent, ChannelTxFacts { local_figures: Some(figures), ..created }) } #[tokio::test] - async fn 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 a_reported_share_of_a_transaction_outranks_the_wallets_view() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; - 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 (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let tx = wallet_paying_tx(&wallet, 4); + let txid = tx.compute_txid(); + insert_unconfirmed_tx(&wallet, tx.clone()); - let reloaded = new_test_wallet(Arc::clone(&store), true).await; - reloaded.refill_address_pool().await.unwrap(); - assert!(pooled_indices(&reloaded).contains(&front_index)); + let figures = LocalFundingFigures { + funding_payment_id: PaymentId([31u8; 32]), + amount_msat: Some(77_000), + fee_paid_msat: Some(1_100), + direction: PaymentDirection::Outbound, + }; + // The wallet reads a shared funding input as wholly this node's, so its view of the + // transaction is a different one, which is the point of preferring the reported share. + assert_ne!( + wallet.onchain_payment_fields(&tx), + (figures.amount_msat, figures.fee_paid_msat, figures.direction), + ); + + let (spent, created) = splice_candidate_facts( + txid, + tx.input[0].previous_output.txid, + &channel, + figures.clone(), + ); + wallet.record_channel_tx_facts(spent).await.unwrap(); + wallet.record_channel_tx_facts(created).await.unwrap(); + + let event = + WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx.clone()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + // 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), + } } + /// The chain-tip pass names only records still pending, and a channel's report can land + /// after its record graduated: LDK matures a claim's outputs at the tip the claim's record + /// graduates at. The event handler names the record as it records the report. #[tokio::test] - async fn loading_survives_an_undecodable_pool_record() { + async fn a_graduated_record_is_named_as_its_facts_arrive() { 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; - // 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], + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", ) - .await .unwrap(); + let channel_id = ChannelId([7u8; 32]); + let channel = Channel { counterparty_node_id, channel_id }; + // A claim the channel monitor made is replaceable, which is what tells it apart from a + // cooperative close paying the wallet directly. + let mut claim = wallet_paying_tx(&wallet, 4); + claim.input[0].sequence = Sequence::ENABLE_RBF_NO_LOCKTIME; + let claim_txid = claim.compute_txid(); + insert_confirmed_tx(&wallet, claim.clone(), 5); + + // Wallet sync records the claim and graduates it before the channel reports it. + let confirmed = WalletEvent::TxConfirmed { + txid: claim_txid, + tx: Arc::new(claim.clone()), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![confirmed]).await.unwrap(); + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let graduated = WalletEvent::ChainTipChanged { + old_tip: block_id(5), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![graduated]).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_id = PaymentId(claim_txid.to_byte_array()); + let unnamed = wallet + .payment_stores + .payment_store() + .get(&payment_id) + .await + .unwrap() + .expect("wallet sync records the transaction"); + assert_eq!(unnamed.status, PaymentStatus::Succeeded); + assert!( + matches!(unnamed.kind, PaymentKind::Onchain { tx_type: None, .. }), + "no channel has reported the transaction yet, so it cannot be named: {:?}", + unnamed.kind, + ); - /// 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, - } + wallet + .record_channel_tx_facts(ChannelTxFacts::new(claim_txid).with_outputs( + &channel, + None, + ChannelOutputRole::Direct, + [0], + )) + .await + .unwrap(); + wallet.name_recorded_transaction(claim_txid).await; - impl RecordFailStore { - fn new() -> Self { - Self { - inner: Arc::new(InMemoryStore::new()), - fail_record_writes: Arc::new(AtomicBool::new(false)), - } - } + let named = wallet + .payment_stores + .payment_store() + .get(&payment_id) + .await + .unwrap() + .expect("the record stays"); + assert!( + matches!( + named.kind, + PaymentKind::Onchain { + tx_type: Some(TransactionType::Claim { counterparty_node_id: cp, channel_id: ch }), + .. + } if cp == counterparty_node_id && ch == channel_id + ), + "the graduated record is named as the facts arrive: {:?}", + named.kind, + ); } - 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 - } - } + /// A coin of the wallet's and an unconfirmed, replaceable spend of it, plus `foreign_input` + /// when given, recorded as the wallet's outbound payment with nothing reported about it. + async fn replaceable_spend( + wallet: &Wallet, coin_byte: u8, foreign_input: Option, + ) -> (Transaction, PaymentId) { + let coin = wallet_paying_tx(wallet, coin_byte); + let coin_txid = coin.compute_txid(); + insert_confirmed_tx(wallet, coin, 5); - 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 spending = |previous_output| bitcoin::TxIn { + previous_output, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + }; + let mut input = vec![spending(OutPoint { txid: coin_txid, vout: 0 })]; + input.extend(foreign_input.map(spending)); + let spend = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input, + output: vec![TxOut { + value: Amount::from_sat(80_000), + script_pubkey: bitcoin::ScriptBuf::new_op_return(&[]), + }], + }; + let txid = spend.compute_txid(); + insert_unconfirmed_tx(wallet, spend.clone()); + let seen = + WalletEvent::TxUnconfirmed { txid, tx: Arc::new(spend.clone()), old_block_time: None }; + wallet.update_payment_store(vec![seen]).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) - } + let payment_id = PaymentId(txid.to_byte_array()); + let recorded = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(recorded.direction, PaymentDirection::Outbound); + assert!(matches!( + recorded.kind, + PaymentKind::Onchain { tx_type: None, status: ConfirmationStatus::Unconfirmed, .. } + )); + (spend, payment_id) } - 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 transaction a channel reported is driven by LDK's funding and close lifecycle, whatever + /// its payment record says of it: the fee bump refuses it from the facts. + #[tokio::test] + async fn an_on_chain_fee_bump_refuses_a_transaction_a_channel_reported() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (spend, payment_id) = replaceable_spend(&wallet, 0x21, None).await; + + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channel = Channel { counterparty_node_id, channel_id: ChannelId([7u8; 32]) }; + wallet + .record_channel_tx_facts(ChannelTxFacts::new(spend.compute_txid()).with_outputs( + &channel, + Some(crate::UserChannelId(1)), + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(); + + assert!(matches!( + wallet.bump_fee_rbf(payment_id, None, 0).await, + Err(Error::InvalidPaymentId) + )); } + /// A transaction reaching beyond the wallet's own coins is refused although no channel + /// reported it: this node alone cannot re-sign it. #[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()); - } + async fn an_on_chain_fee_bump_refuses_a_transaction_spending_a_coin_the_wallet_does_not_own() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let foreign = OutPoint { txid: Txid::from_byte_array([0x31; 32]), vout: 1 }; + let (_spend, payment_id) = replaceable_spend(&wallet, 0x22, Some(foreign)).await; - 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 - ); + assert!(matches!( + wallet.bump_fee_rbf(payment_id, None, 0).await, + Err(Error::InvalidPaymentId) + )); } + /// A fact that cannot be read is no answer about the transaction: the fee bump refuses it + /// with the error rather than passing it for want of a reason to refuse. #[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 an_on_chain_fee_bump_refuses_a_transaction_whose_facts_cannot_be_read() { + let fail_store = + FailSwitchStore::failing_only(CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.refill_address_pool().await.unwrap(); - let (_, oldest_address) = - wallet.address_pool.lock().unwrap().available.front().cloned().unwrap(); + let (_spend, payment_id) = replaceable_spend(&wallet, 0x23, None).await; - // 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; + fail_store.fail_reads.store(true, Ordering::Release); + let result = wallet.bump_fee_rbf(payment_id, None, 0).await; + assert!(matches!(result, Err(Error::PersistenceFailed)), "{:?}", result); + } - // 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; - } + #[tokio::test] + async fn an_unnamed_transaction_is_named_once_its_facts_arrive() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; - // 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); + 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 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) + 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, ); - } - /// 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 + .record_channel_tx_facts(ChannelTxFacts::new(swept).with_outputs( + &channel, + None, + ChannelOutputRole::Spendable, + [0], + )) + .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 block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { old_tip: block_id(1), new_tip: block_id(2) }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let named = wallet + .payment_stores + .payment_store() + .get(&payment_id) + .await + .unwrap() + .expect("the record stays"); + match named.kind { + PaymentKind::Onchain { tx_type: Some(TransactionType::Sweep { channels }), .. } => { + assert_eq!(channels, vec![channel]); + }, + kind => panic!("unexpected kind {:?}", kind), } } - 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) - } + /// 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, for retention; + /// and the outputs all of those hold, for the startup pass. + #[derive(Default)] + struct TestChannelState { + channels: Vec, + monitors: Vec, + tracked_outputs: Vec>, + held_outputs: Vec, + } - 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 - } - } + /// A stand-in for the node's channel state, which holds what it holds whether or not it can + /// be consulted: it cannot be while the node is built and while it is torn down. + struct TestLiveness { + state: Mutex, + reachable: AtomicBool, + } - 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) + impl TestLiveness { + fn holding(state: TestChannelState) -> Arc { + Arc::new(Self { state: Mutex::new(state), reachable: AtomicBool::new(true) }) } - fn list( - &self, primary_namespace: &str, secondary_namespace: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + fn holding_nothing() -> Arc { + Self::holding(TestChannelState::default()) } - } - 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, - ) + /// Holds `state` without answering for it until [`Self::reach`] is called. + fn unreachable_holding(state: TestChannelState) -> Arc { + Arc::new(Self { state: Mutex::new(state), reachable: AtomicBool::new(false) }) + } + + fn unreachable() -> Arc { + Self::unreachable_holding(TestChannelState::default()) } - } - fn dummy_tx() -> Transaction { - Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: Vec::new(), + fn reach(&self) { + self.reachable.store(true, Ordering::Release); } } - fn confirmed_block_time(height: u32) -> ConfirmationBlockTime { - ConfirmationBlockTime { - block_id: BlockId { height, hash: bitcoin::BlockHash::from_byte_array([9u8; 32]) }, - confirmation_time: 100, + impl ChannelLiveness for TestLiveness { + fn live_channels(&self) -> Option> { + if !self.reachable.load(Ordering::Acquire) { + return None; + } + let state = self.state.lock().unwrap(); + Some(live_channels_of( + state.channels.iter().copied(), + state.monitors.iter().copied(), + state.tracked_outputs.iter().copied(), + )) } - } - 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 held_outputs(&self) -> Option> { + if !self.reachable.load(Ordering::Acquire) { + return None; + } + Some(self.state.lock().unwrap().held_outputs.clone()) + } } - fn onchain_details(txid: Txid, status: ConfirmationStatus) -> PaymentDetails { - PaymentDetails::new( - PaymentId([42u8; 32]), - PaymentKind::Onchain { txid, status, tx_type: None }, - Some(1_000_000), - Some(500), - PaymentDirection::Outbound, - PaymentStatus::Pending, - ) + fn 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) } } - fn confirmed_status() -> ConfirmationStatus { - ConfirmationStatus::Confirmed { - block_hash: bitcoin::BlockHash::from_byte_array([8u8; 32]), - height: 100, - timestamp: 1, + /// Builds a transaction spending `outpoint` into the wallet. + fn tx_spending(wallet: &Wallet, outpoint: OutPoint) -> Transaction { + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { previous_output: outpoint, ..Default::default() }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], } } - #[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)); + /// 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) } - #[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, - ); + /// Moves the wallet's chain tip to `height` without running the chain tip pass. + fn set_chain_tip(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 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); + /// Runs the chain tip pass at `height`, which is where recorded facts are dropped. + async fn chain_tip_changed(wallet: &Wallet, height: u32) { + set_chain_tip(wallet, height); + let event = WalletEvent::ChainTipChanged { + old_tip: block_id_at(height - 1), + new_tip: block_id_at(height), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); } - /// 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`. + /// 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 graduation_preserves_classified_figures() { + async fn startup_records_the_held_outputs_no_producer_reported() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(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 wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let open = Channel { counterparty_node_id, channel_id }; + let closed = Channel { counterparty_node_id, channel_id: ChannelId([8u8; 32]) }; + let open_funding_txid = Txid::from_byte_array([41u8; 32]); + let closed_funding_txid = Txid::from_byte_array([42u8; 32]); + let resolved_txid = Txid::from_byte_array([43u8; 32]); + + // The open channel's funding was reported when the channel was opened, as every + // channel's is on a node that recorded facts all along. + let reported = ChannelTxFacts::new(open_funding_txid).with_outputs( + &open, + Some(UserChannelId(7)), + ChannelOutputRole::Funding, + [0], + ); + wallet.record_channel_tx_facts(reported).await.unwrap(); + let reported = wallet.channel_tx_facts(&open_funding_txid).await.expect("reported"); + // The pass runs at a later tip, at which a rewrite of the report would re-date it. + set_chain_tip(&wallet, reported.recorded_at_height + 7); + + let funding = |channel: &Channel, user_channel_id, txid, vout| HeldChannelOutput { + channel: channel.clone(), + user_channel_id, + role: ChannelOutputRole::Funding, + txid, + vout, }; - let tx_type = Some(TransactionType::InteractiveFunding { channels: vec![] }); - - // 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(); + wallet.set_channel_liveness(TestLiveness::holding(TestChannelState { + held_outputs: vec![ + // The open channel, listed by the channel manager and held by its monitor. + funding(&open, Some(UserChannelId(7)), open_funding_txid, 0), + funding(&open, None, open_funding_txid, 0), + // A channel closed before this node recorded facts, which only its monitor still + // holds, and an output it resolved to this node that the sweeper tracks. + funding(&closed, None, closed_funding_txid, 1), + HeldChannelOutput { + channel: closed.clone(), + user_channel_id: None, + role: ChannelOutputRole::Spendable, + txid: resolved_txid, + vout: 2, + }, + ], + ..Default::default() + })); - // The pending entry embeds a stale snapshot: wallet-derived figures recorded before the - // classification above landed. - let mut stale = interactive_funding_details(payment_id, txid, Some(0), Some(0)); - stale.kind = PaymentKind::Onchain { txid, status: confirmed, tx_type }; - let entry = PendingPaymentDetails::new(stale, Vec::new(), Vec::new()); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + wallet.record_held_channel_outputs().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 open_facts = wallet.channel_tx_facts(&open_funding_txid).await.expect("kept"); + assert_eq!(open_facts, reported, "what was reported stays as it was, dated as it was"); + let closed_facts = + wallet.channel_tx_facts(&closed_funding_txid).await.expect("recorded at startup"); + assert_eq!(closed_facts.recorded_at_height, reported.recorded_at_height + 7); + assert_eq!( + closed_facts.outputs, + vec![ChannelOutputFact { + vout: 1, + role: ChannelOutputRole::Funding, + counterparty_node_id, + channel_id: closed.channel_id, + user_channel_id: None, + }] + ); + let resolved_facts = + wallet.channel_tx_facts(&resolved_txid).await.expect("recorded at startup"); + assert_eq!( + resolved_facts + .outputs + .iter() + .map(|output| (output.vout, output.role)) + .collect::>(), + vec![(2, ChannelOutputRole::Spendable)] + ); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Succeeded); + // Which is what lets the wallet say what the closed channel's closing transaction is. + let close = tx_spending(&wallet, OutPoint { txid: closed_funding_txid, vout: 1 }); assert_eq!( - payment.amount_msat, - Some(2_000_000), - "graduation must not roll figures back to the snapshot's" + wallet.tx_provenance(close.compute_txid(), &close).await.classify(&close), + Some(TransactionType::CooperativeClose { + counterparty_node_id, + channel_id: closed.channel_id, + }) ); - 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()); } - /// 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. + /// The channel state holds an output no producer reported, but cannot be consulted: nothing + /// is recorded, and the output is recorded by the pass once the state can be. #[tokio::test] - async fn graduation_declines_on_diverged_record() { + async fn startup_records_nothing_while_the_channel_state_is_unreachable() { 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_store.insert_or_update(recorded).await.unwrap(); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let funding_txid = Txid::from_byte_array([44u8; 32]); + let liveness = TestLiveness::unreachable_holding(TestChannelState { + held_outputs: vec![HeldChannelOutput { + channel: Channel { counterparty_node_id, channel_id }, + user_channel_id: None, + role: ChannelOutputRole::Funding, + txid: funding_txid, + vout: 0, + }], + ..Default::default() + }); + wallet.set_channel_liveness(liveness.clone()); - // ...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_held_channel_outputs().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 keys = store + .list_async( + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE, + ) + .await + .unwrap(); + assert!(keys.is_empty(), "the state was not consulted"); - 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" - ); + liveness.reach(); + wallet.record_held_channel_outputs().await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); } - /// 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). #[tokio::test] - async fn find_payment_by_txid_maps_candidate_txids() { + 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()); - 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(); + chain_tip_changed(&wallet, LONG_AFTER).await; - // 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)); + 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)); } - /// 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; + 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 txid = Txid::from_byte_array([1u8; 32]); - let conflicting_txid = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId(txid.to_byte_array()); + 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() }, + ), + ]; - // 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(); + 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)); - wallet.remove_payment(&payment_id).await.unwrap(); + chain_tip_changed(&wallet, LONG_AFTER).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); + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_some(), + "the facts are still needed: {}", + why, + ); + } + } - // 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()); + #[tokio::test] + async fn nothing_is_dropped_while_the_nodes_channels_cannot_be_consulted() { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + + // Before the node's channel state is handed over, which is how the wallet starts out. + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); + + // And once it can no longer be reached, as while the node is torn down. + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::unreachable()); + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); } - /// 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() { + async fn the_facts_of_a_channel_are_kept_until_the_age_cap() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - 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, + 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", ); - wallet.payment_store.insert_or_update(details).await.unwrap(); - wallet.remove_payment(&payment_id).await.unwrap(); - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none()); + chain_tip_changed(&wallet, CHANNEL_TX_FACTS_RETENTION_BLOCKS).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_none()); + } - // Removing an id known to neither store is also a no-op rather than an error. - wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap(); + /// A wallet that recorded a channel's funding transaction and has seen no spend of it. + async fn wallet_with_an_unspent_funding( + store: Arc, channel: &Channel, + ) -> (Arc, Txid) { + let wallet = new_test_wallet(store, false).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(); + (wallet, funding_txid) } - /// A funding-typed broadcast that doesn't touch the on-chain wallet must not be recorded. - /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding - /// path, so a splice the interactive-funding classification deliberately declined — no local - /// contribution, or none of the moved funds are the wallet's — would otherwise come back as - /// a spurious zero-amount record that nothing ever confirms. + /// A commitment transaction that pays none of the wallet's scripts never enters the wallet's + /// graph, so the funding it spent looks unspent to the wallet for good. Once the node no + /// longer holds the channel, no transaction the facts could classify is still to come. #[tokio::test] - async fn funding_broadcast_without_wallet_activity_is_not_recorded() { + async fn the_facts_of_a_funding_spent_outside_the_wallets_view_are_dropped() { 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 (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let (wallet, funding_txid) = + wallet_with_an_unspent_funding(Arc::clone(&store), &channel).await; + + // While the chain monitor holds the channel, it may yet produce a transaction to name. + let liveness = TestLiveness::holding(TestChannelState { + monitors: vec![channel_id], + ..Default::default() + }); + wallet.set_channel_liveness(liveness.clone()); + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_some(), + "the node still holds the channel", ); - let splice_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: vec![bitcoin::TxIn { - previous_output: prev_funding_outpoint, - ..Default::default() - }], - output: vec![TxOut { - value: Amount::from_sat(99_000), - script_pubkey: ScriptBuf::new(), - }], - }; - wallet.classify_funding(&splice_tx, &channels, tx_type.clone()).await.unwrap(); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); - // Control: a funding transaction the wallet participates in is still recorded. - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let funded_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - }; - wallet.classify_funding(&funded_tx, &channels, tx_type).await.unwrap(); - let payments = wallet.payment_store.list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1); - assert_eq!(payments[0].id, PaymentId(funded_tx.compute_txid().to_byte_array())); + *liveness.state.lock().unwrap() = TestChannelState::default(); + chain_tip_changed(&wallet, LONG_AFTER + 1).await; + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_none(), + "the wallet holds no spend to classify and the channel cannot produce one", + ); } - /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding - /// path: same txid, but typed as a plain funding transaction with wallet-view figures and no - /// contribution metadata. The rebroadcast must not overwrite the contribution-derived - /// figures or the interactive-funding classification — neither while the record is - /// unconfirmed nor once it confirmed under that same txid, where updates naming the - /// confirmed txid may otherwise move figures. #[tokio::test] - async fn funding_rebroadcast_keeps_interactive_funding_classification() { + async fn the_facts_of_a_funding_whose_spend_is_still_shallow_are_kept() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - // The rebroadcast passes the wallet-activity guard: a splice-in funds the new channel - // output partly from the wallet, so the wallet sees movement. - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - }; - let txid = tx.compute_txid(); - let payment_id = PaymentId(txid.to_byte_array()); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let (wallet, funding_txid) = + wallet_with_an_unspent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::holding_nothing()); + + // A spend that has yet to be buried twice over may still be reorganized out. + 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(), + "a block short of twice the reorg delay is short of it", + ); - 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(); + chain_tip_changed(&wallet, LONG_AFTER + 1).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_none()); + } - 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_funding_whose_spender_is_still_pending_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()); + + // The spend is long buried, but its payment is still pending: the facts of what it + // spends are kept for as long as the pending store refers to it, whatever its record + // already says of it. + let close_txid = { + let locked = wallet.inner.lock().unwrap(); + *locked + .tx_graph() + .outspends(OutPoint { txid: funding_txid, vout: 0 }) + .iter() + .next() + .expect("the funding is spent") + }; + let id = PaymentId([46u8; 32]); + let entry = PendingPaymentDetails::new( + funding_payment(id, close_txid, PaymentStatus::Pending), + Vec::new(), + Vec::new(), + ); + wallet.payment_stores.pending_payment_store().insert(entry).await.unwrap(); - wallet.classify_funding(&tx, &channels, tx_type.clone()).await.unwrap(); - assert_unchanged(&wallet, payment_id, false).await; + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); - // 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; + 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()); } - /// Barrier test, classification-first ordering: wallet sync's confirmation handling must - /// wait for classification's two-store write pair. Classification is parked between its - /// payment-store and pending-store writes (the torn window) and only then is the - /// confirmation of the replacement candidate dispatched; unless the sync arm holds the - /// cross-store lock from payment-id resolution onwards, it resolves the id against the - /// still-missing pending index and mints a duplicate record keyed by the event txid. #[tokio::test] - async fn funding_confirmation_waits_for_classification() { - let gated = NamespaceGatedStore::new(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); - let store: Arc = Arc::new(DynStoreWrapper(gated.clone())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; + 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(); - 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; + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); - // 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 } - }); + // 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()); + } - // 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); + #[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; - drop(gate_guard); - classification.await.unwrap().unwrap(); - sync.await.unwrap().unwrap(); + let evaluated = wallet.channel_tx_facts(&funding_txid).await.expect("recorded above"); - // 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), - } + // 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 a_payment_that_already_advanced_gets_no_pending_entry() { 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 sync confirmed the payment through `ANTI_REORG_DELAY` before a writer holding a + // `Pending` copy read earlier got to the pending store: the payment graduated, so no + // entry belongs there. wallet - .apply_update(Update { tx_update, chain: Some(chain), ..Default::default() }) + .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)); - } - - // 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 stores = wallet.payment_stores.lock().await; + wallet + .upsert_pending_payment( + &stores, + funding_payment(id, txid, PaymentStatus::Pending), + Vec::new(), + ) + .await + .unwrap(); + drop(stores); - 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); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } } diff --git a/src/wallet/payment_stores.rs b/src/wallet/payment_stores.rs new file mode 100644 index 0000000000..234e5d3c97 --- /dev/null +++ b/src/wallet/payment_stores.rs @@ -0,0 +1,172 @@ +// 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 + } + + /// 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 + } + + /// Removes the pending-store entry stored under `id`, if any. + pub(super) async fn remove_pending_payment(&self, id: &PaymentId) -> Result<(), Error> { + self.stores.pending_payment_store.remove(id).await + } + + /// Transforms the pending-store entry stored under `id` through `f` and persists the result, + /// all in one critical section of the store; see + /// [`DataStore::mutate`](crate::data_store::DataStore::mutate). + pub(super) async fn mutate_pending_payment( + &self, id: &PaymentId, f: F, + ) -> Result, Error> + where + F: FnOnce(Option<&PendingPaymentDetails>) -> Option, + { + self.stores.pending_payment_store.mutate(id, f).await + } + + /// [`Self::mutate_pending_payment`] with a transformation that awaits fallible reads; see + /// [`DataStore::mutate_async`](crate::data_store::DataStore::mutate_async). + pub(super) async fn mutate_pending_payment_async( + &self, id: &PaymentId, f: F, + ) -> Result, Error> + where + F: FnOnce(Option) -> Fut, + Fut: Future, Error>>, + { + self.stores.pending_payment_store.mutate_async(id, f).await + } +} diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs new file mode 100644 index 0000000000..5be269ce02 --- /dev/null +++ b/src/wallet/provenance.rs @@ -0,0 +1,1776 @@ +// 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, + /// An output a channel paid straight to a script of this wallet's own: the proceeds of a + /// claim, or the shutdown output of a cooperative close. + Direct, +} + +impl_writeable_tlv_based_enum!(ChannelOutputRole, + (0, Funding) => {}, + (2, Anchor) => {}, + (4, Htlc) => {}, + (6, Spendable) => {}, + (8, Direct) => {}, +); + +/// 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), +}); + +impl ChannelOutputFact { + /// Whether `other` describes the same output the same way. The local channel identifier + /// counts only where both carry one: a producer that does not know it, as the node's channel + /// state does not for a channel it no longer lists, contradicts nothing by leaving it out. + fn agrees_with(&self, other: &Self) -> bool { + self.vout == other.vout + && self.role == other.role + && self.counterparty_node_id == other.counterparty_node_id + && self.channel_id == other.channel_id + && match (self.user_channel_id, other.user_channel_id) { + (Some(recorded), Some(incoming)) => recorded == incoming, + _ => true, + } + } +} + +/// 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 a channel laid claim to: ones it controls rather than the + /// wallet, and ones it paid straight to 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() + } + + /// Facts about the outputs the node's channel state holds, as one record per transaction. + /// + /// An output listed by more than one part of that state, as an open channel's funding + /// output is by the channel manager and by its monitor, is taken from the listing that + /// knows the channel's local identifier. + pub(crate) fn of_held_outputs(mut held: Vec) -> Vec { + held.sort_by_key(|output| (output.txid, output.vout, output.user_channel_id.is_none())); + held.dedup_by_key(|output| (output.txid, output.vout)); + + let mut records: Vec = Vec::new(); + for output in held { + let index = match records.iter().position(|record| record.txid == output.txid) { + Some(index) => index, + None => { + records.push(Self::new(output.txid)); + records.len() - 1 + }, + }; + records[index].outputs.push(ChannelOutputFact { + vout: output.vout, + role: output.role, + counterparty_node_id: output.channel.counterparty_node_id, + channel_id: output.channel.channel_id, + user_channel_id: output.user_channel_id, + }); + } + records + } + + /// 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`, `local_figures` and an output's local + /// channel identifier 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_mut().find(|recorded| recorded.vout == output.vout) { + Some(recorded) if recorded.agrees_with(output) => { + if recorded.user_channel_id.is_none() && output.user_channel_id.is_some() { + recorded.user_channel_id = output.user_channel_id; + changed = true; + } + }, + 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 unclassifiable. + pub(crate) fn size_checked(self) -> Result { + let bytes = self.serialized_length(); + if bytes > CHANNEL_TX_FACTS_MAX_RECORD_BYTES { + return Err(ChannelTxFactsRejection::TooLarge { + bytes, + limit: CHANNEL_TX_FACTS_MAX_RECORD_BYTES, + }); + } + Ok(self) + } +} + +/// What became of a producer's report of what a transaction is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FactsRecordOutcome { + /// Everything reported is on record. + Recorded, + /// Part of what was reported is not on record, because recording it would have taken the + /// facts past the resources they are allowed. Transactions that would have been classified + /// from the missing part are reported without a classification instead. + Incomplete, +} + +/// An output the node's channel state holds: the funding output of a channel the channel manager +/// lists or the chain monitor watches, or an output a channel resolved to this node that the +/// sweeper has yet to spend. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct HeldChannelOutput { + /// The channel the output belongs to. + pub channel: Channel, + /// The channel's local identifier, where the state holding the output knows it: the channel + /// manager does, a monitor and the sweeper do not. + pub user_channel_id: Option, + /// What the output is for. + pub role: ChannelOutputRole, + /// The transaction the output is of. + pub txid: Txid, + /// The index of the output within that transaction. + pub vout: u32, +} + +/// The node's channel state, as the recorded facts consult it: for which channels are still +/// held, which decides what retention may drop, and for which outputs they hold, which the +/// startup pass records where no producer did. +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 outputs that state holds, or `None` when it cannot be consulted at all. An output may + /// be listed more than once, by each part of the state holding it. + fn held_outputs(&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), + )) + } + + fn held_outputs(&self) -> Option> { + let channel_manager = self.channel_manager.upgrade()?; + let chain_monitor = self.chain_monitor.upgrade()?; + let output_sweeper = self.output_sweeper.upgrade()?; + + let mut held = Vec::new(); + for channel in channel_manager.list_channels() { + // A channel still negotiating its funding holds no output yet; its producer reports + // the funding once there is one. + let Some(funding_txo) = channel.funding_txo else { continue }; + held.push(HeldChannelOutput { + channel: Channel { + counterparty_node_id: channel.counterparty.node_id, + channel_id: channel.channel_id, + }, + user_channel_id: Some(UserChannelId(channel.user_channel_id)), + role: ChannelOutputRole::Funding, + txid: funding_txo.txid, + vout: funding_txo.index as u32, + }); + } + for channel_id in chain_monitor.list_monitors() { + let Ok(monitor) = chain_monitor.get_monitor(channel_id) else { continue }; + let funding_txo = monitor.get_funding_txo(); + held.push(HeldChannelOutput { + channel: Channel { + counterparty_node_id: monitor.get_counterparty_node_id(), + channel_id, + }, + user_channel_id: None, + role: ChannelOutputRole::Funding, + txid: funding_txo.txid, + vout: funding_txo.index as u32, + }); + } + // The sweeper spends what it tracks, so each is an output a channel resolved to this + // node, whatever its descriptor. One tracked without its channel says nothing about + // which channel resolved it and is left out, as it is for retention. + for output in output_sweeper.tracked_spendable_outputs() { + let (Some(channel_id), Some(counterparty_node_id)) = + (output.channel_id, output.counterparty_node_id) + else { + continue; + }; + let outpoint = output.descriptor.spendable_outpoint(); + held.push(HeldChannelOutput { + channel: Channel { counterparty_node_id, channel_id }, + user_channel_id: None, + role: ChannelOutputRole::Spendable, + txid: outpoint.txid, + vout: outpoint.index as u32, + }); + } + Some(held) + } +} + +/// 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 spend the wallet holds of a funding output the facts record is confirmed at + /// least `2 * ANTI_REORG_DELAY` deep with its own payment settled. `true` for facts recording + /// no funding output, and for a funding output the wallet holds no spend of. + 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) }); + } + + // A channel that paid its funds straight to this wallet without spending its funding output + // did so through a claim its monitor made: an HTLC resolved on the counterparty's commitment, + // or a revoked commitment punished. A cooperative close pays its shutdown output the same + // way; with its funding on record it was named above, and without it is left alone rather + // than called a claim. + let mut direct = created.iter().filter(|output| output.role == ChannelOutputRole::Direct); + if let Some(paid) = direct.next() { + if !is_cooperative_close(tx) { + return Some(TransactionType::Claim { + counterparty_node_id: paid.counterparty_node_id, + channel_id: paid.channel_id, + }); + } + } + + 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 held_outputs_become_one_record_per_transaction() { + let channel = test_channel(1); + let other = test_channel(2); + let funding = |channel: &Channel, user_channel_id, txid, vout| HeldChannelOutput { + channel: channel.clone(), + user_channel_id, + role: ChannelOutputRole::Funding, + txid, + vout, + }; + let records = ChannelTxFacts::of_held_outputs(vec![ + // An open channel's funding output, held by its monitor and listed by the channel + // manager, which alone knows the channel's local identifier. + funding(&channel, None, test_txid(1), 0), + funding(&channel, Some(UserChannelId(7)), test_txid(1), 0), + // A closed channel's funding output, held by its monitor alone. + funding(&other, None, test_txid(2), 1), + // An output the closed channel resolved to this node, tracked by the sweeper. + HeldChannelOutput { + channel: other.clone(), + user_channel_id: None, + role: ChannelOutputRole::Spendable, + txid: test_txid(2), + vout: 2, + }, + ]); + + assert_eq!(records.len(), 2); + let open = records.iter().find(|facts| facts.txid == test_txid(1)).expect("recorded"); + assert_eq!( + open.outputs, + vec![ChannelOutputFact { + vout: 0, + role: ChannelOutputRole::Funding, + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + user_channel_id: Some(UserChannelId(7)), + }] + ); + let closed = records.iter().find(|facts| facts.txid == test_txid(2)).expect("recorded"); + assert_eq!( + closed.outputs.iter().map(|output| (output.vout, output.role)).collect::>(), + vec![(1, ChannelOutputRole::Funding), (2, ChannelOutputRole::Spendable)] + ); + assert!(closed.outputs.iter().all(|output| { + output.channel_id == other.channel_id && output.user_channel_id.is_none() + })); + } + + #[test] + fn facts_key_round_trips_through_its_hex_encoding() { + let txid = test_txid(3); + let encoded = txid.encode_to_hex_str(); + assert_eq!(encoded.len(), 64); + assert_eq!(Txid::decode_from_hex_str(&encoded), Some(txid)); + assert_eq!(Txid::decode_from_hex_str("not hex"), None); + assert_eq!(Txid::decode_from_hex_str("00"), None); + } + + #[test] + fn replaying_a_fact_changes_nothing() { + let facts = full_facts(); + assert_eq!(facts.clone().merged_with(&facts), Ok(None)); + + // A producer that reports only part of what is already recorded is likewise a no-op, which + // is what a replay of an earlier event looks like once a later one has filled the record + // in. + let channel = test_channel(1); + let partial = ChannelTxFacts::new(test_txid(7)).with_outputs( + &channel, + Some(UserChannelId(42)), + ChannelOutputRole::Funding, + [0], + ); + assert_eq!(facts.clone().merged_with(&partial), Ok(None)); + } + + #[test] + fn outputs_of_two_producers_merge_into_one_record() { + let channel = test_channel(1); + let other = test_channel(2); + let txid = test_txid(7); + + // A batched sweep resolves outputs of two different channels; each producer reports only + // its own. + let first = ChannelTxFacts::new(txid).with_outputs( + &channel, + None, + ChannelOutputRole::Spendable, + [0, 2], + ); + let second = + ChannelTxFacts::new(txid).with_outputs(&other, None, ChannelOutputRole::Spendable, [1]); + + let merged = first.merged_with(&second).expect("disjoint outputs merge").expect("changed"); + assert_eq!(merged.outputs.len(), 3); + let mut vouts: Vec = merged.outputs.iter().map(|output| output.vout).collect(); + vouts.sort_unstable(); + assert_eq!(vouts, vec![0, 1, 2]); + assert_eq!( + merged.outputs.iter().find(|output| output.vout == 1).map(|output| output.channel_id), + Some(other.channel_id) + ); + } + + #[test] + fn a_second_role_for_one_output_is_rejected() { + let channel = test_channel(1); + let txid = test_txid(7); + let recorded = + ChannelTxFacts::new(txid).with_outputs(&channel, None, ChannelOutputRole::Funding, [0]); + let conflicting = + ChannelTxFacts::new(txid).with_outputs(&channel, None, ChannelOutputRole::Anchor, [0]); + + match recorded.clone().merged_with(&conflicting) { + Err(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 an_output_reported_without_its_user_channel_id_agrees_with_one_reported_with_it() { + let channel = test_channel(1); + let txid = test_txid(7); + let known = ChannelTxFacts::new(txid).with_outputs( + &channel, + Some(UserChannelId(42)), + ChannelOutputRole::Funding, + [0], + ); + let unknown = + ChannelTxFacts::new(txid).with_outputs(&channel, None, ChannelOutputRole::Funding, [0]); + + // The node's channel state reports a closed channel's funding output without the + // identifier the event that first recorded it carried: that adds nothing and contradicts + // nothing. + assert_eq!(known.clone().merged_with(&unknown), Ok(None)); + + // Reported the other way round, the identifier fills in. + let merged = unknown.merged_with(&known).expect("the same output").expect("filled in"); + assert_eq!(merged.outputs[0].user_channel_id, Some(UserChannelId(42))); + + // Two identifiers for one output are still a contradiction. + let other = ChannelTxFacts::new(txid).with_outputs( + &channel, + Some(UserChannelId(43)), + ChannelOutputRole::Funding, + [0], + ); + assert!(matches!(known.merged_with(&other), 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 spending_a_resolved_output_into_a_funding_output_is_a_sweep() { + let closed = test_channel(1); + let opened = test_channel(2); + let tx = unrecognised_shaped(); + let self_facts = funds(&tx, &opened, 0); + let recorded = parents([parent_outputs(&closed, ChannelOutputRole::Spendable, [0])]); + + // What a transaction spends settles its type before what it creates: moving a channel's + // resolved output is that channel's sweep, whatever the output it lands in. + assert_eq!( + classify(&tx, Some(&self_facts), &recorded), + Some(TransactionType::Sweep { channels: vec![closed] }) + ); + } + + #[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 paying_a_channels_funds_straight_to_the_wallet_is_a_claim() { + let channel = test_channel(1); + // A claim the channel monitor made: replaceable, spending outputs of a commitment this + // node holds no facts about, and paying this wallet's destination script. + let tx = spending_tx( + &[(test_txid(PARENT + 30), 0), (test_txid(PARENT + 30), 1)], + Sequence(0xff_ff_ff_fd), + 0, + ); + let self_facts = ChannelTxFacts::new(tx.compute_txid()).with_outputs( + &channel, + None, + ChannelOutputRole::Direct, + [0], + ); + + assert_eq!( + classify(&tx, Some(&self_facts), &HashMap::new()), + Some(TransactionType::Claim { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn a_cooperative_close_paying_the_wallet_directly_is_not_a_claim() { + let channel = test_channel(1); + let tx = cooperative_close_shaped(); + // LDK reports the shutdown output of a cooperative close the way it reports the + // proceeds of a claim: as paid straight to the wallet. + let self_facts = ChannelTxFacts::new(tx.compute_txid()).with_outputs( + &channel, + None, + ChannelOutputRole::Direct, + [0], + ); + + // With the funding it spends on record, the transaction is the close it is. + let recorded = parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]); + assert_eq!( + classify(&tx, Some(&self_facts), &recorded), + Some(TransactionType::CooperativeClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + + // Without it, the close is left unnamed rather than called a claim. + assert_eq!(classify(&tx, Some(&self_facts), &HashMap::new()), 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..5e2f2e5dcf 100644 --- a/tests/common/logging.rs +++ b/tests/common/logging.rs @@ -192,6 +192,11 @@ impl CollectingLogWriter { self.logs.lock().unwrap().iter().filter(|message| message.contains(text)).count() } + /// Every message logged so far, in order. + pub(crate) fn lines(&self) -> Vec { + self.logs.lock().unwrap().clone() + } + /// Waits up to ten seconds for a logged message containing `text`, returning whether one /// arrived. Polling beats a fixed sleep: it returns as soon as the line lands and only pays /// the full timeout when the line never comes. diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8814980711..36aae7bc4c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -836,8 +836,37 @@ pub(crate) fn setup_two_nodes_with_store( } pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> TestNode { + let builder = configured_builder(chain_source, &config); + + let node = match config.store_type { + TestStoreType::TestSyncStore => { + let kv_store = TestSyncStore::new(config.node_config.storage_dir_path.into()); + builder.build_with_store(config.node_entropy.into(), kv_store).unwrap() + }, + #[cfg(feature = "storage-sqlite")] + TestStoreType::Sqlite => builder.build(config.node_entropy.into()).unwrap(), + #[cfg(feature = "storage-filesystem")] + TestStoreType::FilesystemStore => { + builder.build_with_fs_store(config.node_entropy.into()).unwrap() + }, + }; + + start_node(node) +} + +/// Like [`setup_node`], but around `kv_store`, which the test keeps hold of: to read or change +/// what the node persisted, or to build the node again around it. +pub(crate) fn setup_node_with_store( + chain_source: &TestChainSource, config: TestConfig, kv_store: S, +) -> TestNode { + let builder = configured_builder(chain_source, &config); + let node = builder.build_with_store(config.node_entropy.into(), kv_store).unwrap(); + start_node(node) +} + +fn configured_builder(chain_source: &TestChainSource, config: &TestConfig) -> Builder { setup_builder!(builder, config.node_config); - configure_chain_source(chain_source, &mut builder, &config); + configure_chain_source(chain_source, &mut builder, config); match &config.log_writer { TestLogWriter::FileWriter => { @@ -851,25 +880,16 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> }, } - builder.set_async_payments_role(config.async_payments_role).unwrap(); + builder.set_async_payments_role(config.async_payments_role.clone()).unwrap(); - if let Some(probing) = config.probing { + if let Some(probing) = config.probing.clone() { builder.set_probing_config(probing.into()); } - let node = match config.store_type { - TestStoreType::TestSyncStore => { - let kv_store = TestSyncStore::new(config.node_config.storage_dir_path.into()); - builder.build_with_store(config.node_entropy.into(), kv_store).unwrap() - }, - #[cfg(feature = "storage-sqlite")] - TestStoreType::Sqlite => builder.build(config.node_entropy.into()).unwrap(), - #[cfg(feature = "storage-filesystem")] - TestStoreType::FilesystemStore => { - builder.build_with_fs_store(config.node_entropy.into()).unwrap() - }, - }; + builder +} +fn start_node(node: TestNode) -> TestNode { node.start().unwrap(); assert!(node.status().is_running); assert!(node.status().latest_fee_rate_cache_update_timestamp.is_some()); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index f58cad6904..726b7bd38e 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -15,9 +15,10 @@ use std::sync::{mpsc, Arc}; use std::time::Duration; use bitcoin::address::NetworkUnchecked; +use bitcoin::hashes::hex::FromHex; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; -use bitcoin::{Address, Amount, ScriptBuf, Txid}; +use bitcoin::{Address, Amount, ScriptBuf, Transaction, Txid}; use common::logging::{ init_log_logger, validate_log_entry, CollectingLogWriter, MultiNodeLogger, TestLogWriter, }; @@ -29,9 +30,9 @@ use common::{ generate_blocks_and_wait, generate_listening_addresses, invalidate_blocks, open_channel, open_channel_no_wait, open_channel_push_amt, open_channel_with_all, premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config, - setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, splice_in_with_all, - wait_for_block, wait_for_tx, InMemoryStore, NodePaymentExt, TestChainSource, TestConfig, - TestNode, TestStoreType, TestSyncStore, + setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_node_with_store, setup_two_nodes, + splice_in_with_all, wait_for_block, wait_for_outpoint_spend, wait_for_tx, InMemoryStore, + NodePaymentExt, TestChainSource, TestConfig, TestNode, TestStoreType, TestSyncStore, }; use electrsd::corepc_node::{self, Node as BitcoinD}; use electrsd::ElectrsD; @@ -45,8 +46,9 @@ 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, 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 +57,16 @@ use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; use serde_json::json; -/// Waits until `node` has classified the funding broadcast `funding_txid` (a channel open or splice -/// candidate) into a payment record carrying a `tx_type`. Classification runs off the broadcaster's -/// queue, which can lag a `sync_wallets` call under load — and for a splice the counterparty also -/// broadcasts the same tx, so a racing sync can see it before this node classifies. Waiting here -/// keeps the next sync on the funding short-circuit instead of recording a generic on-chain payment -/// that clobbers the classification. +/// Waits until `node` has recorded the funding broadcast `funding_txid` (a channel open or splice +/// candidate) as a payment carrying a `tx_type`, syncing its wallet until it has. Wallet sync +/// records the payment when it first observes the transaction, so the sync is what settles this, +/// and a chain source can lag the broadcast, so one `sync_wallets` call may not observe it yet. 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 +92,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 +128,10 @@ impl KVStore for ContendedStore { let serializer = Arc::clone(&self.serializer); let block_writes = Arc::clone(&self.block_writes); let wallet_write_started = Arc::clone(&self.wallet_write_started); + let serialized_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 +139,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 +199,8 @@ fn wallet_store_contention_does_not_stall_runtime() { serializer: Arc::new(tokio::sync::RwLock::new(())), block_writes: Arc::new(AtomicBool::new(false)), wallet_write_started: Arc::new(tokio::sync::Notify::new()), + serialized: None, + serialized_in_flight: Arc::new(AtomicUsize::new(0)), }; let node = builder .build_with_store(test_config.node_entropy.into(), store.clone()) @@ -299,6 +338,85 @@ impl PaginatedKVStore for WalletPersistGatedStore { } } +/// A store whose writes to one primary namespace fail while the test says so, for exercising how +/// the node answers a failed write of one kind of record. +#[derive(Clone)] +struct NamespaceWriteFailingStore { + inner: Arc, + primary_namespace: &'static str, + failing: Arc, +} + +impl NamespaceWriteFailingStore { + fn new(primary_namespace: &'static str) -> Self { + Self { + inner: Arc::new(InMemoryStore::new()), + primary_namespace, + failing: Arc::new(AtomicBool::new(false)), + } + } + + /// Makes every write to the namespace fail from now on, or lets them through again. + fn fail_writes(&self, fail: bool) { + self.failing.store(fail, Ordering::Release); + } +} + +impl KVStore for NamespaceWriteFailingStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, lightning::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 { + // The inner store writes as soon as it is asked, not when its future is polled, so a + // failing write is never asked for. + let fail = + primary_namespace == self.primary_namespace && self.failing.load(Ordering::Acquire); + let write = (!fail).then(|| { + KVStore::write(&*self.inner, primary_namespace, secondary_namespace, key, buf) + }); + async move { + match write { + Some(write) => write.await, + None => Err(lightning::io::Error::new( + lightning::io::ErrorKind::Other, + "write failed at the test's request", + )), + } + } + } + + 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, lightning::io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } +} + +impl PaginatedKVStore for NamespaceWriteFailingStore { + 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, + ) + } +} + // LDK invokes the sync `SignerProvider::get_shutdown_scriptpubkey` callback on a runtime worker // thread while holding channel locks when a node accepts (or opens) a channel. If deriving the // shutdown script waits on wallet persistence, a contended wallet store wedges the event handler @@ -546,6 +664,73 @@ async fn peer_removed_when_counterparty_force_closes_last_channel() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn a_close_is_classified_for_a_channel_whose_facts_were_never_recorded() { + // A node upgraded from a version that recorded no channel facts, or one whose report of a + // funding failed, holds a channel it has no facts for. Starting records what its channel + // state holds, so the close of that channel is classified like any other. + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let config_a = random_config(); + let store_a = TestSyncStore::new(config_a.node_config.storage_dir_path.clone().into()); + let node_a = setup_node_with_store(&chain_source, config_a.clone(), store_a.clone()); + 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(); + + let funding_txo = 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(); + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Take node A back to the state of a node that never recorded the channel's facts. + node_a.stop().unwrap(); + drop(node_a); + let facts_keys = KVStore::list(&store_a, "channel_tx_facts", "").await.unwrap(); + assert!(!facts_keys.is_empty(), "opening the channel recorded its funding"); + for key in facts_keys { + KVStore::remove(&store_a, "channel_tx_facts", "", &key, false).await.unwrap(); + } + + let node_a = setup_node_with_store(&chain_source, config_a, store_a.clone()); + assert!( + !KVStore::list(&store_a, "channel_tx_facts", "").await.unwrap().is_empty(), + "starting recorded the channel's funding from the node's channel state" + ); + + let node_addr_b = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.connect(node_b.node_id(), node_addr_b, false).unwrap(); + let user_channel_id_a = node_a.list_channels().first().unwrap().user_channel_id; + node_a.close_channel(&user_channel_id_a, node_b.node_id()).unwrap(); + expect_event!(node_a, ChannelClosed); + expect_event!(node_b, ChannelClosed); + wait_for_outpoint_spend(&electrsd.client, funding_txo).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let closes = node_a.list_payments_matching(|payment| { + matches!( + payment.kind, + PaymentKind::Onchain { tx_type: Some(TransactionType::CooperativeClose { .. }), .. } + ) + }); + assert_eq!(closes.len(), 1, "node_a classified the close of a channel it had no facts for"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle_0conf() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -600,6 +785,190 @@ async fn channel_full_cycle_0conf_0reserve() { .await; } +// The handler answers a failed write of a channel's facts two ways. The funding this node +// generates is withheld from LDK until its facts are on record, since nothing may broadcast a +// transaction this node could not classify: the event is replayed. Every other report accompanies +// a transaction already released, so its failure is logged and the event proceeds. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn a_funding_is_withheld_until_its_facts_are_recorded() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let store_a = NamespaceWriteFailingStore::new("channel_tx_facts"); + let node_a = setup_node_with_store(&chain_source, random_config(), store_a.clone()); + let store_b = NamespaceWriteFailingStore::new("channel_tx_facts"); + let node_b = setup_node_with_store(&chain_source, random_config(), store_b.clone()); + + 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(); + + store_a.fail_writes(true); + store_b.fail_writes(true); + let address_b = node_b.listening_addresses().unwrap().first().unwrap().clone(); + node_a.open_channel(node_b.node_id(), address_b, 1_000_000, None, None).unwrap(); + + // While node A cannot record what the funding transaction is, the transaction stays with + // node A: the channel becomes pending for neither node. + let withheld = tokio::time::timeout(Duration::from_secs(3), node_a.next_event_async()).await; + assert!( + withheld.is_err(), + "node_a released a funding transaction it holds no facts for: {:?}", + withheld + ); + assert!(KVStore::list(&store_a, "channel_tx_facts", "").await.unwrap().is_empty()); + + // Once the facts can be recorded, the replayed event records them and hands the funding over. + store_a.fail_writes(false); + let funding_txo = expect_channel_pending_event!(node_a, node_b.node_id()); + assert_eq!(KVStore::list(&store_a, "channel_tx_facts", "").await.unwrap().len(), 1); + + // Node B's reports of the funding output fail throughout and are only logged: the channel + // becomes pending and ready for it all the same. + expect_channel_pending_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, funding_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!(KVStore::list(&store_b, "channel_tx_facts", "").await.unwrap().is_empty()); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +// LDK reports the output a claim paid this wallet at the very depth the claim's payment record +// graduates at. Polling Bitcoin Core connects each block to the wallet before the channel monitor, +// so the record graduates unnamed, and only the naming the event handler runs after recording the +// report gives the claim its type. +#[cfg(feature = "chain-bitcoind")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn a_claim_is_named_after_its_record_graduated() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::BitcoindRpcSync(&bitcoind); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b], + Amount::from_sat(2_125_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let funding_txo = 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()); + let channel_id = node_b.list_channels()[0].channel_id; + + // Node B holds the payment node A makes it, so the HTLC is still outstanding when node A + // force-closes. Supplying the preimage then has node B's monitor claim the HTLC output of + // node A's commitment transaction straight to node B's wallet. + let preimage = PaymentPreimage([7u8; 32]); + let payment_hash = PaymentHash(Sha256Hash::hash(&preimage.0).to_byte_array()); + let amount_msat = 50_000_000; + let description = + Bolt11InvoiceDescription::Direct(Description::new("held".to_string()).unwrap()); + let invoice = node_b + .bolt11_payment() + .receive_for_hash(amount_msat, &description, 9217, payment_hash) + .unwrap(); + node_a.bolt11_payment().send(&invoice, None).unwrap(); + let (payment_id, claimable_amount_msat) = + expect_payment_claimable_event!(node_b, payment_hash, amount_msat); + + node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + expect_event!(node_a, ChannelClosed); + expect_event!(node_b, ChannelClosed); + + node_b.bolt11_payment().claim_for_id(payment_id, claimable_amount_msat, preimage).unwrap(); + expect_payment_received_event!(node_b, claimable_amount_msat); + + let onchain_payments = |node: &TestNode| -> Vec { + node.list_all_payments() + .into_iter() + .filter(|payment| matches!(payment.kind, PaymentKind::Onchain { .. })) + .collect() + }; + let payment_of = |node: &TestNode, txid: Txid| -> PaymentDetails { + onchain_payments(node) + .into_iter() + .find( + |payment| matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid), + ) + .unwrap() + }; + + // The claim is node B's first channel transaction on record: nothing of node A's commitment + // transaction pays node B's wallet. It is recorded once confirmed, unnamed. + let before: Vec = onchain_payments(&node_b).iter().map(|p| p.id).collect(); + wait_for_outpoint_spend(&electrsd.client, funding_txo).await; + let mut claim_txid = None; + for _ in 0..5 { + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + let new: Vec = + onchain_payments(&node_b).into_iter().filter(|p| !before.contains(&p.id)).collect(); + if let Some(claim) = new.first() { + assert_eq!(new.len(), 1, "node_b recorded more than its claim: {:?}", new); + assert_eq!(claim.direction, PaymentDirection::Inbound); + match claim.kind { + PaymentKind::Onchain { txid, tx_type: None, .. } => claim_txid = Some(txid), + ref kind => panic!("node_b's claim was named before it was reported: {:?}", kind), + } + break; + } + } + let claim_txid = claim_txid.expect("node_b never recorded its claim"); + + // The block that graduates the record reaches the wallet first, so the record graduates + // before the report of what the claim paid is recorded and named from. + for _ in 0..6 { + if payment_of(&node_b, claim_txid).status == PaymentStatus::Succeeded { + break; + } + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + } + assert_eq!(payment_of(&node_b, claim_txid).status, PaymentStatus::Succeeded); + + let named = async { + loop { + if let PaymentKind::Onchain { tx_type: Some(tx_type), .. } = + payment_of(&node_b, claim_txid).kind + { + break tx_type; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }; + let tx_type = tokio::time::timeout(Duration::from_secs(10), named) + .await + .expect("node_b never named its claim from the report of what it paid"); + assert_eq!( + tx_type, + TransactionType::Claim { counterparty_node_id: node_a.node_id(), channel_id } + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_open_fails_when_funds_insufficient() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -2327,8 +2696,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; @@ -2349,9 +2716,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!( @@ -2387,8 +2752,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; @@ -2402,9 +2765,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 @@ -2429,256 +2790,66 @@ async fn splice_channel() { ); } -/// Canary for the upstream behavior the zero-activity skip in `classify_funding` works around: -/// after a 0conf splice is promoted, LDK re-broadcasts the still-unconfirmed funding transaction -/// through its generic funding path — re-typed as a plain funding transaction without its -/// contribution metadata — on every monitor-update completion until it confirms. A splice-out -/// paying an external address moves no wallet funds, so the interactive-funding classification -/// declines to record it and each re-offer then arrives with nothing to record. The re-typing is -/// tracked upstream at . -/// -/// If this test fails, upstream likely stopped re-offering the transaction that way (or now -/// preserves its interactive-funding classification): re-evaluate whether the skip still sees -/// traffic. #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn zero_conf_splice_out_funding_rebroadcast_canary() { - let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = random_chain_source(&bitcoind, &electrsd); +async fn rbf_splice_channel() { + run_rbf_splice_channel_test(false).await; +} - // 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); +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn rbf_splice_channel_original_candidate_confirms() { + run_rbf_splice_channel_test(true).await; +} - 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); +async fn run_rbf_splice_channel_test(confirm_original: bool) { + // 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); + + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); 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], + vec![address_a, address_b], Amount::from_sat(premine_amount_sat), ) .await; - node_a.sync_wallets().unwrap(); - open_channel(&node_a, &node_b, 2_000_000, false, &electrsd).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); - // 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()); + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; - // Confirm the original funding so the splice below is the only unconfirmed funding. generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); - // Splice out to a third-party address: channel funds leave without touching Node A's - // on-chain wallet, so no classification path records the transaction. - let external_address = bitcoind.client.new_address().unwrap(); - node_a.splice_out(&user_channel_id_a, node_b.node_id(), &external_address, 500_000).unwrap(); - let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); - - // The 0conf splice locks without confirmations, re-signaled as `ChannelReady`. - expect_channel_ready_event!(node_a, node_b.node_id()); - expect_channel_ready_event!(node_b, node_a.node_id()); - - // Locking the splice completed monitor updates that re-offered the unconfirmed funding - // transaction; a payment drives further monitor updates and thus further re-broadcasts. - let amount_msat = 1_000_000; - let payment_id = - node_a.spontaneous_payment().send(amount_msat, node_b.node_id(), None).unwrap(); - expect_payment_successful_event!(node_a, payment_id, None); - expect_payment_received_event!(node_b, amount_msat); - - // Canary: the skip saw a re-offer. When this stops firing, LDK no longer re-offers the - // promoted-but-unconfirmed splice through the generic funding path. The line is also the - // synchronization point: it is the terminal action of classifying a re-offer, so once it - // appears the classification pipeline has demonstrably processed one. - let skipped = format!("Not recording channel-funding broadcast {}", txo.txid); - assert!( - logger_a.wait_for(&skipped).await, - "Node A never skipped a generic-funding re-broadcast of the promoted 0conf splice-out; if \ - upstream stopped re-offering it, re-evaluate the zero-activity skip in classify_funding" - ); - - // The re-offers must not have minted a record for a transaction the wallet has no stake in. - let splice_records = node_a.list_payments_matching( - |p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == txo.txid), - ); - assert!( - splice_records.is_empty(), - "a zero-activity funding re-broadcast minted a record: {:?}", - splice_records - ); -} - -/// Canary for the upstream behavior the funding-over-interactive-funding 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`. - 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" - ); - - // 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 { .. }), .. } - )); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn rbf_splice_channel() { - run_rbf_splice_channel_test(false).await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn rbf_splice_channel_original_candidate_confirms() { - run_rbf_splice_channel_test(true).await; -} - -async fn run_rbf_splice_channel_test(confirm_original: bool) { - // 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); - - let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); - - 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()); - let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + 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()); // bump_channel_funding_fee should fail when there's no pending splice assert_eq!( @@ -2696,18 +2867,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())]) @@ -2733,19 +2907,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, @@ -2819,8 +2990,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 { .. }, .. } => { @@ -2880,8 +3051,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 { @@ -2934,8 +3104,9 @@ 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 what the splice's transaction is when signing it, so the sync below files it + // as a funding payment and 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 @@ -2943,8 +3114,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, @@ -3026,6 +3197,933 @@ 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 +/// passes on. +#[cfg(feature = "chain-esplora")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, _store_a, logs_a) = setup_contended_node(&chain_source, random_config(), None); + let (node_b, store_b, logs_b) = setup_contended_node(&chain_source, random_config(), None); + let (user_channel_id_a, _) = + open_and_splice_from_counterparty(&bitcoind, &electrsd, &node_a, &node_b).await; + + let signed_a = logs_a.count(SIGNED_FUNDING); + let signed_b = logs_b.count(SIGNED_FUNDING); + let committed_a = logs_a.count(RECEIVED_COMMITMENT_SIGNED); + let reclaimed_a = logs_a.count(RECLAIMED_ADDRESSES); + + let hold_b = Arc::clone(&store_b.serializer).write_owned().await; + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000).unwrap(); + assert!(logs_a.wait_for_count(SIGNED_FUNDING, signed_a + 1).await, "node A never signed"); + let rbf_txid = only_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); + expect_event!(node_a, SpliceNegotiationFailed); + assert!( + logs_a.wait_for_count(RECLAIMED_ADDRESSES, reclaimed_a + 1).await, + "node A's contribution to the discarded round was not reclaimed" + ); + + // 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(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();