diff --git a/dash-spv/src/sync/filters/manager.rs b/dash-spv/src/sync/filters/manager.rs index f01fbcb59..fcc907b09 100644 --- a/dash-spv/src/sync/filters/manager.rs +++ b/dash-spv/src/sync/filters/manager.rs @@ -860,7 +860,12 @@ impl = Vec::new(); for wallet_id in &behind { let synced = wallet.wallet_synced_height(wallet_id); - let scripts = wallet.monitored_script_pubkeys_for(wallet_id); + // The scan query, not the full monitored set: spent single-use + // (CoinJoin) addresses are pruned so the per-filter match cost + // stays bounded by active UTXOs + gap lookahead instead of + // growing with every historical mixing round + // (dashpay/rust-dashcore#948). + let scripts = wallet.scan_script_pubkeys_for(wallet_id); // Bare owner/voting key hashes a compact filter carries beyond the // wallet's scriptPubKeys. let elements = wallet.monitored_filter_elements_for(wallet_id); @@ -1931,6 +1936,64 @@ mod tests { assert!(!attr_70.contains(&wallet_high)); } + /// `scan_batch` matches filters against the wallet's scan query + /// (`scan_script_pubkeys_for`), not the full monitored set: a monitored + /// script pruned from the scan query — a spent single-use CoinJoin + /// address (dashpay/rust-dashcore#948) — must not pull its block in. + #[tokio::test] + async fn test_scan_batch_uses_pruned_scan_query() { + let wallet_id: WalletId = [0x03; 32]; + let dead_address = dashcore::Address::dummy(Network::Regtest, 1); + let live_address = dashcore::Address::dummy(Network::Regtest, 2); + + let multi = Arc::new(RwLock::new(MultiMockWallet::new())); + { + let mut w = multi.write().await; + w.insert_wallet( + wallet_id, + MockWalletState { + addresses: vec![dead_address.clone(), live_address.clone()], + synced_height: 0, + last_processed_height: 0, + account_generation: 0, + }, + ); + // The scan query excludes the dead address. + w.set_scan_addresses(wallet_id, vec![live_address.clone()]); + } + let mut manager = create_multi_test_manager(multi).await; + manager.set_state(SyncState::Syncing); + + let mut filters: HashMap = HashMap::new(); + let (key_dead, f_dead) = filter_for_address(30, &dead_address); + let (key_live, f_live) = filter_for_address(60, &live_address); + filters.insert(key_dead.clone(), f_dead); + filters.insert(key_live.clone(), f_live); + + let mut batch = FiltersBatch::new(0, 99, filters); + batch.mark_verified(); + manager.active_batches.insert(0, batch); + manager.progress.update_stored_height(99); + + let events = manager.scan_batch(0).await.unwrap(); + + let blocks = events + .iter() + .find_map(|e| match e { + SyncEvent::BlocksNeeded { + blocks, + } => Some(blocks), + _ => None, + }) + .expect("BlocksNeeded event"); + + assert!(blocks.contains_key(&key_live), "block paying the scan-query address is needed"); + assert!( + !blocks.contains_key(&key_dead), + "block paying only the pruned address must not be downloaded" + ); + } + /// `rescan_batch` with multiple wallets in `scripts_by_wallet`: /// each wallet's new scripts are matched independently and the /// attribution is correct in the emitted `BlocksNeeded`. diff --git a/key-wallet-manager/Cargo.toml b/key-wallet-manager/Cargo.toml index 4eb5f181b..6ae704d9e 100644 --- a/key-wallet-manager/Cargo.toml +++ b/key-wallet-manager/Cargo.toml @@ -39,6 +39,11 @@ key-wallet = { path = "../key-wallet", features = ["test-utils", "bincode"] } dashcore = { path = "../dash", features = ["test-utils"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } hex = "0.4" +criterion = "0.8.1" + +[[bench]] +name = "filter_scan" +harness = false [lints.rust] unexpected_cfgs = { level = "allow", check-cfg = ['cfg(bench)', 'cfg(fuzzing)'] } diff --git a/key-wallet-manager/benches/filter_scan.rs b/key-wallet-manager/benches/filter_scan.rs new file mode 100644 index 000000000..e9eed83d2 --- /dev/null +++ b/key-wallet-manager/benches/filter_scan.rs @@ -0,0 +1,174 @@ +//! Compact-filter matching cost: full monitored set vs the pruned +//! forward-scan set for a mixing-heavy CoinJoin wallet +//! (dashpay/rust-dashcore#948). +//! +//! Mimics a wallet mid-recovery after many mixing rounds. Every CoinJoin +//! round pays a fresh single-use address, so the account accumulates `used` +//! spent addresses, keeps a small set of still-funded denominations +//! ([`LIVE_UTXOS`]), and watches the usual gap-limit lookahead on top. One +//! scan batch of BIP158 filters is then matched with +//! `monitored_script_pubkeys_for` (the pre-#948 query, which drags every +//! historical address through SipHash + sort per filter) and with +//! `scan_script_pubkeys_for` (the pruned query, bounded by live UTXOs + gap +//! lookahead). +//! +//! BIP158 keys each filter's SipHashes off the block hash, so the whole +//! query set is re-hashed and re-sorted per filter — which is exactly why +//! the query size dominates and why nothing is cacheable across filters. +//! +//! Run with: +//! `cargo bench -p key-wallet-manager --bench filter_scan` + +use std::collections::HashMap; +use std::hint::black_box; + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use dashcore::bip158::BlockFilter; +use dashcore::hashes::Hash; +use dashcore::{Address, Block, OutPoint, Transaction, TxOut, Txid}; +use key_wallet::account::ManagedAccountTrait; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::{KeySource, ManagedAccountType, Network, Utxo}; +use key_wallet_manager::{ + check_compact_filters_for_elements, FilterMatchKey, WalletInterface, WalletManager, +}; + +/// Denominated coins still unspent in the CoinJoin account — the wallet's +/// active mixing balance, which stays in the scan query. +const LIVE_UTXOS: usize = 200; + +/// Filters matched per iteration — one scan batch. +const FILTERS: u32 = 512; + +/// Historical single-use address counts to sweep, roughly +/// `denominations x rounds` at different points of a recovery scan. The +/// issue's reference wallet starts a mainnet recovery at a few hundred +/// monitored scripts and ends at several thousand. +const USED_ADDRESSES: [u32; 3] = [500, 2_000, 6_000]; + +type Manager = WalletManager; + +/// Build a wallet whose CoinJoin account carries `used` spent single-use +/// addresses, [`LIVE_UTXOS`] still-funded ones, and the default gap-limit +/// lookahead of unused addresses above them. +/// +/// The wallet is created from a fresh random mnemonic each run: the +/// workload is defined entirely by the pool/UTXO counts, so the timings +/// are stable across runs without pinning key material. +fn wallet_with_mixing_history(used: u32) -> (Manager, [u8; 32]) { + let mut manager = Manager::new(Network::Regtest); + let wallet_id = manager + .create_wallet_with_random_mnemonic(WalletAccountCreationOptions::Default) + .expect("create wallet"); + + let key_source = KeySource::Public( + manager + .get_wallet(&wallet_id) + .expect("wallet") + .accounts + .coinjoin_accounts + .get(&0) + .expect("CoinJoin account 0") + .account_xpub, + ); + + let info = manager.get_wallet_info_mut(&wallet_id).expect("wallet info"); + let coinjoin = info.accounts.coinjoin_accounts.get_mut(&0).expect("managed CoinJoin account"); + + // Extend the external (mixed-coin) branch so the pool holds `used` + // historical addresses plus the pre-generated gap window above them. + let addresses = { + let ManagedAccountType::CoinJoin { + external_addresses, + .. + } = coinjoin.managed_account_type_mut() + else { + panic!("expected CoinJoin managed account type"); + }; + external_addresses + .generate_addresses(used, &key_source, true) + .expect("derive CoinJoin addresses"); + external_addresses.all_addresses() + }; + + // The first `used` indices each received one mixing round's payout... + let spent = &addresses[..used as usize]; + for address in spent { + assert!(coinjoin.mark_address_used(address), "address should belong to the pool"); + } + // ...and only the most recent LIVE_UTXOS denominations remain unspent. + for (i, address) in spent.iter().rev().take(LIVE_UTXOS).enumerate() { + let mut txid = [0u8; 32]; + txid[..4].copy_from_slice(&(i as u32).to_le_bytes()); + txid[31] = 0xc1; + let utxo = Utxo::new( + OutPoint::new(Txid::from_byte_array(txid), 0), + TxOut { + value: 100_001, + script_pubkey: address.script_pubkey(), + }, + address.clone(), + 100 + i as u32, + false, + ); + coinjoin.utxos.insert(utxo.outpoint, utxo); + } + + (manager, wallet_id) +} + +/// One scan batch of realistic filters over blocks that do not pay the +/// wallet. Each block's hash differs, so every filter re-keys its SipHashes +/// — the property that forces the per-filter re-hash being measured. +fn scan_batch_filters(count: u32) -> HashMap { + (0..count) + .map(|height| { + let third_party = Address::dummy(Network::Regtest, 1_000_000 + height as usize); + let tx = Transaction::dummy(&third_party, 0..2, &[u64::from(height) + 1, 546]); + let block = Block::dummy(height, vec![tx]); + (FilterMatchKey::new(height, block.block_hash()), BlockFilter::dummy(&block)) + }) + .collect() +} + +fn bench_filter_scan(c: &mut Criterion) { + let filters = scan_batch_filters(FILTERS); + + let mut group = c.benchmark_group("filter_scan"); + group.sample_size(10); + group.throughput(Throughput::Elements(u64::from(FILTERS))); + + for used in USED_ADDRESSES { + let (manager, wallet_id) = wallet_with_mixing_history(used); + let monitored = manager.monitored_script_pubkeys_for(&wallet_id); + let pruned = manager.scan_script_pubkeys_for(&wallet_id); + assert!( + pruned.len() < monitored.len(), + "the scan query must shrink once CoinJoin addresses are spent" + ); + println!( + "used={used}: monitored query = {} scripts, pruned scan query = {} scripts", + monitored.len(), + pruned.len() + ); + + for (name, scripts) in [("monitored", &monitored), ("pruned", &pruned)] { + group.bench_with_input(BenchmarkId::new(name, used), scripts, |b, scripts| { + b.iter(|| { + check_compact_filters_for_elements( + black_box(&filters), + black_box(scripts), + &[], + 0, + ) + }) + }); + } + } + + group.finish(); +} + +criterion_group!(benches, bench_filter_scan); +criterion_main!(benches); diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index b751ada64..28e0f986f 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -230,6 +230,10 @@ impl WalletInterface for WalletM .unwrap_or_default() } + fn scan_script_pubkeys_for(&self, wallet_id: &WalletId) -> Vec { + self.wallet_infos.get(wallet_id).map(|info| info.scan_script_pubkeys()).unwrap_or_default() + } + fn monitored_filter_elements_for(&self, wallet_id: &WalletId) -> Vec> { self.wallet_infos .get(wallet_id) @@ -747,6 +751,34 @@ mod tests { ); } + #[tokio::test] + async fn test_scan_script_pubkeys_for_prunes_spent_coinjoin_addresses() { + use key_wallet::account::ManagedAccountTrait; + + let (mut manager, wallet_id, _addr) = setup_manager_with_wallet(); + + // Untouched wallet: the scan set equals the monitored set. + let monitored = manager.monitored_script_pubkeys_for(&wallet_id); + assert_eq!(manager.scan_script_pubkeys_for(&wallet_id), monitored); + + // Mark a CoinJoin address used with no unspent output — a spent + // single-use address. The scan query drops it; the monitored set + // keeps it. + let info = manager.get_wallet_info_mut(&wallet_id).expect("wallet info"); + let coinjoin = info.accounts.coinjoin_accounts.get_mut(&0).expect("CoinJoin account 0"); + let spent_addr = coinjoin.all_addresses().first().cloned().expect("CoinJoin address"); + assert!(coinjoin.mark_address_used(&spent_addr)); + + let monitored = manager.monitored_script_pubkeys_for(&wallet_id); + let scan = manager.scan_script_pubkeys_for(&wallet_id); + assert!(monitored.contains(&spent_addr.script_pubkey())); + assert!(!scan.contains(&spent_addr.script_pubkey())); + assert_eq!(scan.len(), monitored.len() - 1); + + // Unknown wallet id yields an empty scan set. + assert!(manager.scan_script_pubkeys_for(&[0xff; 32]).is_empty()); + } + #[tokio::test] async fn test_monitor_revision_bumps_and_stability() { let mut manager: WalletManager = WalletManager::new(Network::Testnet); diff --git a/key-wallet-manager/src/test_utils/mock_wallet.rs b/key-wallet-manager/src/test_utils/mock_wallet.rs index c81649436..a559f520c 100644 --- a/key-wallet-manager/src/test_utils/mock_wallet.rs +++ b/key-wallet-manager/src/test_utils/mock_wallet.rs @@ -389,6 +389,11 @@ pub struct MockWalletState { /// enabling tests that exercise per-wallet attribution paths. pub struct MultiMockWallet { wallets: std::collections::BTreeMap, + /// Per-wallet override for `scan_script_pubkeys_for`. Wallets absent here + /// fall back to the monitored set, mirroring the trait default. Lets tests + /// hand the filter scan a pruned query while the monitored set stays full + /// (dashpay/rust-dashcore#948). + scan_addresses: std::collections::BTreeMap>, event_sender: broadcast::Sender, /// Track every block processed for assertions. processed: Arc>>, @@ -405,6 +410,7 @@ impl MultiMockWallet { let (event_sender, _) = broadcast::channel(16); Self { wallets: std::collections::BTreeMap::new(), + scan_addresses: std::collections::BTreeMap::new(), event_sender, processed: Arc::new(Mutex::new(Vec::new())), } @@ -415,6 +421,12 @@ impl MultiMockWallet { self.wallets.insert(wallet_id, state); } + /// Override the scan query for one wallet: `scan_script_pubkeys_for` + /// returns these addresses' scripts instead of the monitored set. + pub fn set_scan_addresses(&mut self, wallet_id: WalletId, addresses: Vec
) { + self.scan_addresses.insert(wallet_id, addresses); + } + /// Mutable access to a wallet's state, panicking if absent. pub fn wallet_mut(&mut self, wallet_id: &WalletId) -> &mut MockWalletState { self.wallets.get_mut(wallet_id).expect("wallet present") @@ -466,6 +478,13 @@ impl WalletInterface for MultiMockWallet { .unwrap_or_default() } + fn scan_script_pubkeys_for(&self, wallet_id: &WalletId) -> Vec { + match self.scan_addresses.get(wallet_id) { + Some(addresses) => addresses.iter().map(|a| a.script_pubkey()).collect(), + None => self.monitored_script_pubkeys_for(wallet_id), + } + } + fn watched_outpoints(&self) -> Vec { Vec::new() } diff --git a/key-wallet-manager/src/wallet_interface.rs b/key-wallet-manager/src/wallet_interface.rs index 3b175d9fe..484fb2c84 100644 --- a/key-wallet-manager/src/wallet_interface.rs +++ b/key-wallet-manager/src/wallet_interface.rs @@ -88,6 +88,21 @@ pub trait WalletInterface: Send + Sync + 'static { /// Get cached scriptPubKeys for every address monitored by `wallet_id`. fn monitored_script_pubkeys_for(&self, wallet_id: &WalletId) -> Vec; + /// Get the scriptPubKeys `wallet_id` wants matched during a forward + /// compact-filter scan. + /// + /// Defaults to [`Self::monitored_script_pubkeys_for`]. Implementations may + /// return a subset when some monitored scripts can no longer be paid in + /// practice — the managed-wallet implementation drops CoinJoin addresses + /// whose outputs are all spent, since those are single-use by protocol and + /// their monotonic growth dominates per-filter matching cost late in a + /// mixing-heavy recovery scan (dashpay/rust-dashcore#948). Block + /// processing still checks transactions against the full monitored set, so + /// pruning only narrows which blocks the filter scan downloads. + fn scan_script_pubkeys_for(&self, wallet_id: &WalletId) -> Vec { + self.monitored_script_pubkeys_for(wallet_id) + } + /// Get the bare `hash160` compact-filter elements monitored by `wallet_id` /// that are not covered by its scriptPubKeys. /// 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 a52816c18..663e24dc6 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -30,7 +30,7 @@ use crate::wallet::balance::WalletCoreBalance; use crate::{ExtendedPubKey, Network}; use dashcore::blockdata::transaction::OutPoint; use dashcore::prelude::CoreBlockHeight; -use dashcore::{Address, Transaction, Txid}; +use dashcore::{Address, ScriptBuf, Transaction, Txid}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -164,6 +164,28 @@ impl ManagedCoreFundsAccount { self.spent_outpoints.contains(outpoint) } + /// Cached scriptPubKeys for every address that could still receive or hold + /// funds under a single-use address discipline: addresses not yet used + /// (the gap-limit lookahead, including reserved ones) plus used addresses + /// that still hold at least one unspent output. + /// + /// A used address whose outputs are all spent is omitted. That is only + /// sound for account types whose addresses are single-use by protocol + /// (CoinJoin — reuse would link mixing rounds), where nothing ever pays a + /// spent-and-emptied address again; callers must not apply this to + /// account types where address reuse is merely discouraged. + pub fn unspent_or_unused_script_pubkeys(&self) -> Vec { + let funded: HashSet<&ScriptBuf> = + self.utxos.values().map(|utxo| &utxo.txout.script_pubkey).collect(); + self.managed_account_type() + .address_pools() + .iter() + .flat_map(|pool| pool.addresses.values()) + .filter(|info| !info.is_used() || funded.contains(&info.script_pubkey)) + .map(|info| info.script_pubkey.clone()) + .collect() + } + /// Add new UTXOs for received outputs, remove spent ones. /// /// Skips any output whose outpoint is already in `observed_spent` — it is diff --git a/key-wallet/src/tests/mod.rs b/key-wallet/src/tests/mod.rs index 8a91ccf52..66e42abde 100644 --- a/key-wallet/src/tests/mod.rs +++ b/key-wallet/src/tests/mod.rs @@ -28,6 +28,8 @@ mod performance_tests; mod provider_key_derivation_tests; +mod scan_script_pubkeys_tests; + mod special_transaction_matching_tests; mod special_transaction_tests; diff --git a/key-wallet/src/tests/scan_script_pubkeys_tests.rs b/key-wallet/src/tests/scan_script_pubkeys_tests.rs new file mode 100644 index 000000000..56351741b --- /dev/null +++ b/key-wallet/src/tests/scan_script_pubkeys_tests.rs @@ -0,0 +1,118 @@ +//! Tests for the forward-scan query pruning of spent single-use (CoinJoin) +//! addresses (dashpay/rust-dashcore#948). +//! +//! `scan_script_pubkeys` must drop CoinJoin addresses that are used and hold +//! no unspent output, while keeping unused (gap-window) CoinJoin addresses, +//! used CoinJoin addresses that still hold a UTXO, and every address of every +//! other account type — used or not. + +use crate::account::ManagedAccountTrait; +use crate::wallet::initialization::WalletAccountCreationOptions; +use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use crate::wallet::{ManagedWalletInfo, Wallet}; +use crate::{Network, Utxo}; +use dashcore::blockdata::transaction::txout::TxOut; +use dashcore::hashes::Hash; +use dashcore::{Address, OutPoint, ScriptBuf, Txid}; + +/// Known test mnemonic for deterministic testing +const TEST_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +fn setup_wallet_info() -> ManagedWalletInfo { + let mnemonic = + crate::mnemonic::Mnemonic::from_phrase(TEST_MNEMONIC, crate::mnemonic::Language::English) + .unwrap(); + let wallet = + Wallet::from_mnemonic(mnemonic, Network::Testnet, WalletAccountCreationOptions::Default) + .unwrap(); + ManagedWalletInfo::from_wallet(&wallet, 0) +} + +fn dummy_utxo_for(address: &Address, salt: u8) -> Utxo { + Utxo::new( + OutPoint::new(Txid::from_byte_array([salt; 32]), 0), + TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }, + address.clone(), + 100, + false, + ) +} + +#[test] +fn test_scan_set_prunes_spent_and_empty_coinjoin_addresses() { + let mut info = setup_wallet_info(); + + let coinjoin = info.accounts.coinjoin_accounts.get_mut(&0).expect("CoinJoin account 0"); + let addresses = coinjoin.all_addresses(); + assert!(addresses.len() >= 2, "CoinJoin pools should pre-generate addresses"); + + // Address 0: used, all outputs spent (no UTXO left) — must be pruned. + let spent_addr = addresses[0].clone(); + // Address 1: used, but still holds an unspent output — must be kept. + let funded_addr = addresses[1].clone(); + + assert!(coinjoin.mark_address_used(&spent_addr)); + assert!(coinjoin.mark_address_used(&funded_addr)); + let utxo = dummy_utxo_for(&funded_addr, 0xaa); + coinjoin.utxos.insert(utxo.outpoint, utxo); + + let monitored = info.monitored_script_pubkeys(); + let scan = info.scan_script_pubkeys(); + + let spent_script = spent_addr.script_pubkey(); + let funded_script = funded_addr.script_pubkey(); + + assert!(monitored.contains(&spent_script), "monitored set keeps the spent address"); + assert!(!scan.contains(&spent_script), "scan set drops the spent-and-empty address"); + assert!(scan.contains(&funded_script), "scan set keeps the address still holding a UTXO"); + + // Exactly one script was pruned; every unused gap-window address stays. + assert_eq!(scan.len(), monitored.len() - 1); +} + +#[test] +fn test_scan_set_keeps_used_and_empty_standard_addresses() { + let mut info = setup_wallet_info(); + + let standard = + info.accounts.standard_bip44_accounts.get_mut(&0).expect("standard BIP44 account 0"); + let addr = standard.all_addresses().first().cloned().expect("pre-generated address"); + // Used with no remaining UTXO: a standard address can always be paid + // again, so the scan set must keep watching it. + assert!(standard.mark_address_used(&addr)); + + let scan = info.scan_script_pubkeys(); + assert!( + scan.contains(&addr.script_pubkey()), + "used-and-empty standard addresses stay in the scan set" + ); + assert_eq!(scan.len(), info.monitored_script_pubkeys().len()); +} + +#[test] +fn test_unspent_or_unused_script_pubkeys_on_funds_account() { + let mut info = setup_wallet_info(); + let coinjoin = info.accounts.coinjoin_accounts.get_mut(&0).expect("CoinJoin account 0"); + + let all: Vec = coinjoin.all_script_pubkeys(); + // Untouched account: nothing is used, so nothing is pruned. + assert_eq!(coinjoin.unspent_or_unused_script_pubkeys().len(), all.len()); + + // Mark one address used without a UTXO: it drops out. + let addr = coinjoin.all_addresses()[0].clone(); + assert!(coinjoin.mark_address_used(&addr)); + let pruned = coinjoin.unspent_or_unused_script_pubkeys(); + assert_eq!(pruned.len(), all.len() - 1); + assert!(!pruned.contains(&addr.script_pubkey())); + + // Give it back an unspent output: it returns to the scan set. + let utxo = dummy_utxo_for(&addr, 0xbb); + coinjoin.utxos.insert(utxo.outpoint, utxo); + let restored = coinjoin.unspent_or_unused_script_pubkeys(); + assert_eq!(restored.len(), all.len()); + assert!(restored.contains(&addr.script_pubkey())); +} 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 db795d200..c2f298d5d 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 @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, BTreeSet}; use super::managed_account_operations::ManagedAccountOperations; use crate::account::{AccountType, ManagedAccountTrait}; use crate::managed_account::managed_account_collection::ManagedAccountCollection; -use crate::managed_account::managed_account_ref::ManagedAccountRefMut; +use crate::managed_account::managed_account_ref::{ManagedAccountRef, ManagedAccountRefMut}; use crate::managed_account::managed_account_type::ManagedAccountType; use crate::managed_account::ManagedCoreFundsAccount; use crate::transaction_checking::TransactionContext; @@ -90,6 +90,20 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// Get cached scriptPubKeys for every monitored address. fn monitored_script_pubkeys(&self) -> Vec; + /// Get the scriptPubKeys worth matching in a forward compact-filter scan. + /// + /// Defaults to [`Self::monitored_script_pubkeys`]. Implementations may + /// return a subset when some monitored scripts can no longer be paid in + /// practice — [`ManagedWalletInfo`] drops CoinJoin addresses that are used + /// and hold no unspent output, since CoinJoin addresses are single-use by + /// protocol (reuse would link mixing rounds) and the query-set growth they + /// cause dominates late-scan filter matching for mixing-heavy wallets + /// (dashpay/rust-dashcore#948). Block processing and gap-limit maintenance + /// keep using the full monitored set; only the filter-scan query shrinks. + fn scan_script_pubkeys(&self) -> Vec { + self.monitored_script_pubkeys() + } + /// Get bare `hash160` filter elements that a compact filter carries in /// addition to scriptPubKeys. /// @@ -386,6 +400,26 @@ impl WalletInfoInterface for ManagedWalletInfo { scripts } + fn scan_script_pubkeys(&self) -> Vec { + let mut scripts = Vec::new(); + for account in self.accounts.all_accounts() { + // Only CoinJoin accounts are pruned: their addresses are + // single-use by protocol, so one that is used and holds no + // unspent output will never be paid again and contributes + // nothing to a forward scan. Every other account type keeps its + // full monitored set — address reuse there is possible even if + // discouraged. + if let ManagedAccountRef::Funds(funds) = account { + if matches!(funds.managed_account_type(), ManagedAccountType::CoinJoin { .. }) { + scripts.extend(funds.unspent_or_unused_script_pubkeys()); + continue; + } + } + scripts.extend(account.all_script_pubkeys()); + } + scripts + } + fn monitored_filter_elements(&self) -> Vec> { let mut elements = Vec::new(); for account in self.accounts.all_accounts() {