From 7fa9851f28c4a49feaaf2f65b0ae694d1c520f3a Mon Sep 17 00:00:00 2001 From: xdustinface Date: Fri, 10 Jul 2026 22:11:45 +1000 Subject: [PATCH] fix(spv): match masternode collateral outpoints in compact filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dash Core inserts a `ProRegTx`'s `collateralOutpoint` into the block's BIP158 compact filter serialized the consensus way — 32-byte txid followed by the 4-byte little-endian vout, 36 bytes total (see `ExtractSpecialTxFilterElements`). A wallet that owns the 1000-DASH collateral output but holds none of the masternode's owner/voting keys — for example a third party registered the masternode against the wallet's collateral — never matches that `ProRegTx` on the scriptPubKey path, so the block is skipped during compact-filter sync. This extends `ManagedWalletInfo::monitored_filter_elements` to append each watched UTXO's consensus-serialized outpoint after the existing owner/voting hashes. The matcher and manager already fold `monitored_filter_elements` into the shared `FilterQuery`, so no change is needed there. The outpoint elements are gated on the wallet holding provider accounts (`ManagedAccountCollection::has_provider_accounts`). The collateral element only helps a masternode owner — it discovers a `ProRegTx` that references one of the wallet's UTXOs as collateral — and the common paths are already covered without it (an owner-created `ProRegTx` is matched by its fee input's script, a key-holding owner by the owner/voting hashes). A funding-only wallet can never hit that case, so it skips these elements rather than pay the per-UTXO false-positive cost on every compact-filter query. Within a provider wallet every UTXO is watched, to avoid hardcoding network-specific collateral amounts. Step 2 of #861: collateral outpoint. `proTxHash` follows in the next stacked branch. --- key-wallet-manager/src/matching.rs | 47 +++++++++++- .../managed_account_collection.rs | 12 +++ key-wallet/src/test_utils/wallet.rs | 10 ++- .../wallet_info_interface.rs | 76 +++++++++++++++++++ 4 files changed, 142 insertions(+), 3 deletions(-) diff --git a/key-wallet-manager/src/matching.rs b/key-wallet-manager/src/matching.rs index 18c8bc19c..68502ad0e 100644 --- a/key-wallet-manager/src/matching.rs +++ b/key-wallet-manager/src/matching.rs @@ -82,7 +82,7 @@ mod tests { use super::*; use dashcore::address::Payload; use dashcore::bip158::BlockFilterWriter; - use dashcore::{Address, Block, Transaction}; + use dashcore::{Address, Block, OutPoint, Transaction, Txid}; use key_wallet::Network; fn scripts_for(addresses: &[Address]) -> Vec { @@ -256,4 +256,49 @@ mod tests { let with_element = check_compact_filters_for_elements(&input, &[], &[owner_hash], 0); assert!(with_element.contains(&key), "bare-element query must match"); } + + /// A wallet that owns a masternode's 1000-DASH collateral output but not + /// its owner/voting keys sees a compact filter that carries the + /// `ProRegTx`'s `collateralOutpoint` as a bare 36-byte consensus-serialized + /// element (the way Dash Core's `ExtractSpecialTxFilterElements` inserts + /// it), not as one of the block's scriptPubKeys. The scripts-only query + /// must miss it and the query carrying the serialized outpoint must hit it. + #[test] + fn test_collateral_outpoint_requires_extra_element() { + // The collateral outpoint a peer inserts as a bare element. + let outpoint = OutPoint::new(Txid::from([7u8; 32]), 1); + let serialized = dashcore::consensus::encode::serialize(&outpoint); + assert_eq!(serialized.len(), 36, "outpoint serializes to txid ++ le-vout"); + + // An unrelated output so the filter is realistic. + let unrelated = Address::dummy(Network::Regtest, 99); + let tx = Transaction::dummy(&unrelated, 0..0, &[1]); + let block = Block::dummy(100, vec![tx]); + + // Build the filter like a Dash Core peer: block output scripts plus the + // collateral outpoint as a bare element. + let mut content = Vec::new(); + { + let mut writer = BlockFilterWriter::new(&mut content, &block); + writer.add_output_scripts(); + writer.add_element(&serialized); + writer.finish().expect("finish filter"); + } + let filter = BlockFilter::new(&content); + let key = FilterMatchKey::new(100, block.block_hash()); + + let mut input = HashMap::new(); + input.insert(key.clone(), filter); + + // A wallet watching only its own addresses (none of them in this block) + // does not carry the bare 36-byte outpoint element and misses. + let watched = Address::dummy(Network::Regtest, 7); + let scripts_only = + check_compact_filters_for_elements(&input, &scripts_for(&[watched]), &[], 0); + assert!(!scripts_only.contains(&key), "scripts-only query must miss the outpoint"); + + // Carrying the serialized outpoint in `extra_elements` matches. + let with_element = check_compact_filters_for_elements(&input, &[], &[serialized], 0); + assert!(with_element.contains(&key), "serialized-outpoint query must match"); + } } diff --git a/key-wallet/src/managed_account/managed_account_collection.rs b/key-wallet/src/managed_account/managed_account_collection.rs index b79107c31..4cf5f459f 100644 --- a/key-wallet/src/managed_account/managed_account_collection.rs +++ b/key-wallet/src/managed_account/managed_account_collection.rs @@ -96,6 +96,18 @@ impl ManagedAccountCollection { } /// Check if a managed account type exists in the collection + /// Whether the wallet holds any masternode provider account (owner, + /// voting, operator, or platform keys). + /// + /// A wallet with none of these can never own or operate a masternode, so it + /// has no reason to watch for masternode special transactions. + pub(crate) fn has_provider_accounts(&self) -> bool { + self.provider_owner_keys.is_some() + || self.provider_voting_keys.is_some() + || self.provider_operator_keys.is_some() + || self.provider_platform_keys.is_some() + } + pub fn contains_managed_account_type(&self, managed_type: &ManagedAccountType) -> bool { use crate::account::StandardAccountType; diff --git a/key-wallet/src/test_utils/wallet.rs b/key-wallet/src/test_utils/wallet.rs index 2a4756dd9..7f7859a5a 100644 --- a/key-wallet/src/test_utils/wallet.rs +++ b/key-wallet/src/test_utils/wallet.rs @@ -29,8 +29,14 @@ impl TestWalletContext { /// Creates a new random testnet wallet with a BIP44 account and one /// pre-derived receive address. pub fn new_random() -> Self { - let wallet = Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default) - .expect("Should create wallet"); + Self::new_random_with_options(WalletAccountCreationOptions::Default) + } + + /// Like [`new_random`](Self::new_random) but with explicit account-creation + /// options, so a test can build e.g. a funding-only wallet with no provider + /// accounts. + pub fn new_random_with_options(options: WalletAccountCreationOptions) -> Self { + let wallet = Wallet::new_random(Network::Testnet, options).expect("Should create wallet"); let mut managed_wallet = ManagedWalletInfo::from_wallet_with_name(&wallet, "Test".to_string(), 0); 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 6e0b931c0..ca7aca184 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 @@ -374,6 +374,31 @@ impl WalletInfoInterface for ManagedWalletInfo { })); } } + // Dash Core inserts a `ProRegTx`'s `collateralOutpoint` into the block's + // compact filter serialized the consensus way — 32-byte txid followed by + // the 4-byte little-endian vout, 36 bytes total (see + // `ExtractSpecialTxFilterElements`). A wallet that owns the collateral + // output but not the masternode's owner/voting keys would otherwise never + // match that `ProRegTx` on the scriptPubKey path, so watch each UTXO's + // outpoint directly. + // + // This is gated on the wallet holding provider accounts because the + // element is only ever useful to a masternode owner: it discovers a + // `ProRegTx` that *references* one of the wallet's UTXOs as collateral. + // The common paths are already covered without it — an owner-created + // `ProRegTx` is matched by its fee input's script, and a key-holding + // owner by the owner/voting hashes above — so the collateral element + // only adds the narrow case where the wallet owns the collateral but + // holds neither the keys nor the funding inputs. A funding-only wallet + // can never hit that case, so it skips these elements rather than pay + // the per-UTXO false-positive cost on every compact-filter query. Within + // a provider wallet every UTXO is watched (not just collateral-sized + // ones) to avoid hardcoding network-specific collateral amounts. + if self.accounts.has_provider_accounts() { + for utxo in self.utxos() { + elements.push(dashcore::consensus::encode::serialize(&utxo.outpoint)); + } + } elements } @@ -505,3 +530,54 @@ impl WalletInfoInterface for ManagedWalletInfo { self.accounts.all_accounts().iter().map(|a| a.monitor_revision()).sum() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::TestWalletContext; + use crate::wallet::initialization::WalletAccountCreationOptions; + + /// A wallet that owns a UTXO must surface that UTXO's outpoint as a bare + /// filter element, consensus-serialized to the 36-byte form Dash Core + /// inserts for a `ProRegTx`'s `collateralOutpoint`, so a compact-filter + /// scan matches a masternode registration against the wallet's collateral + /// even when the wallet holds none of the masternode's keys. + #[tokio::test] + async fn test_watched_utxo_outpoint_is_filter_element() { + let (ctx, _tx) = TestWalletContext::new_random().with_mempool_funding(200_000).await; + let outpoint = ctx.first_utxo().outpoint; + let serialized = dashcore::consensus::encode::serialize(&outpoint); + assert_eq!(serialized.len(), 36, "outpoint serializes to txid ++ le-vout"); + + let elements = ctx.managed_wallet.monitored_filter_elements(); + assert!( + elements.contains(&serialized), + "monitored_filter_elements must carry the watched UTXO's serialized outpoint" + ); + } + + /// A funding-only wallet (no provider accounts) must not surface its UTXO + /// outpoints as filter elements. The collateral outpoint only helps a + /// masternode owner discover a `ProRegTx` referencing its collateral, so a + /// plain payment wallet skips that per-UTXO cost entirely. + #[tokio::test] + async fn test_funding_only_wallet_omits_collateral_outpoints() { + let (ctx, _tx) = TestWalletContext::new_random_with_options( + WalletAccountCreationOptions::BIP44AccountsOnly([0].into()), + ) + .with_mempool_funding(200_000) + .await; + + // The wallet is funded, so it owns a UTXO... + let serialized = dashcore::consensus::encode::serialize(&ctx.first_utxo().outpoint); + assert_eq!(serialized.len(), 36); + + // ...but with no provider accounts it emits no filter elements at all, + // in particular none of its UTXO outpoints. + let elements = ctx.managed_wallet.monitored_filter_elements(); + assert!( + elements.is_empty(), + "funding-only wallet must not emit collateral outpoint elements" + ); + } +}