From 5fa6b4a2e96abfd6b93c39e67027e337a1aa1b7b Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 22 Aug 2026 20:54:54 -0500 Subject: [PATCH] fix(wallet): don't cache CoinJoin rounds for txes unknown to the wallet GetRealOutpointCoinJoinRounds() memoizes its result in mapOutpointRoundsCache, including the -1 it returns for an outpoint whose transaction the wallet has no record of. Nothing invalidates that entry when the wallet later learns about the transaction (the only invalidation is ClearCoinJoinRoundsCache(), called from the coinjoinsalt RPCs), so the -1 sticks for the lifetime of the wallet in memory. This used to be unreachable in practice because every caller passed wallet-owned outpoints. Since dash#7261, fundrawtransaction, send and walletcreatefundedpsbt feed arbitrary user-supplied preset inputs into IsFullyMixed(), so querying an outpoint before the wallet knows its transaction (e.g. while a rescan is still running) permanently marks it as having no rounds: a coin that is in fact a fully mixed denomination is then excluded from use_cj spends, missing from the anonymized balance and re-qualifies for mixing. Drop the cache entry on that path instead of memoizing it; the recursion only descends into IsMine inputs whose transactions are always present, so the directly queried outpoint is the only case that can be unknown. --- src/wallet/coinjoin.cpp | 9 ++++++--- src/wallet/test/coinjoin_tests.cpp | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/wallet/coinjoin.cpp b/src/wallet/coinjoin.cpp index 0a2b6705d49b..d4f12cad2713 100644 --- a/src/wallet/coinjoin.cpp +++ b/src/wallet/coinjoin.cpp @@ -284,10 +284,13 @@ int CWallet::GetRealOutpointCoinJoinRounds(const COutPoint& outpoint, int nRound const CWalletTx* wtx{GetWalletTx(outpoint.hash)}; if (wtx == nullptr || wtx->tx == nullptr) { - // no such tx in this wallet - *nRoundsRef = -1; + // No such tx in this wallet: don't memoize the result, the wallet may + // still learn about the tx later (e.g. a rescan in progress) and nothing + // invalidates the cache when it does. RPCs feed user-supplied outpoints + // into this via IsFullyMixed, so this path is reachable for any outpoint. + mapOutpointRoundsCache.erase(pair.first); WalletCJLogPrint(this, "%s FAILED %-70s %3d\n", __func__, outpoint.ToStringShort(), -1); - return *nRoundsRef; + return -1; } // bounds check diff --git a/src/wallet/test/coinjoin_tests.cpp b/src/wallet/test/coinjoin_tests.cpp index 582593382a1c..4fcec9982b0b 100644 --- a/src/wallet/test/coinjoin_tests.cpp +++ b/src/wallet/test/coinjoin_tests.cpp @@ -552,6 +552,29 @@ BOOST_FIXTURE_TEST_CASE(coinjoin_rebalance_rounds_reset_tests, CTransactionBuild } } +BOOST_FIXTURE_TEST_CASE(coinjoin_rounds_cache_unknown_tx_tests, CTransactionBuilderTestSetup) +{ + constexpr CAmount nDenomAmount{10000100}; // 0.100001 DASH + BOOST_REQUIRE(CoinJoin::IsDenominatedAmount(nDenomAmount)); + + CompactTallyItem tallyItem = GetTallyItem({nDenomAmount}); + const CScript scriptOurs = GetScriptForRawPubKey(coinbaseKey.GetPubKey()); + + CMutableTransaction mtxMix; + mtxMix.vin.emplace_back(tallyItem.outpoints[0]); + mtxMix.vout.emplace_back(nDenomAmount, scriptOurs); + const COutPoint outpointMixed{mtxMix.GetHash(), 0}; + + // Queried before the wallet knows the tx, the way RPCs do for user-supplied + // preset inputs: unknown, reported as -1 + BOOST_CHECK_EQUAL(wallet->GetRealOutpointCoinJoinRounds(outpointMixed), -1); + + // Once the wallet learns the tx its rounds are computed from it; the miss + // above must not stick in mapOutpointRoundsCache + BOOST_REQUIRE(wallet->AddToWallet(MakeTransactionRef(mtxMix), TxStateInMempool{})); + BOOST_CHECK_EQUAL(wallet->GetRealOutpointCoinJoinRounds(outpointMixed), 1); +} + BOOST_FIXTURE_TEST_CASE(CTransactionBuilderTest, CTransactionBuilderTestSetup) { // NOTE: Mock wallet version is FEATURE_BASE which means that it uses uncompressed pubkeys