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
- Create a wallet and receive funds (UTXO created at height H1)
- Spend the UTXO from a different environment — confirmed at height H2 > H1
- Restart the SPV wallet and let it sync past H2
- Both H1 and H2 match the compact filter (our address appears in both blocks)
- If H2 is downloaded/processed before H1, the spend is missed
- 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
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
Root Cause
Compact block filter matching (
check_compact_filters_for_addressesinkey-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):Block 1458904 spends that UTXO (sends to external address
yV8Bj8Td...):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_addressesshould ideally also includewatched_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
Verified on Dash testnet:
706a9619...22bd6a45:1— created at block 1458841c8f671e5...1225c5bb— confirmed at block 1458904 (528 confirmations)10:30:50.069and block 1458841 at10:30:50.117— 48ms out of orderProposed 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_outpointstracking inManagedAccountalready handles some out-of-order cases (it skips UTXO creation if the outpoint is inspent_outpoints), but this relies on the spending tx being processed first, which setssent=0when 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_outpointsreferences 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