Skip to content

Commit e5eabfe

Browse files
fix(coinjoin): only report a promotion when its inputs can be selected
GetDenominationCounts() counted every fully-mixed coin, but SelectFullyMixedForPromotion() takes at most one coin per parent transaction. Standard mixing clusters up to 9 of a wallet's coins in each mixing transaction, so wallets routinely held PROMOTION_RATIO fully-mixed coins spread over fewer than PROMOTION_RATIO transactions. Such a wallet reported a promotion opportunity on every attempt, failed selection every time, never promoted, and skipped the "nothing to do" exits it would otherwise take. Count fully-mixed coins at most once per parent transaction, which is what selection can return. ShouldDemote() only needs one such coin and is unaffected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4326d22 commit e5eabfe

3 files changed

Lines changed: 71 additions & 1 deletion

File tree

‎src/wallet/coinjoin.cpp‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,7 @@ CoinJoinDenomCounts CWallet::GetDenominationCounts() const
431431
if (!CCoinJoinClientOptions::IsEnabled()) return counts;
432432

433433
const auto& denoms = CoinJoin::GetStandardDenominations();
434+
std::array<std::set<uint256>, std::tuple_size_v<decltype(counts.fully_mixed)>> fully_mixed_parents;
434435

435436
LOCK(cs_wallet);
436437
for (const auto& outpoint : setWalletUTXO) {
@@ -448,7 +449,9 @@ CoinJoinDenomCounts CWallet::GetDenominationCounts() const
448449

449450
const size_t idx = static_cast<size_t>(denom_it - denoms.begin());
450451
counts.total[idx]++;
451-
if (IsFullyMixed(outpoint)) counts.fully_mixed[idx]++;
452+
if (IsFullyMixed(outpoint) && fully_mixed_parents[idx].insert(outpoint.hash).second) {
453+
counts.fully_mixed[idx]++;
454+
}
452455
}
453456

454457
return counts;

‎src/wallet/test/coinjoin_tests.cpp‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,71 @@ BOOST_FIXTURE_TEST_CASE(coinjoin_rebalance_rounds_reset_tests, CTransactionBuild
597597
}
598598
}
599599

