Skip to content

fix(wallet): exclude unconfirmable outputs from CoinJoin accounting - #7634

Open
UdjinM6 wants to merge 4 commits into
dashpay:developfrom
UdjinM6:coinjoin-utxo-liveness
Open

fix(wallet): exclude unconfirmable outputs from CoinJoin accounting#7634
UdjinM6 wants to merge 4 commits into
dashpay:developfrom
UdjinM6:coinjoin-utxo-liveness

Conversation

@UdjinM6

@UdjinM6 UdjinM6 commented Aug 22, 2026

Copy link
Copy Markdown

Issue being fixed or feature implemented

setWalletUTXO holds every unspent output the wallet owns, including outputs of transactions that cannot confirm as they stand: conflicted ones, and ones that were abandoned, never broadcast, or rejected from the mempool. AvailableCoins() filters those out — it skips depth < 0, and depth == 0 && !wtx.InMempool() — but the CoinJoin functions that read setWalletUTXO directly checked at most depth < 0, and GetAverageAnonymizedRounds() checked nothing at all. Outputs that will never exist were therefore counted as spendable wallet funds.

The consequences are all in CoinJoin:

  • CountInputsWithAmount() is what CCoinJoinClientSession::CreateDenominated() uses to cap how many outputs of each denomination to create. Counting phantom denominations makes the wallet create fewer real ones than the user asked for.
  • GetAverageAnonymizedRounds() and GetNormalizedAnonymizedBalance() feed the mixing figures shown on the Qt overview page, so the reported progress does not match the coins the wallet can actually use.
  • SelectCoinsGroupedByAddresses() tallies candidates for mixing from the same set.

To reproduce: create a wallet transaction paying yourself a denomination and never broadcast it (or let the mempool reject it). Its outputs are counted immediately, before it can possibly confirm, and stay counted.

What was done?

Added CWallet::IsWalletUTXOSpendable(), which expresses the same liveness rule AvailableCoins() applies — reject conflicted transactions, and reject zero-depth transactions that are not in the mempool — and applied it to all four direct readers of setWalletUTXO in src/wallet/coinjoin.cpp.

Applying it to all four rather than only the miscounting one is deliberate: they read the same set for the same purpose, and filtering some but not others would be arbitrary.

No consensus, network or serialization code is touched.

How Has This Been Tested?

New unit test availablecoins_tests/UnconfirmableOutputsAreNotWalletFunds: a transaction the wallet knows about but that never reached the mempool has its outputs excluded, and the same transaction in TxStateInMempool has them counted. The test was confirmed to be a genuine regression test by reverting the filter back to GetTxDepthInMainChain(...) < 0 and observing it fail with 1 != 0.

Ran availablecoins_tests, coinjoin_tests, coinjoin_inouts_tests, wallet_tests and spend_tests — all pass. Built with the full tree including Qt on macOS (aarch64-apple-darwin), no new warnings.

Breaking Changes

None to any API, RPC or wallet format.

There is an intentional behaviour change inside CoinJoin: outputs of transactions that are conflicted, or that sit at depth 0 outside the mempool, no longer contribute to denomination counts, average rounds or the normalized anonymized balance. Newly created denominations still count as soon as they reach the mempool, so mixing progresses as before; only outputs that cannot be spent stop being counted.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation
  • I have assigned this pull request to a milestone

setWalletUTXO holds every unspent output the wallet owns, including outputs
of transactions that cannot confirm as they stand: conflicted ones, and ones
that were abandoned, never broadcast, or rejected from the mempool.
AvailableCoins() filters those out, but the CoinJoin functions that read
setWalletUTXO directly checked at most `depth < 0`, so such outputs were
counted as spendable wallet funds.

CountInputsWithAmount() feeds CCoinJoinClientSession::CreateDenominated(),
which then under-creates denominations, and GetAverageAnonymizedRounds() and
GetNormalizedAnonymizedBalance() surface the same skew in the GUI.

