Skip to content

fix(spv): discover masternode special txs matched only by payload keys (owner/voting) during compact-filter sync #174

Description

@xdustinface

Summary

SPV historical sync cannot discover masternode special transactions (ProRegTx and the ProUp* updates) that are relevant to the wallet only through their payload keys, not through any normal input or output. A masternode owner who controls just the owner/voting keys — with a third party paying the fee and collateral — will never see their ProRegTx after a compact-filter sync, even though the wallet already knows how to match it.

The payload-matching logic itself is complete. The gap is narrow: it lives in the compact-filter query construction, not in the wallet checker, and it does not require downloading extra blocks.

Background: how Dash Core handles this

Dash Core matches special-transaction payload elements in both of its filter builders, and keeps them in sync:

  • Bloom (BIP37): CBloomFilter::CheckSpecialTransactionMatchesAndUpdatesrc/common/bloom.cpp:127
  • Compact (BIP157/158): ExtractSpecialTxFilterElementssrc/evo/specialtx_filter.cpp:55, wired into the basic block filter at src/blockfilter.cpp:201

For a ProRegTx both insert: collateralOutpoint, keyIDOwner as bare 20-byte hash160, keyIDVoting as bare 20-byte hash160, and scriptPayout. The BLS pubKeyOperator is deliberately not inserted by either.

The consequence that matters: every Dash Core node already publishes compact filters that contain the owner/voting key hashes. A compact-filter light client only has to query for them.

Note the wallet in Dash Core does not do this — CWallet::IsMine/IsFromMe (src/wallet/wallet.cpp:1596,1618) ignore payloads entirely; "my masternodes" is answered separately by protx list wallet (src/rpc/evo.cpp:1452) walking the deterministic masternode list. Payload matching is a filter-serving feature, and the compact-filter query side is the correct place to mirror it here.

Current state in rust-dashcore

Wallet-side matching already works. key-wallet has first-class provider accounts (owner/voting/operator/platform) and inspects payloads directly:

  • key-wallet/src/transaction_checking/account_checker.rs:940 matches owner_key_hash, :895 matches voting_key_hash, :980 matches the BLS operator_public_key, plus payout-script matching.
  • Any downloaded block runs the full payload-aware scan (key-wallet-manager/src/process_block.rs:62 -> wallet_checker.rs:57).

So if the transaction reaches the wallet, it is matched. The problem is that a payload-only tx never reaches it during historical sync.

The compact-filter query is scriptPubKey-only. key-wallet-manager/src/matching.rs:39:

let query: FilterQuery = script_pubkeys.iter().map(|s| s.as_bytes()).collect();

fed from wallet.monitored_script_pubkeys_for(wallet_id) (dash-spv/src/sync/filters/manager.rs:748).

The mismatch: the peer inserted keyIDOwner as a bare 20-byte hash, but we test the filter with the full P2PKH script (OP_DUP OP_HASH160 <hash> OP_EQUALVERIFY OP_CHECKSIG). Different byte strings -> no match -> the block is never selected -> never downloaded -> the payload-aware scan never runs on it.

(FilterQuery already supports arbitrary-length elements via its other bucket — dash/src/bip158.rs:377 — so a bare hash160 slots in with no type changes.)

Payout scripts are the exception: they are already wallet scripts in monitored_script_pubkeys, so payout-to-a-wallet-address already matches today. Only the bare key hashes (and, for updates, proTxHash) are missing from the query.

Not a structural BIP158 limitation

Worth stating explicitly to pre-empt the wrong fix: this is not a case for "download all blocks in the masternode range." Our own dash/src/bip158.rs filter builder (add_output_scripts/add_input_scripts) is irrelevant here — during sync we match against filters served by Dash Core peers, and those already carry the payload key hashes (blockfilter.cpp:201). We are simply not querying for them.

Proposed fix

Mirror Dash Core's ExtractSpecialTxFilterElements on the query side.

Phase 1 — close the ProRegTx discovery gap (the core ask):

  1. Add a wallet-interface method that returns the bare payload elements the wallet wants to match, e.g. monitored_filter_elements_for(wallet_id) -> Vec<Vec<u8>>, sourced from the provider owner/voting accounts: each owner_key_hash and voting_key_hash as raw 20-byte hash160.
  2. Extend check_compact_filters_for_script_pubkeys (or add a sibling) to accept these extra raw elements and fold them into the same FilterQuery alongside the scripts:
pub fn check_compact_filters_for_elements(
    input: &HashMap<FilterMatchKey, BlockFilter>,
    script_pubkeys: &[ScriptBuf],
    extra_elements: &[Vec<u8>],   // bare hash160 owner/voting keys, serialized outpoints, proTxHashes
    min_height: CoreBlockHeight,
) -> BTreeSet<FilterMatchKey> {
    let query: FilterQuery = script_pubkeys
        .iter()
        .map(|s| s.as_bytes())
        .chain(extra_elements.iter().map(|e| e.as_slice()))
        .collect();
    // ... unchanged match loop ...
}
  1. Pass the new elements at both call sites in dash-spv/src/sync/filters/manager.rs:663,801.

This alone lets a payload-only ProRegTx be selected and downloaded, after which the existing account_checker matching does the rest.

Phase 2 — updates and operator-only masternodes (follow-up):

  • To catch ProUpRegTx/ProUpServTx/ProUpRevTx, feed each tracked masternode's proTxHash into extra_elements. Those hashes are learned after discovering the ProRegTx, or from the already-synced masternode list (dash-spv/src/sync/masternodes/).
  • The BLS pubKeyOperator is not in Dash Core's filters at all, so a masternode you only operate (never own) cannot be found via compact filters. Cross-reference the synced masternode list (SML) against the wallet's operator BLS keys to learn those proTxHashes / txids directly. This is also the more robust general discovery path.

Not affected

  • Mempool discovery already works: the BIP37 bloom build (dash-spv/src/sync/mempool/filter.rs:32) already inserts owner/voting hashes as addresses. Only historical/confirmed-block sync has the hole.
  • No consensus or wallet-checker change is needed. The payload matching in account_checker.rs is already correct and reused as-is.

Acceptance

  • A wallet holding only the owner (or voting) key of a ProRegTx — with no wallet-owned input or output in that tx — discovers and records the ProRegTx after a compact-filter historical sync.
  • Regression test: a compact filter built by the Dash Core rules (owner/voting hash as bare elements) is matched by the new query; the old scriptPubKey-only query does not match it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions