Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion dash-spv/src/sync/filters/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -860,7 +860,12 @@ impl<H: BlockHeaderStorage, FH: FilterHeaderStorage, F: FilterStorage, W: Wallet
let mut wallet_states: Vec<WalletScanState> = 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);
Expand Down Expand Up @@ -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<FilterMatchKey, BlockFilter> = 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`.
Expand Down
32 changes: 32 additions & 0 deletions key-wallet-manager/src/process_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ impl<T: WalletInfoInterface + Send + Sync + 'static> WalletInterface for WalletM
.unwrap_or_default()
}

fn scan_script_pubkeys_for(&self, wallet_id: &WalletId) -> Vec<ScriptBuf> {
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<Vec<u8>> {
self.wallet_infos
.get(wallet_id)
Expand Down Expand Up @@ -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<ManagedWalletInfo> = WalletManager::new(Network::Testnet);
Expand Down
19 changes: 19 additions & 0 deletions key-wallet-manager/src/test_utils/mock_wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,11 @@ pub struct MockWalletState {
/// enabling tests that exercise per-wallet attribution paths.
pub struct MultiMockWallet {
wallets: std::collections::BTreeMap<WalletId, MockWalletState>,
/// 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<WalletId, Vec<Address>>,
event_sender: broadcast::Sender<WalletEvent>,
/// Track every block processed for assertions.
processed: Arc<Mutex<Vec<(WalletId, dashcore::BlockHash, u32)>>>,
Expand All @@ -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())),
}
Expand All @@ -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<Address>) {
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")
Expand Down Expand Up @@ -466,6 +478,13 @@ impl WalletInterface for MultiMockWallet {
.unwrap_or_default()
}

fn scan_script_pubkeys_for(&self, wallet_id: &WalletId) -> Vec<ScriptBuf> {
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<OutPoint> {
Vec::new()
}
Expand Down
15 changes: 15 additions & 0 deletions key-wallet-manager/src/wallet_interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScriptBuf>;

/// 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<ScriptBuf> {
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.
///
Expand Down
24 changes: 23 additions & 1 deletion key-wallet/src/managed_account/managed_core_funds_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ScriptBuf> {
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
Expand Down
2 changes: 2 additions & 0 deletions key-wallet/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
118 changes: 118 additions & 0 deletions key-wallet/src/tests/scan_script_pubkeys_tests.rs
Original file line number Diff line number Diff line change
@@ -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<ScriptBuf> = 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()));
}
Loading
Loading