Give all four direct readers of setWalletUTXO the liveness filter
AvailableCoins() uses, via IsWalletUTXOSpendable().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@UdjinM6 UdjinM6 added this to the 24 milestone Aug 22, 2026
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Potential PR merge conflicts

This is advisory only. It does not block CI, but it marks PRs that will likely need a rebase depending on merge order.

If this PR merges first

These open PRs will likely need a rebase:

If these PRs merge first

This PR will likely need a rebase:

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: db5a3fd9-be20-420d-abb3-28980878d63d

📥 Commits

Reviewing files that changed from the base of the PR and between 72ad8cb and 6b8b0b8.

📒 Files selected for processing (4)
  • src/wallet/coinjoin.cpp
  • src/wallet/test/availablecoins_tests.cpp
  • src/wallet/wallet.cpp
  • src/wallet/wallet.h
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/wallet/wallet.h

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


Walkthrough

The wallet adds IsWalletUTXOSpendable for confirmed and mempool transactions. CoinJoin selection and anonymized-balance calculations use this check. Mempool removal resets anonymizable tally caches. Tests verify that inactive outputs are excluded and mempool outputs are counted.

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

Merge Risk: ⚪ Minimal · up to 6b8b0

This change excludes unconfirmable outputs from CoinJoin accounting, keeping wallet mixing counts aligned with spendable funds. No actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: pastapastapasta, thepastaclaw

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 4 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: excluding unconfirmable outputs from CoinJoin accounting.
Description check ✅ Passed The description directly explains the issue, implementation, tests, behavior change, and scope of the CoinJoin accounting fix.
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 unit tests (beta)
  • Create PR with unit tests

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.

@thepastaclaw

thepastaclaw commented Aug 22, 2026

Copy link
Copy Markdown

