Skip to content

bug: out-of-order block processing causes SPV wallet to miss UTXO spends #649

Description

@lklimek

Summary

When the SPV block processing pipeline processes blocks out of height order, UTXO spends are silently missed. If a UTXO is created in block H1 and spent in block H2 (H2 > H1), but H2 is processed before H1, the wallet never removes the spent UTXO — it appears as permanently spendable.

Impact

  • Stale UTXOs: The wallet retains UTXOs that were already spent on-chain, showing an inflated balance.
  • Failed broadcasts: Transactions built from stale UTXOs are silently rejected by peers as double-spends. The wallet reports "broadcast successfully" but the tx never enters mempools or gets mined.
  • Cascading failures: Any flow that depends on spendable balance after a payment hangs indefinitely.

Root Cause

Compact block filter matching (check_compact_filters_for_addresses in key-wallet-manager/src/matching.rs) correctly identifies blocks containing wallet-relevant transactions. However, blocks are downloaded and processed in non-deterministic order — the download pipeline doesn't enforce ascending height ordering.

Observed in trace logs

Block 1458841 creates a UTXO for address yYTQPB4A... (received 2.39 DASH):

10:30:50.117596 New wallet transaction detected txid=706a9619... context=block 1458841
                net_change=-30000226 received=239274892 sent=269275118

Block 1458904 spends that UTXO (sends to external address yV8Bj8Td...):

10:30:50.069353 New wallet transaction detected txid=c8f671e5... context=block 1458904
                net_change=0 received=0 sent=0

Block 1458904 was processed 48ms BEFORE block 1458841. When the spending tx was checked against the wallet, the UTXO didn't exist yet (sent=0). The creating block was processed later and added the UTXO, but the spend was already missed.

Secondary concern: filter matching completeness

The compact filter matching function only queries GCS filters with output scriptPubKeys. Per BIP158, the basic filter also contains prevout scriptPubKeys for non-coinbase inputs. If Dash Core follows this (which it appears to, since the spending block was downloaded), the filter matching works. However, check_compact_filters_for_addresses should ideally also include watched_outpoints() serialized as bytes for defense-in-depth, similar to how the mempool bloom filter (dash-spv/src/sync/mempool/filter.rs) already does.

Steps to Reproduce

  1. Create a wallet and receive funds (UTXO created at height H1)
  2. Spend the UTXO from a different environment — confirmed at height H2 > H1
  3. Restart the SPV wallet and let it sync past H2
  4. Both H1 and H2 match the compact filter (our address appears in both blocks)
  5. If H2 is downloaded/processed before H1, the spend is missed
  6. The wallet still shows the UTXO as spendable

Verified on Dash testnet:

  • UTXO 706a9619...22bd6a45:1 — created at block 1458841
  • Spent by c8f671e5...1225c5bb — confirmed at block 1458904 (528 confirmations)
  • SPV processed block 1458904 at 10:30:50.069 and block 1458841 at 10:30:50.117 — 48ms out of order

Proposed Fix

Option A (block pipeline): Enforce ascending height order in the block download/processing pipeline. Blocks should be processed strictly in height order so that UTXOs are always created before they can be spent.

Option B (wallet checker): After processing all matched blocks in a batch, do a second pass to reconcile: for any UTXO that was created, check if a later block in the same batch spent it. The spent_outpoints tracking in ManagedAccount already handles some out-of-order cases (it skips UTXO creation if the outpoint is in spent_outpoints), but this relies on the spending tx being processed first, which sets sent=0 when the UTXO doesn't exist yet.

Option C (retroactive detection): When a creating transaction is processed and adds a UTXO, check if any already-processed transaction in spent_outpoints references this outpoint. If so, immediately remove the UTXO.

Option A is the cleanest but may have performance implications. Option C is the most targeted fix.

🤖 Co-authored by Claudius the Magnificent AI Agent

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions