Skip to content

fix(key-wallet): promote confirmed spend-only transactions - #998

Merged
xdustinface merged 1 commit into
devfrom
fix/spend-only-confirmed-context
Sep 2, 2026
Merged

fix(key-wallet): promote confirmed spend-only transactions#998
xdustinface merged 1 commit into
devfrom
fix/spend-only-confirmed-context

Conversation

@shumkov

@shumkov shumkov commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

The actual issue

A wallet's own spend-only transaction — a Dash type-8 asset lock (and any tx that
spends wallet UTXOs while paying no wallet-owned output, e.g. a drain with no change)
— is recorded from its mempool sighting but never advances to InBlock, forever.
Downstream (dashpay/platform mobile wallets) every "spent" signal keys on in-block
context, so the consumed outputs keep reading as unspent: balances over-report and
re-inflate on every app relaunch, and the stale outputs are handed back as spendable
(field reports in dashpay/platform#4336).

Root cause — the transaction is starved of relevance by its own mempool sighting
(reproduced at the platform-pinned rev 4db5c36 with a real DIP-2 type-8
AssetLockPayload driven through the production path):

  1. The mempool delivery creates the record and removes the live UTXO it spends.
  2. The containing block arrives — the compact filter matches fine: Dash Core BASIC
    filters include every spent coin's scriptPubKey (dashpay/dash
    src/blockfilter.cpp, BasicFilterElements; also verified empirically against a live
    testnet filter). The filter/scan layer is NOT the problem.
  3. Block-time relevance is recomputed from output scripts + current live UTXOs
    (key-wallet/src/transaction_checking/wallet_checker.rs:54-61 ->
    account_checker.rs:645-672). Outputs pay nothing owned, and the UTXO is already
    gone — deleted in step 1. Fresh relevance is false, and processing early-returns
    before ever looking up the existing txid record.

Relevance is true exactly once, and the first sighting consumes it — hence
mempool-yes / block-no on every affected transaction.

Summary

Own spend-only transactions now advance from mempool to block or chainlocked context even after the mempool sighting consumed their only live UTXO evidence. The fallback trusts only an existing txid record in router-selected accounts, so compact-filter false positives remain irrelevant and the normal new-transaction path stays unchanged.

This prevents new downstream wallet rows from remaining in Mempool forever. Rows already stuck downstream still require the separate platform healing path.

Design

  • Promote only an unconfirmed record that already exists for the exact txid in a routed account.
  • Reuse the existing monotonic context and finalized-record retention rules.
  • Emit the normal existing_txids and BlockProcessed.updated signals once; confirmed replays are idempotent.
  • Run key-wallet-manager CI with both all features and default features so both retention branches execute.

Validation

  • TDD regression: test_block_promotes_known_mempool_asset_lock_spending_wallet_utxo failed before the engine change (existing_txids was empty and the record stayed Mempool) and passes after it.
  • cargo test -p key-wallet-manager --all-features --quiet - pass.
  • cargo test -p key-wallet-manager --quiet - pass.
  • cargo test -p key-wallet --all-features --quiet - pass.
  • cargo clippy -p key-wallet -p key-wallet-manager -p dash-spv --all-features --all-targets -- -D warnings - pass.
  • cargo fmt --all --check and git diff --check - pass.
  • CI command-matrix mock - pass: non-coverage jobs run key-wallet-manager with all features and then default features; coverage remains all-features-only.
  • cargo test -p dash-spv --all-features - Rust unit suites passed (570 + 7 tests; 2 project-marked ignored). The 10 real-dashd integration cases could not start locally because macOS killed the downloaded unsigned dashd binary with exit 137 before startup.

Post-Deploy Monitoring & Validation

  • Healthy: a known mempool spend-only txid appears once in existing_txids and BlockProcessed.updated when its block is processed, with InBlock or InChainLockedBlock context.
  • Failure signals: the stored context remains Mempool, a confirmed replay emits duplicate updates, or an unknown irrelevant transaction is promoted.
  • Validation window: rust-dashcore maintainers and downstream platform integrators should check the first dev integration run and the first affected wallet replay.
  • Rollback: revert this commit. That restores the old behavior but does not alter rows already healed downstream.