600+
BOOST_FIXTURE_TEST_CASE(coinjoin_promotion_needs_coins_from_distinct_txs_tests, CTransactionBuilderTestSetup)
601+
{
602+
constexpr CAmount nSmallerAmount{10000100};
603+
constexpr CAmount nLargerAmount{nSmallerAmount * CoinJoin::PROMOTION_RATIO};
604+
const int nSmallerDenom{CoinJoin::AmountToDenomination(nSmallerAmount)};
605+
const int nLargerDenom{CoinJoin::AmountToDenomination(nLargerAmount)};
606+
const size_t idxSmaller = CoinJoin::GetDenominationIndex(nSmallerDenom);
607+
const CScript scriptOurs = GetScriptForRawPubKey(coinbaseKey.GetPubKey());
608+
609+
const bool fEnabledPrev{CCoinJoinClientOptions::IsEnabled()};
610+
const int nRoundsPrev{CCoinJoinClientOptions::GetRounds()};
611+
const int nGoalPrev{CCoinJoinClientOptions::GetDenomsGoal()};
612+
CCoinJoinClientOptions::SetEnabled(true);
613+
CCoinJoinClientOptions::SetRounds(MIN_COINJOIN_ROUNDS);
614+
CCoinJoinClientOptions::SetDenomsGoal(MIN_COINJOIN_DENOMS_GOAL);
615+
616+
// Mines a zero-fee self-mixing tx spending vecIn, one output per input
617+
const auto mix = [&](const std::vector<COutPoint>& vecIn) {
618+
CMutableTransaction mtx;
619+
for (const auto& outpoint : vecIn) {
620+
mtx.vin.emplace_back(outpoint);
621+
mtx.vout.emplace_back(nSmallerAmount, scriptOurs);
622+
}
623+
BOOST_REQUIRE(WITH_LOCK(wallet->cs_wallet, return wallet->SignTransaction(mtx)));
624+
BOOST_REQUIRE(wallet->AddToWallet(MakeTransactionRef(mtx), TxStateInactive{}));
625+
AddTxToChain(mtx.GetHash());
626+
std::vector<COutPoint> vecOut;
627+
for (uint32_t n = 0; n < mtx.vout.size(); ++n) {
628+
vecOut.emplace_back(mtx.GetHash(), n);
629+
}
630+
return vecOut;
631+
};
632+
633+
// PROMOTION_RATIO coins mixed together until fully mixed whatever the salt decides
634+
std::vector<COutPoint> vecCoins = GetTallyItem(std::vector<CAmount>(CoinJoin::PROMOTION_RATIO, nSmallerAmount)).outpoints;
635+
for (int i = 0; i < CCoinJoinClientOptions::GetRounds() + CCoinJoinClientOptions::GetRandomRounds(); ++i) {
636+
vecCoins = mix(vecCoins);
637+
}
638+
for (const auto& outpoint : vecCoins) {
639+
BOOST_REQUIRE(wallet->IsFullyMixed(outpoint));
640+
}
641+
642+
// All of them share one parent tx, so a promotion can only ever select one of them: no
643+
// promotion opportunity may be reported, or the client would chase it on every attempt
644+
auto counts = wallet->GetDenominationCounts();
645+
BOOST_CHECK_EQUAL(counts.total[idxSmaller], CoinJoin::PROMOTION_RATIO);
646+
BOOST_CHECK_EQUAL(counts.fully_mixed[idxSmaller], 1);
647+
BOOST_CHECK(!CCoinJoinClientManager::ShouldPromote(nSmallerDenom, nLargerDenom, counts));
648+
BOOST_CHECK_EQUAL(wallet->SelectFullyMixedForPromotion(nSmallerDenom, CoinJoin::PROMOTION_RATIO).size(), 1U);
649+
650+
// One more round, each coin in its own tx: now the promotion is both reported and selectable
651+
for (const auto& outpoint : vecCoins) {
652+
mix({outpoint});
653+
}
654+
counts = wallet->GetDenominationCounts();
655+
BOOST_CHECK_EQUAL(counts.fully_mixed[idxSmaller], CoinJoin::PROMOTION_RATIO);
656+
BOOST_CHECK(CCoinJoinClientManager::ShouldPromote(nSmallerDenom, nLargerDenom, counts));
657+
BOOST_CHECK_EQUAL(wallet->SelectFullyMixedForPromotion(nSmallerDenom, CoinJoin::PROMOTION_RATIO).size(),
658+
static_cast<size_t>(CoinJoin::PROMOTION_RATIO));
659+
660+
CCoinJoinClientOptions::SetDenomsGoal(nGoalPrev);
661+
CCoinJoinClientOptions::SetRounds(nRoundsPrev);
662+
CCoinJoinClientOptions::SetEnabled(fEnabledPrev);
663+
}
664+
600665
BOOST_FIXTURE_TEST_CASE(coinjoin_rounds_cache_unknown_tx_tests, CTransactionBuilderTestSetup)
601666
{
602667
constexpr CAmount nDenomAmount{10000100}; // 0.100001 DASH

‎src/wallet/wallet.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,8 @@ class WalletRescanReserver; //forward declarations for ScanForWalletTransactions
311311
//! CoinJoin::vecStandardDenominations (index 0 = largest denomination).
312312
struct CoinJoinDenomCounts {
313313
std::array<int, 5> total{};
314+
//! Fully-mixed coins that can be spent together, i.e. at most one per transaction: a
315+
//! conversion never takes two coins with the same parent (see SelectFullyMixedForPromotion)
314316
std::array<int, 5> fully_mixed{};
315317
};
316318

0 commit comments

Comments
 (0)