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..4c863a34aa 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,12 @@ pub(crate) struct PendingPaymentDetails { /// RBF history, keyed by each candidate's txid and recorded as each round is signed. /// Empty for non-funding payments. pub candidates: Vec, + /// The live splice intent, or `None` for a non-splice payment or a splice that has + /// locked. Persisted at splice initiation and cleared once the splice locks or its failure + /// is surfaced, it outlives the rounds negotiated under it, because a fee bump is a fresh + /// negotiation LDK likewise abandons if the peer disconnects before signing, and shares the + /// bumped round's record rather than getting one of its own. + pub splice_intent: Option, /// The candidates LDK promoted to the channel's funding, as `ChannelReady` reported them. /// A zero-conf splice locks before its transaction confirms, and every later splice builds /// on it, so such a round can still confirm once the channel's funding has moved on from @@ -84,6 +156,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 +170,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 +193,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 +201,7 @@ impl PendingPaymentDetails { conflicting_txids: Vec::new(), funding_channels, candidates, + splice_intent, locked_rounds: Vec::new(), } } @@ -136,6 +231,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 +264,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 +277,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 +287,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 +341,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 +365,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 with no record yet carries nothing 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 +380,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 +419,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 +483,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 +527,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 +653,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 +813,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 +834,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 b883ea918a..58ad1eeac8 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -643,8 +643,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!( @@ -2612,6 +2612,67 @@ impl Wallet { Ok(tx) } + /// 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. + /// Only a history no live record tracks falls back to the channel's splice intent: a + /// user-initiated splice adopts the `PaymentId` generated when it was initiated, so its intent, + /// funding payment and candidate history share one record. The intent identifies the channel, + /// not the round, which is why it must not decide the id of a round already on record: a fee + /// bump this node signs of a round wallet sync recorded first must converge on the record sync + /// created, not be filed under the bump's intent as a second record. Otherwise a fresh id is + /// generated — an id derived from a txid would tie the record's identity to one round of a + /// replaceable transaction, and resolution through the record's txid history is what keeps its + /// identity stable across RBF replacements. The caller holds the cross-store lock: resolved + /// outside it, the id could go stale against a record wallet sync creates for the same + /// transaction before the caller's write. + async fn resolve_interactive_funding_id( + &self, stores: &PaymentStoresGuard<'_>, candidates: &[FundingCandidate], + active: &FundingCandidate, + ) -> Result { + for candidate in candidates.iter() { + if let Some(id) = self.find_payment_by_txid(candidate.txid).await? { + let failed = stores + .payment(&id) + .await? + .is_some_and(|payment| payment.status == PaymentStatus::Failed); + if !failed { + return Ok(id); + } + } + } + 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 @@ -2692,8 +2753,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 @@ -2727,9 +2789,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 @@ -2737,11 +2799,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) { @@ -2749,6 +2809,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) { @@ -2790,12 +2851,39 @@ impl Wallet { .with_local_figures(figures), ) .await?; + // The rounds this node contributed nothing to are candidates of this payment all the same, + // and one of them may be the round that confirms. Their facts carry the payment's id as + // the signed round's do, with no share of their own: the pending entry written below lists + // them, but graduation removes it, and the facts are then all that ties such a round to the + // payment (`find_payment_by_txid`). A round whose facts already carry a payment keeps it — + // the facts are immutable — so only rounds attributed to nothing yet are written. + 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(); @@ -2814,15 +2902,32 @@ 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. + 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> { @@ -2832,13 +2937,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 @@ -2849,13 +2953,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(()) } @@ -3100,9 +3210,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, @@ -3113,7 +3224,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) @@ -3121,7 +3232,72 @@ 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 an untyped duplicate whose pending entry then + /// shadows the funding record in [`Self::find_payment_by_txid`]'s direct probe. Once the + /// round is a recorded candidate, the duplicate's confirmation (if any) belongs on the + /// funding record: adopt it, then remove the duplicate and its pending entry. + /// + /// Runs once a record's candidate history is written, so the funding-status gate accepts the + /// candidates it adopts, and under the writer's lock acquisition, so sync cannot interleave. + /// It is idempotent: a failure at signing time ([`Self::record_signed_funding`]) is left to the + /// signed round's `SpliceNegotiated` event ([`Self::record_broadcast_splice_round`]), which + /// re-runs the merge and replays on failure. The caller must hold the [`PaymentStores`] lock, + /// per [`Self::apply_funding_status_update_locked`]'s contract. + async fn merge_duplicate_candidate_records( + &self, stores: &PaymentStoresGuard<'_>, id: PaymentId, candidates: &[FundingTxCandidate], + ) -> Result<(), Error> { + for candidate in candidates { + let duplicate_id = PaymentId(candidate.txid.to_byte_array()); + if duplicate_id == id { + continue; + } + let duplicate = match stores.payment(&duplicate_id).await? { + Some(duplicate) => duplicate, + None => continue, + }; + // Only a duplicate view of this candidate's transaction qualifies: a record wallet sync + // created for the round before it was a candidate, left untyped or classified as a + // plain funding. Anything else keyed by the txid-derived id is left alone. + let status = match &duplicate.kind { + PaymentKind::Onchain { + txid, + status, + tx_type: None | Some(TransactionType::Funding { .. }), + } if *txid == candidate.txid => status.clone(), + _ => continue, + }; + // Only a confirmation is worth adopting; an unconfirmed duplicate carries nothing the + // record needs — the actively-broadcast candidate stays the record's current txid. + if matches!(status, ConfirmationStatus::Confirmed { .. }) { + let outcome = self + .apply_funding_status_update_locked(stores, id, candidate.txid, status) + .await?; + debug_assert!(matches!(outcome, FundingStatusUpdate::Applied)); + if !matches!(outcome, FundingStatusUpdate::Applied) { + // Adoption declined; keep the duplicate rather than discard its confirmation. + continue; + } + } + log_debug!( + self.logger, + "Merging duplicate payment record for funding transaction {}", + candidate.txid, + ); + // Pending entry first: the retry of a failure between these two removals rediscovers + // the duplicate through its payment record. Removed the other way around, the + // leftover pending entry would be unreachable to the retry yet keep shadowing the + // funding record in `find_payment_by_txid`'s direct probe. + stores.remove_pending_payment(&duplicate_id).await?; + stores.remove_payment(&duplicate_id).await?; + } + Ok(()) } /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. @@ -3195,22 +3371,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(()) @@ -3236,11 +3427,15 @@ 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, the rounds this node contributed nothing to included. 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. + /// The payment store itself is never listed: it is bounded, and a record wallet sync created + /// under a transaction's own id is reached through that id, which the wallet-sync arms derive + /// from the transaction when nothing resolves 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) @@ -3258,8 +3453,9 @@ 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, and a splice keyed by a generated + // PaymentId is not found by the txid-derived id above: map any of the + // candidate txids (an earlier RBF round may confirm) back to the record. || p.candidate(target_txid).is_some() }; let matches = self @@ -3838,6 +4034,15 @@ enum FundingPaymentFailure { MovedOn, } +/// Generates a fresh funding-record [`PaymentId`] from the OS entropy source. A funding record's id +/// carries no meaning beyond uniqueness: the record is found through its transaction history +/// ([`Wallet::find_payment_by_txid`]), never re-derived from a txid. +fn random_payment_id() -> PaymentId { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("getrandom failed"); + PaymentId(bytes) +} + /// The outcome of [`Wallet::apply_funding_status_update_locked`]. enum FundingStatusUpdate { /// The event's transaction belongs to the funding payment; its refreshed confirmation status @@ -4200,7 +4405,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::{ @@ -4374,6 +4580,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 { @@ -5240,6 +5526,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) { @@ -5405,6 +5699,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. @@ -5512,18 +5926,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. @@ -5647,378 +6064,445 @@ 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 contributed nothing to is a candidate of the payment all the same, 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); + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); + // 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.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)); + 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()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_stores + .payment_store() + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); + + // The bump's intent, recorded at submission with no record left to join. + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let id = PaymentId([31u8; 32]); + let intent = SpliceIntent { + contribution: bump_contribution.clone(), + kind: SpliceKind::Rbf {}, + ..splice_intent_for(counterparty_node_id, channel_id, pre_splice_funding) + }; + wallet + .payment_stores + .pending_payment_store() + .insert(PendingPaymentDetails::pending_splice(id, intent.clone())) + .await + .unwrap(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - wallet.record_signed_funding(&tx, &[]).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()); } - /// A round this node did not contribute to is not its payment: the signing-time recording - /// declines it. + /// 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_skips_a_round_without_a_local_contribution() { + 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 (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); - let candidates = - splice_candidates(counterparty_node_id, channel_id, &[(tx.compute_txid(), None)]); - - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert!(wallet - .payment_stores - .payment_store() - .list_page(None) - .await - .unwrap() - .objects - .is_empty()); - assert!(wallet + let id = PaymentId([31u8; 32]); + let intent = 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()); + .unwrap(); + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; + + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the bump must join the first round's record"); + assert_eq!(payments[0].id, id); + assert!(matches!(payments[0].kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!( + entry.candidates().iter().map(|c| c.txid).collect::>(), + vec![txid, bump_txid] + ); + assert_eq!(entry.splice_intent(), Some(&intent)); } - /// A splice-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. + /// 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 signing_skips_a_wallet_untouched_transaction() { + 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 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 (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); let candidates = splice_candidates( counterparty_node_id, channel_id, - &[(tx.compute_txid(), Some(contribution))], + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_stores + .payment_store() + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); + + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], ); + sign_and_observe_round(&wallet, &bump_tx, &bump_candidates).await; - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - assert!(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(&bump_id) + .await + .unwrap() + .expect("entry"); + assert_eq!(entry.details(), Some(&payment)); + assert!(entry.candidate(bump_txid).expect("candidate").awaiting_broadcast); + let failed = wallet .payment_stores .payment_store() - .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. + /// 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 signing_merges_ldk_history_into_the_recorded_one() { + 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 prior_txid = Txid::from_byte_array([0xAA; 32]); - 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, - &[(prior_txid, None), (txid, Some(contribution))], + &[(counterparty_txid, None), (txid, Some(contribution.clone()))], ); sign_and_observe_round(&wallet, &tx, &candidates).await; + let failed_id = wallet.find_payment_by_txid(txid).await.unwrap().expect("record"); + // Wallet sync failed the payment and removed its entry. + wallet + .payment_stores + .payment_store() + .mutate(&failed_id, |existing| { + let mut update = PaymentDetailsUpdate::new(failed_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = existing?.clone(); + updated.update(update).then_some(updated) + }) + .await + .unwrap(); + wallet.payment_stores.pending_payment_store().remove(&failed_id).await.unwrap(); - let (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, 3, 498_000, 1_100); + 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))], - ); - sign_and_observe_round(&wallet, &next_tx, &next_candidates).await; - - let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; - assert_eq!(payments.len(), 1); - let id = payments[0].id; - assert_eq!(wallet.find_payment_by_txid(prior_txid).await.unwrap(), Some(id)); - assert!( - matches!(&payments[0].kind, PaymentKind::Onchain { txid: t, .. } if *t == next_txid) - ); - assert_eq!(payments[0].amount_msat, Some(400_700_000)); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!( - record.candidates().iter().map(|c| c.txid).collect::>(), - vec![prior_txid, txid, next_txid] - ); - assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); - assert_eq!(record.candidate(next_txid).unwrap().amount_msat, Some(400_700_000)); - } - - /// LDK abandoned a signed first round (the counterparty aborted before the signatures were - /// exchanged) and reports the failure: the round was never broadcast, so its entry goes rather - /// than wait on a transaction that will never exist — while another channel's entry is left - /// alone. - #[tokio::test] - async fn dropping_an_abandoned_first_round_removes_its_entry() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); - let wallet = new_test_wallet(Arc::clone(&store), false).await; - let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); - let other_channel_id = ChannelId([8u8; 32]); - - let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); - let txid = tx.compute_txid(); - let candidates = - splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - wallet.record_signed_funding(&tx, &candidates).await.unwrap(); - let (other_tx, other_contribution) = splice_out_round(&wallet, 2, 400_000, 700); - let other_txid = other_tx.compute_txid(); - let other_candidates = splice_candidates( - counterparty_node_id, - other_channel_id, - &[(other_txid, Some(other_contribution))], + &[ + (counterparty_txid, None), + (txid, Some(contribution)), + (bump_txid, Some(bump_contribution)), + ], ); - wallet.record_signed_funding(&other_tx, &other_candidates).await.unwrap(); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - let other_id = wallet.find_payment_by_txid(other_txid).await.unwrap().expect("other id"); - - wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + wallet + .record_signed_funding(&bump_tx, &bump_candidates) + .await + .expect("the signing is recorded"); - 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 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(&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!(entry.candidate(counterparty_txid).is_some()); } - /// 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. + /// 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_an_abandoned_bump_restores_the_prior_round() { + 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.clone()))], + &[(prior_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"); - - 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 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"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == bump_txid)); + 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, &[txid]).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), - "the original round must be the actively-tracked transaction again" + assert_eq!( + record.candidates().iter().map(|c| c.txid).collect::>(), + vec![prior_txid, txid] ); - 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)); + assert!(!record.candidate(txid).unwrap().awaiting_broadcast); + assert_eq!(record.candidate(txid).unwrap().amount_msat, Some(500_300_000)); } - /// 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. + /// A replayed `SpliceNegotiated` event refers to a round already marked broadcast; the store + /// is not written. #[tokio::test] - async fn dropping_keeps_a_round_the_wallet_has_seen() { - 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(); @@ -6026,218 +6510,276 @@ mod tests { let txid = tx.compute_txid(); let candidates = splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); - sign_and_observe_round(&wallet, &tx, &candidates).await; - evict_tx(&wallet, txid); - assert!(wallet.inner.lock().unwrap().get_tx(txid).is_none(), "evicted: not canonical"); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - - wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_broadcast_splice_round(channel_id, txid).await.unwrap(); - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - let payment = - wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); - assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); - assert_eq!(payment.status, PaymentStatus::Pending); + 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" + ); } - /// 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 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 dropping_keeps_rounds_handed_to_the_broadcaster() { - let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + 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 (_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(); + 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(); - let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); - - let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); - let bump_txid = bump_tx.compute_txid(); - let bump_candidates = splice_candidates( - counterparty_node_id, - channel_id, - &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], - ); - wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); - - wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); - - let record = - wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); - assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - let kept = record.candidate(txid).expect("the negotiated round keeps its place"); - assert_eq!(kept.amount_msat, Some(500_300_000)); - assert_eq!(kept.fee_paid_msat, Some(300_000)); - assert!(!kept.awaiting_broadcast); - // Wallet sync has not picked the round up, so there is no payment record either way. - assert!(wallet.payment_stores.payment_store().get(&id).await.unwrap().is_none()); + 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"); - let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); - let bump_txid = bump_tx.compute_txid(); - let bump_candidates = splice_candidates( - counterparty_node_id, - channel_id, - &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], - ); - wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + wallet.record_signed_funding(&tx, &[]).await.unwrap(); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + } - 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 - )); + /// 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(); - wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + let (tx, _contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(tx.compute_txid(), None)]); - assert_eq!( - wallet.payment_stores.payment_store().get(&id).await.unwrap(), - Some(payment.clone()) + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + assert!(wallet + .payment_stores + .payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + assert!(wallet + .payment_stores + .pending_payment_store() + .list_page(None) + .await + .unwrap() + .objects + .is_empty()); + } + + /// A splice-out to an external address moves no wallet funds; the signing-time recording + /// declines it — wallet sync cannot observe it either, so there is no race to close. + #[tokio::test] + async fn signing_skips_a_wallet_untouched_transaction() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let splice_out = + TxOut { value: Amount::from_sat(500_000), script_pubkey: ScriptBuf::new() }; + let contribution = + test_funding_contribution_with_outputs(300, 253, std::slice::from_ref(&splice_out)); + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: OutPoint { txid: Txid::from_byte_array([1u8; 32]), vout: 0 }, + ..Default::default() + }], + output: vec![splice_out], + }; + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(tx.compute_txid(), Some(contribution))], ); - 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(); @@ -6251,6 +6793,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( @@ -6260,165 +6803,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 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!( + 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!(entry.details(), Some(&payment)); - assert!(entry.candidate(bump_txid).is_none()); + 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 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. + /// 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(); @@ -6431,7 +6872,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( @@ -6440,77 +6883,54 @@ 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. + /// 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 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_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(); @@ -6521,30 +6941,422 @@ mod tests { channel_id, &[(txid, Some(contribution.clone()))], ); - sign_and_observe_round(&wallet, &tx, &candidates).await; + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); 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); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); - assert_eq!(wallet.payment_stores.payment_store().get(&id).await.unwrap(), Some(prior)); + insert_confirmed_tx(&wallet, tx.clone(), 105); + let event = WalletEvent::TxConfirmed { + txid, + tx: Arc::new(tx), + block_time: confirmed_block_time(105), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: t, status: ConfirmationStatus::Confirmed { .. }, .. } + if t == txid + )); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + assert_eq!( + wallet.payment_stores.payment_store().get(&id).await.unwrap(), + Some(payment.clone()) + ); let record = wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); assert_eq!(record.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); - assert!(wallet.payment_stores.payment_store().get(&bump_id).await.unwrap().is_none()); + assert_eq!(record.details(), Some(&payment)); + assert!(record.candidate(bump_txid).is_none()); } - /// The candidates handed to the signing-time recording are the channel's pending splice + /// The record moved on to a bump the entry does not list, so the entry still lists only the + /// original round. Abandoning that round, with no round of ours remaining, leaves the record + /// as it stands and only shrinks the entry's history, its copy of the record catching up. + #[tokio::test] + async fn dropping_the_last_round_leaves_a_record_that_moved_on() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + + // Wallet sync moved the record onto a bump the entry does not list. + let (bump_tx, _bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let mut moved_on = PaymentDetailsUpdate::new(id); + moved_on.txid = Some(bump_txid); + wallet.payment_stores.payment_store().update(moved_on).await.unwrap(); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert_eq!( + wallet.payment_stores.payment_store().get(&id).await.unwrap(), + Some(payment.clone()) + ); + let record = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("record"); + assert!(record.candidates().is_empty()); + assert_eq!(record.details(), Some(&payment)); + } + + /// A removal that was cut short between the two stores — the payment record went, the pending + /// entry stayed — is finished by the replayed drop: the entry alone still resolves the round's + /// txid, so it is what the replayed event finds and removes. + #[tokio::test] + async fn a_cut_short_removal_is_finished_by_the_replayed_drop() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = + splice_candidates(counterparty_node_id, channel_id, &[(txid, Some(contribution))]); + wallet.record_signed_funding(&tx, &candidates).await.unwrap(); + record_unseen_round(&wallet, &tx).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + wallet.payment_stores.payment_store().remove(&id).await.unwrap(); + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_some()); + + wallet.drop_abandoned_splice_rounds(channel_id, &[]).await.unwrap(); + + assert!(wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().is_none()); + } + + /// A hand-back that was cut short between the two stores — the payment record tracks the + /// original round again, the pending entry still lists the bump and mirrors the record as it + /// was — is finished by the replayed drop: the bump leaves the history and the entry's copy of + /// the record catches up with the record. + #[tokio::test] + async fn a_cut_short_hand_back_is_finished_by_the_replayed_drop() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(Arc::clone(&store), false).await; + let (counterparty_node_id, channel_id) = test_counterparty_and_channel(); + + let (tx, contribution) = splice_out_round(&wallet, 1, 500_000, 300); + let txid = tx.compute_txid(); + let candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution.clone()))], + ); + sign_and_observe_round(&wallet, &tx, &candidates).await; + let id = wallet.find_payment_by_txid(txid).await.unwrap().expect("id"); + let (bump_tx, bump_contribution) = splice_out_round(&wallet, 2, 499_000, 700); + let bump_txid = bump_tx.compute_txid(); + let bump_candidates = splice_candidates( + counterparty_node_id, + channel_id, + &[(txid, Some(contribution)), (bump_txid, Some(bump_contribution))], + ); + wallet.record_signed_funding(&bump_tx, &bump_candidates).await.unwrap(); + record_unseen_round(&wallet, &bump_tx).await; + + // The first half of the hand-back: the payment record alone tracks the original round. + let mut update = PaymentDetailsUpdate::new(id); + update.txid = Some(txid); + update.confirmation_status = Some(ConfirmationStatus::Unconfirmed); + update.amount_msat = Some(Some(500_300_000)); + update.fee_paid_msat = Some(Some(300_000)); + wallet.payment_stores.payment_store().update(update).await.unwrap(); + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert!(matches!( + entry.details().map(|details| &details.kind), + Some(PaymentKind::Onchain { txid: t, .. }) if *t == bump_txid + )); + + wallet.drop_abandoned_splice_rounds(channel_id, &[txid]).await.unwrap(); + + let entry = + wallet.payment_stores.pending_payment_store().get(&id).await.unwrap().expect("entry"); + assert_eq!(entry.candidates().iter().map(|c| c.txid).collect::>(), vec![txid]); + let payment = + wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)); + assert_eq!(payment.amount_msat, Some(500_300_000)); + assert_eq!(entry.details(), Some(&payment)); + assert!(entry.candidate(bump_txid).is_none()); + } + + /// The rounds LDK 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. @@ -6713,8 +7525,8 @@ mod tests { } /// 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). + /// 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())); @@ -6966,6 +7778,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7184,6 +7997,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7269,6 +8083,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7362,6 +8177,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7447,6 +8263,7 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); @@ -7500,16 +8317,138 @@ mod tests { )); } - /// 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 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_a_confirmed_conflict_that_is_a_candidate() { + 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 bumped_tx = wallet_paying_tx(&wallet, 3); - let bumped_txid = bumped_tx.compute_txid(); + 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]); @@ -7521,7 +8460,7 @@ mod tests { awaiting_broadcast: false, }, FundingTxCandidate { - txid: bumped_txid, + txid: live_candidate_txid, amount_msat: Some(1_000_000), fee_paid_msat: Some(600), awaiting_broadcast: false, @@ -7536,13 +8475,15 @@ mod tests { .update(PendingPaymentDetailsUpdate { id: payment_id, payment_update: None, - conflicting_txids: Some(vec![bumped_txid]), + conflicting_txids: Some(vec![conflict_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); - insert_confirmed_tx(&wallet, bumped_tx, 5); + 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]) }; @@ -7557,22 +8498,267 @@ mod tests { 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 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" ); } - /// 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. + /// 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(); + + // r1 reappears in the mempool after the failure... + let event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + + // ...and even confirms: the record settled as `Failed` and must stay that way. + let event = WalletEvent::TxConfirmed { + txid: r1, + tx: Arc::new(dummy_tx()), + block_time: confirmed_block_time(5), + old_block_time: None, + }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the record must not resurrect"); + assert!(matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == r2)); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&payment_id) + .await + .unwrap() + .is_none()); + } + + /// The same collision through a conflict list: a pending entry 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 conflict_listed_event_does_not_resurrect_a_settled_funding_payment() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + + // A settled funding record under the txid-derived id of r1, its pending entry gone. + let r1 = Txid::from_byte_array([2u8; 32]); + let settled_id = PaymentId(r1.to_byte_array()); + let mut settled = interactive_funding_details(settled_id, r1, Some(1_000_000), Some(600)); + settled.status = PaymentStatus::Failed; + settled.latest_update_timestamp = 7; + wallet.payment_stores.payment_store().insert_or_update(settled).await.unwrap(); + + // A live funding record whose entry lists r1 as a conflict of its round r2. + let r2 = Txid::from_byte_array([4u8; 32]); + let live_id = PaymentId(r2.to_byte_array()); + let live = interactive_funding_details(live_id, r2, Some(2_000_000), Some(700)); + wallet.payment_stores.payment_store().insert_or_update(live.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(live.clone(), vec![r1], Vec::new())) + .await + .unwrap(); + assert_eq!(wallet.find_payment_by_txid(r1).await.unwrap(), Some(live_id)); + + let event = + WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; + wallet.update_payment_store(vec![event]).await.unwrap(); + + let payment = + wallet.payment_stores.payment_store().get(&settled_id).await.unwrap().unwrap(); + assert_eq!(payment.status, PaymentStatus::Failed, "the settled record must not resurrect"); + assert_eq!(payment.latest_update_timestamp, 7); + assert!(wallet + .payment_stores + .pending_payment_store() + .get(&settled_id) + .await + .unwrap() + .is_none()); + assert_eq!(wallet.payment_stores.payment_store().get(&live_id).await.unwrap(), Some(live)); + } + + /// The failure transition must apply regardless of the payment's direction: a splice-out + /// records as `Inbound` (funds return to the wallet) and dies to a conflicting close the + /// same way an outbound one does. #[tokio::test] - async fn funding_payment_survives_a_foreign_conflict_short_of_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; + // 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 splice_txid = Txid::from_byte_array([2u8; 32]); let payment_id = PaymentId([21u8; 32]); let candidates = vec![FundingTxCandidate { txid: splice_txid, @@ -7580,8 +8766,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 @@ -7591,396 +8778,585 @@ mod tests { payment_update: None, conflicting_txids: Some(vec![close_txid]), candidates: Vec::new(), + splice_intent: None, }) .await .unwrap(); + 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 - 2), + 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_eq!(payment.status, PaymentStatus::Failed); assert!(wallet .payment_stores .pending_payment_store() .get(&payment_id) .await .unwrap() - .is_some()); + .is_none()); } - /// 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. + /// 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_a_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; - 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 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![ + // 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: splice_txid, + txid: txid1, amount_msat: Some(1_000_000), fee_paid_msat: Some(500), awaiting_broadcast: false, }, FundingTxCandidate { - txid: live_candidate_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(); + + // 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() - .update(PendingPaymentDetailsUpdate { - id: payment_id, - payment_update: None, - conflicting_txids: Some(vec![conflict_txid]), - candidates: Vec::new(), - }) + .get(&duplicate_id) .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" - ); + .unwrap() + .is_none()); + assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(funding_id)); } - /// 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 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 a_failed_funding_payment_with_a_lingering_entry_is_cleaned_up() { + 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 close_tx = wallet_paying_tx(&wallet, 3); - let close_txid = close_tx.compute_txid(); - - let splice_txid = Txid::from_byte_array([2u8; 32]); - let payment_id = PaymentId([21u8; 32]); - let mut recorded = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - recorded.status = PaymentStatus::Failed; - recorded.latest_update_timestamp = 7; - wallet.payment_stores.payment_store().insert_or_update(recorded).await.unwrap(); - - // The entry embeds the pre-failure snapshot, as a crash between the two writes leaves it. - let snapshot = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - let candidates = vec![FundingTxCandidate { - txid: splice_txid, - amount_msat: Some(1_000_000), - fee_paid_msat: Some(500), - awaiting_broadcast: false, - }]; - let entry = PendingPaymentDetails::new(snapshot, vec![close_txid], candidates); - wallet.payment_stores.pending_payment_store().insert_or_update(entry).await.unwrap(); + let funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); - insert_confirmed_tx(&wallet, close_tx, 5); + // 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 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(); + // Round 2 is the active broadcast; its record lists both rounds. + let rounds = vec![ + FundingTxCandidate { + txid: txid1, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(500), + awaiting_broadcast: false, + }, + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, + }, + ]; + let details = interactive_funding_details(funding_id, txid2, Some(1_000_000), Some(400)); + wallet.record_funding_payment(details, rounds).await.unwrap(); - let 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" - ); + let payments = wallet.payment_stores.payment_store().list_page(None).await.unwrap().objects; + assert_eq!(payments.len(), 1, "the duplicate must be merged away"); + let payment = &payments[0]; + assert_eq!(payment.id, funding_id); + // The record keeps tracking the actively-broadcast round; a duplicate that never confirmed + // has nothing to adopt. + match &payment.kind { + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, .. } => { + assert_eq!(*txid, txid2) + }, + kind => panic!("unexpected kind {:?}", kind), + } + assert_eq!(payment.fee_paid_msat, Some(400)); + assert_eq!(wallet.find_payment_by_txid(txid1).await.unwrap(), Some(funding_id)); } - /// 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. + /// 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 replayed_replacement_finishes_an_interrupted_failure() { - 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 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 funding_id = PaymentId([21u8; 32]); + let txid1 = Txid::from_byte_array([1u8; 32]); + let txid2 = Txid::from_byte_array([2u8; 32]); - let snapshot = - interactive_funding_details(payment_id, splice_txid, Some(1_000_000), Some(500)); - 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 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 details = interactive_funding_details(funding_id, txid1, Some(1_000_000), Some(500)); + wallet.record_funding_payment(details, round1).await.unwrap(); + + // Wallet sync recorded round 2's confirmation while the round was not yet a candidate. + let duplicate_id = PaymentId(txid2.to_byte_array()); + let duplicate = PaymentDetails::new( + duplicate_id, + PaymentKind::Onchain { txid: txid2, status: confirmed_status(), tx_type: None }, + Some(999_000), + Some(999), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + wallet.payment_stores.payment_store().insert_or_update(duplicate.clone()).await.unwrap(); + wallet + .payment_stores + .pending_payment_store() + .insert_or_update(PendingPaymentDetails::new(duplicate, Vec::new(), Vec::new())) + .await + .unwrap(); - 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)], + // 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, }, - WalletEvent::ChainTipChanged { - old_tip: block_id(9), - new_tip: block_id(5 + ANTI_REORG_DELAY - 1), + FundingTxCandidate { + txid: txid2, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(400), + awaiting_broadcast: false, }, ]; - 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 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 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; + // The merge re-runs with the record's next write; it must finish the cleanup. + wallet.record_funding_payment(details, rounds).await.unwrap(); - 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"); + 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)); } - /// 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] @@ -10215,4 +11591,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..2cf8224e46 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; @@ -31,6 +32,10 @@ use crate::Error; /// write compiles only for a holder of the lock. The reads are methods of this type and take no /// lock; a caller whose write depends on what it read takes the lock first and reads through the /// guard. +/// +/// The payment store is bounded, so nothing here lists it: a transaction is mapped to its payment +/// through the pending store, which keeps every entry in memory, and through what was recorded +/// about the transaction, which is read by its id. pub(super) struct PaymentStores { payment_store: Arc, pending_payment_store: Arc, @@ -146,4 +151,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 080fb8c359..e8b12bc168 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2899,9 +2899,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!( @@ -2952,9 +2950,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 @@ -3187,15 +3183,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())]) @@ -3226,12 +3227,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, @@ -3305,8 +3305,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 { .. }, .. } => { @@ -3366,8 +3366,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 { @@ -3430,8 +3429,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, @@ -3601,6 +3600,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() @@ -4391,9 +4392,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