Related

Related: dashpay/platform#4336

Related: #948 (separate CoinJoin scan-query pruning path; out of scope)


Compound Engineering

Summary by CodeRabbit

  • Bug Fixes

    • Improved wallet transaction state updates when confirmed transactions were previously recorded as unconfirmed or irrelevant.
    • Ensured stronger confirmed or chainlocked states are preserved when transactions are replayed.
    • Improved handling of spend-only asset-lock transactions as they move from the mempool into blocks.
    • Prevented irrelevant transactions from creating unnecessary wallet records or events.
  • Tests

    • Added coverage for transaction promotion, state preservation, idempotency, and irrelevant transaction handling.

When mempool processing consumes a wallet UTXO, a later block replay can no longer rediscover a spend-only transaction from live relevance evidence. Promote the existing routed record by txid, preserve monotonic finality and retention behavior, and emit the normal update path.

Run key-wallet-manager in both all-features and default-feature CI configurations so both finalized-record retention branches execute.

Test would have caught this in CI: FAIL before fix, PASS after fix.
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.42857% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 77.17%. Comparing base (e871c02) to head (da1fdea).

Files with missing lines Patch % Lines
...-wallet/src/transaction_checking/wallet_checker.rs 96.42% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #998      +/-   ##
==========================================
+ Coverage   77.12%   77.17%   +0.04%     
==========================================
  Files         329      329              
  Lines       83483    83511      +28     
==========================================
+ Hits        64388    64448      +60     
+ Misses      19095    19063      -32     
Flag Coverage Δ
core 78.25% <ø> (ø)
ffi 51.43% <ø> (+0.28%) ⬆️
rpc 20.00% <ø> (ø)
spv 92.06% <ø> (-0.02%) ⬇️
wallet 79.62% <96.42%> (+0.04%) ⬆️
Files with missing lines Coverage Δ
...-wallet/src/transaction_checking/wallet_checker.rs 99.46% <96.42%> (-0.04%) ⬇️

... and 18 files with indirect coverage changes

@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 9839358e-d862-458b-846c-ff91cc58dc8c

📥 Commits

Reviewing files that changed from the base of the PR and between e871c02 and da1fdea.

📒 Files selected for processing (3)
  • .github/scripts/ci_config.py
  • key-wallet-manager/src/event_tests.rs
  • key-wallet/src/transaction_checking/wallet_checker.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The wallet checker now promotes stored mempool records when confirmed contexts arrive for irrelevant transactions. New tests cover AssetLock block promotion, chainlocks, replay protection, irrelevant transactions, and idempotence. CI adds a feature-disabled test run for key-wallet-manager.

Changes

Spend-only transaction promotion

Layer / File(s) Summary
Stored context promotion
key-wallet/src/transaction_checking/wallet_checker.rs
check_core_transaction promotes matching unconfirmed records for confirmed contexts. The helper filters account types, preserves confirmed records, handles finalized records, and updates result state.
Block promotion validation
key-wallet-manager/src/event_tests.rs, .github/scripts/ci_config.py
Tests cover AssetLock promotion, chainlocked contexts, weaker replays, unknown transactions, and repeated blocks. CI reruns key-wallet-manager without --all-features when coverage is disabled.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🔵 Low · up to da1fd

This change promotes known spend-only transactions from Mempool to confirmed context, preventing affected wallet rows from remaining stuck. It is narrowly scoped and tested, but maintainers should ensure callers authenticate the supplied block or chainlock context before relying on this promotion path.

Suggested reviewers: hashengineering, quantumexplorer, xdustinface

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: promoting confirmed spend-only transactions in key-wallet.
Docstring Coverage ✅ Passed Docstring coverage is 81.82% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 3 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/spend-only-confirmed-context

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@shumkov