🕓 Ready for review — next in queue (commit 6b8b0b8)
Queue position: 1/1 · 2 reviews active
ETA: start ~09:55 UTC · complete ~10:20 UTC (median 24m across 30 recent reviews; 2 slots)
Queued 19m ago · Last checked: 2026-08-22 09:50 UTC

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f44af84fe9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/wallet/coinjoin.cpp
CAmount nValue = it->second.tx->vout[outpoint.n].nValue;
if (!CoinJoin::IsDenominatedAmount(nValue)) continue;
if (GetTxDepthInMainChain(it->second) < 0) continue;
if (!IsWalletUTXOSpendable(it->second)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply the liveness check to aggregate CoinJoin balances

Filtering only the normalized/rounds paths leaves the primary CoinJoin balances unchanged: GetBalance() still calls CachedTxGetAvailableCoinJoinCredits(), whose nDepth < 0 guard accepts an inactive depth-zero transaction and then classifies its denominations as denominated_trusted; CachedTxGetAnonymizedCredit() has the same gap for CoinJoin-only spending. Thus the transaction reproduced by the new test still inflates getbalances.mine.coinjoin, coinjoin_balance, the Qt denomination/anonymized figures, and—if its output has enough rounds—the CoinJoin send balance, while this newly filtered normalized value excludes it. Apply the same inactive/non-mempool rejection in both aggregate credit functions.

Useful? React with 👍 / 👎.

Comment thread src/wallet/coinjoin.cpp
if (wtx.IsCoinBase() && GetTxBlocksToMaturity(wtx) > 0) continue;
if (fSkipUnconfirmed && !CachedTxIsTrusted(*this, wtx)) continue;
if (GetTxDepthInMainChain(wtx) < 0) continue;
if (!IsWalletUTXOSpendable(wtx)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Invalidate tallies when a transaction leaves the mempool

When a trusted wallet transaction is in the mempool, the default GetAnonymizableBalance() path can cache it before reaching this check on later calls. For non-conflict mempool removals, transactionRemovedFromMempool() only calls RefreshMempoolStatus() and does not clear fAnonymizableTallyCached or fAnonymizableTallyCachedNonDenom, so after eviction/expiry this function returns the cached tally at lines 137–147 without evaluating the new liveness condition. Automatic CoinJoin accounting can therefore continue using the unconfirmable outputs until an unrelated cache reset; invalidate these caches on the in-mempool-to-inactive transition or avoid caching zero-depth entries.

Useful? React with 👍 / 👎.

CachedTxGetAvailableCoinJoinCredits() and CachedTxGetAnonymizedCredit()
value a transaction's outputs for GetBalance() and the Qt figures, and both
rejected only conflicted transactions. An inactive transaction that never
reached the mempool therefore still inflated getbalances.mine.coinjoin and
the denominated and anonymized balances, while the counts and the normalized
balance no longer counted it — the two disagreed about the same output.

Reuse IsWalletUTXOSpendable() there as well, so everything that values
wallet outputs applies the same rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Preliminary review — Codex only

The liveness predicate is correctly applied to the direct CoinJoin UTXO readers and aggregate credit functions, but the anonymizable tally caches can retain a trusted transaction after it leaves the mempool, temporarily restoring the accounting error this PR intends to fix. This leaves one in-scope blocking correctness issue.
Source: reviewers gpt-5.6-sol (general and dash-core-commit-history); final verifier gpt-5.6-sol. Orchestration only, not reviewer evidence: openclaw-agent/cliproxy/gpt-5.6-sol.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/wallet/coinjoin.cpp`:
- [BLOCKING] src/wallet/coinjoin.cpp:165: Invalidate cached tallies when a transaction leaves the mempool
  `SelectCoinsGroupedByAddresses()` returns `vecAnonymizableTallyCached` or `vecAnonymizableTallyCachedNonDenom` at lines 137-147 before reaching this new liveness predicate. A wallet-created zero-depth transaction can pass `CachedTxIsTrusted()` while it is in the mempool and populate either cache at lines 214-220. On non-conflict removals such as `EXPIRY`, `SIZELIMIT`, or `MANUAL`, `CWallet::transactionRemovedFromMempool()` changes that transaction from `TxStateInMempool` to `TxStateInactive` through `RefreshMempoolStatus()`, but does not clear either cache flag. Subsequent `GetAnonymizableBalance()` calls therefore continue counting the now-unconfirmable outputs until a block or unrelated wallet event invalidates the cache. Clear both tally caches on this mempool-state transition and add a regression test that primes the cache before removing the transaction from the mempool.

Comment thread src/wallet/coinjoin.cpp
if (wtx.IsCoinBase() && GetTxBlocksToMaturity(wtx) > 0) continue;
if (fSkipUnconfirmed && !CachedTxIsTrusted(*this, wtx)) continue;
if (GetTxDepthInMainChain(wtx) < 0) continue;
if (!IsWalletUTXOSpendable(wtx)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: Invalidate cached tallies when a transaction leaves the mempool

SelectCoinsGroupedByAddresses() returns vecAnonymizableTallyCached or vecAnonymizableTallyCachedNonDenom at lines 137-147 before reaching this new liveness predicate. A wallet-created zero-depth transaction can pass CachedTxIsTrusted() while it is in the mempool and populate either cache at lines 214-220. On non-conflict removals such as EXPIRY, SIZELIMIT, or MANUAL, CWallet::transactionRemovedFromMempool() changes that transaction from TxStateInMempool to TxStateInactive through RefreshMempoolStatus(), but does not clear either cache flag. Subsequent GetAnonymizableBalance() calls therefore continue counting the now-unconfirmable outputs until a block or unrelated wallet event invalidates the cache. Clear both tally caches on this mempool-state transition and add a regression test that primes the cache before removing the transaction from the mempool.

source: ['codex']

SelectCoinsGroupedByAddresses() serves its result from
vecAnonymizableTallyCached before it looks at any transaction, so the liveness
filter never runs on a cached call. A wallet transaction is trusted at depth
zero while it sits in the mempool, so its outputs enter that cache; when it
later leaves the mempool for any reason other than a conflict,
transactionRemovedFromMempool() refreshes its state but left both cache flags
set. The tally then kept handing out outputs of a transaction that can no
longer confirm as it stands, until some unrelated event cleared the cache.

Clear both flags on that transition, as the conflict path already does
through SyncTransaction().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants