From 3574dead1bd8aa33844adc78be63ead7050e9594 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 18 Aug 2026 14:27:39 -0500 Subject: [PATCH 01/23] Only adopt a funding payment's own transactions from wallet sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallet sync resolves a funding payment's id for any transaction linked to the record through its conflicting txids, and then adopted that transaction's txid and confirmation outright. A cooperative close conflicts with a pending splice in exactly that way: the splice record would report the close's txid and confirmation under its InteractiveFunding type and contribution figures and graduate as if the splice had confirmed, while the close's own record never received its confirmation. Adopt a transaction only when it is part of the payment's funding history — the record's current txid or a classified candidate. Anything else is recorded under its own txid-keyed id, which also delivers the close's confirmation to the close's own record. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 --- src/wallet/mod.rs | 189 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 164 insertions(+), 25 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 13a8ef4e00..12b9374b9d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -346,12 +346,12 @@ impl Wallet { // duplicating) the record classification just wrote. let guard = self.funding_payment_update_lock.lock().await; - let payment_id = self + let mut payment_id = self .find_payment_by_txid(txid) .await? .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self + match self .apply_funding_status_update_locked( &guard, payment_id, @@ -360,7 +360,13 @@ impl Wallet { ) .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. + FundingStatusUpdate::Foreign => { + payment_id = PaymentId(txid.to_byte_array()); + }, } let payment = { @@ -487,12 +493,12 @@ impl Wallet { // with classification. let guard = self.funding_payment_update_lock.lock().await; - let payment_id = self + let mut payment_id = self .find_payment_by_txid(txid) .await? .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self + match self .apply_funding_status_update_locked( &guard, payment_id, @@ -501,7 +507,13 @@ impl Wallet { ) .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. + FundingStatusUpdate::Foreign => { + payment_id = PaymentId(txid.to_byte_array()); + }, } let payment = { @@ -563,12 +575,12 @@ impl Wallet { // with classification. let guard = self.funding_payment_update_lock.lock().await; - let payment_id = self + let mut payment_id = self .find_payment_by_txid(txid) .await? .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self + match self .apply_funding_status_update_locked( &guard, payment_id, @@ -577,7 +589,13 @@ impl Wallet { ) .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. + FundingStatusUpdate::Foreign => { + payment_id = PaymentId(txid.to_byte_array()); + }, } let payment = { @@ -1949,9 +1967,11 @@ impl Wallet { /// If `payment_id` refers to a classified funding payment, refreshes its confirmation status /// and the candidate txid the event refers to, while preserving the contribution-derived /// amount/fee and `tx_type` that wallet sync must not recompute from its own view: the wallet's - /// `sent`/`received` don't capture our contribution to a shared funding output. Returns `true` - /// when it handled the payment, so the caller skips the default on-chain path. Graduation to - /// `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. + /// `sent`/`received` don't capture our contribution to a shared funding output. Returns + /// [`FundingStatusUpdate::Applied`] when it handled the payment, so the caller skips the + /// default on-chain path — or [`FundingStatusUpdate::Foreign`] when the transaction is not + /// part of the payment's funding history, so the caller records it under its own id. + /// Graduation to `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. /// /// The caller must hold [`Self::funding_payment_update_lock`] — from resolving `payment_id` /// through its own last write, not just across this call — so that classification's two-store @@ -1960,38 +1980,51 @@ impl Wallet { async fn apply_funding_status_update_locked( &self, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, - ) -> Result { + ) -> Result { // The caller's wallet-level lock keeps the candidate history stable while we await its - // read. The funding-type gate and write then share the payment store's mutation lock: - // against a separate payment `get`, a classification merging in between would have its - // `tx_type` and contribution figures clobbered by this stale snapshot. + // read. The funding-type gate, the candidate lookup, and the write then share the payment + // store's mutation lock: against a separate payment `get`, a classification merging in + // between would have its `tx_type` and contribution figures clobbered by this stale + // snapshot. let pending_payment = self.pending_payment_store.get(&payment_id).await?; + let mut outcome = FundingStatusUpdate::NotFunding; let mut handled = None; self.payment_store .mutate(&payment_id, |existing| { let payment = existing?; - let tx_type = match &payment.kind { + let (current_txid, tx_type) = match &payment.kind { PaymentKind::Onchain { + txid, tx_type: tx_type @ Some( TransactionType::Funding { .. } | TransactionType::InteractiveFunding { .. }, ), .. - } => tx_type.clone(), + } => (*txid, tx_type.clone()), _ => return None, }; + // Adopt the event's txid only when the transaction is part of this payment's + // funding history: its current txid or a classified candidate. A conflicting + // transaction that is neither — a close also spends the funding outpoint — must + // not overwrite the record. + let owns_event_tx = event_txid == current_txid + || pending_payment.as_ref().is_some_and(|p| p.candidate(event_txid).is_some()); + if !owns_event_tx { + outcome = FundingStatusUpdate::Foreign; + return None; + } // Report the figures of the candidate that actually confirmed, which need not be // the last one broadcast (an earlier, lower-fee candidate may win) and may carry // no figures at all (`None`) for a round we didn't contribute to. (`direction` is // invariant across a splice's candidates and cannot be changed through the store // anyway.) let mut target = payment.clone(); - if let Some(pending) = pending_payment.as_ref() { - if let Some(candidate) = pending.candidate(event_txid) { - target.amount_msat = candidate.amount_msat; - target.fee_paid_msat = candidate.fee_paid_msat; - } + if let Some(candidate) = + pending_payment.as_ref().and_then(|p| p.candidate(event_txid)) + { + target.amount_msat = candidate.amount_msat; + target.fee_paid_msat = candidate.fee_paid_msat; } target.kind = PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; @@ -2009,7 +2042,7 @@ 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 @@ -2019,7 +2052,7 @@ impl Wallet { let pending = self.create_pending_payment_from_tx(payment, Vec::new()); self.pending_payment_store.insert_or_update(pending).await?; } - Ok(true) + Ok(FundingStatusUpdate::Applied) } #[allow(deprecated)] @@ -2322,6 +2355,20 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { } } +/// 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, @@ -4048,6 +4095,98 @@ mod tests { wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap(); } + /// 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), + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + + // Sync saw the close double-spend the splice's funding transaction. + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + }) + .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_store.get(&payment_id).await.unwrap().unwrap(); + match &funding.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid, "the record must not adopt the close's txid"); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(funding.amount_msat, Some(1_000_000)); + assert_eq!(funding.fee_paid_msat, Some(500)); + + let close = wallet + .payment_store + .get(&PaymentId(close_txid.to_byte_array())) + .await + .unwrap() + .unwrap(); + match &close.kind { + PaymentKind::Onchain { txid, status, .. } => { + assert_eq!(*txid, close_txid); + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + kind => panic!("unexpected kind {:?}", kind), + } + } + /// 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 From fd354144fbf7780c45b2a43b80bfc0c50ad0f4ec Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 2 Sep 2026 13:53:47 -0500 Subject: [PATCH 02/23] Fail funding payments lost to a confirmed conflict Since declining to adopt a conflicting close's confirmation, a funding payment whose transaction was double-spent stayed Pending forever -- nothing wrote a terminal status for an on-chain record -- and the sync loop kept re-queueing the dead transaction for rebroadcast on every tip change. Mark such a record Failed once a conflict from outside its candidate history has confirmed through ANTI_REORG_DELAY while neither its own transaction nor any RBF candidate can still confirm, mirroring the anti-reorg finality the Succeeded transition already assumes. Removing the payment's pending entry then stops the re-queueing. Settling also removes the entry that maps candidate txids to the record, so a later wallet event for a dead candidate falls back to keying by that candidate's txid -- which, for the first candidate, is the record's own id. Skip such events rather than let the generic handling resurrect the settled record, and let a replayed replacement event finish an entry removal a crash interrupted instead of stamping the terminal status into the leftover entry. Implemented with Claude Code. Co-Authored-By: Claude Fable 5 --- src/wallet/mod.rs | 934 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 926 insertions(+), 8 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 12b9374b9d..d4d77fa9b9 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -12,6 +12,7 @@ use std::str::FromStr; use std::sync::{Arc, Mutex}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; +use bdk_chain::ChainPosition; use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; #[allow(deprecated)] @@ -363,9 +364,20 @@ impl Wallet { 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. + // funding outpoint): record it under its own id below instead, unless a + // settled funding payment sits there already. FundingStatusUpdate::Foreign => { - payment_id = PaymentId(txid.to_byte_array()); + 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; + }, + } }, } @@ -455,8 +467,16 @@ impl Wallet { txid, status: ConfirmationStatus::Unconfirmed, .. - } if payment.details.direction == PaymentDirection::Outbound => { - unconfirmed_outbound_txids.push(txid); + } => { + if self + .fail_funding_payment_lost_to_conflict(&payment, new_tip.height) + .await? + { + continue; + } + if payment.details.direction == PaymentDirection::Outbound { + unconfirmed_outbound_txids.push(txid); + } }, _ => {}, } @@ -510,9 +530,20 @@ impl Wallet { 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. + // funding outpoint): record it under its own id below instead, unless a + // settled funding payment sits there already. FundingStatusUpdate::Foreign => { - payment_id = PaymentId(txid.to_byte_array()); + 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; + }, + } }, } @@ -565,6 +596,18 @@ impl Wallet { payment_id, ); let payment = stored_payment.ok_or(Error::InvalidPaymentId)?; + + // A terminal record means the entry is the leftover of an interrupted settle + // — the record write landed, the entry removal was lost to a crash — and this + // event is the restart's replay of the same transition. Re-embedding the + // record would stamp the terminal status into the entry and hide it from the + // pending listing that repairs such leftovers; finish the interrupted removal + // instead. + if payment.status != PaymentStatus::Pending { + self.pending_payment_store.remove(&payment_id).await?; + continue; + } + let pending_payment_details = self.create_pending_payment_from_tx(payment, conflict_txids.clone()); @@ -592,9 +635,20 @@ impl Wallet { 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. + // funding outpoint): record it under its own id below instead, unless a + // settled funding payment sits there already. FundingStatusUpdate::Foreign => { - payment_id = PaymentId(txid.to_byte_array()); + 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; + }, + } }, } @@ -623,6 +677,174 @@ impl Wallet { Ok(()) } + /// 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's id is anchored to its first + /// candidate's txid, so a wallet event for that transaction falls back to this id whenever the + /// pending entry no longer maps it — which only happens once the negotiation settled and the + /// entry was removed. The generic event handling must then skip its write: merging a + /// wallet-view `Pending` payment into the settled record would resurrect it with figures no + /// classification derived. 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_store.get(&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) }) + } + + /// 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. + /// + /// 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 { + match payment.details.kind { + PaymentKind::Onchain { + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + .. + } => {}, + _ => return Ok(false), + } + if payment.conflicting_txids.is_empty() { + return Ok(false); + } + + // Serialize with classification, whose retries extend the candidate history: the + // decision below must see that history in its settled form, and holding the lock keeps a + // concurrent write from resurrecting the entry removed at the end. + let _guard = self.funding_payment_update_lock.lock().await; + + // Re-read the entry under the lock; the listing snapshot may predate a classification. + let entry = match self.pending_payment_store.get(&payment.details.id).await? { + Some(entry) => entry, + None => return Ok(false), + }; + let record_txid = match entry.details.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + } => txid, + _ => return Ok(false), + }; + + let foreign_conflicts: Vec = entry + .conflicting_txids + .iter() + .copied() + .filter(|conflict| *conflict != record_txid && entry.candidate(*conflict).is_none()) + .collect(); + if foreign_conflicts.is_empty() { + return Ok(false); + } + + let lost = { + let locked_wallet = self.inner.lock().expect("lock"); + 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) + && entry.candidates.iter().all(|c| cannot_confirm(c.txid)) + && foreign_conflicts.iter().any(|conflict| confirmed_to_depth(*conflict)) + }; + if !lost { + return Ok(false); + } + + // As with graduation, decide from the live record and write only the status. A record + // already `Failed` — a prior pass whose entry removal below was lost to a crash — still + // matches, no-ops the update, and gets its lingering entry removed. + let payment_id = entry.details.id; + let mut failed = false; + self.payment_store + .mutate(&payment_id, |existing| { + let current = existing?; + match current.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + } if txid == record_txid => { + failed = true; + let mut update = PaymentDetailsUpdate::new(payment_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = current.clone(); + updated.update(update).then_some(updated) + }, + _ => None, + } + }) + .await?; + if failed { + self.pending_payment_store.remove(&payment_id).await?; + log_info!( + self.logger, + "Failed funding payment {}: transaction {} lost to a conflicting transaction confirmed beyond the reorg depth", + payment_id, + record_txid, + ); + } + Ok(failed) + } + #[allow(deprecated)] pub(crate) async fn create_funding_transaction( &self, output_script: ScriptBuf, amount: Amount, confirmation_target: ConfirmationTarget, @@ -2775,6 +2997,71 @@ mod tests { const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; + /// An in-memory store counting the reads it serves, by primary namespace, so tests can pin + /// how many backend reads an operation costs. + #[derive(Clone)] + struct ReadCountingStore { + inner: Arc, + reads: Arc>>, + } + + impl ReadCountingStore { + fn new() -> Self { + Self { inner: Arc::new(InMemoryStore::new()), reads: Arc::new(Mutex::new(Vec::new())) } + } + + /// The number of reads served from `primary_namespace` so far. + fn reads(&self, primary_namespace: &str) -> usize { + self.reads + .lock() + .unwrap() + .iter() + .filter(|namespace| *namespace == primary_namespace) + .count() + } + } + + impl KVStore for ReadCountingStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + self.reads.lock().unwrap().push(primary_namespace.to_string()); + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + KVStore::write(&*self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for ReadCountingStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + /// An in-memory store whose writes can be made to fail on demand. #[derive(Clone)] struct FailSwitchStore { @@ -3772,6 +4059,66 @@ mod tests { } } + /// Inserts `tx` into the BDK wallet as canonically confirmed at `height`, extending the + /// local chain to that height. + fn insert_confirmed_tx(wallet: &Wallet, tx: Transaction, height: u32) { + let txid = tx.compute_txid(); + let mut locked = wallet.inner.lock().unwrap(); + let block = + BlockId { height, hash: bitcoin::BlockHash::from_byte_array([height as u8; 32]) }; + let chain = locked.latest_checkpoint().insert(block); + let mut tx_update = bdk_chain::TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.anchors = + [(ConfirmationBlockTime { block_id: block, confirmation_time: 100 }, txid)].into(); + locked + .apply_update(Update { tx_update, chain: Some(chain), ..Default::default() }) + .unwrap(); + } + + /// Inserts `tx` into the BDK wallet as canonically unconfirmed (seen in the mempool). + fn insert_unconfirmed_tx(wallet: &Wallet, tx: Transaction) { + let txid = tx.compute_txid(); + let mut locked = wallet.inner.lock().unwrap(); + let mut tx_update = bdk_chain::TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + tx_update.seen_ats = [(txid, 100)].into(); + locked.apply_update(Update { tx_update, ..Default::default() }).unwrap(); + } + + /// 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 }], + } + } + #[test] fn funding_reclassification_update_substitutes_the_confirmed_candidate() { let confirmed_txid = Txid::from_byte_array([1u8; 32]); @@ -4187,6 +4534,577 @@ mod tests { } } + /// 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), + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + }) + .await + .unwrap(); + + // The close is canonically confirmed; the splice transaction, having lost the conflict, + // is no longer canonical (here: never inserted at all). + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + match &payment.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid, "failing must not adopt the conflict's txid"); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(payment.amount_msat, Some(1_000_000)); + assert_eq!(payment.fee_paid_msat, Some(500)); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none(), + "the entry must go so the 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), + }, + FundingTxCandidate { + txid: second_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + }, + ]; + let details = + interactive_funding_details(payment_id, second_txid, Some(1_000_000), Some(600)); + wallet.persist_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 + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + }) + .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_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending, "the evicted round can still confirm"); + let entry = wallet.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_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + } + + /// A confirmed conflict that is one of the record's own candidates is RBF resolution, not a + /// loss: classification adopts it into the record, so the failure pass must leave the record + /// alone. + #[tokio::test] + async fn funding_payment_survives_a_confirmed_conflict_that_is_a_candidate() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let bumped_tx = wallet_paying_tx(&wallet, 3); + let bumped_txid = bumped_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }, + FundingTxCandidate { + txid: bumped_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + }, + ]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![bumped_txid]), + candidates: Vec::new(), + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, bumped_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some(), + "the entry must survive for classification to adopt the confirmed candidate" + ); + } + + /// A foreign conflict that has confirmed but not yet through the anti-reorg depth may still + /// be reorged out, letting the funding transaction confirm after all; the record must stay + /// pending until the conflict's confirmation is final. + #[tokio::test] + async fn funding_payment_survives_a_foreign_conflict_short_of_depth() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 2), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some()); + } + + /// A conflict may double-spend only one round of the negotiation — e.g. it shares an input + /// with an RBF attempt but not with the original candidate. While any candidate is still + /// canonical it can still confirm, so the record must stay pending. + #[tokio::test] + async fn funding_payment_survives_while_a_candidate_can_still_confirm() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let conflict_tx = wallet_paying_tx(&wallet, 3); + let conflict_txid = conflict_tx.compute_txid(); + // A live candidate: spends a different outpoint, so the conflict didn't kill it. + let live_candidate_tx = wallet_paying_tx(&wallet, 4); + let live_candidate_txid = live_candidate_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }, + FundingTxCandidate { + txid: live_candidate_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + }, + ]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![conflict_txid]), + candidates: Vec::new(), + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, conflict_tx, 5); + insert_unconfirmed_tx(&wallet, live_candidate_tx); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some(), + "a candidate can still confirm, so the record must stay pending" + ); + } + + /// The failure write pair is record first, entry second: a crash in between leaves a + /// `Failed` record with a lingering entry. The next tip pass must finish the job — remove + /// the entry without disturbing the record. + #[tokio::test] + async fn a_failed_funding_payment_with_a_lingering_entry_is_cleaned_up() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut recorded = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_store.insert_or_update(recorded).await.unwrap(); + + // The entry embeds the pre-failure snapshot, as a crash between the two writes leaves it. + let snapshot = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the repair pass must not rewrite"); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none(), + "the lingering entry must be removed" + ); + } + + /// A crash between the failure's record write and its entry removal loses the wallet + /// changeset too, so the restart's catch-up sync replays the same events: `TxReplaced` for + /// the 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_store.insert_or_update(recorded).await.unwrap(); + + let snapshot = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let events = vec![ + WalletEvent::TxReplaced { + txid: splice_txid, + tx: Arc::new(dummy_tx()), + conflicts: vec![(0, close_txid)], + }, + WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }, + ]; + wallet.update_payment_store(events).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the replay must not rewrite the record"); + assert!( + wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none(), + "the replay must finish the interrupted entry removal" + ); + } + + /// 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_store.get(&payment_id).await.unwrap().is_some()); + assert_eq!(reads, 2, "recording an unknown transaction re-read the payment store"); + } + + /// A funding record's id is anchored to its first candidate's txid. Once the payment settles + /// and its entry is removed, a wallet event for that candidate no longer resolves through the + /// candidate history — the fallback keys it by its own txid, colliding with the record's id. + /// Recording the event there would merge a fresh wallet-view `Pending` payment into the + /// terminal record; such events must be skipped. + #[tokio::test] + async fn candidate_event_does_not_resurrect_a_settled_funding_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // The record's id derives from the first candidate r1; its txid rotated to the RBF round + // r2. The payment failed and its pending entry is gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let r2 = Txid::from_byte_array([4u8; 32]); + let payment_id = PaymentId(r1.to_byte_array()); + let mut recorded = interactive_funding_details(payment_id, r2, Some(1_000_000), Some(600)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_store.insert_or_update(recorded).await.unwrap(); + + // r1 reappears in the mempool after the failure... + let event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + + // ...and even confirms: the record settled as `Failed` and must stay that way. + let event = WalletEvent::TxConfirmed { + txid: r1, + tx: Arc::new(dummy_tx()), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + } + + /// The 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_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_store.insert_or_update(live.clone()).await.unwrap(); + wallet + .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_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.pending_payment_store.get(&settled_id).await.unwrap().is_none()); + assert_eq!(wallet.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), + }]; + let mut details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + details.direction = PaymentDirection::Inbound; + wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet + .pending_payment_store + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + } + /// A funding-typed broadcast that doesn't touch the on-chain wallet must not be recorded. /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding /// path, so a splice the interactive-funding classification deliberately declined — no local From 943e8452bf5dc5eda50f3301da9239582bad50e1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 16:46:50 +0200 Subject: [PATCH 03/23] Add a channel transaction provenance store What a transaction is, is known only to the channel that produced it, and only while the event announcing it is being handled. Record that knowledge durably, keyed by transaction id, so it is still available whenever the transaction is looked at later. Facts are immutable and merged rather than replaced, because several channel events describe the same transaction from different angles: re-recording what is already known writes nothing, so an event handler may replay freely, while a report contradicting a recorded fact is rejected and logged rather than overwriting it. Co-Authored-By: HAL 9000 --- src/builder.rs | 36 ++- src/config.rs | 16 ++ src/io/mod.rs | 4 + src/types.rs | 2 + src/wallet/mod.rs | 64 ++++- src/wallet/provenance.rs | 551 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 666 insertions(+), 7 deletions(-) create mode 100644 src/wallet/provenance.rs diff --git a/src/builder.rs b/src/builder.rs index ff7626ae26..797ffb3031 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,9 +107,9 @@ 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::Wallet; @@ -1564,6 +1567,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 +1591,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 +1929,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 +1967,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 diff --git a/src/config.rs b/src/config.rs index cb74b55c80..c9c7372ca9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,6 +65,22 @@ 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 default {Esplora,Electrum} client timeout we're using. const DEFAULT_PER_REQUEST_TIMEOUT_SECS: u8 = 10; 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/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 d4d77fa9b9..0a122628e9 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -67,7 +67,8 @@ use crate::payment::{ PendingPaymentDetails, TransactionType, }; use crate::runtime::Runtime; -use crate::types::{Broadcaster, PaymentStore, PendingPaymentStore}; +use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; +use crate::wallet::provenance::ChannelTxFacts; use crate::{ChainSource, Error}; pub(crate) enum OnchainSendAmount { @@ -82,6 +83,7 @@ pub(crate) enum FundingAmount { } pub(crate) mod persist; +pub(crate) mod provenance; pub(crate) mod ser; const DUST_LIMIT_SATS: u64 = 546; @@ -171,6 +173,9 @@ pub(crate) struct Wallet { // 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<()>, + // What this node's channels reported about the transactions they produced, keyed by + // transaction id. + channel_tx_facts_store: Arc, } impl Wallet { @@ -180,6 +185,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); @@ -199,6 +205,45 @@ impl Wallet { logger, pending_payment_store, funding_payment_update_lock: tokio::sync::Mutex::new(()), + channel_tx_facts_store, + } + } + + /// 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. + pub(crate) async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) -> Result<(), Error> { + let txid = facts.txid; + // 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 conflict = None; + self.channel_tx_facts_store + .mutate(&txid, |current| match current { + Some(recorded) => match recorded.clone().merged_with(&facts) { + Ok(merged) => merged, + Err(e) => { + conflict = Some(e); + None + }, + }, + None => Some(facts), + }) + .await?; + + match conflict { + Some(e) => { + log_error!( + self.logger, + "Rejected facts contradicting what is recorded for transaction {}: {}", + txid, + e, + ); + Err(Error::PersistenceFailed) + }, + None => Ok(()), } } @@ -2982,12 +3027,14 @@ 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, }; @@ -3200,6 +3247,14 @@ 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(); @@ -3216,6 +3271,7 @@ mod tests { config, logger, pending_payment_store, + channel_tx_facts_store, )) } diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs new file mode 100644 index 0000000000..ac2f90968e --- /dev/null +++ b/src/wallet/provenance.rs @@ -0,0 +1,551 @@ +// 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::fmt; + +use bitcoin::hashes::Hash; +use bitcoin::secp256k1::PublicKey; +use bitcoin::Txid; +use lightning::ln::channelmanager::PaymentId; +use lightning::ln::types::ChannelId; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; + +use crate::data_store::{StorableObject, StorableObjectId}; +use crate::hex_utils; +use crate::payment::store::{Channel, TransactionType}; +use crate::payment::PaymentDirection; +use crate::types::UserChannelId; + +/// The part a transaction output plays in a channel. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ChannelOutputRole { + /// The output holding a channel's funds, spendable only by the channel's commitment and + /// closing transactions. + Funding, + /// An anchor output of a commitment transaction, spendable to fee-bump that transaction. + Anchor, + /// An HTLC output of a commitment transaction. + Htlc, + /// An output a channel resolved to this node, spendable by the on-chain wallet. + Spendable, +} + +impl_writeable_tlv_based_enum!(ChannelOutputRole, + (0, Funding) => {}, + (2, Anchor) => {}, + (4, Htlc) => {}, + (6, Spendable) => {}, +); + +/// One output of a transaction that a channel controls, and the channel controlling it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ChannelOutputFact { + /// The index of the output within its transaction. + pub vout: u32, + /// What the output is for. + pub role: ChannelOutputRole, + /// The `node_id` of the channel's counterparty. + pub counterparty_node_id: PublicKey, + /// The channel controlling the output. + pub channel_id: ChannelId, + /// The channel's local identifier, when the producer of this fact knew it. It survives the + /// temporary-to-final `channel_id` transition, unlike `channel_id` itself. + pub user_channel_id: Option, +} + +impl_writeable_tlv_based!(ChannelOutputFact, { + (0, vout, required), + (2, role, required), + (4, counterparty_node_id, required), + (6, channel_id, required), + (8, user_channel_id, option), +}); + +/// This node's share of an interactively negotiated funding transaction, and the funding payment +/// the transaction belongs to. +/// +/// The amount and the fee are `None` for a candidate this node contributed nothing to, e.g. a +/// counterparty-initiated round before one of ours replaced it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocalFundingFigures { + /// The funding payment this transaction is a candidate of. + pub funding_payment_id: PaymentId, + /// This node's share of the funding amount, in millisatoshis. + pub amount_msat: Option, + /// This node's share of the transaction's on-chain fee, in millisatoshis. + pub fee_paid_msat: Option, + /// Whether this node's share moves funds into or out of its on-chain wallet. + pub direction: PaymentDirection, +} + +impl_writeable_tlv_based!(LocalFundingFigures, { + (0, funding_payment_id, required), + (2, amount_msat, option), + (4, fee_paid_msat, option), + (6, direction, required), +}); + +/// What this node's producers reported about one transaction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ChannelTxFacts { + /// The transaction these facts are about. + pub txid: Txid, + /// Outputs of this transaction controlled by a channel rather than by the wallet. + pub outputs: Vec, + /// What this transaction is, when a producer identified it directly. + pub self_role: Option, + /// This node's share of an interactive-funding candidate, and the funding record it belongs + /// to. + pub local_figures: Option, +} + +impl_writeable_tlv_based!(ChannelTxFacts, { + (0, txid, required), + (2, outputs, optional_vec), + (4, self_role, option), + (6, local_figures, option), +}); + +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 } + } + + /// 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 + } + + /// 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 transaction. + pub(crate) fn with_local_figures(mut self, local_figures: LocalFundingFigures) -> Self { + self.local_figures = Some(local_figures); + self + } + + /// Merges `incoming` into these facts, returning the result, or `None` when `incoming` adds + /// nothing to what is already recorded. + /// + /// Outputs are unioned by `vout`, while `self_role` and `local_figures` are filled in only + /// where they are still absent. Re-reporting a fact is therefore a no-op, which is what lets + /// a producer replay its event without consequence. Reporting a *different* value for + /// something already recorded is rejected, leaving the recorded facts as they were. + pub(crate) fn merged_with( + mut self, incoming: &ChannelTxFacts, + ) -> Result, ChannelTxFactsConflict> { + if self.txid != incoming.txid { + return Err(ChannelTxFactsConflict::Txid { + recorded: self.txid, + incoming: incoming.txid, + }); + } + + let mut changed = false; + for output in &incoming.outputs { + match self.outputs.iter().find(|recorded| recorded.vout == output.vout) { + Some(recorded) if recorded == output => {}, + Some(recorded) => { + return Err(ChannelTxFactsConflict::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(ChannelTxFactsConflict::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(ChannelTxFactsConflict::LocalFigures { + recorded: recorded.clone(), + incoming: incoming.clone(), + }) + }, + (None, Some(incoming)) => { + self.local_figures = Some(incoming.clone()); + changed = true; + }, + _ => {}, + } + + Ok(changed.then_some(self)) + } +} + +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 contradicts one already recorded for the same transaction. +/// +/// Facts are immutable, so this means two producers disagree about the same transaction, which +/// they cannot both be right about. The recorded value stands and the reported one is dropped. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ChannelTxFactsConflict { + /// 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 }, +} + +impl fmt::Display for ChannelTxFactsConflict { + 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) + }, + } + } +} + +#[cfg(test)] +mod tests { + 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 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); + 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()] }) + .with_local_figures(LocalFundingFigures { + funding_payment_id: PaymentId([9u8; 32]), + amount_msat: Some(1_000_000), + fee_paid_msat: Some(2_500), + direction: PaymentDirection::Outbound, + }) + } + + #[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 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(ChannelTxFactsConflict::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(ChannelTxFactsConflict::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(ChannelTxFactsConflict::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 = ChannelTxFacts::new(txid).with_local_figures(figures.clone()); + let conflicting = ChannelTxFacts::new(txid) + .with_local_figures(LocalFundingFigures { fee_paid_msat: Some(5_000), ..figures }); + + assert!(matches!( + recorded.merged_with(&conflicting), + Err(ChannelTxFactsConflict::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(ChannelTxFactsConflict::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(&ChannelTxFacts::new(txid).with_local_figures(figures.clone())) + .expect("fills in") + .expect("changed"); + assert_eq!(filled.local_figures, Some(figures.clone())); + + assert_eq!( + filled.merged_with(&ChannelTxFacts::new(txid).with_local_figures(figures)), + Ok(None) + ); + } +} From ffd9200bcbfd76073036086060574f5977849a76 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 16:53:40 +0200 Subject: [PATCH 04/23] Record channel transaction provenance from events The channel events that hand a transaction over are the only place this node learns what that transaction is; record it there, so the knowledge outlives the handler. A funding transaction this node builds is recorded before LDK is allowed to release it, because the event is regenerated rather than persisted: recording afterwards could lose the outpoint to a crash. Sweeps are recorded once the sweeper holds the outputs; anchor bumps and HTLC claims once the bump handler has been handed the event, whose outcome it does not report. In both cases a failed write is logged rather than reported, so that bookkeeping can never withhold a claim. The remaining channel events record the same funding outpoints a second time as a backstop, which the merge absorbs. Outputs the sweeper is told to leave alone are left out of the record as well: LDK reports an output paying a script of this wallet's own, such as the closing output of a cooperative close, as a static output, and whatever spends it next is an ordinary wallet transaction, not a sweep. Recording it would label that transaction a sweep and refuse to fee-bump it. Co-Authored-By: HAL 9000 Co-Authored-By: Claude Fable 5.1 --- src/event.rs | 250 ++++++++++++++++++++++++++++++++++++++- src/wallet/provenance.rs | 95 ++++++++++++--- 2 files changed, 321 insertions(+), 24 deletions(-) diff --git a/src/event.rs b/src/event.rs index 730a682dd1..7182a16070 100644 --- a/src/event.rs +++ b/src/event.rs @@ -13,8 +13,9 @@ 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::transaction::OutPoint as LdkOutPoint; use lightning::events::bump_transaction::BumpTransactionEvent; #[cfg(not(feature = "uniffi"))] use lightning::events::PaidBolt12Invoice; @@ -26,7 +27,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,7 +52,8 @@ 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; @@ -59,6 +61,7 @@ use crate::runtime::Runtime; use crate::types::{ CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, }; +use crate::wallet::provenance::{ChannelOutputRole, ChannelTxFacts}; use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, UserChannelId, @@ -733,6 +736,18 @@ where Ok((payment_id, None)) } + /// Records what one of this node's channels reported about a transaction it produced. + /// + /// A failure is logged rather than reported: these facts accompany a transaction this node + /// has already released or a claim it has already made, so there is nothing left to withhold, + /// and the producing event is re-offered until the claim resolves. + async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) { + let txid = facts.txid; + if let Err(e) = self.wallet.record_channel_tx_facts(facts).await { + 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 +770,7 @@ where let funding_transaction = self .wallet .create_funding_transaction( - output_script, + output_script.clone(), channel_amount, confirmation_target, locktime, @@ -763,6 +778,49 @@ 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], + ); + if let Err(e) = self.wallet.record_channel_tx_facts(facts).await { + 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 +892,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 +1618,38 @@ where .await; }, LdkEvent::SpendableOutputs { outputs, channel_id, counterparty_node_id } => { + let spendable_outpoints = sweepable_outpoints(&outputs); + + // Static outputs are excluded from the sweeper, as `sweepable_outpoints` excludes + // them from the record below. match self .output_sweeper .track_spendable_outputs(outputs, channel_id, counterparty_node_id, true, None) .await { - Ok(_) => return Ok(()), + Ok(_) => {}, Err(_) => { log_error!(self.logger, "Failed to track spendable outputs"); return Err(ReplayEvent()); }, }; + + // Record which channel resolved these outputs only once the sweeper holds them: + // the sweep itself must never wait on bookkeeping, and the sweeper's own record + // is durable, so a failure here costs a label rather than the funds. + if let (Some(counterparty_node_id), Some(channel_id)) = + (counterparty_node_id, channel_id) + { + let channel = Channel { counterparty_node_id, channel_id }; + for facts in ChannelTxFacts::per_transaction( + &channel, + None, + ChannelOutputRole::Spendable, + spendable_outpoints, + ) { + self.record_channel_tx_facts(facts).await; + } + } }, LdkEvent::OpenChannelRequest { temporary_channel_id, @@ -1834,6 +1928,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 +2012,20 @@ 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; + } + self.liquidity_source .lsps2_service() .handle_channel_ready(user_channel_id, &channel_id, &counterparty_node_id) @@ -2087,6 +2206,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 { @@ -2231,6 +2408,19 @@ 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; + let event = Event::SpliceNegotiated { channel_id, user_channel_id: UserChannelId(user_channel_id), @@ -2278,6 +2468,22 @@ 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 so is the record: whatever spends such an output next is an +/// ordinary wallet transaction, not a sweep. +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() +} + #[cfg(test)] mod tests { use std::collections::VecDeque; @@ -2635,4 +2841,36 @@ 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 nothing about it is recorded: the transaction that spends it next is an + /// ordinary wallet transaction, not a sweep. The outputs the sweeper does take are recorded. + #[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)]); + } } diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index ac2f90968e..94f9fec5ac 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -140,18 +140,37 @@ impl ChannelTxFacts { self } + /// Facts about `outpoints`, all controlled by `channel` in `role`, as one record per + /// transaction they belong to. + pub(crate) fn per_transaction( + channel: &Channel, user_channel_id: Option, role: ChannelOutputRole, + outpoints: impl IntoIterator, + ) -> Vec { + let mut grouped: Vec<(Txid, Vec)> = Vec::new(); + for (txid, vout) in outpoints { + match grouped.iter_mut().find(|(recorded, _)| *recorded == txid) { + Some((_, vouts)) => { + if !vouts.contains(&vout) { + vouts.push(vout); + } + }, + None => grouped.push((txid, vec![vout])), + } + } + grouped + .into_iter() + .map(|(txid, vouts)| { + Self::new(txid).with_outputs(channel, user_channel_id, role, vouts) + }) + .collect() + } + /// Records what this transaction is. pub(crate) fn with_self_role(mut self, self_role: TransactionType) -> Self { self.self_role = Some(self_role); self } - /// Records this node's share of an interactively negotiated funding transaction. - 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. /// @@ -301,6 +320,10 @@ mod tests { .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"); @@ -309,18 +332,23 @@ mod tests { fn full_facts() -> ChannelTxFacts { let channel = test_channel(1); - ChannelTxFacts::new(test_txid(7)) + 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()] }) - .with_local_figures(LocalFundingFigures { + .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] @@ -344,6 +372,40 @@ mod tests { assert_eq!(decoded.local_figures, None); } + #[test] + fn outpoints_group_into_one_record_per_transaction() { + let channel = test_channel(1); + let records = ChannelTxFacts::per_transaction( + &channel, + Some(UserChannelId(7)), + ChannelOutputRole::Spendable, + [ + (test_txid(1), 0), + (test_txid(2), 4), + (test_txid(1), 3), + // A producer reporting the same outpoint twice contributes it once. + (test_txid(2), 4), + ], + ); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].txid, test_txid(1)); + assert_eq!( + records[0].outputs.iter().map(|output| output.vout).collect::>(), + vec![0, 3] + ); + assert_eq!(records[1].txid, test_txid(2)); + assert_eq!( + records[1].outputs.iter().map(|output| output.vout).collect::>(), + vec![4] + ); + assert!(records.iter().flat_map(|facts| &facts.outputs).all(|output| { + output.role == ChannelOutputRole::Spendable + && output.channel_id == channel.channel_id + && output.user_channel_id == Some(UserChannelId(7)) + })); + } + #[test] fn facts_key_round_trips_through_its_hex_encoding() { let txid = test_txid(3); @@ -467,9 +529,9 @@ mod tests { fee_paid_msat: Some(2_500), direction: PaymentDirection::Outbound, }; - let recorded = ChannelTxFacts::new(txid).with_local_figures(figures.clone()); - let conflicting = ChannelTxFacts::new(txid) - .with_local_figures(LocalFundingFigures { fee_paid_msat: Some(5_000), ..figures }); + 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), @@ -538,14 +600,11 @@ mod tests { }; let filled = ChannelTxFacts::new(txid) - .merged_with(&ChannelTxFacts::new(txid).with_local_figures(figures.clone())) + .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(&ChannelTxFacts::new(txid).with_local_figures(figures)), - Ok(None) - ); + assert_eq!(filled.merged_with(&with_local_figures(txid, figures)), Ok(None)); } } From 484942369f9936cf6d81d6312a74a006c96f255c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 2 Oct 2026 22:21:13 -0500 Subject: [PATCH 05/23] f - Test the two answers to a failed facts write A funding transaction this node generates is withheld from LDK until its facts are on record, so a failed write replays the event and the channel becomes pending only once the write goes through. Every other report accompanies a transaction already released, so its failure is logged and the event proceeds. Cover both with a store whose writes to the facts namespace fail while the test says so. This change was made with the help of an AI tool. Co-Authored-By: Claude Fable 5.1 --- tests/common/mod.rs | 50 +++++++---- tests/integration_tests_rust.rs | 143 +++++++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 18 deletions(-) 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..d7efbb3620 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -29,9 +29,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_tx, InMemoryStore, NodePaymentExt, + TestChainSource, TestConfig, TestNode, TestStoreType, TestSyncStore, }; use electrsd::corepc_node::{self, Node as BitcoinD}; use electrsd::ElectrsD; @@ -299,6 +299,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 @@ -600,6 +679,64 @@ 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(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_open_fails_when_funds_insufficient() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 7fb8786217ca1beacedd9c79f343bb9a057752f1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 17:12:50 +0200 Subject: [PATCH 06/23] Classify transactions from recorded provenance What a transaction is follows from what this node's channels said about it and about the transactions it spends from, so derive it there rather than from the tag its broadcast carried: a tag describes one broadcast, while the facts describe the transaction and survive re-broadcasts and replacements unchanged. A funding output spent in a shape no channel produces stays unnamed. Guessing would put a classification on a payment record that nothing later corrects, and an unnamed record is the honest answer. Co-Authored-By: HAL 9000 --- src/wallet/provenance.rs | 429 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 428 insertions(+), 1 deletion(-) diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index 94f9fec5ac..1c05d46332 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -14,11 +14,12 @@ //! 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; use std::fmt; use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; -use bitcoin::Txid; +use bitcoin::{Sequence, Transaction, Txid}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::types::ChannelId; use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; @@ -291,8 +292,165 @@ impl fmt::Display for ChannelTxFactsConflict { } } +/// The recorded facts a transaction's classification rests on: what this node's channels reported +/// about the transaction itself, and what they reported about the transactions its inputs spend. +#[derive(Clone, Debug, Default)] +pub(crate) struct TxProvenance { + /// What was reported about the transaction itself, if anything. + self_facts: Option, + /// What was reported about the transactions the inputs spend, keyed by transaction id. Only + /// the transactions the inputs actually reference are represented. + parent_facts: HashMap, +} + +impl TxProvenance { + /// The provenance assembled from the facts recorded for a transaction and for the + /// transactions its inputs spend. + pub(crate) fn new( + self_facts: Option, parent_facts: HashMap, + ) -> Self { + Self { self_facts, parent_facts } + } + + /// What `tx` is, as far as these facts can tell; see [`classify`]. + pub(crate) fn classify(&self, tx: &Transaction) -> Option { + classify(tx, self.self_facts.as_ref(), &self.parent_facts) + } + + /// This node's share of the transaction, for a candidate of an interactively negotiated + /// funding a producer reported the figures of. + pub(crate) fn local_figures(&self) -> Option<&LocalFundingFigures> { + self.self_facts.as_ref()?.local_figures.as_ref() + } +} + +/// What a transaction is, derived from what this node's channels recorded about it and about the +/// transactions its inputs spend. +/// +/// `self_facts` are the facts recorded for `tx`, `parent_facts` those recorded for the +/// transactions `tx` spends from, keyed by transaction id. Anything these cannot account for is +/// left unclassified rather than guessed: without a channel of this node's laying claim to an +/// output, a transaction is an ordinary on-chain payment. +pub(crate) fn classify( + tx: &Transaction, self_facts: Option<&ChannelTxFacts>, + parent_facts: &HashMap, +) -> Option { + // A producer that named the transaction outright is the most reliable answer there is, and + // the only one that stays put across a re-broadcast or a replacement of the transaction. + if let Some(self_role) = self_facts.and_then(|facts| facts.self_role.as_ref()) { + return Some(self_role.clone()); + } + + let spent: Vec<&ChannelOutputFact> = tx + .input + .iter() + .filter_map(|input| { + parent_facts.get(&input.previous_output.txid).and_then(|parent| { + parent.outputs.iter().find(|output| output.vout == input.previous_output.vout) + }) + }) + .collect(); + let created: &[ChannelOutputFact] = self_facts.map_or(&[], |facts| facts.outputs.as_slice()); + let funds: Vec<&ChannelOutputFact> = + created.iter().filter(|output| output.role == ChannelOutputRole::Funding).collect(); + + let spent_funding = in_role(&spent, ChannelOutputRole::Funding); + if let Some(funding) = spent_funding.first() { + // Moving a channel's funds into a new funding output is what an interactive negotiation + // produces, whichever side of it this node is on. + if !funds.is_empty() { + let channels = channels_of(spent_funding.iter().copied().chain(funds.iter().copied())); + return Some(TransactionType::InteractiveFunding { channels }); + } + if is_cooperative_close(tx) { + return Some(TransactionType::CooperativeClose { + counterparty_node_id: funding.counterparty_node_id, + channel_id: funding.channel_id, + }); + } + if is_commitment(tx) { + return Some(TransactionType::UnilateralClose { + counterparty_node_id: funding.counterparty_node_id, + channel_id: funding.channel_id, + }); + } + // The funding output is gone in a shape none of the transactions a channel produces has. + // Naming it anyway would put a guess on a payment record that nothing later corrects. + return None; + } + + let spent_anchors = in_role(&spent, ChannelOutputRole::Anchor); + if let Some(anchor) = spent_anchors.first() { + return Some(TransactionType::AnchorBump { + counterparty_node_id: anchor.counterparty_node_id, + channel_id: anchor.channel_id, + }); + } + + let spent_htlcs = in_role(&spent, ChannelOutputRole::Htlc); + if let Some(htlc) = spent_htlcs.first() { + return Some(TransactionType::Claim { + counterparty_node_id: htlc.counterparty_node_id, + channel_id: htlc.channel_id, + }); + } + + let spent_spendable = in_role(&spent, ChannelOutputRole::Spendable); + if !spent_spendable.is_empty() { + return Some(TransactionType::Sweep { channels: channels_of(spent_spendable) }); + } + + if !funds.is_empty() { + return Some(TransactionType::Funding { channels: channels_of(funds) }); + } + + None +} + +/// The outputs among `outputs` a channel controls in `role`. +fn in_role<'a>( + outputs: &[&'a ChannelOutputFact], role: ChannelOutputRole, +) -> Vec<&'a ChannelOutputFact> { + outputs.iter().copied().filter(|output| output.role == role).collect() +} + +/// The channels controlling `outputs`, each named once, in the order the outputs name them. +fn channels_of<'a>(outputs: impl IntoIterator) -> Vec { + let mut channels: Vec = Vec::new(); + for output in outputs { + let channel = Channel { + counterparty_node_id: output.counterparty_node_id, + channel_id: output.channel_id, + }; + if !channels.contains(&channel) { + channels.push(channel); + } + } + channels +} + +/// Whether `tx` has the shape BOLT 2 gives a cooperative closing transaction: the sole spend of +/// the funding output, final and valid from the moment it is signed. +fn is_cooperative_close(tx: &Transaction) -> bool { + tx.input.len() == 1 + && tx.input[0].sequence == Sequence::MAX + && tx.lock_time.to_consensus_u32() == 0 +} + +/// Whether `tx` has the shape BOLT 3 gives a commitment transaction: the sole spend of the +/// funding output, with the upper byte of its sequence and of its locktime set to the constants +/// that mark the remainder of both as the obscured commitment number. +fn is_commitment(tx: &Transaction) -> bool { + tx.input.len() == 1 + && (tx.input[0].sequence.0 >> 24) as u8 == 0x80 + && (tx.lock_time.to_consensus_u32() >> 24) as u8 == 0x20 +} + #[cfg(test)] mod tests { + use bitcoin::absolute::LockTime; + use bitcoin::transaction::Version; + use bitcoin::{Amount, OutPoint, ScriptBuf, TxIn, TxOut, Witness}; use lightning::util::ser::{Readable, Writeable}; use super::*; @@ -607,4 +765,273 @@ mod tests { assert_eq!(filled.merged_with(&with_local_figures(txid, figures)), Ok(None)); } + /// The transaction whose outputs the classification cases below spend. + const PARENT: u8 = 0x11; + + /// A transaction spending `inputs`, each input carrying `sequence`. + fn spending_tx(inputs: &[(Txid, u32)], sequence: Sequence, lock_time: u32) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(lock_time), + input: inputs + .iter() + .map(|(txid, vout)| TxIn { + previous_output: OutPoint { txid: *txid, vout: *vout }, + script_sig: ScriptBuf::new(), + sequence, + witness: Witness::new(), + }) + .collect(), + output: vec![TxOut { value: Amount::from_sat(1_000), script_pubkey: ScriptBuf::new() }], + } + } + + /// The single spend of `PARENT`'s first output, in the shape BOLT 2 gives a cooperative + /// closing transaction. + fn cooperative_close_shaped() -> Transaction { + spending_tx(&[(test_txid(PARENT), 0)], Sequence::MAX, 0) + } + + /// The single spend of `PARENT`'s first output, in the shape BOLT 3 gives a commitment + /// transaction: the obscured commitment number split across sequence and locktime. + fn commitment_shaped() -> Transaction { + spending_tx(&[(test_txid(PARENT), 0)], Sequence(0x80_12_34_56), 0x20_ab_cd_ef) + } + + /// The single spend of `PARENT`'s first output in no shape a channel produces: replaceable, + /// and without a commitment number. + fn unrecognised_shaped() -> Transaction { + spending_tx(&[(test_txid(PARENT), 0)], Sequence(0xff_ff_ff_fd), 0) + } + + fn parents(facts: impl IntoIterator) -> HashMap { + facts.into_iter().map(|facts| (facts.txid, facts)).collect() + } + + /// Facts recording `PARENT`'s outputs `vouts` as controlled by `channel` in `role`. + fn parent_outputs( + channel: &Channel, role: ChannelOutputRole, vouts: impl IntoIterator, + ) -> ChannelTxFacts { + ChannelTxFacts::new(test_txid(PARENT)).with_outputs(channel, None, role, vouts) + } + + /// Facts recording `tx`'s first output as `channel`'s funding output. + fn funds(tx: &Transaction, channel: &Channel, vout: u32) -> ChannelTxFacts { + ChannelTxFacts::new(tx.compute_txid()).with_outputs( + channel, + Some(UserChannelId(42)), + ChannelOutputRole::Funding, + [vout], + ) + } + + #[test] + fn a_reported_role_settles_what_a_transaction_is() { + let channel = test_channel(1); + let tx = cooperative_close_shaped(); + let recorded = parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]); + + // Left to its shape alone, the transaction is a cooperative close. + assert_eq!( + classify(&tx, None, &recorded), + Some(TransactionType::CooperativeClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + + // The channel that produced it says otherwise, and it is the one that knows. + let reported = TransactionType::UnilateralClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }; + let self_facts = ChannelTxFacts::new(tx.compute_txid()).with_self_role(reported.clone()); + assert_eq!(classify(&tx, Some(&self_facts), &recorded), Some(reported)); + } + + #[test] + fn spending_and_creating_a_funding_output_is_an_interactive_funding() { + let channel = test_channel(1); + let tx = unrecognised_shaped(); + let self_facts = funds(&tx, &channel, 0); + + assert_eq!( + classify( + &tx, + Some(&self_facts), + &parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]), + ), + Some(TransactionType::InteractiveFunding { channels: vec![channel] }) + ); + } + + #[test] + fn a_final_single_spend_of_a_funding_output_is_a_cooperative_close() { + let channel = test_channel(1); + assert_eq!( + classify( + &cooperative_close_shaped(), + None, + &parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]), + ), + Some(TransactionType::CooperativeClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn a_commitment_shaped_spend_of_a_funding_output_is_a_unilateral_close() { + let channel = test_channel(1); + assert_eq!( + classify( + &commitment_shaped(), + None, + &parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]), + ), + Some(TransactionType::UnilateralClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn an_unrecognised_spend_of_a_funding_output_is_left_unnamed() { + let channel = test_channel(1); + let recorded = parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]); + + // Neither template matches and nothing reported the transaction, so there is no answer + // to give. A close of either kind would be a guess. + assert_eq!(classify(&unrecognised_shaped(), None, &recorded), None); + + // A second input rules both templates out as well, whatever the first input looks like. + let two_inputs = + spending_tx(&[(test_txid(PARENT), 0), (test_txid(PARENT + 1), 0)], Sequence::MAX, 0); + assert_eq!(classify(&two_inputs, None, &recorded), None); + } + + #[test] + fn spending_an_anchor_output_is_an_anchor_bump() { + let channel = test_channel(1); + let tx = spending_tx( + &[(test_txid(PARENT), 1), (test_txid(PARENT + 9), 0)], + Sequence(0xff_ff_ff_fd), + 0, + ); + + assert_eq!( + classify( + &tx, + None, + &parents([parent_outputs(&channel, ChannelOutputRole::Anchor, [1])]), + ), + Some(TransactionType::AnchorBump { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn spending_an_htlc_output_is_a_claim() { + let channel = test_channel(1); + let tx = spending_tx(&[(test_txid(PARENT), 2)], Sequence(0xff_ff_ff_fd), 0); + + assert_eq!( + classify(&tx, None, &parents([parent_outputs(&channel, ChannelOutputRole::Htlc, [2])]),), + Some(TransactionType::Claim { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + } + + #[test] + fn spending_resolved_outputs_is_a_sweep_naming_every_channel() { + let channel = test_channel(1); + let other = test_channel(2); + let tx = spending_tx( + &[(test_txid(PARENT), 0), (test_txid(PARENT), 1), (test_txid(PARENT + 1), 0)], + Sequence(0xff_ff_ff_fd), + 0, + ); + + // One sweep resolving outputs of two channels is associated with both of them. + let recorded = parents([ + parent_outputs(&channel, ChannelOutputRole::Spendable, [0, 1]), + ChannelTxFacts::new(test_txid(PARENT + 1)).with_outputs( + &other, + None, + ChannelOutputRole::Spendable, + [0], + ), + ]); + assert_eq!( + classify(&tx, None, &recorded), + Some(TransactionType::Sweep { channels: vec![channel, other] }) + ); + } + + #[test] + fn creating_a_funding_output_alone_is_a_funding_naming_every_channel() { + let channel = test_channel(1); + let other = test_channel(2); + // Nothing channel-controlled is spent: the wallet pays for both funding outputs. + let tx = spending_tx(&[(test_txid(PARENT + 20), 0)], Sequence(0xff_ff_ff_fd), 0); + let self_facts = funds(&tx, &channel, 0).with_outputs( + &other, + Some(UserChannelId(43)), + ChannelOutputRole::Funding, + [1], + ); + + assert_eq!( + classify(&tx, Some(&self_facts), &HashMap::new()), + Some(TransactionType::Funding { channels: vec![channel, other] }) + ); + } + + #[test] + fn an_ordinary_wallet_spend_is_left_unnamed() { + let tx = spending_tx(&[(test_txid(PARENT), 0)], Sequence(0xff_ff_ff_fd), 0); + + // Nothing was ever reported about the transaction or about what it spends. + assert_eq!(classify(&tx, None, &HashMap::new()), None); + + // Nor does spending an output a channel left alone make the transaction a channel's. + let channel = test_channel(1); + let recorded = parents([parent_outputs(&channel, ChannelOutputRole::Spendable, [7])]); + assert_eq!(classify(&tx, None, &recorded), None); + } + + #[test] + fn provenance_answers_from_the_facts_it_holds() { + let channel = test_channel(1); + let tx = cooperative_close_shaped(); + let figures = LocalFundingFigures { + funding_payment_id: PaymentId([9u8; 32]), + amount_msat: Some(1_000_000), + fee_paid_msat: Some(2_500), + direction: PaymentDirection::Outbound, + }; + + let empty = TxProvenance::default(); + assert_eq!(empty.classify(&tx), None); + assert_eq!(empty.local_figures(), None); + + let provenance = TxProvenance::new( + Some(with_local_figures(tx.compute_txid(), figures.clone())), + parents([parent_outputs(&channel, ChannelOutputRole::Funding, [0])]), + ); + assert_eq!( + provenance.classify(&tx), + Some(TransactionType::CooperativeClose { + counterparty_node_id: channel.counterparty_node_id, + channel_id: channel.channel_id, + }) + ); + assert_eq!(provenance.local_figures(), Some(&figures)); + } } From fcdeeb1e7ad09315ab4def9600285b67e0729e0c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 2 Oct 2026 22:23:36 -0500 Subject: [PATCH 07/23] f - Test that a spent resolved output is named before a created funding output What a transaction spends settles its type before what it creates, so moving a channel's resolved output into a new funding output is the closed channel's sweep. Pin that order with a test of its own. This change was made with the help of an AI tool. Co-Authored-By: Claude Fable 5.1 --- src/wallet/provenance.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index 1c05d46332..210bbcdbfc 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -993,6 +993,22 @@ mod tests { ); } + #[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); From affb416c38eff5504f21b4df312b35d1169d7411 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 17:12:55 +0200 Subject: [PATCH 08/23] Classify on-chain payments during wallet sync Wallet sync recorded every on-chain transaction as unclassified, leaving what the transaction is to the classification the broadcast queue wrote separately. Name it from the recorded facts instead, so the record wallet sync creates already says what its transaction is. The facts live behind an async store while the record is built under the wallet lock, so each caller reads them first and passes them in, looking up only the transactions the inputs actually reference. A transaction the wallet sees before its channel reports it is named on a later chain tip, from the same scan that graduates confirmed payments. The retry only names a record that is still unnamed, read inside the payment store's critical section, so it can add a name but never replace one. Where a producer reported this node's share of an interactively negotiated funding, that share describes the payment better than the wallet's view does, which reads a shared funding input as wholly this node's. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 311 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 281 insertions(+), 30 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 0a122628e9..8ab4ea7a67 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -5,7 +5,7 @@ // 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; @@ -68,7 +68,7 @@ use crate::payment::{ }; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; -use crate::wallet::provenance::ChannelTxFacts; +use crate::wallet::provenance::{ChannelTxFacts, TxProvenance}; use crate::{ChainSource, Error}; pub(crate) enum OnchainSendAmount { @@ -247,6 +247,43 @@ impl Wallet { } } + /// Everything this node recorded about `tx` and about the transactions its inputs spend, as + /// classifying `tx` needs it. + /// + /// Facts that cannot be read are logged and left out, leaving the transaction less + /// classifiable rather than failing the caller: a transaction whose record says nothing about + /// what it is remains a correct record of the funds it moved, and is picked up again on a + /// later chain tip. + async fn tx_provenance(&self, txid: Txid, tx: &Transaction) -> TxProvenance { + let self_facts = self.channel_tx_facts(&txid).await; + let parents: HashSet = + tx.input.iter().map(|input| input.previous_output.txid).collect(); + let mut parent_facts = HashMap::new(); + for parent in parents { + if let Some(facts) = self.channel_tx_facts(&parent).await { + parent_facts.insert(parent, facts); + } + } + TxProvenance::new(self_facts, parent_facts) + } + + /// What this node's channels reported about the transaction `txid`, or nothing when they + /// reported nothing or the report cannot be read. + async fn channel_tx_facts(&self, txid: &Txid) -> Option { + match self.channel_tx_facts_store.get(txid).await { + Ok(facts) => facts, + Err(e) => { + log_error!( + self.logger, + "Failed to read what this node recorded about transaction {}: {}", + txid, + e, + ); + None + }, + } + } + pub(crate) fn get_full_scan_request(&self) -> FullScanRequest { self.inner.lock().expect("lock").start_full_scan().build() } @@ -426,6 +463,7 @@ impl Wallet { }, } + let provenance = self.tx_provenance(txid, &tx).await; let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -433,6 +471,7 @@ impl Wallet { txid, payment_id, &tx, + &provenance, payment_status, confirmation_status, ) @@ -462,8 +501,18 @@ impl Wallet { .await; let mut unconfirmed_outbound_txids: Vec = Vec::new(); + let mut unnamed_transactions: Vec<(PaymentId, Txid)> = Vec::new(); for payment in pending_payments { + // 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, .. } = + payment.details.kind + { + unnamed_transactions.push((payment.details.id, txid)); + } + match payment.details.kind { PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { height, .. }, @@ -527,6 +576,8 @@ impl Wallet { } } + self.name_recorded_transactions(unnamed_transactions).await?; + if !unconfirmed_outbound_txids.is_empty() { let txs_to_broadcast: Vec = { let locked_wallet = self.inner.lock().expect("lock"); @@ -592,6 +643,7 @@ impl Wallet { }, } + let provenance = self.tx_provenance(txid, &tx).await; let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -599,6 +651,7 @@ impl Wallet { txid, payment_id, &tx, + &provenance, PaymentStatus::Pending, ConfirmationStatus::Unconfirmed, ) @@ -697,6 +750,7 @@ impl Wallet { }, } + let provenance = self.tx_provenance(txid, &tx).await; let payment = { let locked_wallet = self.inner.lock().expect("lock"); self.create_payment_from_tx( @@ -704,6 +758,7 @@ impl Wallet { txid, payment_id, &tx, + &provenance, PaymentStatus::Pending, ConfirmationStatus::Unconfirmed, ) @@ -722,6 +777,53 @@ impl Wallet { Ok(()) } + /// Names the transactions of the given payments from the facts this node has recorded about + /// them, for records that do not say what their transaction is. + /// + /// This is how a record written before the producing channel reported its transaction picks + /// that report up: the facts are durable, so a report arriving after the record does reach it + /// on a later chain tip. A transaction the facts still cannot account for leaves its record + /// as it is, and so does a record that names its transaction already: whoever named it knew + /// more than the facts alone say. + async fn name_recorded_transactions( + &self, payments: Vec<(PaymentId, Txid)>, + ) -> Result<(), Error> { + for (payment_id, txid) in payments { + let tx = { + let locked_wallet = self.inner.lock().expect("lock"); + locked_wallet.get_tx(txid).map(|tx| tx.tx_node.tx.as_ref().clone()) + }; + let Some(tx) = tx else { + continue; + }; + let Some(tx_type) = self.tx_provenance(txid, &tx).await.classify(&tx) else { + continue; + }; + + let mut update = PaymentDetailsUpdate::new(payment_id); + update.tx_type = Some(Some(tx_type)); + // The write touches one record and leaves its pending entry alone. + let named = self + .payment_store + .mutate(&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; + } + 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(()) + } + /// 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's id is anchored to its first @@ -2161,27 +2263,28 @@ 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) } @@ -2551,19 +2654,29 @@ 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 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, + ) + }; let pending_payment_store = self.create_pending_payment_from_tx(new_payment.clone(), Vec::new()); - let change_set = locked_wallet.take_staged().unwrap_or_default(); - drop(locked_wallet); + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet after fee bump of {}: {}", txid, e); Error::PersistenceFailed @@ -3038,7 +3151,9 @@ mod tests { PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; + use crate::payment::store::Channel; use crate::types::{DynStore, DynStoreWrapper}; + use crate::wallet::provenance::{ChannelOutputRole, LocalFundingFigures}; use crate::{NodeMetrics, PersistedNodeMetrics}; const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; @@ -5527,4 +5642,140 @@ mod tests { ); assert_ne!(locked_wallet.next_unused_address(KeychainKind::Internal).index, 0); } + + /// 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 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; + + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channel = Channel { counterparty_node_id, channel_id: ChannelId([7u8; 32]) }; + let tx = wallet_paying_tx(&wallet, 4); + let txid = tx.compute_txid(); + insert_unconfirmed_tx(&wallet, tx.clone()); + + 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(); + + let payment = wallet + .payment_store + .get(&PaymentId(txid.to_byte_array())) + .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), + } + } + + #[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; + + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channel = Channel { counterparty_node_id, channel_id: ChannelId([7u8; 32]) }; + 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(); + + let payment_id = PaymentId(sweep_txid.to_byte_array()); + let unnamed = wallet + .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, + ); + + wallet + .record_channel_tx_facts(ChannelTxFacts::new(swept).with_outputs( + &channel, + None, + ChannelOutputRole::Spendable, + [0], + )) + .await + .unwrap(); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { old_tip: block_id(1), new_tip: block_id(2) }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let named = wallet.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), + } + } } From bb52262d9097dbf24466340c494003c9be7b7453 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 30 Sep 2026 13:31:46 -0500 Subject: [PATCH 09/23] Delete broadcast-time payment classification Wallet sync classifies on-chain payments from recorded provenance and owns the payment record. The second classifier, which ran on the broadcaster's queue and had to hold a broadcast back until its record was persisted, is now redundant: it wrote records sync would write anyway, under merge rules that existed only to keep the two writers from clobbering each other. Broadcasting no longer waits on persistence, so the queue needs neither a bound nor a handle on the wallet: it is a plain FIFO that the chain source drains and sends. The LDK-supplied transaction type is ignored on arrival. The classifier's helpers go with it: the per-candidate stake aggregation, the confirmed-figures guard on payment updates, and DataStore::mutate_async, which only its two-store write pair called. The wallet-view derivation of a transaction's figures stays for the test that checks a reported share outranks it. Tests deleted with their subjects: - zero_conf_splice_{out,in}_funding_rebroadcast_canary, together with the rust-lightning#4878 TODO they pin. They assert log lines emitted by the funding-over-interactive-funding guards, which are gone; with no tag to re-type, the upstream behaviour they watch is unobservable. - funding_reclassification_* and funding_classification_*, plus transaction_type_from_ldk_variants: their subjects are funding_reclassification_update, PaymentDetailsUpdate:: funding_reclassification, the confirmed-figures guard and the LdkTransactionType conversion. - funding_confirmation_waits_for_classification and funding_classification_waits_for_wallet_sync: race tests between classification's two-store write pair and a sync arm. There is no second writer left to race. - classify_funding's own tests, including its rebroadcast handling. - mutate_async_awaits_fallible_reads, with its subject. Tests re-expressed rather than deleted: the funding-record fixture the conflict and graduation tests build on now writes the payment record and its pending entry the way wallet sync does instead of calling the deleted classification path. The queue's arrival order and its wake-on-push get tests of their own. Co-Authored-By: HAL 9000 Co-Authored-By: Claude Fable 5.1 --- src/builder.rs | 2 - src/chain/mod.rs | 67 +- src/data_store.rs | 50 -- src/payment/mod.rs | 2 +- src/payment/pending_payment_store.rs | 75 -- src/payment/store.rs | 535 +------------- src/tx_broadcaster.rs | 188 +++-- src/wallet/mod.rs | 1013 ++------------------------ tests/integration_tests_rust.rs | 204 +----- 9 files changed, 215 insertions(+), 1921 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 797ffb3031..a6b8673b4f 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1977,8 +1977,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); 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/data_store.rs b/src/data_store.rs index bdd190621d..52d95c76f1 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -432,19 +432,6 @@ where Ok(Some(new_object)) } - /// Like [`Self::mutate`], but allows the transformation to await fallible reads. - /// - /// The mutation lock remains held while `f` runs. This is useful when the new state must be - /// decided from an async read of another store without letting a concurrent writer invalidate - /// that decision. Callers must keep cross-store lock ordering consistent to avoid deadlocks. - pub(crate) async fn mutate_async(&self, id: &SO::Id, f: F) -> Result, Error> - where - F: FnOnce(Option) -> Fut, - Fut: Future, Error>>, - { - self.mutate_with(id, f).await - } - /// Returns whether an object is stored under `id`. pub(crate) async fn contains_key(&self, id: &SO::Id) -> Result { let _guard = self.mutation_lock.read().await; @@ -1191,43 +1178,6 @@ mod tests { assert_eq!(Some(expected), data_store.get(&id).await.unwrap()); } - #[tokio::test] - async fn mutate_async_awaits_fallible_reads() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let logger = Arc::new(TestLogger::new()); - let id = TestObjectId { id: [42u8; 4] }; - let other_id = TestObjectId { id: [43u8; 4] }; - let existing_object = TestObject::new(id, [23u8; 3]); - let other_object = TestObject::new(other_id, [24u8; 3]); - let data_store: DataStore> = DataStore::new( - vec![existing_object], - KeepAllEntries, - TEST_PRIMARY_NAMESPACE.to_string(), - TEST_SECONDARY_NAMESPACE.to_string(), - Arc::clone(&store), - Arc::clone(&logger), - ); - let other_store: DataStore> = DataStore::new( - vec![other_object], - KeepAllEntries, - "other_datastore_test_primary".to_string(), - "other_datastore_test_secondary".to_string(), - store, - logger, - ); - - let result = data_store - .mutate_async(&id, |existing| async move { - let mut updated = existing.unwrap(); - updated.data = other_store.get(&other_id).await?.unwrap().data; - Ok(Some(updated)) - }) - .await; - let expected = TestObject::new(id, [24u8; 3]); - assert_eq!(Ok(Some(expected)), result); - assert_eq!(Some(expected), data_store.get(&id).await.unwrap()); - } - #[tokio::test] async fn mutate_runs_the_closure_without_the_cache_lock() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/payment/mod.rs b/src/payment/mod.rs index e1c60da79a..4a25f9059c 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -32,7 +32,7 @@ pub use forwarding::{ #[cfg(feature = "unified-payments")] pub(crate) use hrn::HRNResolver; pub use onchain::OnchainPayment; -pub(crate) use pending_payment_store::{FundingTxCandidate, PendingPaymentDetails}; +pub(crate) use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; pub use store::{ Channel, ConfirmationStatus, LSPS2Parameters, PageToken, PaymentDetails, PaymentDetailsPage, diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index e14f64c380..aebdecfc45 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -245,79 +245,4 @@ mod tests { "current txid must not remain in its own conflict list" ); } - - #[test] - fn funding_classification_pending_update_preserves_mirrored_confirmation() { - use bitcoin::BlockHash; - - use crate::payment::store::PaymentDetailsUpdate; - - let txid = test_txid(7); - let payment_id = PaymentId(txid.to_byte_array()); - - // 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, - ); - 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", - ); - - // 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(), - }; - 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", - ); - assert_eq!(merged.candidates, candidates); - assert_eq!(merged.details.amount_msat, Some(1_000)); - assert_eq!(merged.details.fee_paid_msat, Some(100)); - } } diff --git a/src/payment/store.rs b/src/payment/store.rs index 46cc57b87b..ec5bd38b8b 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,11 @@ 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. #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] pub enum TransactionType { @@ -498,58 +480,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 +497,10 @@ pub enum PaymentKind { status: ConfirmationStatus, /// The classification of this transaction, if known. /// - /// `None` for plain on-chain sends, and for records written by versions of LDK Node that - /// predate on-chain transaction classification. + /// `None` for plain on-chain sends, for records written by versions of LDK Node that + /// predate on-chain transaction classification, for transactions of channels opened + /// before this node began recording what its channels' transactions are, and for a + /// transaction whose channel's report of it could not be recorded. tx_type: Option, }, /// A [BOLT 11] payment. @@ -787,33 +719,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 +986,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/wallet/mod.rs b/src/wallet/mod.rs index 8ab4ea7a67..bcde922882 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -29,19 +29,17 @@ use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; use bitcoin::secp256k1::{All, PublicKey, Scalar, Secp256k1, SecretKey}; use bitcoin::transaction::Sequence; use bitcoin::{ - Address, Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, Transaction, TxOut, Txid, - WPubkeyHash, Weight, WitnessProgram, WitnessVersion, -}; -use lightning::chain::chaininterface::{ - FundingCandidate, TransactionType as LdkTransactionType, - INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, + Address, Amount, FeeRate, OutPoint, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, Weight, + WitnessProgram, WitnessVersion, }; +use lightning::chain::chaininterface::INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::{BlockLocator, ClaimId, Listen}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; use lightning::ln::script::ShutdownScript; +#[cfg(test)] use lightning::ln::types::ChannelId; use lightning::sign::{ ChangeDestinationSource, EntropySource, InMemorySigner, KeysManager, NodeSigner, OutputSpender, @@ -60,11 +58,14 @@ use crate::data_store::UpdatableObject; use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +#[cfg(test)] +use crate::payment::pending_payment_store::FundingTxCandidate; +#[cfg(test)] use crate::payment::pending_payment_store::PendingPaymentDetailsUpdate; use crate::payment::store::{ConfirmationStatus, PaymentDetailsUpdate}; use crate::payment::{ - FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, - PendingPaymentDetails, TransactionType, + PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, PendingPaymentDetails, + TransactionType, }; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; @@ -424,9 +425,9 @@ 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. + // 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 guard = self.funding_payment_update_lock.lock().await; let mut payment_id = self @@ -521,8 +522,8 @@ impl Wallet { let payment_id = payment.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 @@ -594,7 +595,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, @@ -606,7 +607,7 @@ impl Wallet { }, WalletEvent::TxUnconfirmed { txid, tx, .. } => { // See `TxConfirmed`: id resolution and the writes below must not interleave - // with classification. + // with the funding-record writers. let guard = self.funding_payment_update_lock.lock().await; let mut payment_id = self @@ -663,9 +664,9 @@ impl Wallet { }, 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. + // 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 _guard = self.funding_payment_update_lock.lock().await; let Some(payment_id) = self.find_payment_by_txid(txid).await? else { @@ -684,9 +685,8 @@ impl Wallet { 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. + // cycle, and sync itself records a transaction the first time it observes it, + // before anything can report it replaced. So we can safely fetch it here. let stored_payment = self.payment_store.get(&payment_id).await?; debug_assert!( stored_payment.is_some(), @@ -713,7 +713,7 @@ impl Wallet { }, WalletEvent::TxDropped { txid, tx } => { // See `TxConfirmed`: id resolution and the writes below must not interleave - // with classification. + // with the funding-record writers. let guard = self.funding_payment_update_lock.lock().await; let mut payment_id = self @@ -826,14 +826,14 @@ impl Wallet { /// 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's id is anchored to its first - /// candidate's txid, so a wallet event for that transaction falls back to this id whenever the - /// pending entry no longer maps it — which only happens once the negotiation settled and the - /// entry was removed. The generic event handling must then skip its write: merging a - /// wallet-view `Pending` payment into the settled record would resurrect it with figures no - /// classification derived. 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. + /// 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> { @@ -1603,7 +1603,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, .. } => { @@ -1902,331 +1902,22 @@ 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 { .. }), - .. - } - ) { - 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(()) - } - - /// 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"); - - let aggregate = aggregate_local_stakes(active); - let amount_msat = match aggregate.amount_msat { - Some(amt) => Some(amt), - None => { - log_trace!( - self.logger, - "Not recording interactive-funding broadcast {} as a payment: no local contribution", - txid, - ); - return Ok(()); - }, - }; - let fee_paid_msat = aggregate.fee_paid_msat; - let direction = aggregate.direction; - - // A contribution doesn't mean the tx touches our on-chain wallet: a splice-out to an - // external address sends channel funds to a third party, which BDK sees as zero wallet - // movement. Nothing for the on-chain payment store to record, so skip it. - let (wallet_amount_msat, _wallet_fee_msat, _wallet_direction) = - self.onchain_payment_fields(tx); - if wallet_amount_msat == Some(0) { - log_trace!( - self.logger, - "Not recording interactive-funding broadcast {} as a payment: no wallet-level activity", - txid, - ); - return Ok(()); - } - - // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable - // across RBF replacements. - let payment_id = PaymentId(first.txid.to_byte_array()); - - // Record every candidate's figures (`None` for any round we didn't contribute to, e.g. a - // counterparty-initiated splice our `splice_in` later joined via RBF) so the confirmed - // candidate's amount/fee can be applied on confirmation, even if it isn't the last one - // broadcast or one we contributed to. - let candidate_records: Vec = candidates - .iter() - .map(|candidate| { - let aggregate = aggregate_local_stakes(candidate); - FundingTxCandidate { - txid: candidate.txid, - amount_msat: aggregate.amount_msat, - fee_paid_msat: aggregate.fee_paid_msat, - } - }) - .collect(); - - let details = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(tx_type), - }, - amount_msat, - fee_paid_msat, - direction, - PaymentStatus::Pending, - ); - self.persist_funding_payment(details, candidate_records).await?; - log_debug!( - self.logger, - "Recorded interactive-funding broadcast {} ({} candidates, {} channels)", - txid, - candidates.len(), - active.channels.len(), - ); - Ok(()) - } - - /// Records a non-funding LDK broadcast as an on-chain payment, tagged with its transaction type. - /// Wallet sync later refreshes confirmation status while preserving the type. - async fn classify_regular_broadcast( - &self, tx: &Transaction, tx_type: TransactionType, - ) -> Result<(), Error> { - let txid = tx.compute_txid(); - let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); - - if amount_msat == Some(0) && fee_paid_msat == Some(0) { - log_trace!( - self.logger, - "Not recording classified broadcast {} as a payment: no wallet-level activity", - txid, - ); - return 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); - 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 wallet sync does once it observes its transaction: the + /// payment record and its pending-store entry, the latter carrying the candidate history. + /// 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 id = details.id; - let mut update = None; - self.payment_store - .mutate(&id, |existing| { - let reclassification = - funding_reclassification_update(details.clone(), &candidates, existing); - update = Some(reclassification.clone()); - match existing { - None => Some(details.clone()), - Some(current) => { - let mut updated = current.clone(); - updated.update(reclassification).then_some(updated) - }, - } - }) - .await?; - let update = update.expect("the mutate closure always runs"); - - // The pending index must exist exactly while the authoritative record is Pending: - // graduation and rebroadcast read it, and a graduated payment must not be re-indexed. - // Deciding by the post-write status rather than by whether the write inserted also - // repairs a missing index — a crash or failed write between the two stores leaves a - // Pending record with no entry, and a merge alone would never recreate it, leaving the - // payment unable to graduate and its txids unmapped. - // - // The status must be read inside the pending store's critical section. Graduation writes - // `Succeeded` before removing the entry, so a read there that still observes `Pending` - // is ordered before the removal, which then also deletes anything inserted here. A - // status read taken before this write goes stale when graduation lands in between, and - // would re-index the graduated payment. - let payment_store = Arc::clone(&self.payment_store); - self.pending_payment_store - .mutate_async(&id, move |existing| async move { - // The record was written above and removal serializes on the cross-store lock held - // here, so absence means the write failed out; fall back to the fresh details. - let recorded = payment_store.get(&id).await?.unwrap_or(details); - Ok(match existing { - // The inserted entry embeds the post-write record rather than the fresh - // details, so a confirmation wallet sync already recorded keeps driving - // graduation. - None if recorded.status == PaymentStatus::Pending => { - Some(PendingPaymentDetails::new(recorded, Vec::new(), candidates)) - }, - // The payment already advanced beyond Pending: the graduation path removed - // the entry and it must not be re-created. - None => None, - // The entry predates this classification — wallet sync recorded the - // transaction before it was classified (its arms and this write pair - // serialize on the cross-store lock, so nothing lands in between): merge - // only the classification into the existing entry. - Some(mut entry) => { - let pending_update = PendingPaymentDetailsUpdate { - id, - payment_update: Some(update), - conflicting_txids: None, - candidates, - }; - entry.update(pending_update).then_some(entry) - }, - }) - }) - .await?; + self.payment_store.insert_or_update(details.clone()).await?; + let entry = PendingPaymentDetails::new(details, Vec::new(), candidates); + self.pending_payment_store.insert_or_update(entry).await?; Ok(()) } /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. + #[cfg(test)] pub(crate) fn onchain_payment_fields( &self, tx: &Transaction, ) -> (Option, Option, PaymentDirection) { @@ -2301,7 +1992,7 @@ impl Wallet { /// would ever clean it up, since graduation only removes entries whose record is still live. pub(crate) async fn remove_payment(&self, payment_id: &PaymentId) -> Result<(), Error> { // Hold the cross-store lock so the two-store removal cannot interleave with a sync arm's - // or classification's resolve-then-write sequence. The pending entry goes first: a failure + // 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; @@ -2353,8 +2044,8 @@ impl Wallet { ) -> Result { // The caller's wallet-level lock keeps the candidate history stable while we await its // read. The funding-type gate, the candidate lookup, and the write then share the payment - // store's mutation lock: against a separate payment `get`, a classification merging in - // between would have its `tx_type` and contribution figures clobbered by this stale + // 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 = self.pending_payment_store.get(&payment_id).await?; let mut outcome = FundingStatusUpdate::NotFunding; @@ -2685,7 +2376,7 @@ impl Wallet { self.payment_store.insert_or_update(new_payment).await?; self.pending_payment_store.insert_or_update(pending_payment_store).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); @@ -2693,48 +2384,6 @@ impl Wallet { } } -struct LocalStakeAggregate { - amount_msat: Option, - fee_paid_msat: Option, - direction: PaymentDirection, -} - -/// Aggregates our net stake across the channels of a single [`FundingCandidate`] by summing each -/// channel's signed [`FundingContribution::net_value`]. Returns no amount if we contributed to none -/// of them. -fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { - let mut net_stake = SignedAmount::ZERO; - let mut fee = Amount::ZERO; - let mut have_contribution = false; - for channel in &candidate.channels { - if let Some(contribution) = channel.contribution.as_ref() { - have_contribution = true; - net_stake += contribution.net_value(); - // `estimated_fee` is our per-contributor share, so summing across channels is correct. - fee += contribution.estimated_fee(); - } - } - if !have_contribution { - return LocalStakeAggregate { - amount_msat: None, - fee_paid_msat: None, - direction: PaymentDirection::Outbound, - }; - } - // Direction is from our on-chain wallet's perspective: a positive net stake funds the channel - // (Outbound), while a negative one is a splice-out that returns funds to the wallet (Inbound). - let direction = if net_stake >= SignedAmount::ZERO { - PaymentDirection::Outbound - } else { - PaymentDirection::Inbound - }; - LocalStakeAggregate { - amount_msat: Some(net_stake.unsigned_abs().to_sat() * 1000), - fee_paid_msat: Some(fee.to_sat() * 1000), - direction, - } -} - /// The outcome of [`Wallet::apply_funding_status_update_locked`]. enum FundingStatusUpdate { /// The event's transaction belongs to the funding payment; its refreshed confirmation status @@ -3068,65 +2717,9 @@ 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 bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate}; use bdk_wallet::Wallet as BdkWallet; @@ -4102,81 +3695,6 @@ mod tests { ); } - /// 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>, - } - - 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(())), - } - } - } - - impl KVStore for NamespaceGatedStore { - fn read( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, - ) -> impl Future, io::Error>> + 'static + Send { - KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) - } - - fn write( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, - ) -> impl Future> + 'static + Send { - let inner = Arc::clone(&self.inner); - let gated = primary_namespace == self.gated_namespace; - let parked = Arc::clone(&self.parked); - let gate = Arc::clone(&self.gate); - let primary_namespace = primary_namespace.to_string(); - let secondary_namespace = secondary_namespace.to_string(); - let key = key.to_string(); - async move { - if gated { - parked.notify_one(); - let _guard = gate.read().await; - } - KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await - } - } - - fn remove( - &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, - ) -> impl Future> + 'static + Send { - KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) - } - - 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 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, - ) - } - } - fn dummy_tx() -> Transaction { Transaction { version: bitcoin::transaction::Version::TWO, @@ -4211,25 +3729,6 @@ mod tests { ) } - 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 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) { @@ -4290,111 +3789,6 @@ mod tests { } } - #[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)); - } - - #[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, - ); - - 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); - } - /// 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`. @@ -4656,7 +4050,7 @@ mod tests { }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet.record_funding_payment(details, candidates).await.unwrap(); // Sync saw the close double-spend the splice's funding transaction. wallet @@ -4726,7 +4120,7 @@ mod tests { }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet.record_funding_payment(details, candidates).await.unwrap(); wallet .pending_payment_store .update(PendingPaymentDetailsUpdate { @@ -4801,7 +4195,7 @@ mod tests { ]; let details = interactive_funding_details(payment_id, second_txid, Some(1_000_000), Some(600)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + 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. @@ -4876,7 +4270,7 @@ mod tests { ]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet.record_funding_payment(details, candidates).await.unwrap(); wallet .pending_payment_store .update(PendingPaymentDetailsUpdate { @@ -4926,7 +4320,7 @@ mod tests { }]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet.record_funding_payment(details, candidates).await.unwrap(); wallet .pending_payment_store .update(PendingPaymentDetailsUpdate { @@ -4983,7 +4377,7 @@ mod tests { ]; let details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet.record_funding_payment(details, candidates).await.unwrap(); wallet .pending_payment_store .update(PendingPaymentDetailsUpdate { @@ -5249,7 +4643,7 @@ mod tests { let mut details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); details.direction = PaymentDirection::Inbound; - wallet.persist_funding_payment(details, candidates).await.unwrap(); + wallet.record_funding_payment(details, candidates).await.unwrap(); wallet .pending_payment_store .update(PendingPaymentDetailsUpdate { @@ -5276,311 +4670,6 @@ mod tests { assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); } - /// A funding-typed broadcast that doesn't touch the on-chain wallet must not be recorded. - /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding - /// path, so a splice the interactive-funding classification deliberately declined — no local - /// contribution, or none of the moved funds are the wallet's — would otherwise come back as - /// a spurious zero-amount record that nothing ever confirms. - #[tokio::test] - async fn funding_broadcast_without_wallet_activity_is_not_recorded() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; - let tx_type = TransactionType::Funding { channels: vec![] }; - - // No inputs or outputs involve the wallet: nothing to record. - wallet.classify_funding(&dummy_tx(), &channels, tx_type.clone()).await.unwrap(); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); - assert!(wallet.pending_payment_store.list_filter(|_| true).await.is_empty()); - - // A computable fee is not wallet participation. The wallet can resolve a splice's shared - // input whenever the previous funding transaction touched it (e.g. it funded the original - // channel open), so it derives the splice's fee even when no wallet funds move. - let prev_funding_outpoint = OutPoint { txid: Txid::from_byte_array([8u8; 32]), vout: 0 }; - wallet.inner.lock().unwrap().insert_txout( - prev_funding_outpoint, - TxOut { value: Amount::from_sat(100_000), script_pubkey: ScriptBuf::new() }, - ); - let splice_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: vec![bitcoin::TxIn { - previous_output: prev_funding_outpoint, - ..Default::default() - }], - output: vec![TxOut { - value: Amount::from_sat(99_000), - script_pubkey: ScriptBuf::new(), - }], - }; - wallet.classify_funding(&splice_tx, &channels, tx_type.clone()).await.unwrap(); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); - - // Control: a funding transaction the wallet participates in is still recorded. - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let funded_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - }; - wallet.classify_funding(&funded_tx, &channels, tx_type).await.unwrap(); - let payments = wallet.payment_store.list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1); - assert_eq!(payments[0].id, PaymentId(funded_tx.compute_txid().to_byte_array())); - } - - /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding - /// path: same txid, but typed as a plain funding transaction with wallet-view figures and no - /// contribution metadata. The rebroadcast must not overwrite the contribution-derived - /// figures or the interactive-funding classification — neither while the record is - /// unconfirmed nor once it confirmed under that same txid, where updates naming the - /// confirmed txid may otherwise move figures. - #[tokio::test] - async fn funding_rebroadcast_keeps_interactive_funding_classification() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - // The rebroadcast passes the wallet-activity guard: a splice-in funds the new channel - // output partly from the wallet, so the wallet sees movement. - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }], - }; - let txid = tx.compute_txid(); - let payment_id = PaymentId(txid.to_byte_array()); - - let candidates = vec![FundingTxCandidate { - txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - }]; - let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500)); - wallet.persist_funding_payment(details, candidates).await.unwrap(); - - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channels = vec![(counterparty_node_id, ChannelId([7u8; 32]))]; - let tx_type = TransactionType::Funding { channels: vec![] }; - - async fn assert_unchanged(wallet: &Wallet, payment_id: PaymentId, confirmed: bool) { - let payments = wallet.payment_store.list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1, "the rebroadcast must not mint a second record"); - let payment = &payments[0]; - assert_eq!(payment.id, payment_id); - assert_eq!(payment.amount_msat, Some(1_000_000)); - assert_eq!(payment.fee_paid_msat, Some(500)); - match &payment.kind { - PaymentKind::Onchain { - status, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - .. - } => assert_eq!(matches!(status, ConfirmationStatus::Confirmed { .. }), confirmed), - kind => panic!("unexpected kind {:?}", kind), - } - } - - wallet.classify_funding(&tx, &channels, tx_type.clone()).await.unwrap(); - assert_unchanged(&wallet, payment_id, false).await; - - // Confirm the record, then replay the rebroadcast: a monitor-update completion can race - // wallet sync around confirmation. - let event = WalletEvent::TxConfirmed { - txid, - tx: Arc::new(tx.clone()), - block_time: confirmed_block_time(5), - old_block_time: None, - }; - wallet.update_payment_store(vec![event]).await.unwrap(); - wallet.classify_funding(&tx, &channels, tx_type).await.unwrap(); - assert_unchanged(&wallet, payment_id, true).await; - } - - /// 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; - - 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; - - // 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 } - }); - - // 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); - - drop(gate_guard); - classification.await.unwrap().unwrap(); - sync.await.unwrap().unwrap(); - - // Both writers converge on the classified record: the confirmation refreshes it in - // place with the confirmed candidate's figures rather than minting a second record - // keyed by the event txid. - let payment_keys = KVStore::list( - &*store, - PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, - ) - .await - .unwrap(); - assert_eq!(payment_keys.len(), 1, "the confirmation must not mint a duplicate record"); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.id, payment_id); - assert_eq!(payment.amount_msat, Some(2_000_000)); - assert_eq!(payment.fee_paid_msat, Some(999)); - match &payment.kind { - PaymentKind::Onchain { - txid, - status: ConfirmationStatus::Confirmed { .. }, - tx_type: Some(TransactionType::InteractiveFunding { .. }), - } => assert_eq!(*txid, txid2), - kind => panic!("unexpected kind {:?}", kind), - } - } - - /// 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() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - - 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 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; - - drop(inner_guard); - sync.await.unwrap().unwrap(); - classification.await.unwrap().unwrap(); - - // 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); - assert_eq!( - payment.amount_msat, - Some(1_000_000), - "wallet sync's fallback must not overwrite contribution figures" - ); - 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() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index d7efbb3620..af4b3027b9 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -18,9 +18,7 @@ use bitcoin::address::NetworkUnchecked; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; use bitcoin::{Address, Amount, ScriptBuf, Txid}; -use common::logging::{ - init_log_logger, validate_log_entry, CollectingLogWriter, MultiNodeLogger, TestLogWriter, -}; +use common::logging::{init_log_logger, validate_log_entry, MultiNodeLogger, TestLogWriter}; use common::{ bump_fee_and_broadcast, configure_chain_source, distribute_funds_unconfirmed, do_channel_full_cycle, expect_channel_pending_event, expect_channel_ready_event, @@ -55,12 +53,10 @@ 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 transaction `funding_txid` (a channel open or splice +/// round) as a payment carrying a `tx_type`. The payment is recorded when wallet sync first +/// observes the transaction, which a `sync_wallets` call can run too early for: the chain source +/// may lag the broadcast. async fn wait_for_classified_funding_payment(node: &Node, funding_txid: Txid) { let poll = async { loop { @@ -2566,196 +2562,6 @@ 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); - - // The skip leaves no trace in the payment stores — that is its point — so observe it through - // Node A's logs. `setup_two_nodes` wires file loggers, so build the pair manually with a - // collector, Node B trusting Node A for 0conf. - let logger_a = Arc::new(CollectingLogWriter::new()); - let mut config_a = random_config(); - config_a.log_writer = TestLogWriter::Custom(logger_a.clone()); - let node_a = setup_node(&chain_source, config_a); - - let 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. - 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; From 501924d3476237619c18d3073ab549eb398a21f4 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 2 Oct 2026 17:22:32 -0500 Subject: [PATCH 10/23] Type the claims a channel pays straight to the wallet The channel monitor's claims on a counterparty's commitment, resolving an HTLC or punishing a revoked commitment, pay this wallet's destination script directly rather than an output the sweeper takes charge of. Since classification stopped reading the broadcast tag, the event handler never learned which channel these transactions belonged to, so their payments came out untyped where the broadcast-time classification had typed them. LDK reports each such output as a static spendable output once the claim matures. The event handler now records the transaction creating it as the channel's payment straight to this wallet, and a transaction that creates such an output without spending a recorded funding output is typed as a claim. A cooperative close pays its shutdown output the same way: with its funding on record it is a close, as before, and without one it is left untyped rather than mistaken for a claim. The report arrives at the depth the claim's payment record graduates at, and the chain-tip pass names only records still pending. The event handler therefore names the transaction's record as it records a channel's report, instead of leaving a graduated record untyped for good. This change was made with the help of an AI tool. Co-Authored-By: Claude Fable 5.1 --- src/event.rs | 58 ++++++++++++++++---- src/wallet/mod.rs | 115 +++++++++++++++++++++++++++++++++++++-- src/wallet/provenance.rs | 75 ++++++++++++++++++++++++- 3 files changed, 232 insertions(+), 16 deletions(-) diff --git a/src/event.rs b/src/event.rs index 7182a16070..172b69a672 100644 --- a/src/event.rs +++ b/src/event.rs @@ -736,15 +736,24 @@ where Ok((payment_id, None)) } - /// Records what one of this node's channels reported about a transaction it produced. + /// 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; - if let Err(e) = self.wallet.record_channel_tx_facts(facts).await { - log_error!(self.logger, "Failed to record what channel transaction {} is: {}", txid, e); + match self.wallet.record_channel_tx_facts(facts).await { + Ok(()) => self.wallet.name_recorded_transaction(txid).await, + Err(e) => { + log_error!( + self.logger, + "Failed to record what channel transaction {} is: {}", + txid, + e + ); + }, } } @@ -1619,9 +1628,11 @@ where }, 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, as `sweepable_outpoints` excludes - // them from the record below. + // 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) @@ -1641,12 +1652,19 @@ where (counterparty_node_id, channel_id) { let channel = Channel { counterparty_node_id, channel_id }; - for facts in ChannelTxFacts::per_transaction( + 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; } } @@ -2471,8 +2489,9 @@ 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 so is the record: whatever spends such an output next is an -/// ordinary wallet transaction, not a sweep. +/// 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() @@ -2484,6 +2503,21 @@ fn sweepable_outpoints(outputs: &[SpendableOutputDescriptor]) -> Vec<(Txid, 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; @@ -2843,8 +2877,9 @@ mod tests { } /// A `StaticOutput` pays a script of this wallet's own, so the sweeper is told to leave it - /// alone and nothing about it is recorded: the transaction that spends it next is an - /// ordinary wallet transaction, not a sweep. The outputs the sweeper does take are recorded. + /// 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; @@ -2872,5 +2907,6 @@ mod tests { ]; assert_eq!(sweepable_outpoints(&outputs), vec![(outpoint(2).txid, 2)]); + assert_eq!(direct_outpoints(&outputs), vec![(outpoint(1).txid, 1)]); } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index bcde922882..29f653e8ea 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -248,6 +248,36 @@ impl Wallet { } } + /// 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 + ); + } + } + /// Everything this node recorded about `tx` and about the transactions its inputs spend, as /// classifying `tx` needs it. /// @@ -781,10 +811,11 @@ impl Wallet { /// them, for records that do not say what their transaction is. /// /// This is how a record written before the producing channel reported its transaction picks - /// that report up: the facts are durable, so a report arriving after the record does reach it - /// on a later chain tip. A transaction the facts still cannot account for leaves its record - /// as it is, and so does a record that names its transaction already: whoever named it knew - /// more than the facts alone say. + /// 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> { @@ -4808,6 +4839,82 @@ mod tests { } } + /// 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 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; + + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .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 payment_id = PaymentId(claim_txid.to_byte_array()); + let unnamed = wallet + .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, + ); + + 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; + + let named = wallet.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, + ); + } + #[tokio::test] async fn an_unnamed_transaction_is_named_once_its_facts_arrive() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index 210bbcdbfc..c211d5d1ee 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -42,6 +42,9 @@ pub(crate) enum ChannelOutputRole { 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, @@ -49,6 +52,7 @@ impl_writeable_tlv_based_enum!(ChannelOutputRole, (2, Anchor) => {}, (4, Htlc) => {}, (6, Spendable) => {}, + (8, Direct) => {}, ); /// One output of a transaction that a channel controls, and the channel controlling it. @@ -104,7 +108,8 @@ impl_writeable_tlv_based!(LocalFundingFigures, { pub(crate) struct ChannelTxFacts { /// The transaction these facts are about. pub txid: Txid, - /// Outputs of this transaction controlled by a channel rather than by the wallet. + /// 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, @@ -404,6 +409,21 @@ pub(crate) fn classify( 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 } @@ -1022,6 +1042,59 @@ mod tests { 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); From 49e8f5e3d78e68971487e68977da713332e47efd Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 2 Oct 2026 22:47:02 -0500 Subject: [PATCH 11/23] f - Test the naming of a claim whose record graduated first LDK reports the output a claim paid this wallet at the very depth the claim's payment record graduates at, and polling Bitcoin Core hands each block to the wallet before the channel monitor. The record therefore graduates unnamed and only the naming the event handler runs after recording the report gives the claim its type. Cover that route through the event itself: a held HTLC claimed on chain after the counterparty force-closes. This change was made with the help of an AI tool. Co-Authored-By: Claude Fable 5.1 --- tests/integration_tests_rust.rs | 130 +++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index af4b3027b9..92062d62d1 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -28,8 +28,8 @@ use common::{ 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_node_with_store, setup_two_nodes, - splice_in_with_all, wait_for_block, wait_for_tx, InMemoryStore, NodePaymentExt, - TestChainSource, TestConfig, TestNode, TestStoreType, TestSyncStore, + 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; @@ -733,6 +733,132 @@ async fn a_funding_is_withheld_until_its_facts_are_recorded() { 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(); From 040ff7bf87605cfec8f913b22fe4bae6a839d2d5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 20:06:24 +0200 Subject: [PATCH 12/23] Gate on-chain RBF positively on a wallet-only tx The RBF gate refused a payment whose recorded type named a funding transaction and allowed everything else, so a record carrying no type at all -- one wallet sync wrote before it could name the transaction, or one from a node version that predates classification -- passed as an ordinary payment and could have its replacement broadcast behind LDK's back. Decide it the other way round: allow the bump only when the recorded facts make nothing of the transaction and every input is an output this wallet owns and can re-sign. A transaction that spends a funding, anchor, HTLC or spendable output is refused by its inputs alone, since the wallet holds none of those. A v1 funding transaction this node built from its own coins spends only wallet outputs, so for it the refusal rests on the Funding fact that FundingGenerationReady records before the transaction is released, an event that is replayed if the write fails. A fact that cannot be read is no answer about the transaction: the bump is refused with the error rather than allowed for want of a reason to refuse it. The confirmation, direction and payment-kind checks are unchanged. This change was made with the help of an AI tool. Co-Authored-By: HAL 9000 Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 248 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 206 insertions(+), 42 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 29f653e8ea..7c1edef3c0 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -284,7 +284,8 @@ impl Wallet { /// 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. + /// 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 = @@ -298,6 +299,23 @@ impl Wallet { 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 { @@ -2156,20 +2174,66 @@ impl Wallet { 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 funding payment {} via bump_fee_rbf; use bump_channel_funding_fee instead", + "Cannot RBF payment {} via bump_fee_rbf: {} is {:?}; a pending splice is fee-bumped with bump_channel_funding_fee", payment_id, + txid, + tx_type, + ); + return Err(Error::InvalidPaymentId); + } + if !all_inputs_owned { + log_error!( + self.logger, + "Cannot RBF payment {}: transaction {} spends inputs this wallet does not own", + payment_id, + txid, ); return Err(Error::InvalidPaymentId); } @@ -2197,36 +2261,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 @@ -2848,11 +2885,17 @@ mod tests { } } - /// An in-memory store whose writes can be made to fail on demand. + /// An in-memory store whose writes and reads can be made to fail on demand, all of them or + /// those of one primary namespace. #[derive(Clone)] struct FailSwitchStore { inner: Arc, fail_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 { @@ -2860,15 +2903,34 @@ mod tests { Self { inner: Arc::new(InMemoryStore::new()), fail_writes: Arc::new(AtomicBool::new(false)), + 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 { - KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + 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( @@ -2876,11 +2938,13 @@ mod tests { ) -> impl Future> + 'static + Send { let inner = Arc::clone(&self.inner); let fail_writes = Arc::clone(&self.fail_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 fail_writes.load(Ordering::Acquire) { + if may_fail && 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 @@ -4915,6 +4979,106 @@ mod tests { ); } + /// 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); + + 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(); + + let payment_id = PaymentId(txid.to_byte_array()); + let recorded = wallet.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) + } + + /// 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 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; + + 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 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; + let (_spend, payment_id) = replaceable_spend(&wallet, 0x23, None).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); + } + #[tokio::test] async fn an_unnamed_transaction_is_named_once_its_facts_arrive() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); From 07759f36dfabb7a54f6185aa53bb430653469f70 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 30 Sep 2026 14:32:26 -0500 Subject: [PATCH 13/23] Record a signed splice round before its signatures leave Wallet sync can observe a splice transaction before this node has recorded anything about it: once tx_signatures are exchanged, the counterparty may broadcast first, and sync then files the round as a plain on-chain payment of its own, with the wallet's view of a funding output both parties own as its figures. Record the round while handling FundingTransactionReadyForSigning, before funding_transaction_signed hands our signatures to LDK. The counterparty cannot broadcast without them, so the record precedes anything wallet sync can observe. What is recorded is what the round is: an interactive funding of its channels, this node's share of it and the funding payment it belongs to, all under the round's transaction id, plus the round's place in the channel's splice history. No payment record is written: wallet sync creates one when it observes the transaction and resolves its identity through the recorded facts, so sync stays the only creator of funding payment records. A pending-store entry therefore tracks a splice before any payment record exists, and names the channels of the funding it tracks, since with no record nothing else says which channel's splice a signed round belongs to. An entry left tracking nothing is removed. If the record cannot be written, the event is replayed rather than proceeding unrecorded: LDK re-offers it in-session and regenerates it across restarts while the transaction remains unsigned. Both writes are idempotent, and a replay adopts the figures already on record rather than deriving a second answer the facts would refuse. Recording before the round is negotiated means a recorded round can still be abandoned: the counterparty may abort after we sign but before its commitment_signed, or the channel may close, and until LDK has released our signatures nothing can ever broadcast the transaction. Left in place, the round would sit in the channel's record forever. The signed round is therefore marked as awaiting broadcast until LDK reports the splice negotiated, which it does once our tx_signatures were ready to send, normally as it hands the fully signed round to the broadcaster: from then on the counterparty may hold our signatures and broadcast on its own. If the mark cannot be cleared, that event is replayed as well. A marked round is dropped once LDK no longer holds it. LDK's view is consulted when it reports the failed negotiation of a channel it still lists, when the channel closes, and at startup, before any background task runs: 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 stops before the next one. The channel manager forgets a closed channel's pending rounds, but its monitor keeps watching every round the counterparty's commitment_signed reached, and our signatures cannot have left the node before that message: the counterparty may hold the fully signed transaction and broadcast it, as when this node's contributed input value is the smaller and its tx_signatures therefore go first, so such a round is kept for wallet sync to resolve should it confirm, while a marked round the monitor never watched is dropped, as nothing can broadcast it. A round already missing from the channel's history when the signing event is handled is not recorded at all. A round this node contributed nothing to is not recorded here and is left to wallet sync, as before. An entry written before this change does not decode under the new layout, and a stale entry fails node startup. The pending store has not been in a release, so no released node holds one. Developed with assistance from Claude Code. Co-Authored-By: Elias Rohrer Co-Authored-By: Claude Fable 5.1 --- src/event.rs | 157 +- src/lib.rs | 17 + src/payment/mod.rs | 2 +- src/payment/pending_payment_store.rs | 206 ++- src/wallet/mod.rs | 2090 ++++++++++++++++++++++++-- src/wallet/provenance.rs | 7 + tests/common/logging.rs | 5 + tests/integration_tests_rust.rs | 301 +++- 8 files changed, 2596 insertions(+), 189 deletions(-) diff --git a/src/event.rs b/src/event.rs index 172b69a672..c64259b5a4 100644 --- a/src/event.rs +++ b/src/event.rs @@ -15,6 +15,7 @@ use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; 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"))] @@ -59,9 +60,11 @@ 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}; +use crate::wallet::{closed_channel_held_rounds, funding_candidates, held_splice_rounds}; use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, UserChannelId, @@ -562,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, @@ -586,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, @@ -736,6 +742,31 @@ 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. /// @@ -2068,10 +2099,41 @@ 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 round the counterparty committed to, and our signatures may have left the + // node for such a round, so it is kept (see `closed_channel_held_rounds`). 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.drop_abandoned_splice_rounds(channel_id, &held_rounds).await + { + log_error!( + self.logger, + "Failed to drop the splice rounds of closed channel {} from its funding \ + payment: {}", + 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"); @@ -2387,6 +2449,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, @@ -2401,9 +2483,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, + ); }, } }, @@ -2439,6 +2530,26 @@ where }); 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), @@ -2467,6 +2578,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), diff --git a/src/lib.rs b/src/lib.rs index 1c88de2f7d..51cc30e590 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -369,6 +369,22 @@ impl Node { ) })?; + // 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 +704,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/mod.rs b/src/payment/mod.rs index 4a25f9059c..e1c60da79a 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -32,7 +32,7 @@ pub use forwarding::{ #[cfg(feature = "unified-payments")] pub(crate) use hrn::HRNResolver; pub use onchain::OnchainPayment; -pub(crate) use pending_payment_store::PendingPaymentDetails; +pub(crate) use pending_payment_store::{FundingTxCandidate, PendingPaymentDetails}; pub use spontaneous::SpontaneousPayment; pub use store::{ Channel, ConfirmationStatus, LSPS2Parameters, PageToken, PaymentDetails, PaymentDetailsPage, diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index aebdecfc45..e55eed0262 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -10,7 +10,7 @@ use lightning::impl_writeable_tlv_based; use lightning::ln::channelmanager::PaymentId; 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 +28,123 @@ 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 +/// A pending payment tracked by LDK Node, keyed by [`PaymentId`]. +/// +/// Each part of an entry is written by a different subsystem and is present on its own schedule, +/// so all of them are optional. Signing a round of an interactive funding adds the round to +/// `candidates` and 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. 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 struct PendingPaymentDetails { - /// The full payment details - pub details: PaymentDetails, - /// Transaction IDs that have replaced or conflict with this payment. +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, } impl PendingPaymentDetails { pub(crate) fn new( details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, ) -> Self { - Self { details, conflicting_txids, candidates } + Self { + id: details.id, + details: Some(details), + conflicting_txids, + funding_channels: Vec::new(), + candidates, + } + } + + /// 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, + ) -> Self { + Self { id, details: None, conflicting_txids: Vec::new(), funding_channels, candidates } + } + + /// 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 } /// 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.candidates.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), }); #[derive(Clone, Debug, PartialEq, Eq)] @@ -80,7 +159,7 @@ impl StorableObject for PendingPaymentDetails { type Id = PaymentId; fn id(&self) -> Self::Id { - self.details.id + self.id } } @@ -90,9 +169,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,14 +185,19 @@ 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; @@ -131,18 +219,63 @@ 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. + None => Self { + id: value.id, + payment_update: None, + conflicting_txids: None, + candidates: value.candidates.clone(), + }, + Some(details) => { + let conflicting_txids = if value.conflicting_txids.is_empty() { + None + } else { + Some(value.conflicting_txids.clone()) + }; + Self { + id: details.id, + payment_update: Some(details.to_update()), + conflicting_txids, + candidates: value.candidates.clone(), + } + }, + } + } +} + +/// 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 { + use lightning::util::ser::Writeable; + let mut records = vec![1, 8]; // (1, estimated_fee) + records.extend_from_slice(&estimated_fee_sat.to_be_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) + records.push(u8::try_from(output_bytes.len()).expect("test outputs must stay small")); + records.extend_from_slice(&output_bytes); } + records.extend_from_slice(&[9, 8]); // (9, feerate) + records.extend_from_slice(&feerate.to_be_bytes()); + records.extend_from_slice(&[11, 8]); // (11, max_feerate) + records.extend_from_slice(&feerate.to_be_bytes()); + records.extend_from_slice(&[13, 1, 1]); // (13, is_splice: true) + // BigSize length prefix over the TLV records above; single-byte as long as they stay short. + let mut tlv_bytes = vec![u8::try_from(records.len()).expect("test TLV stream must stay small")]; + tlv_bytes.extend(records); + lightning::util::ser::Readable::read(&mut &tlv_bytes[..]) + .expect("hand-built TLV stream must decode") } #[cfg(test)] @@ -163,16 +296,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,7 +380,7 @@ 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" ); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 7c1edef3c0..6d08636578 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -29,17 +29,20 @@ use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; use bitcoin::secp256k1::{All, PublicKey, Scalar, Secp256k1, SecretKey}; use bitcoin::transaction::Sequence; use bitcoin::{ - Address, Amount, FeeRate, OutPoint, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, Weight, - WitnessProgram, WitnessVersion, + Address, Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, Transaction, TxOut, Txid, + WPubkeyHash, Weight, WitnessProgram, WitnessVersion, +}; +use lightning::chain::chaininterface::{ + ChannelFunding, FundingCandidate, FundingPurpose, INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; -use lightning::chain::chaininterface::INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::chain::{BlockLocator, ClaimId, Listen}; +use lightning::ln::channel_state::{SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; use lightning::ln::script::ShutdownScript; -#[cfg(test)] use lightning::ln::types::ChannelId; use lightning::sign::{ ChangeDestinationSource, EntropySource, InMemorySigner, KeysManager, NodeSigner, OutputSpender, @@ -53,23 +56,21 @@ use lightning_invoice::RawBolt11Invoice; use persist::KVStoreWalletPersister; use crate::config::{Config, ADDRESS_POOL_SIZE}; -use crate::data_store::UpdatableObject; #[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}; -#[cfg(test)] -use crate::payment::pending_payment_store::FundingTxCandidate; +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::{ - PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, PendingPaymentDetails, - TransactionType, + FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, + PendingPaymentDetails, TransactionType, }; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; -use crate::wallet::provenance::{ChannelTxFacts, TxProvenance}; +use crate::wallet::provenance::{ChannelTxFacts, LocalFundingFigures, TxProvenance}; use crate::{ChainSource, Error}; pub(crate) enum OnchainSendAmount { @@ -529,23 +530,33 @@ impl Wallet { self.payment_store.insert_or_update(payment.clone()).await?; if payment_status == PaymentStatus::Pending { - let pending_payment = - self.create_pending_payment_from_tx(payment, Vec::new()); - - self.pending_payment_store.insert_or_update(pending_payment).await?; + self.upsert_pending_payment(payment, Vec::new()).await?; + } 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. + self.pending_payment_store.remove(&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 { .. }) + .list_filter(|p| match p.details() { + // An entry of signed rounds whose transaction nothing has observed + // yet carries no payment 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; @@ -553,21 +564,24 @@ impl Wallet { let mut unnamed_transactions: Vec<(PaymentId, Txid)> = Vec::new(); for payment in pending_payments { + // 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, .. } = - payment.details.kind - { - unnamed_transactions.push((payment.details.id, txid)); + if let PaymentKind::Onchain { txid, tx_type: None, .. } = details.kind { + unnamed_transactions.push((details.id, txid)); } - match payment.details.kind { + 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 write landing since then must not have its @@ -617,7 +631,7 @@ impl Wallet { { continue; } - if payment.details.direction == PaymentDirection::Outbound { + if details.direction == PaymentDirection::Outbound { unconfirmed_outbound_txids.push(txid); } }, @@ -705,10 +719,8 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ) }; - let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.payment_store.insert_or_update(payment).await?; - self.pending_payment_store.insert_or_update(pending_payment).await?; + self.payment_store.insert_or_update(payment.clone()).await?; + self.upsert_pending_payment(payment, Vec::new()).await?; }, WalletEvent::TxReplaced { txid, conflicts, .. } => { // See `TxConfirmed`: id resolution and the writes below must not interleave @@ -754,10 +766,7 @@ impl Wallet { continue; } - let pending_payment_details = - self.create_pending_payment_from_tx(payment, conflict_txids.clone()); - - self.pending_payment_store.insert_or_update(pending_payment_details).await?; + self.upsert_pending_payment(payment, conflict_txids).await?; }, WalletEvent::TxDropped { txid, tx } => { // See `TxConfirmed`: id resolution and the writes below must not interleave @@ -811,10 +820,8 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ) }; - let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.payment_store.insert_or_update(payment).await?; - self.pending_payment_store.insert_or_update(pending_payment).await?; + self.payment_store.insert_or_update(payment.clone()).await?; + self.upsert_pending_payment(payment, Vec::new()).await?; }, _ => { continue; @@ -924,33 +931,40 @@ impl Wallet { async fn fail_funding_payment_lost_to_conflict( &self, payment: &PendingPaymentDetails, tip_height: u32, ) -> Result { - match payment.details.kind { - PaymentKind::Onchain { - status: ConfirmationStatus::Unconfirmed, - tx_type: - Some( - TransactionType::Funding { .. } - | TransactionType::InteractiveFunding { .. }, - ), - .. - } => {}, - _ => return Ok(false), - } - if payment.conflicting_txids.is_empty() { + let payment_id = match payment.details() { + Some(details) => match details.kind { + PaymentKind::Onchain { + status: ConfirmationStatus::Unconfirmed, + tx_type: + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + .. + } => details.id, + _ => return Ok(false), + }, + None => return Ok(false), + }; + if payment.conflicting_txids().is_empty() { return Ok(false); } - // Serialize with classification, whose retries extend the candidate history: the + // 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 _guard = self.funding_payment_update_lock.lock().await; - // Re-read the entry under the lock; the listing snapshot may predate a classification. - let entry = match self.pending_payment_store.get(&payment.details.id).await? { + // Re-read the entry under the lock; the listing snapshot may predate a record write. + let entry = match self.pending_payment_store.get(&payment_id).await? { Some(entry) => entry, None => return Ok(false), }; - let record_txid = match entry.details.kind { + 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, @@ -963,8 +977,7 @@ impl Wallet { _ => return Ok(false), }; - let foreign_conflicts: Vec = entry - .conflicting_txids + let foreign_conflicts: Vec = conflicting_txids .iter() .copied() .filter(|conflict| *conflict != record_txid && entry.candidate(*conflict).is_none()) @@ -994,7 +1007,7 @@ impl Wallet { None => true, }; cannot_confirm(record_txid) - && entry.candidates.iter().all(|c| cannot_confirm(c.txid)) + && candidates.iter().all(|c| cannot_confirm(c.txid)) && foreign_conflicts.iter().any(|conflict| confirmed_to_depth(*conflict)) }; if !lost { @@ -1004,7 +1017,7 @@ impl Wallet { // As with graduation, decide from the live record and write only the status. A record // already `Failed` — a prior pass whose entry removal below was lost to a crash — still // matches, no-ops the update, and gets its lingering entry removed. - let payment_id = entry.details.id; + let payment_id = entry.id(); let mut failed = false; self.payment_store .mutate(&payment_id, |existing| { @@ -1951,6 +1964,482 @@ impl Wallet { Ok(tx) } + /// Builds this node's share of the `active` round of an interactive funding whose negotiated + /// history is `candidates`, and the per-candidate figures of that history, for recording the + /// round under `payment_id`. Returns `None` when there is nothing to record: no local + /// contribution to the round, or no wallet-level activity. + fn interactive_funding_figures( + &self, payment_id: PaymentId, candidates: &[FundingCandidate], active: &FundingCandidate, + tx: &Transaction, + ) -> Option<(LocalFundingFigures, Vec)> { + let txid = active.txid; + + let aggregate = aggregate_local_stakes(active); + let amount_msat = match aggregate.amount_msat { + Some(amt) => Some(amt), + None => { + log_trace!( + self.logger, + "Not recording signed funding {} as a payment: no local contribution", + txid, + ); + return None; + }, + }; + + // A contribution doesn't mean the tx touches our on-chain wallet: a splice-out to an + // external address sends channel funds to a third party, which BDK sees as zero wallet + // movement. Nothing for the on-chain payment store to record, so skip it. + let (wallet_amount_msat, _wallet_fee_msat, _wallet_direction) = + self.onchain_payment_fields(tx); + if wallet_amount_msat == Some(0) { + log_trace!( + self.logger, + "Not recording signed funding {} as a payment: no wallet-level activity", + txid, + ); + return None; + } + + // Record every candidate's figures (`None` for any round we didn't contribute to, e.g. a + // counterparty-initiated splice our `splice_in` later joined via RBF) so the confirmed + // candidate's amount/fee can be applied on confirmation, even if it isn't the last one + // broadcast or one we contributed to. + let candidate_records: Vec = candidates + .iter() + .map(|candidate| { + let aggregate = aggregate_local_stakes(candidate); + FundingTxCandidate { + txid: candidate.txid, + amount_msat: aggregate.amount_msat, + fee_paid_msat: aggregate.fee_paid_msat, + awaiting_broadcast: false, + } + }) + .collect(); + + let figures = LocalFundingFigures { + funding_payment_id: payment_id, + amount_msat, + fee_paid_msat: aggregate.fee_paid_msat, + direction: aggregate.direction, + }; + Some((figures, candidate_records)) + } + + /// Records what this node knows about a splice round it is about to sign, before + /// [`ChannelManager::funding_transaction_signed`] releases our signatures: without them the + /// counterparty cannot broadcast, so the record precedes anything wallet sync could observe. + /// + /// Two things are written. The round's transaction gets a provenance fact naming it an + /// interactive funding of the round's channels and carrying this node's share of it along with + /// the funding payment the round belongs to, so that whoever first observes the transaction — + /// wallet sync, whichever party broadcast it — records it under that payment rather than as a + /// payment of its own, with this node's figures rather than the wallet's view of a funding + /// output both parties own. The pending store gets the round's place in the channel's splice + /// history, marked as awaiting broadcast until LDK reports the splice negotiated and + /// [`Self::record_broadcast_splice_round`] clears the mark: only such a round can be abandoned + /// without a trace, and [`Self::drop_abandoned_splice_rounds`] takes it back once LDK no longer + /// holds it. No payment record is written here — wallet sync creates it when it observes the + /// transaction, and resolves its identity through the fact. + /// + /// `candidates` is the channel's pending splice history as [`funding_candidates`] lists it from + /// the channel's [`SpliceDetails`], so the history is written in full, under the first + /// candidate's txid as id. + /// + /// 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(); + + // The reads and the writes below must share one lock acquisition, as in every + // funding-record write: read outside it, the record could change under us before the + // write. + let _guard = self.funding_payment_update_lock.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); + // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable + // across RBF replacements. + let payment_id = match &recorded_figures { + Some(figures) => figures.funding_payment_id, + None => PaymentId(candidates.first().map_or(txid, |first| first.txid).to_byte_array()), + }; + 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 = self.pending_payment_store.get(&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?; + + self.pending_payment_store + .mutate(&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()) + }); + if entry.funding_channels.is_empty() && !funding_channels.is_empty() { + entry.funding_channels = funding_channels.clone(); + changed = true; + } + if entry.candidates != recorded { + entry.candidates = recorded.clone(); + changed = true; + } + changed.then_some(entry) + }) + .await?; + log_debug!( + self.logger, + "Recorded signed splice funding {} ({} candidates)", + txid, + candidates.len(), + ); + Ok(()) + } + + /// 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. Nothing is + /// written for a round no funding payment of `channel_id` tracks (no local contribution, or no + /// wallet-level activity) or one already marked (a replayed event). + pub(crate) async fn record_broadcast_splice_round( + &self, channel_id: ChannelId, txid: Txid, + ) -> Result<(), Error> { + // Serialize with the other funding-record writers, which all hold this lock from their + // reads through their last write. + let _guard = self.funding_payment_update_lock.lock().await; + + let entries = self + .pending_payment_store + .list_filter(|entry| { + tracks_channel(entry, channel_id) + && entry.candidate(txid).is_some_and(|candidate| candidate.awaiting_broadcast) + }) + .await; + for entry in entries { + let payment_id = entry.id(); + self.pending_payment_store + .mutate(&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?; + log_debug!( + self.logger, + "Marked splice round {} of channel {} as broadcast in funding payment {}", + txid, + channel_id, + payment_id, + ); + } + 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. 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 _guard = self.funding_payment_update_lock.lock().await; + + let entries = self + .pending_payment_store + .list_filter(|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) + && 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; + self.pending_payment_store + .mutate(&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.funding_channels.clear(); + } + emptied = entry.is_empty(); + (!emptied).then_some(entry) + }) + .await?; + if emptied { + self.pending_payment_store.remove(&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. + self.payment_store + .mutate(&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 = self.payment_store.get(&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. + self.payment_store.remove(&payment_id).await?; + self.pending_payment_store.remove(&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, + ); + } + self.pending_payment_store + .mutate(&payment_id, |existing| { + let mut entry = existing?.clone(); + entry.candidates.retain(|c| !abandoned_txids.contains(&c.txid)); + if let Some(mirrored) = mirrored { + entry.details = Some(mirrored); + } + Some(entry) + }) + .await?; + log_debug!( + self.logger, + "Dropped abandoned splice round(s) {:?} from funding payment {}", + abandoned_txids, + payment_id, + ); + } + Ok(()) + } + + /// Drops the splice rounds recorded when signing that LDK does not hold once the node restarts. + /// LDK reports the loss of a negotiation its last channel manager write carried mid-way, but a + /// round committed, negotiated and signed since that write is gone without a report if the + /// node stopped before the next one. `held_rounds` yields the rounds LDK holds for a channel, + /// as [`held_splice_rounds`] lists them, or `None` for a channel LDK no longer lists, which is + /// left to its `ChannelClosed` event: LDK queues one for every channel it drops, and handling + /// it takes back what neither the closed channel's funding nor its monitor holds. Runs before + /// events are processed again, so no round is recorded while LDK's view is being read. + pub(crate) async fn drop_splice_rounds_lost_across_restart( + &self, held_rounds: impl Fn(ChannelId) -> Option>, + ) -> Result<(), Error> { + let channels: HashSet = self + .pending_payment_store + .list_filter(|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(()) + } + /// Records a funding payment the way wallet sync does once it observes its transaction: the /// payment record and its pending-store entry, the latter carrying the candidate history. /// Composes that sequence for tests that need a recorded funding payment to act on. @@ -1966,7 +2455,6 @@ impl Wallet { } /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. - #[cfg(test)] pub(crate) fn onchain_payment_fields( &self, tx: &Transaction, ) -> (Option, Option, PaymentDirection) { @@ -2029,10 +2517,32 @@ impl Wallet { PaymentDetails::new(payment_id, kind, amount_msat, fee_paid_msat, direction, payment_status) } - fn create_pending_payment_from_tx( + /// Inserts or refreshes the pending-store entry tracking `payment` toward graduation, + /// atomically with reading the entry's current state. + async fn upsert_pending_payment( &self, payment: PaymentDetails, conflicting_txids: Vec, - ) -> PendingPaymentDetails { - PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()) + ) -> Result<(), Error> { + let id = payment.id; + self.pending_payment_store + .mutate(&id, |existing| match existing { + None => Some(PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())), + // Promote an entry that has no record yet: wallet sync saw the splice + // transaction before this node recorded a payment for it. The entry keeps the + // rounds signed under it, and gains the record. + Some(entry) if entry.details().is_none() => { + let mut entry = entry.clone(); + entry.details = Some(payment); + entry.conflicting_txids = conflicting_txids; + Some(entry) + }, + Some(tracked) => { + let mut tracked = tracked.clone(); + let fresh = PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()); + tracked.update(fresh.to_update()).then_some(tracked) + }, + }) + .await?; + Ok(()) } /// Removes the payment with the given id from the payment store, along with any pending-store @@ -2049,7 +2559,19 @@ impl Wallet { self.payment_store.remove(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. 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? { return Ok(Some(direct_payment_id)); @@ -2058,8 +2580,9 @@ impl Wallet { if let Some(replaced_details) = self .pending_payment_store .list_filter(|p| { - matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid) - || p.conflicting_txids.contains(&target_txid) + p.details().is_some_and( + |d| matches!(d.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), + ) || p.conflicting_txids().contains(&target_txid) // A middle RBF round is not the record's current txid and may never have // received a `TxReplaced` event of its own, so map any of its candidate // txids (an earlier RBF round may confirm) back to the record. @@ -2068,7 +2591,7 @@ impl Wallet { .await .first() { - return Ok(Some(replaced_details.details.id)); + return Ok(Some(replaced_details.id())); } Ok(None) @@ -2159,8 +2682,7 @@ impl Wallet { // the same dual-write the default `TxConfirmed` path performs; an empty conflicting-txids // list leaves any stored conflicts intact (the update treats absent as "unchanged"). if payment.status == PaymentStatus::Pending { - let pending = self.create_pending_payment_from_tx(payment, Vec::new()); - self.pending_payment_store.insert_or_update(pending).await?; + self.upsert_pending_payment(payment, Vec::new()).await?; } Ok(FundingStatusUpdate::Applied) } @@ -2433,16 +2955,13 @@ impl Wallet { ) }; - let pending_payment_store = - self.create_pending_payment_from_tx(new_payment.clone(), Vec::new()); - 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?; + self.payment_store.insert_or_update(new_payment.clone()).await?; + self.upsert_pending_payment(new_payment, Vec::new()).await?; self.broadcaster.broadcast(fee_bumped_tx); @@ -2452,6 +2971,130 @@ impl Wallet { } } +struct LocalStakeAggregate { + amount_msat: Option, + fee_paid_msat: Option, + direction: PaymentDirection, +} + +/// Aggregates our net stake across the channels of a single [`FundingCandidate`] by summing each +/// channel's signed [`FundingContribution::net_value`]. Returns no amount if we contributed to none +/// of them. +fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { + let mut net_stake = SignedAmount::ZERO; + let mut fee = Amount::ZERO; + let mut have_contribution = false; + for channel in &candidate.channels { + if let Some(contribution) = channel.contribution.as_ref() { + have_contribution = true; + net_stake += contribution.net_value(); + // `estimated_fee` is our per-contributor share, so summing across channels is correct. + fee += contribution.estimated_fee(); + } + } + if !have_contribution { + return LocalStakeAggregate { + amount_msat: None, + fee_paid_msat: None, + direction: PaymentDirection::Outbound, + }; + } + // Direction is from our on-chain wallet's perspective: a positive net stake funds the channel + // (Outbound), while a negative one is a splice-out that returns funds to the wallet (Inbound). + let direction = if net_stake >= SignedAmount::ZERO { + PaymentDirection::Outbound + } else { + PaymentDirection::Inbound + }; + LocalStakeAggregate { + amount_msat: Some(net_stake.unsigned_abs().to_sat() * 1000), + fee_paid_msat: Some(fee.to_sat() * 1000), + direction, + } +} + +/// Whether `entry` tracks the funding payment of a splice into `channel_id`. +fn tracks_channel(entry: &PendingPaymentDetails, channel_id: ChannelId) -> bool { + entry.funding_channels().iter().any(|channel| channel.channel_id == channel_id) +} + +/// 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 a closed channel may still see confirm, 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 round the counterparty's +/// `commitment_signed` reached, and our signatures cannot have left the node before that message: +/// 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. The watched transactions also +/// include the funding and whatever spent it on chain, which no recorded round is. +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 outcome of [`Wallet::apply_funding_status_update_locked`]. enum FundingStatusUpdate { /// The event's transaction belongs to the funding payment; its refreshed confirmation status @@ -2787,13 +3430,14 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { #[cfg(all(test, any(feature = "chain-esplora", feature = "chain-electrum")))] mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use 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::*; @@ -2812,7 +3456,7 @@ mod tests { PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; - use crate::payment::store::Channel; + use crate::payment::pending_payment_store::test_funding_contribution_with_outputs; use crate::types::{DynStore, DynStoreWrapper}; use crate::wallet::provenance::{ChannelOutputRole, LocalFundingFigures}; use crate::{NodeMetrics, PersistedNodeMetrics}; @@ -2886,11 +3530,13 @@ mod tests { } /// An in-memory store whose writes and reads can be made to fail on demand, all of them or - /// those of one primary namespace. + /// 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 @@ -2903,6 +3549,7 @@ mod tests { 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, } @@ -2938,6 +3585,7 @@ mod tests { ) -> impl Future> + 'static + Send { let inner = Arc::clone(&self.inner); let fail_writes = Arc::clone(&self.fail_writes); + let failed_writes = Arc::clone(&self.failed_writes); let may_fail = self.failing_namespace.as_deref().map_or(true, |ns| ns == primary_namespace); let primary_namespace = primary_namespace.to_string(); @@ -2945,6 +3593,7 @@ mod tests { 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 @@ -3316,12 +3965,14 @@ mod tests { } /// 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. + /// 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, } @@ -3332,6 +3983,7 @@ mod tests { 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()), } @@ -3351,6 +4003,7 @@ mod tests { 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(); @@ -3362,6 +4015,7 @@ mod tests { 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 @@ -3884,43 +4538,1208 @@ mod tests { } } - /// 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_store.insert_or_update(recorded).await.unwrap(); + /// 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])) + } - // 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(); + /// 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() + } - 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) }; + /// 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(); + } - let payment = wallet.payment_store.get(&payment_id).await.unwrap().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) + } + + /// 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_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.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.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(); + + // A counterparty round precedes ours, so the payment's id is not the round's own txid. + let prior_txid = Txid::from_byte_array([0xAA; 32]); + let (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(); + let id = PaymentId(prior_txid.to_byte_array()); + + // Our signatures leave the node and the counterparty broadcasts: wallet sync is the first + // to see the transaction. + observe_unconfirmed(&wallet, &tx).await; + + let payments = wallet.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.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_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.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_store.update(graduated).await.unwrap(); + wallet.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", + ); + } + + /// 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_store.get(&id).await.unwrap().expect("payment"); + let record = wallet.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_store.get(&id).await.unwrap(), Some(payment)); + let record = wallet.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_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_store.list_page(None).await.unwrap().objects.is_empty()); + assert!(wallet.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_store.list_page(None).await.unwrap().objects.is_empty()); + assert!(wallet.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_store.list_page(None).await.unwrap().objects.is_empty()); + assert!(wallet.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_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.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_store.get(&id).await.unwrap().is_none()); + assert!(wallet.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 + .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_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.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_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.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_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.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_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_store.get(&id).await.unwrap().is_some()); + + wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); + + assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.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_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_store.get(&id).await.unwrap(), Some(payment.clone())); + let record = wallet.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_store.update(moved_on).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(payment.clone())); + let record = wallet.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_store.remove(&id).await.unwrap(); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!(wallet.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_store.update(update).await.unwrap(); + let entry = wallet.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.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_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_store.get(&id).await.unwrap().is_none()); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_store.get(&other_id).await.unwrap().is_some()); + assert!(wallet.pending_payment_store.get(&other_id).await.unwrap().is_some()); + assert!(wallet.payment_store.get(&closed_id).await.unwrap().is_some()); + assert!(wallet.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_store.update(update).await.unwrap(); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + let payment = wallet.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.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_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.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.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_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_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_store.get(&id).await.unwrap(), Some(prior)); + let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert!(wallet.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_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.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_store.get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.status, PaymentStatus::Succeeded); assert_eq!( payment.amount_msat, @@ -4003,16 +5822,19 @@ mod tests { 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)); @@ -4142,6 +5964,7 @@ mod tests { 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)); @@ -4212,6 +6035,7 @@ mod tests { 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)); @@ -4281,11 +6105,13 @@ mod tests { 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 = @@ -4320,7 +6146,7 @@ mod tests { let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.status, PaymentStatus::Pending, "the evicted round can still confirm"); let entry = wallet.pending_payment_store.get(&payment_id).await.unwrap().unwrap(); - assert_eq!(entry.candidates.len(), 2, "both rounds stay on record"); + 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 @@ -4356,11 +6182,13 @@ mod tests { 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 = @@ -4412,6 +6240,7 @@ mod tests { 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)); @@ -4463,11 +6292,13 @@ mod tests { 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 = @@ -4529,6 +6360,7 @@ mod tests { 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.pending_payment_store.insert_or_update(entry).await.unwrap(); @@ -4579,6 +6411,7 @@ mod tests { 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.pending_payment_store.insert_or_update(entry).await.unwrap(); @@ -4734,6 +6567,7 @@ mod tests { 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)); @@ -4848,11 +6682,8 @@ mod tests { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channel = Channel { counterparty_node_id, channel_id: ChannelId([7u8; 32]) }; + 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()); @@ -4883,11 +6714,19 @@ mod tests { WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx.clone()), old_block_time: None }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet + // 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_store .get(&PaymentId(txid.to_byte_array())) .await .unwrap() + .is_none()); + let payment = wallet + .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); @@ -5084,11 +6923,8 @@ mod tests { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; - let counterparty_node_id = PublicKey::from_str( - "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ) - .unwrap(); - let channel = Channel { counterparty_node_id, channel_id: ChannelId([7u8; 32]) }; + 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; diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index c211d5d1ee..31e975c6e2 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -177,6 +177,13 @@ impl ChannelTxFacts { 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. /// 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/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 92062d62d1..90d5eac634 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -18,7 +18,9 @@ use bitcoin::address::NetworkUnchecked; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; use bitcoin::{Address, Amount, ScriptBuf, Txid}; -use common::logging::{init_log_logger, validate_log_entry, MultiNodeLogger, TestLogWriter}; +use common::logging::{ + init_log_logger, validate_log_entry, CollectingLogWriter, MultiNodeLogger, TestLogWriter, +}; use common::{ bump_fee_and_broadcast, configure_chain_source, distribute_funds_unconfirmed, do_channel_full_cycle, expect_channel_pending_event, expect_channel_ready_event, @@ -43,7 +45,7 @@ use ldk_node::payment::{ ConfirmationStatus, ForwardedPaymentId, PayerProofOptions, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType}; +use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType, UserChannelId}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -53,13 +55,16 @@ use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; use serde_json::json; -/// Waits until `node` has recorded the funding transaction `funding_txid` (a channel open or splice -/// round) as a payment carrying a `tx_type`. The payment is recorded when wallet sync first -/// observes the transaction, which a `sync_wallets` call can run too early for: the chain source -/// may lag the broadcast. +/// 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, @@ -85,6 +90,8 @@ struct ContendedStore { serializer: Arc>, block_writes: Arc, wallet_write_started: Arc, + /// When set, only writes to this primary namespace go through `serializer`; the rest bypass it. + serialized_namespace: Option, } impl KVStore for ContendedStore { @@ -101,6 +108,8 @@ 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 = + self.serialized_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(); @@ -108,7 +117,7 @@ impl KVStore for ContendedStore { if block_writes.load(Ordering::Acquire) { wallet_write_started.notify_one(); } - let _guard = serializer.read().await; + let _guard = if serialized { Some(serializer.read().await) } else { None }; KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await } } @@ -158,6 +167,7 @@ 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_namespace: None, }; let node = builder .build_with_store(test_config.node_entropy.into(), store.clone()) @@ -2586,8 +2596,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; @@ -2646,8 +2654,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; @@ -2765,8 +2771,6 @@ 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; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -2802,8 +2806,6 @@ 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(); @@ -3003,8 +3005,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 @@ -3095,6 +3098,270 @@ 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 `serialized_namespace` — 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_namespace: 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_namespace: serialized_namespace.map(str::to_string), + }; + 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. +async fn open_and_splice_from_counterparty( + bitcoind: &BitcoinD, electrsd: &ElectrsD, node_a: &TestNode, node_b: &TestNode, +) -> UserChannelId { + 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 +} + +/// 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 +} + +/// 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 drops a signed round nothing ever broadcast. +const DROPPED_ABANDONED_ROUND: &str = "Dropped abandoned splice round(s)"; +/// 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"; + +/// 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` would leave 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. +/// +/// 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. 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")); + 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; + + // 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); + + 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); + + 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.contains(&round_dropped), "the signed round was taken back with the channel"); + + // With its monitor update through, node B holds both signature sets and hands the round to its + // broadcaster on its own: the kept round is one 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 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(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From bada9dffedc4e1ab5bb58a2d0476aaacce3396d5 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 30 Sep 2026 14:56:29 -0500 Subject: [PATCH 14/23] Resolve funding payments when LDK discards a splice round A splice round this node signed is kept at `ChannelClosed` when the channel's monitor watches it: the counterparty committed to it, so our signatures may have left the node, and the counterparty may broadcast the round and see it confirm. A close the wallet sees as a conflict -- a cooperative close spending an input the round shares -- fails the payment once it confirms beyond the reorg depth, but nothing resolved such a record when a commitment transaction, which pays no wallet script, won instead. Once the close matures -- after the reorg delay for a counterparty's commitment transaction, and once the to_self_delay on our balance has passed for one of our own -- the monitor stops watching the rounds it kept and queues a `DiscardFunding` event for each, and the handler only reclaimed the contribution's addresses: the funding payment stayed `Pending` forever. Likewise for a round of ours that a sibling round this node did not contribute to replaced on an open channel: LDK discards our round as the sibling locks, and the payment stayed `Pending` for a transaction that can no longer confirm. Resolve the channel's funding payments by the rounds LDK holds. A round nothing ever broadcast is dropped first, as `ChannelClosed` already did, and with it a record no broadcast round of ours remains under. A payment is then left alone if a round of ours that LDK still holds remains in its record -- the round that locked, or one still pending -- or one LDK promoted to the funding before, and failed otherwise: no round of ours can confirm anymore, whether the channel closed on a commitment transaction or a round we did not contribute to locked. The rounds LDK holds are the channel's pending rounds and funding while the manager lists the channel, and once it does not, the funding its monitor settled on plus whatever the monitor still watches. The monitor is left out for a listed channel: 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. The event names this node's contribution, not the round: the inputs and output scripts LDK returns of it. Matching that to a recorded round would take the parts of every contribution on record. LDK discards the round's siblings as it promotes the round and reports the promotion through `ChannelReady`, so that event resolves the payments of a listed channel instead: it records the promotion and resolves the channel's other payments by the rounds the manager holds once updated -- the promoted round, and whatever was negotiated behind it. For a channel the manager no longer lists it records the promotion alone and leaves the payments to the close. A `DiscardFunding` for a listed channel then only drops a round nothing broadcast that the manager no longer holds and reclaims the contribution's addresses. A zero-conf splice is promoted to the funding as `splice_locked` is exchanged, before its transaction confirms, and a later splice moves the funding on again: at the close neither the manager nor the monitor holds the earlier round, although it can still confirm, the later round descending from it. So the funding payment records each promotion LDK reports through `ChannelReady`, and a round promoted once counts as one that can confirm wherever the rounds LDK holds decide: as a sibling round is promoted, and when the channel closes. The monitor's events can 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 still listed and leaves the payments, there being no promotion to resolve them. So `ChannelClosed` fails every payment of the channel left with no round of ours the monitor watches and none promoted before, and a `DiscardFunding` event for a channel the manager no longer lists resolves each record the same way, by the funding its monitor settled on and whatever it still watches. An entry of a round LDK released that wallet sync never observed holds no payment record yet. It is resolved on the same terms: with no round of ours held or locked, the attempt is failed under a record written for it then, from the share of its newest round of ours that the signing recorded, so that it shows in the payment list as one wallet sync had observed would, and the entry is removed. Wallet sync otherwise creates every funding record; here it never saw the transaction, so the record is written from what the signing kept. That newest round may have no share on record: the signing records nothing for a round that moves no wallet funds, such as a splice-out to an external address, while a later round signed with it in the history lists it as ours by its contribution. Such a round was never a payment of the wallet's, so once the later round is dropped the entry is removed without a record. A share that cannot be read is no answer about the round: the pass fails with the error, and the event is replayed with the entry kept, still listing the round. Developed with assistance from Claude Code. Co-Authored-By: Elias Rohrer Co-Authored-By: Claude Fable 5.1 --- src/event.rs | 103 +- src/payment/pending_payment_store.rs | 146 ++- src/wallet/mod.rs | 1290 +++++++++++++++++++++++++- tests/integration_tests_rust.rs | 752 ++++++++++++++- 4 files changed, 2225 insertions(+), 66 deletions(-) diff --git a/src/event.rs b/src/event.rs index c64259b5a4..c56b084ccf 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2075,6 +2075,39 @@ where 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) @@ -2109,10 +2142,15 @@ where // 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 round the counterparty committed to, and our signatures may have left the - // node for such a round, so it is kept (see `closed_channel_held_rounds`). The - // monitor's guard is not `Send`, so its watched transactions are collected before - // anything is awaited. + // 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) @@ -2122,12 +2160,11 @@ where .unwrap_or_default(); let held_rounds = closed_channel_held_rounds(channel_funding_txo, watched_txids); if let Err(e) = - self.wallet.drop_abandoned_splice_rounds(channel_id, &held_rounds).await + self.wallet.resolve_closed_channel_splice_rounds(channel_id, &held_rounds).await { log_error!( self.logger, - "Failed to drop the splice rounds of closed channel {} from its funding \ - payment: {}", + "Failed to resolve the funding payments of channel {} at its close: {}", channel_id, e, ); @@ -2191,6 +2228,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, diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index e55eed0262..aa68e5b8dd 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -73,6 +73,12 @@ pub(crate) struct PendingPaymentDetails { /// RBF history, keyed by each candidate's txid and recorded as each round is signed. /// Empty for non-funding payments. pub candidates: Vec, + /// The 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 { @@ -85,6 +91,7 @@ impl PendingPaymentDetails { conflicting_txids, funding_channels: Vec::new(), candidates, + locked_rounds: Vec::new(), } } @@ -94,7 +101,14 @@ impl PendingPaymentDetails { pub(crate) fn signed_rounds( id: PaymentId, funding_channels: Vec, candidates: Vec, ) -> Self { - Self { id, details: None, conflicting_txids: Vec::new(), funding_channels, candidates } + Self { + id, + details: None, + conflicting_txids: Vec::new(), + funding_channels, + candidates, + locked_rounds: Vec::new(), + } } /// The full payment details, or `None` for a splice whose transaction has not been observed. @@ -107,6 +121,21 @@ impl PendingPaymentDetails { &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 + } + /// 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) @@ -135,7 +164,7 @@ impl PendingPaymentDetails { /// Whether this entry tracks nothing anymore and can be dropped. pub(crate) fn is_empty(&self) -> bool { - self.details.is_none() && self.candidates.is_empty() + self.details.is_none() && self.candidates.is_empty() && self.locked_rounds.is_empty() } } @@ -145,6 +174,7 @@ impl_writeable_tlv_based!(PendingPaymentDetails, { (4, conflicting_txids, optional_vec), (6, funding_channels, optional_vec), (8, candidates, optional_vec), + (12, locked_rounds, optional_vec), }); #[derive(Clone, Debug, PartialEq, Eq)] @@ -254,25 +284,65 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { pub(crate) fn test_funding_contribution_with_outputs( estimated_fee_sat: u64, feerate: u64, outputs: &[bitcoin::TxOut], ) -> lightning::ln::funding::FundingContribution { - use lightning::util::ser::Writeable; + 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 { + 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) - records.push(u8::try_from(output_bytes.len()).expect("test outputs must stay small")); + 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) - // BigSize length prefix over the TLV records above; single-byte as long as they stay short. - let mut tlv_bytes = vec![u8::try_from(records.len()).expect("test TLV stream must stay small")]; + 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") @@ -281,6 +351,7 @@ pub(crate) fn test_funding_contribution_with_outputs( #[cfg(test)] mod tests { use bitcoin::hashes::Hash; + use lightning::util::ser::{Readable, Writeable}; use super::*; use crate::payment::store::ConfirmationStatus; @@ -385,4 +456,67 @@ mod tests { "current txid must not remain in its own conflict list" ); } + + /// 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 were kept on it reads as an + /// entry with none, everything else intact. + #[test] + fn an_entry_written_without_locked_rounds_reads_with_none() { + 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 were kept still reads"); + assert!(decoded.locked_rounds().is_empty()); + 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/wallet/mod.rs b/src/wallet/mod.rs index 6d08636578..35cddb52f2 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1014,11 +1014,37 @@ impl Wallet { return Ok(false); } - // As with graduation, decide from the live record and write only the status. A record - // already `Failed` — a prior pass whose entry removal below was lost to a crash — still - // matches, no-ops the update, and gets its lingering entry removed. let payment_id = entry.id(); - let mut failed = false; + let outcome = + self.fail_unconfirmed_funding_payment_locked(&_guard, 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, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, record_txid: Txid, + ) -> Result { + let mut outcome = FundingPaymentFailure::MovedOn; self.payment_store .mutate(&payment_id, |existing| { let current = existing?; @@ -1032,26 +1058,351 @@ impl Wallet { | TransactionType::InteractiveFunding { .. }, ), } if txid == record_txid => { - failed = true; let mut update = PaymentDetailsUpdate::new(payment_id); update.status = Some(PaymentStatus::Failed); let mut updated = current.clone(); - updated.update(update).then_some(updated) + if updated.update(update) { + outcome = FundingPaymentFailure::Failed; + Some(updated) + } else { + outcome = FundingPaymentFailure::EntryRemoved; + None + } }, _ => None, } }) .await?; - if failed { + if outcome != FundingPaymentFailure::MovedOn { self.pending_payment_store.remove(&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 guard = self.funding_payment_update_lock.lock().await; + self.resolve_closed_channel_splice_rounds_locked(&guard, channel_id, held_rounds).await + } + + /// [`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, guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, held_rounds: &[Txid], + ) -> Result<(), Error> { + self.drop_abandoned_splice_rounds_locked(guard, channel_id, held_rounds).await?; + self.fail_funding_payments_without_held_round_locked( + guard, + 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(()) + } + + /// 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, guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, + held_rounds: &[Txid], resolution: FundingResolution, + ) -> Result<(), Error> { + let occasion = match resolution { + FundingResolution::Close => format!("of closed channel {}", channel_id), + FundingResolution::Promotion(promoted) => { + format!("of channel {} once splice round {} locked", channel_id, promoted) + }, + }; + let entries = + self.pending_payment_store.list_filter(|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 { + self.pending_payment_store.remove(&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, + ); + self.payment_store.insert_or_update(failed).await?; + self.pending_payment_store.remove(&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(guard, 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(()) + } + + /// 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 guard = self.funding_payment_update_lock.lock().await; + self.record_locked_splice_round_locked(&guard, channel_id, promoted).await?; + let held_rounds = match held_rounds { + Some(held_rounds) => held_rounds, + None => { + log_debug!( + self.logger, + "Channel {} is no longer listed as splice round {} locks: leaving its funding \ + payments to its close", + channel_id, + promoted, + ); + return Ok(()); + }, + }; + // The drop goes first: a round nothing broadcast is taken back rather than failed, and + // the payment recorded for it alone goes with it. + self.drop_abandoned_splice_rounds_locked(&guard, channel_id, held_rounds).await?; + self.fail_funding_payments_without_held_round_locked( + &guard, + 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(()) + } + + /// 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, _guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, txid: Txid, + ) -> Result<(), Error> { + let entries = self + .pending_payment_store + .list_filter(|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(); + self.pending_payment_store + .mutate(&payment_id, |existing| { + let mut entry = existing?.clone(); + if !entry.record_locked_round(txid) { + return None; + } + Some(entry) + }) + .await?; log_info!( self.logger, - "Failed funding payment {}: transaction {} lost to a conflicting transaction confirmed beyond the reorg depth", + "Splice round {} of funding payment {} locked as the funding of channel {}", + txid, payment_id, - record_txid, + channel_id, ); } - Ok(failed) + Ok(()) } #[allow(deprecated)] @@ -2230,19 +2581,29 @@ impl Wallet { /// 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. 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. + /// 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 _guard = self.funding_payment_update_lock.lock().await; + let guard = self.funding_payment_update_lock.lock().await; + self.drop_abandoned_splice_rounds_locked(&guard, 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, _guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, + held_rounds: &[Txid], + ) -> Result<(), Error> { let entries = self .pending_payment_store .list_filter(|entry| { @@ -2264,6 +2625,7 @@ impl Wallet { 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() }) }; @@ -2299,6 +2661,7 @@ impl Wallet { 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(); @@ -3073,16 +3436,26 @@ pub(crate) fn held_splice_rounds( held } -/// The splice rounds a closed channel may still see confirm, as +/// 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 round the counterparty's -/// `commitment_signed` reached, and our signatures cannot have left the node before that message: -/// 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. The watched transactions also -/// include the funding and whatever spent it on chain, which no recorded round is. +/// `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 { @@ -3095,6 +3468,28 @@ pub(crate) fn closed_channel_held_rounds( 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, +} + /// The outcome of [`Wallet::apply_funding_status_update_locked`]. enum FundingStatusUpdate { /// The event's transaction belongs to the funding payment; its refreshed confirmation status @@ -3456,7 +3851,9 @@ mod tests { PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; - use crate::payment::pending_payment_store::test_funding_contribution_with_outputs; + use crate::payment::pending_payment_store::{ + test_funding_contribution_with_outputs, test_funding_contribution_with_parts, + }; use crate::types::{DynStore, DynStoreWrapper}; use crate::wallet::provenance::{ChannelOutputRole, LocalFundingFigures}; use crate::{NodeMetrics, PersistedNodeMetrics}; @@ -4619,6 +5016,30 @@ mod tests { (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) + } + /// 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. @@ -6661,6 +7082,827 @@ mod tests { assert_ne!(locked_wallet.next_unused_address(KeychainKind::Internal).index, 0); } + /// 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 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.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + + let payment = wallet.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.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_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 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; + + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + + assert_failed_unobserved_round(&wallet, id, txid).await; + assert!(wallet.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 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; + 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_closed_channel_splice_rounds(channel_id, &[txid]).await.unwrap(); + + assert!( + wallet.payment_store.get(&id).await.unwrap().is_none(), + "the round can still confirm, so nothing is failed", + ); + let entry = wallet.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 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 (_, 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(); + + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!( + wallet.payment_store.get(&id).await.unwrap().is_none(), + "the locked round can still confirm, so nothing is failed", + ); + let entry = wallet.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.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.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) + } + + /// 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; + + // 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(); + + assert!( + wallet.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.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// 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_store.get(&id).await.unwrap().is_none()); + fail_store.fail_reads.store(false, Ordering::Release); + let entry = wallet.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_store.get(&id).await.unwrap().is_none()); + assert!(wallet.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; + + wallet + .resolve_promoted_splice_round(channel_id, sibling_txid, Some(&[sibling_txid])) + .await + .unwrap(); + + assert_failed_unobserved_round(&wallet, id, txid).await; + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// LDK promoted a round of ours and discarded the counterparty's round it replaced with the + /// promotion, so the payment stays as it is, the promotion recorded and the discarded round + /// still in its history. + #[tokio::test] + async fn 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_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet.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 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; + 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, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + let payment = wallet.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.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// 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_store.get(&id).await.unwrap().unwrap(); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) + ); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + let payment = wallet.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.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// The same at a close whose monitor holds the counterparty's round the record moved onto: + /// the record naming a round recorded without a stake does not make it a round of ours. + #[tokio::test] + async fn 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_store.get(&id).await.unwrap().unwrap(); + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) + ); + + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) + .await + .unwrap(); + + let payment = wallet.payment_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.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// 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_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_store.get(&id).await.unwrap().is_none()); + assert!(wallet.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 promoting_a_round_finishes_a_failure_cut_short() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + wallet + .payment_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.pending_payment_store.get(&id).await.unwrap().is_some()); + + wallet + .resolve_promoted_splice_round( + channel_id, + counterparty_txid, + Some(&[counterparty_txid]), + ) + .await + .unwrap(); + + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// A promotion reported for a channel the manager no longer lists — the channel closed before + /// the event was handled — records the round and leaves the payments to the close, which + /// resolves them by what the monitor holds: nothing of ours here, the promoted round being the + /// counterparty's, so the payment is failed then. Recording the counterparty's round does not + /// keep it. + #[tokio::test] + async fn promoting_a_round_on_an_unlisted_channel_records_it_alone() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let counterparty_txid = Txid::from_byte_array([0xAA; 32]); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; + let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; + + wallet.resolve_promoted_splice_round(channel_id, counterparty_txid, None).await.unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + assert_eq!(entry.locked_rounds(), &[counterparty_txid]); + + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) + .await + .unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// A payment whose round LDK promoted before is kept when a later splice's round is promoted + /// — the round can still confirm, the later one descending from it — while the later round's + /// payment is kept for the round LDK holds. The close after that keeps both as well. + #[tokio::test] + async fn 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(); + + 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_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = + wallet.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_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(wallet.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 promoting_a_round_drops_an_abandoned_bump_and_hands_the_record_back() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (first_tx, first) = splice_out_round(&wallet, 1, 500_000, 300); + let (bump_tx, bump) = splice_out_round(&wallet, 2, 500_000, 600); + let (first_txid, bump_txid) = (first_tx.compute_txid(), bump_tx.compute_txid()); + let id = + record_broadcast_rounds(&wallet, &first_tx, &[(first_txid, Some(first.clone()))]).await; + let payment = wallet.payment_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_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); + + wallet + .resolve_promoted_splice_round(channel_id, first_txid, Some(&[first_txid])) + .await + .unwrap(); + + let payment = wallet.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.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 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.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + assert_eq!(entry.locked_rounds(), &[txid]); + + wallet + .resolve_closed_channel_splice_rounds(channel_id, &[later_funding_txid]) + .await + .unwrap(); + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(wallet.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 closing_keeps_a_locked_round_whose_negotiation_event_is_unhandled() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (tx, contribution) = splice_in_round(&wallet, 1); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + wallet.resolve_promoted_splice_round(channel_id, txid, Some(&[txid])).await.unwrap(); + + 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_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet.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) + } + + /// 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_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet.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_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.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// 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_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Pending); + let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + assert_eq!(entry.candidates().len(), 2); + } + + /// 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_store + .mutate(&id, |existing| { + let mut updated = existing?.clone(); + if let PaymentKind::Onchain { status, .. } = &mut updated.kind { + *status = confirmed; + } + updated.status = PaymentStatus::Succeeded; + Some(updated) + }) + .await + .unwrap(); + wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); + let payment = wallet.payment_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.pending_payment_store.get(&id).await.unwrap().is_some()); + } + + /// 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_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_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + + /// The close resolves every record of the channel — two splices signed under different + /// first-candidate ids, as two negotiations from the same coins are — each by the rounds the + /// monitor holds: nothing of ours here, so both are failed. + #[tokio::test] + async fn closing_resolves_every_record_of_the_channel() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_, channel_id) = test_counterparty_and_channel(); + let (first_tx, contribution) = splice_in_round(&wallet, 1); + let (second_tx, _) = splice_in_round(&wallet, 2); + let (first_txid, second_txid) = (first_tx.compute_txid(), second_tx.compute_txid()); + let first_id = record_broadcast_rounds( + &wallet, + &first_tx, + &[(first_txid, Some(contribution.clone()))], + ) + .await; + let second_id = + record_broadcast_rounds(&wallet, &second_tx, &[(second_txid, Some(contribution))]) + .await; + let funding_txid = Txid::from_byte_array([0xF0; 32]); + wallet.resolve_closed_channel_splice_rounds(channel_id, &[funding_txid]).await.unwrap(); + for id in [first_id, second_id] { + let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + } + } + /// 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( diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 90d5eac634..440897de49 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, }; @@ -46,7 +47,8 @@ use ldk_node::payment::{ PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType, UserChannelId}; -use lightning::ln::channelmanager::PaymentId; +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}; @@ -90,8 +92,26 @@ struct ContendedStore { serializer: Arc>, block_writes: Arc, wallet_write_started: Arc, - /// When set, only writes to this primary namespace go through `serializer`; the rest bypass it. - serialized_namespace: Option, + /// 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 { @@ -108,8 +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 = - self.serialized_namespace.as_deref().map_or(true, |ns| ns == primary_namespace); + 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(); @@ -117,8 +139,18 @@ impl KVStore for ContendedStore { if block_writes.load(Ordering::Acquire) { wallet_write_started.notify_one(); } - let _guard = if serialized { Some(serializer.read().await) } else { None }; - 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 } } @@ -167,7 +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_namespace: None, + serialized: None, + serialized_in_flight: Arc::new(AtomicUsize::new(0)), }; let node = builder .build_with_store(test_config.node_entropy.into(), store.clone()) @@ -3099,10 +3132,11 @@ async fn splice_in_rbf_joins_counterparty_splice() { } /// Builds and starts a node over a [`ContendedStore`], whose writes — all of them, or only those -/// to `serialized_namespace` — a test holds back by taking the store's `serializer` write lock, -/// logging into a [`CollectingLogWriter`]. +/// 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_namespace: Option<&str>, + 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()); @@ -3111,7 +3145,9 @@ fn setup_contended_node( serializer: Arc::new(tokio::sync::RwLock::new(())), block_writes: Arc::new(AtomicBool::new(false)), wallet_write_started: Arc::new(tokio::sync::Notify::new()), - serialized_namespace: serialized_namespace.map(str::to_string), + 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); @@ -3128,10 +3164,10 @@ fn setup_contended_node( /// 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. +/// `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 { +) -> (UserChannelId, Txid) { let address_a = node_a.onchain_payment().new_address().unwrap(); premine_and_distribute_funds( &bitcoind.client, @@ -3164,7 +3200,7 @@ async fn open_and_splice_from_counterparty( wait_for_tx(&electrsd.client, counterparty_txo.txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); - user_channel_id_a + (user_channel_id_a, counterparty_txo.txid) } /// The transaction of the only splice round `logs` show the node having recorded at signing. A @@ -3182,6 +3218,201 @@ fn only_signed_round_txid(logs: &CollectingLogWriter) -> Txid { txid } +/// `node`'s payment for the funding transaction `funding_txid`, which it must have recorded. +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 @@ -3196,17 +3427,38 @@ const BROADCAST_FUNDING: &str = "Broadcasting interactively funded transaction w 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` would leave such -/// a broadcast to resurface as an untyped payment. The sibling +/// 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. @@ -3220,21 +3472,23 @@ const RECLAIMED_ADDRESSES: &str = "Reclaiming unused addresses from channel"; /// [`open_and_splice_from_counterparty`]. Pinned to Esplora so node A's wallet syncs only on /// demand. /// -/// 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. 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. +/// 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")); + 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 = + 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. @@ -3243,6 +3497,7 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { 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 @@ -3265,7 +3520,16 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { "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); @@ -3282,11 +3546,31 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { // 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: the kept round is one the counterparty could release without this - // node. + // 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, @@ -3296,6 +3580,179 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { 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 @@ -3314,7 +3771,7 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { 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 = + 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); @@ -3362,6 +3819,243 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { 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_id = PaymentId(first_txo.txid.to_byte_array()); + let payment = node_a.payment(&payment_id).unwrap().expect("the splice has a payment"); + assert_eq!(payment.status, PaymentStatus::Pending); + + // 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(); From 7da2fc56930abb878b6eaf12da806d6d9e58b863 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 30 Sep 2026 14:58:08 -0500 Subject: [PATCH 15/23] Prefer the record that owns a txid over one listing it as a conflict A pending-store entry lists the transactions that replaced its own, so a cooperative close (or any other wallet transaction) that a splice round replaces lists the round among its conflicting txids. The round's events then matched two entries, its own record's and the close's, and the pending cache's iteration order decided which one won. About one time in five the round's confirmation landed on the close's record, which took the round's txid, figures and confirmation and graduated, while the splice's payment never learned of the confirmation and stayed pending for good. Prefer the entry that records the transaction as its own, whether as its current transaction or as a negotiated candidate, and fall back to an entry that only lists it as a conflict when no entry owns it. The conflict listing stays: it is how a replaced round of a record without candidates, an ordinary payment's RBF history or the replacement of an inbound transaction, maps back to its record. A round this node signed is named by the facts the signing recorded before either entry is consulted, so the order decides for a round without such facts, as one this node contributed nothing to. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 164 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 150 insertions(+), 14 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 35cddb52f2..4d526f08e8 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2940,21 +2940,25 @@ impl Wallet { return Ok(Some(direct_payment_id)); } - if let Some(replaced_details) = self + 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, so map any of its candidate + // txids (an earlier RBF round may confirm) back to the record. + || p.candidate(target_txid).is_some() + }; + let matches = self .pending_payment_store - .list_filter(|p| { - p.details().is_some_and( - |d| matches!(d.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), - ) || p.conflicting_txids().contains(&target_txid) - // A middle RBF round is not the record's current txid and may never have - // received a `TxReplaced` event of its own, so map any of its candidate - // txids (an earlier RBF round may confirm) back to the record. - || p.candidate(target_txid).is_some() - }) - .await - .first() - { - return Ok(Some(replaced_details.id())); + .list_filter(|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())); } Ok(None) @@ -6438,6 +6442,138 @@ mod tests { } } + /// 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_store.insert_or_update(close_details.clone()).await.unwrap(); + let entry = + PendingPaymentDetails::new(close_details, vec![splice_txid], Vec::new()); + wallet.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_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_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 From 29237c84ddc03e8f73639812113951f3255584d4 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 30 Sep 2026 14:59:39 -0500 Subject: [PATCH 16/23] Have funding-record writers take a guard only the funding lock issues The writers of funding payment records take a lock guard so that a caller has to hold the funding lock to reach them. The parameter took a guard of any `Mutex<()>`, though, and the wallet has another one, for refilling the address pool, so a caller holding the wrong lock compiled. Wrap the lock in a type whose guard only it can produce and have the writers take that guard, so holding this lock is the only way to call them. Whether the caller's reads before the write happened under the same acquisition is still up to the caller. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 50 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 4d526f08e8..44312e30b7 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -149,6 +149,32 @@ impl AddressPool { } } +/// The lock serializing the writers of funding payment records, see +/// [`Wallet::funding_payment_update_lock`]. Locking it hands out a guard of a type only this +/// module constructs, so a function taking one can be called only by a holder of this lock, not of +/// any other `Mutex<()>` the wallet has. +mod funding_payment_update_lock { + pub(super) struct FundingPaymentUpdateLock(tokio::sync::Mutex<()>); + + /// Held by a holder of the [`FundingPaymentUpdateLock`], and by no one else. + #[must_use = "dropping the guard releases the funding lock at once"] + pub(super) struct FundingPaymentUpdateGuard<'a> { + _guard: tokio::sync::MutexGuard<'a, ()>, + } + + impl FundingPaymentUpdateLock { + pub(super) fn new() -> Self { + Self(tokio::sync::Mutex::new(())) + } + + pub(super) async fn lock(&self) -> FundingPaymentUpdateGuard<'_> { + FundingPaymentUpdateGuard { _guard: self.0.lock().await } + } + } +} + +use funding_payment_update_lock::{FundingPaymentUpdateGuard, FundingPaymentUpdateLock}; + pub(crate) struct Wallet { // A BDK on-chain wallet. inner: Mutex>, @@ -173,8 +199,9 @@ pub(crate) struct Wallet { // 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<()>, + // a concurrent classification could lose. The functions that need it held take its guard, + // which only this lock hands out. + funding_payment_update_lock: FundingPaymentUpdateLock, // What this node's channels reported about the transactions they produced, keyed by // transaction id. channel_tx_facts_store: Arc, @@ -206,7 +233,7 @@ impl Wallet { config, logger, pending_payment_store, - funding_payment_update_lock: tokio::sync::Mutex::new(()), + funding_payment_update_lock: FundingPaymentUpdateLock::new(), channel_tx_facts_store, } } @@ -1042,7 +1069,7 @@ impl Wallet { /// 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, _guard: &tokio::sync::MutexGuard<'_, ()>, payment_id: PaymentId, record_txid: Txid, + &self, _guard: &FundingPaymentUpdateGuard<'_>, payment_id: PaymentId, record_txid: Txid, ) -> Result { let mut outcome = FundingPaymentFailure::MovedOn; self.payment_store @@ -1109,7 +1136,7 @@ impl Wallet { /// [`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, guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, held_rounds: &[Txid], + &self, guard: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], ) -> Result<(), Error> { self.drop_abandoned_splice_rounds_locked(guard, channel_id, held_rounds).await?; self.fail_funding_payments_without_held_round_locked( @@ -1145,8 +1172,8 @@ impl Wallet { /// 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, guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, - held_rounds: &[Txid], resolution: FundingResolution, + &self, guard: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], + resolution: FundingResolution, ) -> Result<(), Error> { let occasion = match resolution { FundingResolution::Close => format!("of closed channel {}", channel_id), @@ -1373,7 +1400,7 @@ impl Wallet { /// 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, _guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, txid: Txid, + &self, _guard: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, txid: Txid, ) -> Result<(), Error> { let entries = self .pending_payment_store @@ -2601,8 +2628,7 @@ impl Wallet { /// [`Self::drop_abandoned_splice_rounds`] for a caller already holding the funding-record /// writers' lock. async fn drop_abandoned_splice_rounds_locked( - &self, _guard: &tokio::sync::MutexGuard<'_, ()>, channel_id: ChannelId, - held_rounds: &[Txid], + &self, _guard: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], ) -> Result<(), Error> { let entries = self .pending_payment_store @@ -2976,9 +3002,9 @@ impl Wallet { /// 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. + /// 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, _guard: &FundingPaymentUpdateGuard<'_>, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, ) -> Result { // The caller's wallet-level lock keeps the candidate history stable while we await its From 03d61ded49c2ecd93d10e72dcf3b3076a2c0ce44 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 30 Sep 2026 15:15:35 -0500 Subject: [PATCH 17/23] Put the wallet's payment stores behind an API that locks their writers The writers of funding payment records take the funding lock's guard, but the stores are fields of the wallet and a writer can still call them directly. Three did, with no lock: on-chain payment graduation, the naming of a recorded transaction once its channel's facts arrive, and the fee bump. Move both stores and the lock into one type. Writes are methods of the guard the lock hands out, so a write compiles only for a holder of the lock; reads take no lock. The three writers take the lock too. Graduation was kept off it on purpose, since its status-only write could clobber nothing a concurrent writer wrote; it locks now so that the API needs no unlocked write, per payment, because the conflict check in the same loop takes the lock itself. The fee bump locks after the wallet persister, the order wallet sync takes the two locks in. Developed with assistance from Claude Code. Co-Authored-By: Elias Rohrer Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 1132 +++++++++++++++++++++++----------- src/wallet/payment_stores.rs | 149 +++++ 2 files changed, 923 insertions(+), 358 deletions(-) create mode 100644 src/wallet/payment_stores.rs diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 44312e30b7..aa89dae624 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -53,6 +53,7 @@ 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}; @@ -84,6 +85,7 @@ pub(crate) enum FundingAmount { Max, } +mod payment_stores; pub(crate) mod persist; pub(crate) mod provenance; pub(crate) mod ser; @@ -149,32 +151,6 @@ impl AddressPool { } } -/// The lock serializing the writers of funding payment records, see -/// [`Wallet::funding_payment_update_lock`]. Locking it hands out a guard of a type only this -/// module constructs, so a function taking one can be called only by a holder of this lock, not of -/// any other `Mutex<()>` the wallet has. -mod funding_payment_update_lock { - pub(super) struct FundingPaymentUpdateLock(tokio::sync::Mutex<()>); - - /// Held by a holder of the [`FundingPaymentUpdateLock`], and by no one else. - #[must_use = "dropping the guard releases the funding lock at once"] - pub(super) struct FundingPaymentUpdateGuard<'a> { - _guard: tokio::sync::MutexGuard<'a, ()>, - } - - impl FundingPaymentUpdateLock { - pub(super) fn new() -> Self { - Self(tokio::sync::Mutex::new(())) - } - - pub(super) async fn lock(&self) -> FundingPaymentUpdateGuard<'_> { - FundingPaymentUpdateGuard { _guard: self.0.lock().await } - } - } -} - -use funding_payment_update_lock::{FundingPaymentUpdateGuard, FundingPaymentUpdateLock}; - pub(crate) struct Wallet { // A BDK on-chain wallet. inner: Mutex>, @@ -185,23 +161,11 @@ 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. The functions that need it held take its guard, - // which only this lock hands out. - funding_payment_update_lock: FundingPaymentUpdateLock, + // 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, @@ -228,12 +192,10 @@ impl Wallet { broadcaster, fee_estimator, chain_source, - payment_store, runtime, config, logger, - pending_payment_store, - funding_payment_update_lock: FundingPaymentUpdateLock::new(), + payment_stores: PaymentStores::new(payment_store, pending_payment_store), channel_tx_facts_store, } } @@ -504,7 +466,7 @@ impl Wallet { // 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 guard = self.funding_payment_update_lock.lock().await; + let stores = self.payment_stores.lock().await; let mut payment_id = self .find_payment_by_txid(txid) @@ -513,7 +475,7 @@ impl Wallet { match self .apply_funding_status_update_locked( - &guard, + &stores, payment_id, txid, confirmation_status, @@ -554,10 +516,10 @@ impl Wallet { ) }; - self.payment_store.insert_or_update(payment.clone()).await?; + stores.insert_or_update_payment(payment.clone()).await?; if payment_status == PaymentStatus::Pending { - self.upsert_pending_payment(payment, Vec::new()).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 @@ -565,13 +527,13 @@ impl Wallet { // 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. - self.pending_payment_store.remove(&payment_id).await?; + stores.remove_pending_payment(&payment_id).await?; } }, WalletEvent::ChainTipChanged { new_tip, .. } => { let pending_payments: Vec = self - .pending_payment_store - .list_filter(|p| match p.details() { + .payment_stores + .pending_payments(|p| match p.details() { // An entry of signed rounds whose transaction nothing has observed // yet carries no payment and cannot graduate. None => false, @@ -620,8 +582,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 { @@ -643,7 +608,7 @@ impl Wallet { }) .await?; if graduated { - self.pending_payment_store.remove(&payment_id).await?; + stores.remove_pending_payment(&payment_id).await?; } } }, @@ -697,7 +662,7 @@ impl Wallet { WalletEvent::TxUnconfirmed { txid, tx, .. } => { // See `TxConfirmed`: id resolution and the writes below must not interleave // with the funding-record writers. - let guard = self.funding_payment_update_lock.lock().await; + let stores = self.payment_stores.lock().await; let mut payment_id = self .find_payment_by_txid(txid) @@ -706,7 +671,7 @@ impl Wallet { match self .apply_funding_status_update_locked( - &guard, + &stores, payment_id, txid, ConfirmationStatus::Unconfirmed, @@ -746,15 +711,15 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ) }; - self.payment_store.insert_or_update(payment.clone()).await?; - self.upsert_pending_payment(payment, Vec::new()).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 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 _guard = self.funding_payment_update_lock.lock().await; + let stores = self.payment_stores.lock().await; let Some(payment_id) = self.find_payment_by_txid(txid).await? else { log_error!( @@ -774,7 +739,7 @@ impl Wallet { // updates the payment store with the replacement txid before the next sync // cycle, and sync itself records a transaction the first time it observes it, // before anything can report it replaced. So we can safely fetch it here. - let stored_payment = self.payment_store.get(&payment_id).await?; + let stored_payment = stores.payment(&payment_id).await?; debug_assert!( stored_payment.is_some(), "Payment {:?} expected in store during WalletEvent::TxReplaced but not found", @@ -789,16 +754,16 @@ impl Wallet { // pending listing that repairs such leftovers; finish the interrupted removal // instead. if payment.status != PaymentStatus::Pending { - self.pending_payment_store.remove(&payment_id).await?; + stores.remove_pending_payment(&payment_id).await?; continue; } - self.upsert_pending_payment(payment, conflict_txids).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 the funding-record writers. - let guard = self.funding_payment_update_lock.lock().await; + let stores = self.payment_stores.lock().await; let mut payment_id = self .find_payment_by_txid(txid) @@ -807,7 +772,7 @@ impl Wallet { match self .apply_funding_status_update_locked( - &guard, + &stores, payment_id, txid, ConfirmationStatus::Unconfirmed, @@ -847,8 +812,8 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ) }; - self.payment_store.insert_or_update(payment.clone()).await?; - self.upsert_pending_payment(payment, Vec::new()).await?; + stores.insert_or_update_payment(payment.clone()).await?; + self.upsert_pending_payment(&stores, payment, Vec::new()).await?; }, _ => { continue; @@ -885,10 +850,11 @@ impl Wallet { let mut update = PaymentDetailsUpdate::new(payment_id); update.tx_type = Some(Some(tx_type)); - // The write touches one record and leaves its pending entry alone. - let named = self - .payment_store - .mutate(&payment_id, |existing| { + // 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 @@ -925,7 +891,7 @@ impl Wallet { return Ok(None); } let has_funding_record = - self.payment_store.get(&fallback_id).await?.is_some_and(|payment| { + self.payment_stores.payment(&fallback_id).await?.is_some_and(|payment| { matches!( payment.kind, PaymentKind::Onchain { @@ -980,10 +946,10 @@ impl Wallet { // 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 _guard = self.funding_payment_update_lock.lock().await; + let stores = self.payment_stores.lock().await; // Re-read the entry under the lock; the listing snapshot may predate a record write. - let entry = match self.pending_payment_store.get(&payment_id).await? { + let entry = match stores.pending_payment(&payment_id).await? { Some(entry) => entry, None => return Ok(false), }; @@ -1043,7 +1009,7 @@ impl Wallet { let payment_id = entry.id(); let outcome = - self.fail_unconfirmed_funding_payment_locked(&_guard, payment_id, record_txid).await?; + self.fail_unconfirmed_funding_payment_locked(&stores, payment_id, record_txid).await?; match outcome { FundingPaymentFailure::Failed => log_info!( self.logger, @@ -1069,11 +1035,11 @@ impl Wallet { /// 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, _guard: &FundingPaymentUpdateGuard<'_>, payment_id: PaymentId, record_txid: Txid, + &self, stores: &PaymentStoresGuard<'_>, payment_id: PaymentId, record_txid: Txid, ) -> Result { let mut outcome = FundingPaymentFailure::MovedOn; - self.payment_store - .mutate(&payment_id, |existing| { + stores + .mutate_payment(&payment_id, |existing| { let current = existing?; match current.kind { PaymentKind::Onchain { @@ -1101,7 +1067,7 @@ impl Wallet { }) .await?; if outcome != FundingPaymentFailure::MovedOn { - self.pending_payment_store.remove(&payment_id).await?; + stores.remove_pending_payment(&payment_id).await?; } Ok(outcome) } @@ -1129,18 +1095,18 @@ impl Wallet { ) -> Result<(), Error> { // Serialize with the other funding-record writers, which all hold this lock from their // reads through their last write. - let guard = self.funding_payment_update_lock.lock().await; - self.resolve_closed_channel_splice_rounds_locked(&guard, channel_id, held_rounds).await + let stores = self.payment_stores.lock().await; + self.resolve_closed_channel_splice_rounds_locked(&stores, channel_id, held_rounds).await } /// [`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, guard: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], + &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], ) -> Result<(), Error> { - self.drop_abandoned_splice_rounds_locked(guard, channel_id, held_rounds).await?; + self.drop_abandoned_splice_rounds_locked(stores, channel_id, held_rounds).await?; self.fail_funding_payments_without_held_round_locked( - guard, + stores, channel_id, held_rounds, FundingResolution::Close, @@ -1172,7 +1138,7 @@ impl Wallet { /// 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, guard: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], + &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], resolution: FundingResolution, ) -> Result<(), Error> { let occasion = match resolution { @@ -1181,8 +1147,7 @@ impl Wallet { format!("of channel {} once splice round {} locked", channel_id, promoted) }, }; - let entries = - self.pending_payment_store.list_filter(|entry| tracks_channel(entry, channel_id)).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, @@ -1231,7 +1196,7 @@ impl Wallet { None => None, }; let (Some(newest_round), Some(figures)) = (newest_round, figures) else { - self.pending_payment_store.remove(&payment_id).await?; + 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", @@ -1254,8 +1219,8 @@ impl Wallet { figures.direction, PaymentStatus::Failed, ); - self.payment_store.insert_or_update(failed).await?; - self.pending_payment_store.remove(&payment_id).await?; + 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", @@ -1306,7 +1271,7 @@ impl Wallet { continue; } match self - .fail_unconfirmed_funding_payment_locked(guard, payment_id, record_txid) + .fail_unconfirmed_funding_payment_locked(stores, payment_id, record_txid) .await? { FundingPaymentFailure::Failed => log_info!( @@ -1360,8 +1325,8 @@ impl Wallet { ) -> Result<(), Error> { // Serialize with the other funding-record writers, which all hold this lock from their // reads through their last write. - let guard = self.funding_payment_update_lock.lock().await; - self.record_locked_splice_round_locked(&guard, channel_id, promoted).await?; + 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 => { @@ -1377,9 +1342,9 @@ impl Wallet { }; // 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(&guard, channel_id, held_rounds).await?; + self.drop_abandoned_splice_rounds_locked(&stores, channel_id, held_rounds).await?; self.fail_funding_payments_without_held_round_locked( - &guard, + &stores, channel_id, held_rounds, FundingResolution::Promotion(promoted), @@ -1400,11 +1365,10 @@ impl Wallet { /// 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, _guard: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, txid: Txid, + &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, txid: Txid, ) -> Result<(), Error> { - let entries = self - .pending_payment_store - .list_filter(|entry| { + let entries = stores + .pending_payments(|entry| { tracks_channel(entry, channel_id) && entry.candidate(txid).is_some() && !entry.locked_rounds().contains(&txid) @@ -1412,8 +1376,8 @@ impl Wallet { .await; for entry in entries { let payment_id = entry.id(); - self.pending_payment_store - .mutate(&payment_id, |existing| { + stores + .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); if !entry.record_locked_round(txid) { return None; @@ -2460,7 +2424,7 @@ impl Wallet { // The reads and the writes below must share one lock acquisition, as in every // funding-record write: read outside it, the record could change under us before the // write. - let _guard = self.funding_payment_update_lock.lock().await; + 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 @@ -2485,7 +2449,7 @@ impl Wallet { signed.awaiting_broadcast = true; } - let prior_pending = self.pending_payment_store.get(&payment_id).await?; + 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(()); @@ -2521,8 +2485,8 @@ impl Wallet { ) .await?; - self.pending_payment_store - .mutate(&payment_id, |existing| { + 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()) @@ -2558,19 +2522,18 @@ impl Wallet { ) -> Result<(), Error> { // Serialize with the other funding-record writers, which all hold this lock from their // reads through their last write. - let _guard = self.funding_payment_update_lock.lock().await; + let stores = self.payment_stores.lock().await; - let entries = self - .pending_payment_store - .list_filter(|entry| { + let entries = stores + .pending_payments(|entry| { tracks_channel(entry, channel_id) && entry.candidate(txid).is_some_and(|candidate| candidate.awaiting_broadcast) }) .await; for entry in entries { let payment_id = entry.id(); - self.pending_payment_store - .mutate(&payment_id, |existing| { + stores + .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); let round = entry .candidates @@ -2621,18 +2584,17 @@ impl Wallet { ) -> Result<(), Error> { // Serialize with the other funding-record writers, which all hold this lock from their // reads through their last write. - let guard = self.funding_payment_update_lock.lock().await; - self.drop_abandoned_splice_rounds_locked(&guard, channel_id, held_rounds).await + 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, _guard: &FundingPaymentUpdateGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], + &self, stores: &PaymentStoresGuard<'_>, channel_id: ChannelId, held_rounds: &[Txid], ) -> Result<(), Error> { - let entries = self - .pending_payment_store - .list_filter(|entry| { + let entries = stores + .pending_payments(|entry| { tracks_channel(entry, channel_id) && entry.candidates().iter().any(|candidate| candidate.awaiting_broadcast) }) @@ -2680,8 +2642,8 @@ impl Wallet { // nothing goes. if entry.details().is_none() { let mut emptied = false; - self.pending_payment_store - .mutate(&payment_id, |existing| { + 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)); @@ -2695,7 +2657,7 @@ impl Wallet { }) .await?; if emptied { - self.pending_payment_store.remove(&payment_id).await?; + stores.remove_pending_payment(&payment_id).await?; } log_debug!( self.logger, @@ -2712,8 +2674,8 @@ impl Wallet { Some(active) => { // Whether the record still waits on the dropped rounds is decided inside the // write's critical section, from the record found there. - self.payment_store - .mutate(&payment_id, |existing| { + stores + .mutate_payment(&payment_id, |existing| { let current = existing?; if !waits_on_abandoned(current) { history_only = true; @@ -2735,14 +2697,14 @@ impl Wallet { }, None => { // A removal has no critical section to decide in, so the record is read first. - let record = self.payment_store.get(&payment_id).await?; + 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. - self.payment_store.remove(&payment_id).await?; - self.pending_payment_store.remove(&payment_id).await?; + 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 \ @@ -2769,8 +2731,8 @@ impl Wallet { abandoned_txids, ); } - self.pending_payment_store - .mutate(&payment_id, |existing| { + 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 { @@ -2801,8 +2763,8 @@ impl Wallet { &self, held_rounds: impl Fn(ChannelId) -> Option>, ) -> Result<(), Error> { let channels: HashSet = self - .pending_payment_store - .list_filter(|entry| { + .payment_stores + .pending_payments(|entry| { entry.candidates().iter().any(|candidate| candidate.awaiting_broadcast) }) .await @@ -2829,18 +2791,29 @@ impl Wallet { Ok(()) } - /// Records a funding payment the way wallet sync does once it observes its transaction: the - /// payment record and its pending-store entry, the latter carrying the candidate history. - /// Composes that sequence for tests that need a recorded funding payment to act on. + /// Records a funding payment the way the node does: the rounds this node signed supply the + /// candidate history, and wallet sync writes the payment record and its pending-store entry + /// once it observes the transaction. Composes that sequence for tests that need a recorded + /// funding payment to act on. #[cfg(test)] async fn record_funding_payment( &self, details: PaymentDetails, candidates: Vec, ) -> Result<(), Error> { - let _guard = self.funding_payment_update_lock.lock().await; - self.payment_store.insert_or_update(details.clone()).await?; - let entry = PendingPaymentDetails::new(details, Vec::new(), candidates); - self.pending_payment_store.insert_or_update(entry).await?; - Ok(()) + let stores = self.payment_stores.lock().await; + let id = details.id; + if !candidates.is_empty() { + stores + .mutate_pending_payment(&id, |existing| { + let mut entry = existing.cloned().unwrap_or_else(|| { + PendingPaymentDetails::signed_rounds(id, Vec::new(), Vec::new()) + }); + entry.candidates = candidates.clone(); + Some(entry) + }) + .await?; + } + stores.insert_or_update_payment(details.clone()).await?; + self.upsert_pending_payment(&stores, details, Vec::new()).await } /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. @@ -2909,11 +2882,12 @@ impl Wallet { /// Inserts or refreshes the pending-store entry tracking `payment` toward graduation, /// atomically with reading the entry's current state. async fn upsert_pending_payment( - &self, payment: PaymentDetails, conflicting_txids: Vec, + &self, stores: &PaymentStoresGuard<'_>, payment: PaymentDetails, + conflicting_txids: Vec, ) -> Result<(), Error> { let id = payment.id; - self.pending_payment_store - .mutate(&id, |existing| match existing { + stores + .mutate_pending_payment(&id, |existing| match existing { None => Some(PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())), // Promote an entry that has no record yet: wallet sync saw the splice // transaction before this node recorded a payment for it. The entry keeps the @@ -2943,9 +2917,9 @@ impl Wallet { // 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. @@ -2962,7 +2936,7 @@ impl Wallet { } 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)); } @@ -2976,8 +2950,8 @@ impl Wallet { || p.candidate(target_txid).is_some() }; let matches = self - .pending_payment_store - .list_filter(|p| owns(p) || p.conflicting_txids().contains(&target_txid)) + .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 @@ -2999,12 +2973,12 @@ impl Wallet { /// 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 + /// 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: &FundingPaymentUpdateGuard<'_>, payment_id: PaymentId, event_txid: Txid, + &self, stores: &PaymentStoresGuard<'_>, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, ) -> Result { // The caller's wallet-level lock keeps the candidate history stable while we await its @@ -3012,11 +2986,11 @@ impl Wallet { // 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 = self.pending_payment_store.get(&payment_id).await?; + 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 (current_txid, tx_type) = match &payment.kind { PaymentKind::Onchain { @@ -3075,7 +3049,7 @@ impl Wallet { // the same dual-write the default `TxConfirmed` path performs; an empty conflicting-txids // list leaves any stored conflicts intact (the update treats absent as "unchanged"). if payment.status == PaymentStatus::Pending { - self.upsert_pending_payment(payment, Vec::new()).await?; + self.upsert_pending_payment(stores, payment, Vec::new()).await?; } Ok(FundingStatusUpdate::Applied) } @@ -3084,7 +3058,7 @@ impl Wallet { 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 })?; @@ -3353,8 +3327,10 @@ impl Wallet { Error::PersistenceFailed })?; - self.payment_store.insert_or_update(new_payment.clone()).await?; - self.upsert_pending_payment(new_payment, Vec::new()).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(fee_bumped_tx); @@ -5085,10 +5061,18 @@ mod tests { splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); + 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.pending_payment_store.get(&id).await.unwrap().expect("entry"); + 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!( @@ -5151,7 +5135,8 @@ mod tests { .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.pending_payment_store.get(&id).await.unwrap().expect("entry"); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); } @@ -5181,7 +5166,7 @@ mod tests { // to see the transaction. observe_unconfirmed(&wallet, &tx).await; - let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + 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!( @@ -5196,7 +5181,8 @@ mod tests { // own. assert_eq!(payments[0].amount_msat, Some(500_300_000)); assert_eq!(payments[0].fee_paid_msat, Some(300_000)); - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + 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); } @@ -5238,7 +5224,8 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("record"); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); assert_eq!(payment.status, PaymentStatus::Succeeded); assert!(matches!( payment.kind, @@ -5250,7 +5237,7 @@ mod tests { )); assert_eq!(payment.amount_msat, Some(500_300_000)); assert!( - wallet.pending_payment_store.get(&id).await.unwrap().is_none(), + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none(), "a settled payment leaves the entry nothing to track", ); } @@ -5286,8 +5273,8 @@ mod tests { // 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_store.update(graduated).await.unwrap(); - wallet.pending_payment_store.remove(&id).await.unwrap(); + 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!( @@ -5316,14 +5303,17 @@ mod tests { ); 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_store.get(&id).await.unwrap().expect("payment"); - let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + 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_store.get(&id).await.unwrap(), Some(payment)); - let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + 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] @@ -5370,7 +5360,14 @@ mod tests { 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_store.list_page(None).await.unwrap().objects.is_empty()); + 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. @@ -5416,8 +5413,22 @@ mod tests { wallet.record_signed_funding(&tx, &candidates).await.unwrap(); wallet.record_signed_funding(&tx, &[]).await.unwrap(); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); - assert!(wallet.pending_payment_store.list_page(None).await.unwrap().objects.is_empty()); + 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 @@ -5433,8 +5444,22 @@ mod tests { splice_candidates(counterparty_node_id, channel_id, &[(tx.compute_txid(), None)]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); - assert!(wallet.pending_payment_store.list_page(None).await.unwrap().objects.is_empty()); + 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 @@ -5465,8 +5490,22 @@ mod tests { ); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert!(wallet.payment_store.list_page(None).await.unwrap().objects.is_empty()); - assert!(wallet.pending_payment_store.list_page(None).await.unwrap().objects.is_empty()); + 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 @@ -5497,7 +5536,7 @@ mod tests { ); sign_and_observe_round(&wallet, &next_tx, &next_candidates).await; - let payments = wallet.payment_store.list_page(None).await.unwrap().objects; + 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)); @@ -5505,7 +5544,8 @@ mod tests { 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.pending_payment_store.get(&id).await.unwrap().expect("record"); + 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] @@ -5543,14 +5583,15 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + 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 - .pending_payment_store + .payment_stores + .pending_payment_store() .get(&other_id) .await .unwrap() @@ -5586,14 +5627,17 @@ mod tests { ); wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); record_unseen_round(&wallet, &bump_tx).await; - let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + 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.pending_payment_store.get(&id).await.unwrap().expect("record"); + 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_store.get(&id).await.unwrap().expect("payment"); + 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" @@ -5625,9 +5669,11 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + 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_store.get(&id).await.unwrap().expect("payment"); + 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); } @@ -5664,14 +5710,15 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + 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_store.get(&id).await.unwrap().is_none()); + 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 @@ -5694,12 +5741,12 @@ mod tests { 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_store.get(&id).await.unwrap().is_some()); + 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_store.get(&id).await.unwrap().is_none()); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + 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 @@ -5738,7 +5785,8 @@ mod tests { old_block_time: None, }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); assert!(matches!( payment.kind, PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } @@ -5747,8 +5795,12 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); - assert_eq!(wallet.payment_store.get(&id).await.unwrap(), Some(payment.clone())); - let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + 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()); @@ -5776,13 +5828,18 @@ mod tests { let bump_txid = bump_tx.compute_txid(); let mut moved_on = PaymentDetailsUpdate::new(id); moved_on.txid = Some(bump_txid); - wallet.payment_store.update(moved_on).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + 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_store.get(&id).await.unwrap(), Some(payment.clone())); - let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + 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)); } @@ -5803,12 +5860,12 @@ mod tests { 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_store.remove(&id).await.unwrap(); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); + 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.pending_payment_store.get(&id).await.unwrap().is_none()); + 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 @@ -5846,8 +5903,9 @@ mod tests { update.confirmation_status = Some(ConfirmationStatus::Unconfirmed); update.amount_msat = Some(Some(500_300_000)); update.fee_paid_msat = Some(Some(300_000)); - wallet.payment_store.update(update).await.unwrap(); - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + 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 @@ -5855,9 +5913,11 @@ mod tests { wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("entry"); + 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_store.get(&id).await.unwrap().expect("payment"); + 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)); @@ -5969,12 +6029,24 @@ mod tests { .await .unwrap(); - assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); - assert!(wallet.payment_store.get(&other_id).await.unwrap().is_some()); - assert!(wallet.pending_payment_store.get(&other_id).await.unwrap().is_some()); - assert!(wallet.payment_store.get(&closed_id).await.unwrap().is_some()); - assert!(wallet.pending_payment_store.get(&closed_id).await.unwrap().is_some()); + 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 @@ -6008,14 +6080,16 @@ mod tests { let mut update = PaymentDetailsUpdate::new(id); update.status = Some(PaymentStatus::Succeeded); - wallet.payment_store.update(update).await.unwrap(); + wallet.payment_stores.payment_store().update(update).await.unwrap(); wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + 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.pending_payment_store.get(&id).await.unwrap().expect("entry"); + 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)); } @@ -6039,21 +6113,30 @@ mod tests { 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_store.list_page(None).await.unwrap().objects.is_empty()); + 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.pending_payment_store.get(&id).await.unwrap().is_none()); + 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.pending_payment_store.get(&id).await.unwrap().expect("record"); + 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_store.get(&id).await.unwrap().expect("payment"); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); } @@ -6076,7 +6159,7 @@ mod tests { ); sign_and_observe_round(&wallet, &tx, &candidates).await; let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - let prior = wallet.payment_store.get(&id).await.unwrap().expect("payment"); + 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(); @@ -6090,10 +6173,11 @@ mod tests { 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_store.get(&id).await.unwrap(), Some(prior)); - let record = wallet.pending_payment_store.get(&id).await.unwrap().expect("record"); + 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_store.get(&bump_id).await.unwrap().is_none()); + 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 @@ -6176,21 +6260,22 @@ mod tests { 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.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.pending_payment_store.insert_or_update(entry).await.unwrap(); + 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_store.get(&payment_id).await.unwrap().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, @@ -6199,7 +6284,13 @@ mod tests { ); 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()); + 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 @@ -6222,7 +6313,7 @@ mod tests { // 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(); + 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 = @@ -6233,14 +6324,15 @@ mod tests { 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.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_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!( payment.status, PaymentStatus::Pending, @@ -6251,7 +6343,7 @@ mod tests { PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } )); assert!( - wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some(), + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), "the entry must survive for future events to drive" ); } @@ -6290,7 +6382,7 @@ mod tests { ]; 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(); + 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. @@ -6322,14 +6414,20 @@ mod tests { PaymentDirection::Outbound, PaymentStatus::Pending, ); - wallet.payment_store.insert_or_update(details.clone()).await.unwrap(); + wallet.payment_stores.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(); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); wallet.remove_payment(&payment_id).await.unwrap(); - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none()); - assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + 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); @@ -6341,7 +6439,7 @@ mod tests { conflicts: vec![(0, conflicting_txid)], }; wallet.update_payment_store(vec![event]).await.unwrap(); - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none()); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); } /// Payments without a pending-store entry — lightning payments, and on-chain payments that @@ -6366,10 +6464,10 @@ mod tests { PaymentDirection::Outbound, PaymentStatus::Succeeded, ); - wallet.payment_store.insert_or_update(details).await.unwrap(); + wallet.payment_stores.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()); + 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(); @@ -6423,7 +6521,8 @@ mod tests { // Sync saw the close double-spend the splice's funding transaction. wallet - .pending_payment_store + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -6441,7 +6540,8 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let funding = wallet.payment_store.get(&payment_id).await.unwrap().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"); @@ -6454,7 +6554,8 @@ mod tests { assert_eq!(funding.fee_paid_msat, Some(500)); let close = wallet - .payment_store + .payment_stores + .payment_store() .get(&PaymentId(close_txid.to_byte_array())) .await .unwrap() @@ -6544,10 +6645,20 @@ mod tests { PaymentDirection::Inbound, PaymentStatus::Pending, ); - wallet.payment_store.insert_or_update(close_details.clone()).await.unwrap(); + 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.pending_payment_store.insert_or_update(entry).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(entry) + .await + .unwrap(); } assert_eq!( @@ -6564,7 +6675,8 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let funding = wallet.payment_store.get(&payment_id).await.unwrap().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); @@ -6578,7 +6690,8 @@ mod tests { for close_txid in &close_txids { let close = wallet - .payment_store + .payment_stores + .payment_store() .get(&PaymentId(close_txid.to_byte_array())) .await .unwrap() @@ -6624,7 +6737,8 @@ mod tests { interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); wallet.record_funding_payment(details, candidates).await.unwrap(); wallet - .pending_payment_store + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -6646,7 +6760,8 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().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 } => { @@ -6659,7 +6774,7 @@ mod tests { assert_eq!(payment.amount_msat, Some(1_000_000)); assert_eq!(payment.fee_paid_msat, Some(500)); assert!( - wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none(), + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), "the entry must go so the lost transaction stops being rebroadcast" ); } @@ -6706,7 +6821,8 @@ mod tests { let close_tx = wallet_paying_tx(&wallet, 1); let close_txid = close_tx.compute_txid(); wallet - .pending_payment_store + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -6726,9 +6842,11 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().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.pending_payment_store.get(&payment_id).await.unwrap().unwrap(); + 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 @@ -6742,9 +6860,16 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.status, PaymentStatus::Failed); - assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + 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 @@ -6778,7 +6903,8 @@ mod tests { interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); wallet.record_funding_payment(details, candidates).await.unwrap(); wallet - .pending_payment_store + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -6798,10 +6924,11 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.status, PaymentStatus::Pending); assert!( - wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some(), + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), "the entry must survive for classification to adopt the confirmed candidate" ); } @@ -6829,7 +6956,8 @@ mod tests { interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); wallet.record_funding_payment(details, candidates).await.unwrap(); wallet - .pending_payment_store + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -6849,9 +6977,16 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.status, PaymentStatus::Pending); - assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some()); + 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 @@ -6888,7 +7023,8 @@ mod tests { interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); wallet.record_funding_payment(details, candidates).await.unwrap(); wallet - .pending_payment_store + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -6909,10 +7045,11 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.status, PaymentStatus::Pending); assert!( - wallet.pending_payment_store.get(&payment_id).await.unwrap().is_some(), + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), "a candidate can still confirm, so the record must stay pending" ); } @@ -6934,7 +7071,7 @@ mod tests { interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); recorded.status = PaymentStatus::Failed; recorded.latest_update_timestamp = 7; - wallet.payment_store.insert_or_update(recorded).await.unwrap(); + 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 = @@ -6946,7 +7083,7 @@ mod tests { awaiting_broadcast: false, }]; let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); insert_confirmed_tx(&wallet, close_tx, 5); @@ -6958,11 +7095,12 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().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.pending_payment_store.get(&payment_id).await.unwrap().is_none(), + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), "the lingering entry must be removed" ); } @@ -6986,7 +7124,7 @@ mod tests { interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); recorded.status = PaymentStatus::Failed; recorded.latest_update_timestamp = 7; - wallet.payment_store.insert_or_update(recorded).await.unwrap(); + 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)); @@ -6997,7 +7135,7 @@ mod tests { awaiting_broadcast: false, }]; let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); - wallet.pending_payment_store.insert_or_update(entry).await.unwrap(); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); insert_confirmed_tx(&wallet, close_tx, 5); @@ -7016,11 +7154,12 @@ mod tests { ]; wallet.update_payment_store(events).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().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.pending_payment_store.get(&payment_id).await.unwrap().is_none(), + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), "the replay must finish the interrupted entry removal" ); } @@ -7042,7 +7181,7 @@ mod tests { let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; let payment_id = PaymentId(txid.to_byte_array()); - assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_some()); + 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"); } @@ -7064,18 +7203,25 @@ mod tests { let mut recorded = interactive_funding_details(payment_id, r2, Some(1_000_000), Some(600)); recorded.status = PaymentStatus::Failed; recorded.latest_update_timestamp = 7; - wallet.payment_store.insert_or_update(recorded).await.unwrap(); + 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_store.get(&payment_id).await.unwrap().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.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + 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 { @@ -7086,11 +7232,18 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().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.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + 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 @@ -7108,15 +7261,16 @@ mod tests { 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_store.insert_or_update(settled).await.unwrap(); + 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_store.insert_or_update(live.clone()).await.unwrap(); + wallet.payment_stores.payment_store().insert_or_update(live.clone()).await.unwrap(); wallet - .pending_payment_store + .payment_stores + .pending_payment_store() .insert_or_update(PendingPaymentDetails::new(live.clone(), vec![r1], Vec::new())) .await .unwrap(); @@ -7126,11 +7280,18 @@ mod tests { WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&settled_id).await.unwrap().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.pending_payment_store.get(&settled_id).await.unwrap().is_none()); - assert_eq!(wallet.payment_store.get(&live_id).await.unwrap(), Some(live)); + 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 @@ -7157,7 +7318,8 @@ mod tests { details.direction = PaymentDirection::Inbound; wallet.record_funding_payment(details, candidates).await.unwrap(); wallet - .pending_payment_store + .payment_stores + .pending_payment_store() .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, @@ -7177,9 +7339,16 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.status, PaymentStatus::Failed); - assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); } #[tokio::test] @@ -7285,14 +7454,20 @@ mod tests { wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + 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.pending_payment_store.get(&id).await.unwrap().is_none()); + 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 @@ -7311,8 +7486,13 @@ mod tests { /// `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_store.get(&id).await.unwrap().expect("the attempt is on record"); + 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, @@ -7347,7 +7527,7 @@ mod tests { wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); assert_failed_unobserved_round(&wallet, id, txid).await; - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + 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 @@ -7366,10 +7546,16 @@ mod tests { wallet.resolve_closed_channel_splice_rounds(channel_id, &[txid]).await.unwrap(); assert!( - wallet.payment_store.get(&id).await.unwrap().is_none(), + wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none(), "the round can still confirm, so nothing is failed", ); - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + 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()); } @@ -7391,10 +7577,16 @@ mod tests { wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); assert!( - wallet.payment_store.get(&id).await.unwrap().is_none(), + wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none(), "the locked round can still confirm, so nothing is failed", ); - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + 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]); @@ -7415,14 +7607,27 @@ mod tests { let candidates = splice_candidates(counterparty_node_id, channel_id, &rounds); wallet.record_signed_funding(&external_tx, &candidates[..1]).await.unwrap(); - assert!(wallet.pending_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()); 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.pending_payment_store.get(&id).await.unwrap().expect("the entry"); + 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) @@ -7445,11 +7650,11 @@ mod tests { wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); assert!( - wallet.payment_store.get(&id).await.unwrap().is_none(), + 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.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// A failed read of the round's facts is no answer about its share: the close fails for the @@ -7470,9 +7675,15 @@ mod tests { let result = wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await; assert!(matches!(result, Err(Error::PersistenceFailed)), "{:?}", result); - assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); fail_store.fail_reads.store(false, Ordering::Release); - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + 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())); @@ -7480,8 +7691,8 @@ mod tests { // 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_store.get(&id).await.unwrap().is_none()); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + 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 @@ -7506,7 +7717,7 @@ mod tests { .unwrap(); assert_failed_unobserved_round(&wallet, id, txid).await; - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// LDK promoted a round of ours and discarded the counterparty's round it replaced with the @@ -7525,9 +7736,21 @@ mod tests { wallet.resolve_promoted_splice_round(channel_id, txid, Some(&[txid])).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Pending); - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + 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]); } @@ -7558,14 +7781,20 @@ mod tests { .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + 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.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// Wallet sync moved the record onto the counterparty's round before LDK promoted it, so the @@ -7587,7 +7816,7 @@ mod tests { old_block_time: None, }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().unwrap(); + let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().unwrap(); assert!( matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) ); @@ -7601,14 +7830,20 @@ mod tests { .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + 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.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// The same at a close whose monitor holds the counterparty's round the record moved onto: @@ -7628,7 +7863,7 @@ mod tests { old_block_time: None, }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().unwrap(); + let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().unwrap(); assert!( matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) ); @@ -7638,14 +7873,20 @@ mod tests { .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + 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.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// A round of ours nothing had broadcast when the counterparty's round locked — our @@ -7667,7 +7908,10 @@ mod tests { 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_store.get(&id).await.unwrap().is_some(), "the round was recorded"); + assert!( + wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some(), + "the round was recorded" + ); wallet .resolve_promoted_splice_round( @@ -7678,8 +7922,8 @@ mod tests { .await .unwrap(); - assert!(wallet.payment_store.get(&id).await.unwrap().is_none()); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + 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 @@ -7695,7 +7939,8 @@ mod tests { let rounds = [(counterparty_txid, None), (txid, Some(contribution))]; let id = record_broadcast_rounds(&wallet, &tx, &rounds).await; wallet - .payment_store + .payment_stores + .payment_store() .mutate(&id, |existing| { let mut update = PaymentDetailsUpdate::new(id); update.status = Some(PaymentStatus::Failed); @@ -7704,7 +7949,7 @@ mod tests { }) .await .unwrap(); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); wallet .resolve_promoted_splice_round( @@ -7715,9 +7960,15 @@ mod tests { .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Failed); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + 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 @@ -7737,18 +7988,36 @@ mod tests { 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_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Pending); - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); assert_eq!(entry.locked_rounds(), &[counterparty_txid]); wallet .resolve_closed_channel_splice_rounds(channel_id, &[counterparty_txid]) .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Failed); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// A payment whose round LDK promoted before is kept when a later splice's round is promoted @@ -7778,18 +8047,41 @@ mod tests { .unwrap(); for (id, locked) in [(first_id, first_txid), (second_id, second_txid)] { - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Pending); - let entry = - wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + 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_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Pending); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .is_some()); } } @@ -7806,7 +8098,13 @@ mod tests { 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_store.get(&id).await.unwrap().expect("the record exists"); + 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, @@ -7815,7 +8113,13 @@ mod tests { ); wallet.record_signed_funding(&bump_tx, &candidates).await.unwrap(); record_unseen_round(&wallet, &bump_tx).await; - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record exists"); + 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); @@ -7824,11 +8128,23 @@ mod tests { .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + 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.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + 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]); } @@ -7854,16 +8170,31 @@ mod tests { .await .unwrap(); } - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); assert_eq!(entry.locked_rounds(), &[txid]); wallet .resolve_closed_channel_splice_rounds(channel_id, &[later_funding_txid]) .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Pending); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_some(), "the entry stays"); + 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 @@ -7887,9 +8218,21 @@ mod tests { .resolve_closed_channel_splice_rounds(channel_id, &[later_funding_txid]) .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Pending); - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + 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)); } @@ -7929,14 +8272,32 @@ mod tests { for _ in 0..2 { wallet.drop_abandoned_splice_rounds(channel_id, &held).await.unwrap(); } - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Pending); - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + 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_store.get(&id).await.unwrap().expect("the record stays"); + 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, @@ -7946,7 +8307,7 @@ mod tests { tx_type: Some(TransactionType::InteractiveFunding { .. }), } if txid == bump_txid )); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// The close leaves a payment alone while the monitor watches a round of ours in its record: @@ -7966,9 +8327,21 @@ mod tests { .resolve_closed_channel_splice_rounds(channel_id, &[funding_txid, bump_txid]) .await .unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Pending); - let entry = wallet.pending_payment_store.get(&id).await.unwrap().expect("the entry stays"); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .expect("the entry stays"); assert_eq!(entry.candidates().len(), 2); } @@ -7989,7 +8362,8 @@ mod tests { timestamp: 1_700_000_000, }; wallet - .payment_store + .payment_stores + .payment_store() .mutate(&id, |existing| { let mut updated = existing?.clone(); if let PaymentKind::Onchain { status, .. } = &mut updated.kind { @@ -8001,13 +8375,19 @@ mod tests { .await .unwrap(); wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + 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.pending_payment_store.get(&id).await.unwrap().is_some()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); } /// Failing the payment writes the record before it removes the entry; the close replayed after @@ -8021,7 +8401,8 @@ mod tests { let txid = tx.compute_txid(); let id = record_broadcast_rounds(&wallet, &tx, &[(txid, Some(contribution))]).await; wallet - .payment_store + .payment_stores + .payment_store() .mutate(&id, |existing| { let mut update = PaymentDetailsUpdate::new(id); update.status = Some(PaymentStatus::Failed); @@ -8031,9 +8412,15 @@ mod tests { .await .unwrap(); wallet.resolve_closed_channel_splice_rounds(channel_id, &[]).await.unwrap(); - let payment = wallet.payment_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Failed); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } /// The close resolves every record of the channel — two splices signed under different @@ -8059,9 +8446,21 @@ mod tests { 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_store.get(&id).await.unwrap().expect("the record stays"); + let payment = wallet + .payment_stores + .payment_store() + .get(&id) + .await + .unwrap() + .expect("the record stays"); assert_eq!(payment.status, PaymentStatus::Failed); - assert!(wallet.pending_payment_store.get(&id).await.unwrap().is_none()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&id) + .await + .unwrap() + .is_none()); } } @@ -8121,13 +8520,15 @@ mod tests { // 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_store + .payment_stores + .payment_store() .get(&PaymentId(txid.to_byte_array())) .await .unwrap() .is_none()); let payment = wallet - .payment_store + .payment_stores + .payment_store() .get(&figures.funding_payment_id) .await .unwrap() @@ -8185,7 +8586,8 @@ mod tests { let payment_id = PaymentId(claim_txid.to_byte_array()); let unnamed = wallet - .payment_store + .payment_stores + .payment_store() .get(&payment_id) .await .unwrap() @@ -8208,7 +8610,13 @@ mod tests { .unwrap(); wallet.name_recorded_transaction(claim_txid).await; - let named = wallet.payment_store.get(&payment_id).await.unwrap().expect("the record stays"); + let named = wallet + .payment_stores + .payment_store() + .get(&payment_id) + .await + .unwrap() + .expect("the record stays"); assert!( matches!( named.kind, @@ -8254,7 +8662,8 @@ mod tests { wallet.update_payment_store(vec![seen]).await.unwrap(); let payment_id = PaymentId(txid.to_byte_array()); - let recorded = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); + let recorded = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!(recorded.direction, PaymentDirection::Outbound); assert!(matches!( recorded.kind, @@ -8344,7 +8753,8 @@ mod tests { let payment_id = PaymentId(sweep_txid.to_byte_array()); let unnamed = wallet - .payment_store + .payment_stores + .payment_store() .get(&payment_id) .await .unwrap() @@ -8370,7 +8780,13 @@ mod tests { 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_store.get(&payment_id).await.unwrap().expect("the record stays"); + 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]); diff --git a/src/wallet/payment_stores.rs b/src/wallet/payment_stores.rs new file mode 100644 index 0000000000..e00f5d598e --- /dev/null +++ b/src/wallet/payment_stores.rs @@ -0,0 +1,149 @@ +// 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::ops::Deref; +use std::sync::Arc; + +use lightning::ln::channelmanager::PaymentId; + +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 + } + + /// 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 + } +} From 7e7bc58728e914130cb5a508c5f174845a026d31 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 21:35:58 +0200 Subject: [PATCH 18/23] Skip a replacement whose payment record is gone What a transaction's facts record names its payment from the moment a round is signed, which is before wallet sync creates the record and for as long as the facts are kept after `remove_payment` has taken it away. Those facts describe a transaction that happened and still classify later ones, so a bookkeeping removal leaves them where they are. Resolving a replaced transaction can therefore name a payment nothing holds a record of. That now skips the event, as a transaction resolving to no payment at all already did, rather than failing: the failure abandoned every remaining event of the batch, and the wallet's own view of the chain went unpersisted with it, discarding an ordinary sync. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 100 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 11 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index aa89dae624..85ff644fd8 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -735,17 +735,20 @@ 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 sync itself records a transaction the first time it observes it, - // before anything can report it replaced. So we can safely fetch it here. - let stored_payment = stores.payment(&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)?; + // 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 @@ -2912,6 +2915,10 @@ impl Wallet { /// entry indexing its txids. An orphaned entry would keep resolving those txids to the removed /// record — routing later wallet-sync events to a payment that no longer exists — and nothing /// would ever clean it up, since graduation only removes entries whose record is still live. + /// + /// What this node recorded about the transactions themselves stays behind: those facts + /// describe transactions that happened, and classifying a later transaction — a close + /// spending a funding output, say — still reads them. pub(crate) async fn remove_payment(&self, payment_id: &PaymentId) -> Result<(), Error> { // Hold the cross-store lock so the two-store removal cannot interleave with a sync arm's // or a funding-record writer's resolve-then-write sequence. The pending entry goes first: a failure @@ -6442,6 +6449,77 @@ mod tests { 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. From 5402975a496640243cb0c2386982558b62b7f445 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 21:05:06 +0200 Subject: [PATCH 19/23] Drop recorded channel facts nothing needs anymore The store of what this node's channels reported about the transactions they produced grew for the lifetime of the node: nothing ever removed a record, so a node kept evidence about channels it had settled years ago. A transaction's record now goes once every use this node has for it is over: nothing has been learned about the transaction for about a year, none of the channels it names is still held by the channel manager, the chain monitor or the output sweeper, no pending payment still refers to it, and every spend the wallet holds of a channel funding it records is confirmed to twice the depth that counts as safe from a reorg, with its own payment settled. Any one of those keeps the record, and the wallet keeps everything while it cannot reach the node's channel state at all, so the loss of that view is never mistaken for a node with no channels. A funding the wallet holds no spend of does not keep it: a commitment transaction paying none of the wallet's scripts never enters the wallet's graph, so a channel closed that way would otherwise keep its record for good, and whether the channel may still produce a transaction is already answered by whether the node still holds it. The check shares the chain tip pass that graduates payments and resumes where the previous tip left it, so it costs one page of records a block however large the store is, and it runs after the pass has named what it could. A record is dropped only while it still is the one the check looked at, since a producer may have reported something about the transaction in between. Because a payment is classified when its transaction is observed, expiring a record never takes a classification back. It means a transaction of a long-resolved channel, met for the first time after its evidence expired, is reported without one -- which the public API now says. Co-Authored-By: HAL 9000 Co-Authored-By: Claude Fable 5.1 --- src/builder.rs | 9 + src/config.rs | 20 ++ src/data_store.rs | 68 +++++ src/payment/store.rs | 14 +- src/wallet/mod.rs | 568 ++++++++++++++++++++++++++++++++++++++- src/wallet/provenance.rs | 290 +++++++++++++++++++- 6 files changed, 959 insertions(+), 10 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index a6b8673b4f..bfe21e5057 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -112,6 +112,7 @@ use crate::types::{ PeerManager, PendingPaymentStore, }; use crate::wallet::persist::{read_address_pool, KVStoreWalletPersister}; +use crate::wallet::provenance::NodeChannelLiveness; use crate::wallet::Wallet; use crate::{Node, NodeMetrics, PersistedNodeMetrics}; @@ -2457,6 +2458,14 @@ fn build_with_store_internal( }, }; + // The wallet drops the facts it recorded for a channel once nothing holds that channel + // anymore, which it can only ask now that the node's channel state exists. + wallet.set_channel_liveness(Arc::new(NodeChannelLiveness::new( + &channel_manager, + &chain_monitor, + &output_sweeper, + ))); + let event_queue = match event_queue_res { Ok(event_queue) => Arc::new(event_queue), Err(e) => { diff --git a/src/config.rs b/src/config.rs index c9c7372ca9..60c7497771 100644 --- a/src/config.rs +++ b/src/config.rs @@ -81,6 +81,26 @@ pub(crate) const CHANNEL_TX_FACTS_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::n // 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 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 52d95c76f1..8d51660996 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; @@ -1150,6 +1188,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/payment/store.rs b/src/payment/store.rs index ec5bd38b8b..cb459ab7ab 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -403,6 +403,14 @@ impl_writeable_tlv_based!(Channel, { /// /// 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 { @@ -499,8 +507,10 @@ pub enum PaymentKind { /// /// `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, and for a - /// transaction whose channel's report of it could not be recorded. + /// 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. diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 85ff644fd8..be7cd2607e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -9,7 +9,7 @@ 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; @@ -56,7 +56,10 @@ use lightning_invoice::RawBolt11Invoice; use payment_stores::{PaymentStores, PaymentStoresGuard}; use persist::KVStoreWalletPersister; -use crate::config::{Config, ADDRESS_POOL_SIZE}; +use crate::config::{ + Config, ADDRESS_POOL_SIZE, 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}; @@ -71,7 +74,10 @@ use crate::payment::{ }; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; -use crate::wallet::provenance::{ChannelTxFacts, LocalFundingFigures, TxProvenance}; +use crate::wallet::provenance::{ + ChannelLiveness, ChannelTxFacts, FactsRetention, LocalFundingFigures, RetentionCheck, + TxProvenance, +}; use crate::{ChainSource, Error}; pub(crate) enum OnchainSendAmount { @@ -169,6 +175,11 @@ pub(crate) struct Wallet { // 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 { @@ -197,6 +208,19 @@ impl Wallet { logger, 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"); } } @@ -208,6 +232,8 @@ impl Wallet { /// overwriting it: one of the two producers is wrong, and the recorded facts came first. pub(crate) async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) -> Result<(), Error> { 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 conflict = None; @@ -268,6 +294,11 @@ impl Wallet { } } + /// 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. /// @@ -633,6 +664,10 @@ impl Wallet { 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"); @@ -876,6 +911,151 @@ impl Wallet { Ok(()) } + /// 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. + /// + /// Nothing here is reported to the caller: dropping records is housekeeping, and failing the + /// chain tip pass over it would cost the payment graduations it shares the pass with. + async fn prune_channel_tx_facts(&self, tip_height: u32) { + let Some(live_channels) = self.channel_liveness.get().and_then(|l| l.live_channels()) + else { + return; + }; + let pending_txids = self.pending_referenced_txids().await; + + 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; + return; + }, + }; + + 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) => { + log_debug!( + self.logger, + "Dropped what was recorded about transaction {}: nothing needs it anymore", + txid, + ); + }, + Ok(false) => {}, + Err(e) => log_error!( + self.logger, + "Failed to drop what was recorded about transaction {}: {}", + txid, + e, + ), + } + } + + match page.next_page_token { + Some(token) => walk.cursor = Some(token), + None => { + // The walk has been all the way round; start the next one from the beginning. + walk.cursor = None; + break; + }, + } + } + } + + /// Drops the recorded facts `facts` was read as, and reports whether anything was dropped. + /// + /// 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. + /// + /// 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; + } + + 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, + } + }) + }) + } + + /// 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 @@ -3868,7 +4048,7 @@ mod tests { test_funding_contribution_with_outputs, test_funding_contribution_with_parts, }; use crate::types::{DynStore, DynStoreWrapper}; - use crate::wallet::provenance::{ChannelOutputRole, LocalFundingFigures}; + use crate::wallet::provenance::{live_channels_of, ChannelOutputRole, LocalFundingFigures}; use crate::{NodeMetrics, PersistedNodeMetrics}; const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; @@ -4141,6 +4321,21 @@ mod tests { 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(); @@ -8872,4 +9067,369 @@ mod tests { kind => panic!("unexpected kind {:?}", kind), } } + + /// The node's channel state as a test dictates it: the channels its channel manager lists, + /// the monitors its chain monitor holds, and the outputs its sweeper tracks. + #[derive(Default)] + struct TestChannelState { + channels: Vec, + monitors: Vec, + tracked_outputs: Vec>, + } + + /// A stand-in for the node's channel state. `None` stands for the state being unreachable, + /// as it is while the node is built and while it is torn down. + struct TestLiveness(Mutex>); + + impl TestLiveness { + fn holding(state: TestChannelState) -> Arc { + Arc::new(Self(Mutex::new(Some(state)))) + } + + fn holding_nothing() -> Arc { + Self::holding(TestChannelState::default()) + } + + fn unreachable() -> Arc { + Arc::new(Self(Mutex::new(None))) + } + } + + impl ChannelLiveness for TestLiveness { + fn live_channels(&self) -> Option> { + let locked = self.0.lock().unwrap(); + let state = locked.as_ref()?; + Some(live_channels_of( + state.channels.iter().copied(), + state.monitors.iter().copied(), + state.tracked_outputs.iter().copied(), + )) + } + } + + fn block_id_at(height: u32) -> BlockId { + let mut hash = [0u8; 32]; + hash[..4].copy_from_slice(&height.to_le_bytes()); + BlockId { height, hash: bitcoin::BlockHash::from_byte_array(hash) } + } + + /// 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 }], + } + } + + /// A wallet that recorded a channel's funding transaction and has since seen that funding + /// spent by a transaction confirmed at height 10, which no pending payment refers to. + /// Everything but the node's channel state and the chain tip is then in the state that lets + /// the recorded facts go. + async fn wallet_with_a_spent_funding( + store: Arc, channel: &Channel, + ) -> (Arc, Txid) { + let wallet = new_test_wallet(store, false).await; + let funding_txid = Txid::from_byte_array([41u8; 32]); + wallet + .record_channel_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + channel, + None, + ChannelOutputRole::Funding, + [0], + )) + .await + .unwrap(); + let close = tx_spending(&wallet, OutPoint { txid: funding_txid, vout: 0 }); + insert_confirmed_tx(&wallet, close, 10); + (wallet, funding_txid) + } + + /// Runs the chain tip pass at `height`, which is where recorded facts are dropped. + async fn chain_tip_changed(wallet: &Wallet, height: u32) { + { + let mut locked = wallet.inner.lock().unwrap(); + let chain = locked.latest_checkpoint().insert(block_id_at(height)); + locked.apply_update(Update { chain: Some(chain), ..Default::default() }).unwrap(); + } + let event = WalletEvent::ChainTipChanged { + old_tip: block_id_at(height - 1), + new_tip: block_id_at(height), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + } + + /// 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 the_facts_of_a_resolved_channel_are_reclaimed() { + 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()); + + chain_tip_changed(&wallet, LONG_AFTER).await; + + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_none(), + "nothing holds the channel and its funding is long spent", + ); + } + + #[tokio::test] + async fn the_facts_of_a_channel_the_node_still_holds_are_kept() { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + + let still_held = [ + ( + "the channel manager lists it", + TestChannelState { channels: vec![channel_id], ..Default::default() }, + ), + ( + "the chain monitor holds its monitor", + TestChannelState { monitors: vec![channel_id], ..Default::default() }, + ), + ( + "the sweeper tracks an output of it", + TestChannelState { tracked_outputs: vec![Some(channel_id)], ..Default::default() }, + ), + ]; + + for (why, state) in still_held { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::holding(state)); + + chain_tip_changed(&wallet, LONG_AFTER).await; + + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_some(), + "the facts are still needed: {}", + why, + ); + } + } + + #[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()); + } + + #[tokio::test] + async fn the_facts_of_a_channel_are_kept_until_the_age_cap() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + wallet.set_channel_liveness(TestLiveness::holding_nothing()); + + // The facts were recorded at height 0, before the spend moved the wallet's tip. + chain_tip_changed(&wallet, CHANNEL_TX_FACTS_RETENTION_BLOCKS - 1).await; + assert!( + wallet.channel_tx_facts(&funding_txid).await.is_some(), + "a block short of the cap is short of it", + ); + + chain_tip_changed(&wallet, CHANNEL_TX_FACTS_RETENTION_BLOCKS).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_none()); + } + + /// 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 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 the_facts_of_a_funding_spent_outside_the_wallets_view_are_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_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", + ); + + *liveness.0.lock().unwrap() = Some(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", + ); + } + + #[tokio::test] + async fn the_facts_of_a_funding_whose_spend_is_still_shallow_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_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", + ); + + chain_tip_changed(&wallet, LONG_AFTER + 1).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_none()); + } + + #[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 own record is still pending: whatever it is has yet + // to be written onto that record, and these facts are what would decide 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(); + + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); + + wallet.payment_stores.pending_payment_store().remove(&id).await.unwrap(); + chain_tip_changed(&wallet, LONG_AFTER + 1).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_none()); + } + + #[tokio::test] + async fn 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(); + + chain_tip_changed(&wallet, LONG_AFTER).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); + + // Once the payment is no longer pending, nothing refers to the transaction anymore. + wallet.payment_stores.pending_payment_store().remove(&id).await.unwrap(); + chain_tip_changed(&wallet, LONG_AFTER + 1).await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_none()); + } + + #[tokio::test] + async fn a_record_a_producer_changed_since_the_check_is_not_dropped() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let channel = Channel { counterparty_node_id, channel_id }; + let (wallet, funding_txid) = + wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; + + let evaluated = wallet.channel_tx_facts(&funding_txid).await.expect("recorded above"); + + // A producer reports a further output of the same transaction between the decision and + // the removal — the way a channel comes back into play for a record already judged + // disposable, since whatever makes it live again reports what it resolved. + let reopened = Channel { counterparty_node_id, channel_id: ChannelId([9u8; 32]) }; + wallet + .record_channel_tx_facts(ChannelTxFacts::new(funding_txid).with_outputs( + &reopened, + None, + ChannelOutputRole::Spendable, + [1], + )) + .await + .unwrap(); + + assert!(!wallet.drop_recorded_facts(evaluated).await.unwrap()); + let kept = wallet.channel_tx_facts(&funding_txid).await.expect("the record stays"); + assert_eq!(kept.outputs.len(), 2); + } } diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index 31e975c6e2..cffb7ae713 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -14,21 +14,23 @@ //! 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; +use std::collections::{HashMap, HashSet}; use std::fmt; +use std::sync::{Arc, 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::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use crate::data_store::{StorableObject, StorableObjectId}; use crate::hex_utils; use crate::payment::store::{Channel, TransactionType}; use crate::payment::PaymentDirection; -use crate::types::UserChannelId; +use crate::types::{ChainMonitor, ChannelManager, Sweeper, UserChannelId}; /// The part a transaction output plays in a channel. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -116,6 +118,10 @@ pub(crate) struct ChannelTxFacts { /// 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, { @@ -123,12 +129,25 @@ impl_writeable_tlv_based!(ChannelTxFacts, { (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 } + 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`. @@ -191,6 +210,9 @@ impl ChannelTxFacts { /// 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. + /// + /// 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, ChannelTxFactsConflict> { @@ -246,7 +268,149 @@ impl ChannelTxFacts { _ => {}, } - Ok(changed.then_some(self)) + if !changed { + return Ok(None); + } + self.recorded_at_height = self.recorded_at_height.max(incoming.recorded_at_height); + Ok(Some(self)) + } +} + +/// The channels this node still holds on-chain state for, as the retention of recorded facts +/// consults them. +pub(crate) trait ChannelLiveness: Send + Sync { + /// The channels the node's channel manager, chain monitor or output sweeper still knows + /// about, or `None` when that state cannot be consulted at all. Nothing is dropped while the + /// answer is `None`: without it there is no way to tell which facts are still needed. + fn live_channels(&self) -> Option>; +} + +/// The node's own channel state, as [`ChannelLiveness`]. +/// +/// The handles are weak because the node's channel state holds the wallet in turn, through the +/// keys manager, so strong ones here would keep both alive for good. A handle that no longer +/// upgrades means the node is being torn down, which is no time to be dropping records. +pub(crate) struct NodeChannelLiveness { + channel_manager: Weak, + chain_monitor: Weak, + output_sweeper: Weak, +} + +impl NodeChannelLiveness { + pub(crate) fn new( + channel_manager: &Arc, chain_monitor: &Arc, + output_sweeper: &Arc, + ) -> Self { + Self { + channel_manager: Arc::downgrade(channel_manager), + chain_monitor: Arc::downgrade(chain_monitor), + output_sweeper: Arc::downgrade(output_sweeper), + } + } +} + +impl ChannelLiveness for NodeChannelLiveness { + fn live_channels(&self) -> Option> { + let channel_manager = self.channel_manager.upgrade()?; + let chain_monitor = self.chain_monitor.upgrade()?; + let output_sweeper = self.output_sweeper.upgrade()?; + + Some(live_channels_of( + channel_manager.list_channels().into_iter().map(|channel| channel.channel_id), + chain_monitor.list_monitors(), + output_sweeper.tracked_spendable_outputs().into_iter().map(|output| output.channel_id), + )) + } +} + +/// The channels named by a node's open channels, by the monitors it holds and by the spendable +/// outputs its sweeper tracks, each named once. +/// +/// A channel counts as held if any one of the three names it: an open channel can still produce +/// transactions, a monitor can still claim from one, and a tracked output has yet to be swept. +/// A tracked output that names no channel — one the sweeper was given without one — says nothing +/// about which channel is held and is left out. +pub(crate) fn live_channels_of( + channels: impl IntoIterator, monitors: impl IntoIterator, + tracked_outputs: impl IntoIterator>, +) -> HashSet { + let mut live: HashSet = channels.into_iter().collect(); + live.extend(monitors); + live.extend(tracked_outputs.into_iter().flatten()); + live +} + +/// How far the pruning of recorded facts has walked the store. +/// +/// The walk visits every record over consecutive chain tips rather than in one pass, so a batch +/// costs one page however large the store is. +pub(crate) struct FactsRetention { + /// Where the walk resumes, held by the pruning pass alone. + walk: tokio::sync::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, +} + +impl FactsRetention { + pub(crate) fn new() -> Self { + Self { walk: tokio::sync::Mutex::new(FactsWalk { cursor: 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 + } +} + +/// 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) } } @@ -1130,4 +1294,122 @@ mod tests { ); 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_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); + } } From f8c099410b37dab7a132183c0cd0c4eef08995a0 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 5 Oct 2026 11:33:10 -0500 Subject: [PATCH 20/23] f - Say what the pending-spender retention test exercises The test's comment said the spender's record had yet to learn what the transaction is, but the record it inserts is typed already. What keeps the facts is that the pending store still refers to the spender. This change was made with the help of an AI tool. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index be7cd2607e..cc9c8d0637 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -9344,8 +9344,9 @@ mod tests { wallet_with_a_spent_funding(Arc::clone(&store), &channel).await; wallet.set_channel_liveness(TestLiveness::holding_nothing()); - // The spend is long buried, but its own record is still pending: whatever it is has yet - // to be written onto that record, and these facts are what would decide it. + // 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 From e64d06f14d05de9d1b99830fe43e521a753aecbf Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 29 Sep 2026 21:05:53 +0200 Subject: [PATCH 21/23] Bound what the channel facts store may hold Records of what a channel reported about its transactions are dropped only once that channel has resolved, so between two of those passes a counterparty decides how much this node stores: how many HTLCs it puts on a commitment transaction, and how many channels and negotiated fundings it drives. A record is now refused once it would outgrow what one record may take up, and a transaction this node holds no record of at all is refused once the store holds as many records as it may. What this node already took on is still kept up to date however full the store is, so an obligation is never half-kept; refusing is only ever about taking on a new one. The store's size comes from the walk the dropping pass already makes: 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 every transaction in memory. A refusal is reported as an incomplete record rather than as a failure. There is nothing to retry -- a replay would meet the same full store -- and the cost is a transaction reported without a classification, which is bounded loss of detail rather than a lost write. Co-Authored-By: HAL 9000 --- src/config.rs | 17 ++++ src/event.rs | 30 ++++-- src/wallet/mod.rs | 154 ++++++++++++++++++++++++++++--- src/wallet/provenance.rs | 195 ++++++++++++++++++++++++++++++++++----- 4 files changed, 354 insertions(+), 42 deletions(-) diff --git a/src/config.rs b/src/config.rs index 60c7497771..6375ed2e54 100644 --- a/src/config.rs +++ b/src/config.rs @@ -91,6 +91,23 @@ pub(crate) const CHANNEL_TX_FACTS_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsiz // 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 diff --git a/src/event.rs b/src/event.rs index c56b084ccf..d629c9f8e4 100644 --- a/src/event.rs +++ b/src/event.rs @@ -63,7 +63,7 @@ use crate::types::{ ChainMonitor, CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, }; -use crate::wallet::provenance::{ChannelOutputRole, ChannelTxFacts}; +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, @@ -776,7 +776,10 @@ where async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) { let txid = facts.txid; match self.wallet.record_channel_tx_facts(facts).await { - Ok(()) => self.wallet.name_recorded_transaction(txid).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, @@ -844,14 +847,25 @@ where ChannelOutputRole::Funding, [vout as u32], ); - if let Err(e) = self.wallet.record_channel_tx_facts(facts).await { - log_error!( + 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, - "Failed to record the funding transaction of channel {}: {}", + "Funding channel {} with a transaction this node has no room to describe", temporary_channel_id, - e, - ); - return Err(ReplayEvent()); + ), + Err(e) => { + log_error!( + self.logger, + "Failed to record the funding transaction of channel {}: {}", + temporary_channel_id, + e, + ); + return Err(ReplayEvent()); + }, } } else { log_error!( diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index cc9c8d0637..0b0aeeeb5d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -57,7 +57,7 @@ use payment_stores::{PaymentStores, PaymentStoresGuard}; use persist::KVStoreWalletPersister; use crate::config::{ - Config, ADDRESS_POOL_SIZE, CHANNEL_TX_FACTS_PRUNE_PAGES_PER_TIP, + Config, ADDRESS_POOL_SIZE, CHANNEL_TX_FACTS_MAX_RECORDS, CHANNEL_TX_FACTS_PRUNE_PAGES_PER_TIP, CHANNEL_TX_FACTS_RETENTION_BLOCKS, }; #[cfg(test)] @@ -75,8 +75,8 @@ use crate::payment::{ use crate::runtime::Runtime; use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; use crate::wallet::provenance::{ - ChannelLiveness, ChannelTxFacts, FactsRetention, LocalFundingFigures, RetentionCheck, - TxProvenance, + ChannelLiveness, ChannelTxFacts, ChannelTxFactsRejection, FactsRecordOutcome, FactsRetention, + LocalFundingFigures, RetentionCheck, TxProvenance, }; use crate::{ChainSource, Error}; @@ -230,27 +230,66 @@ impl Wallet { /// 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. - pub(crate) async fn record_channel_tx_facts(&self, facts: ChannelTxFacts) -> Result<(), Error> { + /// + /// 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 conflict = None; + 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) => { - conflict = Some(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 }, }, - None => Some(facts), }) .await?; + if created { + self.facts_retention.record_created(); + } - match conflict { + 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, @@ -260,7 +299,7 @@ impl Wallet { ); Err(Error::PersistenceFailed) }, - None => Ok(()), + None => Ok(FactsRecordOutcome::Recorded), } } @@ -921,7 +960,8 @@ impl Wallet { /// 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. + /// 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. @@ -941,9 +981,11 @@ impl Wallet { // 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 { @@ -963,6 +1005,8 @@ impl Wallet { 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", @@ -982,8 +1026,11 @@ impl Wallet { match page.next_page_token { Some(token) => walk.cursor = Some(token), None => { - // The walk has been all the way round; start the next one from the beginning. + // 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; }, } @@ -9185,6 +9232,8 @@ mod tests { 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)); } #[tokio::test] @@ -9433,4 +9482,87 @@ mod tests { let kept = wallet.channel_tx_facts(&funding_txid).await.expect("the record stays"); assert_eq!(kept.outputs.len(), 2); } + + #[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 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, + ); + + // A walk of the store found it as full as it may get. + wallet.facts_retention.walk_completed(CHANNEL_TX_FACTS_MAX_RECORDS); + + // 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); + + // ...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!( + 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, + ); + } } diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index cffb7ae713..04b2237cb6 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -16,7 +16,7 @@ use std::collections::{HashMap, HashSet}; use std::fmt; -use std::sync::{Arc, Weak}; +use std::sync::{Arc, Mutex, Weak}; use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; @@ -24,8 +24,10 @@ 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}; @@ -209,15 +211,16 @@ impl ChannelTxFacts { /// Outputs are unioned by `vout`, while `self_role` and `local_figures` are filled in only /// where they are still absent. Re-reporting a fact is therefore a no-op, which is what lets /// a producer replay its event without consequence. Reporting a *different* value for - /// something already recorded is rejected, leaving the recorded facts as they were. + /// 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, ChannelTxFactsConflict> { + ) -> Result, ChannelTxFactsRejection> { if self.txid != incoming.txid { - return Err(ChannelTxFactsConflict::Txid { + return Err(ChannelTxFactsRejection::Txid { recorded: self.txid, incoming: incoming.txid, }); @@ -228,7 +231,7 @@ impl ChannelTxFacts { match self.outputs.iter().find(|recorded| recorded.vout == output.vout) { Some(recorded) if recorded == output => {}, Some(recorded) => { - return Err(ChannelTxFactsConflict::Output { + return Err(ChannelTxFactsRejection::Output { recorded: recorded.clone(), incoming: output.clone(), }) @@ -242,7 +245,7 @@ impl ChannelTxFacts { match (&self.self_role, &incoming.self_role) { (Some(recorded), Some(incoming)) if recorded != incoming => { - return Err(ChannelTxFactsConflict::SelfRole { + return Err(ChannelTxFactsRejection::SelfRole { recorded: recorded.clone(), incoming: incoming.clone(), }) @@ -256,7 +259,7 @@ impl ChannelTxFacts { match (&self.local_figures, &incoming.local_figures) { (Some(recorded), Some(incoming)) if recorded != incoming => { - return Err(ChannelTxFactsConflict::LocalFigures { + return Err(ChannelTxFactsRejection::LocalFigures { recorded: recorded.clone(), incoming: incoming.clone(), }) @@ -272,8 +275,37 @@ impl ChannelTxFacts { return Ok(None); } self.recorded_at_height = self.recorded_at_height.max(incoming.recorded_at_height); - Ok(Some(self)) + 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, } /// The channels this node still holds on-chain state for, as the retention of recorded facts @@ -340,30 +372,72 @@ pub(crate) fn live_channels_of( live } -/// How far the pruning of recorded facts has walked the store. +/// How far the pruning of recorded facts has walked the store, and how many records that walk +/// found there. /// -/// The walk visits every record over consecutive chain tips rather than in one pass, so a batch -/// costs one page however large the store is. +/// 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, held by the pruning pass alone. + /// 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 }) } + 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 @@ -433,12 +507,13 @@ impl StorableObject for ChannelTxFacts { } } -/// A reported fact that contradicts one already recorded for the same transaction. +/// A reported fact that was not recorded, leaving what is on record as it was. /// -/// Facts are immutable, so this means two producers disagree about the same transaction, which -/// they cannot both be right about. The recorded value stands and the reported one is dropped. +/// 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 ChannelTxFactsConflict { +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. @@ -447,9 +522,24 @@ pub(crate) enum ChannelTxFactsConflict { 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 fmt::Display for ChannelTxFactsConflict { +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 } => { @@ -464,6 +554,12 @@ impl fmt::Display for ChannelTxFactsConflict { 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) + }, } } } @@ -821,7 +917,7 @@ mod tests { ChannelTxFacts::new(txid).with_outputs(&channel, None, ChannelOutputRole::Anchor, [0]); match recorded.clone().merged_with(&conflicting) { - Err(ChannelTxFactsConflict::Output { recorded, incoming }) => { + Err(ChannelTxFactsRejection::Output { recorded, incoming }) => { assert_eq!(recorded.role, ChannelOutputRole::Funding); assert_eq!(incoming.role, ChannelOutputRole::Anchor); }, @@ -842,7 +938,7 @@ mod tests { ); assert!(matches!( recorded.merged_with(&reattributed), - Err(ChannelTxFactsConflict::Output { .. }) + Err(ChannelTxFactsRejection::Output { .. }) )); } @@ -858,7 +954,7 @@ mod tests { }); match recorded.clone().merged_with(&conflicting) { - Err(ChannelTxFactsConflict::SelfRole { recorded, incoming }) => { + Err(ChannelTxFactsRejection::SelfRole { recorded, incoming }) => { assert_eq!(recorded, TransactionType::Funding { channels: vec![channel.clone()] }); assert_eq!( incoming, @@ -884,7 +980,7 @@ mod tests { assert!(matches!( recorded.merged_with(&conflicting), - Err(ChannelTxFactsConflict::LocalFigures { .. }) + Err(ChannelTxFactsRejection::LocalFigures { .. }) )); } @@ -894,7 +990,7 @@ mod tests { let other = ChannelTxFacts::new(test_txid(8)); assert_eq!( recorded.merged_with(&other), - Err(ChannelTxFactsConflict::Txid { recorded: test_txid(7), incoming: test_txid(8) }) + Err(ChannelTxFactsRejection::Txid { recorded: test_txid(7), incoming: test_txid(8) }) ); } @@ -1395,6 +1491,41 @@ mod tests { ); } + #[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); @@ -1412,4 +1543,22 @@ mod tests { 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"); + } } From 9c40360bd25187b507d3428ca19667cf32c861b6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 2 Oct 2026 21:39:44 -0500 Subject: [PATCH 22/23] Record what the node's channel state holds when it starts A channel's facts are recorded by the event that creates its outputs: the funding when the channel is opened, the outputs a channel resolved to this node when LDK reports them. A node upgraded from a version without the facts store holds channels none of those events will fire for again, and a report that failed in an earlier session is not repeated either. A close or a sweep of such a channel then goes unclassified for good. On start, before anything syncs, the node now records what LDK still holds for its channels: the funding output of every channel the channel manager lists or the chain monitor watches, and every output the sweeper tracks for a channel. A node whose producers reported everything finds each of those on record already and writes nothing. A failure to record one costs that output's classification until the next start, not the start itself. The channel manager alone knows a channel's local identifier; a monitor and the sweeper report without it. A fact reported without the identifier therefore no longer contradicts one recorded with it: the identifier fills in where absent and counts only where both reports carry one. This change was made with the help of an AI tool. Co-Authored-By: Claude Fable 5.1 --- src/builder.rs | 3 +- src/lib.rs | 6 + src/wallet/mod.rs | 150 ++++++++++++++++++++- src/wallet/provenance.rs | 230 ++++++++++++++++++++++++++++++-- tests/integration_tests_rust.rs | 67 ++++++++++ 5 files changed, 443 insertions(+), 13 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index bfe21e5057..05db960b72 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2459,7 +2459,8 @@ fn build_with_store_internal( }; // The wallet drops the facts it recorded for a channel once nothing holds that channel - // anymore, which it can only ask now that the node's channel state exists. + // 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, diff --git a/src/lib.rs b/src/lib.rs index 51cc30e590..d05a1e8eba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -369,6 +369,12 @@ 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 diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 0b0aeeeb5d..503baaff34 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -224,6 +224,37 @@ impl Wallet { } } + /// 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. /// @@ -4094,8 +4125,11 @@ mod tests { use crate::payment::pending_payment_store::{ test_funding_contribution_with_outputs, test_funding_contribution_with_parts, }; - use crate::types::{DynStore, DynStoreWrapper}; - use crate::wallet::provenance::{live_channels_of, ChannelOutputRole, LocalFundingFigures}; + 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/*)"; @@ -9116,12 +9150,14 @@ mod tests { } /// 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. + /// 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, } /// A stand-in for the node's channel state. `None` stands for the state being unreachable, @@ -9152,6 +9188,11 @@ mod tests { state.tracked_outputs.iter().copied(), )) } + + fn held_outputs(&self) -> Option> { + let locked = self.0.lock().unwrap(); + Some(locked.as_ref()?.held_outputs.clone()) + } } fn block_id_at(height: u32) -> BlockId { @@ -9217,6 +9258,109 @@ mod tests { /// 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 startup_records_the_held_outputs_no_producer_reported() { + 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 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.clone()).await.unwrap(); + + let funding = |channel: &Channel, user_channel_id, txid, vout| HeldChannelOutput { + channel: channel.clone(), + user_channel_id, + role: ChannelOutputRole::Funding, + txid, + vout, + }; + 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() + })); + + wallet.record_held_channel_outputs().await; + + let open_facts = wallet.channel_tx_facts(&open_funding_txid).await.expect("kept"); + assert_eq!(open_facts.outputs, reported.outputs, "what was reported stays as it was"); + let closed_facts = + wallet.channel_tx_facts(&closed_funding_txid).await.expect("recorded at startup"); + 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)] + ); + + // 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!( + wallet.tx_provenance(close.compute_txid(), &close).await.classify(&close), + Some(TransactionType::CooperativeClose { + counterparty_node_id, + channel_id: closed.channel_id, + }) + ); + } + + #[tokio::test] + async fn startup_records_nothing_while_the_channel_state_is_unreachable() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + wallet.set_channel_liveness(TestLiveness::unreachable()); + + wallet.record_held_channel_outputs().await; + + let keys = store + .list_async( + CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_TX_FACTS_PERSISTENCE_SECONDARY_NAMESPACE, + ) + .await + .unwrap(); + assert!(keys.is_empty()); + } + #[tokio::test] async fn the_facts_of_a_resolved_channel_are_reclaimed() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/wallet/provenance.rs b/src/wallet/provenance.rs index 04b2237cb6..5be269ce02 100644 --- a/src/wallet/provenance.rs +++ b/src/wallet/provenance.rs @@ -83,6 +83,22 @@ impl_writeable_tlv_based!(ChannelOutputFact, { (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. /// @@ -192,6 +208,35 @@ impl ChannelTxFacts { .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); @@ -208,11 +253,12 @@ impl ChannelTxFacts { /// Merges `incoming` into these facts, returning the result, or `None` when `incoming` adds /// nothing to what is already recorded. /// - /// Outputs are unioned by `vout`, while `self_role` and `local_figures` are filled in only - /// where they are still absent. Re-reporting a fact is therefore a no-op, which is what lets - /// a producer replay its event without consequence. Reporting a *different* value for - /// something already recorded is rejected, leaving the recorded facts as they were, and so is - /// a report that would take the record past the size a single record is allowed. + /// 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. @@ -228,8 +274,13 @@ impl ChannelTxFacts { let mut changed = false; for output in &incoming.outputs { - match self.outputs.iter().find(|recorded| recorded.vout == output.vout) { - Some(recorded) if recorded == output => {}, + 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(), @@ -308,13 +359,36 @@ pub(crate) enum FactsRecordOutcome { Incomplete, } -/// The channels this node still holds on-chain state for, as the retention of recorded facts -/// consults them. +/// 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`]. @@ -353,6 +427,62 @@ impl ChannelLiveness for NodeChannelLiveness { 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 @@ -851,6 +981,56 @@ mod tests { })); } + #[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); @@ -942,6 +1122,38 @@ mod tests { )); } + #[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); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 440897de49..256224b9d1 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -664,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(); From c7ac2681495311ac5a0277f3b7c7446b2df65d58 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 5 Oct 2026 11:37:13 -0500 Subject: [PATCH 23/23] f - Make the two startup tests tell a no-op from an empty answer The unreachable-state test asserted an empty namespace against a state that held nothing, which an empty answer would satisfy as well as the unreachable one. The state now holds an output while unreachable, and the pass records it once the state can be consulted. The held-outputs test asserted the reported funding's outputs unchanged, which a same-content rewrite would satisfy too. The pass now runs at a later tip and the whole record, its date included, is asserted equal. This change was made with the help of an AI tool. Co-Authored-By: Claude Fable 5.1 --- src/wallet/mod.rs | 79 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 503baaff34..3c9baee2cb 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -9160,28 +9160,42 @@ mod tests { held_outputs: Vec, } - /// A stand-in for the node's channel state. `None` stands for the state being unreachable, - /// as it is while the node is built and while it is torn down. - struct TestLiveness(Mutex>); + /// 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, + } impl TestLiveness { fn holding(state: TestChannelState) -> Arc { - Arc::new(Self(Mutex::new(Some(state)))) + Arc::new(Self { state: Mutex::new(state), reachable: AtomicBool::new(true) }) } fn holding_nothing() -> Arc { Self::holding(TestChannelState::default()) } + /// 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 { - Arc::new(Self(Mutex::new(None))) + Self::unreachable_holding(TestChannelState::default()) + } + + fn reach(&self) { + self.reachable.store(true, Ordering::Release); } } impl ChannelLiveness for TestLiveness { fn live_channels(&self) -> Option> { - let locked = self.0.lock().unwrap(); - let state = locked.as_ref()?; + 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(), @@ -9190,8 +9204,10 @@ mod tests { } fn held_outputs(&self) -> Option> { - let locked = self.0.lock().unwrap(); - Some(locked.as_ref()?.held_outputs.clone()) + if !self.reachable.load(Ordering::Acquire) { + return None; + } + Some(self.state.lock().unwrap().held_outputs.clone()) } } @@ -9241,13 +9257,16 @@ mod tests { (wallet, funding_txid) } + /// 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(); + } + /// Runs the chain tip pass at `height`, which is where recorded facts are dropped. async fn chain_tip_changed(wallet: &Wallet, height: u32) { - { - let mut locked = wallet.inner.lock().unwrap(); - let chain = locked.latest_checkpoint().insert(block_id_at(height)); - locked.apply_update(Update { chain: Some(chain), ..Default::default() }).unwrap(); - } + set_chain_tip(wallet, height); let event = WalletEvent::ChainTipChanged { old_tip: block_id_at(height - 1), new_tip: block_id_at(height), @@ -9277,7 +9296,10 @@ mod tests { ChannelOutputRole::Funding, [0], ); - wallet.record_channel_tx_facts(reported.clone()).await.unwrap(); + 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(), @@ -9308,9 +9330,10 @@ mod tests { wallet.record_held_channel_outputs().await; let open_facts = wallet.channel_tx_facts(&open_funding_txid).await.expect("kept"); - assert_eq!(open_facts.outputs, reported.outputs, "what was reported stays as it was"); + 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 { @@ -9343,11 +9366,25 @@ mod tests { ); } + /// 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 startup_records_nothing_while_the_channel_state_is_unreachable() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; - wallet.set_channel_liveness(TestLiveness::unreachable()); + 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()); wallet.record_held_channel_outputs().await; @@ -9358,7 +9395,11 @@ mod tests { ) .await .unwrap(); - assert!(keys.is_empty()); + assert!(keys.is_empty(), "the state was not consulted"); + + liveness.reach(); + wallet.record_held_channel_outputs().await; + assert!(wallet.channel_tx_facts(&funding_txid).await.is_some()); } #[tokio::test] @@ -9498,7 +9539,7 @@ mod tests { "the node still holds the channel", ); - *liveness.0.lock().unwrap() = Some(TestChannelState::default()); + *liveness.state.lock().unwrap() = TestChannelState::default(); chain_tip_changed(&wallet, LONG_AFTER + 1).await; assert!( wallet.channel_tx_facts(&funding_txid).await.is_none(),