shumkov commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Adversarial cross-model review (Codex gpt-5.6, xhigh; ground truth = the diagnostic report that proved the defect) — no merge-blocking findings. Verified at file:line:

  • Contract: the fallback runs only after fresh relevance is false and only for InBlock/InChainLockedBlock (wallet_checker.rs:116-123,163-175); searches router-selected accounts only; sole mutation target is transactions_mut().get_mut(txid) — a compact-filter match alone can promote nothing.
  • Safety: no demotion path (already-confirmed and finalized records rejected before the setter, wallet_checker.rs:67-78); finalized-record retention respected incl. the chainlock-pruning branch; no insertion path, so pruned/finalized records cannot resurrect.
  • Cross-wallet / false-positive discipline: promotion requires the exact txid pre-existing in that wallet's own routed accounts; wallets are checked in isolation (lib.rs:599-617) — one wallet's block cannot touch another's records. The unknown-txid negative test pins this (event_tests.rs:709-733).
  • Idempotent on confirmed replays; event emission consistent with existing paths; per-block fallback cost bounded (exact-txid lookups on routed accounts only).

The red→green diagnostic regression matches the field defect exactly (record stuck at Mempool, no BlockProcessed.updated). LGTM from this side; human approval still needed since the author cannot self-approve.

@HashEngineering HashEngineering left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — the diagnosis matches what we've measured downstream, and the fix is scoped tightly enough that I don't see a false-positive risk.

The relevance-starvation mechanism is right, and the decision to gate promotion on an existing unconfirmed record for the exact txid in a routed account is the part that makes this safe: compact-filter false positives can't manufacture a record, so the promotion path can only ever fire on transactions the wallet already recorded itself. The existing_txids / BlockProcessed.updated idempotency on confirmed replay is the right call too — downstream persistence upserts on those signals, so a duplicate emission would have been visible as churn.

The three transaction shapes we care about downstream, all of which are "spends wallet UTXOs, pays no wallet-owned output":

  1. Asset-lock (type 8) credit top-ups. The case this PR reproduces. Downstream symptom is a balance that re-inflates on every app relaunch, because the consumed output is handed back as spendable — dashpay/platform#4336 documents a 0.1 DASH wallet reading 0.21.

  2. CoinJoin collateral / fee transactions with an OP_RETURN-only output. Zero value out, nothing owned on the output side. On a large testnet CoinJoin wallet (~7,320 transactions) we have four of these — e.g. f2fe8ad5aad30d6d0859a55db01b2c9240ee028eaff792516051f32c795377b7 at height 1153176 — whose input coins remain unspent, overstating the balance by 0.00048773 on every launch.

  3. Ordinary sends with no change back to us — a drain, or an exact-amount payment. Same shape, no CoinJoin or platform involvement required. We haven't isolated one on this wallet, but nothing about the mechanism excludes it, and it's the case most likely to show up on a user wallet with a small UTXO set.

Where this PR lands for each. It fixes all three on the live path, where a mempool sighting created the record first. It does not reach them when the wallet meets the transaction through a restore or rescan — there was never a mempool sighting, so there is no record to promote and the transaction is dropped at the relevance gate without being recorded at all. Our four burns are exactly that: mined 2019 at heights 1153140–1194323, first seen by a restored wallet. Filed separately as #992 so it isn't confused with this PR's scope, and the PR body's "rows already stuck downstream still require the separate platform healing path" reads consistently with that.

One thing worth noting for whoever picks up the follow-up: platform#4336 is currently load-bearing for shape 1 only, because every repair it makes keys on the tracked asset lock's own status (InstantSendLocked / Consumed). Shapes 2 and 3 have no equivalent signal, so platform can't generalize its side — which is an argument for widening the engine-side fallback from "an existing record for this txid" to spent-coin history (#897 Half 1) rather than adding a second platform special case per transaction shape. Not a blocker for this PR.

@xdustinface
xdustinface merged commit 93260bf into dev Sep 2, 2026
40 checks passed
@xdustinface
xdustinface deleted the fix/spend-only-confirmed-context branch September 2, 2026 00:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-review CodeRabbit has approved this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants