From d72f5531eab1c01ee089e5ddec3420b7a4935d9e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:33:01 +0000 Subject: [PATCH 01/10] fix(wallet): reconcile late inputs and publish accounting corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep input attribution account-local, preserve complete transaction slices, and relay corrections without assigning another transaction's lock. Co-Authored-By: Codex 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- CHANGELOG.md | 5 + key-wallet-manager/src/event_tests.rs | 121 +++++++ key-wallet-manager/src/events.rs | 5 +- key-wallet-manager/src/process_block.rs | 36 +- .../managed_account/managed_account_ref.rs | 28 ++ .../managed_core_funds_account.rs | 99 ++++++ .../src/managed_account/transaction_record.rs | 24 ++ .../transaction_checking/wallet_checker.rs | 320 +++++++++++++++++- 8 files changed, 618 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd501d27d..d4cf02742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## Unreleased +### Fixed + +- Keep late wallet input attribution account-local, restore outgoing history details, + and emit complete accounting corrections without assigning another transaction’s InstantSend lock. + ### Changed - **Breaking:** the `bincode` feature and binary serialization dependencies now use diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index afccc35ae..0a3f3d398 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -2119,3 +2119,124 @@ async fn dropped_persistence_consumer_does_not_wedge_emission() { "broadcast delivery must be unaffected by a lost persistence consumer" ); } + +#[tokio::test] +async fn should_emit_late_input_corrections_without_borrowing_funding_lock() { + for locked_funding in [false, true] { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let funding = create_tx_paying_to(&addr, 0xa1); + let mut spender = create_tx_paying_to(&addr, 0xa2); + spender.input[0].previous_output = OutPoint { + txid: funding.txid(), + vout: 0, + }; + spender.output[0].value = TX_AMOUNT - 2000; + spender.output.push(TxOut { + value: 1000, + script_pubkey: ScriptBuf::new_p2pkh( + &PublicKey::from_slice(&[2; 33]).unwrap().pubkey_hash(), + ), + }); + manager.process_mempool_transaction(&spender, None).await; + let mut rx = manager.subscribe_events(); + let lock = locked_funding.then(|| dummy_instant_lock(funding.txid())); + manager.process_mempool_transaction(&funding, lock.clone()).await; + let events = drain_events(&mut rx); + let corrected = events + .iter() + .find_map(|event| match event { + WalletEvent::TransactionDetected { + wallet_id: id, + record, + .. + } if *id == wallet_id && record.txid == spender.txid() => Some(record), + _ => None, + }) + .expect("the corrected spender must reach persistence subscribers"); + assert_eq!(corrected.net_amount, -2000); + assert_eq!( + corrected.direction, + key_wallet::managed_account::transaction_record::TransactionDirection::Outgoing + ); + assert_eq!(corrected.input_details.len(), 1); + assert_eq!(corrected.context, TransactionContext::Mempool); + assert!( + !events.iter().any(|event| matches!(event, + WalletEvent::TransactionInstantLocked { txid, .. } if *txid == spender.txid() + )), + "the funding lock must never be assigned to its spender" + ); + manager.process_mempool_transaction(&funding, lock).await; + assert_no_events(&mut rx); + } +} + +#[tokio::test] +async fn should_preserve_attributed_inputs_when_another_funding_arrives() { + let (mut manager, _, addr) = setup_manager_with_wallet(); + let first = create_tx_paying_to(&addr, 0xb1); + let second = create_tx_paying_to(&addr, 0xb2); + let mut spender = create_tx_paying_to(&addr, 0xb3); + spender.input[0].previous_output = OutPoint { + txid: first.txid(), + vout: 0, + }; + let mut input = spender.input[0].clone(); + input.previous_output.txid = second.txid(); + spender.input.push(input); + spender.output[0].value = 2 * TX_AMOUNT - 1000; + manager.process_mempool_transaction(&spender, None).await; + manager.process_mempool_transaction(&first, None).await; + let mut rx = manager.subscribe_events(); + manager.process_mempool_transaction(&second, None).await; + let events = drain_events(&mut rx); + let corrected = events + .iter() + .find_map(|event| match event { + WalletEvent::TransactionDetected { + record, + .. + } if record.txid == spender.txid() => Some(record), + _ => None, + }) + .expect("second input correction"); + assert_eq!(corrected.net_amount, -1000); + assert_eq!(corrected.input_details.len(), 2); + assert_eq!(corrected.input_details[0].index, 0); + assert_eq!(corrected.input_details[1].index, 1); + manager.process_mempool_transaction(&second, None).await; + assert_no_events(&mut rx); +} + +#[tokio::test] +async fn should_emit_one_complete_correction_for_multiple_inputs_from_one_parent() { + let (mut manager, _, addr) = setup_manager_with_wallet(); + let mut funding = create_tx_paying_to(&addr, 0xc1); + funding.output.push(funding.output[0].clone()); + let mut spender = create_tx_paying_to(&addr, 0xc2); + spender.input[0].previous_output = OutPoint { + txid: funding.txid(), + vout: 0, + }; + let mut input = spender.input[0].clone(); + input.previous_output.vout = 1; + spender.input.push(input); + spender.output[0].value = 2 * TX_AMOUNT - 1000; + manager.process_mempool_transaction(&spender, None).await; + let mut rx = manager.subscribe_events(); + manager.process_mempool_transaction(&funding, None).await; + let events = drain_events(&mut rx); + let corrected: Vec<_> = events + .iter() + .filter_map(|event| match event { + WalletEvent::TransactionDetected { + record, + .. + } if record.txid == spender.txid() => Some(record), + _ => None, + }) + .collect(); + assert_eq!(corrected.len(), 1, "one final slice per account and transaction"); + assert_eq!(corrected[0].net_amount, -1000); + assert_eq!(corrected[0].input_details.len(), 2); +} diff --git a/key-wallet-manager/src/events.rs b/key-wallet-manager/src/events.rs index e044f0796..d1884f55f 100644 --- a/key-wallet-manager/src/events.rs +++ b/key-wallet-manager/src/events.rs @@ -180,9 +180,8 @@ fn format_account_balances(map: &BTreeMap) -> St /// consumers can persist the record(s) and balance atomically. #[derive(Debug, Clone)] pub enum WalletEvent { - /// First time the wallet sees an off-chain wallet-relevant transaction - /// (mempool, or directly via an InstantSend lock — in that case - /// `record.context` is `InstantSend(..)`). + /// An off-chain transaction was detected or its accounting details were corrected. + /// Corrections retain the transaction's own confirmation context; consumers upsert by account and txid. TransactionDetected { /// ID of the affected wallet. wallet_id: WalletId, diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index 5d9b61092..8aacf24c5 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -272,26 +272,30 @@ impl WalletInterface for WalletM per_wallet_released ); - if let Some(lock) = instant_lock { - for (wallet_id, records) in per_wallet_updated_records { - if records.is_empty() { - continue; - } - let Some(info) = self.wallet_infos.get(&wallet_id) else { - continue; - }; - let balance = info.balance(); - let account_balances = - per_wallet_account_diff.get(&wallet_id).cloned().unwrap_or_default(); - for record in records { - let event = WalletEvent::TransactionInstantLocked { + for (wallet_id, records) in per_wallet_updated_records { + let Some(info) = self.wallet_infos.get(&wallet_id) else { + continue; + }; + let balance = info.balance(); + let account_balances = + per_wallet_account_diff.get(&wallet_id).cloned().unwrap_or_default(); + for record in records { + let txid = record.txid; + self.emit_event(WalletEvent::TransactionDetected { + wallet_id, + record: Box::new(record), + balance, + account_balances: account_balances.clone(), + addresses_derived: Vec::new(), + }); + if let Some(lock) = instant_lock.as_ref().filter(|lock| lock.txid == txid) { + self.emit_event(WalletEvent::TransactionInstantLocked { wallet_id, - txid: record.txid, + txid, instant_lock: lock.clone(), balance, account_balances: account_balances.clone(), - }; - self.emit_event(event); + }); } } } diff --git a/key-wallet/src/managed_account/managed_account_ref.rs b/key-wallet/src/managed_account/managed_account_ref.rs index 192a358e2..00ba1d5fe 100644 --- a/key-wallet/src/managed_account/managed_account_ref.rs +++ b/key-wallet/src/managed_account/managed_account_ref.rs @@ -423,6 +423,34 @@ impl<'a> ManagedAccountRefMut<'a> { ManagedAccountRefMut::Keys(_) => false, } } + + /// Drain the born-spent funding outputs staged during the last + /// `record_transaction` / `confirm_transaction` call (out-of-order + /// funding — see `ManagedCoreFundsAccount::take_born_spent_outputs`). + /// Always empty for the [`Keys`](Self::Keys) variant. + pub(crate) fn take_born_spent_outputs(&mut self) -> Vec<(OutPoint, u64, Address)> { + match self { + ManagedAccountRefMut::Funds(a) => a.take_born_spent_outputs(), + ManagedAccountRefMut::Keys(_) => Vec::new(), + } + } + + /// Attribute a late funding output to its owning account, using known spender templates. + /// Always empty for the [`Keys`](Self::Keys) variant. + pub(crate) fn attribute_spent_input( + &mut self, + outpoint: &OutPoint, + value: u64, + address: &Address, + spenders: &[TransactionRecord], + ) -> Vec { + match self { + ManagedAccountRefMut::Funds(a) => { + a.attribute_spent_input(outpoint, value, address, spenders) + } + ManagedAccountRefMut::Keys(_) => Vec::new(), + } + } } /// Owned managed core account, either funds-bearing or keys-only. diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index e075f977c..5d9da1ea6 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -71,6 +71,10 @@ pub struct ManagedCoreFundsAccount { /// re-establish which coins are spent. #[cfg_attr(feature = "serde", serde(skip))] reservations: ReservationSet, + /// Late funding outputs awaiting attribution to account-local spender records. + /// Drained at wallet scope before returning; never persisted. + #[cfg_attr(feature = "serde", serde(skip))] + born_spent_outputs: Vec<(OutPoint, u64, Address)>, } /// What [`ManagedCoreFundsAccount::apply_abandon`] removed from one account. @@ -108,6 +112,7 @@ impl ManagedCoreFundsAccount { spent_outpoints: HashSet::new(), spent_before_funded: BTreeMap::new(), reservations: ReservationSet::default(), + born_spent_outputs: Vec::new(), } } @@ -136,6 +141,7 @@ impl ManagedCoreFundsAccount { spent_outpoints: HashSet::new(), spent_before_funded: BTreeMap::new(), reservations: ReservationSet::default(), + born_spent_outputs: Vec::new(), } } @@ -329,6 +335,11 @@ impl ManagedCoreFundsAccount { outpoint = %outpoint, "Skipping UTXO already spent by previously processed transaction" ); + self.born_spent_outputs.push(( + outpoint, + output.value, + addr.clone(), + )); continue; } @@ -343,6 +354,11 @@ impl ManagedCoreFundsAccount { outpoint = %outpoint, "Skipping UTXO already observed spent in an earlier-processed block (#649)" ); + self.born_spent_outputs.push(( + outpoint, + output.value, + addr.clone(), + )); self.spent_before_funded.insert( outpoint, Utxo::new( @@ -420,6 +436,88 @@ impl ManagedCoreFundsAccount { } } + /// Attribute a late funding output only to its owning account's spender slices. + pub(crate) fn attribute_spent_input( + &mut self, + outpoint: &OutPoint, + value: u64, + address: &Address, + spenders: &[TransactionRecord], + ) -> Vec { + if !self.contains_address(address) { + return Vec::new(); + } + let mut corrected = Vec::new(); + for template in spenders { + let Some(input_index) = template + .transaction + .input + .iter() + .position(|input| &input.previous_output == outpoint) + else { + continue; + }; + let mut record = + self.keys.transactions().get(&template.txid).cloned().unwrap_or_else(|| { + let mut record = template.clone(); + record.account_type = self.keys.managed_account_type().to_account_type(); + record.input_details.clear(); + record.output_details.clear(); + record + }); + if record.input_details.iter().any(|d| d.index == input_index as u32) { + continue; + } + record.input_details.push(InputDetail { + index: input_index as u32, + value, + address: address.clone(), + }); + record.input_details.sort_by_key(|d| d.index); + for (index, output) in record.transaction.output.iter().enumerate() { + if record.output_details.iter().any(|detail| detail.index == index as u32) { + continue; + } + let output_address = + Address::from_script(&output.script_pubkey, self.keys.network()).ok(); + let pool = output_address.as_ref().and_then(|addr| { + self.managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.address_index(addr).is_some()) + }); + let role = match pool { + Some(pool) if pool.pool_type == address_pool::AddressPoolType::Internal => { + OutputRole::Change + } + Some(_) => OutputRole::Received, + None if output.script_pubkey.is_provably_unspendable() => { + OutputRole::Unspendable + } + None => OutputRole::Sent, + }; + record.output_details.push(OutputDetail { + index: index as u32, + value: output.value, + address: output_address, + role, + }); + } + record.output_details.sort_by_key(|detail| detail.index); + record.recompute_net_and_direction(); + self.keys.transactions_mut().insert(record.txid, record.clone()); + self.spent_outpoints.insert(*outpoint); + corrected.push(record); + } + corrected + } + + /// Drain the born-spent outputs staged by [`Self::update_utxos`] since + /// the last drain, for the wallet-scope attribution sweep. + pub(crate) fn take_born_spent_outputs(&mut self) -> Vec<(OutPoint, u64, Address)> { + std::mem::take(&mut self.born_spent_outputs) + } + /// Drop the spent-marks that `freed` contributed, keeping every mark a /// surviving record still claims. /// @@ -1337,6 +1435,7 @@ impl<'de> Deserialize<'de> for ManagedCoreFundsAccount { spent_outpoints, spent_before_funded: helper.spent_before_funded, reservations: ReservationSet::default(), + born_spent_outputs: Vec::new(), }) } } diff --git a/key-wallet/src/managed_account/transaction_record.rs b/key-wallet/src/managed_account/transaction_record.rs index b51aee6f1..55c12052e 100644 --- a/key-wallet/src/managed_account/transaction_record.rs +++ b/key-wallet/src/managed_account/transaction_record.rs @@ -131,6 +131,30 @@ impl TransactionRecord { } } + /// Derive account-local flow from attributed inputs and owned outputs. + pub(crate) fn recompute_net_and_direction(&mut self) { + let owned: i64 = self + .output_details + .iter() + .filter(|o| matches!(o.role, OutputRole::Received | OutputRole::Change)) + .map(|o| o.value as i64) + .sum(); + let spent: i64 = self.input_details.iter().map(|i| i.value as i64).sum(); + self.net_amount = owned - spent; + if self.direction != TransactionDirection::CoinJoin { + let has_inputs = !self.input_details.is_empty(); + let has_sent = self.output_details.iter().any(|d| d.role == OutputRole::Sent); + let has_our_outputs = owned > 0; + self.direction = if !has_sent && has_inputs && has_our_outputs { + TransactionDirection::Internal + } else if has_inputs { + TransactionDirection::Outgoing + } else { + TransactionDirection::Incoming + }; + } + } + /// Calculate the number of confirmations based on current chain height pub fn confirmations(&self, current_height: u32) -> u32 { match self.context.block_info() { diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 81f1adf1b..9755c5ed7 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -11,7 +11,8 @@ use crate::wallet::managed_wallet_info::ManagedWalletInfo; use crate::{KeySource, Wallet}; use async_trait::async_trait; use dashcore::blockdata::transaction::Transaction; -use dashcore::{Amount, SignedAmount}; +use dashcore::{Address, Amount, OutPoint, SignedAmount}; +use std::collections::BTreeMap; /// Extension trait for ManagedWalletInfo to add transaction checking capabilities #[async_trait] @@ -42,6 +43,47 @@ pub trait WalletTransactionChecker { } impl ManagedWalletInfo { + /// Find spenders across accounts and attribute each input only to its owning account. + /// Drain before returning from transaction processing so corrections survive persistence. + fn attribute_born_spent( + &mut self, + born_spent: &[(OutPoint, u64, Address)], + result: &mut TransactionCheckResult, + ) { + if born_spent.is_empty() { + return; + } + for (outpoint, value, address) in born_spent { + let spenders: BTreeMap<_, _> = self + .accounts + .all_accounts() + .into_iter() + .flat_map(|account| account.transactions().values()) + .filter(|record| { + record.transaction.input.iter().any(|input| input.previous_output == *outpoint) + }) + .map(|record| (record.txid, record.clone())) + .collect(); + let spenders: Vec<_> = spenders.into_values().collect(); + for mut account in self.accounts.all_accounts_mut() { + let corrected = account.attribute_spent_input(outpoint, *value, address, &spenders); + if !corrected.is_empty() { + result.state_modified = true; + for record in corrected { + if let Some(existing) = result.updated_records.iter_mut().find(|existing| { + existing.txid == record.txid + && existing.account_type == record.account_type + }) { + *existing = record; + } else { + result.updated_records.push(record); + } + } + } + } + } + } + /// Promote records whose first sighting already consumed the live UTXO /// evidence used by transaction relevance checks. /// @@ -218,6 +260,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { // already holding a record — backfill via `record_transaction` // before marking UTXOs so the freshly registered UTXOs get the // IS-lock flag too. + let mut born_spent_instant: Vec<(OutPoint, u64, Address)> = Vec::new(); for account_match in result.affected_accounts.clone() { let Some(mut account) = self .accounts @@ -242,8 +285,10 @@ impl WalletTransactionChecker for ManagedWalletInfo { ); account.mark_utxos_instant_send(&txid); result.new_records.push(record); + born_spent_instant.extend(account.take_born_spent_outputs()); } } + self.attribute_born_spent(&born_spent_instant, &mut result); if update_balance { self.update_balance(); } @@ -257,6 +302,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { } // Process each affected account + let mut born_spent: Vec<(OutPoint, u64, Address)> = Vec::new(); for account_match in result.affected_accounts.clone() { let Some(mut account) = self.accounts.get_by_account_type_match_mut(&account_match.account_type_match) @@ -294,6 +340,8 @@ impl WalletTransactionChecker for ManagedWalletInfo { } } + born_spent.extend(account.take_born_spent_outputs()); + for address_info in account_match.account_type_match.all_involved_addresses() { account.mark_address_used(&address_info.address); } @@ -329,6 +377,8 @@ impl WalletTransactionChecker for ManagedWalletInfo { } } + self.attribute_born_spent(&born_spent, &mut result); + if is_new { // Populate dedup sets when a tx arrives with an initial IS status if context.is_instant_send() { @@ -3762,4 +3812,272 @@ mod tests { assert_eq!(ctx.managed_wallet.balance.confirmed(), 0); assert_eq!(ctx.managed_wallet.balance.unconfirmed(), payment_value); } + + /// A late input belongs to its funding account even when another account first saw the spender. + #[tokio::test] + async fn born_spent_attribution_reaches_sibling_account_spenders() { + for own_change in [0, 50_000] { + let network = Network::Testnet; + let mut wallet = + Wallet::new_random(network, WalletAccountCreationOptions::Default).expect("wallet"); + let mut managed_wallet = + ManagedWalletInfo::from_wallet_with_name(&wallet, "Test".to_string(), 0); + + // Funding pays the BIP44 receive address (account A). + let bip44_xpub = + wallet.accounts.standard_bip44_accounts.get(&0).expect("bip44").account_xpub; + let bip44_address = managed_wallet + .first_bip44_managed_account_mut() + .expect("bip44 managed") + .next_receive_address(Some(&bip44_xpub), true) + .expect("bip44 address"); + // The spender pays the CoinJoin account (account B) — so processed + // first, it is recorded in B only. The pool is pre-generated to the + // gap limit at construction; index 0 is already watched. + let cj_address = managed_wallet + .coinjoin_managed_account_at_index(0) + .expect("coinjoin managed") + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| { + pool.pool_type + == crate::managed_account::address_pool::AddressPoolType::External + }) + .expect("coinjoin external pool") + .address_at_index(0) + .expect("pre-generated coinjoin address") + .clone(); + + const FUND: u64 = 1_000_000; + const BACK: u64 = 900_000; + let funding_tx = Transaction::dummy(&bip44_address, 0..1, &[FUND]); + let funded_outpoint = OutPoint { + txid: funding_tx.txid(), + vout: 0, + }; + let mut spender_tx = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: funded_outpoint, + script_sig: ScriptBuf::new(), + sequence: 0xffffffff, + witness: dashcore::Witness::new(), + }], + output: vec![TxOut { + value: BACK, + script_pubkey: cj_address.script_pubkey(), + }], + special_transaction_payload: None, + }; + if own_change > 0 { + spender_tx.output.push(TxOut { + value: own_change, + script_pubkey: bip44_address.script_pubkey(), + }); + } + let spender_txid = spender_tx.txid(); + + // Spender first (height 2), landing in the CoinJoin account. + let result = managed_wallet + .check_core_transaction( + &spender_tx, + TransactionContext::InBlock(BlockInfo::new( + 2, + BlockHash::from_slice(&[3u8; 32]).expect("hash"), + 1_650_000_100, + )), + &mut wallet, + true, + true, + ) + .await; + assert!(result.is_relevant && result.is_new_transaction); + { + let cj = managed_wallet.coinjoin_managed_account_at_index(0).expect("cj"); + let record = + cj.transactions().get(&spender_txid).expect("spender in CoinJoin acct"); + assert_eq!( + record.net_amount, BACK as i64, + "born income-only in the sibling account" + ); + } + + // Funding second (height 1) — recognized by BIP44, whose account- + // local record may be missing. Attribution must create or update its own slice. + let result = managed_wallet + .check_core_transaction( + &funding_tx, + TransactionContext::InBlock(BlockInfo::new( + 1, + BlockHash::from_slice(&[2u8; 32]).expect("hash"), + 1_650_000_000, + )), + &mut wallet, + true, + true, + ) + .await; + assert!(result.is_relevant); + + let cj = managed_wallet.coinjoin_managed_account_at_index(0).expect("cj"); + let record = cj.transactions().get(&spender_txid).expect("spender record"); + assert_eq!(record.net_amount, BACK as i64); + assert!(record.input_details.is_empty(), "sibling inputs stay in their owning account"); + let bip44 = managed_wallet.first_bip44_managed_account().expect("bip44"); + let outgoing = bip44.transactions().get(&spender_txid).expect("owning account slice"); + assert_eq!(outgoing.net_amount, own_change as i64 - FUND as i64); + assert_eq!(outgoing.input_details.len(), 1); + assert_eq!( + record.net_amount + outgoing.net_amount, + (BACK + own_change) as i64 - FUND as i64 + ); + let corrected = + result.updated_records.iter().find(|r| r.txid == spender_txid).expect("correction"); + assert_eq!(corrected.net_amount, outgoing.net_amount); + } + } + + /// InstantSend backfill must publish corrections before its early return. + #[tokio::test] + async fn born_spent_attribution_runs_on_the_instant_send_backfill_branch() { + const FUND1: u64 = 50_000; + const BACK: u64 = 40_000; + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default) + .expect("Should create wallet"); + wallet + .add_account( + AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }, + None, + ) + .expect("Should add second BIP44 account"); + let mut managed_wallet = + ManagedWalletInfo::from_wallet_with_name(&wallet, "Test".to_string(), 0); + let xpub0 = + wallet.accounts.standard_bip44_accounts.get(&0).expect("account 0").account_xpub; + let address0 = managed_wallet + .bip44_managed_account_at_index_mut(0) + .expect("managed account 0") + .next_receive_address(Some(&xpub0), true) + .expect("address for account 0"); + let xpub1 = + wallet.accounts.standard_bip44_accounts.get(&1).expect("account 1").account_xpub; + let address1 = managed_wallet + .bip44_managed_account_at_index_mut(1) + .expect("managed account 1") + .next_receive_address(Some(&xpub1), true) + .expect("address for account 1"); + + // Funding pays both accounts; mempool delivery reaches both. + let mut funding_tx = Transaction::dummy(&address0, 0..1, &[100_000]); + funding_tx.output.push(TxOut { + value: FUND1, + script_pubkey: address1.script_pubkey(), + }); + let funding_txid = funding_tx.txid(); + let funded_outpoint = OutPoint { + txid: funding_txid, + vout: 1, + }; + let mut wallet_mut = wallet; + let mempool = managed_wallet + .check_core_transaction( + &funding_tx, + TransactionContext::Mempool, + &mut wallet_mut, + true, + true, + ) + .await; + assert!(mempool.is_relevant && mempool.is_new_transaction); + assert_eq!(mempool.affected_accounts.len(), 2); + + // Account 1 loses the delivery; account 0 keeps the record, so the + // wallet-level `is_new` is false when the IS lock arrives. + { + let account1 = + managed_wallet.bip44_managed_account_at_index_mut(1).expect("managed account 1"); + account1.transactions_mut().remove(&funding_txid); + account1.utxos.clear(); + } + + // A spend of account 1's output is mined before account 1 re-learns + // the funding: recorded with the income-only net (+BACK). + let spender_tx = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: funded_outpoint, + script_sig: ScriptBuf::new(), + sequence: 0xffffffff, + witness: dashcore::Witness::new(), + }], + output: vec![TxOut { + value: BACK, + script_pubkey: address1.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spender_txid = spender_tx.txid(); + let spender_context = TransactionContext::InBlock(BlockInfo::new( + 2, + BlockHash::from_slice(&[3u8; 32]).expect("block hash"), + 1_650_000_100, + )); + let result = managed_wallet + .check_core_transaction(&spender_tx, spender_context, &mut wallet_mut, true, true) + .await; + assert!(result.is_relevant && result.is_new_transaction); + { + let account1 = + managed_wallet.bip44_managed_account_at_index(1).expect("managed account 1"); + let record = account1.transactions().get(&spender_txid).expect("spender record"); + assert_eq!(record.net_amount, BACK as i64, "income-only until the funding is seen"); + } + + // The IS-locked funding takes the `!is_new` branch: account 0 is + // updated, account 1 is backfilled — and the backfill stages the + // born-spent output, which this branch must drain before returning. + let is_lock = InstantLock { + txid: funding_txid, + ..InstantLock::default() + }; + let result = managed_wallet + .check_core_transaction( + &funding_tx, + TransactionContext::InstantSend(is_lock), + &mut wallet_mut, + true, + true, + ) + .await; + assert!(result.is_relevant); + assert!(!result.is_new_transaction, "account 0 still holds the record"); + assert_eq!(result.new_records.len(), 1, "account 1 backfilled"); + assert_eq!(result.new_records[0].txid, funding_txid); + + let account1 = managed_wallet.bip44_managed_account_at_index(1).expect("managed account 1"); + let record = account1.transactions().get(&spender_txid).expect("spender record"); + assert_eq!( + record.net_amount, + BACK as i64 - FUND1 as i64, + "IS backfill branch must attribute the born-spent funding output immediately" + ); + assert_eq!(record.input_details.len(), 1); + assert!( + !account1.utxos.contains_key(&funded_outpoint), + "a born-spent output never becomes spendable" + ); + let corrected = result + .updated_records + .iter() + .find(|r| r.txid == spender_txid) + .expect("corrected spender record must surface in updated_records"); + assert_eq!(corrected.net_amount, BACK as i64 - FUND1 as i64); + } } From 93b6bc664fbad1ceb9892b85ce1c9fc4d1a1b8e4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:58:55 +0000 Subject: [PATCH 02/10] fix(wallet): preserve finalized retention during late attribution Collect every late input before pruning reconstructed chainlocked records. Do not resurrect records already finalized under the default retention policy; retain complete corrections when retention is enabled. Co-Authored-By: OpenAI Codex --- CHANGELOG.md | 1 + .../managed_core_funds_account.rs | 4 + .../transaction_checking/wallet_checker.rs | 88 +++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4cf02742..62dabd3d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Keep late wallet input attribution account-local, restore outgoing history details, and emit complete accounting corrections without assigning another transaction’s InstantSend lock. + Preserve finalized-record retention after collecting all late input corrections. ### Changed diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index 5d9da1ea6..cd8c4d4f6 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -449,6 +449,10 @@ impl ManagedCoreFundsAccount { } let mut corrected = Vec::new(); for template in spenders { + #[cfg(not(feature = "keep-finalized-transactions"))] + if self.keys.transaction_is_finalized(&template.txid) { + continue; + } let Some(input_index) = template .transaction .input diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 9755c5ed7..5cb6f220e 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -6,6 +6,8 @@ pub(crate) use super::account_checker::TransactionCheckResult; use super::transaction_context::TransactionContext; use super::transaction_router::{AccountTypeToCheck, TransactionRouter}; +#[cfg(not(feature = "keep-finalized-transactions"))] +use crate::managed_account::ManagedAccountRefMut; use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::wallet::managed_wallet_info::ManagedWalletInfo; use crate::{KeySource, Wallet}; @@ -82,6 +84,23 @@ impl ManagedWalletInfo { } } } + // Keep every input available until the complete correction has been captured. + #[cfg(not(feature = "keep-finalized-transactions"))] + for mut account in self.accounts.all_accounts_mut() { + let account_type = account.managed_account_type().to_account_type(); + for record in &result.updated_records { + if record.account_type == account_type && record.context.is_chain_locked() { + match &mut account { + ManagedAccountRefMut::Funds(funds) => { + funds.keys_mut().drop_finalized_transaction(&record.txid); + } + ManagedAccountRefMut::Keys(keys) => { + keys.drop_finalized_transaction(&record.txid); + } + } + } + } + } } /// Promote records whose first sighting already consumed the live UTXO @@ -3813,6 +3832,75 @@ mod tests { assert_eq!(ctx.managed_wallet.balance.unconfirmed(), payment_value); } + #[test] + fn born_spent_attribution_preserves_finalized_retention() { + use crate::account::TransactionRecord; + + for already_finalized in [false, true] { + let mut ctx = TestWalletContext::new_random(); + let funding = Transaction::dummy(&ctx.receive_address, 0..1, &[40_000, 60_000]); + let outpoints: Vec<_> = (0..2) + .map(|vout| OutPoint { + txid: funding.txid(), + vout, + }) + .collect(); + let mut spender = Transaction::dummy(&ctx.receive_address, 0..2, &[99_000]); + for (input, outpoint) in spender.input.iter_mut().zip(&outpoints) { + input.previous_output = *outpoint; + } + let txid = spender.txid(); + let sibling = ctx.managed_wallet.coinjoin_managed_account_at_index_mut(0).unwrap(); + let template = TransactionRecord::new( + spender, + sibling.managed_account_type().to_account_type(), + TransactionContext::InChainLockedBlock(BlockInfo::new( + 2, + BlockHash::all_zeros(), + 123, + )), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 0, + ); + sibling.transactions_mut().insert(txid, template.clone()); + if already_finalized { + let owner = ctx.managed_wallet.first_bip44_managed_account_mut().unwrap(); + let mut record = template; + record.account_type = owner.managed_account_type().to_account_type(); + owner.transactions_mut().insert(txid, record); + #[cfg(not(feature = "keep-finalized-transactions"))] + owner.keys_mut().drop_finalized_transaction(&txid); + } + let mut result = ctx.managed_wallet.accounts.check_transaction(&funding, &[]); + ctx.managed_wallet.attribute_born_spent( + &[ + (outpoints[0], 40_000, ctx.receive_address.clone()), + (outpoints[1], 60_000, ctx.receive_address.clone()), + ], + &mut result, + ); + let owner = ctx.managed_wallet.first_bip44_managed_account().unwrap(); + #[cfg(not(feature = "keep-finalized-transactions"))] + if already_finalized { + assert!(result.updated_records.is_empty(), "do not resurrect finalized records"); + assert!(!owner.transactions().contains_key(&txid)); + continue; + } + assert_eq!(result.updated_records.len(), 1); + let corrected = &result.updated_records[0]; + assert_eq!(corrected.input_details.len(), 2, "publish all late inputs together"); + assert_eq!(corrected.net_amount, -1_000); + assert!(owner.transaction_is_finalized(&txid)); + assert_eq!( + owner.transactions().contains_key(&txid), + cfg!(feature = "keep-finalized-transactions"), + ); + } + } + /// A late input belongs to its funding account even when another account first saw the spender. #[tokio::test] async fn born_spent_attribution_reaches_sibling_account_spenders() { From dd4dd777f126596c40f465452c4750dc2c218ef8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:17:41 +0000 Subject: [PATCH 03/10] chore: revert CHANGELOG.md changes Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62dabd3d9..fd501d27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## Unreleased -### Fixed - -- Keep late wallet input attribution account-local, restore outgoing history details, - and emit complete accounting corrections without assigning another transaction’s InstantSend lock. - Preserve finalized-record retention after collecting all late input corrections. - ### Changed - **Breaking:** the `bincode` feature and binary serialization dependencies now use From dfb8036d2782892319f1fd991a5431b71a3201e5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:17:57 +0000 Subject: [PATCH 04/10] fix: address late-input accounting review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve lock-only notifications for known mempool transactions and share initial/late-input direction classification, including zero-value change. Add reachable block, account-import, finality and mutation regressions. Document repeated correction events and the default finality retention limit. Co-Authored-By: Codex GPT-6 Astra 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- dash-spv-ffi/src/callbacks.rs | 7 +- key-wallet-manager/src/event_tests.rs | 165 ++++++++- key-wallet-manager/src/events.rs | 2 + key-wallet-manager/src/process_block.rs | 17 +- key-wallet/README.md | 13 + .../managed_core_funds_account.rs | 103 +++++- .../src/managed_account/transaction_record.rs | 36 +- .../transaction_checking/wallet_checker.rs | 335 +++++++----------- 8 files changed, 442 insertions(+), 236 deletions(-) diff --git a/dash-spv-ffi/src/callbacks.rs b/dash-spv-ffi/src/callbacks.rs index bb01d1d05..1c43c8d4e 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -723,7 +723,12 @@ impl Drop for FFIDerivedAddress { /// /// Fires when a wallet-relevant transaction is first seen off-chain — either /// in the mempool, or directly via an InstantSend lock (in that case the -/// record's `context` is `InstantSend(..)`). +/// record's `context` is `InstantSend(..)`). Also fires when an off-chain +/// funding arrival corrects an earlier transaction's accounting. Consumers must +/// upsert by wallet, account and txid; a correction retains the spender's context. +/// A plain InstantSend lock on a known mempool transaction emits only the lock callback. +/// Corrections after ChainLock require `keep-finalized-transactions` to retain +/// the spender's full record; default retention cannot repair an already pruned row. /// /// All pointer parameters are borrowed and only valid for the duration of the /// callback. `balance` is the wallet's balance *after* the transaction was diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index 0a3f3d398..b862b02f9 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -2186,9 +2186,9 @@ async fn should_preserve_attributed_inputs_when_another_funding_arrives() { spender.input.push(input); spender.output[0].value = 2 * TX_AMOUNT - 1000; manager.process_mempool_transaction(&spender, None).await; - manager.process_mempool_transaction(&first, None).await; - let mut rx = manager.subscribe_events(); manager.process_mempool_transaction(&second, None).await; + let mut rx = manager.subscribe_events(); + manager.process_mempool_transaction(&first, None).await; let events = drain_events(&mut rx); let corrected = events .iter() @@ -2240,3 +2240,164 @@ async fn should_emit_one_complete_correction_for_multiple_inputs_from_one_parent assert_eq!(corrected[0].net_amount, -1000); assert_eq!(corrected[0].input_details.len(), 2); } + +#[tokio::test] +async fn known_mempool_instant_lock_emits_only_lock_event() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let tx = create_tx_paying_to(&addr, 0xd1); + manager.process_mempool_transaction(&tx, None).await; + let mut rx = manager.subscribe_events(); + manager.process_mempool_transaction(&tx, Some(dummy_instant_lock(tx.txid()))).await; + let events = drain_events(&mut rx); + assert_eq!(events.len(), 1, "a lock must not detect a known transaction again"); + assert!(matches!(&events[0], WalletEvent::TransactionInstantLocked { wallet_id: id, txid, .. } + if *id == wallet_id && *txid == tx.txid())); +} + +#[tokio::test] +async fn confirming_funding_after_mempool_spend_preserves_attribution() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let funding = create_tx_paying_to(&addr, 0xd2); + let mut spender = create_tx_paying_to(&addr, 0xd3); + spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); + spender.output[0].value = TX_AMOUNT - 2000; + spender.output.push(TxOut { + value: 1000, + script_pubkey: Address::dummy(Network::Testnet, 999).script_pubkey(), + }); + manager.process_mempool_transaction(&funding, None).await; + manager.process_mempool_transaction(&spender, None).await; + let mut rx = manager.subscribe_events(); + let block = make_block(vec![funding], 0xd4, 100); + manager + .process_block_for_wallets(&block, block.block_hash(), 1, &BTreeSet::from([wallet_id])) + .await; + let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); + let record = &account.transactions()[&spender.txid()]; + assert_eq!(record.net_amount, -2000); + assert_eq!(record.input_details.len(), 1); + assert_eq!( + record.direction, + key_wallet::managed_account::transaction_record::TransactionDirection::Outgoing + ); + for event in drain_events(&mut rx) { + if let WalletEvent::BlockProcessed { + updated, + .. + } = event + { + assert!( + updated.iter().all(|record| record.txid != spender.txid()), + "unchanged spender needs no correction" + ); + } + } +} + +#[tokio::test] +async fn late_funding_block_publishes_spender_correction() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let funding = create_tx_paying_to(&addr, 0xd5); + let mut spender = create_tx_paying_to(&addr, 0xd6); + spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); + spender.output[0].value = TX_AMOUNT - 2000; + let spend_block = make_block(vec![spender.clone()], 0xd7, 200); + let wallets = BTreeSet::from([wallet_id]); + manager.process_block_for_wallets(&spend_block, spend_block.block_hash(), 2, &wallets).await; + let mut rx = manager.subscribe_events(); + let fund_block = make_block(vec![funding.clone()], 0xd8, 100); + manager.process_block_for_wallets(&fund_block, fund_block.block_hash(), 1, &wallets).await; + let events = drain_events(&mut rx); + let updated = events + .iter() + .find_map(|event| match event { + WalletEvent::BlockProcessed { + updated, + .. + } => Some(updated), + _ => None, + }) + .expect("block event"); + let record = + updated.iter().find(|record| record.txid == spender.txid()).expect("spender correction"); + assert_eq!(record.net_amount, -2000); + assert_eq!(record.input_details.len(), 1); + assert_eq!(record.input_details[0].value, TX_AMOUNT); + assert_eq!( + record.direction, + key_wallet::managed_account::transaction_record::TransactionDirection::Internal + ); + assert_eq!(record.context.block_info().unwrap().height(), 2); + assert!(!manager.wallet_infos[&wallet_id] + .first_bip44_managed_account() + .unwrap() + .utxos + .contains_key(&OutPoint::new(funding.txid(), 0))); +} + +#[tokio::test] +async fn finalized_spender_late_funding_chainlock_first() { + finalized_spender_late_funding_retention_policy(true).await; +} + +#[tokio::test] +async fn finalized_spender_late_funding_chainlock_between() { + finalized_spender_late_funding_retention_policy(false).await; +} + +async fn finalized_spender_late_funding_retention_policy(chainlock_first: bool) { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let wallets = BTreeSet::from([wallet_id]); + let funding = create_tx_paying_to(&addr, 0xe1); + let mut spender = create_tx_paying_to(&addr, 0xe2); + spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); + spender.output[0].value = TX_AMOUNT - 2000; + if chainlock_first { + manager.apply_chain_lock(ChainLock::dummy(2)); + } + let mut rx = manager.subscribe_events(); + let block = make_block(vec![spender.clone()], 0xe3, 200); + manager.process_block_for_wallets(&block, block.block_hash(), 2, &wallets).await; + let initial = drain_events(&mut rx); + let incoming = initial + .iter() + .find_map(|event| match event { + WalletEvent::BlockProcessed { + inserted, + .. + } => inserted.iter().find(|r| r.txid == spender.txid()), + _ => None, + }) + .expect("initial spender record"); + assert_eq!(incoming.net_amount, (TX_AMOUNT - 2000) as i64); + assert_eq!( + incoming.direction, + key_wallet::managed_account::transaction_record::TransactionDirection::Incoming + ); + if !chainlock_first { + manager.apply_chain_lock(ChainLock::dummy(2)); + } + let mut rx = manager.subscribe_events(); + let block = make_block(vec![funding], 0xe4, 100); + manager.process_block_for_wallets(&block, block.block_hash(), 1, &wallets).await; + let events = drain_events(&mut rx); + let correction = events.iter().find_map(|event| match event { + WalletEvent::BlockProcessed { + updated, + .. + } => updated.iter().find(|r| r.txid == spender.txid()), + _ => None, + }); + let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); + assert!(account.transaction_is_finalized(&spender.txid())); + if cfg!(feature = "keep-finalized-transactions") { + let record = correction.expect("retained finalized spender is correctable"); + assert_eq!(record.net_amount, -2000); + assert_eq!(record.input_details.len(), 1); + assert!(record.context.is_chain_locked()); + assert_eq!(account.transactions()[&spender.txid()].net_amount, -2000); + } else { + assert!(correction.is_none(), "txid-only retention cannot reconstruct accounting"); + assert!(!account.transactions().contains_key(&spender.txid())); + } +} diff --git a/key-wallet-manager/src/events.rs b/key-wallet-manager/src/events.rs index d1884f55f..67a3406a8 100644 --- a/key-wallet-manager/src/events.rs +++ b/key-wallet-manager/src/events.rs @@ -182,6 +182,8 @@ fn format_account_balances(map: &BTreeMap) -> St pub enum WalletEvent { /// An off-chain transaction was detected or its accounting details were corrected. /// Corrections retain the transaction's own confirmation context; consumers upsert by account and txid. + /// Lock-only updates use `TransactionInstantLocked`. Accounting corrections after ChainLock + /// require `keep-finalized-transactions`; default retention drops the full spender record. TransactionDetected { /// ID of the affected wallet. wallet_id: WalletId, diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index 8aacf24c5..3d3609f09 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -281,13 +281,16 @@ impl WalletInterface for WalletM per_wallet_account_diff.get(&wallet_id).cloned().unwrap_or_default(); for record in records { let txid = record.txid; - self.emit_event(WalletEvent::TransactionDetected { - wallet_id, - record: Box::new(record), - balance, - account_balances: account_balances.clone(), - addresses_derived: Vec::new(), - }); + // The arriving tx only changes lock status; other txids carry late-input corrections. + if txid != tx.txid() { + self.emit_event(WalletEvent::TransactionDetected { + wallet_id, + record: Box::new(record), + balance, + account_balances: account_balances.clone(), + addresses_derived: Vec::new(), + }); + } if let Some(lock) = instant_lock.as_ref().filter(|lock| lock.txid == txid) { self.emit_event(WalletEvent::TransactionInstantLocked { wallet_id, diff --git a/key-wallet/README.md b/key-wallet/README.md index 039e39a98..714101d05 100644 --- a/key-wallet/README.md +++ b/key-wallet/README.md @@ -269,6 +269,19 @@ Core dependencies: - `bip39`: Mnemonic phrase support - Additional optional dependencies for specialized features +## Late funding and finalized transaction history + +When funding arrives after its spender, retained transaction records receive corrected +input details, direction, and net amount. With default features, ChainLocks discard full +funds records and retain only transaction IDs. Funding discovered after that pruning cannot +repair the earlier record or an application’s persisted copy: an incoming change record may +remain incorrect. This applies whether the spender arrives below an existing ChainLock or +a ChainLock arrives between the spender and its funding. + +Enable `keep-finalized-transactions` before processing if your application needs accounting +corrections after finalization. It retains the full transaction history and therefore uses +more memory. Enabling it after records have been discarded does not recover those records. + ## Contributing Contributions are welcome! Please ensure: diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index cd8c4d4f6..a66a41f0f 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -20,9 +20,7 @@ use crate::managed_account::managed_account_trait::ManagedAccountTrait; use crate::managed_account::managed_account_type::ManagedAccountType; use crate::managed_account::managed_core_keys_account::ManagedCoreKeysAccount; use crate::managed_account::reservation::{ReservationSet, ReservationToken}; -use crate::managed_account::transaction_record::{ - InputDetail, OutputDetail, OutputRole, TransactionDirection, -}; +use crate::managed_account::transaction_record::{InputDetail, OutputDetail, OutputRole}; use crate::transaction_checking::transaction_router::TransactionType; use crate::transaction_checking::{AccountMatch, TransactionContext}; use crate::utxo::Utxo; @@ -991,20 +989,8 @@ impl ManagedCoreFundsAccount { }); } - // Determine direction - let has_sent = output_details.iter().any(|d| d.role == OutputRole::Sent); - let has_our_outputs = output_details - .iter() - .any(|d| d.role == OutputRole::Received || d.role == OutputRole::Change); - let direction = if transaction_type == TransactionType::CoinJoin { - TransactionDirection::CoinJoin - } else if !has_sent && has_inputs && has_our_outputs { - TransactionDirection::Internal - } else if has_inputs { - TransactionDirection::Outgoing - } else { - TransactionDirection::Incoming - }; + let direction = + TransactionRecord::direction_for(transaction_type, has_inputs, &output_details); let tx_record = TransactionRecord::new( tx.clone(), @@ -1479,6 +1465,7 @@ mod conflict_sweep_walk_tests { use super::*; use crate::account::AccountType; use crate::account::StandardAccountType; + use crate::managed_account::transaction_record::TransactionDirection; use crate::transaction_checking::BlockInfo; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::hashes::Hash; @@ -1697,3 +1684,85 @@ mod conflict_sweep_walk_tests { ); } } + +#[cfg(test)] +mod attribution_tests { + use super::*; + use crate::account::{AccountType, StandardAccountType}; + use crate::test_utils::TestWalletContext; + use crate::transaction_checking::BlockInfo; + use crate::wallet::ManagedWalletInfo; + use dashcore::hashes::Hash; + use dashcore::{BlockHash, TxOut}; + + #[tokio::test] + async fn born_spent_attribution_reconstructs_imported_account_change_and_spent_mark() { + use crate::managed_account::transaction_record::OutputRole; + use crate::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; + let mut ctx = TestWalletContext::new_random(); + let account_type = AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }; + ctx.wallet.add_account(account_type, None).unwrap(); + let xpub = ctx.wallet.accounts.standard_bip44_accounts[&1].account_xpub; + let mut preview = + ManagedWalletInfo::from_wallet_with_name(&ctx.wallet, "preview".into(), 0); + let account = preview.bip44_managed_account_at_index_mut(1).unwrap(); + let receive = account.next_receive_address(Some(&xpub), true).unwrap(); + let change = account.next_change_address(Some(&xpub), true).unwrap(); + let funding = Transaction::dummy(&receive, 30..31, &[100_000]); + let outpoint = OutPoint::new(funding.txid(), 0); + let mut spender = Transaction::dummy(&ctx.receive_address, 31..32, &[60_000]); + spender.input[0].previous_output = outpoint; + spender.output.push(TxOut { + value: 39_000, + script_pubkey: change.script_pubkey(), + }); + ctx.check_transaction( + &spender, + TransactionContext::InBlock(BlockInfo::new(2, BlockHash::all_zeros(), 200)), + ) + .await; + ctx.managed_wallet.add_managed_account(&ctx.wallet, account_type).unwrap(); + let result = ctx + .check_transaction( + &funding, + TransactionContext::InBlock(BlockInfo::new(1, BlockHash::all_zeros(), 100)), + ) + .await; + let corrected = + result.updated_records.iter().find(|r| r.account_type == account_type).unwrap(); + assert_eq!(corrected.net_amount, -61_000); + assert_eq!(corrected.output_details[1].role, OutputRole::Change); + assert_eq!(corrected.output_details[1].address.as_ref(), Some(&change)); + assert_eq!(corrected.input_details.len(), 1); + assert!( + ctx.managed_wallet + .bip44_managed_account_at_index(1) + .unwrap() + .is_outpoint_spent(&outpoint), + "late attribution must mark the input spent in its owning account" + ); + assert!(result.state_modified); + assert!(!ctx + .managed_wallet + .bip44_managed_account_at_index(1) + .unwrap() + .utxos + .contains_key(&outpoint)); + ctx.check_transaction( + &funding, + TransactionContext::InBlock(BlockInfo::new(1, BlockHash::all_zeros(), 100)), + ) + .await; + assert!( + !ctx.managed_wallet + .bip44_managed_account_at_index(1) + .unwrap() + .utxos + .contains_key(&outpoint), + "confirming funding must not resurrect an attributed spent coin" + ); + } +} diff --git a/key-wallet/src/managed_account/transaction_record.rs b/key-wallet/src/managed_account/transaction_record.rs index 55c12052e..ff200f6b6 100644 --- a/key-wallet/src/managed_account/transaction_record.rs +++ b/key-wallet/src/managed_account/transaction_record.rs @@ -141,17 +141,31 @@ impl TransactionRecord { .sum(); let spent: i64 = self.input_details.iter().map(|i| i.value as i64).sum(); self.net_amount = owned - spent; - if self.direction != TransactionDirection::CoinJoin { - let has_inputs = !self.input_details.is_empty(); - let has_sent = self.output_details.iter().any(|d| d.role == OutputRole::Sent); - let has_our_outputs = owned > 0; - self.direction = if !has_sent && has_inputs && has_our_outputs { - TransactionDirection::Internal - } else if has_inputs { - TransactionDirection::Outgoing - } else { - TransactionDirection::Incoming - }; + self.direction = Self::direction_for( + self.transaction_type, + !self.input_details.is_empty(), + &self.output_details, + ); + } + + /// Classify account-local flow consistently for initial records and late-input corrections. + pub(crate) fn direction_for( + transaction_type: TransactionType, + has_inputs: bool, + output_details: &[OutputDetail], + ) -> TransactionDirection { + let has_sent = output_details.iter().any(|d| d.role == OutputRole::Sent); + let has_our_outputs = output_details + .iter() + .any(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)); + if transaction_type == TransactionType::CoinJoin { + TransactionDirection::CoinJoin + } else if !has_sent && has_inputs && has_our_outputs { + TransactionDirection::Internal + } else if has_inputs { + TransactionDirection::Outgoing + } else { + TransactionDirection::Incoming } } diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 5cb6f220e..35d39c1bb 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -32,6 +32,11 @@ pub trait WalletTransactionChecker { /// Callers that batch multiple transactions (e.g. block processing) can pass `false` /// and refresh once at the end via `update_last_processed_height`. /// + /// Late funding corrects accounting only while the spender's full record is retained. + /// By default, ChainLocks prune funds records to txids; later funding cannot correct + /// those records or previously emitted copies. Enable `keep-finalized-transactions` + /// before processing for corrections after finalization, at the cost of retaining history. + /// /// The context parameter indicates where the transaction comes from (mempool, block, etc.) /// async fn check_core_transaction( @@ -1228,6 +1233,15 @@ mod tests { // because the stored spend_tx spends it let account = managed_wallet.first_bip44_managed_account().expect("Should have account"); + let record = &account.transactions()[&spend_tx.txid()]; + assert_eq!(record.net_amount, -50_000); + assert_eq!(record.direction, TransactionDirection::Internal); + assert_eq!(record.input_details.len(), 1); + assert_eq!(record.input_details[0].index, 0); + assert_eq!(record.input_details[0].value, 100_000); + assert_eq!(fund_result.updated_records[0].txid, spend_tx.txid()); + assert!(fund_result.state_modified); + // Should still only have one UTXO (the change from spend_tx) assert_eq!( account.utxos.len(), @@ -3832,75 +3846,6 @@ mod tests { assert_eq!(ctx.managed_wallet.balance.unconfirmed(), payment_value); } - #[test] - fn born_spent_attribution_preserves_finalized_retention() { - use crate::account::TransactionRecord; - - for already_finalized in [false, true] { - let mut ctx = TestWalletContext::new_random(); - let funding = Transaction::dummy(&ctx.receive_address, 0..1, &[40_000, 60_000]); - let outpoints: Vec<_> = (0..2) - .map(|vout| OutPoint { - txid: funding.txid(), - vout, - }) - .collect(); - let mut spender = Transaction::dummy(&ctx.receive_address, 0..2, &[99_000]); - for (input, outpoint) in spender.input.iter_mut().zip(&outpoints) { - input.previous_output = *outpoint; - } - let txid = spender.txid(); - let sibling = ctx.managed_wallet.coinjoin_managed_account_at_index_mut(0).unwrap(); - let template = TransactionRecord::new( - spender, - sibling.managed_account_type().to_account_type(), - TransactionContext::InChainLockedBlock(BlockInfo::new( - 2, - BlockHash::all_zeros(), - 123, - )), - TransactionType::Standard, - TransactionDirection::Incoming, - Vec::new(), - Vec::new(), - 0, - ); - sibling.transactions_mut().insert(txid, template.clone()); - if already_finalized { - let owner = ctx.managed_wallet.first_bip44_managed_account_mut().unwrap(); - let mut record = template; - record.account_type = owner.managed_account_type().to_account_type(); - owner.transactions_mut().insert(txid, record); - #[cfg(not(feature = "keep-finalized-transactions"))] - owner.keys_mut().drop_finalized_transaction(&txid); - } - let mut result = ctx.managed_wallet.accounts.check_transaction(&funding, &[]); - ctx.managed_wallet.attribute_born_spent( - &[ - (outpoints[0], 40_000, ctx.receive_address.clone()), - (outpoints[1], 60_000, ctx.receive_address.clone()), - ], - &mut result, - ); - let owner = ctx.managed_wallet.first_bip44_managed_account().unwrap(); - #[cfg(not(feature = "keep-finalized-transactions"))] - if already_finalized { - assert!(result.updated_records.is_empty(), "do not resurrect finalized records"); - assert!(!owner.transactions().contains_key(&txid)); - continue; - } - assert_eq!(result.updated_records.len(), 1); - let corrected = &result.updated_records[0]; - assert_eq!(corrected.input_details.len(), 2, "publish all late inputs together"); - assert_eq!(corrected.net_amount, -1_000); - assert!(owner.transaction_is_finalized(&txid)); - assert_eq!( - owner.transactions().contains_key(&txid), - cfg!(feature = "keep-finalized-transactions"), - ); - } - } - /// A late input belongs to its funding account even when another account first saw the spender. #[tokio::test] async fn born_spent_attribution_reaches_sibling_account_spenders() { @@ -4027,145 +3972,139 @@ mod tests { } } - /// InstantSend backfill must publish corrections before its early return. + /// An imported account can discover a mempool child before its known parent's IS redelivery. #[tokio::test] async fn born_spent_attribution_runs_on_the_instant_send_backfill_branch() { - const FUND1: u64 = 50_000; - const BACK: u64 = 40_000; - let mut wallet = - Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default) - .expect("Should create wallet"); - wallet - .add_account( - AccountType::Standard { - index: 1, - standard_account_type: StandardAccountType::BIP44Account, - }, - None, - ) - .expect("Should add second BIP44 account"); - let mut managed_wallet = - ManagedWalletInfo::from_wallet_with_name(&wallet, "Test".to_string(), 0); - let xpub0 = - wallet.accounts.standard_bip44_accounts.get(&0).expect("account 0").account_xpub; - let address0 = managed_wallet - .bip44_managed_account_at_index_mut(0) - .expect("managed account 0") - .next_receive_address(Some(&xpub0), true) - .expect("address for account 0"); - let xpub1 = - wallet.accounts.standard_bip44_accounts.get(&1).expect("account 1").account_xpub; - let address1 = managed_wallet + use crate::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; + let mut ctx = TestWalletContext::new_random(); + let account_type = AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }; + ctx.wallet.add_account(account_type, None).unwrap(); + let xpub = ctx.wallet.accounts.standard_bip44_accounts[&1].account_xpub; + let mut preview = + ManagedWalletInfo::from_wallet_with_name(&ctx.wallet, "preview".into(), 0); + let address = preview .bip44_managed_account_at_index_mut(1) - .expect("managed account 1") - .next_receive_address(Some(&xpub1), true) - .expect("address for account 1"); - - // Funding pays both accounts; mempool delivery reaches both. - let mut funding_tx = Transaction::dummy(&address0, 0..1, &[100_000]); - funding_tx.output.push(TxOut { - value: FUND1, - script_pubkey: address1.script_pubkey(), + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let mut funding = Transaction::dummy(&ctx.receive_address, 0..1, &[100_000]); + funding.output.push(TxOut { + value: 50_000, + script_pubkey: address.script_pubkey(), }); - let funding_txid = funding_tx.txid(); - let funded_outpoint = OutPoint { - txid: funding_txid, - vout: 1, - }; - let mut wallet_mut = wallet; - let mempool = managed_wallet - .check_core_transaction( - &funding_tx, - TransactionContext::Mempool, - &mut wallet_mut, - true, - true, + let first = ctx.check_transaction(&funding, TransactionContext::Mempool).await; + assert_eq!(first.new_records.len(), 1); + + ctx.managed_wallet.add_managed_account(&ctx.wallet, account_type).unwrap(); + let mut spender = Transaction::dummy(&address, 1..2, &[40_000]); + let outpoint = OutPoint::new(funding.txid(), 1); + spender.input[0].previous_output = outpoint; + ctx.check_transaction(&spender, TransactionContext::Mempool).await; + let result = ctx + .check_transaction( + &funding, + TransactionContext::InstantSend(InstantLock { + txid: funding.txid(), + ..InstantLock::default() + }), ) .await; - assert!(mempool.is_relevant && mempool.is_new_transaction); - assert_eq!(mempool.affected_accounts.len(), 2); + assert!(!result.is_new_transaction); + assert!(result.state_modified); + assert_eq!(result.new_records.len(), 1); + assert_eq!(result.new_records[0].account_type, account_type); + let corrected = result.updated_records.iter().find(|r| r.txid == spender.txid()).unwrap(); + assert_eq!(corrected.net_amount, -10_000); + assert_eq!(corrected.input_details.len(), 1); + assert_eq!(corrected.context, TransactionContext::Mempool); + assert!(!ctx + .managed_wallet + .bip44_managed_account_at_index(1) + .unwrap() + .utxos + .contains_key(&outpoint)); + } - // Account 1 loses the delivery; account 0 keeps the record, so the - // wallet-level `is_new` is false when the IS lock arrives. - { - let account1 = - managed_wallet.bip44_managed_account_at_index_mut(1).expect("managed account 1"); - account1.transactions_mut().remove(&funding_txid); - account1.utxos.clear(); - } + #[tokio::test] + async fn born_spent_attribution_preserves_coinjoin_direction() { + let mut ctx = TestWalletContext::new_random(); + let funding = Transaction::dummy(&ctx.receive_address, 10..11, &[100_001_000]); + let mut spender = Transaction::dummy(&ctx.receive_address, 11..14, &[100_001_000; 3]); + spender.input[1].previous_output = OutPoint::new(funding.txid(), 0); + let first = ctx.check_transaction(&spender, TransactionContext::Mempool).await; + assert_eq!(first.new_records[0].direction, TransactionDirection::CoinJoin); + let result = ctx.check_transaction(&funding, TransactionContext::Mempool).await; + let record = result.updated_records.iter().find(|r| r.txid == spender.txid()).unwrap(); + assert_eq!(record.direction, TransactionDirection::CoinJoin); + assert_eq!(record.input_details[0].index, 1); + assert_eq!(record.net_amount, 200_002_000); + } - // A spend of account 1's output is mined before account 1 re-learns - // the funding: recorded with the income-only net (+BACK). - let spender_tx = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: funded_outpoint, - script_sig: ScriptBuf::new(), - sequence: 0xffffffff, - witness: dashcore::Witness::new(), - }], - output: vec![TxOut { - value: BACK, - script_pubkey: address1.script_pubkey(), - }], - special_transaction_payload: None, - }; - let spender_txid = spender_tx.txid(); - let spender_context = TransactionContext::InBlock(BlockInfo::new( - 2, - BlockHash::from_slice(&[3u8; 32]).expect("block hash"), - 1_650_000_100, - )); - let result = managed_wallet - .check_core_transaction(&spender_tx, spender_context, &mut wallet_mut, true, true) - .await; - assert!(result.is_relevant && result.is_new_transaction); - { - let account1 = - managed_wallet.bip44_managed_account_at_index(1).expect("managed account 1"); - let record = account1.transactions().get(&spender_txid).expect("spender record"); - assert_eq!(record.net_amount, BACK as i64, "income-only until the funding is seen"); + #[tokio::test] + async fn born_spent_attribution_internal_change_including_zero_value() { + use crate::managed_account::transaction_record::OutputRole; + for change_value in [0, 98_000] { + let mut ctx = TestWalletContext::new_random(); + let change = ctx + .managed_wallet + .first_bip44_managed_account_mut() + .unwrap() + .next_change_address(Some(&ctx.xpub), true) + .unwrap(); + let funding = Transaction::dummy(&ctx.receive_address, 20..21, &[100_000]); + let mut spender = Transaction::dummy(&change, 21..22, &[change_value]); + spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); + ctx.check_transaction(&spender, TransactionContext::Mempool).await; + let result = ctx.check_transaction(&funding, TransactionContext::Mempool).await; + let record = result.updated_records.iter().find(|r| r.txid == spender.txid()).unwrap(); + assert_eq!(record.direction, TransactionDirection::Internal); + assert_eq!(record.net_amount, change_value as i64 - 100_000); + assert_eq!(record.output_details[0].role, OutputRole::Change); + assert!(result.state_modified); } + } - // The IS-locked funding takes the `!is_new` branch: account 0 is - // updated, account 1 is backfilled — and the backfill stages the - // born-spent output, which this branch must drain before returning. - let is_lock = InstantLock { - txid: funding_txid, - ..InstantLock::default() - }; - let result = managed_wallet - .check_core_transaction( - &funding_tx, - TransactionContext::InstantSend(is_lock), - &mut wallet_mut, - true, - true, - ) - .await; - assert!(result.is_relevant); - assert!(!result.is_new_transaction, "account 0 still holds the record"); - assert_eq!(result.new_records.len(), 1, "account 1 backfilled"); - assert_eq!(result.new_records[0].txid, funding_txid); + #[tokio::test] + async fn born_spent_correction_marks_state_modified_on_unchanged_funding_redelivery() { + let mut ctx = TestWalletContext::new_random(); + let mut preview = ctx.bip44_account().clone(); + let late_address = preview + .next_receive_addresses(Some(&ctx.xpub), 100, true) + .unwrap() + .last() + .unwrap() + .clone(); + assert!(!ctx.bip44_account().contains_address(&late_address)); + let mut funding = Transaction::dummy(&ctx.receive_address, 40..41, &[30_000]); + funding.output.push(TxOut { + value: 100_000, + script_pubkey: late_address.script_pubkey(), + }); + let fund_context = + TransactionContext::InBlock(BlockInfo::new(1, BlockHash::all_zeros(), 100)); + ctx.check_transaction(&funding, fund_context.clone()).await; + let mut spender = Transaction::dummy(&ctx.receive_address, 41..42, &[98_000]); + spender.input[0].previous_output = OutPoint::new(funding.txid(), 1); + ctx.check_transaction( + &spender, + TransactionContext::InBlock(BlockInfo::new(2, BlockHash::all_zeros(), 200)), + ) + .await; + assert_eq!(ctx.transaction(&spender.txid()).net_amount, 98_000); - let account1 = managed_wallet.bip44_managed_account_at_index(1).expect("managed account 1"); - let record = account1.transactions().get(&spender_txid).expect("spender record"); - assert_eq!( - record.net_amount, - BACK as i64 - FUND1 as i64, - "IS backfill branch must attribute the born-spent funding output immediately" - ); - assert_eq!(record.input_details.len(), 1); - assert!( - !account1.utxos.contains_key(&funded_outpoint), - "a born-spent output never becomes spendable" - ); - let corrected = result - .updated_records - .iter() - .find(|r| r.txid == spender_txid) - .expect("corrected spender record must surface in updated_records"); - assert_eq!(corrected.net_amount, BACK as i64 - FUND1 as i64); + ctx.managed_wallet + .first_bip44_managed_account_mut() + .unwrap() + .next_receive_addresses(Some(&ctx.xpub), 100, true) + .unwrap(); + assert!(ctx.bip44_account().contains_address(&late_address)); + let result = ctx.check_transaction(&funding, fund_context).await; + assert_eq!(result.updated_records.len(), 1); + assert_eq!(result.updated_records[0].txid, spender.txid()); + assert_eq!(result.updated_records[0].net_amount, -2000); + assert!(result.state_modified, "a correction alone must trigger persistence"); } } From b6fb3a246795dc41c97376fcef1dd3401ef44da7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:59:10 +0000 Subject: [PATCH 05/10] fix(wallet): derive late attribution and avoid duplicate replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive late funding candidates from existing spend marks. Skip block replay once the owning account has attributed the spend, while keeping replay for unknown spenders and pruned sibling templates. Cover the manager's sibling-account replay decision and finalized fallback, and strengthen unknown-spender replay assertions. Co-Authored-By: Codex GPT-6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- key-wallet-manager/src/event_tests.rs | 136 ++++++++++++++++++ key-wallet-manager/src/process_block.rs | 11 ++ key-wallet/README.md | 14 +- .../managed_account/managed_account_ref.rs | 11 -- .../managed_core_funds_account.rs | 25 +--- .../transaction_checking/wallet_checker.rs | 37 ++--- .../wallet_info_interface.rs | 9 +- 7 files changed, 188 insertions(+), 55 deletions(-) diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index b862b02f9..dbef3b211 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -2335,6 +2335,142 @@ async fn late_funding_block_publishes_spender_correction() { .contains_key(&OutPoint::new(funding.txid(), 0))); } +#[tokio::test] +async fn late_funding_recorded_sibling_spender_needs_no_reapply() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let sibling_address = manager.wallet_infos[&wallet_id] + .coinjoin_managed_account_at_index(0) + .unwrap() + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.pool_type == AddressPoolType::External) + .unwrap() + .address_at_index(0) + .unwrap() + .clone(); + let mut funding = create_tx_paying_to(&addr, 0xd9); + funding.output[0].value = 1_000_000; + let mut spender = create_tx_paying_to(&sibling_address, 0xda); + spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); + spender.output[0].value = 900_000; + let wallets = BTreeSet::from([wallet_id]); + let spend_block = make_block(vec![spender.clone()], 0xdb, 200); + manager.process_block_for_wallets(&spend_block, spend_block.block_hash(), 2, &wallets).await; + assert!(!manager.wallet_infos[&wallet_id] + .first_bip44_managed_account() + .unwrap() + .transactions() + .contains_key(&spender.txid())); + + let mut rx = manager.subscribe_events(); + let fund_block = make_block(vec![funding.clone()], 0xdc, 100); + let result = + manager.process_block_for_wallets(&fund_block, fund_block.block_hash(), 1, &wallets).await; + let events = drain_events(&mut rx); + let corrected = events + .iter() + .find_map(|event| match event { + WalletEvent::BlockProcessed { + updated, + .. + } => updated.iter().find(|record| { + record.txid == spender.txid() + && matches!(record.account_type, AccountType::Standard { .. }) + }), + _ => None, + }) + .expect("funding event contains reconstructed BIP44 spender"); + assert_eq!(corrected.net_amount, -1_000_000); + assert_eq!(corrected.input_details.len(), 1); + assert_eq!(corrected.input_details[0].value, 1_000_000); + assert_eq!(corrected.context.block_info().unwrap().height(), 2); + let info = &manager.wallet_infos[&wallet_id]; + let funding_account = info.first_bip44_managed_account().unwrap(); + let stored = &funding_account.transactions()[&spender.txid()]; + assert_eq!(stored.net_amount, corrected.net_amount); + assert_eq!(stored.input_details.len(), 1); + assert!(!funding_account.utxos.contains_key(&OutPoint::new(funding.txid(), 0))); + let sibling = + &info.coinjoin_managed_account_at_index(0).unwrap().transactions()[&spender.txid()]; + assert_eq!(sibling.net_amount, 900_000); + assert!(sibling.input_details.is_empty(), "inputs belong only to BIP44"); + assert!(result.reapply_heights.is_empty(), "corrected spender must not reload block 2"); + assert!(info.unrecorded_spend_heights(&funding).is_empty()); + + let again = + manager.process_block_for_wallets(&fund_block, fund_block.block_hash(), 1, &wallets).await; + assert!(again.reapply_heights.is_empty()); + assert!(drain_events(&mut rx).iter().all(|event| !matches!( + event, WalletEvent::BlockProcessed { updated, .. } if !updated.is_empty() + ))); +} + +#[tokio::test] +async fn late_funding_finalized_sibling_preserves_correction_path() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let sibling_address = manager.wallet_infos[&wallet_id] + .coinjoin_managed_account_at_index(0) + .unwrap() + .managed_account_type() + .address_pools() + .into_iter() + .find(|pool| pool.pool_type == AddressPoolType::External) + .unwrap() + .address_at_index(0) + .unwrap() + .clone(); + let funding = create_tx_paying_to(&addr, 0xdd); + let mut spender = create_tx_paying_to(&sibling_address, 0xde); + spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); + spender.output[0].value = TX_AMOUNT - 2000; + let wallets = BTreeSet::from([wallet_id]); + let spend_block = make_block(vec![spender.clone()], 0xdf, 200); + manager.process_block_for_wallets(&spend_block, spend_block.block_hash(), 2, &wallets).await; + manager.apply_chain_lock(ChainLock::dummy(2)); + + let mut rx = manager.subscribe_events(); + let fund_block = make_block(vec![funding.clone()], 0xe0, 100); + let result = + manager.process_block_for_wallets(&fund_block, fund_block.block_hash(), 1, &wallets).await; + let correction_events = if cfg!(feature = "keep-finalized-transactions") { + assert!(result.reapply_heights.is_empty()); + drain_events(&mut rx) + } else { + assert_eq!(result.reapply_heights.get(&wallet_id), Some(&BTreeSet::from([2]))); + drain_events(&mut rx); + manager + .process_block_for_wallets(&spend_block, spend_block.block_hash(), 2, &wallets) + .await; + drain_events(&mut rx) + }; + let corrected = correction_events + .iter() + .find_map(|event| match event { + WalletEvent::BlockProcessed { + inserted, + updated, + .. + } => inserted.iter().chain(updated).find(|record| { + record.txid == spender.txid() + && matches!(record.account_type, AccountType::Standard { .. }) + }), + _ => None, + }) + .expect("owning account receives the complete chainlocked spender slice"); + assert_eq!(corrected.net_amount, -(TX_AMOUNT as i64)); + assert_eq!(corrected.input_details.len(), 1); + assert_eq!(corrected.input_details[0].value, TX_AMOUNT); + assert!(corrected.context.is_chain_locked()); + let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); + assert!(account.transaction_is_finalized(&spender.txid())); + assert_eq!( + account.transactions().contains_key(&spender.txid()), + cfg!(feature = "keep-finalized-transactions") + ); + assert!(!account.utxos.contains_key(&OutPoint::new(funding.txid(), 0))); +} + #[tokio::test] async fn finalized_spender_late_funding_chainlock_first() { finalized_spender_late_funding_retention_policy(true).await; diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index 3d3609f09..66251ad48 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -705,6 +705,8 @@ mod tests { let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); let funding = create_tx_paying_to(&addr, 0xaa); let spend = spend_first_output_of(&funding); + let spent_outpoint = OutPoint::new(funding.txid(), 0); + let spender_txid = spend.txid(); let wallets = BTreeSet::from([wallet_id]); let mut spend_block = make_block(vec![spend]); @@ -718,10 +720,19 @@ mod tests { .process_block_for_wallets(&funding_block, funding_block.block_hash(), 100, &wallets) .await; assert_eq!(result.reapply_heights, BTreeMap::from([(wallet_id, BTreeSet::from([200]))])); + let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); + assert!(!account.transactions().contains_key(&spender_txid)); + assert!(!account.utxos.contains_key(&spent_outpoint)); manager .process_block_for_wallets(&spend_block, spend_block.block_hash(), 200, &wallets) .await; + let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); + let recorded = &account.transactions()[&spender_txid]; + assert_eq!(recorded.net_amount, -(TX_AMOUNT as i64)); + assert_eq!(recorded.input_details.len(), 1); + assert_eq!(recorded.input_details[0].value, TX_AMOUNT); + assert!(!account.utxos.contains_key(&spent_outpoint)); let again = manager .process_block_for_wallets(&funding_block, funding_block.block_hash(), 100, &wallets) .await; diff --git a/key-wallet/README.md b/key-wallet/README.md index 714101d05..bbe9ad21b 100644 --- a/key-wallet/README.md +++ b/key-wallet/README.md @@ -272,8 +272,18 @@ Core dependencies: ## Late funding and finalized transaction history When funding arrives after its spender, retained transaction records receive corrected -input details, direction, and net amount. With default features, ChainLocks discard full -funds records and retain only transaction IDs. Funding discovered after that pruning cannot +input details, direction, and net amount. Funding outputs are matched against existing +account spend marks; no separate attribution queue is stored. Each missing owning-account +slice has one correction path: + +- A full spender record retained by a wallet account supplies in-place attribution. + Once the owning slice is attributed, it does not request block replay. +- A spender recorded nowhere requires the block replay introduced in #1015. Replay also + handles a missing owning slice when a sibling's full spender record has been pruned: + the sibling's remaining spend mark alone cannot reconstruct that slice. + +With default features, ChainLocks discard full funds records and retain only transaction +IDs. Funding discovered after that pruning cannot repair the earlier record or an application’s persisted copy: an incoming change record may remain incorrect. This applies whether the spender arrives below an existing ChainLock or a ChainLock arrives between the spender and its funding. diff --git a/key-wallet/src/managed_account/managed_account_ref.rs b/key-wallet/src/managed_account/managed_account_ref.rs index 00ba1d5fe..c9dbd263f 100644 --- a/key-wallet/src/managed_account/managed_account_ref.rs +++ b/key-wallet/src/managed_account/managed_account_ref.rs @@ -424,17 +424,6 @@ impl<'a> ManagedAccountRefMut<'a> { } } - /// Drain the born-spent funding outputs staged during the last - /// `record_transaction` / `confirm_transaction` call (out-of-order - /// funding — see `ManagedCoreFundsAccount::take_born_spent_outputs`). - /// Always empty for the [`Keys`](Self::Keys) variant. - pub(crate) fn take_born_spent_outputs(&mut self) -> Vec<(OutPoint, u64, Address)> { - match self { - ManagedAccountRefMut::Funds(a) => a.take_born_spent_outputs(), - ManagedAccountRefMut::Keys(_) => Vec::new(), - } - } - /// Attribute a late funding output to its owning account, using known spender templates. /// Always empty for the [`Keys`](Self::Keys) variant. pub(crate) fn attribute_spent_input( diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index a66a41f0f..42fb5be8d 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -69,10 +69,6 @@ pub struct ManagedCoreFundsAccount { /// re-establish which coins are spent. #[cfg_attr(feature = "serde", serde(skip))] reservations: ReservationSet, - /// Late funding outputs awaiting attribution to account-local spender records. - /// Drained at wallet scope before returning; never persisted. - #[cfg_attr(feature = "serde", serde(skip))] - born_spent_outputs: Vec<(OutPoint, u64, Address)>, } /// What [`ManagedCoreFundsAccount::apply_abandon`] removed from one account. @@ -110,7 +106,6 @@ impl ManagedCoreFundsAccount { spent_outpoints: HashSet::new(), spent_before_funded: BTreeMap::new(), reservations: ReservationSet::default(), - born_spent_outputs: Vec::new(), } } @@ -139,7 +134,6 @@ impl ManagedCoreFundsAccount { spent_outpoints: HashSet::new(), spent_before_funded: BTreeMap::new(), reservations: ReservationSet::default(), - born_spent_outputs: Vec::new(), } } @@ -194,7 +188,7 @@ impl ManagedCoreFundsAccount { } /// Check if an outpoint was spent by a previously recorded transaction. - fn is_outpoint_spent(&self, outpoint: &OutPoint) -> bool { + pub(crate) fn is_outpoint_spent(&self, outpoint: &OutPoint) -> bool { self.spent_outpoints.contains(outpoint) } @@ -333,11 +327,6 @@ impl ManagedCoreFundsAccount { outpoint = %outpoint, "Skipping UTXO already spent by previously processed transaction" ); - self.born_spent_outputs.push(( - outpoint, - output.value, - addr.clone(), - )); continue; } @@ -352,11 +341,6 @@ impl ManagedCoreFundsAccount { outpoint = %outpoint, "Skipping UTXO already observed spent in an earlier-processed block (#649)" ); - self.born_spent_outputs.push(( - outpoint, - output.value, - addr.clone(), - )); self.spent_before_funded.insert( outpoint, Utxo::new( @@ -514,12 +498,6 @@ impl ManagedCoreFundsAccount { corrected } - /// Drain the born-spent outputs staged by [`Self::update_utxos`] since - /// the last drain, for the wallet-scope attribution sweep. - pub(crate) fn take_born_spent_outputs(&mut self) -> Vec<(OutPoint, u64, Address)> { - std::mem::take(&mut self.born_spent_outputs) - } - /// Drop the spent-marks that `freed` contributed, keeping every mark a /// surviving record still claims. /// @@ -1425,7 +1403,6 @@ impl<'de> Deserialize<'de> for ManagedCoreFundsAccount { spent_outpoints, spent_before_funded: helper.spent_before_funded, reservations: ReservationSet::default(), - born_spent_outputs: Vec::new(), }) } } diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 35d39c1bb..f7ab82707 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -51,16 +51,24 @@ pub trait WalletTransactionChecker { impl ManagedWalletInfo { /// Find spenders across accounts and attribute each input only to its owning account. - /// Drain before returning from transaction processing so corrections survive persistence. - fn attribute_born_spent( - &mut self, - born_spent: &[(OutPoint, u64, Address)], - result: &mut TransactionCheckResult, - ) { - if born_spent.is_empty() { - return; - } - for (outpoint, value, address) in born_spent { + fn attribute_born_spent(&mut self, tx: &Transaction, result: &mut TransactionCheckResult) { + let txid = tx.txid(); + let born_spent: Vec<_> = tx + .output + .iter() + .enumerate() + .filter_map(|(vout, output)| { + let address = Address::from_script(&output.script_pubkey, self.network).ok()?; + let outpoint = OutPoint::new(txid, vout as u32); + self.accounts + .all_accounts() + .into_iter() + .filter_map(|account| account.as_funds()) + .any(|account| account.is_outpoint_spent(&outpoint)) + .then_some((outpoint, output.value, address)) + }) + .collect(); + for (outpoint, value, address) in &born_spent { let spenders: BTreeMap<_, _> = self .accounts .all_accounts() @@ -284,7 +292,6 @@ impl WalletTransactionChecker for ManagedWalletInfo { // already holding a record — backfill via `record_transaction` // before marking UTXOs so the freshly registered UTXOs get the // IS-lock flag too. - let mut born_spent_instant: Vec<(OutPoint, u64, Address)> = Vec::new(); for account_match in result.affected_accounts.clone() { let Some(mut account) = self .accounts @@ -309,10 +316,9 @@ impl WalletTransactionChecker for ManagedWalletInfo { ); account.mark_utxos_instant_send(&txid); result.new_records.push(record); - born_spent_instant.extend(account.take_born_spent_outputs()); } } - self.attribute_born_spent(&born_spent_instant, &mut result); + self.attribute_born_spent(tx, &mut result); if update_balance { self.update_balance(); } @@ -326,7 +332,6 @@ impl WalletTransactionChecker for ManagedWalletInfo { } // Process each affected account - let mut born_spent: Vec<(OutPoint, u64, Address)> = Vec::new(); for account_match in result.affected_accounts.clone() { let Some(mut account) = self.accounts.get_by_account_type_match_mut(&account_match.account_type_match) @@ -364,8 +369,6 @@ impl WalletTransactionChecker for ManagedWalletInfo { } } - born_spent.extend(account.take_born_spent_outputs()); - for address_info in account_match.account_type_match.all_involved_addresses() { account.mark_address_used(&address_info.address); } @@ -401,7 +404,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { } } - self.attribute_born_spent(&born_spent, &mut result); + self.attribute_born_spent(tx, &mut result); if is_new { // Populate dedup sets when a tx arrives with an initial IS status diff --git a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs index 1dabac23d..27df6104e 100644 --- a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs +++ b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs @@ -280,6 +280,8 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// sweep removing a loser, so this is broader than "a UTXO was marked". fn mark_instant_send_utxos(&mut self, txid: &Txid, lock: &InstantLock) -> bool; + /// Return observed spend heights still missing attribution in an output's owning account. + /// Retained spenders are attributed in place; absent or pruned templates need block replay. fn unrecorded_spend_heights(&self, tx: &Transaction) -> BTreeSet; /// Return the aggregated monitor revision across all accounts. @@ -624,7 +626,12 @@ impl WalletInfoInterface for ManagedWalletInfo { .all_accounts() .into_iter() .filter_map(|account| account.as_funds()) - .any(|account| account.spent_before_funded.contains_key(outpoint)) + .any(|account| { + // Attribution marks the owning slice spent. A pruned sibling's + // mark alone cannot supply the missing record for this account. + account.spent_before_funded.contains_key(outpoint) + && !account.is_outpoint_spent(outpoint) + }) }) .filter_map(|outpoint| self.observed_spent_outpoints.get(&outpoint).copied()) .collect() From a27ef941ee65e3abf426e15b93f62c1325d684e8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:16:16 +0000 Subject: [PATCH 06/10] refactor(wallet): limit late input corrections to existing records Use account-local correction and the existing block replay for missing account slices. Remove sibling reconstruction and the IS-backfill call. Keep all tests unchanged to expose the behavior lost by simplification: 4 unit tests fail with default features, 5 with all features. Integration and doc tests, formatting, FFI docs, and debug/release Clippy pass. Co-Authored-By: Codex --- dash-spv-ffi/src/callbacks.rs | 2 - key-wallet-manager/src/events.rs | 3 +- key-wallet/README.md | 25 +--- .../managed_account/managed_account_ref.rs | 17 --- .../managed_core_funds_account.rs | 80 ------------- .../transaction_checking/wallet_checker.rs | 111 ++++++++---------- .../wallet_info_interface.rs | 9 +- 7 files changed, 57 insertions(+), 190 deletions(-) diff --git a/dash-spv-ffi/src/callbacks.rs b/dash-spv-ffi/src/callbacks.rs index 1c43c8d4e..1c186f0da 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -727,8 +727,6 @@ impl Drop for FFIDerivedAddress { /// funding arrival corrects an earlier transaction's accounting. Consumers must /// upsert by wallet, account and txid; a correction retains the spender's context. /// A plain InstantSend lock on a known mempool transaction emits only the lock callback. -/// Corrections after ChainLock require `keep-finalized-transactions` to retain -/// the spender's full record; default retention cannot repair an already pruned row. /// /// All pointer parameters are borrowed and only valid for the duration of the /// callback. `balance` is the wallet's balance *after* the transaction was diff --git a/key-wallet-manager/src/events.rs b/key-wallet-manager/src/events.rs index 67a3406a8..4613abb4a 100644 --- a/key-wallet-manager/src/events.rs +++ b/key-wallet-manager/src/events.rs @@ -182,8 +182,7 @@ fn format_account_balances(map: &BTreeMap) -> St pub enum WalletEvent { /// An off-chain transaction was detected or its accounting details were corrected. /// Corrections retain the transaction's own confirmation context; consumers upsert by account and txid. - /// Lock-only updates use `TransactionInstantLocked`. Accounting corrections after ChainLock - /// require `keep-finalized-transactions`; default retention drops the full spender record. + /// Lock-only updates use `TransactionInstantLocked`. TransactionDetected { /// ID of the affected wallet. wallet_id: WalletId, diff --git a/key-wallet/README.md b/key-wallet/README.md index bbe9ad21b..c5dfe6577 100644 --- a/key-wallet/README.md +++ b/key-wallet/README.md @@ -271,26 +271,11 @@ Core dependencies: ## Late funding and finalized transaction history -When funding arrives after its spender, retained transaction records receive corrected -input details, direction, and net amount. Funding outputs are matched against existing -account spend marks; no separate attribution queue is stored. Each missing owning-account -slice has one correction path: - -- A full spender record retained by a wallet account supplies in-place attribution. - Once the owning slice is attributed, it does not request block replay. -- A spender recorded nowhere requires the block replay introduced in #1015. Replay also - handles a missing owning slice when a sibling's full spender record has been pruned: - the sibling's remaining spend mark alone cannot reconstruct that slice. - -With default features, ChainLocks discard full funds records and retain only transaction -IDs. Funding discovered after that pruning cannot -repair the earlier record or an application’s persisted copy: an incoming change record may -remain incorrect. This applies whether the spender arrives below an existing ChainLock or -a ChainLock arrives between the spender and its funding. - -Enable `keep-finalized-transactions` before processing if your application needs accounting -corrections after finalization. It retains the full transaction history and therefore uses -more memory. Enabling it after records have been discarded does not recover those records. +Late funding corrects input details, direction, and net amount in retained spender records +within the owning account. Missing account records are recovered through block replay. +With default features, ChainLocks prune full records, so later funding cannot correct +an already-pruned spender or its persisted copy. Enable `keep-finalized-transactions` +before processing to retain those records, at the cost of keeping finalized history in memory. ## Contributing diff --git a/key-wallet/src/managed_account/managed_account_ref.rs b/key-wallet/src/managed_account/managed_account_ref.rs index c9dbd263f..192a358e2 100644 --- a/key-wallet/src/managed_account/managed_account_ref.rs +++ b/key-wallet/src/managed_account/managed_account_ref.rs @@ -423,23 +423,6 @@ impl<'a> ManagedAccountRefMut<'a> { ManagedAccountRefMut::Keys(_) => false, } } - - /// Attribute a late funding output to its owning account, using known spender templates. - /// Always empty for the [`Keys`](Self::Keys) variant. - pub(crate) fn attribute_spent_input( - &mut self, - outpoint: &OutPoint, - value: u64, - address: &Address, - spenders: &[TransactionRecord], - ) -> Vec { - match self { - ManagedAccountRefMut::Funds(a) => { - a.attribute_spent_input(outpoint, value, address, spenders) - } - ManagedAccountRefMut::Keys(_) => Vec::new(), - } - } } /// Owned managed core account, either funds-bearing or keys-only. diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index 42fb5be8d..8dd0dbbda 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -418,86 +418,6 @@ impl ManagedCoreFundsAccount { } } - /// Attribute a late funding output only to its owning account's spender slices. - pub(crate) fn attribute_spent_input( - &mut self, - outpoint: &OutPoint, - value: u64, - address: &Address, - spenders: &[TransactionRecord], - ) -> Vec { - if !self.contains_address(address) { - return Vec::new(); - } - let mut corrected = Vec::new(); - for template in spenders { - #[cfg(not(feature = "keep-finalized-transactions"))] - if self.keys.transaction_is_finalized(&template.txid) { - continue; - } - let Some(input_index) = template - .transaction - .input - .iter() - .position(|input| &input.previous_output == outpoint) - else { - continue; - }; - let mut record = - self.keys.transactions().get(&template.txid).cloned().unwrap_or_else(|| { - let mut record = template.clone(); - record.account_type = self.keys.managed_account_type().to_account_type(); - record.input_details.clear(); - record.output_details.clear(); - record - }); - if record.input_details.iter().any(|d| d.index == input_index as u32) { - continue; - } - record.input_details.push(InputDetail { - index: input_index as u32, - value, - address: address.clone(), - }); - record.input_details.sort_by_key(|d| d.index); - for (index, output) in record.transaction.output.iter().enumerate() { - if record.output_details.iter().any(|detail| detail.index == index as u32) { - continue; - } - let output_address = - Address::from_script(&output.script_pubkey, self.keys.network()).ok(); - let pool = output_address.as_ref().and_then(|addr| { - self.managed_account_type() - .address_pools() - .into_iter() - .find(|pool| pool.address_index(addr).is_some()) - }); - let role = match pool { - Some(pool) if pool.pool_type == address_pool::AddressPoolType::Internal => { - OutputRole::Change - } - Some(_) => OutputRole::Received, - None if output.script_pubkey.is_provably_unspendable() => { - OutputRole::Unspendable - } - None => OutputRole::Sent, - }; - record.output_details.push(OutputDetail { - index: index as u32, - value: output.value, - address: output_address, - role, - }); - } - record.output_details.sort_by_key(|detail| detail.index); - record.recompute_net_and_direction(); - self.keys.transactions_mut().insert(record.txid, record.clone()); - self.spent_outpoints.insert(*outpoint); - corrected.push(record); - } - corrected - } - /// Drop the spent-marks that `freed` contributed, keeping every mark a /// surviving record still claims. /// diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index f7ab82707..7961803c8 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -6,15 +6,14 @@ pub(crate) use super::account_checker::TransactionCheckResult; use super::transaction_context::TransactionContext; use super::transaction_router::{AccountTypeToCheck, TransactionRouter}; -#[cfg(not(feature = "keep-finalized-transactions"))] -use crate::managed_account::ManagedAccountRefMut; +use crate::managed_account::managed_account_trait::ManagedAccountTrait; +use crate::managed_account::transaction_record::{InputDetail, OutputDetail, OutputRole}; use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::wallet::managed_wallet_info::ManagedWalletInfo; use crate::{KeySource, Wallet}; use async_trait::async_trait; use dashcore::blockdata::transaction::Transaction; use dashcore::{Address, Amount, OutPoint, SignedAmount}; -use std::collections::BTreeMap; /// Extension trait for ManagedWalletInfo to add transaction checking capabilities #[async_trait] @@ -50,67 +49,58 @@ pub trait WalletTransactionChecker { } impl ManagedWalletInfo { - /// Find spenders across accounts and attribute each input only to its owning account. - fn attribute_born_spent(&mut self, tx: &Transaction, result: &mut TransactionCheckResult) { + /// Correct late inputs in existing records; missing account slices use block replay. + fn attribute_late_inputs(&mut self, tx: &Transaction, result: &mut TransactionCheckResult) { let txid = tx.txid(); - let born_spent: Vec<_> = tx - .output - .iter() - .enumerate() - .filter_map(|(vout, output)| { - let address = Address::from_script(&output.script_pubkey, self.network).ok()?; + for mut account in self.accounts.all_accounts_mut() { + let Some(funds) = account.as_funds_mut() else { + continue; + }; + for (vout, output) in tx.output.iter().enumerate() { let outpoint = OutPoint::new(txid, vout as u32); - self.accounts - .all_accounts() - .into_iter() - .filter_map(|account| account.as_funds()) - .any(|account| account.is_outpoint_spent(&outpoint)) - .then_some((outpoint, output.value, address)) - }) - .collect(); - for (outpoint, value, address) in &born_spent { - let spenders: BTreeMap<_, _> = self - .accounts - .all_accounts() - .into_iter() - .flat_map(|account| account.transactions().values()) - .filter(|record| { - record.transaction.input.iter().any(|input| input.previous_output == *outpoint) - }) - .map(|record| (record.txid, record.clone())) - .collect(); - let spenders: Vec<_> = spenders.into_values().collect(); - for mut account in self.accounts.all_accounts_mut() { - let corrected = account.attribute_spent_input(outpoint, *value, address, &spenders); - if !corrected.is_empty() { - result.state_modified = true; - for record in corrected { - if let Some(existing) = result.updated_records.iter_mut().find(|existing| { - existing.txid == record.txid - && existing.account_type == record.account_type - }) { - *existing = record; - } else { - result.updated_records.push(record); - } - } + if !funds.is_outpoint_spent(&outpoint) { + continue; } - } - } - // Keep every input available until the complete correction has been captured. - #[cfg(not(feature = "keep-finalized-transactions"))] - for mut account in self.accounts.all_accounts_mut() { - let account_type = account.managed_account_type().to_account_type(); - for record in &result.updated_records { - if record.account_type == account_type && record.context.is_chain_locked() { - match &mut account { - ManagedAccountRefMut::Funds(funds) => { - funds.keys_mut().drop_finalized_transaction(&record.txid); - } - ManagedAccountRefMut::Keys(keys) => { - keys.drop_finalized_transaction(&record.txid); + let Ok(address) = Address::from_script(&output.script_pubkey, self.network) else { + continue; + }; + if !funds.contains_address(&address) { + continue; + } + for record in funds.transactions_mut().values_mut() { + let Some(index) = + record.transaction.input.iter().position(|i| i.previous_output == outpoint) + else { + continue; + }; + if record.input_details.iter().any(|d| d.index == index as u32) { + continue; + } + record.input_details.push(InputDetail { + index: index as u32, + value: output.value, + address: address.clone(), + }); + record.input_details.sort_by_key(|d| d.index); + // Records without known inputs omit foreign outputs. + for (index, output) in record.transaction.output.iter().enumerate() { + if record.output_details.iter().all(|d| d.index != index as u32) { + record.output_details.push(OutputDetail { + index: index as u32, + role: OutputRole::Sent, + address: Address::from_script(&output.script_pubkey, self.network) + .ok(), + value: output.value, + }); } } + record.output_details.sort_by_key(|d| d.index); + record.recompute_net_and_direction(); + result + .updated_records + .retain(|r| r.txid != record.txid || r.account_type != record.account_type); + result.updated_records.push(record.clone()); + result.state_modified = true; } } } @@ -318,7 +308,6 @@ impl WalletTransactionChecker for ManagedWalletInfo { result.new_records.push(record); } } - self.attribute_born_spent(tx, &mut result); if update_balance { self.update_balance(); } @@ -404,7 +393,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { } } - self.attribute_born_spent(tx, &mut result); + self.attribute_late_inputs(tx, &mut result); if is_new { // Populate dedup sets when a tx arrives with an initial IS status diff --git a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs index 27df6104e..1dabac23d 100644 --- a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs +++ b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs @@ -280,8 +280,6 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// sweep removing a loser, so this is broader than "a UTXO was marked". fn mark_instant_send_utxos(&mut self, txid: &Txid, lock: &InstantLock) -> bool; - /// Return observed spend heights still missing attribution in an output's owning account. - /// Retained spenders are attributed in place; absent or pruned templates need block replay. fn unrecorded_spend_heights(&self, tx: &Transaction) -> BTreeSet; /// Return the aggregated monitor revision across all accounts. @@ -626,12 +624,7 @@ impl WalletInfoInterface for ManagedWalletInfo { .all_accounts() .into_iter() .filter_map(|account| account.as_funds()) - .any(|account| { - // Attribution marks the owning slice spent. A pruned sibling's - // mark alone cannot supply the missing record for this account. - account.spent_before_funded.contains_key(outpoint) - && !account.is_outpoint_spent(outpoint) - }) + .any(|account| account.spent_before_funded.contains_key(outpoint)) }) .filter_map(|outpoint| self.observed_spent_outpoints.get(&outpoint).copied()) .collect() From 2a5e13e525ad9a839f5c4c0d168ed08d214f71fc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:25:02 +0000 Subject: [PATCH 07/10] test(wallet): verify late funding corrections after block replay Follow requested replay heights before checking missing account records, including imported accounts and finalized sibling transactions. Preserve accounting, output-role, spent-mark, event, and retention assertions. All four adapted tests pass with default and all features. The unchanged InstantSend backfill regression still fails in both configurations. Scoped debug/release Clippy and formatting pass. Production is unchanged. Co-Authored-By: Codex --- key-wallet-manager/src/event_tests.rs | 50 +++++++++++++------ .../managed_core_funds_account.rs | 37 ++++++++++---- .../transaction_checking/wallet_checker.rs | 40 +++++++++++++-- 3 files changed, 97 insertions(+), 30 deletions(-) diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index dbef3b211..fe0d55cd8 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -2336,7 +2336,7 @@ async fn late_funding_block_publishes_spender_correction() { } #[tokio::test] -async fn late_funding_recorded_sibling_spender_needs_no_reapply() { +async fn late_funding_recorded_sibling_spender_is_corrected_after_reapply() { let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); let sibling_address = manager.wallet_infos[&wallet_id] .coinjoin_managed_account_at_index(0) @@ -2367,20 +2367,36 @@ async fn late_funding_recorded_sibling_spender_needs_no_reapply() { let fund_block = make_block(vec![funding.clone()], 0xdc, 100); let result = manager.process_block_for_wallets(&fund_block, fund_block.block_hash(), 1, &wallets).await; + assert_eq!(result.reapply_heights, BTreeMap::from([(wallet_id, BTreeSet::from([2]))])); + drain_events(&mut rx); + for (replay_wallet_id, heights) in result.reapply_heights { + for height in heights { + let replay = manager + .process_block_for_wallets( + &spend_block, + spend_block.block_hash(), + height, + &BTreeSet::from([replay_wallet_id]), + ) + .await; + assert!(replay.reapply_heights.is_empty()); + } + } let events = drain_events(&mut rx); let corrected = events .iter() .find_map(|event| match event { WalletEvent::BlockProcessed { + inserted, updated, .. - } => updated.iter().find(|record| { + } => inserted.iter().chain(updated).find(|record| { record.txid == spender.txid() && matches!(record.account_type, AccountType::Standard { .. }) }), _ => None, }) - .expect("funding event contains reconstructed BIP44 spender"); + .expect("replayed block publishes the BIP44 spender"); assert_eq!(corrected.net_amount, -1_000_000); assert_eq!(corrected.input_details.len(), 1); assert_eq!(corrected.input_details[0].value, 1_000_000); @@ -2395,7 +2411,6 @@ async fn late_funding_recorded_sibling_spender_needs_no_reapply() { &info.coinjoin_managed_account_at_index(0).unwrap().transactions()[&spender.txid()]; assert_eq!(sibling.net_amount, 900_000); assert!(sibling.input_details.is_empty(), "inputs belong only to BIP44"); - assert!(result.reapply_heights.is_empty(), "corrected spender must not reload block 2"); assert!(info.unrecorded_spend_heights(&funding).is_empty()); let again = @@ -2433,17 +2448,22 @@ async fn late_funding_finalized_sibling_preserves_correction_path() { let fund_block = make_block(vec![funding.clone()], 0xe0, 100); let result = manager.process_block_for_wallets(&fund_block, fund_block.block_hash(), 1, &wallets).await; - let correction_events = if cfg!(feature = "keep-finalized-transactions") { - assert!(result.reapply_heights.is_empty()); - drain_events(&mut rx) - } else { - assert_eq!(result.reapply_heights.get(&wallet_id), Some(&BTreeSet::from([2]))); - drain_events(&mut rx); - manager - .process_block_for_wallets(&spend_block, spend_block.block_hash(), 2, &wallets) - .await; - drain_events(&mut rx) - }; + assert_eq!(result.reapply_heights, BTreeMap::from([(wallet_id, BTreeSet::from([2]))])); + drain_events(&mut rx); + for (replay_wallet_id, heights) in result.reapply_heights { + for height in heights { + let replay = manager + .process_block_for_wallets( + &spend_block, + spend_block.block_hash(), + height, + &BTreeSet::from([replay_wallet_id]), + ) + .await; + assert!(replay.reapply_heights.is_empty()); + } + } + let correction_events = drain_events(&mut rx); let corrected = correction_events .iter() .find_map(|event| match event { diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index 8dd0dbbda..99b2f5e60 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -1593,9 +1593,10 @@ mod attribution_tests { use dashcore::{BlockHash, TxOut}; #[tokio::test] - async fn born_spent_attribution_reconstructs_imported_account_change_and_spent_mark() { + async fn late_funding_replay_restores_imported_account_change_and_spent_mark() { use crate::managed_account::transaction_record::OutputRole; use crate::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; + use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; let mut ctx = TestWalletContext::new_random(); let account_type = AccountType::Standard { index: 1, @@ -1622,14 +1623,31 @@ mod attribution_tests { ) .await; ctx.managed_wallet.add_managed_account(&ctx.wallet, account_type).unwrap(); - let result = ctx - .check_transaction( - &funding, - TransactionContext::InBlock(BlockInfo::new(1, BlockHash::all_zeros(), 100)), - ) - .await; - let corrected = - result.updated_records.iter().find(|r| r.account_type == account_type).unwrap(); + ctx.check_transaction( + &funding, + TransactionContext::InBlock(BlockInfo::new(1, BlockHash::all_zeros(), 100)), + ) + .await; + let heights = ctx.managed_wallet.unrecorded_spend_heights(&funding); + assert_eq!(heights, BTreeSet::from([2])); + let mut corrections = Vec::new(); + for height in heights { + let result = ctx + .check_transaction( + &spender, + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::all_zeros(), + 200, + )), + ) + .await; + assert!(result.state_modified); + corrections.extend(result.new_records); + corrections.extend(result.updated_records); + } + assert!(ctx.managed_wallet.unrecorded_spend_heights(&funding).is_empty()); + let corrected = corrections.iter().find(|r| r.account_type == account_type).unwrap(); assert_eq!(corrected.net_amount, -61_000); assert_eq!(corrected.output_details[1].role, OutputRole::Change); assert_eq!(corrected.output_details[1].address.as_ref(), Some(&change)); @@ -1641,7 +1659,6 @@ mod attribution_tests { .is_outpoint_spent(&outpoint), "late attribution must mark the input spent in its owning account" ); - assert!(result.state_modified); assert!(!ctx .managed_wallet .bip44_managed_account_at_index(1) diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 7961803c8..719aaf0c5 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -3840,7 +3840,7 @@ mod tests { /// A late input belongs to its funding account even when another account first saw the spender. #[tokio::test] - async fn born_spent_attribution_reaches_sibling_account_spenders() { + async fn late_funding_replay_recovers_missing_sibling_account_records() { for own_change in [0, 50_000] { let network = Network::Testnet; let mut wallet = @@ -3929,8 +3929,7 @@ mod tests { ); } - // Funding second (height 1) — recognized by BIP44, whose account- - // local record may be missing. Attribution must create or update its own slice. + // Funding corrects existing slices and requests replay for missing ones. let result = managed_wallet .check_core_transaction( &funding_tx, @@ -3946,6 +3945,34 @@ mod tests { .await; assert!(result.is_relevant); + let heights = managed_wallet.unrecorded_spend_heights(&funding_tx); + assert_eq!( + heights, + if own_change == 0 { + BTreeSet::from([2]) + } else { + BTreeSet::new() + } + ); + let mut corrections = result.updated_records; + for height in heights { + let replay = managed_wallet + .check_core_transaction( + &spender_tx, + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_slice(&[3u8; 32]).expect("hash"), + 1_650_000_100, + )), + &mut wallet, + true, + true, + ) + .await; + corrections.extend(replay.new_records); + corrections.extend(replay.updated_records); + } + assert!(managed_wallet.unrecorded_spend_heights(&funding_tx).is_empty()); let cj = managed_wallet.coinjoin_managed_account_at_index(0).expect("cj"); let record = cj.transactions().get(&spender_txid).expect("spender record"); assert_eq!(record.net_amount, BACK as i64); @@ -3958,8 +3985,11 @@ mod tests { record.net_amount + outgoing.net_amount, (BACK + own_change) as i64 - FUND as i64 ); - let corrected = - result.updated_records.iter().find(|r| r.txid == spender_txid).expect("correction"); + assert!(!bip44.utxos.contains_key(&funded_outpoint)); + let corrected = corrections + .iter() + .find(|r| r.txid == spender_txid && r.account_type == outgoing.account_type) + .expect("correction"); assert_eq!(corrected.net_amount, outgoing.net_amount); } } From 561b48c2d23cf80ce114a0fcae533022d4c554e0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:43:23 +0000 Subject: [PATCH 08/10] test(wallet): move InstantSend backfill coverage to follow-up Keep the late-input accounting PR scoped to existing-record correction and block replay. Carry the InstantSend backfill regression together with its attribution call in a separate stacked PR. Default/all-feature package suites and debug/release Clippy pass. Co-Authored-By: Codex --- .../transaction_checking/wallet_checker.rs | 56 ------------------- 1 file changed, 56 deletions(-) diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 719aaf0c5..58f1ffdd7 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -3994,62 +3994,6 @@ mod tests { } } - /// An imported account can discover a mempool child before its known parent's IS redelivery. - #[tokio::test] - async fn born_spent_attribution_runs_on_the_instant_send_backfill_branch() { - use crate::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; - let mut ctx = TestWalletContext::new_random(); - let account_type = AccountType::Standard { - index: 1, - standard_account_type: StandardAccountType::BIP44Account, - }; - ctx.wallet.add_account(account_type, None).unwrap(); - let xpub = ctx.wallet.accounts.standard_bip44_accounts[&1].account_xpub; - let mut preview = - ManagedWalletInfo::from_wallet_with_name(&ctx.wallet, "preview".into(), 0); - let address = preview - .bip44_managed_account_at_index_mut(1) - .unwrap() - .next_receive_address(Some(&xpub), true) - .unwrap(); - let mut funding = Transaction::dummy(&ctx.receive_address, 0..1, &[100_000]); - funding.output.push(TxOut { - value: 50_000, - script_pubkey: address.script_pubkey(), - }); - let first = ctx.check_transaction(&funding, TransactionContext::Mempool).await; - assert_eq!(first.new_records.len(), 1); - - ctx.managed_wallet.add_managed_account(&ctx.wallet, account_type).unwrap(); - let mut spender = Transaction::dummy(&address, 1..2, &[40_000]); - let outpoint = OutPoint::new(funding.txid(), 1); - spender.input[0].previous_output = outpoint; - ctx.check_transaction(&spender, TransactionContext::Mempool).await; - let result = ctx - .check_transaction( - &funding, - TransactionContext::InstantSend(InstantLock { - txid: funding.txid(), - ..InstantLock::default() - }), - ) - .await; - assert!(!result.is_new_transaction); - assert!(result.state_modified); - assert_eq!(result.new_records.len(), 1); - assert_eq!(result.new_records[0].account_type, account_type); - let corrected = result.updated_records.iter().find(|r| r.txid == spender.txid()).unwrap(); - assert_eq!(corrected.net_amount, -10_000); - assert_eq!(corrected.input_details.len(), 1); - assert_eq!(corrected.context, TransactionContext::Mempool); - assert!(!ctx - .managed_wallet - .bip44_managed_account_at_index(1) - .unwrap() - .utxos - .contains_key(&outpoint)); - } - #[tokio::test] async fn born_spent_attribution_preserves_coinjoin_direction() { let mut ctx = TestWalletContext::new_random(); From b6a740e77ce740ec840aa2a88ee5cefb326b4814 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:21:23 +0000 Subject: [PATCH 09/10] test(wallet): consolidate late funding regression coverage Cover mempool correction, block correction, and missing-account replay in three scenarios with the relevant accounting and event assertions. Co-Authored-By: Codex --- key-wallet-manager/src/event_tests.rs | 508 ++++-------------- key-wallet-manager/src/process_block.rs | 143 +++-- .../managed_core_funds_account.rs | 99 ---- .../transaction_checking/wallet_checker.rs | 245 --------- 4 files changed, 220 insertions(+), 775 deletions(-) diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index fe0d55cd8..f8f6c0324 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -2121,30 +2121,43 @@ async fn dropped_persistence_consumer_does_not_wedge_emission() { } #[tokio::test] -async fn should_emit_late_input_corrections_without_borrowing_funding_lock() { +async fn late_funding_mempool_publishes_complete_correction() { + use key_wallet::managed_account::transaction_record::{OutputRole, TransactionDirection}; + for locked_funding in [false, true] { let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); - let funding = create_tx_paying_to(&addr, 0xa1); - let mut spender = create_tx_paying_to(&addr, 0xa2); - spender.input[0].previous_output = OutPoint { - txid: funding.txid(), - vout: 0, - }; - spender.output[0].value = TX_AMOUNT - 2000; + let mut funding = create_tx_paying_to(&addr, 0xa1); + funding.output.push(funding.output[0].clone()); + let earlier_funding = create_tx_paying_to(&addr, 0xa2); + let mut spender = create_tx_paying_to(&addr, 0xa3); + spender.input = [ + OutPoint::new(funding.txid(), 1), + OutPoint::new(funding.txid(), 0), + OutPoint::new(earlier_funding.txid(), 0), + ] + .into_iter() + .map(|previous_output| TxIn { + previous_output, + ..spender.input[0].clone() + }) + .collect(); + spender.output[0].value = 3 * TX_AMOUNT - 2000; spender.output.push(TxOut { value: 1000, - script_pubkey: ScriptBuf::new_p2pkh( - &PublicKey::from_slice(&[2; 33]).unwrap().pubkey_hash(), - ), + script_pubkey: Address::dummy(Network::Testnet, 999).script_pubkey(), }); manager.process_mempool_transaction(&spender, None).await; + manager.process_mempool_transaction(&earlier_funding, None).await; + let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); + assert_eq!(account.transactions()[&spender.txid()].input_details.len(), 1); + let mut rx = manager.subscribe_events(); let lock = locked_funding.then(|| dummy_instant_lock(funding.txid())); manager.process_mempool_transaction(&funding, lock.clone()).await; let events = drain_events(&mut rx); - let corrected = events + let corrections: Vec<_> = events .iter() - .find_map(|event| match event { + .filter_map(|event| match event { WalletEvent::TransactionDetected { wallet_id: id, record, @@ -2152,408 +2165,111 @@ async fn should_emit_late_input_corrections_without_borrowing_funding_lock() { } if *id == wallet_id && record.txid == spender.txid() => Some(record), _ => None, }) - .expect("the corrected spender must reach persistence subscribers"); + .collect(); + assert_eq!(corrections.len(), 1, "publish one complete correction"); + let corrected = corrections[0]; assert_eq!(corrected.net_amount, -2000); + assert_eq!(corrected.direction, TransactionDirection::Outgoing); + assert_eq!(corrected.context, TransactionContext::Mempool); assert_eq!( - corrected.direction, - key_wallet::managed_account::transaction_record::TransactionDirection::Outgoing + corrected.input_details.iter().map(|d| (d.index, d.value)).collect::>(), + vec![(0, TX_AMOUNT), (1, TX_AMOUNT), (2, TX_AMOUNT)] ); - assert_eq!(corrected.input_details.len(), 1); - assert_eq!(corrected.context, TransactionContext::Mempool); + assert_eq!(corrected.output_details.len(), 2); + assert_eq!(corrected.output_details[1].role, OutputRole::Sent); assert!( !events.iter().any(|event| matches!(event, WalletEvent::TransactionInstantLocked { txid, .. } if *txid == spender.txid() )), - "the funding lock must never be assigned to its spender" + "the funding lock belongs only to the funding transaction" ); manager.process_mempool_transaction(&funding, lock).await; assert_no_events(&mut rx); - } -} - -#[tokio::test] -async fn should_preserve_attributed_inputs_when_another_funding_arrives() { - let (mut manager, _, addr) = setup_manager_with_wallet(); - let first = create_tx_paying_to(&addr, 0xb1); - let second = create_tx_paying_to(&addr, 0xb2); - let mut spender = create_tx_paying_to(&addr, 0xb3); - spender.input[0].previous_output = OutPoint { - txid: first.txid(), - vout: 0, - }; - let mut input = spender.input[0].clone(); - input.previous_output.txid = second.txid(); - spender.input.push(input); - spender.output[0].value = 2 * TX_AMOUNT - 1000; - manager.process_mempool_transaction(&spender, None).await; - manager.process_mempool_transaction(&second, None).await; - let mut rx = manager.subscribe_events(); - manager.process_mempool_transaction(&first, None).await; - let events = drain_events(&mut rx); - let corrected = events - .iter() - .find_map(|event| match event { - WalletEvent::TransactionDetected { - record, - .. - } if record.txid == spender.txid() => Some(record), - _ => None, - }) - .expect("second input correction"); - assert_eq!(corrected.net_amount, -1000); - assert_eq!(corrected.input_details.len(), 2); - assert_eq!(corrected.input_details[0].index, 0); - assert_eq!(corrected.input_details[1].index, 1); - manager.process_mempool_transaction(&second, None).await; - assert_no_events(&mut rx); -} - -#[tokio::test] -async fn should_emit_one_complete_correction_for_multiple_inputs_from_one_parent() { - let (mut manager, _, addr) = setup_manager_with_wallet(); - let mut funding = create_tx_paying_to(&addr, 0xc1); - funding.output.push(funding.output[0].clone()); - let mut spender = create_tx_paying_to(&addr, 0xc2); - spender.input[0].previous_output = OutPoint { - txid: funding.txid(), - vout: 0, - }; - let mut input = spender.input[0].clone(); - input.previous_output.vout = 1; - spender.input.push(input); - spender.output[0].value = 2 * TX_AMOUNT - 1000; - manager.process_mempool_transaction(&spender, None).await; - let mut rx = manager.subscribe_events(); - manager.process_mempool_transaction(&funding, None).await; - let events = drain_events(&mut rx); - let corrected: Vec<_> = events - .iter() - .filter_map(|event| match event { - WalletEvent::TransactionDetected { - record, - .. - } if record.txid == spender.txid() => Some(record), - _ => None, - }) - .collect(); - assert_eq!(corrected.len(), 1, "one final slice per account and transaction"); - assert_eq!(corrected[0].net_amount, -1000); - assert_eq!(corrected[0].input_details.len(), 2); -} - -#[tokio::test] -async fn known_mempool_instant_lock_emits_only_lock_event() { - let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); - let tx = create_tx_paying_to(&addr, 0xd1); - manager.process_mempool_transaction(&tx, None).await; - let mut rx = manager.subscribe_events(); - manager.process_mempool_transaction(&tx, Some(dummy_instant_lock(tx.txid()))).await; - let events = drain_events(&mut rx); - assert_eq!(events.len(), 1, "a lock must not detect a known transaction again"); - assert!(matches!(&events[0], WalletEvent::TransactionInstantLocked { wallet_id: id, txid, .. } - if *id == wallet_id && *txid == tx.txid())); -} -#[tokio::test] -async fn confirming_funding_after_mempool_spend_preserves_attribution() { - let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); - let funding = create_tx_paying_to(&addr, 0xd2); - let mut spender = create_tx_paying_to(&addr, 0xd3); - spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); - spender.output[0].value = TX_AMOUNT - 2000; - spender.output.push(TxOut { - value: 1000, - script_pubkey: Address::dummy(Network::Testnet, 999).script_pubkey(), - }); - manager.process_mempool_transaction(&funding, None).await; - manager.process_mempool_transaction(&spender, None).await; - let mut rx = manager.subscribe_events(); - let block = make_block(vec![funding], 0xd4, 100); - manager - .process_block_for_wallets(&block, block.block_hash(), 1, &BTreeSet::from([wallet_id])) - .await; - let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); - let record = &account.transactions()[&spender.txid()]; - assert_eq!(record.net_amount, -2000); - assert_eq!(record.input_details.len(), 1); - assert_eq!( - record.direction, - key_wallet::managed_account::transaction_record::TransactionDirection::Outgoing - ); - for event in drain_events(&mut rx) { - if let WalletEvent::BlockProcessed { - updated, - .. - } = event - { - assert!( - updated.iter().all(|record| record.txid != spender.txid()), - "unchanged spender needs no correction" - ); + // Funding confirmation must not attribute the same inputs again. + let block = make_block(vec![funding, earlier_funding], 0xa4, 100); + manager + .process_block_for_wallets(&block, block.block_hash(), 1, &BTreeSet::from([wallet_id])) + .await; + let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); + let stored = &account.transactions()[&spender.txid()]; + assert_eq!(stored.net_amount, -2000); + assert_eq!(stored.input_details.len(), 3); + assert_eq!(account.utxos.len(), 1); + for input in &spender.input { + assert!(!account.utxos.contains_key(&input.previous_output)); } + assert!(drain_events(&mut rx).iter().all(|event| !matches!(event, + WalletEvent::BlockProcessed { updated, .. } + if updated.iter().any(|r| r.txid == spender.txid()) + ))); + + manager + .process_mempool_transaction(&spender, Some(dummy_instant_lock(spender.txid()))) + .await; + let events = drain_events(&mut rx); + assert_eq!(events.len(), 1, "a lock alone must not redetect the spender"); + assert!(matches!(&events[0], WalletEvent::TransactionInstantLocked { txid, .. } + if *txid == spender.txid())); } } #[tokio::test] async fn late_funding_block_publishes_spender_correction() { - let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); - let funding = create_tx_paying_to(&addr, 0xd5); - let mut spender = create_tx_paying_to(&addr, 0xd6); - spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); - spender.output[0].value = TX_AMOUNT - 2000; - let spend_block = make_block(vec![spender.clone()], 0xd7, 200); - let wallets = BTreeSet::from([wallet_id]); - manager.process_block_for_wallets(&spend_block, spend_block.block_hash(), 2, &wallets).await; - let mut rx = manager.subscribe_events(); - let fund_block = make_block(vec![funding.clone()], 0xd8, 100); - manager.process_block_for_wallets(&fund_block, fund_block.block_hash(), 1, &wallets).await; - let events = drain_events(&mut rx); - let updated = events - .iter() - .find_map(|event| match event { - WalletEvent::BlockProcessed { - updated, - .. - } => Some(updated), - _ => None, - }) - .expect("block event"); - let record = - updated.iter().find(|record| record.txid == spender.txid()).expect("spender correction"); - assert_eq!(record.net_amount, -2000); - assert_eq!(record.input_details.len(), 1); - assert_eq!(record.input_details[0].value, TX_AMOUNT); - assert_eq!( - record.direction, - key_wallet::managed_account::transaction_record::TransactionDirection::Internal - ); - assert_eq!(record.context.block_info().unwrap().height(), 2); - assert!(!manager.wallet_infos[&wallet_id] - .first_bip44_managed_account() - .unwrap() - .utxos - .contains_key(&OutPoint::new(funding.txid(), 0))); -} + use key_wallet::managed_account::transaction_record::{OutputRole, TransactionDirection}; -#[tokio::test] -async fn late_funding_recorded_sibling_spender_is_corrected_after_reapply() { - let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); - let sibling_address = manager.wallet_infos[&wallet_id] - .coinjoin_managed_account_at_index(0) - .unwrap() - .managed_account_type() - .address_pools() - .into_iter() - .find(|pool| pool.pool_type == AddressPoolType::External) - .unwrap() - .address_at_index(0) - .unwrap() - .clone(); - let mut funding = create_tx_paying_to(&addr, 0xd9); - funding.output[0].value = 1_000_000; - let mut spender = create_tx_paying_to(&sibling_address, 0xda); - spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); - spender.output[0].value = 900_000; - let wallets = BTreeSet::from([wallet_id]); - let spend_block = make_block(vec![spender.clone()], 0xdb, 200); - manager.process_block_for_wallets(&spend_block, spend_block.block_hash(), 2, &wallets).await; - assert!(!manager.wallet_infos[&wallet_id] - .first_bip44_managed_account() - .unwrap() - .transactions() - .contains_key(&spender.txid())); - - let mut rx = manager.subscribe_events(); - let fund_block = make_block(vec![funding.clone()], 0xdc, 100); - let result = - manager.process_block_for_wallets(&fund_block, fund_block.block_hash(), 1, &wallets).await; - assert_eq!(result.reapply_heights, BTreeMap::from([(wallet_id, BTreeSet::from([2]))])); - drain_events(&mut rx); - for (replay_wallet_id, heights) in result.reapply_heights { - for height in heights { - let replay = manager - .process_block_for_wallets( - &spend_block, - spend_block.block_hash(), - height, - &BTreeSet::from([replay_wallet_id]), - ) - .await; - assert!(replay.reapply_heights.is_empty()); - } - } - let events = drain_events(&mut rx); - let corrected = events - .iter() - .find_map(|event| match event { - WalletEvent::BlockProcessed { - inserted, - updated, - .. - } => inserted.iter().chain(updated).find(|record| { - record.txid == spender.txid() - && matches!(record.account_type, AccountType::Standard { .. }) - }), - _ => None, - }) - .expect("replayed block publishes the BIP44 spender"); - assert_eq!(corrected.net_amount, -1_000_000); - assert_eq!(corrected.input_details.len(), 1); - assert_eq!(corrected.input_details[0].value, 1_000_000); - assert_eq!(corrected.context.block_info().unwrap().height(), 2); - let info = &manager.wallet_infos[&wallet_id]; - let funding_account = info.first_bip44_managed_account().unwrap(); - let stored = &funding_account.transactions()[&spender.txid()]; - assert_eq!(stored.net_amount, corrected.net_amount); - assert_eq!(stored.input_details.len(), 1); - assert!(!funding_account.utxos.contains_key(&OutPoint::new(funding.txid(), 0))); - let sibling = - &info.coinjoin_managed_account_at_index(0).unwrap().transactions()[&spender.txid()]; - assert_eq!(sibling.net_amount, 900_000); - assert!(sibling.input_details.is_empty(), "inputs belong only to BIP44"); - assert!(info.unrecorded_spend_heights(&funding).is_empty()); - - let again = - manager.process_block_for_wallets(&fund_block, fund_block.block_hash(), 1, &wallets).await; - assert!(again.reapply_heights.is_empty()); - assert!(drain_events(&mut rx).iter().all(|event| !matches!( - event, WalletEvent::BlockProcessed { updated, .. } if !updated.is_empty() - ))); -} - -#[tokio::test] -async fn late_funding_finalized_sibling_preserves_correction_path() { - let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); - let sibling_address = manager.wallet_infos[&wallet_id] - .coinjoin_managed_account_at_index(0) - .unwrap() - .managed_account_type() - .address_pools() - .into_iter() - .find(|pool| pool.pool_type == AddressPoolType::External) - .unwrap() - .address_at_index(0) - .unwrap() - .clone(); - let funding = create_tx_paying_to(&addr, 0xdd); - let mut spender = create_tx_paying_to(&sibling_address, 0xde); - spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); - spender.output[0].value = TX_AMOUNT - 2000; - let wallets = BTreeSet::from([wallet_id]); - let spend_block = make_block(vec![spender.clone()], 0xdf, 200); - manager.process_block_for_wallets(&spend_block, spend_block.block_hash(), 2, &wallets).await; - manager.apply_chain_lock(ChainLock::dummy(2)); + for change_value in [0, TX_AMOUNT - 2000] { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let change = manager + .wallet_infos + .get_mut(&wallet_id) + .unwrap() + .first_bip44_managed_account_mut() + .unwrap() + .next_change_address(None, true) + .unwrap(); + let funding = create_tx_paying_to(&addr, 0xd5); + let mut spender = create_tx_paying_to(&change, 0xd6); + spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); + spender.output[0].value = change_value; + let spend_block = make_block(vec![spender.clone()], 0xd7, 200); + let wallets = BTreeSet::from([wallet_id]); + manager + .process_block_for_wallets(&spend_block, spend_block.block_hash(), 2, &wallets) + .await; + let mut rx = manager.subscribe_events(); + let fund_block = make_block(vec![funding.clone()], 0xd8, 100); + let result = manager + .process_block_for_wallets(&fund_block, fund_block.block_hash(), 1, &wallets) + .await; + assert!(result.reapply_heights.is_empty()); + let events = drain_events(&mut rx); + let updated = events + .iter() + .find_map(|event| match event { + WalletEvent::BlockProcessed { + updated, + .. + } => Some(updated), + _ => None, + }) + .expect("block correction event"); + assert_eq!(updated.len(), 1); + let record = &updated[0]; + assert_eq!(record.txid, spender.txid()); + assert_eq!(record.net_amount, change_value as i64 - TX_AMOUNT as i64); + assert_eq!(record.input_details.len(), 1); + assert_eq!(record.input_details[0].value, TX_AMOUNT); + assert_eq!(record.input_details[0].index, 0); + assert_eq!(record.direction, TransactionDirection::Internal); + assert_eq!(record.output_details[0].role, OutputRole::Change); + assert_eq!(record.context.block_info().unwrap().height(), 2); + assert_eq!(record.context.block_info().unwrap().block_hash(), spend_block.block_hash()); + let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); + assert_eq!(account.transactions()[&spender.txid()].net_amount, record.net_amount); + assert!(!account.utxos.contains_key(&OutPoint::new(funding.txid(), 0))); - let mut rx = manager.subscribe_events(); - let fund_block = make_block(vec![funding.clone()], 0xe0, 100); - let result = manager.process_block_for_wallets(&fund_block, fund_block.block_hash(), 1, &wallets).await; - assert_eq!(result.reapply_heights, BTreeMap::from([(wallet_id, BTreeSet::from([2]))])); - drain_events(&mut rx); - for (replay_wallet_id, heights) in result.reapply_heights { - for height in heights { - let replay = manager - .process_block_for_wallets( - &spend_block, - spend_block.block_hash(), - height, - &BTreeSet::from([replay_wallet_id]), - ) - .await; - assert!(replay.reapply_heights.is_empty()); - } - } - let correction_events = drain_events(&mut rx); - let corrected = correction_events - .iter() - .find_map(|event| match event { - WalletEvent::BlockProcessed { - inserted, - updated, - .. - } => inserted.iter().chain(updated).find(|record| { - record.txid == spender.txid() - && matches!(record.account_type, AccountType::Standard { .. }) - }), - _ => None, - }) - .expect("owning account receives the complete chainlocked spender slice"); - assert_eq!(corrected.net_amount, -(TX_AMOUNT as i64)); - assert_eq!(corrected.input_details.len(), 1); - assert_eq!(corrected.input_details[0].value, TX_AMOUNT); - assert!(corrected.context.is_chain_locked()); - let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); - assert!(account.transaction_is_finalized(&spender.txid())); - assert_eq!( - account.transactions().contains_key(&spender.txid()), - cfg!(feature = "keep-finalized-transactions") - ); - assert!(!account.utxos.contains_key(&OutPoint::new(funding.txid(), 0))); -} - -#[tokio::test] -async fn finalized_spender_late_funding_chainlock_first() { - finalized_spender_late_funding_retention_policy(true).await; -} - -#[tokio::test] -async fn finalized_spender_late_funding_chainlock_between() { - finalized_spender_late_funding_retention_policy(false).await; -} - -async fn finalized_spender_late_funding_retention_policy(chainlock_first: bool) { - let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); - let wallets = BTreeSet::from([wallet_id]); - let funding = create_tx_paying_to(&addr, 0xe1); - let mut spender = create_tx_paying_to(&addr, 0xe2); - spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); - spender.output[0].value = TX_AMOUNT - 2000; - if chainlock_first { - manager.apply_chain_lock(ChainLock::dummy(2)); - } - let mut rx = manager.subscribe_events(); - let block = make_block(vec![spender.clone()], 0xe3, 200); - manager.process_block_for_wallets(&block, block.block_hash(), 2, &wallets).await; - let initial = drain_events(&mut rx); - let incoming = initial - .iter() - .find_map(|event| match event { - WalletEvent::BlockProcessed { - inserted, - .. - } => inserted.iter().find(|r| r.txid == spender.txid()), - _ => None, - }) - .expect("initial spender record"); - assert_eq!(incoming.net_amount, (TX_AMOUNT - 2000) as i64); - assert_eq!( - incoming.direction, - key_wallet::managed_account::transaction_record::TransactionDirection::Incoming - ); - if !chainlock_first { - manager.apply_chain_lock(ChainLock::dummy(2)); - } - let mut rx = manager.subscribe_events(); - let block = make_block(vec![funding], 0xe4, 100); - manager.process_block_for_wallets(&block, block.block_hash(), 1, &wallets).await; - let events = drain_events(&mut rx); - let correction = events.iter().find_map(|event| match event { - WalletEvent::BlockProcessed { - updated, - .. - } => updated.iter().find(|r| r.txid == spender.txid()), - _ => None, - }); - let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); - assert!(account.transaction_is_finalized(&spender.txid())); - if cfg!(feature = "keep-finalized-transactions") { - let record = correction.expect("retained finalized spender is correctable"); - assert_eq!(record.net_amount, -2000); - assert_eq!(record.input_details.len(), 1); - assert!(record.context.is_chain_locked()); - assert_eq!(account.transactions()[&spender.txid()].net_amount, -2000); - } else { - assert!(correction.is_none(), "txid-only retention cannot reconstruct accounting"); - assert!(!account.transactions().contains_key(&spender.txid())); + assert_no_events(&mut rx); } } diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index 66251ad48..742b92eab 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -702,41 +702,114 @@ mod tests { #[tokio::test] async fn test_funding_after_its_spend_asks_to_reapply_the_spend_block() { - let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); - let funding = create_tx_paying_to(&addr, 0xaa); - let spend = spend_first_output_of(&funding); - let spent_outpoint = OutPoint::new(funding.txid(), 0); - let spender_txid = spend.txid(); - let wallets = BTreeSet::from([wallet_id]); - - let mut spend_block = make_block(vec![spend]); - spend_block.header.nonce = 1; - let funding_block = make_block(vec![funding]); - - manager - .process_block_for_wallets(&spend_block, spend_block.block_hash(), 200, &wallets) - .await; - let result = manager - .process_block_for_wallets(&funding_block, funding_block.block_hash(), 100, &wallets) - .await; - assert_eq!(result.reapply_heights, BTreeMap::from([(wallet_id, BTreeSet::from([200]))])); - let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); - assert!(!account.transactions().contains_key(&spender_txid)); - assert!(!account.utxos.contains_key(&spent_outpoint)); - - manager - .process_block_for_wallets(&spend_block, spend_block.block_hash(), 200, &wallets) - .await; - let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); - let recorded = &account.transactions()[&spender_txid]; - assert_eq!(recorded.net_amount, -(TX_AMOUNT as i64)); - assert_eq!(recorded.input_details.len(), 1); - assert_eq!(recorded.input_details[0].value, TX_AMOUNT); - assert!(!account.utxos.contains_key(&spent_outpoint)); - let again = manager - .process_block_for_wallets(&funding_block, funding_block.block_hash(), 100, &wallets) - .await; - assert!(again.reapply_heights.is_empty()); + use key_wallet::managed_account::transaction_record::TransactionDirection; + + for (sibling, finalized) in [(false, false), (true, false), (true, true)] { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let funding = create_tx_paying_to(&addr, 0xaa); + let mut spend = spend_first_output_of(&funding); + spend.output[0].value = TX_AMOUNT - 2000; + if sibling { + spend.output[0].script_pubkey = coinjoin_account(&manager, &wallet_id) + .managed_account_type() + .address_pools()[0] + .address_at_index(0) + .unwrap() + .script_pubkey(); + } + let spent_outpoint = OutPoint::new(funding.txid(), 0); + let spender_txid = spend.txid(); + let wallets = BTreeSet::from([wallet_id]); + let mut spend_block = make_block(vec![spend]); + spend_block.header.nonce = 1; + let funding_block = make_block(vec![funding]); + + manager + .process_block_for_wallets(&spend_block, spend_block.block_hash(), 200, &wallets) + .await; + assert_eq!( + coinjoin_account(&manager, &wallet_id).has_transaction(&spender_txid), + sibling + ); + if finalized { + manager.apply_chain_lock(ChainLock::dummy(200)); + } + let result = manager + .process_block_for_wallets( + &funding_block, + funding_block.block_hash(), + 100, + &wallets, + ) + .await; + assert_eq!( + result.reapply_heights, + BTreeMap::from([(wallet_id, BTreeSet::from([200]))]) + ); + let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); + assert!(!account.transactions().contains_key(&spender_txid)); + assert!(!account.utxos.contains_key(&spent_outpoint)); + + let mut rx = manager.subscribe_events(); + for (wallet, heights) in result.reapply_heights { + for height in heights { + let replay = manager + .process_block_for_wallets( + &spend_block, + spend_block.block_hash(), + height, + &BTreeSet::from([wallet]), + ) + .await; + assert!(replay.reapply_heights.is_empty()); + } + } + let events = drain_events(&mut rx); + let recorded = events + .iter() + .find_map(|event| match event { + WalletEvent::BlockProcessed { + inserted, + .. + } => inserted.iter().find(|r| { + r.txid == spender_txid + && matches!(r.account_type, AccountType::Standard { .. }) + }), + _ => None, + }) + .expect("replay publishes the missing funding-account record"); + assert_eq!(recorded.net_amount, -(TX_AMOUNT as i64)); + assert_eq!(recorded.direction, TransactionDirection::Outgoing); + assert_eq!(recorded.input_details.len(), 1); + assert_eq!(recorded.input_details[0].value, TX_AMOUNT); + assert_eq!(recorded.context.block_info().unwrap().height(), 200); + assert_eq!(recorded.context.is_chain_locked(), finalized); + let account = manager.wallet_infos[&wallet_id].first_bip44_managed_account().unwrap(); + assert!(!account.utxos.contains_key(&spent_outpoint)); + assert_eq!( + account.transactions().contains_key(&spender_txid), + !finalized || cfg!(feature = "keep-finalized-transactions") + ); + if sibling { + assert_eq!(manager.wallet_infos[&wallet_id].balance().total(), TX_AMOUNT - 2000); + if let Some(record) = + coinjoin_account(&manager, &wallet_id).transactions().get(&spender_txid) + { + assert_eq!(record.net_amount, (TX_AMOUNT - 2000) as i64); + assert!(record.input_details.is_empty()); + } + } + let again = manager + .process_block_for_wallets( + &funding_block, + funding_block.block_hash(), + 100, + &wallets, + ) + .await; + assert!(again.reapply_heights.is_empty()); + assert_no_events(&mut rx); + } } #[tokio::test] diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index 99b2f5e60..93bfa8e9e 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -1581,102 +1581,3 @@ mod conflict_sweep_walk_tests { ); } } - -#[cfg(test)] -mod attribution_tests { - use super::*; - use crate::account::{AccountType, StandardAccountType}; - use crate::test_utils::TestWalletContext; - use crate::transaction_checking::BlockInfo; - use crate::wallet::ManagedWalletInfo; - use dashcore::hashes::Hash; - use dashcore::{BlockHash, TxOut}; - - #[tokio::test] - async fn late_funding_replay_restores_imported_account_change_and_spent_mark() { - use crate::managed_account::transaction_record::OutputRole; - use crate::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; - use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - let mut ctx = TestWalletContext::new_random(); - let account_type = AccountType::Standard { - index: 1, - standard_account_type: StandardAccountType::BIP44Account, - }; - ctx.wallet.add_account(account_type, None).unwrap(); - let xpub = ctx.wallet.accounts.standard_bip44_accounts[&1].account_xpub; - let mut preview = - ManagedWalletInfo::from_wallet_with_name(&ctx.wallet, "preview".into(), 0); - let account = preview.bip44_managed_account_at_index_mut(1).unwrap(); - let receive = account.next_receive_address(Some(&xpub), true).unwrap(); - let change = account.next_change_address(Some(&xpub), true).unwrap(); - let funding = Transaction::dummy(&receive, 30..31, &[100_000]); - let outpoint = OutPoint::new(funding.txid(), 0); - let mut spender = Transaction::dummy(&ctx.receive_address, 31..32, &[60_000]); - spender.input[0].previous_output = outpoint; - spender.output.push(TxOut { - value: 39_000, - script_pubkey: change.script_pubkey(), - }); - ctx.check_transaction( - &spender, - TransactionContext::InBlock(BlockInfo::new(2, BlockHash::all_zeros(), 200)), - ) - .await; - ctx.managed_wallet.add_managed_account(&ctx.wallet, account_type).unwrap(); - ctx.check_transaction( - &funding, - TransactionContext::InBlock(BlockInfo::new(1, BlockHash::all_zeros(), 100)), - ) - .await; - let heights = ctx.managed_wallet.unrecorded_spend_heights(&funding); - assert_eq!(heights, BTreeSet::from([2])); - let mut corrections = Vec::new(); - for height in heights { - let result = ctx - .check_transaction( - &spender, - TransactionContext::InBlock(BlockInfo::new( - height, - BlockHash::all_zeros(), - 200, - )), - ) - .await; - assert!(result.state_modified); - corrections.extend(result.new_records); - corrections.extend(result.updated_records); - } - assert!(ctx.managed_wallet.unrecorded_spend_heights(&funding).is_empty()); - let corrected = corrections.iter().find(|r| r.account_type == account_type).unwrap(); - assert_eq!(corrected.net_amount, -61_000); - assert_eq!(corrected.output_details[1].role, OutputRole::Change); - assert_eq!(corrected.output_details[1].address.as_ref(), Some(&change)); - assert_eq!(corrected.input_details.len(), 1); - assert!( - ctx.managed_wallet - .bip44_managed_account_at_index(1) - .unwrap() - .is_outpoint_spent(&outpoint), - "late attribution must mark the input spent in its owning account" - ); - assert!(!ctx - .managed_wallet - .bip44_managed_account_at_index(1) - .unwrap() - .utxos - .contains_key(&outpoint)); - ctx.check_transaction( - &funding, - TransactionContext::InBlock(BlockInfo::new(1, BlockHash::all_zeros(), 100)), - ) - .await; - assert!( - !ctx.managed_wallet - .bip44_managed_account_at_index(1) - .unwrap() - .utxos - .contains_key(&outpoint), - "confirming funding must not resurrect an attributed spent coin" - ); - } -} diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 58f1ffdd7..e5ab647db 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -1225,15 +1225,6 @@ mod tests { // because the stored spend_tx spends it let account = managed_wallet.first_bip44_managed_account().expect("Should have account"); - let record = &account.transactions()[&spend_tx.txid()]; - assert_eq!(record.net_amount, -50_000); - assert_eq!(record.direction, TransactionDirection::Internal); - assert_eq!(record.input_details.len(), 1); - assert_eq!(record.input_details[0].index, 0); - assert_eq!(record.input_details[0].value, 100_000); - assert_eq!(fund_result.updated_records[0].txid, spend_tx.txid()); - assert!(fund_result.state_modified); - // Should still only have one UTXO (the change from spend_tx) assert_eq!( account.utxos.len(), @@ -3837,240 +3828,4 @@ mod tests { assert_eq!(ctx.managed_wallet.balance.confirmed(), 0); assert_eq!(ctx.managed_wallet.balance.unconfirmed(), payment_value); } - - /// A late input belongs to its funding account even when another account first saw the spender. - #[tokio::test] - async fn late_funding_replay_recovers_missing_sibling_account_records() { - for own_change in [0, 50_000] { - let network = Network::Testnet; - let mut wallet = - Wallet::new_random(network, WalletAccountCreationOptions::Default).expect("wallet"); - let mut managed_wallet = - ManagedWalletInfo::from_wallet_with_name(&wallet, "Test".to_string(), 0); - - // Funding pays the BIP44 receive address (account A). - let bip44_xpub = - wallet.accounts.standard_bip44_accounts.get(&0).expect("bip44").account_xpub; - let bip44_address = managed_wallet - .first_bip44_managed_account_mut() - .expect("bip44 managed") - .next_receive_address(Some(&bip44_xpub), true) - .expect("bip44 address"); - // The spender pays the CoinJoin account (account B) — so processed - // first, it is recorded in B only. The pool is pre-generated to the - // gap limit at construction; index 0 is already watched. - let cj_address = managed_wallet - .coinjoin_managed_account_at_index(0) - .expect("coinjoin managed") - .managed_account_type() - .address_pools() - .into_iter() - .find(|pool| { - pool.pool_type - == crate::managed_account::address_pool::AddressPoolType::External - }) - .expect("coinjoin external pool") - .address_at_index(0) - .expect("pre-generated coinjoin address") - .clone(); - - const FUND: u64 = 1_000_000; - const BACK: u64 = 900_000; - let funding_tx = Transaction::dummy(&bip44_address, 0..1, &[FUND]); - let funded_outpoint = OutPoint { - txid: funding_tx.txid(), - vout: 0, - }; - let mut spender_tx = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: funded_outpoint, - script_sig: ScriptBuf::new(), - sequence: 0xffffffff, - witness: dashcore::Witness::new(), - }], - output: vec![TxOut { - value: BACK, - script_pubkey: cj_address.script_pubkey(), - }], - special_transaction_payload: None, - }; - if own_change > 0 { - spender_tx.output.push(TxOut { - value: own_change, - script_pubkey: bip44_address.script_pubkey(), - }); - } - let spender_txid = spender_tx.txid(); - - // Spender first (height 2), landing in the CoinJoin account. - let result = managed_wallet - .check_core_transaction( - &spender_tx, - TransactionContext::InBlock(BlockInfo::new( - 2, - BlockHash::from_slice(&[3u8; 32]).expect("hash"), - 1_650_000_100, - )), - &mut wallet, - true, - true, - ) - .await; - assert!(result.is_relevant && result.is_new_transaction); - { - let cj = managed_wallet.coinjoin_managed_account_at_index(0).expect("cj"); - let record = - cj.transactions().get(&spender_txid).expect("spender in CoinJoin acct"); - assert_eq!( - record.net_amount, BACK as i64, - "born income-only in the sibling account" - ); - } - - // Funding corrects existing slices and requests replay for missing ones. - let result = managed_wallet - .check_core_transaction( - &funding_tx, - TransactionContext::InBlock(BlockInfo::new( - 1, - BlockHash::from_slice(&[2u8; 32]).expect("hash"), - 1_650_000_000, - )), - &mut wallet, - true, - true, - ) - .await; - assert!(result.is_relevant); - - let heights = managed_wallet.unrecorded_spend_heights(&funding_tx); - assert_eq!( - heights, - if own_change == 0 { - BTreeSet::from([2]) - } else { - BTreeSet::new() - } - ); - let mut corrections = result.updated_records; - for height in heights { - let replay = managed_wallet - .check_core_transaction( - &spender_tx, - TransactionContext::InBlock(BlockInfo::new( - height, - BlockHash::from_slice(&[3u8; 32]).expect("hash"), - 1_650_000_100, - )), - &mut wallet, - true, - true, - ) - .await; - corrections.extend(replay.new_records); - corrections.extend(replay.updated_records); - } - assert!(managed_wallet.unrecorded_spend_heights(&funding_tx).is_empty()); - let cj = managed_wallet.coinjoin_managed_account_at_index(0).expect("cj"); - let record = cj.transactions().get(&spender_txid).expect("spender record"); - assert_eq!(record.net_amount, BACK as i64); - assert!(record.input_details.is_empty(), "sibling inputs stay in their owning account"); - let bip44 = managed_wallet.first_bip44_managed_account().expect("bip44"); - let outgoing = bip44.transactions().get(&spender_txid).expect("owning account slice"); - assert_eq!(outgoing.net_amount, own_change as i64 - FUND as i64); - assert_eq!(outgoing.input_details.len(), 1); - assert_eq!( - record.net_amount + outgoing.net_amount, - (BACK + own_change) as i64 - FUND as i64 - ); - assert!(!bip44.utxos.contains_key(&funded_outpoint)); - let corrected = corrections - .iter() - .find(|r| r.txid == spender_txid && r.account_type == outgoing.account_type) - .expect("correction"); - assert_eq!(corrected.net_amount, outgoing.net_amount); - } - } - - #[tokio::test] - async fn born_spent_attribution_preserves_coinjoin_direction() { - let mut ctx = TestWalletContext::new_random(); - let funding = Transaction::dummy(&ctx.receive_address, 10..11, &[100_001_000]); - let mut spender = Transaction::dummy(&ctx.receive_address, 11..14, &[100_001_000; 3]); - spender.input[1].previous_output = OutPoint::new(funding.txid(), 0); - let first = ctx.check_transaction(&spender, TransactionContext::Mempool).await; - assert_eq!(first.new_records[0].direction, TransactionDirection::CoinJoin); - let result = ctx.check_transaction(&funding, TransactionContext::Mempool).await; - let record = result.updated_records.iter().find(|r| r.txid == spender.txid()).unwrap(); - assert_eq!(record.direction, TransactionDirection::CoinJoin); - assert_eq!(record.input_details[0].index, 1); - assert_eq!(record.net_amount, 200_002_000); - } - - #[tokio::test] - async fn born_spent_attribution_internal_change_including_zero_value() { - use crate::managed_account::transaction_record::OutputRole; - for change_value in [0, 98_000] { - let mut ctx = TestWalletContext::new_random(); - let change = ctx - .managed_wallet - .first_bip44_managed_account_mut() - .unwrap() - .next_change_address(Some(&ctx.xpub), true) - .unwrap(); - let funding = Transaction::dummy(&ctx.receive_address, 20..21, &[100_000]); - let mut spender = Transaction::dummy(&change, 21..22, &[change_value]); - spender.input[0].previous_output = OutPoint::new(funding.txid(), 0); - ctx.check_transaction(&spender, TransactionContext::Mempool).await; - let result = ctx.check_transaction(&funding, TransactionContext::Mempool).await; - let record = result.updated_records.iter().find(|r| r.txid == spender.txid()).unwrap(); - assert_eq!(record.direction, TransactionDirection::Internal); - assert_eq!(record.net_amount, change_value as i64 - 100_000); - assert_eq!(record.output_details[0].role, OutputRole::Change); - assert!(result.state_modified); - } - } - - #[tokio::test] - async fn born_spent_correction_marks_state_modified_on_unchanged_funding_redelivery() { - let mut ctx = TestWalletContext::new_random(); - let mut preview = ctx.bip44_account().clone(); - let late_address = preview - .next_receive_addresses(Some(&ctx.xpub), 100, true) - .unwrap() - .last() - .unwrap() - .clone(); - assert!(!ctx.bip44_account().contains_address(&late_address)); - let mut funding = Transaction::dummy(&ctx.receive_address, 40..41, &[30_000]); - funding.output.push(TxOut { - value: 100_000, - script_pubkey: late_address.script_pubkey(), - }); - let fund_context = - TransactionContext::InBlock(BlockInfo::new(1, BlockHash::all_zeros(), 100)); - ctx.check_transaction(&funding, fund_context.clone()).await; - let mut spender = Transaction::dummy(&ctx.receive_address, 41..42, &[98_000]); - spender.input[0].previous_output = OutPoint::new(funding.txid(), 1); - ctx.check_transaction( - &spender, - TransactionContext::InBlock(BlockInfo::new(2, BlockHash::all_zeros(), 200)), - ) - .await; - assert_eq!(ctx.transaction(&spender.txid()).net_amount, 98_000); - - ctx.managed_wallet - .first_bip44_managed_account_mut() - .unwrap() - .next_receive_addresses(Some(&ctx.xpub), 100, true) - .unwrap(); - assert!(ctx.bip44_account().contains_address(&late_address)); - let result = ctx.check_transaction(&funding, fund_context).await; - assert_eq!(result.updated_records.len(), 1); - assert_eq!(result.updated_records[0].txid, spender.txid()); - assert_eq!(result.updated_records[0].net_amount, -2000); - assert!(result.state_modified, "a correction alone must trigger persistence"); - } } From 9609990bc2f1818c2bd12de93f74cfa3277c7100 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:12:17 +0000 Subject: [PATCH 10/10] docs(wallet): remove internal accounting details from README Co-Authored-By: Codex --- key-wallet/README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/key-wallet/README.md b/key-wallet/README.md index c5dfe6577..039e39a98 100644 --- a/key-wallet/README.md +++ b/key-wallet/README.md @@ -269,14 +269,6 @@ Core dependencies: - `bip39`: Mnemonic phrase support - Additional optional dependencies for specialized features -## Late funding and finalized transaction history - -Late funding corrects input details, direction, and net amount in retained spender records -within the owning account. Missing account records are recovered through block replay. -With default features, ChainLocks prune full records, so later funding cannot correct -an already-pruned spender or its persisted copy. Enable `keep-finalized-transactions` -before processing to retain those records, at the cost of keeping finalized history in memory. - ## Contributing Contributions are welcome! Please ensure: