diff --git a/src/data_store.rs b/src/data_store.rs index 8d51660996..747e0b7ed6 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -470,6 +470,19 @@ where Ok(Some(new_object)) } + /// Like [`Self::mutate`], but allows the transformation to await fallible reads. + /// + /// The mutation lock remains held while `f` runs. This is useful when the new state must be + /// decided from an async read of another store without letting a concurrent writer invalidate + /// that decision. Callers must keep cross-store lock ordering consistent to avoid deadlocks. + pub(crate) async fn mutate_async(&self, id: &SO::Id, f: F) -> Result, Error> + where + F: FnOnce(Option) -> Fut, + Fut: Future, Error>>, + { + self.mutate_with(id, f).await + } + /// Returns whether an object is stored under `id`. pub(crate) async fn contains_key(&self, id: &SO::Id) -> Result { let _guard = self.mutation_lock.read().await; @@ -1246,6 +1259,43 @@ mod tests { assert_eq!(Some(expected), data_store.get(&id).await.unwrap()); } + #[tokio::test] + async fn mutate_async_awaits_fallible_reads() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let id = TestObjectId { id: [42u8; 4] }; + let other_id = TestObjectId { id: [43u8; 4] }; + let existing_object = TestObject::new(id, [23u8; 3]); + let other_object = TestObject::new(other_id, [24u8; 3]); + let data_store: DataStore> = DataStore::new( + vec![existing_object], + KeepAllEntries, + TEST_PRIMARY_NAMESPACE.to_string(), + TEST_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&store), + Arc::clone(&logger), + ); + let other_store: DataStore> = DataStore::new( + vec![other_object], + KeepAllEntries, + "other_datastore_test_primary".to_string(), + "other_datastore_test_secondary".to_string(), + store, + logger, + ); + + let result = data_store + .mutate_async(&id, |existing| async move { + let mut updated = existing.unwrap(); + updated.data = other_store.get(&other_id).await?.unwrap().data; + Ok(Some(updated)) + }) + .await; + let expected = TestObject::new(id, [24u8; 3]); + assert_eq!(Ok(Some(expected)), result); + assert_eq!(Some(expected), data_store.get(&id).await.unwrap()); + } + #[tokio::test] async fn mutate_runs_the_closure_without_the_cache_lock() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 959b017d63..089e43a96b 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -5,9 +5,13 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use bitcoin::Txid; -use lightning::impl_writeable_tlv_based; +use bitcoin::secp256k1::PublicKey; +use bitcoin::{TxOut, Txid}; +use lightning::chain::transaction::OutPoint as LdkOutPoint; use lightning::ln::channelmanager::PaymentId; +use lightning::ln::funding::FundingContribution; +use lightning::ln::types::ChannelId; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use crate::data_store::{StorableObject, StorableObjectUpdate, UpdatableObject}; use crate::payment::store::{Channel, PaymentDetailsUpdate, TransactionType}; @@ -44,14 +48,76 @@ impl_writeable_tlv_based!(FundingTxCandidate, { (6, awaiting_broadcast, required), }); +/// The parameters of the API call that initiated a splice, recording what was attempted +/// independently of the contribution built from them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SpliceKind { + /// [`Node::splice_in`] with a resolved amount. + /// + /// [`Node::splice_in`]: crate::Node::splice_in + In { amount_sats: u64 }, + /// [`Node::splice_out`] to the given outputs. + /// + /// [`Node::splice_out`]: crate::Node::splice_out + Out { outputs: Vec }, + /// [`Node::bump_channel_funding_fee`] of a pending splice. + /// + /// [`Node::bump_channel_funding_fee`]: crate::Node::bump_channel_funding_fee + Rbf {}, +} + +impl_writeable_tlv_based_enum!(SpliceKind, + (0, In) => { + (0, amount_sats, required), + }, + (2, Out) => { + (0, outputs, required_vec), + }, + (4, Rbf) => {}, +); + +/// A user-initiated splice that has been handed to LDK but is not yet guaranteed to survive a +/// restart. LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, and it +/// abandons an in-progress negotiation whenever the peer disconnects (which includes stopping the +/// node). Until the new funding transaction locks we keep enough state to recognize a splice LDK +/// no longer knows about and to describe events about it in terms of the original request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SpliceIntent { + /// The channel counterparty. + pub counterparty_node_id: PublicKey, + /// The channel being spliced. + pub channel_id: ChannelId, + /// The channel's funding outpoint when the splice was initiated. It only changes once a splice + /// locks, so a mismatch with the channel's current funding outpoint means the splice (or a + /// replacement) completed and the intent is stale. + pub pre_splice_funding_txo: LdkOutPoint, + /// The contribution handed to [`ChannelManager::funding_contributed`], kept to match later + /// events about the splice back to this intent. + /// + /// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed + pub contribution: FundingContribution, + /// The parameters of the originating API call. + pub kind: SpliceKind, +} + +impl_writeable_tlv_based!(SpliceIntent, { + (0, counterparty_node_id, required), + (2, channel_id, required), + (4, pre_splice_funding_txo, required), + (6, contribution, required), + (8, kind, required), +}); + /// A pending payment tracked by LDK Node, keyed by [`PaymentId`]. /// /// Each part of an entry is written by a different subsystem and is present on its own schedule, -/// so all of them are optional. Signing a round of an interactive funding adds the round to -/// `candidates` and lists the `funding_channels` it belongs to, still without a transaction anyone -/// has seen; wallet sync adds `details` once it observes the transaction, and records -/// `conflicting_txids` for any wallet transaction. A splice uses all of them; the fields do not -/// partition by payment type. An entry with none of them tracks no payment and is removed. +/// so all of them are optional. A user-initiated splice is persisted with nothing but its +/// [`SpliceIntent`] before its contribution is handed to LDK; signing a round of it adds the +/// round to `candidates` and lists the `funding_channels` it belongs to, still without a +/// transaction anyone has seen; wallet sync adds `details` once it observes the transaction, and +/// records `conflicting_txids` for any wallet transaction; the `ChannelReady` arm records +/// `locked_rounds`. A splice uses all of them; the fields do not partition by payment type. An +/// entry with none of them tracks no payment and is removed. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingPaymentDetails { /// The payment this entry tracks. @@ -73,6 +139,11 @@ pub(crate) struct PendingPaymentDetails { /// RBF history, keyed by each candidate's txid and recorded as each round is signed. /// Empty for non-funding payments. pub candidates: Vec, + /// The live splice intent, or `None` for a non-splice payment or a splice that has locked. + /// Set when a splice is initiated and cleared once it locks or its failure is surfaced. A fee + /// bump shares the entry of the round it replaces, so the intent outlives any single round + /// negotiated under it. + pub splice_intent: Option, /// The candidates LDK promoted to the channel's funding, as `ChannelReady` reported them. /// A zero-conf splice locks before its transaction confirms, and every later splice builds /// on it, so such a round can still confirm once the channel's funding has moved on from @@ -84,6 +155,13 @@ pub(crate) struct PendingPaymentDetails { impl PendingPaymentDetails { pub(crate) fn new( details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, + ) -> Self { + Self::tracked(details, conflicting_txids, candidates, None) + } + + pub(crate) fn tracked( + details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, + splice_intent: Option, ) -> Self { Self { id: details.id, @@ -91,6 +169,20 @@ impl PendingPaymentDetails { conflicting_txids, funding_channels: Vec::new(), candidates, + splice_intent, + locked_rounds: Vec::new(), + } + } + + #[cfg(test)] + pub(crate) fn pending_splice(id: PaymentId, intent: SpliceIntent) -> Self { + Self { + id, + details: None, + conflicting_txids: Vec::new(), + funding_channels: Vec::new(), + candidates: Vec::new(), + splice_intent: Some(intent), locked_rounds: Vec::new(), } } @@ -100,6 +192,7 @@ impl PendingPaymentDetails { /// record for it exists. pub(crate) fn signed_rounds( id: PaymentId, funding_channels: Vec, candidates: Vec, + splice_intent: Option, ) -> Self { Self { id, @@ -107,6 +200,7 @@ impl PendingPaymentDetails { conflicting_txids: Vec::new(), funding_channels, candidates, + splice_intent, locked_rounds: Vec::new(), } } @@ -136,6 +230,11 @@ impl PendingPaymentDetails { true } + /// The splice intent this record carries, if it is a splice that has not yet locked. + pub(crate) fn splice_intent(&self) -> Option<&SpliceIntent> { + self.splice_intent.as_ref() + } + /// Returns this node's recorded funding figures for the candidate with the given txid, if any. pub(crate) fn candidate(&self, txid: Txid) -> Option<&FundingTxCandidate> { self.candidates.iter().find(|candidate| candidate.txid == txid) @@ -164,7 +263,10 @@ impl PendingPaymentDetails { /// Whether this entry tracks nothing anymore and can be dropped. pub(crate) fn is_empty(&self) -> bool { - self.details.is_none() && self.candidates.is_empty() && self.locked_rounds.is_empty() + self.details.is_none() + && self.splice_intent.is_none() + && self.candidates.is_empty() + && self.locked_rounds.is_empty() } } @@ -174,6 +276,7 @@ impl_writeable_tlv_based!(PendingPaymentDetails, { (4, conflicting_txids, optional_vec), (6, funding_channels, optional_vec), (8, candidates, optional_vec), + (10, splice_intent, option), (12, locked_rounds, optional_vec), }); @@ -183,6 +286,10 @@ pub(crate) struct PendingPaymentDetailsUpdate { pub payment_update: Option, pub conflicting_txids: Option>, pub candidates: Vec, + /// The splice intent to set (`Some(Some(..))`) or clear (`Some(None)`), or `None` to leave it + /// unchanged. Clearing the intent of an entry that carries only the intent is done by removing + /// the entry, not through this field. + pub splice_intent: Option>, } impl StorableObject for PendingPaymentDetails { @@ -233,6 +340,13 @@ impl UpdatableObject for PendingPaymentDetails { updated = true; } + if let Some(new_splice_intent) = update.splice_intent { + if self.splice_intent != new_splice_intent { + self.splice_intent = new_splice_intent; + updated = true; + } + } + updated } @@ -250,12 +364,14 @@ impl StorableObjectUpdate for PendingPaymentDetailsUpdate impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { fn from(value: &PendingPaymentDetails) -> Self { match &value.details { - // An entry with no record yet carries nothing a payment-tracking merge could apply. + // An entry without a record yet doesn't carry anything a payment-tracking merge could + // apply beyond its intent, which the entry containing it owns outright. None => Self { id: value.id, payment_update: None, conflicting_txids: None, candidates: value.candidates.clone(), + splice_intent: value.splice_intent.clone().map(Some), }, Some(details) => { let conflicting_txids = if value.conflicting_txids.is_empty() { @@ -263,11 +379,16 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { } else { Some(value.conflicting_txids.clone()) }; + // Leave the splice intent unchanged: it is owned by the writers that persist and + // settle splices, never by a payment-tracking merge. Emitting the current value + // here would let an `insert_or_update` of a payment record (e.g. from wallet sync, + // built without an intent) clobber a live intent to `None`. Self { id: details.id, payment_update: Some(details.to_update()), conflicting_txids, candidates: value.candidates.clone(), + splice_intent: None, } }, } @@ -297,6 +418,27 @@ pub(crate) fn test_funding_contribution_with_outputs( pub(crate) fn test_funding_contribution_with_parts( estimated_fee_sat: u64, feerate: u64, prevtxs: &[bitcoin::Transaction], outputs: &[bitcoin::TxOut], change_output: Option<&bitcoin::TxOut>, +) -> lightning::ln::funding::FundingContribution { + test_funding_contribution_inheriting( + estimated_fee_sat, + feerate, + prevtxs, + outputs, + change_output, + &[], + &[], + ) +} + +/// Like [`test_funding_contribution_with_parts`], but recording `inherited_inputs` and +/// `inherited_output_scripts` as parts a still-pending splice attempt reserved before this +/// contribution, as LDK records them at the hand-off of a fee bump built from the round it +/// replaces: the contribution's `reserved_inputs` and `reserved_outputs` leave them out. +#[cfg(test)] +pub(crate) fn test_funding_contribution_inheriting( + estimated_fee_sat: u64, feerate: u64, prevtxs: &[bitcoin::Transaction], + outputs: &[bitcoin::TxOut], change_output: Option<&bitcoin::TxOut>, + inherited_inputs: &[bitcoin::OutPoint], inherited_output_scripts: &[bitcoin::ScriptBuf], ) -> lightning::ln::funding::FundingContribution { use lightning::util::ser::{BigSize, Writeable}; use lightning::util::wallet_utils::ConfirmedUtxo; @@ -340,6 +482,42 @@ pub(crate) fn test_funding_contribution_with_parts( records.extend_from_slice(&[11, 8]); // (11, max_feerate) records.extend_from_slice(&feerate.to_be_bytes()); records.extend_from_slice(&[13, 1, 1]); // (13, is_splice: true) + if !inherited_inputs.is_empty() || !inherited_output_scripts.is_empty() { + // (17, pending_components): a length-prefixed TLV stream of its own. + let mut components = Vec::new(); + if !inherited_inputs.is_empty() { + let mut bytes = Vec::new(); + for outpoint in inherited_inputs { + outpoint.write(&mut bytes).expect("in-memory write must succeed"); + } + components.push(1); // (1, inputs) + BigSize(bytes.len() as u64) + .write(&mut components) + .expect("in-memory write must succeed"); + components.extend(bytes); + } + if !inherited_output_scripts.is_empty() { + let mut bytes = Vec::new(); + for script in inherited_output_scripts { + script.write(&mut bytes).expect("in-memory write must succeed"); + } + components.push(3); // (3, output_scripts) + BigSize(bytes.len() as u64) + .write(&mut components) + .expect("in-memory write must succeed"); + components.extend(bytes); + } + let mut component_bytes = Vec::new(); + BigSize(components.len() as u64) + .write(&mut component_bytes) + .expect("in-memory write must succeed"); + component_bytes.extend(components); + records.push(17); + BigSize(component_bytes.len() as u64) + .write(&mut records) + .expect("in-memory write must succeed"); + records.extend(component_bytes); + } let mut tlv_bytes = Vec::new(); // BigSize length prefix over the TLV records above. BigSize(records.len() as u64).write(&mut tlv_bytes).expect("in-memory write must succeed"); @@ -348,6 +526,23 @@ pub(crate) fn test_funding_contribution_with_parts( .expect("hand-built TLV stream must decode") } +/// Builds a [`FundingContribution`] for tests carrying just the required TLV records: a zero +/// estimated fee, the default feerate, and no contributed outputs. +/// +/// [`FundingContribution`]: lightning::ln::funding::FundingContribution +#[cfg(test)] +pub(crate) fn test_funding_contribution() -> lightning::ln::funding::FundingContribution { + test_funding_contribution_with_feerate(253) +} + +/// Like [`test_funding_contribution`], but with the given input-selection feerate in sat/kwu. +#[cfg(test)] +pub(crate) fn test_funding_contribution_with_feerate( + feerate: u64, +) -> lightning::ln::funding::FundingContribution { + test_funding_contribution_with_outputs(0, feerate, &[]) +} + #[cfg(test)] mod tests { use bitcoin::hashes::Hash; @@ -457,6 +652,150 @@ mod tests { ); } + #[test] + fn splice_kind_round_trips() { + for kind in [ + SpliceKind::In { amount_sats: 500_000 }, + SpliceKind::Out { + outputs: vec![TxOut { + value: bitcoin::Amount::from_sat(400_000), + script_pubkey: bitcoin::ScriptBuf::new(), + }], + }, + SpliceKind::Rbf {}, + ] { + let encoded = kind.encode(); + let decoded = SpliceKind::read(&mut &encoded[..]).unwrap(); + assert_eq!(kind, decoded); + } + } + + #[test] + fn pending_splice_round_trips() { + use std::str::FromStr; + + let id = PaymentId([10u8; 32]); + let intent = SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution: test_funding_contribution(), + kind: SpliceKind::In { amount_sats: 500_000 }, + }; + let record = PendingPaymentDetails::pending_splice(id, intent); + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + assert_eq!(decoded.id(), id); + assert!(decoded.details().is_none()); + } + + /// A fee bump's contribution inherits the inputs and change of the round it replaces, which + /// LDK records in the contribution at the hand-off so that `reserved_inputs` and + /// `reserved_outputs` leave them out: what a failure of the bump releases, and what a retry + /// must reserve again. That record is a private field the contribution's `PartialEq` ignores, + /// so a persisted intent's round trip is checked through those accessors. + #[test] + fn pending_splice_keeps_the_contribution_reserved_parts() { + use std::str::FromStr; + + use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, WPubkeyHash}; + + let prevtx = |seed: u8| Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(10_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])), + }], + }; + let prevtxs = [prevtx(1), prevtx(2)]; + let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 }; + let script = |seed: u8| ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])); + let change = TxOut { value: Amount::from_sat(21_000), script_pubkey: script(9) }; + let splice_out = TxOut { value: Amount::from_sat(50_000), script_pubkey: script(8) }; + let reserved = |contribution: &FundingContribution| { + ( + contribution.reserved_inputs().map(|input| input.outpoint()).collect::>(), + contribution.reserved_outputs().cloned().collect::>(), + ) + }; + + // Without the record, every part counts as reserved. + let plain = test_funding_contribution_with_parts( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change), + ); + assert_eq!( + reserved(&plain), + (prevtxs.iter().map(outpoint).collect(), vec![splice_out.clone(), change.clone()]) + ); + + // The bump reuses the first input and the change address of the round it replaces; the + // second input and the splice-out output are its own. + let contribution = test_funding_contribution_inheriting( + 0, + 300, + &prevtxs, + &[splice_out.clone()], + Some(&change), + &[outpoint(&prevtxs[0])], + &[change.script_pubkey.clone()], + ); + let expected = (vec![outpoint(&prevtxs[1])], vec![splice_out]); + assert_eq!(reserved(&contribution), expected); + + let intent = SpliceIntent { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([11u8; 32]), + pre_splice_funding_txo: LdkOutPoint { txid: test_txid(12), index: 0 }, + contribution, + kind: SpliceKind::Rbf {}, + }; + let record = PendingPaymentDetails::pending_splice(PaymentId([10u8; 32]), intent); + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + let intent = decoded.splice_intent.expect("a pending splice decoded without its intent"); + assert_eq!(reserved(&intent.contribution), expected); + } + + #[test] + fn tracked_payment_round_trips() { + // An entry without a payment record round-trips in `pending_splice_round_trips`; here we + // cover one carrying the record and its candidate history. + let payment_id = PaymentId([7u8; 32]); + let txid = Txid::from_byte_array([8u8; 32]); + let record = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, txid), + vec![Txid::from_byte_array([9u8; 32])], + vec![FundingTxCandidate { + txid, + amount_msat: Some(1_000), + fee_paid_msat: Some(100), + awaiting_broadcast: false, + }], + ); + + let encoded = record.encode(); + let decoded = PendingPaymentDetails::read(&mut &encoded[..]).unwrap(); + assert_eq!(record, decoded); + assert_eq!(decoded.id(), payment_id); + assert!(decoded.details().is_some()); + } + /// A candidate with the given txid byte, with a stake of ours in it if `ours`. fn candidate(txid_byte: u8, ours: bool) -> FundingTxCandidate { FundingTxCandidate { @@ -473,10 +812,10 @@ mod tests { PendingPaymentDetails::new(pending_onchain_payment(payment_id, txid), vec![], candidates) } - /// An entry written by a node from before the rounds LDK promoted were kept on it reads as an - /// entry with none, everything else intact. + /// An entry written by a node from before the rounds LDK promoted and the splice's intent were + /// kept on it reads as an entry with neither, everything else intact. #[test] - fn an_entry_written_without_locked_rounds_reads_with_none() { + fn an_entry_written_without_locked_rounds_or_an_intent_reads_with_neither() { let mut stored = entry(vec![candidate(2, false), candidate(3, true)]); stored.conflicting_txids = vec![test_txid(9)]; @@ -494,8 +833,9 @@ mod tests { let written_before = write_as_before().unwrap(); let decoded: PendingPaymentDetails = Readable::read(&mut &written_before[..]) - .expect("an entry written before the rounds were kept still reads"); + .expect("an entry written before the rounds and the intent were kept still reads"); assert!(decoded.locked_rounds().is_empty()); + assert!(decoded.splice_intent.is_none()); assert_eq!(decoded, stored); } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index edc0004d58..a818bcc10c 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -649,8 +649,8 @@ impl Wallet { let pending_payments: Vec = self .payment_stores .pending_payments(|p| match p.details() { - // An entry of signed rounds whose transaction nothing has observed - // yet carries no payment and cannot graduate. + // A pre-broadcast splice intent carries no payment yet and cannot + // graduate. None => false, Some(details) => { debug_assert!( @@ -2639,6 +2639,61 @@ impl Wallet { Ok(()) } + /// Returns the `PaymentId` of a user-initiated splice intent for one of the channels in + /// `candidate`, if any, so the first recorded round of a splice adopts the id chosen at splice + /// time rather than a fresh one. The intent identifies the channel, not the round, so it + /// decides the id only for a history no record tracks yet + /// ([`Self::resolve_interactive_funding_id`]). A fee bump reuses the channel's existing intent, + /// so at most one in-flight intent matches and the first is unambiguous. + async fn find_splice_payment_id(&self, candidate: &FundingCandidate) -> Option { + self.payment_stores + .pending_payments(|p| { + p.splice_intent().is_some_and(|intent| { + candidate.channels.iter().any(|channel| { + channel.channel_id == intent.channel_id + && channel.counterparty_node_id == intent.counterparty_node_id + }) + }) + }) + .await + .first() + .map(|p| p.id()) + } + + /// Resolves the id under which the `active` round of the interactive funding with negotiated + /// history `candidates` is recorded. A round already on record keeps its record: the id of the + /// first round of the history any record tracks is adopted (wallet sync may record a round + /// before this node does), so a replacement, a replayed signing and a sync-created record + /// converge on one record. A record already failed is passed over: wallet sync fails a payment + /// whose round lost to a conflicting spend confirmed while the channel stays open, LDK still + /// tracks the round and a fee bump of it is signed with the round among its candidates, and a + /// failed record's status is never revisited, so the bump filed under it would go untracked. + /// A history no live record tracks adopts the id of the channel's splice intent + /// ([`Self::find_splice_payment_id`]), so a user-initiated splice's intent, funding payment and + /// candidate history share one record. Otherwise a fresh id is generated. The caller holds the + /// cross-store lock: resolved outside it, the id could go stale against a record wallet sync + /// creates for the same transaction before the caller's write. + async fn resolve_interactive_funding_id( + &self, stores: &PaymentStoresGuard<'_>, candidates: &[FundingCandidate], + active: &FundingCandidate, + ) -> Result { + for candidate in candidates.iter() { + if let Some(id) = self.find_payment_by_txid(candidate.txid).await? { + let failed = stores + .payment(&id) + .await? + .is_some_and(|payment| payment.status == PaymentStatus::Failed); + if !failed { + return Ok(id); + } + } + } + if let Some(id) = self.find_splice_payment_id(active).await { + return Ok(id); + } + Ok(random_payment_id()) + } + /// Builds this node's share of the `active` round of an interactive funding whose negotiated /// history is `candidates`, and the per-candidate figures of that history, for recording the /// round under `payment_id`. Returns `None` when there is nothing to record: no local @@ -2719,8 +2774,9 @@ impl Wallet { /// transaction, and resolves its identity through the fact. /// /// `candidates` is the channel's pending splice history as [`funding_candidates`] lists it from - /// the channel's [`SpliceDetails`], so the history is written in full, under the first - /// candidate's txid as id. + /// the channel's [`SpliceDetails`], so the history is written in full, under the id + /// [`Self::resolve_interactive_funding_id`] resolves (that of a record already tracking any + /// round of the history, else the channel's splice intent, else a fresh one). /// /// Nothing is recorded for a round missing from the history (reset between the event's /// emission and its handling, so LDK will refuse the signed transaction), already recorded (a @@ -2754,9 +2810,9 @@ impl Wallet { }) .collect(); - // The reads and the writes below must share one lock acquisition, as in every - // funding-record write: read outside it, the record could change under us before the - // write. + // Resolution, the reads and the writes below must share one lock acquisition, as in every + // funding-record write: done outside it, the id could go stale against a record wallet + // sync creates for the same transaction before the write. let stores = self.payment_stores.lock().await; // The facts of a round already on record identify its payment and this node's share of // it. Those facts are immutable, so a replay adopts them rather than deriving figures @@ -2764,11 +2820,9 @@ impl Wallet { // would be refused rather than recorded, leaving the event replaying forever. let recorded_figures = self.channel_tx_facts(&txid).await.and_then(|facts| facts.local_figures); - // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable - // across RBF replacements. let payment_id = match &recorded_figures { Some(figures) => figures.funding_payment_id, - None => PaymentId(candidates.first().map_or(txid, |first| first.txid).to_byte_array()), + None => self.resolve_interactive_funding_id(&stores, candidates, signed_round).await?, }; let (figures, mut history) = match self.interactive_funding_figures(payment_id, candidates, signed_round, tx) { @@ -2776,6 +2830,7 @@ impl Wallet { None => return Ok(()), }; let figures = recorded_figures.unwrap_or(figures); + let direction = figures.direction; // Only the signed round awaits broadcast: LDK broadcast the others once their signatures // were exchanged. if let Some(signed) = history.iter_mut().find(|candidate| candidate.txid == txid) { @@ -2817,12 +2872,39 @@ impl Wallet { .with_local_figures(figures), ) .await?; + // Any round of the history may be the one that confirms, including the rounds this + // node didn't contribute to. Write facts for those too, carrying this payment's id and + // no share, so `find_payment_by_txid` can still map a confirming round to the payment + // after graduation removes the pending entry that lists it. Facts are immutable, so a + // round that is already attributed to a payment is left alone; an attribution is taken + // back only with the payment it names, by [`Self::drop_abandoned_splice_rounds`]. + for candidate in candidates.iter().filter(|candidate| candidate.txid != txid) { + if aggregate_local_stakes(candidate).amount_msat.is_some() { + continue; + } + let attributed = self + .channel_tx_facts(&candidate.txid) + .await + .is_some_and(|facts| facts.local_figures.is_some()); + if attributed { + continue; + } + self.record_channel_tx_facts(ChannelTxFacts::new(candidate.txid).with_local_figures( + LocalFundingFigures { + funding_payment_id: payment_id, + amount_msat: None, + fee_paid_msat: None, + direction, + }, + )) + .await?; + } stores .mutate_pending_payment(&payment_id, |existing| { let mut changed = existing.is_none(); let mut entry = existing.cloned().unwrap_or_else(|| { - PendingPaymentDetails::signed_rounds(payment_id, Vec::new(), Vec::new()) + PendingPaymentDetails::signed_rounds(payment_id, Vec::new(), Vec::new(), None) }); if entry.funding_channels.is_empty() && !funding_channels.is_empty() { entry.funding_channels = funding_channels.clone(); @@ -2841,15 +2923,33 @@ impl Wallet { txid, candidates.len(), ); + + // The history is complete; merging the duplicates wallet sync created for earlier rounds + // is a courtesy. The signed round can have no duplicate yet, as our signatures have not + // left the node, and the round's `SpliceNegotiated` event re-runs the merge, replaying on + // failure, so a failure here is logged rather than replaying the signing. A round + // abandoned before that event leaves the duplicates as they were: records of their own. + if let Err(e) = self.merge_duplicate_candidate_records(&stores, payment_id, &recorded).await + { + log_error!( + self.logger, + "Failed to merge duplicate records into funding payment {}: {}", + payment_id, + e, + ); + } Ok(()) } /// Marks a splice round recorded when signing ([`Self::record_signed_funding`]) as broadcast /// once LDK reports the splice negotiated: `SpliceNegotiated` is emitted only once our /// `tx_signatures` for the round are ready to send, so the counterparty may have them by then - /// and may broadcast the round, which is therefore no longer dropped as abandoned. Nothing is - /// written for a round no funding payment of `channel_id` tracks (no local contribution, or no - /// wallet-level activity) or one already marked (a replayed event). + /// and may broadcast the round, which is therefore no longer dropped as abandoned. Then merges + /// the duplicate records wallet sync created for the record's candidates + /// ([`Self::merge_duplicate_candidate_records`]), completing a merge the signing left + /// unfinished. Nothing is written for a round no funding payment of `channel_id` tracks (no + /// local contribution, or no wallet-level activity); a replayed event finds the round marked + /// already and only re-runs the merge. pub(crate) async fn record_broadcast_splice_round( &self, channel_id: ChannelId, txid: Txid, ) -> Result<(), Error> { @@ -2859,13 +2959,12 @@ impl Wallet { let entries = stores .pending_payments(|entry| { - tracks_channel(entry, channel_id) - && entry.candidate(txid).is_some_and(|candidate| candidate.awaiting_broadcast) + tracks_channel(entry, channel_id) && entry.candidate(txid).is_some() }) .await; for entry in entries { let payment_id = entry.id(); - stores + let marked = stores .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); let round = entry @@ -2876,13 +2975,19 @@ impl Wallet { Some(entry) }) .await?; - log_debug!( - self.logger, - "Marked splice round {} of channel {} as broadcast in funding payment {}", - txid, - channel_id, - payment_id, - ); + if marked.is_some() { + log_debug!( + self.logger, + "Marked splice round {} of channel {} as broadcast in funding payment {}", + txid, + channel_id, + payment_id, + ); + } + // The round's record is complete, so the duplicates wallet sync created for earlier + // rounds can be folded in. A failure replays the event, which re-runs the merge + // idempotently. + self.merge_duplicate_candidate_records(&stores, payment_id, entry.candidates()).await?; } Ok(()) } @@ -2910,9 +3015,12 @@ impl Wallet { /// Dropping the record's current round hands the record back to the last remaining round this /// node contributed to, figures included; dropping the last such round removes the record, as /// whatever rounds remain are not this node's payment (LDK keeps this node's contributions to a - /// suffix of the rounds). A record that no longer waits on the dropped round — wallet sync + /// suffix of the rounds), and takes back their attribution to it, for wallet sync to record + /// them from its own view. A record that no longer waits on the dropped round — wallet sync /// moved it on, or an earlier drop was cut short after moving it — keeps its state and only - /// loses the round from its history. + /// loses the round from its history. When that round was the last of ours, the record is of a + /// round of the counterparty's alone: the remaining rounds lose their attribution to it and the + /// entry its history, and wallet sync records the rounds from its own view. pub(crate) async fn drop_abandoned_splice_rounds( &self, channel_id: ChannelId, tracked_rounds: &[Txid], ) -> Result<(), Error> { @@ -2975,6 +3083,9 @@ impl Wallet { // the last round of ours it loses the rest of the history too. An entry left tracking // nothing goes. if entry.details().is_none() { + if handed_back.is_none() { + self.withdraw_unshared_attributions(&entry, payment_id).await?; + } let mut emptied = false; stores .mutate_pending_payment(&payment_id, |existing| { @@ -3036,8 +3147,11 @@ impl Wallet { // 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. + // so a removal that fails midway is finished by the replayed event, as is the + // attribution of the remaining rounds to the payment, taken back before the entry + // for the same reason. stores.remove_payment(&payment_id).await?; + self.withdraw_unshared_attributions(&entry, payment_id).await?; stores.remove_pending_payment(&payment_id).await?; log_debug!( self.logger, @@ -3048,6 +3162,14 @@ impl Wallet { ); continue; } + // No round of ours remains, so the remaining rounds are the counterparty's + // alone, and wallet sync's to record from its own view once they confirm, as + // it would have had this node never joined: they lose their attribution to the + // payment, and the entry its history and channels, so that a confirmation + // applied to the record takes no figures from the history either. The record + // itself is left as it stands. The attribution goes before the entry's write, + // so that a drop cut short is finished by the replayed event. + self.withdraw_unshared_attributions(&entry, payment_id).await?; history_only = true; mirrored = record.filter(|current| current.status == PaymentStatus::Pending); }, @@ -3068,7 +3190,12 @@ impl Wallet { stores .mutate_pending_payment(&payment_id, |existing| { let mut entry = existing?.clone(); - entry.candidates.retain(|c| !abandoned_txids.contains(&c.txid)); + if handed_back.is_none() { + entry.candidates.clear(); + entry.funding_channels.clear(); + } else { + entry.candidates.retain(|c| !abandoned_txids.contains(&c.txid)); + } if let Some(mirrored) = mirrored { entry.details = Some(mirrored); } @@ -3085,6 +3212,35 @@ impl Wallet { Ok(()) } + /// Takes back the attribution to the funding payment `payment_id` of the rounds in `entry`'s + /// history this node didn't contribute to, the counterparty's rounds. The signing recorded it + /// for such a round to be filed under the payment should it confirm; with no round of ours + /// left under the payment, the round is the counterparty's transaction alone, for wallet sync + /// to record from its own view as it would have had this node never joined. What else was + /// recorded about the round stays, and the rounds of ours keep their + /// figures: they say what the transaction would have been, which the drop does not change. + async fn withdraw_unshared_attributions( + &self, entry: &PendingPaymentDetails, payment_id: PaymentId, + ) -> Result<(), Error> { + for candidate in entry.candidates() { + self.channel_tx_facts_store + .mutate(&candidate.txid, |current| { + let facts = current?; + match &facts.local_figures { + Some(figures) + if figures.funding_payment_id == payment_id + && figures.amount_msat.is_none() => + { + Some(ChannelTxFacts { local_figures: None, ..facts.clone() }) + }, + _ => None, + } + }) + .await?; + } + Ok(()) + } + /// Drops the splice rounds recorded when signing that LDK no longer tracks 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 @@ -3127,9 +3283,10 @@ impl Wallet { } /// Records a funding payment the way the node does: the rounds this node signed supply the - /// candidate history, and wallet sync writes the payment record and its pending-store entry - /// once it observes the transaction. Composes that sequence for tests that need a recorded - /// funding payment to act on. + /// candidate history, wallet sync writes the payment record and its pending-store entry once + /// it observes the transaction, and the duplicates sync created for those rounds are merged + /// into the record. Composes that sequence for tests that need a recorded funding payment to + /// act on. #[cfg(test)] async fn record_funding_payment( &self, details: PaymentDetails, candidates: Vec, @@ -3140,7 +3297,7 @@ impl Wallet { stores .mutate_pending_payment(&id, |existing| { let mut entry = existing.cloned().unwrap_or_else(|| { - PendingPaymentDetails::signed_rounds(id, Vec::new(), Vec::new()) + PendingPaymentDetails::signed_rounds(id, Vec::new(), Vec::new(), None) }); entry.candidates = candidates.clone(); Some(entry) @@ -3148,7 +3305,121 @@ impl Wallet { .await?; } stores.insert_or_update_payment(details.clone()).await?; - self.upsert_pending_payment(&stores, details, Vec::new()).await + self.upsert_pending_payment(&stores, details, Vec::new()).await?; + self.merge_duplicate_candidate_records(&stores, id, &candidates).await + } + + /// Merges duplicate records wallet sync created for this funding payment's candidates before + /// they were recorded as such. Sync re-keys an event for a round it cannot attribute to the + /// funding record — not yet a candidate, so the funding-status gate reports it foreign — to + /// the round's txid-derived id, creating a duplicate whose pending entry then shadows the + /// funding record in [`Self::find_payment_by_txid`]'s direct probe. Once the + /// round is a recorded candidate, the duplicate's confirmation (if any) belongs on the + /// funding record: adopt it, then remove the duplicate and its pending entry. When no + /// classified funding record exists under `id` yet, the duplicate's record moves under it + /// instead. A duplicate wallet sync has already failed is left alone: its failure is settled + /// history the funding record never carries, failing it removed the pending entry that would + /// shadow the funding record, and [`Self::resolve_interactive_funding_id`] passes it over, so + /// it is the record of a round that lost rather than a duplicate view of one. + /// + /// Runs once a record's candidate history is written, so the funding-status gate accepts the + /// candidates it adopts, and under the writer's lock acquisition, so sync cannot interleave. + /// It is idempotent, so a caller may re-run it after a failure. The caller must hold the + /// [`PaymentStores`] lock, per [`Self::apply_funding_status_update_locked`]'s contract. + async fn merge_duplicate_candidate_records( + &self, stores: &PaymentStoresGuard<'_>, id: PaymentId, candidates: &[FundingTxCandidate], + ) -> Result<(), Error> { + for candidate in candidates { + let duplicate_id = PaymentId(candidate.txid.to_byte_array()); + if duplicate_id == id { + continue; + } + let duplicate = match stores.payment(&duplicate_id).await? { + Some(duplicate) if duplicate.status != PaymentStatus::Failed => duplicate, + _ => continue, + }; + // Only a duplicate view of one of this payment's rounds qualifies: a record wallet + // sync created for the round before it was a candidate, whether left untyped, + // classified as a plain funding, or classified as an interactive funding by the facts + // the round's `SpliceNegotiated` event records. Its transaction is a later candidate + // when sync saw a replacement of the round it was created for. Anything else keyed by + // the txid-derived id is left alone. + let (duplicate_txid, status, tx_type) = match &duplicate.kind { + PaymentKind::Onchain { + txid, + status, + tx_type: + tx_type @ (None + | Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + )), + } if candidates.iter().any(|candidate| candidate.txid == *txid) => { + (*txid, status.clone(), tx_type.clone()) + }, + _ => continue, + }; + // Only a confirmation is worth adopting; an unconfirmed duplicate doesn't carry + // anything the record needs — the actively-broadcast candidate stays the record's + // current txid. + if matches!(status, ConfirmationStatus::Confirmed { .. }) { + match self + .apply_funding_status_update_locked(stores, id, duplicate_txid, status.clone()) + .await? + { + FundingStatusUpdate::Applied => {}, + // No classified funding record under `id` to adopt into: wallet sync + // hasn't seen any of this payment's rounds yet, or has only seen rounds this + // node didn't contribute to. The duplicate's record is the round's, so it moves + // under `id`, as a pending record for graduation to settle, with the figures + // the facts report for the round in place of the wallet's view of it when + // they have an amount, as in [`Self::create_payment_from_tx`]. + FundingStatusUpdate::NotFunding => { + let figures = self + .channel_tx_facts_store + .get(&duplicate_txid) + .await? + .and_then(|facts| facts.local_figures); + let (amount_msat, fee_paid_msat, direction) = match figures { + Some(figures) if figures.amount_msat.is_some() => { + (figures.amount_msat, figures.fee_paid_msat, figures.direction) + }, + _ => ( + duplicate.amount_msat, + duplicate.fee_paid_msat, + duplicate.direction, + ), + }; + let payment = PaymentDetails::new( + id, + PaymentKind::Onchain { txid: duplicate_txid, status, tx_type }, + amount_msat, + fee_paid_msat, + direction, + PaymentStatus::Pending, + ); + stores.insert_or_update_payment(payment.clone()).await?; + self.upsert_pending_payment(stores, payment, Vec::new()).await?; + }, + FundingStatusUpdate::Foreign => { + debug_assert!(false, "a candidate is never foreign to its own payment"); + continue; + }, + } + } + log_debug!( + self.logger, + "Merging duplicate payment record for funding transaction {}", + duplicate_txid, + ); + // Pending entry first: the retry of a failure between these two removals rediscovers + // the duplicate through its payment record. Removed the other way around, the + // leftover pending entry would be unreachable to the retry yet keep shadowing the + // funding record in `find_payment_by_txid`'s direct probe. + stores.remove_pending_payment(&duplicate_id).await?; + stores.remove_payment(&duplicate_id).await?; + } + Ok(()) } /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. @@ -3205,10 +3476,15 @@ impl Wallet { // 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. + // reads as this node having spent all of it. Figures that don't have an amount only attribute the + // transaction to a payment: the signing writes them for the rounds this node didn't + // contribute to, so that such a round confirms under the payment. This node didn't + // contribute an input to it, so the wallet's view of it is left to stand. 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), + Some(figures) if figures.amount_msat.is_some() => { + (figures.amount_msat, figures.fee_paid_msat, figures.direction) + }, + _ => self.onchain_payment_fields_locked(locked_wallet, tx), }; PaymentDetails::new(payment_id, kind, amount_msat, fee_paid_msat, direction, payment_status) @@ -3222,22 +3498,37 @@ impl Wallet { ) -> Result<(), Error> { let id = payment.id; stores - .mutate_pending_payment(&id, |existing| match existing { - None => Some(PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())), - // Promote an entry that has no record yet: wallet sync saw the splice - // transaction before this node recorded a payment for it. The entry keeps the - // rounds signed under it, and gains the record. - Some(entry) if entry.details().is_none() => { - let mut entry = entry.clone(); - entry.details = Some(payment); - entry.conflicting_txids = conflicting_txids; - Some(entry) - }, - Some(tracked) => { - let mut tracked = tracked.clone(); - let fresh = PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()); - tracked.update(fresh.to_update()).then_some(tracked) - }, + .mutate_pending_payment_async(&id, move |existing| async move { + // Only `Pending` payments belong in the pending store. The authoritative + // status is re-read inside the store's critical section, where it cannot go + // stale against graduation. + let is_pending = stores + .payment(&id) + .await? + .map_or(payment.status == PaymentStatus::Pending, |recorded| { + recorded.status == PaymentStatus::Pending + }); + if !is_pending { + return Ok(None); + } + Ok(match existing { + None => { + Some(PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())) + }, + // Promote an entry that has no record yet: wallet sync saw the splice + // transaction before this node recorded a payment for it. The entry keeps + // the splice intent and the rounds signed under it, and gains the record. + Some(mut entry) if entry.details().is_none() => { + entry.details = Some(payment); + entry.conflicting_txids = conflicting_txids; + Some(entry) + }, + Some(mut tracked) => { + let fresh = + PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()); + tracked.update(fresh.to_update()).then_some(tracked) + }, + }) }) .await?; Ok(()) @@ -3263,11 +3554,12 @@ impl Wallet { /// The payment the transaction `target_txid` belongs to, as far as anything on record says. /// - /// A transaction this node signed a round of an interactive funding for identifies its - /// payment outright, in the facts the signing recorded about it; that is the only answer that - /// holds before the payment record exists. Otherwise the pending store is asked, by the - /// record's own transaction, by its candidate history and by the conflicts wallet sync listed - /// for it. + /// A round of an interactive funding identifies its payment outright, in the facts the + /// signing recorded about it — about the round it signed and about every other candidate of + /// the history, including the rounds this node didn't contribute to. That is the only answer + /// that holds before the payment record exists, and the only one left once the record + /// graduated out of the pending store. Otherwise the pending store is asked, by the record's + /// own transaction, by its candidate history and by the conflicts wallet sync listed for it. 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) @@ -3285,8 +3577,8 @@ impl Wallet { |d| matches!(d.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid), ) // A middle RBF round is not the record's current txid and may never have - // received a `TxReplaced` event of its own, so map any of its candidate - // txids (an earlier RBF round may confirm) back to the record. + // received a `TxReplaced` event of its own, 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 @@ -3868,6 +4160,15 @@ enum FundingPaymentFailure { MovedOn, } +/// Generates a fresh funding-record [`PaymentId`] from the OS entropy source. The record is found +/// through its transaction history ([`Wallet::find_payment_by_txid`]), so the id only has to be +/// unique. +fn random_payment_id() -> PaymentId { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("getrandom failed"); + PaymentId(bytes) +} + /// The outcome of [`Wallet::apply_funding_status_update_locked`]. enum FundingStatusUpdate { /// The event's transaction belongs to the funding payment; its refreshed confirmation status @@ -4231,7 +4532,8 @@ mod tests { PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; use crate::payment::pending_payment_store::{ - test_funding_contribution_with_outputs, test_funding_contribution_with_parts, + test_funding_contribution_with_outputs, test_funding_contribution_with_parts, SpliceIntent, + SpliceKind, }; use crate::types::{DynStore, DynStoreWrapper, UserChannelId}; use crate::wallet::provenance::{ @@ -4315,6 +4617,9 @@ mod tests { inner: Arc, fail_writes: Arc, failed_writes: Arc, + /// How many writes still go through once the write switch is on, for failing a sequence + /// of writes part-way. + writes_before_failing: 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 @@ -4328,6 +4633,7 @@ mod tests { inner: Arc::new(InMemoryStore::new()), fail_writes: Arc::new(AtomicBool::new(false)), failed_writes: Arc::new(AtomicUsize::new(0)), + writes_before_failing: Arc::new(AtomicUsize::new(0)), fail_reads: Arc::new(AtomicBool::new(false)), failing_namespace: None, } @@ -4364,6 +4670,7 @@ mod tests { let inner = Arc::clone(&self.inner); let fail_writes = Arc::clone(&self.fail_writes); let failed_writes = Arc::clone(&self.failed_writes); + let writes_before_failing = Arc::clone(&self.writes_before_failing); let may_fail = self.failing_namespace.as_deref().map_or(true, |ns| ns == primary_namespace); let primary_namespace = primary_namespace.to_string(); @@ -4371,8 +4678,25 @@ 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")); + let passed = loop { + let remaining = writes_before_failing.load(Ordering::Acquire); + if remaining == 0 { + break false; + } + let claimed = writes_before_failing.compare_exchange( + remaining, + remaining - 1, + Ordering::AcqRel, + Ordering::Acquire, + ); + if claimed.is_ok() { + break true; + } + }; + if !passed { + 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 } @@ -4405,6 +4729,86 @@ mod tests { } } + /// An in-memory store that fails the next remove issued against an armed namespace, for + /// exercising cleanup paths that must survive a failure between two removals. + #[derive(Clone)] + struct FailRemoveStore { + inner: Arc, + fail_remove_in: Arc>>, + } + + impl FailRemoveStore { + fn new() -> Self { + Self { + inner: Arc::new(InMemoryStore::new()), + fail_remove_in: Arc::new(std::sync::Mutex::new(None)), + } + } + + fn fail_next_remove_in(&self, primary_namespace: &str) { + *self.fail_remove_in.lock().unwrap() = Some(primary_namespace.to_string()); + } + } + + impl KVStore for FailRemoveStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + KVStore::write(&*self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + let inner = Arc::clone(&self.inner); + let armed = Arc::clone(&self.fail_remove_in); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + async move { + let fail = { + let mut armed = armed.lock().unwrap(); + if armed.as_deref() == Some(primary_namespace.as_str()) { + *armed = None; + true + } else { + false + } + }; + if fail { + return Err(io::Error::new(io::ErrorKind::Other, "removes disabled")); + } + KVStore::remove(&*inner, &primary_namespace, &secondary_namespace, &key, lazy).await + } + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for FailRemoveStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + /// Constructs a `Wallet` around the given store, either creating a fresh BDK wallet or /// loading the one the store already holds. async fn new_test_wallet(store: Arc, load_existing: bool) -> Arc { @@ -5427,6 +5831,14 @@ mod tests { ) } + fn confirmed_status() -> ConfirmationStatus { + ConfirmationStatus::Confirmed { + block_hash: bitcoin::BlockHash::from_byte_array([8u8; 32]), + height: 100, + timestamp: 1, + } + } + /// Inserts `tx` into the BDK wallet as canonically confirmed at `height`, extending the /// local chain to that height. fn insert_confirmed_tx(wallet: &Wallet, tx: Transaction, height: u32) { @@ -5592,6 +6004,126 @@ mod tests { (tx, contribution) } + /// The intent of a user-initiated splice of `channel_id` with `counterparty_node_id`, anchored + /// at the channel's funding `pre_splice_funding` when the splice was submitted. + fn splice_intent_for( + counterparty_node_id: PublicKey, channel_id: ChannelId, pre_splice_funding: LdkOutPoint, + ) -> SpliceIntent { + SpliceIntent { + counterparty_node_id, + channel_id, + pre_splice_funding_txo: pre_splice_funding, + contribution: test_funding_contribution_with_outputs(300, 253, &[]), + kind: SpliceKind::Out { outputs: Vec::new() }, + } + } + + /// A round signed under the channel's splice intent that has since locked with zero + /// confirmations — clearing its intent — with a second splice submitted against the locked + /// funding before the round's `SpliceNegotiated` event was handled: the channel's intent no + /// longer belongs to the recorded round. + struct LockedRoundWithNewerIntent { + first_id: PaymentId, + tx: Transaction, + candidates: Vec, + second_id: PaymentId, + second_intent: SpliceIntent, + } + + async fn lock_a_signed_round_and_submit_another_splice( + wallet: &Wallet, + ) -> LockedRoundWithNewerIntent { + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + + let first_id = PaymentId([31u8; 32]); + let first_intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(first_id, first_intent)) + .await + .unwrap(); + let (tx, contribution) = splice_out_round(wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + // The round locks with zero confirmations, which clears its intent... + let cleared = PendingPaymentDetailsUpdate { + id: first_id, + payment_update: None, + conflicting_txids: None, + candidates: Vec::new(), + splice_intent: Some(None), + }; + wallet.payment_stores.pending_payment_store().update(cleared).await.unwrap(); + // ...and a second splice of the channel is submitted against the new funding. + let second_id = PaymentId([32u8; 32]); + let second_intent = + splice_intent_for(counterparty_node_id, channel_id, LdkOutPoint { txid, index: 0 }); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(second_id, second_intent.clone())) + .await + .unwrap(); + + LockedRoundWithNewerIntent { first_id, tx, candidates, second_id, second_intent } + } + + /// A recorded round is marked broadcast in its own record once the channel carries the intent + /// of a newer splice: after a zero-conf lock, the user may submit a second splice before the + /// locked round's `SpliceNegotiated` event is handled, and the event must neither file the + /// round under the new splice as a second record nor touch the new splice's intent. + #[tokio::test] + async fn negotiation_marks_a_recorded_round_broadcast_under_a_newer_intent() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + let txid = setup.tx.compute_txid(); + + let channel_id = setup.candidates[0].channels[0].channel_id; + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&setup.first_id) + .await + .unwrap() + .expect("entry"); + assert!(!entry.candidate(txid).expect("candidate").awaiting_broadcast); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&setup.second_id).await.unwrap(), + Some(PendingPaymentDetails::pending_splice(setup.second_id, setup.second_intent)), + "the newer splice's intent must be left untouched" + ); + } + + /// The signing event of a recorded round, replayed once the channel carries the intent of a + /// newer splice, is a no-op: the round is on record, so the newer intent is not consulted. + #[tokio::test] + async fn a_replayed_signing_under_a_newer_intent_is_a_no_op() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let setup = lock_a_signed_round_and_submit_another_splice(&wallet).await; + + fail_store.fail_writes.store(true, Ordering::Release); + wallet.record_signed_funding(&setup.tx, &setup.candidates).await.unwrap(); + assert_eq!( + fail_store.failed_writes.load(Ordering::Acquire), + 0, + "a replayed signing must produce no new write" + ); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&setup.second_id).await.unwrap(), + Some(PendingPaymentDetails::pending_splice(setup.second_id, setup.second_intent)), + ); + } + /// Signing a round writes no payment record. It records what the round is, this node's share /// of it and the funding payment it belongs to, and leaves the record itself to whoever first /// observes the transaction. @@ -5699,18 +6231,21 @@ mod tests { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - // A counterparty round precedes ours, so the payment's id is not the round's own txid. - let prior_txid = Txid::from_byte_array([0xAA; 32]); + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent)) + .await + .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); - let candidates = splice_candidates( - counterparty_node_id, - channel_id, - &[(prior_txid, None), (txid, Some(contribution))], - ); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = PaymentId(prior_txid.to_byte_array()); // Our signatures leave the node and the counterparty broadcasts: wallet sync is the first // to see the transaction. @@ -5834,597 +6369,722 @@ mod tests { ); } - /// Once LDK reports a round recorded at signing negotiated, there is nothing to add but the - /// broadcast itself: the round's awaiting-broadcast mark is cleared and the record left as - /// written. + /// A round this node didn't contribute to is still a candidate of the payment, and + /// may be the one that confirms. Once the record graduates, the entry that listed the round + /// as a candidate is gone, so the round's facts must identify the payment, as a signed + /// round's do. #[tokio::test] - async fn negotiation_of_a_signed_round_marks_it_broadcast() { + async fn a_graduated_records_counterparty_round_still_identifies_its_payment() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let prior_txid = Txid::from_byte_array([0xAA; 32]); - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + // The counterparty's round, then one of ours signed with it in the history. + let (counterparty_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let counterparty_txid = counterparty_tx.compute_txid(); + let (tx, contribution) = splice_out_round(&wallet, 2, 499_000, 700); let txid = tx.compute_txid(); let candidates = splice_candidates( counterparty_node_id, channel_id, - &[(prior_txid, None), (txid, Some(contribution))], + &[(counterparty_txid, None), (txid, Some(contribution))], ); sign_and_observe_round(&wallet, &tx, &candidates).await; - let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert!(record.candidate(txid).unwrap().awaiting_broadcast); - - wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(payment)); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!( - record.candidates().iter().map(|c| c.txid).collect::>(), - vec![prior_txid, txid] - ); - assert!(!record.candidate(txid).unwrap().awaiting_broadcast); - assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); + // Graduation: the record settles and its pending entry, with the candidate history, goes. + let mut graduated = PaymentDetailsUpdate::new(id); + graduated.status = Some(PaymentStatus::Succeeded); + wallet.payment_stores.payment_store().update(graduated).await.unwrap(); + wallet.payment_stores.pending_payment_store().remove(&id).await.unwrap(); + + assert_eq!(wallet.find_payment_by_txid(counterparty_txid).await.unwrap(), Some(id)); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); } - /// A replayed `SpliceNegotiated` event refers to a round already marked broadcast; the store - /// is not written. + /// The counterparty's round is the one that confirms: the record adopts it as its transaction + /// before graduating, and the round still identifies the payment afterwards. #[tokio::test] - async fn marking_a_broadcast_round_again_writes_nothing() { - let fail_store = FailSwitchStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + async fn a_graduated_record_is_identified_by_the_counterparty_round_it_adopted() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let (counterparty_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let counterparty_txid = counterparty_tx.compute_txid(); + let (tx, contribution) = splice_out_round(&wallet, 2, 499_000, 700); let txid = tx.compute_txid(); - let candidates = - splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - - fail_store.fail_writes.store(true, Ordering::Release); - wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - assert_eq!( - fail_store.failed_writes.load(Ordering::Acquire), - 0, - "marking a round broadcast again must produce no new write" + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], ); - } + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - /// A round no funding payment tracks — this node contributed nothing to it, so signing never - /// recorded it — has no mark to clear, and the clearing writes nothing. - #[tokio::test] - async fn marking_an_unrecorded_round_broadcast_writes_nothing() { - let fail_store = FailSwitchStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (_counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let confirmation_height = 100; + insert_confirmed_tx(&wallet, counterparty_tx.clone(), confirmation_height); + let event = WalletEvent::TxConfirmed { + txid: counterparty_txid, + tx: Arc::new(counterparty_tx), + block_time: confirmed_block_time(confirmation_height), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } + if txid == counterparty_txid + )); + assert_eq!(payment.amount_msat, None); - fail_store.fail_writes.store(true, Ordering::Release); - let txid = Txid::from_byte_array([0xAA; 32]); - wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 0); - assert!(wallet - .payment_stores - .payment_store() - .list_page(None) - .await - .unwrap() - .objects - .is_empty()); + let mut graduated = PaymentDetailsUpdate::new(id); + graduated.status = Some(PaymentStatus::Succeeded); + wallet.payment_stores.payment_store().update(graduated).await.unwrap(); + wallet.payment_stores.pending_payment_store().remove(&id).await.unwrap(); + + assert_eq!(wallet.find_payment_by_txid(counterparty_txid).await.unwrap(), Some(id)); } - /// A replayed signing event re-offers a transaction already recorded, and the recording writes - /// nothing. + /// The first round of a user-initiated splice is on no record when it is signed, so it adopts + /// the id of the channel's splice intent: the bare intent entry becomes the round's record and + /// keeps carrying the intent. #[tokio::test] - async fn signing_a_recorded_round_again_writes_nothing() { - let fail_store = FailSwitchStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + async fn signing_a_first_round_adopts_the_intent_id() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) + .await + .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + sign_and_observe_round(&wallet, &tx, &candidates).await; - fail_store.fail_writes.store(true, Ordering::Release); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert_eq!( - fail_store.failed_writes.load(Ordering::Acquire), - 0, - "a replayed signing must produce no new write" - ); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + let txid_derived_id = PaymentId(txid.to_byte_array()); + assert!(wallet + .payment_stores + .payment_store() + .get(&txid_derived_id) + .await + .unwrap() + .is_none()); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(txid).expect("candidate").awaiting_broadcast); } - /// A signed round absent from the channel's pending splice history was reset between the - /// event's emission and its handling (the counterparty aborted): LDK will refuse the signed - /// transaction, so the round is not recorded — not even when the history contains another - /// round this node contributed to. + /// A fee bump of a round whose payment wallet sync failed — the round lost to a conflicting + /// spend confirmed while the channel stayed open — adopts the channel's splice intent rather + /// than the failed record: the failed round in its history does not decide the id, so the + /// bump is recorded under the intent's id, its entry carrying the intent. #[tokio::test] - async fn signing_skips_a_round_missing_from_the_splice_history() { + async fn signing_a_bump_of_a_failed_round_adopts_the_channels_intent() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - - let other_txid = Txid::from_byte_array([0xAA; 32]); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); let candidates = splice_candidates( counterparty_node_id, channel_id, - &[(other_txid, Some(contribution))], + &[(txid, Some(contribution.clone()))], ); - - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - wallet.record_signed_funding(&tx, &[]).await.unwrap(); - assert!(wallet + sign_and_observe_round(&wallet, &tx, &candidates).await; + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet .payment_stores .payment_store() - .list_page(None) - .await - .unwrap() - .objects - .is_empty()); - assert!(wallet - .payment_stores - .pending_payment_store() - .list_page(None) + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) .await - .unwrap() - .objects - .is_empty()); - } - - /// A round this node did not contribute to is not its payment: the signing-time recording - /// declines it. - #[tokio::test] - async fn signing_skips_a_round_without_a_local_contribution() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - - let (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); - let candidates = - splice_candidates(counterparty_node_id, channel_id, &[(tx.compute_txid(), None)]); + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert!(wallet - .payment_stores - .payment_store() - .list_page(None) - .await - .unwrap() - .objects - .is_empty()); - assert!(wallet + // The bump's intent, recorded at submission with no record left to join. + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let id = PaymentId([31u8; 32]); + let intent = SpliceIntent { + contribution: bump_contribution.clone(), + kind: SpliceKind::Rbf {}, + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + wallet .payment_stores .pending_payment_store() - .list_page(None) + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) .await - .unwrap() - .objects - .is_empty()); - } - - /// A splice-out to an external address moves no wallet funds; the signing-time recording - /// declines it — wallet sync cannot observe it either, so there is no race to close. - #[tokio::test] - async fn signing_skips_a_wallet_untouched_transaction() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - - let splice_out = - TxOut { value: Amount::from_sat(500_000), script_pubkey: ScriptBuf::new() }; - let contribution = - test_funding_contribution_with_outputs(300, 253, std::slice::from_ref(&splice_out)); - let tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: vec![bitcoin::TxIn { - previous_output: OutPoint { txid: Txid::from_byte_array([1u8; 32]), vout: 0 }, - ..Default::default() - }], - output: vec![splice_out], - }; - let candidates = splice_candidates( + .unwrap(); + let bump_candidates = splice_candidates( counterparty_node_id, channel_id, - &[(tx.compute_txid(), Some(contribution))], + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert!(wallet + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!(entry.splice_intent(), Some(&intent)); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let failed = wallet .payment_stores .payment_store() - .list_page(None) + .get(&failed_id) .await .unwrap() - .objects - .is_empty()); + .expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); assert!(wallet .payment_stores .pending_payment_store() - .list_page(None) + .get(&failed_id) .await .unwrap() - .objects - .is_empty()); + .is_none()); } - /// The signing write merges LDK's history into the recorded one instead of replacing it: a - /// recorded round LDK no longer lists survives the write, since dropping the rounds LDK - /// abandoned is [`Wallet::drop_abandoned_splice_rounds`]'s job, once LDK reports the failure. + /// A fee bump signed while the channel's intent is still live joins the record of the round + /// it replaces: that round is on record, so the history decides the id, and the intent the + /// bump shares with the first round stays on the record. #[tokio::test] - async fn signing_merges_ldk_history_into_the_recorded_one() { + async fn signing_a_bump_joins_the_replaced_rounds_record() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let pre_splice_funding = LdkOutPoint { txid: Txid::from_byte_array([0xAA; 32]), index: 0 }; - let prior_txid = Txid::from_byte_array([0xAA; 32]); + let id = PaymentId([31u8; 32]); + let intent = splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding); + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) + .await + .unwrap(); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); let candidates = splice_candidates( counterparty_node_id, channel_id, - &[(prior_txid, None), (txid, Some(contribution))], + &[(txid, Some(contribution.clone()))], ); sign_and_observe_round(&wallet, &tx, &candidates).await; - let (next_tx, next_contribution) = splice_out_round(&wallet, 2, 400_000, 700); - let next_txid = next_tx.compute_txid(); - let next_candidates = splice_candidates( + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( counterparty_node_id, channel_id, - &[(prior_txid, None), (next_txid, Some(next_contribution))], + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); - sign_and_observe_round(&wallet, &next_tx, &next_candidates).await; + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1); - let id = payments[0].id; - assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); - assert!( - matches!(&payments[0].kind, PaymentKind::Onchain { txid: t, .. } if *t == next_txid) - ); - assert_eq!(payments[0].amount_msat, Some(400_700_000)); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(payments.len(), 1, "the bump must join the first round's record"); + assert_eq!(payments[0].id, id); + assert!(matches!(payments[0].kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); assert_eq!( - record.candidates().iter().map(|c| c.txid).collect::>(), - vec![prior_txid, txid, next_txid] + entry.candidates().iter().map(|c| c.txid).collect::>(), + vec![txid, bump_txid] ); - assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); - assert_eq!(record.candidate(next_txid).unwrap().amount_msat, Some(400_700_000)); + assert_eq!(entry.splice_intent(), Some(&intent)); } - /// LDK abandoned a signed first round (the counterparty aborted before the signatures were - /// exchanged) and reports the failure: the round was never broadcast, so its entry goes rather - /// than wait on a transaction that will never exist — while another channel's entry is left - /// alone. + /// A fee bump of a round whose payment wallet sync failed — the round lost to a conflicting + /// spend confirmed while the channel stayed open, so LDK still tracks it and offers the bump — + /// is signed with the failed round among its candidates. The failed record takes no round: its + /// status is never revisited, so the bump would go untracked under it. The bump gets a record + /// of its own. #[tokio::test] - async fn dropping_an_abandoned_first_round_removes_its_entry() { + async fn signing_a_bump_of_a_failed_round_gets_a_record_of_its_own() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let 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( + let candidates = splice_candidates( counterparty_node_id, - other_channel_id, - &[(other_txid, Some(other_contribution))], + channel_id, + &[(txid, Some(contribution.clone()))], ); - 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"); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_stores + .payment_store() + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); - wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); - // The round's provenance fact outlives the drop — it says what the transaction would have - // been, which a drop does not change — so the txid still identifies the payment it belonged - // to, and the stores no longer have a record under that payment. - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); - let other = wallet + let bump_id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("a record"); + assert_ne!(bump_id, failed_id); + let payment = + wallet.payment_stores.payment_store().get(&bump_id).await.unwrap().expect("record"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = wallet .payment_stores .pending_payment_store() - .get(&other_id) + .get(&bump_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)); + .expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let failed = wallet + .payment_stores + .payment_store() + .get(&failed_id) + .await + .unwrap() + .expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + assert!(matches!(failed.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&failed_id) + .await + .unwrap() + .is_none()); } - /// 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. + /// The bump's history carries a round this node contributed nothing to over from the failed + /// record, whose signing attributed the round to the failed payment. Facts are immutable, so + /// the bump's record lists the round as a candidate and leaves its facts as they are, rather + /// than failing the signing over a second attribution. #[tokio::test] - async fn dropping_an_abandoned_bump_restores_the_prior_round() { + async fn a_bump_of_a_failed_round_leaves_a_counterparty_rounds_facts_as_they_are() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let (counterparty_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let counterparty_txid = counterparty_tx.compute_txid(); + let (tx, contribution) = splice_out_round(&wallet, 2, 499_000, 700); let txid = tx.compute_txid(); let candidates = splice_candidates( counterparty_node_id, channel_id, - &[(txid, Some(contribution.clone()))], + &[(counterparty_txid, None), (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 failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_stores + .payment_store() + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); - let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 498_000, 1_100); let bump_txid = bump_tx.compute_txid(); let bump_candidates = splice_candidates( counterparty_node_id, channel_id, - &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + &[ + (counterparty_txid, None), + (txid, Some(contribution)), + (bump_txid, Some(bump_contribution)), + ], ); - wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); - record_unseen_round(&wallet, &bump_tx).await; - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); - - wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + wallet + .record_signed_funding(&bump_tx, &bump_candidates) + .await + .expect("the signing is recorded"); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!( - matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid), - "the original round must be the actively-tracked transaction again" - ); - assert_eq!(payment.amount_msat, Some(500_300_000)); - assert_eq!(payment.fee_paid_msat, Some(300_000)); - assert_eq!(record.details(), Some(&payment)); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + let bump_id = wallet.find_payment_by_txid(bump_txid).await.unwrap().expect("a record"); + assert_ne!(bump_id, failed_id); + let figures = wallet + .channel_tx_facts(&counterparty_txid) + .await + .expect("facts") + .local_figures + .expect("figures"); + assert_eq!(figures.funding_payment_id, failed_id); + let entry = wallet + .payment_stores + .pending_payment_store() + .get(&bump_id) + .await + .unwrap() + .expect("entry"); + assert!(entry.candidate(counterparty_txid).is_some()); } - /// 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. + /// Once LDK reports a round recorded at signing negotiated, there is nothing to add but the + /// broadcast itself: the round's awaiting-broadcast mark is cleared and the record left as + /// written. #[tokio::test] - async fn dropping_keeps_a_round_the_wallet_has_seen() { + async fn negotiation_of_a_signed_round_marks_it_broadcast() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let prior_txid = Txid::from_byte_array([0xAA; 32]); let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); let txid = tx.compute_txid(); - let candidates = - splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (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"); + let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert!(record.candidate(txid).unwrap().awaiting_broadcast); - wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(payment)); let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); - assert_eq!(payment.status, PaymentStatus::Pending); + 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)); } - /// The channel force-closed with a negotiated round unconfirmed and a fee bump of it signed - /// but never exchanged, before wallet sync picked the negotiated round up: LDK lists neither - /// anymore, but the negotiated round was handed to the broadcaster and may still confirm, so - /// only the bump is dropped. + /// A replayed `SpliceNegotiated` event refers to a round already marked broadcast; the store + /// is not written. #[tokio::test] - async fn dropping_keeps_rounds_handed_to_the_broadcaster() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + async fn marking_a_broadcast_round_again_writes_nothing() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); let wallet = new_test_wallet(Arc::clone(&store), false).await; let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); 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()))], - ); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); wallet.record_signed_funding(&tx, &candidates).await.unwrap(); wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); - let bump_txid = bump_tx.compute_txid(); - let bump_candidates = splice_candidates( - counterparty_node_id, - channel_id, - &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + 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" ); - wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + } - wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + /// A round no funding payment tracks — this node contributed nothing to it, so signing never + /// recorded it — has no mark to clear, and the clearing writes nothing. + #[tokio::test] + async fn marking_an_unrecorded_round_broadcast_writes_nothing() { + let fail_store = FailSwitchStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (_counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - let kept = record.candidate(txid).expect("the negotiated round keeps its place"); - assert_eq!(kept.amount_msat, Some(500_300_000)); - assert_eq!(kept.fee_paid_msat, Some(300_000)); - assert!(!kept.awaiting_broadcast); - // Wallet sync has not picked the round up, so there is no payment record either way. - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + fail_store.fail_writes.store(true, Ordering::Release); + let txid = Txid::from_byte_array([0xAA; 32]); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 0); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); } - /// 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. + /// A replayed signing event re-offers a transaction already recorded, and the recording writes + /// nothing. #[tokio::test] - async fn dropping_the_last_contributed_round_removes_the_record() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + 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 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))], - ); + 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(prior_txid).await.unwrap().expect("id"); - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); - - wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + 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" + ); } - /// The record moved on before the drop: wallet sync confirmed the original round while its - /// bump awaited signatures, then LDK abandoned the bump. The confirmed record is left as it - /// stands; only the bump leaves the recorded history. + /// A signed round absent from the channel's pending splice history was reset between the + /// event's emission and its handling (the counterparty aborted): LDK will refuse the signed + /// transaction, so the round is not recorded — not even when the history contains another + /// round this node contributed to. #[tokio::test] - async fn dropping_leaves_a_record_that_moved_on() { + 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 txid = tx.compute_txid(); let candidates = splice_candidates( counterparty_node_id, channel_id, - &[(txid, Some(contribution.clone()))], + &[(other_txid, Some(contribution))], ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + wallet.record_signed_funding(&tx, &[]).await.unwrap(); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + } - 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(); + /// 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(); - insert_confirmed_tx(&wallet, tx.clone(), 105); - let event = WalletEvent::TxConfirmed { - txid, - tx: Arc::new(tx), - block_time: confirmed_block_time(105), - old_block_time: None, - }; - wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!(matches!( - payment.kind, - PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } - if t == txid - )); + 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.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + } - assert_eq!( - wallet.payment_stores.payment_store().get(&id).await.unwrap(), - Some(payment.clone()) + /// 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))], ); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - assert_eq!(record.details(), Some(&payment)); - assert!(record.candidate(bump_txid).is_none()); + + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); } - /// The 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. + /// 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 dropping_the_last_round_leaves_a_record_that_moved_on() { + 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, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - record_unseen_round(&wallet, &tx).await; - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - - // Wallet sync moved the record onto a bump the entry does not list. - let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); - let bump_txid = bump_tx.compute_txid(); - let mut moved_on = PaymentDetailsUpdate::new(id); - moved_on.txid = Some(bump_txid); - wallet.payment_stores.payment_store().update(moved_on).await.unwrap(); - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; - wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + let (next_tx, next_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let next_txid = next_tx.compute_txid(); + let next_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (next_txid, Some(next_contribution))], + ); + sign_and_observe_round(&wallet, &next_tx, &next_candidates).await; - assert_eq!( - wallet.payment_stores.payment_store().get(&id).await.unwrap(), - Some(payment.clone()) + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let id = payments[0].id; + assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); + assert!( + matches!(&payments[0].kind, PaymentKind::Onchain { txid: t, .. } if *t == next_txid) ); + assert_eq!(payments[0].amount_msat, Some(400_700_000)); let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert!(record.candidates().is_empty()); - assert_eq!(record.details(), Some(&payment)); + 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)); } - /// 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. + /// LDK abandoned a signed first round (the counterparty aborted before the signatures were + /// exchanged) and reports the failure: the round was never broadcast, so its entry goes rather + /// than wait on a transaction that will never exist — while another channel's entry is left + /// alone. #[tokio::test] - async fn a_cut_short_removal_is_finished_by_the_replayed_drop() { + 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(); - record_unseen_round(&wallet, &tx).await; + 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"); - wallet.payment_stores.payment_store().remove(&id).await.unwrap(); - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); + let other_id = wallet.find_payment_by_txid(other_txid).await.unwrap().expect("other id"); wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + // The round's provenance fact outlives the drop — it says what the transaction would have + // been, which a drop does not change — so the txid still identifies the payment it belonged + // to, and the stores no longer have a record under that payment. + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + let other = wallet + .payment_stores + .pending_payment_store() + .get(&other_id) + .await + .unwrap() + .expect("the other channel's entry stays"); + assert!(other.candidate(other_txid).is_some()); + assert_eq!(wallet.find_payment_by_txid(other_txid).await.unwrap(), Some(other_id)); } - /// 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. + /// 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 a_cut_short_hand_back_is_finished_by_the_replayed_drop() { + 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(); @@ -6438,6 +7098,7 @@ mod tests { ); 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( @@ -6447,165 +7108,63 @@ mod tests { ); wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); record_unseen_round(&wallet, &bump_tx).await; - - // The first half of the hand-back: the payment record alone tracks the original round. - let mut update = PaymentDetailsUpdate::new(id); - update.txid = Some(txid); - update.confirmation_status = Some(ConfirmationStatus::Unconfirmed); - update.amount_msat = Some(Some(500_300_000)); - update.fee_paid_msat = Some(Some(300_000)); - wallet.payment_stores.payment_store().update(update).await.unwrap(); - let entry = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); - assert!(matches!( - entry.details().map(|details| &details.kind), - Some(PaymentKind::Onchain { txid: t, .. }) if *t == bump_txid - )); + let 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 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 = + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); - assert_eq!(payment.amount_msat, Some(500_300_000)); - assert_eq!(entry.details(), Some(&payment)); - assert!(entry.candidate(bump_txid).is_none()); - } - - /// The rounds LDK tracks 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 tracked_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!( - tracked_splice_rounds(Some(&details), Some(funding)), - vec![pending_txid, funding_txid] - ); - assert_eq!(tracked_splice_rounds(None, Some(funding)), vec![funding_txid]); - assert!(tracked_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 listed. - #[test] - fn closed_channel_tracked_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_tracked_rounds(Some(funding), [funding_txid, watched_txid]), - vec![funding_txid, watched_txid] + assert!( + matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid), + "the original round must be the actively-tracked transaction again" ); - assert_eq!(closed_channel_tracked_rounds(Some(funding), []), vec![funding_txid]); - assert_eq!(closed_channel_tracked_rounds(None, [watched_txid]), vec![watched_txid]); - assert!(closed_channel_tracked_rounds(None, []).is_empty()); + 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)); } - /// 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 does not track it and will not report it failed: the startup - /// sweep drops it, while a round LDK still tracks stays, and so does the round of a channel LDK - /// no longer lists, which is left to the channel's `ChannelClosed` event. + /// 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 startup_drops_the_rounds_ldk_no_longer_tracks() { + 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 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; + 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"); - 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(); + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); - 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()); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(payment.status, PaymentStatus::Pending); } - /// 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. + /// 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_leaves_the_entry_of_a_graduated_record_pending() { + 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(); @@ -6618,7 +7177,9 @@ mod tests { &[(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( @@ -6627,723 +7188,2167 @@ mod tests { &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); - record_unseen_round(&wallet, &bump_tx).await; - - let mut update = PaymentDetailsUpdate::new(id); - update.status = Some(PaymentStatus::Succeeded); - wallet.payment_stores.payment_store().update(update).await.unwrap(); - wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert_eq!(payment.status, PaymentStatus::Succeeded); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); - let entry = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); - assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - assert_eq!(entry.details().map(|details| details.status), Some(PaymentStatus::Pending)); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let kept = record.candidate(txid).expect("the negotiated round keeps its place"); + assert_eq!(kept.amount_msat, Some(500_300_000)); + assert_eq!(kept.fee_paid_msat, Some(300_000)); + assert!(!kept.awaiting_broadcast); + // Wallet sync has not picked the round up, so there is no payment record either way. + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); } - /// 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. + /// 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 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())); + 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, &[(txid, Some(contribution))]); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(prior_txid, None), (txid, Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(prior_txid).await.unwrap().expect("id"); + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_some()); - fail_store.fail_writes.store(true, Ordering::Release); - assert!(wallet.record_signed_funding(&tx, &candidates).await.is_err()); - assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 1); - assert!(wallet - .payment_stores - .payment_store() - .list_page(None) - .await - .unwrap() - .objects - .is_empty()); - // The round's facts landed before the entry, so the transaction identifies its payment - // already, before any entry tracks it. - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + wallet.drop_abandoned_splice_rounds(channel_id, &[prior_txid]).await.unwrap(); - fail_store.fail_writes.store(false, Ordering::Release); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - assert!(record.candidate(txid).expect("candidate").awaiting_broadcast); - // Wallet sync creates the payment record when it observes the transaction. - observe_unconfirmed(&wallet, &tx).await; - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + 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 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. + /// LDK abandoned the only round this node contributed to, an RBF of a counterparty-initiated + /// round wallet sync had recorded from its own view, in the record the signing adopted for the + /// payment. The record is of the counterparty's round alone now, so the round loses its + /// attribution to the payment and the entry its history, and the round's confirmation is + /// recorded by the wallet's view again rather than by the share of ours — none — the signing + /// recorded for it. #[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())); + async fn dropping_the_last_contributed_round_leaves_a_counterparty_round_to_wallet_sync() { + 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 (counterparty_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let counterparty_txid = counterparty_tx.compute_txid(); + observe_unconfirmed(&wallet, &counterparty_tx).await; + let observed_id = PaymentId(counterparty_txid.to_byte_array()); + let observed = wallet + .payment_stores + .payment_store() + .get(&observed_id) + .await + .unwrap() + .expect("wallet sync recorded the counterparty's round"); + + let (tx, contribution) = splice_out_round(&wallet, 2, 499_000, 700); let candidates = splice_candidates( counterparty_node_id, channel_id, - &[(txid, Some(contribution.clone()))], + &[(counterparty_txid, None), (tx.compute_txid(), Some(contribution))], ); - sign_and_observe_round(&wallet, &tx, &candidates).await; - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - let prior = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap().expect("id"); + assert_eq!(id, observed_id); + assert_eq!(wallet.find_payment_by_txid(counterparty_txid).await.unwrap(), Some(id)); - 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); + wallet.drop_abandoned_splice_rounds(channel_id, &[counterparty_txid]).await.unwrap(); - assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(prior)); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - assert!(wallet.payment_stores.payment_store().get(&bump_id).await.unwrap().is_none()); - } + let kept = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("kept"); + assert_eq!(kept.amount_msat, observed.amount_msat); + assert_eq!(kept.direction, observed.direction); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(entry.candidates().is_empty()); + assert!(entry.funding_channels().is_empty()); + assert!(wallet + .channel_tx_facts(&counterparty_txid) + .await + .and_then(|facts| facts.local_figures) + .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, + insert_confirmed_tx(&wallet, counterparty_tx.clone(), 100); + let event = WalletEvent::TxConfirmed { + txid: counterparty_txid, + tx: Arc::new(counterparty_tx), + block_time: confirmed_block_time(100), + old_block_time: None, }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let payment = &payments[0]; + assert_eq!(payment.id, observed_id); + assert_eq!(payment.amount_msat, observed.amount_msat); + assert_eq!(payment.fee_paid_msat, observed.fee_paid_msat); + assert_eq!(payment.direction, observed.direction); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } + if txid == counterparty_txid + )); + } + /// The same with a record already classified as interactive funding when the round confirms: + /// the confirmation is applied to the record from the entry's history, which the drop cleared, + /// so the record keeps the wallet's view rather than taking the share of ours — none — the + /// history listed for the round. + #[tokio::test] + async fn dropping_the_last_contributed_round_leaves_a_typed_record_its_wallet_view() { + 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([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); + let (counterparty_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let counterparty_txid = counterparty_tx.compute_txid(); + let channel = Channel { counterparty_node_id, channel_id }; + wallet + .record_channel_tx_facts( + ChannelTxFacts::new(counterparty_txid).with_self_role( + TransactionType::InteractiveFunding { channels: vec![channel] }, + ), + ) + .await + .unwrap(); + observe_unconfirmed(&wallet, &counterparty_tx).await; + let id = PaymentId(counterparty_txid.to_byte_array()); + let observed = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("observed"); + assert!(matches!( + observed.kind, + PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } + )); - 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)); + let (tx, contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (tx.compute_txid(), Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap(), Some(id)); - assert!(funding_candidates(None, counterparty_node_id, channel_id).is_empty()); + wallet.drop_abandoned_splice_rounds(channel_id, &[counterparty_txid]).await.unwrap(); + + insert_confirmed_tx(&wallet, counterparty_tx.clone(), 100); + let event = WalletEvent::TxConfirmed { + txid: counterparty_txid, + tx: Arc::new(counterparty_tx), + block_time: confirmed_block_time(100), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let payment = &payments[0]; + assert_eq!(payment.id, id); + assert_eq!(payment.amount_msat, observed.amount_msat); + assert_eq!(payment.fee_paid_msat, observed.fee_paid_msat); + assert_eq!(payment.direction, observed.direction); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } + if txid == counterparty_txid + )); } - /// Graduation must decide from the live record and write only the status: a pending-store - /// snapshot taken before a concurrent classification landed must not roll the record's - /// figures back when the payment graduates to `Succeeded`. + /// A second round of the counterparty's, observed after the signing, is recorded by the + /// wallet's view while it is pending, and by the wallet's view again when it confirms after the + /// drop. #[tokio::test] - async fn graduation_preserves_classified_figures() { + async fn dropping_the_last_contributed_round_lets_a_later_counterparty_round_confirm_by_the_wallet_view( + ) { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - 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 (first_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let first_txid = first_tx.compute_txid(); + observe_unconfirmed(&wallet, &first_tx).await; + let id = PaymentId(first_txid.to_byte_array()); + + let (second_tx, _) = splice_out_round(&wallet, 2, 499_500, 500); + let second_txid = second_tx.compute_txid(); + let (tx, contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(first_txid, None), (second_txid, None), (tx.compute_txid(), Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap(), Some(id)); + let (amount_msat, fee_paid_msat, direction) = { + let locked_wallet = wallet.inner.lock().unwrap(); + wallet.onchain_payment_fields_locked(&locked_wallet, &second_tx) }; - let tx_type = Some(TransactionType::InteractiveFunding { channels: vec![] }); + observe_unconfirmed(&wallet, &second_tx).await; + let replaced = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(replaced.amount_msat, amount_msat); + assert_eq!(replaced.fee_paid_msat, fee_paid_msat); + assert!(matches!(replaced.kind, PaymentKind::Onchain { txid, .. } if txid == second_txid)); - // The live record carries the classification: contribution-derived figures, confirmed. - let mut recorded = - interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); - recorded.kind = PaymentKind::Onchain { txid, status: confirmed, tx_type: tx_type.clone() }; - recorded.latest_update_timestamp = 0; - wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + wallet.drop_abandoned_splice_rounds(channel_id, &[first_txid, second_txid]).await.unwrap(); - // The pending entry embeds a stale snapshot: wallet-derived figures recorded before the - // classification above landed. - let mut stale = interactive_funding_details(payment_id, txid, Some(0), Some(0)); - stale.kind = PaymentKind::Onchain { txid, status: confirmed, tx_type }; - let entry = PendingPaymentDetails::new(stale, Vec::new(), Vec::new()); - wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + for txid in [first_txid, second_txid] { + assert!(wallet + .channel_tx_facts(&txid) + .await + .and_then(|facts| facts.local_figures) + .is_none()); + } - 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) }; + insert_confirmed_tx(&wallet, second_tx.clone(), 100); + let event = WalletEvent::TxConfirmed { + txid: second_txid, + tx: Arc::new(second_tx), + block_time: confirmed_block_time(100), + old_block_time: None, + }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Succeeded); - assert_eq!( - payment.amount_msat, - Some(2_000_000), - "graduation must not roll figures back to the snapshot's" - ); - assert_eq!(payment.fee_paid_msat, Some(999)); - assert!(payment.latest_update_timestamp > 0, "the graduation write must timestamp"); - assert!(wallet - .payment_stores - .pending_payment_store() - .get(&payment_id) - .await - .unwrap() - .is_none()); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let payment = &payments[0]; + assert_eq!(payment.id, id); + assert_eq!(payment.amount_msat, amount_msat); + assert_eq!(payment.fee_paid_msat, fee_paid_msat); + assert_eq!(payment.direction, direction); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } + if txid == second_txid + )); } - /// When the live record has diverged from the pending-store snapshot — here the snapshot - /// says Confirmed at graduation depth while the record says Unconfirmed — graduation must - /// decline and keep the entry rather than force-writing `Succeeded` from stale state. The - /// seeded divergence is synthetic (no current production writer downgrades a record's - /// confirmation); the test pins the hardening that comes with deciding from the live record. + /// A round of the counterparty's that wallet sync first sees after the signing is recorded by + /// the wallet's view, under the payment: the figures the signing wrote for it only attribute it + /// to the payment. #[tokio::test] - async fn graduation_declines_on_diverged_record() { + async fn a_counterparty_round_observed_after_the_signing_is_recorded_by_the_wallet_view() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - let txid = Txid::from_byte_array([5u8; 32]); - let payment_id = PaymentId(txid.to_byte_array()); - let confirmed = ConfirmationStatus::Confirmed { - block_hash: bitcoin::BlockHash::from_byte_array([9u8; 32]), - height: 5, - timestamp: 100, - }; + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - // The live record is Unconfirmed... - let recorded = interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); - wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + let (counterparty_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let counterparty_txid = counterparty_tx.compute_txid(); + let (tx, contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (tx.compute_txid(), Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap().expect("id"); - // ...while the pending entry's snapshot claims a graduation-deep confirmation. - let mut snapshot = - interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); - snapshot.kind = PaymentKind::Onchain { - txid, - status: confirmed, - tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + let (amount_msat, fee_paid_msat, direction) = { + let locked_wallet = wallet.inner.lock().unwrap(); + wallet.onchain_payment_fields_locked(&locked_wallet, &counterparty_tx) }; - let entry = PendingPaymentDetails::new(snapshot, Vec::new(), Vec::new()); - wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); - - let block_id = - |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; - let event = WalletEvent::ChainTipChanged { old_tip: block_id(9), new_tip: block_id(10) }; - wallet.update_payment_store(vec![event]).await.unwrap(); + assert!(amount_msat.is_some_and(|amount| amount > 0)); + observe_unconfirmed(&wallet, &counterparty_tx).await; - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!( - payment.status, - PaymentStatus::Pending, - "a diverged snapshot must not force-graduate the record" - ); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let payment = &payments[0]; + assert_eq!(payment.id, id); + assert_eq!(payment.amount_msat, amount_msat); + assert_eq!(payment.fee_paid_msat, fee_paid_msat); + assert_eq!(payment.direction, direction); assert!(matches!( payment.kind, - PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } + if txid == counterparty_txid )); - assert!( - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), - "the entry must survive for future events to drive" - ); + assert_eq!(wallet.find_payment_by_txid(counterparty_txid).await.unwrap(), Some(id)); } - /// A middle RBF candidate must map back to the funding record: it is neither the record's - /// id (derived from the first candidate), nor its current txid (the active candidate), nor - /// in `conflicting_txids` (it never got a `TxReplaced` event of its own). + /// The same before wallet sync has seen the counterparty's round: the entry goes without a + /// record, and the round is recorded by the wallet's view once it is seen. #[tokio::test] - async fn find_payment_by_txid_maps_candidate_txids() { + async fn dropping_the_last_contributed_round_of_an_unobserved_entry_hands_back_the_rest() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let txid1 = Txid::from_byte_array([1u8; 32]); - let txid2 = Txid::from_byte_array([2u8; 32]); - let txid3 = Txid::from_byte_array([3u8; 32]); - let payment_id = PaymentId(txid1.to_byte_array()); - let candidates = vec![ - FundingTxCandidate { - txid: txid1, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }, - FundingTxCandidate { - txid: txid2, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(600), - awaiting_broadcast: false, - }, - FundingTxCandidate { - txid: txid3, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(700), - awaiting_broadcast: false, - }, - ]; - let details = interactive_funding_details(payment_id, txid3, Some(1_000_000), Some(700)); - let entry = PendingPaymentDetails::new(details, Vec::new(), candidates); - wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + let (counterparty_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let counterparty_txid = counterparty_tx.compute_txid(); + let (tx, contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (tx.compute_txid(), Some(contribution))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(tx.compute_txid()).await.unwrap().expect("id"); + assert_eq!(wallet.find_payment_by_txid(counterparty_txid).await.unwrap(), Some(id)); - // The first candidate resolves via the txid-derived id and the active candidate via the - // record's current txid; the middle one must resolve through the candidate history. - assert_eq!(wallet.find_payment_by_txid(txid1).await.unwrap(), Some(payment_id)); - assert_eq!(wallet.find_payment_by_txid(txid3).await.unwrap(), Some(payment_id)); - assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(payment_id)); + wallet.drop_abandoned_splice_rounds(channel_id, &[counterparty_txid]).await.unwrap(); + + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet + .channel_tx_facts(&counterparty_txid) + .await + .and_then(|facts| facts.local_figures) + .is_none()); + + insert_confirmed_tx(&wallet, counterparty_tx.clone(), 100); + let event = WalletEvent::TxConfirmed { + txid: counterparty_txid, + tx: Arc::new(counterparty_tx), + block_time: confirmed_block_time(100), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + let payment = &payments[0]; + assert_eq!(payment.id, PaymentId(counterparty_txid.to_byte_array())); + assert_eq!(payment.amount_msat, Some(90_000_000)); + assert_eq!(payment.direction, PaymentDirection::Inbound); } - /// Removing a payment must also drop its pending-store entry. The entry indexes the - /// payment's txids (current, conflicting, and candidates), so leaving it behind keeps - /// resolving those txids to the removed record — routing later wallet events to a payment - /// that no longer exists — and nothing else ever cleans it up, since graduation only - /// removes entries whose record is still live. + /// 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 remove_payment_drops_pending_entry() { + async fn dropping_leaves_a_record_that_moved_on() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - let txid = Txid::from_byte_array([1u8; 32]); - let conflicting_txid = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId(txid.to_byte_array()); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - // A Pending outbound on-chain payment with a recorded conflict (e.g. an RBF round). - let details = PaymentDetails::new( - payment_id, - PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type: None }, - Some(1_000), - Some(100), - PaymentDirection::Outbound, - PaymentStatus::Pending, + 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.payment_stores.payment_store().insert_or_update(details.clone()).await.unwrap(); - let entry = PendingPaymentDetails::new(details, vec![conflicting_txid], Vec::new()); - wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); - - wallet.remove_payment(&payment_id).await.unwrap(); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - 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); + 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(); - // A replacement event for the removed transaction must skip rather than resolve to the - // removed record: the `TxReplaced` arm asserts the resolved record exists. - let event = WalletEvent::TxReplaced { + insert_confirmed_tx(&wallet, tx.clone(), 105); + let event = WalletEvent::TxConfirmed { txid, - tx: Arc::new(dummy_tx()), - conflicts: vec![(0, conflicting_txid)], + tx: Arc::new(tx), + block_time: confirmed_block_time(105), + old_block_time: None, }; wallet.update_payment_store(vec![event]).await.unwrap(); - assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } + if t == txid + )); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + assert_eq!( + wallet.payment_stores.payment_store().get(&id).await.unwrap(), + Some(payment.clone()) + ); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(record.details(), Some(&payment)); + assert!(record.candidate(bump_txid).is_none()); } - /// A round's facts identify 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 for that transaction therefore resolves to 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. + /// 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 a_replacement_of_a_removed_payments_transaction_is_skipped() { + 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(); - // A signed splice round the wallet has observed: the facts identify 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 identifies its payment"); - assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); + 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.remove_payment(&payment_id).await.unwrap(); - assert_eq!( - wallet.find_payment_by_txid(txid).await.unwrap(), - Some(payment_id), - "the round's facts still identify the payment the user removed", - ); + // Wallet sync moved the record onto a bump the entry does not list. + let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let mut moved_on = PaymentDetailsUpdate::new(id); + moved_on.txid = Some(bump_txid); + wallet.payment_stores.payment_store().update(moved_on).await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - // 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(); + wallet.drop_abandoned_splice_rounds(channel_id, &[]).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", + 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)); } - /// 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. + /// 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 remove_payment_without_pending_entry() { + 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(store, false).await; + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let payment_id = PaymentId([9u8; 32]); - let details = PaymentDetails::new( - payment_id, - PaymentKind::Bolt11 { - hash: lightning_types::payment::PaymentHash([0u8; 32]), - preimage: None, - secret: None, - counterparty_skimmed_fee_msat: None, - }, - Some(1_000), - None, - PaymentDirection::Outbound, - PaymentStatus::Succeeded, - ); - wallet.payment_stores.payment_store().insert_or_update(details).await.unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + wallet.payment_stores.payment_store().remove(&id).await.unwrap(); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); - wallet.remove_payment(&payment_id).await.unwrap(); - assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - // Removing an id known to neither store is also a no-op rather than an error. - wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap(); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); } - /// 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. + /// 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 funding_record_does_not_adopt_a_conflicting_close() { + 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(store, false).await; + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; + + // The first half of the hand-back: the payment record alone tracks the original round. + let mut update = PaymentDetailsUpdate::new(id); + update.txid = Some(txid); + update.confirmation_status = Some(ConfirmationStatus::Unconfirmed); + update.amount_msat = Some(Some(500_300_000)); + update.fee_paid_msat = Some(Some(300_000)); + wallet.payment_stores.payment_store().update(update).await.unwrap(); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(matches!( + entry.details().map(|details| &details.kind), + Some(PaymentKind::Onchain { txid: t, .. }) if *t == bump_txid + )); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(entry.details(), Some(&payment)); + assert!(entry.candidate(bump_txid).is_none()); + } + + /// The rounds LDK tracks 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 tracked_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!( + tracked_splice_rounds(Some(&details), Some(funding)), + vec![pending_txid, funding_txid] + ); + assert_eq!(tracked_splice_rounds(None, Some(funding)), vec![funding_txid]); + assert!(tracked_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 listed. + #[test] + fn closed_channel_tracked_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_tracked_rounds(Some(funding), [funding_txid, watched_txid]), + vec![funding_txid, watched_txid] + ); + assert_eq!(closed_channel_tracked_rounds(Some(funding), []), vec![funding_txid]); + assert_eq!(closed_channel_tracked_rounds(None, [watched_txid]), vec![watched_txid]); + assert!(closed_channel_tracked_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 does not track it and will not report it failed: the startup + /// sweep drops it, while a round LDK still tracks 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_tracks() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let other_channel_id = ChannelId([8u8; 32]); + let closed_channel_id = ChannelId([9u8; 32]); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let (other_tx, other_contribution) = splice_out_round(&wallet, 2, 400_000, 700); + let other_txid = other_tx.compute_txid(); + let other_candidates = splice_candidates( + counterparty_node_id, + other_channel_id, + &[(other_txid, Some(other_contribution))], + ); + sign_and_observe_round(&wallet, &other_tx, &other_candidates).await; + let (closed_tx, closed_contribution) = splice_out_round(&wallet, 3, 300_000, 500); + let closed_txid = closed_tx.compute_txid(); + let closed_candidates = splice_candidates( + counterparty_node_id, + closed_channel_id, + &[(closed_txid, Some(closed_contribution))], + ); + sign_and_observe_round(&wallet, &closed_tx, &closed_candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let other_id = wallet.find_payment_by_txid(other_txid).await.unwrap().expect("other id"); + let closed_id = wallet.find_payment_by_txid(closed_txid).await.unwrap().expect("closed id"); + + wallet + .drop_splice_rounds_lost_across_restart(|channel| { + if channel == other_channel_id { + Some(vec![other_txid]) + } else if channel == closed_channel_id { + None + } else { + Some(Vec::new()) + } + }) + .await + .unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + assert!(wallet.payment_stores.payment_store().get(&other_id).await.unwrap().is_some()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&other_id) + .await + .unwrap() + .is_some()); + assert!(wallet.payment_stores.payment_store().get(&closed_id).await.unwrap().is_some()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&closed_id) + .await + .unwrap() + .is_some()); + } + + /// A record that graduated while its pending entry lingers — the entry's removal is still + /// owed — loses the dropped round from its history but keeps the entry's pending copy of the + /// record: the pass that cleans up lingering entries goes by that copy, and a graduated one + /// would leave the entry behind for good. + #[tokio::test] + async fn dropping_leaves_the_entry_of_a_graduated_record_pending() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; + + let mut update = PaymentDetailsUpdate::new(id); + update.status = Some(PaymentStatus::Succeeded); + wallet.payment_stores.payment_store().update(update).await.unwrap(); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert_eq!(entry.details().map(|details| details.status), Some(PaymentStatus::Pending)); + } + + /// The signing write fails at the pending store: no payment record is minted for a round + /// nothing may broadcast, no entry is left half-written, and the replayed event records the + /// round in full once the store recovers. + #[tokio::test] + async fn a_failed_first_round_signing_write_leaves_no_half_written_record() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.record_signed_funding(&tx, &candidates).await.is_err()); + assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 1); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + // The round's facts landed before the entry, so the transaction identifies its payment + // already, before any entry tracks it. + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + + fail_store.fail_writes.store(false, Ordering::Release); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), Some(id)); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert!(record.candidate(txid).expect("candidate").awaiting_broadcast); + // Wallet sync creates the payment record when it observes the transaction. + observe_unconfirmed(&wallet, &tx).await; + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + } + + /// The same failure while signing a fee bump: the record of the round it replaces is left + /// exactly as it stands, and the recorded history still ends at that round. + #[tokio::test] + async fn a_failed_bump_signing_write_leaves_the_prior_round_tracked() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let prior = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_id = PaymentId(bump_txid.to_byte_array()); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + fail_store.fail_writes.store(true, Ordering::Release); + assert!(wallet.record_signed_funding(&bump_tx, &bump_candidates).await.is_err()); + assert_eq!(fail_store.failed_writes.load(Ordering::Acquire), 1); + + assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(prior)); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + assert!(wallet.payment_stores.payment_store().get(&bump_id).await.unwrap().is_none()); + } + + /// The candidates handed to the signing-time recording are the channel's pending splice + /// rounds that have a transaction — negotiated predecessors and the round awaiting + /// signatures, in LDK's order, each with this node's contribution to it. A contribution + /// still queued behind the pending rounds has no transaction and is left out. + #[test] + fn funding_candidates_list_the_rounds_with_a_transaction() { + use lightning::chain::chaininterface::FundingPurpose; + use lightning::ln::channel_state::{ + SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails, + }; + + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let prior_txid = Txid::from_byte_array([9u8; 32]); + let signing_txid = Txid::from_byte_array([10u8; 32]); + let contribution = test_funding_contribution_with_outputs(0, 253, &[]); + let details = SpliceDetails { + candidates: vec![ + SpliceCandidateDetails { + contribution: None, + status: SpliceCandidateStatus::Negotiated { + txid: prior_txid, + new_channel_value_satoshis: 100_000, + }, + }, + SpliceCandidateDetails { + contribution: Some(contribution.clone()), + status: SpliceCandidateStatus::AwaitingSignatures { + is_initiator: true, + funding_feerate_sat_per_1000_weight: 253, + new_channel_value_satoshis: 110_000, + txid: signing_txid, + }, + }, + SpliceCandidateDetails { + contribution: Some(test_funding_contribution_with_outputs(0, 500, &[])), + status: SpliceCandidateStatus::WaitingOnLock, + }, + ], + confirmed_candidate: None, + received_splice_locked_txid: None, + }; + + let candidates = funding_candidates(Some(&details), counterparty_node_id, channel_id); + + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].txid, prior_txid); + assert_eq!(candidates[0].channels.len(), 1); + assert_eq!(candidates[0].channels[0].contribution, None); + assert_eq!(candidates[1].txid, signing_txid); + assert_eq!(candidates[1].channels.len(), 1); + assert_eq!(candidates[1].channels[0].counterparty_node_id, counterparty_node_id); + assert_eq!(candidates[1].channels[0].channel_id, channel_id); + assert_eq!(candidates[1].channels[0].purpose, FundingPurpose::Splice); + assert_eq!(candidates[1].channels[0].contribution, Some(contribution)); + + assert!(funding_candidates(None, counterparty_node_id, channel_id).is_empty()); + } + + /// Graduation must decide from the live record and write only the status: a pending-store + /// snapshot taken before a concurrent classification landed must not roll the record's + /// figures back when the payment graduates to `Succeeded`. + #[tokio::test] + async fn graduation_preserves_classified_figures() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let txid = Txid::from_byte_array([4u8; 32]); + let payment_id = PaymentId(txid.to_byte_array()); + let confirmed = ConfirmationStatus::Confirmed { + block_hash: bitcoin::BlockHash::from_byte_array([9u8; 32]), + height: 5, + timestamp: 100, + }; + let tx_type = Some(TransactionType::InteractiveFunding { channels: vec![] }); + + // The live record carries the classification: contribution-derived figures, confirmed. + let mut recorded = + interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); + recorded.kind = PaymentKind::Onchain { txid, status: confirmed, tx_type: tx_type.clone() }; + recorded.latest_update_timestamp = 0; + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + // The pending entry embeds a stale snapshot: wallet-derived figures recorded before the + // classification above landed. + let mut stale = interactive_funding_details(payment_id, txid, Some(0), Some(0)); + stale.kind = PaymentKind::Onchain { txid, status: confirmed, tx_type }; + let entry = PendingPaymentDetails::new(stale, Vec::new(), Vec::new()); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { old_tip: block_id(9), new_tip: block_id(10) }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert_eq!( + payment.amount_msat, + Some(2_000_000), + "graduation must not roll figures back to the snapshot's" + ); + assert_eq!(payment.fee_paid_msat, Some(999)); + assert!(payment.latest_update_timestamp > 0, "the graduation write must timestamp"); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + } + + /// When the live record has diverged from the pending-store snapshot — here the snapshot + /// says Confirmed at graduation depth while the record says Unconfirmed — graduation must + /// decline and keep the entry rather than force-writing `Succeeded` from stale state. The + /// seeded divergence is synthetic (no current production writer downgrades a record's + /// confirmation); the test pins the hardening that comes with deciding from the live record. + #[tokio::test] + async fn graduation_declines_on_diverged_record() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let txid = Txid::from_byte_array([5u8; 32]); + let payment_id = PaymentId(txid.to_byte_array()); + let confirmed = ConfirmationStatus::Confirmed { + block_hash: bitcoin::BlockHash::from_byte_array([9u8; 32]), + height: 5, + timestamp: 100, + }; + + // The live record is Unconfirmed... + let recorded = interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + // ...while the pending entry's snapshot claims a graduation-deep confirmation. + let mut snapshot = + interactive_funding_details(payment_id, txid, Some(2_000_000), Some(999)); + snapshot.kind = PaymentKind::Onchain { + txid, + status: confirmed, + tx_type: Some(TransactionType::InteractiveFunding { channels: vec![] }), + }; + let entry = PendingPaymentDetails::new(snapshot, Vec::new(), Vec::new()); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { old_tip: block_id(9), new_tip: block_id(10) }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!( + payment.status, + PaymentStatus::Pending, + "a diverged snapshot must not force-graduate the record" + ); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } + )); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), + "the entry must survive for future events to drive" + ); + } + + /// A middle RBF candidate must map back to the funding record: it is neither the record's + /// id (here the txid-derived id of the first candidate), nor its current txid (the active + /// candidate), nor in `conflicting_txids` (it never got a `TxReplaced` event of its own). + #[tokio::test] + async fn find_payment_by_txid_maps_candidate_txids() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); + let txid3 = Txid::from_byte_array([3u8; 32]); + let payment_id = PaymentId(txid1.to_byte_array()); + let candidates = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid3, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(700), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(payment_id, txid3, Some(1_000_000), Some(700)); + let entry = PendingPaymentDetails::new(details, Vec::new(), candidates); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + // The first candidate resolves via the txid-derived id and the active candidate via the + // record's current txid; the middle one must resolve through the candidate history. + assert_eq!(wallet.find_payment_by_txid(txid1).await.unwrap(), Some(payment_id)); + assert_eq!(wallet.find_payment_by_txid(txid3).await.unwrap(), Some(payment_id)); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(payment_id)); + } + + /// Removing a payment must also drop its pending-store entry. The entry indexes the + /// payment's txids (current, conflicting, and candidates), so leaving it behind keeps + /// resolving those txids to the removed record — routing later wallet events to a payment + /// that no longer exists — and nothing else ever cleans it up, since graduation only + /// removes entries whose record is still live. + #[tokio::test] + async fn remove_payment_drops_pending_entry() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let txid = Txid::from_byte_array([1u8; 32]); + let conflicting_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId(txid.to_byte_array()); + + // A Pending outbound on-chain payment with a recorded conflict (e.g. an RBF round). + let details = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type: None }, + Some(1_000), + Some(100), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(details.clone()).await.unwrap(); + let entry = PendingPaymentDetails::new(details, vec![conflicting_txid], Vec::new()); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + wallet.remove_payment(&payment_id).await.unwrap(); + + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None); + assert_eq!(wallet.find_payment_by_txid(conflicting_txid).await.unwrap(), None); + + // A replacement event for the removed transaction must skip rather than resolve to the + // removed record: the `TxReplaced` arm asserts the resolved record exists. + let event = WalletEvent::TxReplaced { + txid, + tx: Arc::new(dummy_tx()), + conflicts: vec![(0, conflicting_txid)], + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); + } + + /// A round's facts identify 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 for that transaction therefore resolves to 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 identify 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 identifies 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 still identify the payment the user removed", + ); + + // The user fee-bumps the splice, so the wallet reports the signed round replaced. A + // second event in the same batch pins that the batch goes on being handled. + let other = wallet_paying_tx(&wallet, 2); + let other_txid = other.compute_txid(); + insert_unconfirmed_tx(&wallet, other.clone()); + let events = vec![ + WalletEvent::TxReplaced { + txid, + tx: Arc::new(tx.clone()), + conflicts: vec![(0, Txid::from_byte_array([0xB1; 32]))], + }, + WalletEvent::TxUnconfirmed { + txid: other_txid, + tx: Arc::new(other), + old_block_time: None, + }, + ]; + wallet.update_payment_store(events).await.unwrap(); + + assert!( + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none(), + "a removed payment must not come back", + ); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + assert!( + wallet + .payment_stores + .payment_store() + .get(&PaymentId(other_txid.to_byte_array())) + .await + .unwrap() + .is_some(), + "the rest of the batch must still be handled", + ); + } + + /// Payments without a pending-store entry — lightning payments, and on-chain payments that + /// already graduated — must remove cleanly: the unconditional pending-store removal relies + /// on removing a missing key being a no-op. + #[tokio::test] + async fn remove_payment_without_pending_entry() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let payment_id = PaymentId([9u8; 32]); + let details = PaymentDetails::new( + payment_id, + PaymentKind::Bolt11 { + hash: lightning_types::payment::PaymentHash([0u8; 32]), + preimage: None, + secret: None, + counterparty_skimmed_fee_msat: None, + }, + Some(1_000), + None, + PaymentDirection::Outbound, + PaymentStatus::Succeeded, + ); + wallet.payment_stores.payment_store().insert_or_update(details).await.unwrap(); + + wallet.remove_payment(&payment_id).await.unwrap(); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); + + // Removing an id known to neither store is also a no-op rather than an error. + wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap(); + } + + /// A 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(); + // The close pays the shutdown script, which is a wallet address. + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let close_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: funding_outpoint, + script_sig: bitcoin::ScriptBuf::new(), + sequence: bitcoin::Sequence::MAX, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + }; + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + + // Sync saw the close double-spend the splice's funding transaction. + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + + let event = WalletEvent::TxConfirmed { + txid: close_txid, + tx: Arc::new(close_tx), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let funding = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + match &funding.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid, "the record must not adopt the close's txid"); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(funding.amount_msat, Some(1_000_000)); + assert_eq!(funding.fee_paid_msat, Some(500)); + + let close = wallet + .payment_stores + .payment_store() + .get(&PaymentId(close_txid.to_byte_array())) + .await + .unwrap() + .unwrap(); + match &close.kind { + PaymentKind::Onchain { txid, status, .. } => { + assert_eq!(*txid, close_txid); + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + kind => panic!("unexpected kind {:?}", kind), + } + } + + /// The mirror image of [`funding_record_does_not_adopt_a_conflicting_close`]: a cooperative + /// close that a splice round replaces lists the round among its conflicting txids, so the + /// round's confirmation resolves to the close's entry as readily as to the splice's, which + /// records the round as its own. It must land on the splice's record whichever entry the + /// pending cache lists first: the close's record is not the round's, and merging the round + /// into it leaves the splice's payment pending for good. Several closes and several fresh + /// wallets, each with its own cache order, make the splice's entry unlikely to come first + /// every time. + #[tokio::test] + async fn close_record_does_not_adopt_a_conflicting_splice_round() { + let secp = bitcoin::secp256k1::Secp256k1::new(); + let counterparty_node_id = bitcoin::secp256k1::PublicKey::from_secret_key( + &secp, + &bitcoin::secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(), + ); + let channel_id = lightning::ln::types::ChannelId::from_bytes([4u8; 32]); + + for _ in 0..12 { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // The round pays a wallet address, so the wallet's view of it carries figures of its own. + let script_pubkey = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::External) + .address + .script_pubkey(); + let splice_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: Txid::from_byte_array([3u8; 32]), + vout: 0, + }, + script_sig: bitcoin::ScriptBuf::new(), + sequence: bitcoin::Sequence::MAX, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], + }; + let splice_txid = splice_tx.compute_txid(); + // Keyed away from the round's txid, as a later round of a splice is. + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + + // Each close was recorded at broadcast, seen unconfirmed, then replaced by the round: + // what the `TxReplaced` arm leaves behind. + let close_txids: Vec = + (7u8..11).map(|byte| Txid::from_byte_array([byte; 32])).collect(); + for close_txid in &close_txids { + let close_details = PaymentDetails::new( + PaymentId(close_txid.to_byte_array()), + PaymentKind::Onchain { + txid: *close_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::CooperativeClose { + counterparty_node_id, + channel_id, + }), + }, + Some(50_000_000), + Some(1_000), + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + wallet + .payment_stores + .payment_store() + .insert_or_update(close_details.clone()) + .await + .unwrap(); + let entry = + PendingPaymentDetails::new(close_details, vec![splice_txid], Vec::new()); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(entry) + .await + .unwrap(); + } + + assert_eq!( + wallet.find_payment_by_txid(splice_txid).await.unwrap(), + Some(payment_id), + "the round resolved to a record that only lists it as a conflict" + ); + + let event = WalletEvent::TxConfirmed { + txid: splice_txid, + tx: Arc::new(splice_tx), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let funding = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + match &funding.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid); + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(funding.amount_msat, Some(1_000_000)); + assert_eq!(funding.fee_paid_msat, Some(500)); + + for close_txid in &close_txids { + let close = wallet + .payment_stores + .payment_store() + .get(&PaymentId(close_txid.to_byte_array())) + .await + .unwrap() + .unwrap(); + match &close.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!( + *txid, *close_txid, + "the close's record adopted the round's txid" + ); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::CooperativeClose { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(close.amount_msat, Some(50_000_000)); + assert_eq!(close.fee_paid_msat, Some(1_000)); + } + } + } + + /// Continues the story above: once the conflicting close confirms through the anti-reorg + /// depth, the splice's funding transaction can never confirm — its shared input is spent for + /// good. The record must fail rather than stay `Pending` forever, and removing the pending + /// entry stops the lost transaction's rebroadcast on every tip change. + #[tokio::test] + async fn funding_payment_fails_once_a_foreign_conflict_confirms_to_depth() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // The splice round and the close spend the same input. + let splice_tx = wallet_paying_tx(&wallet, 3); + let splice_txid = splice_tx.compute_txid(); + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + + // The round was seen in the mempool; the close is canonically confirmed, so the round, + // having lost the conflict, is no longer canonical. + insert_unconfirmed_tx(&wallet, splice_tx); + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + match &payment.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, splice_txid, "failing must not adopt the conflict's txid"); + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(payment.amount_msat, Some(1_000_000)); + assert_eq!(payment.fee_paid_msat, Some(500)); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), + "the entry must go so the lost transaction stops being rebroadcast" + ); + } + + /// A round that fell out of the mempool has not lost: BDK drops an evicted transaction from + /// the canonical set just as it drops one displaced by a confirmed conflict, but an evicted + /// round can be rebroadcast and confirm. With one round double-spent by a close confirmed to + /// depth and the other merely evicted, the record must stay `Pending`; it is lost only once + /// a transaction confirmed to depth spends an input of every round. + #[tokio::test] + async fn funding_payment_survives_while_an_evicted_candidate_can_still_confirm() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // Two rounds of one negotiation, both seen in the mempool. + let first_round = wallet_paying_tx(&wallet, 1); + let first_txid = first_round.compute_txid(); + let second_round = wallet_paying_tx(&wallet, 2); + let second_txid = second_round.compute_txid(); + insert_unconfirmed_tx(&wallet, first_round); + insert_unconfirmed_tx(&wallet, second_round); + + let payment_id = PaymentId([22u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: first_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: second_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + ]; + let details = + interactive_funding_details(payment_id, second_txid, Some(1_000_000), Some(600)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + + // A close double-spends the first round's input and confirms through the anti-reorg + // depth, while the second round merely drops out of the mempool. + let close_tx = wallet_paying_tx(&wallet, 1); + let close_txid = close_tx.compute_txid(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + insert_confirmed_tx(&wallet, close_tx, 5); + evict_tx(&wallet, second_txid); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending, "the evicted round can still confirm"); + let entry = + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(entry.candidates().len(), 2, "both rounds stay on record"); + + // A second close spends the evicted round's input and confirms to depth too: no round + // can confirm now. The second close never displaced a canonical round, so it is absent + // from the conflict list; the loss is read from the wallet's transaction graph. + let second_close = wallet_paying_tx(&wallet, 2); + insert_confirmed_tx(&wallet, second_close, 6); + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(5 + ANTI_REORG_DELAY - 1), + new_tip: block_id(6 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + } + + /// A round the wallet never saw has not lost either: the wallet's graph learns a transaction + /// from sync alone, and the counterparty has the same signed round and may broadcast it. + /// With the round that added an input double-spent by a close confirmed to depth and an + /// earlier round unseen, the record must stay `Pending`; it is lost once the earlier round is + /// seen and a transaction confirmed to depth spends an input of it too. + #[tokio::test] + async fn funding_payment_survives_while_an_unseen_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: the first was never seen in the mempool, the second + // added an input and was. + let first_tx = wallet_paying_tx(&wallet, 1); + let first_txid = first_tx.compute_txid(); + let second_tx = wallet_paying_tx(&wallet, 2); + let second_txid = second_tx.compute_txid(); + insert_unconfirmed_tx(&wallet, second_tx); + + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: first_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: second_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + ]; + let details = + interactive_funding_details(payment_id, second_txid, Some(1_000_000), Some(600)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + + // A close double-spends the input only the second round added and confirms through the + // anti-reorg depth. + let close_tx = wallet_paying_tx(&wallet, 2); + let close_txid = close_tx.compute_txid(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending, "the unseen round can still confirm"); + let entry = + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(entry.candidates.len(), 2, "both rounds stay on record"); + + // The first round turns up in the mempool, and a second close spends its input and + // confirms to depth too: no round can confirm now. + insert_unconfirmed_tx(&wallet, first_tx); + let second_close = wallet_paying_tx(&wallet, 1); + insert_confirmed_tx(&wallet, second_close, 6); + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(5 + ANTI_REORG_DELAY - 1), + new_tip: block_id(6 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + } + + /// An unseen round that confirms after all is adopted by its record like any other + /// candidate — which a record failed while the round was unseen could never do. + #[tokio::test] + async fn funding_payment_adopts_an_unseen_candidate_once_it_confirms() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let first_tx = wallet_paying_tx(&wallet, 1); + let first_txid = first_tx.compute_txid(); + let second_tx = wallet_paying_tx(&wallet, 2); + let second_txid = second_tx.compute_txid(); + insert_unconfirmed_tx(&wallet, second_tx); + + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: first_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: second_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + ]; + let details = + interactive_funding_details(payment_id, second_txid, Some(1_000_000), Some(600)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + + let close_tx = wallet_paying_tx(&wallet, 2); + let close_txid = close_tx.compute_txid(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + // The first round confirms. + insert_confirmed_tx(&wallet, first_tx.clone(), 6); + let event = WalletEvent::TxConfirmed { + txid: first_txid, + tx: Arc::new(first_tx), + block_time: confirmed_block_time(6), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + match &payment.kind { + PaymentKind::Onchain { txid, status, tx_type } => { + assert_eq!(*txid, first_txid, "the record adopts the round that confirmed"); + assert!(matches!(status, ConfirmationStatus::Confirmed { height: 6, .. })); + assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(payment.fee_paid_msat, Some(500), "with the fee of that round"); + assert!( + wallet + .payment_stores + .payment_store() + .get(&PaymentId(first_txid.to_byte_array())) + .await + .unwrap() + .is_none(), + "the round is not recorded a second time" + ); + let entry = + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().unwrap(); + assert!(matches!( + entry.details().unwrap().kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + } + + /// A confirmed conflict that is one of the record's own candidates is RBF resolution, not a + /// loss: classification adopts it into the record, so the failure pass must leave the record + /// alone. + #[tokio::test] + async fn funding_payment_survives_a_confirmed_conflict_that_is_a_candidate() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let bumped_tx = wallet_paying_tx(&wallet, 3); + let bumped_txid = bumped_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: bumped_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + ]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![bumped_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, bumped_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), + "the entry must survive for classification to adopt the confirmed candidate" + ); + } + + /// A foreign conflict that has confirmed but not yet through the anti-reorg depth may still + /// be reorged out, letting the funding transaction confirm after all; the record must stay + /// pending until the conflict's confirmation is final. + #[tokio::test] + async fn funding_payment_survives_a_foreign_conflict_short_of_depth() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![close_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 2), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_some()); + } + + /// A conflict may double-spend only one round of the negotiation — e.g. it shares an input + /// with an RBF attempt but not with the original candidate. While any candidate is still + /// canonical it can still confirm, so the record must stay pending. + #[tokio::test] + async fn funding_payment_survives_while_a_candidate_can_still_confirm() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let conflict_tx = wallet_paying_tx(&wallet, 3); + let conflict_txid = conflict_tx.compute_txid(); + // A live candidate: spends a different outpoint, so the conflict didn't kill it. + let live_candidate_tx = wallet_paying_tx(&wallet, 4); + let live_candidate_txid = live_candidate_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let candidates = vec![ + FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: live_candidate_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(600), + awaiting_broadcast: false, + }, + ]; + let details = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, candidates).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .update(PendingPaymentDetailsUpdate { + id: payment_id, + payment_update: None, + conflicting_txids: Some(vec![conflict_txid]), + candidates: Vec::new(), + splice_intent: None, + }) + .await + .unwrap(); + + insert_confirmed_tx(&wallet, conflict_tx, 5); + insert_unconfirmed_tx(&wallet, live_candidate_tx); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), + "a candidate can still confirm, so the record must stay pending" + ); + } + + /// The failure write pair is record first, entry second: a crash in between leaves a + /// `Failed` record with a lingering entry. The next tip pass must finish the job — remove + /// the entry without disturbing the record — from the record's status alone: the wallet + /// here never saw the round, as after a crash that also lost the wallet's changeset. + #[tokio::test] + async fn a_failed_funding_payment_with_a_lingering_entry_is_cleaned_up() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut recorded = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + // The entry embeds the pre-failure snapshot, as a crash between the two writes leaves it. + let snapshot = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let event = WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the repair pass must not rewrite"); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), + "the lingering entry must be removed" + ); + } + + /// A crash between the failure's record write and its entry removal loses the wallet + /// changeset too, so the restart's catch-up sync replays the same events: `TxReplaced` for + /// the lost funding transaction resolves through the lingering entry to the already-`Failed` + /// record. Re-embedding that record would stamp `Failed` into the entry and hide it from the + /// pending listing that repairs it; the replay must instead finish the interrupted removal. + #[tokio::test] + async fn replayed_replacement_finishes_an_interrupted_failure() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + let close_tx = wallet_paying_tx(&wallet, 3); + let close_txid = close_tx.compute_txid(); + + let splice_txid = Txid::from_byte_array([2u8; 32]); + let payment_id = PaymentId([21u8; 32]); + let mut recorded = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + + let snapshot = + interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + let candidates = vec![FundingTxCandidate { + txid: splice_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); + wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + + insert_confirmed_tx(&wallet, close_tx, 5); + + let block_id = + |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + let events = vec![ + WalletEvent::TxReplaced { + txid: splice_txid, + tx: Arc::new(dummy_tx()), + conflicts: vec![(0, close_txid)], + }, + WalletEvent::ChainTipChanged { + old_tip: block_id(9), + new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + }, + ]; + wallet.update_payment_store(events).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed); + assert_eq!(payment.latest_update_timestamp, 7, "the replay must not rewrite the record"); + assert!( + wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), + "the replay must finish the interrupted entry removal" + ); + } + + /// Recording a transaction the payment store does not know costs two reads of it: the + /// funding-status check looks the resolved id up, and the generic write merges against the + /// store. Nothing in between re-reads what the funding-status check has already seen. + #[tokio::test] + async fn unknown_transaction_is_recorded_after_two_payment_store_reads() { + let counting_store = ReadCountingStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let tx = wallet_paying_tx(&wallet, 1); + let txid = tx.compute_txid(); + let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let event = WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; + + let payment_id = PaymentId(txid.to_byte_array()); + assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); + assert_eq!(reads, 2, "recording an unknown transaction re-read the payment store"); + } + + /// A funding record wallet sync created for a round this node had not recorded keeps the + /// txid-derived id of its first candidate. Once the payment settles and its entry is removed, a + /// wallet event for that candidate no longer resolves through the candidate history — the + /// fallback keys it by its own txid, colliding with the record's id. Recording the event there + /// would merge a fresh wallet-view `Pending` payment into the terminal record; such events must + /// be skipped. + #[tokio::test] + async fn candidate_event_does_not_resurrect_a_settled_funding_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // The record keeps the txid-derived id of its first candidate r1, as one created for an + // unrecorded round does; its txid rotated to the RBF round r2. The payment failed and its + // pending entry is gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let r2 = Txid::from_byte_array([4u8; 32]); + let payment_id = PaymentId(r1.to_byte_array()); + let mut recorded = interactive_funding_details(payment_id, r2, Some(1_000_000), Some(600)); + recorded.status = PaymentStatus::Failed; + recorded.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); - let splice_txid = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId([21u8; 32]); - let candidates = vec![FundingTxCandidate { - txid: splice_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }]; - let details = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.record_funding_payment(details, candidates).await.unwrap(); + // 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(); - // Sync saw the close double-spend the splice's funding transaction. - wallet + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet .payment_stores .pending_payment_store() - .update(PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: None, - conflicting_txids: Some(vec![close_txid]), - candidates: Vec::new(), - }) + .get(&payment_id) .await - .unwrap(); + .unwrap() + .is_none()); + // ...and even confirms: the record settled as `Failed` and must stay that way. let event = WalletEvent::TxConfirmed { - txid: close_txid, - tx: Arc::new(close_tx), + 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 funding = + let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - match &funding.kind { - PaymentKind::Onchain { txid, status, tx_type } => { - assert_eq!(*txid, splice_txid, "the record must not adopt the close's txid"); - assert!(matches!(status, ConfirmationStatus::Unconfirmed)); - assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); - }, - kind => panic!("unexpected kind {:?}", kind), - } - assert_eq!(funding.amount_msat, Some(1_000_000)); - assert_eq!(funding.fee_paid_msat, Some(500)); - - let close = wallet + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet .payment_stores - .payment_store() - .get(&PaymentId(close_txid.to_byte_array())) + .pending_payment_store() + .get(&payment_id) .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), - } + .is_none()); } - /// 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. + /// The same collision through a conflict list: a pending entry listing a settled funding + /// record's transaction as a conflict of its own round resolves an event for that transaction + /// to the entry's record, which finds it foreign, and the fallback to the transaction's own id + /// lands on the settled record. That id is read before anything is written under it. #[tokio::test] - async fn close_record_does_not_adopt_a_conflicting_splice_round() { - let secp = bitcoin::secp256k1::Secp256k1::new(); - let counterparty_node_id = bitcoin::secp256k1::PublicKey::from_secret_key( - &secp, - &bitcoin::secp256k1::SecretKey::from_slice(&[1u8; 32]).unwrap(), - ); - let channel_id = lightning::ln::types::ChannelId::from_bytes([4u8; 32]); - - for _ in 0..12 { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - // The round pays a wallet address, so the wallet's view of it carries figures of its own. - let script_pubkey = wallet - .inner - .lock() - .unwrap() - .reveal_next_address(KeychainKind::External) - .address - .script_pubkey(); - let splice_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::ZERO, - input: vec![bitcoin::TxIn { - previous_output: bitcoin::OutPoint { - txid: Txid::from_byte_array([3u8; 32]), - vout: 0, - }, - script_sig: bitcoin::ScriptBuf::new(), - sequence: bitcoin::Sequence::MAX, - witness: bitcoin::Witness::new(), - }], - output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey }], - }; - let splice_txid = splice_tx.compute_txid(); - // Keyed away from the round's txid, as a later round of a splice is. - let payment_id = PaymentId([21u8; 32]); - let candidates = vec![FundingTxCandidate { - txid: splice_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }]; - let details = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.record_funding_payment(details, candidates).await.unwrap(); - - // Each close was recorded at broadcast, seen unconfirmed, then replaced by the round: - // what the `TxReplaced` arm leaves behind. - let close_txids: Vec = - (7u8..11).map(|byte| Txid::from_byte_array([byte; 32])).collect(); - for close_txid in &close_txids { - let close_details = PaymentDetails::new( - PaymentId(close_txid.to_byte_array()), - PaymentKind::Onchain { - txid: *close_txid, - status: ConfirmationStatus::Unconfirmed, - tx_type: Some(TransactionType::CooperativeClose { - counterparty_node_id, - channel_id, - }), - }, - Some(50_000_000), - Some(1_000), - PaymentDirection::Inbound, - PaymentStatus::Pending, - ); - wallet - .payment_stores - .payment_store() - .insert_or_update(close_details.clone()) - .await - .unwrap(); - let entry = - PendingPaymentDetails::new(close_details, vec![splice_txid], Vec::new()); - wallet - .payment_stores - .pending_payment_store() - .insert_or_update(entry) - .await - .unwrap(); - } - - assert_eq!( - wallet.find_payment_by_txid(splice_txid).await.unwrap(), - Some(payment_id), - "the round resolved to a record that only lists it as a conflict" - ); - - let event = WalletEvent::TxConfirmed { - txid: splice_txid, - tx: Arc::new(splice_tx), - block_time: confirmed_block_time(5), - old_block_time: None, - }; - wallet.update_payment_store(vec![event]).await.unwrap(); + async fn conflict_listed_event_does_not_resurrect_a_settled_funding_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; - let funding = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - match &funding.kind { - PaymentKind::Onchain { txid, status, tx_type } => { - assert_eq!(*txid, splice_txid); - assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); - assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); - }, - kind => panic!("unexpected kind {:?}", kind), - } - assert_eq!(funding.amount_msat, Some(1_000_000)); - assert_eq!(funding.fee_paid_msat, Some(500)); + // A settled funding record under the txid-derived id of r1, its pending entry gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let settled_id = PaymentId(r1.to_byte_array()); + let mut settled = interactive_funding_details(settled_id, r1, Some(1_000_000), Some(600)); + settled.status = PaymentStatus::Failed; + settled.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(settled).await.unwrap(); - for close_txid in &close_txids { - let close = wallet - .payment_stores - .payment_store() - .get(&PaymentId(close_txid.to_byte_array())) - .await - .unwrap() - .unwrap(); - match &close.kind { - PaymentKind::Onchain { txid, status, tx_type } => { - assert_eq!( - *txid, *close_txid, - "the close's record adopted the round's txid" - ); - assert!(matches!(status, ConfirmationStatus::Unconfirmed)); - assert!(matches!(tx_type, Some(TransactionType::CooperativeClose { .. }))); - }, - kind => panic!("unexpected kind {:?}", kind), - } - assert_eq!(close.amount_msat, Some(50_000_000)); - assert_eq!(close.fee_paid_msat, Some(1_000)); - } - } + // A live funding record whose entry lists r1 as a conflict of its round r2. + let r2 = Txid::from_byte_array([4u8; 32]); + let live_id = PaymentId(r2.to_byte_array()); + let live = interactive_funding_details(live_id, r2, Some(2_000_000), Some(700)); + wallet.payment_stores.payment_store().insert_or_update(live.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(live.clone(), vec![r1], Vec::new())) + .await + .unwrap(); + assert_eq!(wallet.find_payment_by_txid(r1).await.unwrap(), Some(live_id)); + + let event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&settled_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the settled record must not resurrect"); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&settled_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.payment_stores.payment_store().get(&live_id).await.unwrap(), Some(live)); } - /// 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. + /// The failure transition must apply regardless of the payment's direction: a splice-out + /// records as `Inbound` (funds return to the wallet) and dies to a conflicting close the + /// same way an outbound one does. #[tokio::test] - async fn funding_payment_fails_once_a_foreign_conflict_confirms_to_depth() { + async fn inbound_funding_payment_fails_once_a_foreign_conflict_confirms_to_depth() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(store, false).await; @@ -7360,8 +9365,9 @@ mod tests { fee_paid_msat: Some(500), awaiting_broadcast: false, }]; - let details = + let mut details = interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); + details.direction = PaymentDirection::Inbound; wallet.record_funding_payment(details, candidates).await.unwrap(); wallet .payment_stores @@ -7371,12 +9377,11 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); - // The round was seen in the mempool; the close is canonically confirmed, so the round, - // having lost the conflict, is no longer canonical. insert_unconfirmed_tx(&wallet, splice_tx); insert_confirmed_tx(&wallet, close_tx, 5); @@ -7388,106 +9393,6 @@ mod tests { }; wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed); - match &payment.kind { - PaymentKind::Onchain { txid, status, tx_type } => { - assert_eq!(*txid, splice_txid, "failing must not adopt the conflict's txid"); - assert!(matches!(status, ConfirmationStatus::Unconfirmed)); - assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); - }, - kind => panic!("unexpected kind {:?}", kind), - } - assert_eq!(payment.amount_msat, Some(1_000_000)); - assert_eq!(payment.fee_paid_msat, Some(500)); - assert!( - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), - "the entry must go so the lost transaction stops being rebroadcast" - ); - } - - /// A round that fell out of the mempool has not lost: BDK drops an evicted transaction from - /// the canonical set just as it drops one displaced by a confirmed conflict, but an evicted - /// round can be rebroadcast and confirm. With one round double-spent by a close confirmed to - /// depth and the other merely evicted, the record must stay `Pending`; it is lost only once - /// a transaction confirmed to depth spends an input of every round. - #[tokio::test] - async fn funding_payment_survives_while_an_evicted_candidate_can_still_confirm() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - // Two rounds of one negotiation, both seen in the mempool. - let first_round = wallet_paying_tx(&wallet, 1); - let first_txid = first_round.compute_txid(); - let second_round = wallet_paying_tx(&wallet, 2); - let second_txid = second_round.compute_txid(); - insert_unconfirmed_tx(&wallet, first_round); - insert_unconfirmed_tx(&wallet, second_round); - - let payment_id = PaymentId([22u8; 32]); - let candidates = vec![ - FundingTxCandidate { - txid: first_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }, - FundingTxCandidate { - txid: second_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(600), - awaiting_broadcast: false, - }, - ]; - let details = - interactive_funding_details(payment_id, second_txid, Some(1_000_000), Some(600)); - wallet.record_funding_payment(details, candidates).await.unwrap(); - - // A close double-spends the first round's input and confirms through the anti-reorg - // depth, while the second round merely drops out of the mempool. - let close_tx = wallet_paying_tx(&wallet, 1); - let close_txid = close_tx.compute_txid(); - wallet - .payment_stores - .pending_payment_store() - .update(PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: None, - conflicting_txids: Some(vec![close_txid]), - candidates: Vec::new(), - }) - .await - .unwrap(); - insert_confirmed_tx(&wallet, close_tx, 5); - evict_tx(&wallet, second_txid); - - let block_id = - |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; - let event = WalletEvent::ChainTipChanged { - old_tip: block_id(9), - new_tip: block_id(5 + ANTI_REORG_DELAY - 1), - }; - wallet.update_payment_store(vec![event]).await.unwrap(); - - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Pending, "the evicted round can still confirm"); - let entry = - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(entry.candidates().len(), 2, "both rounds stay on record"); - - // A second close spends the evicted round's input and confirms to depth too: no round - // can confirm now. The second close never displaced a canonical round, so it is absent - // from the conflict list; the loss is read from the wallet's transaction graph. - let second_close = wallet_paying_tx(&wallet, 2); - insert_confirmed_tx(&wallet, second_close, 6); - let event = WalletEvent::ChainTipChanged { - old_tip: block_id(5 + ANTI_REORG_DELAY - 1), - new_tip: block_id(6 + ANTI_REORG_DELAY - 1), - }; - wallet.update_payment_store(vec![event]).await.unwrap(); - let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.status, PaymentStatus::Failed); @@ -7500,674 +9405,974 @@ mod tests { .is_none()); } - /// A round the wallet never saw has not lost either: the wallet's graph learns a transaction - /// from sync alone, and the counterparty has the same signed round and may broadcast it. - /// With the round that added an input double-spent by a close confirmed to depth and an - /// earlier round unseen, the record must stay `Pending`; it is lost once the earlier round is - /// seen and a transaction confirmed to depth spends an input of it too. + /// Wallet sync can record a genuine replacement round before it is recorded as a candidate: + /// the counterparty broadcast a round this node did not contribute to, which is recorded only + /// when this node signs a later round of the splice. The funding-status gate then routes the + /// round's confirmation to a duplicate record keyed by the round's txid, whose pending entry + /// shadows the funding record in `find_payment_by_txid`'s direct probe. Once the round is + /// recorded as a candidate, the write must merge the duplicate — adopt its confirmation and + /// remove it — so a single record tracks the splice. #[tokio::test] - async fn funding_payment_survives_while_an_unseen_candidate_can_still_confirm() { + async fn recording_a_round_merges_duplicate_records_for_its_candidates() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(store, false).await; - // Two rounds of one negotiation: the first was never seen in the mempool, the second - // added an input and was. - let first_tx = wallet_paying_tx(&wallet, 1); - let first_txid = first_tx.compute_txid(); - let second_tx = wallet_paying_tx(&wallet, 2); - let second_txid = second_tx.compute_txid(); - insert_unconfirmed_tx(&wallet, second_tx); + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId([21u8; 32]); - let candidates = vec![ + // Round 1 recorded normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }]; + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate: a + // duplicate untyped record under the txid-derived id, plus its pending entry. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(duplicate_id)); + + // Round 2 is recorded as a candidate, with the history of a later round this node signs. + let rounds = vec![ FundingTxCandidate { - txid: first_txid, + txid: txid1, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), awaiting_broadcast: false, }, FundingTxCandidate { - txid: second_txid, + txid: txid2, amount_msat: Some(1_000_000), - fee_paid_msat: Some(600), + fee_paid_msat: Some(400), awaiting_broadcast: false, }, ]; - let details = - interactive_funding_details(payment_id, second_txid, Some(1_000_000), Some(600)); - wallet.record_funding_payment(details, candidates).await.unwrap(); - - // A close double-spends the input only the second round added and confirms through the - // anti-reorg depth. - let close_tx = wallet_paying_tx(&wallet, 2); - let close_txid = close_tx.compute_txid(); - wallet - .payment_stores - .pending_payment_store() - .update(PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: None, - conflicting_txids: Some(vec![close_txid]), - candidates: Vec::new(), - }) - .await - .unwrap(); - insert_confirmed_tx(&wallet, close_tx, 5); - - let block_id = - |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; - let event = WalletEvent::ChainTipChanged { - old_tip: block_id(9), - new_tip: block_id(5 + ANTI_REORG_DELAY - 1), - }; - wallet.update_payment_store(vec![event]).await.unwrap(); - - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Pending, "the unseen round can still confirm"); - let entry = - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(entry.candidates.len(), 2, "both rounds stay on record"); - - // The first round turns up in the mempool, and a second close spends its input and - // confirms to depth too: no round can confirm now. - insert_unconfirmed_tx(&wallet, first_tx); - let second_close = wallet_paying_tx(&wallet, 1); - insert_confirmed_tx(&wallet, second_close, 6); - let event = WalletEvent::ChainTipChanged { - old_tip: block_id(5 + ANTI_REORG_DELAY - 1), - new_tip: block_id(6 + ANTI_REORG_DELAY - 1), - }; - wallet.update_payment_store(vec![event]).await.unwrap(); + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.record_funding_payment(details, rounds).await.unwrap(); - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed); + // One record: the funding record carries the duplicate's confirmation and the confirmed + // candidate's figures; the duplicate and its pending entry are gone, so the round's txid + // resolves to the funding record again. + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + assert_eq!(payment.amount_msat, Some(1_000_000)); + assert_eq!(payment.fee_paid_msat, Some(400)); + match &payment.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } => assert_eq!(*txid, txid2), + kind => panic!("unexpected kind {:?}", kind), + } assert!(wallet .payment_stores .pending_payment_store() - .get(&payment_id) + .get(&duplicate_id) .await .unwrap() .is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); } - /// An unseen round that confirms after all is adopted by its record like any other - /// candidate — which a record failed while the round was unseen could never do. + /// A duplicate for an *unconfirmed* round carries no state the funding record needs: the + /// merge removes it without touching the record's active txid or figures, and the round's + /// txid maps back to the funding record through its candidate history. #[tokio::test] - async fn funding_payment_adopts_an_unseen_candidate_once_it_confirms() { + async fn recording_drops_unconfirmed_duplicates_without_adopting_their_txid() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); let wallet = new_test_wallet(store, false).await; - let first_tx = wallet_paying_tx(&wallet, 1); - let first_txid = first_tx.compute_txid(); - let second_tx = wallet_paying_tx(&wallet, 2); - let second_txid = second_tx.compute_txid(); - insert_unconfirmed_tx(&wallet, second_tx); + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); + + // Wallet sync saw round 1 — still unconfirmed — before any round was recorded. + let duplicate_id = PaymentId(txid1.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: txid1, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); - let payment_id = PaymentId([21u8; 32]); - let candidates = vec![ + // Round 2 is the active broadcast; its record lists both rounds. + let rounds = vec![ FundingTxCandidate { - txid: first_txid, + txid: txid1, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), awaiting_broadcast: false, }, FundingTxCandidate { - txid: second_txid, + txid: txid2, amount_msat: Some(1_000_000), - fee_paid_msat: Some(600), + fee_paid_msat: Some(400), awaiting_broadcast: false, }, ]; - let details = - interactive_funding_details(payment_id, second_txid, Some(1_000_000), Some(600)); - wallet.record_funding_payment(details, candidates).await.unwrap(); - - let close_tx = wallet_paying_tx(&wallet, 2); - let close_txid = close_tx.compute_txid(); - wallet - .payment_stores - .pending_payment_store() - .update(PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: None, - conflicting_txids: Some(vec![close_txid]), - candidates: Vec::new(), - }) - .await - .unwrap(); - insert_confirmed_tx(&wallet, close_tx, 5); - - let block_id = - |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; - let event = WalletEvent::ChainTipChanged { - old_tip: block_id(9), - new_tip: block_id(5 + ANTI_REORG_DELAY - 1), - }; - wallet.update_payment_store(vec![event]).await.unwrap(); - - // The first round confirms. - insert_confirmed_tx(&wallet, first_tx.clone(), 6); - let event = WalletEvent::TxConfirmed { - txid: first_txid, - tx: Arc::new(first_tx), - block_time: confirmed_block_time(6), - old_block_time: None, - }; - wallet.update_payment_store(vec![event]).await.unwrap(); + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.record_funding_payment(details, rounds).await.unwrap(); - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Pending); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + // The record keeps tracking the actively-broadcast round; a duplicate that never confirmed + // has nothing to adopt. match &payment.kind { - PaymentKind::Onchain { txid, status, tx_type } => { - assert_eq!(*txid, first_txid, "the record adopts the round that confirmed"); - assert!(matches!(status, ConfirmationStatus::Confirmed { height: 6, .. })); - assert!(matches!(tx_type, Some(TransactionType::InteractiveFunding { .. }))); + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } => { + assert_eq!(*txid, txid2) }, kind => panic!("unexpected kind {:?}", kind), } - assert_eq!(payment.fee_paid_msat, Some(500), "with the fee of that round"); - assert!( - wallet - .payment_stores - .payment_store() - .get(&PaymentId(first_txid.to_byte_array())) - .await - .unwrap() - .is_none(), - "the round is not recorded a second time" - ); - let entry = - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().unwrap(); - assert!(matches!( - entry.details().unwrap().kind, - PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } - )); + assert_eq!(payment.fee_paid_msat, Some(400)); + assert_eq!(wallet.find_payment_by_txid(txid1).await.unwrap(), Some(funding_id)); } - /// 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. + /// A duplicate wallet sync has already failed is settled history, not a view of the round the + /// funding record takes over: the merge doesn't adopt its failure, and the bump that lists + /// the round among its candidates is filed under a record of its own, which doesn't have a + /// payment record until wallet sync observes the bump. The merge leaves the failed duplicate + /// alone. Failing it removed its pending entry, so it no longer shadows the funding record, + /// and the round's txid still maps to the funding record through its candidate history. #[tokio::test] - async fn funding_payment_survives_a_confirmed_conflict_that_is_a_candidate() { + async fn recording_keeps_a_failed_duplicate() { 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 funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); - let splice_txid = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId([21u8; 32]); - let candidates = vec![ + // Wallet sync recorded round 1 before it was a candidate, then failed it: the round lost + // to a conflicting spend. Failing it removed its pending entry. + let duplicate_id = PaymentId(txid1.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: txid1, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Failed, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate).await.unwrap(); + + // A bump of the round is recorded under a record of its own, with both rounds as its + // candidates. + let rounds = vec![ FundingTxCandidate { - txid: splice_txid, + txid: txid1, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), awaiting_broadcast: false, }, FundingTxCandidate { - txid: bumped_txid, + txid: txid2, amount_msat: Some(1_000_000), - fee_paid_msat: Some(600), + fee_paid_msat: Some(400), awaiting_broadcast: false, }, ]; - let details = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.record_funding_payment(details, candidates).await.unwrap(); - wallet + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.record_funding_payment(details, rounds).await.unwrap(); + + let failed = wallet .payment_stores - .pending_payment_store() - .update(PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: None, - conflicting_txids: Some(vec![bumped_txid]), - candidates: Vec::new(), - }) + .payment_store() + .get(&duplicate_id) .await - .unwrap(); - - insert_confirmed_tx(&wallet, bumped_tx, 5); - - let block_id = - |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; - let event = WalletEvent::ChainTipChanged { - old_tip: block_id(9), - new_tip: block_id(5 + ANTI_REORG_DELAY - 1), - }; - wallet.update_payment_store(vec![event]).await.unwrap(); - - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Pending); - assert!( - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), - "the entry must survive for classification to adopt the confirmed candidate" - ); + .unwrap() + .expect("the failed record stays"); + assert_eq!(failed.status, PaymentStatus::Failed); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 2, "the funding record is added beside the failed one"); + assert!(payments.iter().any(|payment| payment.id == funding_id)); + assert_eq!(wallet.find_payment_by_txid(txid1).await.unwrap(), Some(funding_id)); } - /// 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. + /// Removing the duplicate is two store writes, and the failure between them must leave a + /// state a re-run of the merge (a replayed `SpliceNegotiated` event) can finish cleaning up. + /// If the payment record went first, a failure on the pending-entry removal would orphan that + /// entry where the re-run can no longer discover it (the record lookup misses), and it would + /// keep shadowing the funding record in `find_payment_by_txid`'s direct probe — re-creating + /// the duplicate problem with no further merge coming to fix it. #[tokio::test] - async fn funding_payment_survives_a_foreign_conflict_short_of_depth() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + async fn a_rerun_merge_completes_a_partially_failed_duplicate_removal() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); let wallet = new_test_wallet(store, false).await; - let close_tx = wallet_paying_tx(&wallet, 3); - let close_txid = close_tx.compute_txid(); + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); - let splice_txid = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId([21u8; 32]); - let candidates = vec![FundingTxCandidate { - txid: splice_txid, + // Round 1 recorded normally. + let round1 = vec![FundingTxCandidate { + txid: txid1, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), awaiting_broadcast: false, }]; - let details = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.record_funding_payment(details, candidates).await.unwrap(); + let details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); wallet .payment_stores .pending_payment_store() - .update(PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: None, - conflicting_txids: Some(vec![close_txid]), - candidates: Vec::new(), - }) + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) .await .unwrap(); - insert_confirmed_tx(&wallet, close_tx, 5); + // Round 2 is recorded as a candidate, but one of the duplicate's two removals fails. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let res = wallet.record_funding_payment(details.clone(), rounds.clone()).await; + assert!(res.is_err(), "the injected remove failure must surface"); - 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), + // The merge re-runs with the record's next write; it must finish the cleanup. + wallet.record_funding_payment(details, rounds).await.unwrap(); + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, funding_id); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); + } + + /// Arms `store` so that the signing's first facts write lands and its next one fails, which + /// leaves the signed round's facts carrying the payment's id with nothing else recorded, and + /// runs the signing to that failure. Wallet sync may record the other rounds of the history + /// before the event is replayed, under their own ids: the facts already fix the id, so the + /// replay cannot adopt theirs. + async fn sign_with_the_candidate_facts_write_failing( + wallet: &Wallet, store: &FailSwitchStore, tx: &Transaction, candidates: &[FundingCandidate], + ) -> PaymentId { + store.writes_before_failing.store(1, Ordering::Release); + store.fail_writes.store(true, Ordering::Release); + let result = wallet.record_signed_funding(tx, candidates).await; + store.fail_writes.store(false, Ordering::Release); + assert!(matches!(result, Err(Error::PersistenceFailed)), "{:?}", result); + let txid = tx.compute_txid(); + let id = wallet + .channel_tx_facts(&txid) + .await + .and_then(|facts| facts.local_figures) + .expect("the signed round's facts landed") + .funding_payment_id; + for candidate in candidates.iter().filter(|candidate| candidate.txid != txid) { + assert!(wallet.channel_tx_facts(&candidate.txid).await.is_none()); + } + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + id + } + + /// A counterparty round that wallet sync recorded, under its own txid-derived id, between a + /// signing's failed write and its replay confirms before the replay: a confirmed duplicate + /// while no record exists under the payment's id yet. The merge records the confirmation + /// under the payment's id, by the wallet's view of the round, and the duplicate goes. + #[tokio::test] + async fn a_confirmed_duplicate_is_merged_before_the_payment_record_exists() { + let fail_store = + FailSwitchStore::failing_only(CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (counterparty_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let counterparty_txid = counterparty_tx.compute_txid(); + let (amount_msat, fee_paid_msat, direction) = { + let locked_wallet = wallet.inner.lock().unwrap(); + wallet.onchain_payment_fields_locked(&locked_wallet, &counterparty_tx) + }; + let (tx, contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], + ); + let id = + sign_with_the_candidate_facts_write_failing(&wallet, &fail_store, &tx, &candidates) + .await; + + // The counterparty's round confirms before the replay; wallet sync files it on its own. + let confirmation_height = 100; + insert_confirmed_tx(&wallet, counterparty_tx.clone(), confirmation_height); + let event = WalletEvent::TxConfirmed { + txid: counterparty_txid, + tx: Arc::new(counterparty_tx), + block_time: confirmed_block_time(confirmation_height), + old_block_time: None, }; wallet.update_payment_store(vec![event]).await.unwrap(); + let duplicate_id = PaymentId(counterparty_txid.to_byte_array()); + assert_ne!(duplicate_id, id); + assert!(wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap().is_some()); - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Pending); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + + // One record, under the payment's id, with the round's confirmation and the wallet's view of it. + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, id); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } + if txid == counterparty_txid + )); + assert_eq!(payment.amount_msat, amount_msat); + assert_eq!(payment.fee_paid_msat, fee_paid_msat); + assert_eq!(payment.direction, direction); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(payment)); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(counterparty_txid).await.unwrap(), Some(id)); + } + + /// An unconfirmed duplicate merged away while no record exists under the payment's id yet (a + /// counterparty round wallet sync recorded between a signing's failed write and its replay) + /// loses nothing: the round is a candidate of the entry and its facts carry the payment's id, + /// so its next event is recorded under that id, and a replacement in between is a logged skip. + #[tokio::test] + async fn an_unconfirmed_duplicate_merged_before_the_payment_record_exists_is_recorded_by_its_next_event( + ) { + let fail_store = + FailSwitchStore::failing_only(CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + let (counterparty_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let counterparty_txid = counterparty_tx.compute_txid(); + let (tx, contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(counterparty_txid, None), (txid, Some(contribution))], + ); + let id = + sign_with_the_candidate_facts_write_failing(&wallet, &fail_store, &tx, &candidates) + .await; + + // Wallet sync sees the counterparty's round, unconfirmed, before the replay: a duplicate. + observe_unconfirmed(&wallet, &counterparty_tx).await; + let duplicate_id = PaymentId(counterparty_txid.to_byte_array()); + assert_ne!(id, duplicate_id); + assert!(wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap().is_some()); + + // The replay completes the write and merges the duplicate away; our round is not + // observed, so no payment record exists under the payment's id. + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); assert!(wallet .payment_stores .pending_payment_store() - .get(&payment_id) + .get(&duplicate_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(); + .is_none()); + assert_eq!(wallet.find_payment_by_txid(counterparty_txid).await.unwrap(), Some(id)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(entry.candidate(counterparty_txid).is_some()); - let splice_txid = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId([21u8; 32]); - let candidates = vec![ - FundingTxCandidate { - txid: splice_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }, - FundingTxCandidate { - txid: live_candidate_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(600), - awaiting_broadcast: false, - }, - ]; - let details = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - wallet.record_funding_payment(details, candidates).await.unwrap(); - wallet + // A replacement or eviction of the counterparty's round is harmless without a record. + let replaced = WalletEvent::TxReplaced { + txid: counterparty_txid, + tx: Arc::new(counterparty_tx.clone()), + conflicts: vec![(0, txid)], + }; + wallet.update_payment_store(vec![replaced]).await.unwrap(); + assert!(wallet .payment_stores - .pending_payment_store() - .update(PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: None, - conflicting_txids: Some(vec![conflict_txid]), - candidates: Vec::new(), - }) + .payment_store() + .list_page(None) .await - .unwrap(); - - insert_confirmed_tx(&wallet, conflict_tx, 5); - insert_unconfirmed_tx(&wallet, live_candidate_tx); + .unwrap() + .objects + .is_empty()); - 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), + // The counterparty's round confirms after all: recorded under the payment's id. + let confirmation_height = 100; + insert_confirmed_tx(&wallet, counterparty_tx.clone(), confirmation_height); + let event = WalletEvent::TxConfirmed { + txid: counterparty_txid, + tx: Arc::new(counterparty_tx), + block_time: confirmed_block_time(confirmation_height), + old_block_time: None, }; wallet.update_payment_store(vec![event]).await.unwrap(); - - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Pending); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "{:?}", payments); + assert_eq!(payments[0].id, id); assert!( - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_some(), - "a candidate can still confirm, so the record must stay pending" + matches!( + payments[0].kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } + if txid == counterparty_txid + ), + "{:?}", + payments[0].kind ); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payments[0])); + assert!(wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap().is_none()); } - /// The failure write pair is record first, entry second: a crash in between leaves a - /// `Failed` record with a lingering entry. The next tip pass must finish the job — remove - /// the entry without disturbing the record — from the record's status alone: the wallet - /// here never saw the round, as after a crash that also lost the wallet's changeset. + /// A counterparty round's record is classified as an interactive funding by the facts its + /// `SpliceNegotiated` event records, whether wallet sync wrote the record before or after them. + /// It is a duplicate view of one of the payment's rounds all the same: once a later signing + /// attributes the round to the payment, the round's events route there, so a duplicate left in + /// place would stay pending forever. #[tokio::test] - async fn a_failed_funding_payment_with_a_lingering_entry_is_cleaned_up() { + async fn recording_merges_a_duplicate_classified_as_an_interactive_funding() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - let close_tx = wallet_paying_tx(&wallet, 3); - let close_txid = close_tx.compute_txid(); - - let splice_txid = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId([21u8; 32]); - let mut recorded = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - recorded.status = PaymentStatus::Failed; - recorded.latest_update_timestamp = 7; - wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - // The entry embeds the pre-failure snapshot, as a crash between the two writes leaves it. - let snapshot = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - let candidates = vec![FundingTxCandidate { - txid: splice_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }]; - let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); - wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + // Our first round, signed and observed: the payment's record. + let (first_tx, first) = splice_out_round(&wallet, 1, 500_000, 300); + let first_txid = first_tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(first_txid, Some(first.clone()))], + ); + sign_and_observe_round(&wallet, &first_tx, &candidates).await; + let id = wallet.find_payment_by_txid(first_txid).await.unwrap().expect("id"); - insert_confirmed_tx(&wallet, close_tx, 5); + // The counterparty's bump of it: its `SpliceNegotiated` facts land, then sync sees it. + let (counterparty_tx, _) = splice_out_round(&wallet, 2, 500_000, 400); + let counterparty_txid = counterparty_tx.compute_txid(); + let channel = Channel { counterparty_node_id, channel_id }; + wallet + .record_channel_tx_facts( + ChannelTxFacts::new(counterparty_txid).with_self_role( + TransactionType::InteractiveFunding { channels: vec![channel] }, + ), + ) + .await + .unwrap(); + observe_unconfirmed(&wallet, &counterparty_tx).await; + let duplicate_id = PaymentId(counterparty_txid.to_byte_array()); + assert_ne!(duplicate_id, id); + let duplicate = wallet + .payment_stores + .payment_store() + .get(&duplicate_id) + .await + .unwrap() + .expect("a record of its own"); + assert!(matches!( + duplicate.kind, + PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. } + )); - 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(); + // Our bump of the counterparty's round, signed with the whole history. + let (bump_tx, bump) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(first_txid, Some(first)), (counterparty_txid, None), (bump_txid, Some(bump))], + ); + wallet.record_signed_funding(&bump_tx, &candidates).await.unwrap(); - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed); - assert_eq!(payment.latest_update_timestamp, 7, "the repair pass must not rewrite"); + assert_eq!(wallet.find_payment_by_txid(counterparty_txid).await.unwrap(), Some(id)); assert!( - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), - "the lingering entry must be removed" + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap().is_none(), + "the duplicate must be merged away" ); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1); + assert_eq!(payments[0].id, id); } - /// A crash between the failure's record write and its entry removal loses the wallet - /// changeset too, so the restart's catch-up sync replays the same events: `TxReplaced` for - /// the lost funding transaction resolves through the lingering entry to the already-`Failed` - /// record. Re-embedding that record would stamp `Failed` into the entry and hide it from the - /// pending listing that repairs it; the replay must instead finish the interrupted removal. + /// Two counterparty rounds wallet sync recorded between a signing's failed write and its + /// replay share one record: under the first round's txid-derived id, with the second round, + /// which replaced the first, as its transaction. The merge recognizes the record by that + /// transaction, a candidate too, not only by the round whose id it carries. #[tokio::test] - async fn replayed_replacement_finishes_an_interrupted_failure() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(store, false).await; - - let close_tx = wallet_paying_tx(&wallet, 3); - let close_txid = close_tx.compute_txid(); - - let splice_txid = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId([21u8; 32]); - let mut recorded = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - recorded.status = PaymentStatus::Failed; - recorded.latest_update_timestamp = 7; - wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); - - let snapshot = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - let candidates = vec![FundingTxCandidate { - txid: splice_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }]; - let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); - wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + async fn a_duplicate_whose_transaction_was_replaced_is_merged_by_its_current_round() { + let fail_store = + FailSwitchStore::failing_only(CHANNEL_TX_FACTS_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - insert_confirmed_tx(&wallet, close_tx, 5); + let (first_tx, _) = splice_out_round(&wallet, 1, 500_000, 300); + let first_txid = first_tx.compute_txid(); + let (replacement_tx, _) = splice_out_round(&wallet, 1, 499_000, 600); + let replacement_txid = replacement_tx.compute_txid(); + let (tx, contribution) = splice_out_round(&wallet, 2, 498_000, 900); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(first_txid, None), (replacement_txid, None), (txid, Some(contribution))], + ); + let id = + sign_with_the_candidate_facts_write_failing(&wallet, &fail_store, &tx, &candidates) + .await; - let block_id = - |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; + // The counterparty's round, then its replacement, each in a sync pass of its own. + observe_unconfirmed(&wallet, &first_tx).await; + insert_unconfirmed_tx(&wallet, replacement_tx.clone()); let events = vec![ WalletEvent::TxReplaced { - txid: splice_txid, - tx: Arc::new(dummy_tx()), - conflicts: vec![(0, close_txid)], + txid: first_txid, + tx: Arc::new(first_tx), + conflicts: vec![(0, replacement_txid)], }, - WalletEvent::ChainTipChanged { - old_tip: block_id(9), - new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + WalletEvent::TxUnconfirmed { + txid: replacement_txid, + tx: Arc::new(replacement_tx), + old_block_time: None, }, ]; wallet.update_payment_store(events).await.unwrap(); + let duplicate_id = PaymentId(first_txid.to_byte_array()); + assert_ne!(duplicate_id, id); + let duplicate = wallet + .payment_stores + .payment_store() + .get(&duplicate_id) + .await + .unwrap() + .expect("the replacement is filed under the first round's record"); + assert!(matches!( + duplicate.kind, + PaymentKind::Onchain { txid, .. } if txid == replacement_txid + )); + assert!(wallet + .payment_stores + .payment_store() + .get(&PaymentId(replacement_txid.to_byte_array())) + .await + .unwrap() + .is_none()); - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed); - assert_eq!(payment.latest_update_timestamp, 7, "the replay must not rewrite the record"); - assert!( - wallet.payment_stores.pending_payment_store().get(&payment_id).await.unwrap().is_none(), - "the replay must finish the interrupted entry removal" - ); - } - - /// Recording a transaction the payment store does not know costs two reads of it: the - /// funding-status check looks the resolved id up, and the generic write merges against the - /// store. Nothing in between re-reads what the funding-status check has already seen. - #[tokio::test] - async fn unknown_transaction_is_recorded_after_two_payment_store_reads() { - let counting_store = ReadCountingStore::new(); - let store: Arc = Arc::new(DynStoreWrapper(counting_store.clone())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - - let tx = wallet_paying_tx(&wallet, 1); - let txid = tx.compute_txid(); - let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); - let event = WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx), old_block_time: None }; - wallet.update_payment_store(vec![event]).await.unwrap(); - let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; - - let payment_id = PaymentId(txid.to_byte_array()); - assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_some()); - assert_eq!(reads, 2, "recording an unknown transaction re-read the payment store"); + sign_and_observe_round(&wallet, &tx, &candidates).await; + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(first_txid).await.unwrap(), Some(id)); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); } - /// A funding record's id is anchored to its first candidate's txid. Once the payment settles - /// and its entry is removed, a wallet event for that candidate no longer resolves through the - /// candidate history — the fallback keys it by its own txid, colliding with the record's id. - /// Recording the event there would merge a fresh wallet-view `Pending` payment into the - /// terminal record; such events must be skipped. + /// Signing a later round merges the duplicates of earlier rounds as a courtesy: the signed + /// round itself can have no duplicate yet, as our signatures have not left the node, and the + /// round's own `SpliceNegotiated` event re-runs the merge, replaying on failure. A merge + /// failure must therefore not fail the signing, whose record is complete once both stores are + /// written, and must not leave the record half rolled back. #[tokio::test] - async fn candidate_event_does_not_resurrect_a_settled_funding_payment() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + async fn signing_survives_a_failed_duplicate_merge() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - // The record's id derives from the first candidate r1; its txid rotated to the RBF round - // r2. The payment failed and its pending entry is gone. - let r1 = Txid::from_byte_array([2u8; 32]); - let r2 = Txid::from_byte_array([4u8; 32]); - let payment_id = PaymentId(r1.to_byte_array()); - let mut recorded = interactive_funding_details(payment_id, r2, Some(1_000_000), Some(600)); - recorded.status = PaymentStatus::Failed; - recorded.latest_update_timestamp = 7; - wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); + // Round 1 is recorded at signing; round 2 is a counterparty-initiated replacement the + // wallet observed before it was recorded as a candidate, filed as an untyped duplicate. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new())) + .await + .unwrap(); - // r1 reappears in the mempool after the failure... - let event = - WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; - wallet.update_payment_store(vec![event]).await.unwrap(); + // This node signs round 3, a bump of the replacement, but the duplicate's removal fails. + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + // The signing is recorded in full and the duplicate is left as it was. + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!( + entry.candidates().iter().map(|c| c.txid).collect::>(), + vec![txid, replacement_txid, bump_txid] + ); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + // The signing writes no payment record: the one wallet sync made for the first round + // still describes that round, and the entry still mirrors it. let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); - assert_eq!(payment.latest_update_timestamp, 7); + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(entry.details(), Some(&payment)); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); assert!(wallet .payment_stores .pending_payment_store() - .get(&payment_id) + .get(&duplicate_id) .await .unwrap() - .is_none()); - - // ...and even confirms: the record settled as `Failed` and must stay that way. - let event = WalletEvent::TxConfirmed { - txid: r1, - tx: Arc::new(dummy_tx()), - block_time: confirmed_block_time(5), - old_block_time: None, - }; - wallet.update_payment_store(vec![event]).await.unwrap(); + .is_some()); - let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); - assert_eq!(payment.latest_update_timestamp, 7); + // The bump's `SpliceNegotiated` event merges the duplicate away. + wallet.record_broadcast_splice_round(channel_id, bump_txid).await.unwrap(); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); assert!(wallet .payment_stores .pending_payment_store() - .get(&payment_id) + .get(&duplicate_id) .await .unwrap() .is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); } - /// The same collision through a conflict list: a pending entry listing a settled funding - /// record's transaction as a conflict of its own round resolves an event for that transaction - /// to the entry's record, which finds it foreign, and the fallback to the transaction's own id - /// lands on the settled record. That id is read before anything is written under it. + /// A duplicate merge failing under the `SpliceNegotiated` write must fail that write: the + /// replay it triggers is the merge's only re-run. The mark, cleared before the merge, stays + /// cleared and the duplicate is left as it was; the replayed write finds the round marked + /// already and merges the duplicate away. #[tokio::test] - async fn conflict_listed_event_does_not_resurrect_a_settled_funding_payment() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + async fn a_failed_duplicate_merge_fails_the_negotiation_write_until_its_replay() { + let fail_store = FailRemoveStore::new(); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - // A settled funding record under the txid-derived id of r1, its pending entry gone. - let r1 = Txid::from_byte_array([2u8; 32]); - let settled_id = PaymentId(r1.to_byte_array()); - let mut settled = interactive_funding_details(settled_id, r1, Some(1_000_000), Some(600)); - settled.status = PaymentStatus::Failed; - settled.latest_update_timestamp = 7; - wallet.payment_stores.payment_store().insert_or_update(settled).await.unwrap(); - - // A live funding record whose entry lists r1 as a conflict of its round r2. - let r2 = Txid::from_byte_array([4u8; 32]); - let live_id = PaymentId(r2.to_byte_array()); - let live = interactive_funding_details(live_id, r2, Some(2_000_000), Some(700)); - wallet.payment_stores.payment_store().insert_or_update(live.clone()).await.unwrap(); + // Round 1 is recorded at signing; round 2 is a counterparty-initiated replacement the + // wallet observed before it was recorded as a candidate, filed as an untyped duplicate; + // round 3, a bump this node signs, records round 2 as a candidate, but the signing's + // merge of the duplicate fails and is left to the bump's `SpliceNegotiated` event. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); wallet .payment_stores .pending_payment_store() - .insert_or_update(PendingPaymentDetails::new(live.clone(), vec![r1], Vec::new())) + .insert_or_update(PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new())) .await .unwrap(); - assert_eq!(wallet.find_payment_by_txid(r1).await.unwrap(), Some(live_id)); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); - let event = - WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; - wallet.update_payment_store(vec![event]).await.unwrap(); + // The event's write meets the same failure: it must surface, so the event is replayed, + // with the mark cleared and the duplicate untouched. + fail_store.fail_next_remove_in(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let res = wallet.record_broadcast_splice_round(channel_id, bump_txid).await; + assert!(res.is_err(), "a failed merge must fail the write"); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(!entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&duplicate_id) + .await + .unwrap() + .is_some()); - let payment = - wallet.payment_stores.payment_store().get(&settled_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed, "the settled record must not resurrect"); - assert_eq!(payment.latest_update_timestamp, 7); + // The replayed write finds the round marked already and merges the duplicate away. + wallet.record_broadcast_splice_round(channel_id, bump_txid).await.unwrap(); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); assert!(wallet .payment_stores .pending_payment_store() - .get(&settled_id) + .get(&duplicate_id) .await .unwrap() .is_none()); - assert_eq!(wallet.payment_stores.payment_store().get(&live_id).await.unwrap(), Some(live)); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); } - /// The failure transition must apply regardless of the payment's direction: a splice-out - /// records as `Inbound` (funds return to the wallet) and dies to a conflicting close the - /// same way an outbound one does. + /// A merge cut short between adopting a confirmed duplicate's confirmation and removing the + /// duplicate leaves the funding record confirmed on the duplicate's transaction, the pending + /// entry at its prior status and the duplicate untouched, and a re-run completes the removal: + /// the merge is idempotent, so the record's next write or a replayed `SpliceNegotiated` event + /// can finish what a failure cut short. The failure injected is the pending store's, which the + /// adoption writes after the payment store. #[tokio::test] - async fn inbound_funding_payment_fails_once_a_foreign_conflict_confirms_to_depth() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + async fn a_torn_duplicate_merge_is_completed_by_a_rerun() { + let fail_store = + FailSwitchStore::failing_only(PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + let store: Arc = Arc::new(DynStoreWrapper(fail_store.clone())); let wallet = new_test_wallet(store, false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - // The splice round and the close spend the same input. - let splice_tx = wallet_paying_tx(&wallet, 3); - let splice_txid = splice_tx.compute_txid(); - let close_tx = wallet_paying_tx(&wallet, 3); - let close_txid = close_tx.compute_txid(); + // Round 1 is recorded at signing, round 2 is a counterparty-initiated replacement, and + // round 3 is this node's bump of it, recorded with the channel's history when signed. + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + let (replacement_tx, _) = splice_out_round(&wallet, 2, 500_000, 500); + let replacement_txid = replacement_tx.compute_txid(); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 3, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[ + (txid, Some(contribution)), + (replacement_txid, None), + (bump_txid, Some(bump_contribution)), + ], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); - let payment_id = PaymentId([21u8; 32]); - let candidates = vec![FundingTxCandidate { - txid: splice_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }]; - let mut details = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - details.direction = PaymentDirection::Inbound; - wallet.record_funding_payment(details, candidates).await.unwrap(); + // Wallet sync filed the replacement's confirmation under an untyped record of its own, a + // duplicate of the funding record that already lists the replacement as a candidate. + let duplicate_id = PaymentId(replacement_txid.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { + txid: replacement_txid, + status: confirmed_status(), + tx_type: None, + }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + let duplicate_entry = PendingPaymentDetails::new(duplicate.clone(), Vec::new(), Vec::new()); wallet .payment_stores .pending_payment_store() - .update(PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: None, - conflicting_txids: Some(vec![close_txid]), - candidates: Vec::new(), - }) + .insert_or_update(duplicate_entry.clone()) .await .unwrap(); + let entry_before = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + let rounds = entry_before.candidates().to_vec(); - insert_unconfirmed_tx(&wallet, splice_tx); - insert_confirmed_tx(&wallet, close_tx, 5); - - let block_id = - |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; - let event = WalletEvent::ChainTipChanged { - old_tip: block_id(9), - new_tip: block_id(5 + ANTI_REORG_DELAY - 1), - }; - wallet.update_payment_store(vec![event]).await.unwrap(); - + // The merge adopts the confirmation onto the payment record, then fails to mirror it onto + // the pending entry and stops short of removing the duplicate. + fail_store.fail_writes.store(true, Ordering::Release); + { + let guard = wallet.payment_stores.lock().await; + let res = wallet.merge_duplicate_candidate_records(&guard, id, &rounds).await; + assert!(res.is_err(), "the injected pending-store failure must surface"); + } + fail_store.fail_writes.store(false, Ordering::Release); let payment = - wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); - assert_eq!(payment.status, PaymentStatus::Failed); + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } + if t == replacement_txid + )); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap(), + Some(entry_before) + ); + assert_eq!( + wallet.payment_stores.payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate) + ); + assert_eq!( + wallet.payment_stores.pending_payment_store().get(&duplicate_id).await.unwrap(), + Some(duplicate_entry) + ); + + // A re-run finds the confirmation adopted, mirrors it, and removes the duplicate. + { + let guard = wallet.payment_stores.lock().await; + wallet.merge_duplicate_candidate_records(&guard, id, &rounds).await.unwrap(); + } + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + assert_eq!(payments[0].id, id); assert!(wallet .payment_stores .pending_payment_store() - .get(&payment_id) + .get(&duplicate_id) .await .unwrap() .is_none()); + assert_eq!(wallet.find_payment_by_txid(replacement_txid).await.unwrap(), Some(id)); } #[tokio::test] @@ -10440,4 +12645,35 @@ mod tests { payment.kind, ); } + + #[tokio::test] + async fn a_payment_that_already_advanced_gets_no_pending_entry() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + + let id = PaymentId([23u8; 32]); + let txid = Txid::from_byte_array([24u8; 32]); + // Wallet sync confirmed the payment through `ANTI_REORG_DELAY` before a writer with a + // `Pending` copy read earlier got to the pending store: the payment graduated, so no entry + // belongs there. + wallet + .payment_stores + .payment_store() + .insert(funding_payment(id, txid, PaymentStatus::Succeeded)) + .await + .unwrap(); + + let stores = wallet.payment_stores.lock().await; + wallet + .upsert_pending_payment( + &stores, + funding_payment(id, txid, PaymentStatus::Pending), + Vec::new(), + ) + .await + .unwrap(); + drop(stores); + + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } } diff --git a/src/wallet/payment_stores.rs b/src/wallet/payment_stores.rs index e00f5d598e..e88831725c 100644 --- a/src/wallet/payment_stores.rs +++ b/src/wallet/payment_stores.rs @@ -8,6 +8,7 @@ //! The wallet's payment stores behind one API, so that every write the wallet makes to them //! happens under the lock that keeps a payment record and its pending-store entry consistent. +use std::future::Future; use std::ops::Deref; use std::sync::Arc; @@ -146,4 +147,16 @@ impl PaymentStoresGuard<'_> { { self.stores.pending_payment_store.mutate(id, f).await } + + /// [`Self::mutate_pending_payment`] with a transformation that awaits fallible reads; see + /// [`DataStore::mutate_async`](crate::data_store::DataStore::mutate_async). + pub(super) async fn mutate_pending_payment_async( + &self, id: &PaymentId, f: F, + ) -> Result, Error> + where + F: FnOnce(Option) -> Fut, + Fut: Future, Error>>, + { + self.stores.pending_payment_store.mutate_async(id, f).await + } } diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index bb7c664f05..a1e79de0a1 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3048,9 +3048,7 @@ async fn splice_channel() { // them to the channel balance since there may not be a change output. let expected_splice_in_lightning_balance_sat = 4_000_002; - let payments = node_b.list_all_payments(); - let payment = - payments.into_iter().find(|p| p.id == PaymentId(txo.txid.to_byte_array())).unwrap(); + let payment = funding_payment(&node_b, txo.txid); assert_eq!(payment.fee_paid_msat, Some(expected_splice_in_fee_sat * 1_000)); assert_eq!( @@ -3101,9 +3099,7 @@ async fn splice_channel() { let expected_splice_out_fee_sat = 183; - let payments = node_a.list_all_payments(); - let payment = - payments.into_iter().find(|p| p.id == PaymentId(txo.txid.to_byte_array())).unwrap(); + let payment = funding_payment(&node_a, txo.txid); assert_eq!(payment.fee_paid_msat, Some(expected_splice_out_fee_sat * 1_000)); // The splice-out graduated to a confirmed interactive-funding payment. Its `direction` is left // unasserted on purpose: the destination is our own address, so it is a self-transfer (channel @@ -3577,15 +3573,20 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // classification — the `tx_type` assertion below catches a regression deterministically. wait_for_tx(&electrsd.client, original_txo.txid).await; wait_for_classified_funding_payment(&node_b, original_txo.txid).await; + // The record's random id is fixed at creation; capture it while the original candidate is + // current so its stability can be asserted across the RBF rounds below. + let splice_payment_id = funding_payment(&node_b, original_txo.txid).id; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); // For `confirm_original`, capture the original candidate's fee and raw transaction now, before // the RBF replaces it, so it can be force-confirmed (instead of the RBF) further below. let original_candidate: Option<(Option, String)> = if confirm_original { - let payment_id = PaymentId(original_txo.txid.to_byte_array()); - let fee = - node_b.payment(&payment_id).unwrap().expect("splice payment exists").fee_paid_msat; + let fee = node_b + .payment(&splice_payment_id) + .unwrap() + .expect("splice payment exists") + .fee_paid_msat; let raw_tx: String = bitcoind .client .call("getrawtransaction", &[json!(original_txo.txid.to_string())]) @@ -3616,12 +3617,11 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { node_b.sync_wallets().unwrap(); // After RBF but before confirmation, node_b (the initiator) should have a single on-chain - // payment covering both candidates: id anchored to the first broadcast, `kind.txid` pointing - // at the latest (RBF) candidate, and the durable interactive-funding `tx_type` preserved across - // the replacement. + // payment covering both candidates: still under the id it was created with, `kind.txid` + // pointing at the latest (RBF) candidate, and the durable interactive-funding `tx_type` + // preserved across the replacement. let rbf_candidate_fee = { - let payment_id = PaymentId(original_txo.txid.to_byte_array()); - let payment = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + let payment = node_b.payment(&splice_payment_id).unwrap().expect("splice payment exists"); match payment.kind { PaymentKind::Onchain { txid, @@ -3695,8 +3695,8 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // channel-lifecycle signal, not what drives payment status. Its `kind.txid` reflects the // winning RBF candidate, and `fee_paid_msat` carries this node's `FundingContribution` fee. { - let payment_id = PaymentId(original_txo.txid.to_byte_array()); - let payment = node_b.payment(&payment_id).unwrap().expect("splice payment graduated"); + let payment = + node_b.payment(&splice_payment_id).unwrap().expect("splice payment graduated"); assert_eq!(payment.status, PaymentStatus::Succeeded); match payment.kind { PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } => { @@ -3756,8 +3756,7 @@ async fn funding_payment_graduates_without_channel_ready() { // The funding payment is `Succeeded` purely from wallet sync reaching `ANTI_REORG_DELAY` // confirmations, asserted before draining any LDK event — so graduation is not driven by the // Lightning `ChannelReady` signal. - let payment_id = PaymentId(funding_txo.txid.to_byte_array()); - let payment = node_a.payment(&payment_id).unwrap().expect("funding payment exists"); + let payment = funding_payment(&node_a, funding_txo.txid); assert_eq!(payment.status, PaymentStatus::Succeeded); match payment.kind { PaymentKind::Onchain { @@ -3820,8 +3819,8 @@ async fn splice_payment_reorged_to_unconfirmed() { generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; node_b.sync_wallets().unwrap(); - let payment_id = PaymentId(splice_txo.txid.to_byte_array()); - let payment = node_b.payment(&payment_id).unwrap().expect("splice payment exists"); + let payment = funding_payment(&node_b, splice_txo.txid); + let payment_id = payment.id; assert_eq!(payment.status, PaymentStatus::Pending); assert!(matches!( payment.kind, @@ -3991,6 +3990,8 @@ fn only_signed_round_txid(logs: &CollectingLogWriter) -> Txid { } /// `node`'s payment for the funding transaction `funding_txid`, which it must have recorded. +/// Funding records are keyed by a random id generated at creation, so they are found through their +/// transaction history rather than by deriving an id from a txid. fn funding_payment(node: &TestNode, funding_txid: Txid) -> PaymentDetails { node.list_all_payments() .into_iter() @@ -4781,9 +4782,9 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { .filter(|outpoint| spends(&first_round, *outpoint)) .collect(); assert_eq!(shared, vec![funding_txo], "the bump reused an input of the first round"); - let payment_id = PaymentId(first_txo.txid.to_byte_array()); - let payment = node_a.payment(&payment_id).unwrap().expect("the splice has a payment"); + let payment = funding_payment(&node_a, bump_txo.txid); assert_eq!(payment.status, PaymentStatus::Pending); + let payment_id = payment.id; // Neither node reconnects to the other: node B closes on its own and node A learns of the // close from the chain alone. The commitment conflicts with the bump in the mempool: let it