Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/release-notes-7634.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Wallet changes
--------------

- CoinJoin denomination counts, average mixing rounds and the normalized
anonymized balance no longer include outputs of transactions that cannot
confirm as they stand: conflicted ones, and ones that were abandoned, never
broadcast, or rejected from the mempool. Previously such outputs inflated
these figures, which could make the wallet create fewer denominations than
intended and report mixing progress that did not match the coins it could
actually use. (#7634)
17 changes: 10 additions & 7 deletions src/wallet/coinjoin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ std::vector<CompactTallyItem> CWallet::SelectCoinsGroupedByAddresses(bool fSkipD

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 👍 / 👎.

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']

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved in this update — Invalidate cached tallies when a transaction leaves the mempool no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.


for (unsigned int i = 0; i < wtx.tx->vout.size(); i++) {
CTxDestination txdest;
Expand Down Expand Up @@ -253,7 +253,7 @@ int CWallet::CountInputsWithAmount(CAmount nInputAmount) const
const auto it{mapWallet.find(outpoint.hash)};
if (it == mapWallet.end()) continue;
if (it->second.tx->vout[outpoint.n].nValue != nInputAmount) continue;
if (GetTxDepthInMainChain(it->second) < 0) continue;
if (!IsWalletUTXOSpendable(it->second)) continue;

nTotal++;
}
Expand Down Expand Up @@ -542,6 +542,9 @@ float CWallet::GetAverageAnonymizedRounds() const

LOCK(cs_wallet);
for (const auto& outpoint : setWalletUTXO) {
const auto it{mapWallet.find(outpoint.hash)};
if (it == mapWallet.end()) continue;
if (!IsWalletUTXOSpendable(it->second)) continue;
if (!IsDenominated(outpoint)) continue;

nTotal += GetCappedOutpointCoinJoinRounds(outpoint);
Expand All @@ -568,7 +571,7 @@ CAmount CWallet::GetNormalizedAnonymizedBalance() const

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 👍 / 👎.


int nRounds = GetCappedOutpointCoinJoinRounds(outpoint);
nTotal += nValue * nRounds / CCoinJoinClientOptions::GetRounds();
Expand All @@ -581,8 +584,8 @@ CAmount CachedTxGetAnonymizedCredit(const CWallet& wallet, const CWalletTx& wtx,
{
AssertLockHeld(wallet.cs_wallet);

// Exclude coinbase and conflicted txes
if (wtx.IsCoinBase() || wallet.GetTxDepthInMainChain(wtx) < 0) return 0;
// Exclude coinbase transactions, and any that cannot confirm as they stand
if (wtx.IsCoinBase() || !wallet.IsWalletUTXOSpendable(wtx)) return 0;

CAmount nCredit = 0;
uint256 hashTx = wtx.GetHash();
Expand Down Expand Up @@ -620,9 +623,9 @@ CoinJoinCredits CachedTxGetAvailableCoinJoinCredits(const CWallet& wallet, const
// Must wait until coinbase is safely deep enough in the chain before valuing it
if (wtx.IsCoinBase() && wallet.GetTxBlocksToMaturity(wtx) > 0) return ret;

int nDepth = wallet.GetTxDepthInMainChain(wtx);
if (nDepth < 0) return ret;
if (!wallet.IsWalletUTXOSpendable(wtx)) return ret;

const int nDepth{wallet.GetTxDepthInMainChain(wtx)};
ret.is_unconfirmed = CachedTxIsTrusted(wallet, wtx) && nDepth == 0;

if (wtx.m_amounts[CWalletTx::ANON_CREDIT].m_cached[ISMINE_SPENDABLE]) {
Expand Down
65 changes: 65 additions & 0 deletions src/wallet/test/availablecoins_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
// Distributed under the MIT software license, see the accompanying
// file COPYING or https://www.opensource.org/licenses/mit-license.php.

#include <coinjoin/coinjoin.h>
#include <txmempool.h>
#include <validation.h>
#include <wallet/coincontrol.h>
#include <wallet/coinjoin.h>
#include <wallet/spend.h>
#include <wallet/test/util.h>
#include <wallet/test/wallet_test_fixture.h>
Expand Down Expand Up @@ -89,5 +92,67 @@ BOOST_FIXTURE_TEST_CASE(BasicOutputTypesTest, AvailableCoinsTestingSetup)
BOOST_CHECK_EQUAL(available_coins.legacy.size(), 2U);
}

BOOST_FIXTURE_TEST_CASE(UnconfirmableOutputsAreNotWalletFunds, AvailableCoinsTestingSetup)
{
LOCK(wallet->cs_wallet);

const auto dest{wallet->GetNewDestination("")};
BOOST_ASSERT(dest);

// Use a real CoinJoin denomination so the denominated-credit paths apply.
const CAmount denom{CoinJoin::GetSmallestDenomination()};
CMutableTransaction mtx;
mtx.vin.emplace_back(COutPoint{uint256::ONE, 0});
mtx.vout.emplace_back(denom, GetScriptForDestination(*dest));
const CTransactionRef tx{MakeTransactionRef(mtx)};

// A transaction the wallet knows about but that never reached the mempool cannot
// confirm as it stands, so its outputs are not funds the wallet can spend or mix.
BOOST_CHECK(wallet->AddToWallet(tx, TxStateInactive{}));
BOOST_CHECK_EQUAL(wallet->CountInputsWithAmount(denom), 0);

// The aggregate CoinJoin balances have to agree: an output that is not wallet funds
// is not denominated or anonymized funds either.
const CWalletTx& wtx{wallet->mapWallet.at(tx->GetHash())};
BOOST_CHECK_EQUAL(CachedTxGetAvailableCoinJoinCredits(*wallet, wtx).m_denominated, 0);

// Once it is in the mempool they count.
BOOST_CHECK(wallet->AddToWallet(tx, TxStateInMempool{}));
BOOST_CHECK_EQUAL(wallet->CountInputsWithAmount(denom), 1);
BOOST_CHECK_EQUAL(CachedTxGetAvailableCoinJoinCredits(*wallet, wtx).m_denominated, denom);
}

BOOST_FIXTURE_TEST_CASE(MempoolRemovalInvalidatesAnonymizableTally, AvailableCoinsTestingSetup)
{
LOCK(wallet->cs_wallet);

const auto dest{wallet->GetNewDestination("")};
BOOST_ASSERT(dest);

// A wallet transaction in the mempool is trusted at depth zero, so its outputs count
// towards the anonymizable tally and that tally is cacheable.
auto created{CreateTransaction(*wallet, {CRecipient{GetScriptForDestination(*dest), 1 * COIN,
/*fSubtractFeeFromAmount=*/false}},
RANDOM_CHANGE_POSITION, CCoinControl{})};
BOOST_REQUIRE(created);
const CTransactionRef tx{created->tx};
BOOST_CHECK(wallet->AddToWallet(tx, TxStateInMempool{}));

const auto tallied = [&](const CTxDestination& target) {
for (const auto& item : wallet->SelectCoinsGroupedByAddresses()) {
if (item.txdest == target) return true;
}
return false;
};

// Prime the cache, so that the check below cannot pass by recomputing the tally.
BOOST_REQUIRE(tallied(*dest));

// Leaving the mempool makes the transaction unconfirmable as it stands; the cached
// tally must not keep handing out its outputs.
wallet->transactionRemovedFromMempool(tx, MemPoolRemovalReason::EXPIRY);
BOOST_CHECK(!tallied(*dest));
}

BOOST_AUTO_TEST_SUITE_END()
} // namespace wallet
13 changes: 13 additions & 0 deletions src/wallet/wallet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,14 @@ bool CWallet::IsSpent(const COutPoint& outpoint) const
return false;
}

bool CWallet::IsWalletUTXOSpendable(const CWalletTx& wtx) const
{
AssertLockHeld(cs_wallet);
const int depth{GetTxDepthInMainChain(wtx)};
if (depth < 0) return false;
return depth > 0 || wtx.InMempool();
}

void CWallet::AddToSpends(const COutPoint& outpoint, const uint256& wtxid, WalletBatch* batch)
{
mapTxSpends.insert(std::make_pair(outpoint, wtxid));
Expand Down Expand Up @@ -1440,6 +1448,11 @@ void CWallet::transactionRemovedFromMempool(const CTransactionRef& tx, MemPoolRe
auto it = mapWallet.find(tx->GetHash());
if (it != mapWallet.end()) {
RefreshMempoolStatus(it->second, chain());
// The transaction is inactive now, so its outputs stop counting as wallet
// funds. The anonymizable tallies are served from a cache that would keep
// handing out the old answer until some unrelated event cleared it.
fAnonymizableTallyCached = false;
fAnonymizableTallyCachedNonDenom = false;
}
}
// Handle transactions that were removed from the mempool because they
Expand Down
7 changes: 7 additions & 0 deletions src/wallet/wallet.h
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,13 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
std::vector<COutPoint> SelectFullyMixedForPromotion(int nDenom, int nCount) const;

bool IsSpent(const COutPoint& outpoint) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
/** May `wtx`'s outputs be counted as wallet funds?
*
* The wallet knows about transactions that cannot confirm as they stand: conflicted
* ones, and ones that were abandoned, never broadcast or rejected from the mempool.
* AvailableCoins() filters their outputs out, so anything else that values wallet
* outputs must do the same. */
bool IsWalletUTXOSpendable(const CWalletTx& wtx) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);

// Whether this or any known UTXO with the same single key has been spent.
bool IsSpentKey(const CScript& scriptPubKey) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
Expand Down