From 49d7d5f8650ee100b178558391e2f027a8d76368 Mon Sep 17 00:00:00 2001 From: Pasta Date: Thu, 9 Jul 2026 20:24:46 -0500 Subject: [PATCH 01/26] feat: coinjoin promotion / demotion Add post-V24 CoinJoin denomination promotion and demotion so participants can convert between adjacent standard denominations inside a mixing session instead of being limited to strict 1:1 denomination mixes: - promotion combines PROMOTION_RATIO (10) fully-mixed inputs of one denomination into 1 output of the next larger denomination - demotion splits 1 input into PROMOTION_RATIO outputs of the next smaller denomination Client changes: - ShouldPromote()/ShouldDemote() decide when to rebalance based on per-denomination deficits vs -coinjoindenomsgoal, with a gap threshold to prevent oscillation; promotion additionally requires PROMOTION_RATIO fully-mixed coins so mixed coins are never wasted - DoAutomaticDenominating() checks rebalance opportunities even after the anonymization target is reached and drives promotion/demotion queues via new JoinExistingQueue()/StartNewQueue() overloads - SelectRebalanceInputs() selects and locks rebalance inputs up front, recording them in vecOutPointLocked so UnlockCoins() releases them on every failure path (queue-join failure, connect failure, prepare failure, session reset) - PreparePromotionEntry()/PrepareDemotionEntry() build the unbalanced entries submitted through SendDenominate() Server changes: - AddEntry() accepts up to PROMOTION_RATIO inputs post-V24 and caps non-standard entries so a session can always reach the GetMinPoolParticipants() standard-mixer minimum - CheckPool() only counts standard 1:1 entries toward the privacy threshold and resets immediately when a full session can no longer satisfy it, instead of stalling everyone until timeout - pre-V24, unbalanced entries keep flowing to AddEntry -> IsValidInOuts so their collateral is consumed as before Validation changes: - IsValidInOuts() classifies entries (standard / promotion / demotion) and dispatches to the ValidatePromotionEntry()/ValidateDemotionEntry() helpers; with fFinalTx=true it validates the aggregated final transaction via ValidateFinalTxComposition() plus the zero-fee check since per-entry shape rules don't apply to the concatenation - DSTX IsValidStructure() is now deployment-aware: pre-V24 it keeps the balanced 1:1 rules, post-V24 it allows unbalanced counts with independent input and output caps of GetMaxPoolParticipants() * PROMOTION_RATIO - ValidateDSTX() drops a DSTX that is only valid under post-V24 rules with a reduced penalty around the activation boundary so honest relayers whose tip is ahead of ours aren't discouraged, while structurally malformed transactions keep the full penalty Wallet changes: - GetDenominationCounts() tallies total and fully-mixed coins per denomination in a single scan, CountCoinsByDenomination() and SelectFullyMixedForPromotion() back the rebalance decision and input selection, and SelectTxDSInsByDenomination() gains a CoinType parameter so demotion can prefer fully-mixed coins All new behavior is gated on DEPLOYMENT_V24 activation; pre-V24 behavior is unchanged. --- src/coinjoin/client.cpp | 501 +++++++++++++++++++++++++++-- src/coinjoin/client.h | 44 ++- src/coinjoin/coinjoin.cpp | 282 ++++++++++++++-- src/coinjoin/coinjoin.h | 82 ++++- src/coinjoin/common.h | 99 +++++- src/coinjoin/server.cpp | 47 ++- src/net_processing.cpp | 19 +- src/test/coinjoin_inouts_tests.cpp | 13 +- src/wallet/coinjoin.cpp | 70 +++- src/wallet/wallet.h | 27 ++ test/functional/p2p_dstx.py | 29 +- 11 files changed, 1123 insertions(+), 90 deletions(-) diff --git a/src/coinjoin/client.cpp b/src/coinjoin/client.cpp index de82104b8910..7819a13bc73f 100644 --- a/src/coinjoin/client.cpp +++ b/src/coinjoin/client.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -181,6 +182,12 @@ void CCoinJoinClientSession::SetNull() mixingMasternode = nullptr; pendingDsaRequest = CPendingDsaRequest(); + // Post-V24: rebalance inputs are recorded in vecOutPointLocked at lock time + // (SelectRebalanceInputs), so UnlockCoins() releases them - just clear the bookkeeping + m_fPromotion = false; + m_fDemotion = false; + m_vecRebalanceInputs.clear(); + CCoinJoinBaseSession::SetNull(); } @@ -378,13 +385,21 @@ bool CCoinJoinClientSession::SendDenominate(const std::vector vecTxOutTmp; for (const auto& [txDsIn, txOut] : vecPSInOutPairsIn) { - vecTxDSInTmp.emplace_back(txDsIn); - vecTxOutTmp.emplace_back(txOut); - tx.vin.emplace_back(txDsIn); - tx.vout.emplace_back(txOut); + // For promotion/demotion, filter out empty inputs/outputs + // Promotion: 10 inputs with only 1 real output (others are empty) + // Demotion: 1 input with 10 outputs (only first has real input) + if (!txDsIn.prevout.IsNull()) { + vecTxDSInTmp.emplace_back(txDsIn); + tx.vin.emplace_back(txDsIn); + } + if (txOut.nValue > 0) { + vecTxOutTmp.emplace_back(txOut); + tx.vout.emplace_back(txOut); + } } - WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::SendDenominate -- Submitting partial tx %s", tx.ToString()); /* Continued */ + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::SendDenominate -- Submitting partial tx with %d inputs, %d outputs: %s\n", + vecTxDSInTmp.size(), vecTxOutTmp.size(), tx.ToString()); // store our entry for later use LOCK(cs_coinjoin); @@ -475,7 +490,7 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ // Make sure all inputs/outputs are valid PoolMessage nMessageID{MSG_NOERR}; if (!IsValidInOuts(active_chainstate, m_isman, mempool, finalMutableTransaction.vin, finalMutableTransaction.vout, - nSessionDenom, nMessageID, nullptr)) { + nSessionDenom, nMessageID, nullptr, /*fFinalTx=*/true)) { WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- ERROR! IsValidInOuts() failed: %s\n", __func__, CoinJoin::GetMessageByID(nMessageID).translated); UnlockCoins(); keyHolderStorage.ReturnAll(); @@ -916,7 +931,13 @@ bool CCoinJoinClientSession::DoAutomaticDenominating(ChainstateManager& chainman if (!CCoinJoinClientOptions::IsEnabled()) return false; + // Post-V24: whether denomination promotion/demotion is active. Evaluated before taking + // cs_wallet (IsPromotionDemotionActive locks cs_main). + const bool fV24Active = CoinJoin::IsPromotionDemotionActive(chainman); + CAmount nBalanceNeedsAnonymized; + wallet::CoinJoinDenomCounts denomCounts; + bool fRebalanceOpportunity{false}; { LOCK(m_wallet->cs_wallet); @@ -950,7 +971,22 @@ bool CCoinJoinClientSession::DoAutomaticDenominating(ChainstateManager& chainman CAmount nBalanceAnonymized = bal.m_anonymized; nBalanceNeedsAnonymized = CCoinJoinClientOptions::GetAmount() * COIN - nBalanceAnonymized; - if (nBalanceNeedsAnonymized < 0) { + // Post-V24: check for promotion/demotion opportunities before the "nothing to do" + // exits below - rebalancing matters precisely when the anonymization target is + // reached and all coins are already fully mixed + if (fV24Active) { + // Single wallet scan covering all denominations, reused across every + // adjacent-pair check instead of re-scanning per pair. + denomCounts = m_wallet->GetDenominationCounts(); + for (size_t i = 0; i + 1 < CoinJoin::vecStandardDenominations.size() && !fRebalanceOpportunity; ++i) { + const int nLargerDenom = 1 << i; + const int nSmallerDenom = 1 << (i + 1); + fRebalanceOpportunity = m_clientman.ShouldPromote(nSmallerDenom, nLargerDenom, denomCounts) || + m_clientman.ShouldDemote(nLargerDenom, nSmallerDenom, denomCounts); + } + } + + if (nBalanceNeedsAnonymized < 0 && !fRebalanceOpportunity) { WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::DoAutomaticDenominating -- Nothing to do\n"); // nothing to do, just keep it in idle mode return false; @@ -967,8 +1003,9 @@ bool CCoinJoinClientSession::DoAutomaticDenominating(ChainstateManager& chainman // including denoms but applying some restrictions CAmount nBalanceAnonymizable = m_wallet->GetAnonymizableBalance(); - // mixable balance is way too small - if (nBalanceAnonymizable < nValueMin) { + // mixable balance is way too small; note that fully-mixed coins don't count as + // anonymizable, so a rebalance opportunity must bypass this check too + if (nBalanceAnonymizable < nValueMin && !fRebalanceOpportunity) { strAutoDenomResult = _("Not enough funds to mix."); WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::DoAutomaticDenominating -- %s\n", strAutoDenomResult.original); return false; @@ -1072,13 +1109,63 @@ bool CCoinJoinClientSession::DoAutomaticDenominating(ChainstateManager& chainman } } // LOCK(m_wallet->cs_wallet); - // Always attempt to join an existing queue - if (JoinExistingQueue(nBalanceNeedsAnonymized, connman)) { - return true; + // Post-V24: Check if we should promote or demote denominations + // This helps maintain optimal denomination distribution as coins are spent + if (fRebalanceOpportunity) { + // Check all adjacent denomination pairs for promotion/demotion opportunities + // Denominations: 10, 1, 0.1, 0.01, 0.001 (indices 0-4, smaller index = larger denom) + for (size_t i = 0; i + 1 < CoinJoin::vecStandardDenominations.size(); ++i) { + const int nLargerDenom = 1 << i; // Larger denomination (e.g., 10 DASH) + const int nSmallerDenom = 1 << (i + 1); // Smaller denomination (e.g., 1 DASH) + + // Check if we should promote smaller -> larger; ShouldPromote() requires + // PROMOTION_RATIO fully-mixed coins, the queue functions re-verify on selection + if (m_clientman.ShouldPromote(nSmallerDenom, nLargerDenom, denomCounts)) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::DoAutomaticDenominating -- Promotion opportunity: %d x %s -> 1 x %s\n", + CoinJoin::PROMOTION_RATIO, + CoinJoin::DenominationToString(nSmallerDenom), + CoinJoin::DenominationToString(nLargerDenom)); + + // Try to join an existing queue for promotion + if (JoinExistingQueue(nBalanceNeedsAnonymized, connman, nSmallerDenom, /*fPromotion=*/true)) { + return true; + } + // No existing queue found - try to start a new one for promotion + if (StartNewQueue(nBalanceNeedsAnonymized, connman, nSmallerDenom, /*fPromotion=*/true, /*fDemotion=*/false)) { + return true; + } + } + + // Check if we should demote larger -> smaller + if (m_clientman.ShouldDemote(nLargerDenom, nSmallerDenom, denomCounts)) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::DoAutomaticDenominating -- Demotion opportunity: 1 x %s -> %d x %s\n", + CoinJoin::DenominationToString(nLargerDenom), + CoinJoin::PROMOTION_RATIO, + CoinJoin::DenominationToString(nSmallerDenom)); + + // Try to join an existing queue for demotion + if (JoinExistingQueue(nBalanceNeedsAnonymized, connman, nSmallerDenom, /*fPromotion=*/false, /*fDemotion=*/true)) { + return true; + } + // No existing queue found - try to start a new one for demotion + if (StartNewQueue(nBalanceNeedsAnonymized, connman, nSmallerDenom, /*fPromotion=*/false, /*fDemotion=*/true)) { + return true; + } + } + } } - // If we were unable to find/join an existing queue then start a new one. - if (StartNewQueue(nBalanceNeedsAnonymized, connman)) return true; + // Standard mixing only makes sense while there is still balance to anonymize; a + // rebalance-only pass (target reached) must not fall through to standard queues + if (nBalanceNeedsAnonymized > 0) { + // Always attempt to join an existing queue + if (JoinExistingQueue(nBalanceNeedsAnonymized, connman)) { + return true; + } + + // If we were unable to find/join an existing queue then start a new one. + if (StartNewQueue(nBalanceNeedsAnonymized, connman)) return true; + } strAutoDenomResult = _("No compatible Masternode found."); return false; @@ -1177,10 +1264,100 @@ static int WinnersToSkip() ? 1 : 8; } -bool CCoinJoinClientSession::JoinExistingQueue(CAmount nBalanceNeedsAnonymized, CConnman& connman) +bool CCoinJoinClientSession::SelectRebalanceInputs(int nTargetDenom, bool fPromotion, std::vector& vecTxDSInRet) +{ + vecTxDSInRet.clear(); + m_vecRebalanceInputs.clear(); + + if (fPromotion) { + // Promotion: select 10 fully-mixed coins of the smaller denomination + auto vecCoins = m_wallet->SelectFullyMixedForPromotion(nTargetDenom, CoinJoin::PROMOTION_RATIO); + if (static_cast(vecCoins.size()) < CoinJoin::PROMOTION_RATIO) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- Not enough fully-mixed coins for promotion\n", __func__); + return false; + } + // Convert COutPoints to CTxDSIn + LOCK(m_wallet->cs_wallet); + for (const auto& outpoint : vecCoins) { + const auto it = m_wallet->mapWallet.find(outpoint.hash); + if (it == m_wallet->mapWallet.end()) continue; + const wallet::CWalletTx& wtx = it->second; + if (outpoint.n >= wtx.tx->vout.size()) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- invalid outpoint index %u for tx %s\n", __func__, outpoint.n, outpoint.hash.ToString()); + continue; + } + vecTxDSInRet.emplace_back(CTxIn(outpoint), wtx.tx->vout[outpoint.n].scriptPubKey, + m_wallet->GetRealOutpointCoinJoinRounds(outpoint)); + } + if (static_cast(vecTxDSInRet.size()) < CoinJoin::PROMOTION_RATIO) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- Failed to build promotion inputs\n", __func__); + vecTxDSInRet.clear(); + return false; + } + } else { + // Demotion: select 1 coin of the larger adjacent denomination. Prefer fully-mixed + // coins, falling back to ready-to-mix - unlike promotion inputs, demotion outputs + // keep mixing afterwards, so spending a not-yet-mixed input is acceptable. + const int nLargerDenom = CoinJoin::GetLargerAdjacentDenom(nTargetDenom); + if (nLargerDenom == 0) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- No larger adjacent denom for demotion\n", __func__); + return false; + } + if (!m_wallet->SelectTxDSInsByDenomination(nLargerDenom, CoinJoin::DenominationToAmount(nLargerDenom), vecTxDSInRet, CoinType::ONLY_FULLY_MIXED) && + !m_wallet->SelectTxDSInsByDenomination(nLargerDenom, CoinJoin::DenominationToAmount(nLargerDenom), vecTxDSInRet, CoinType::ONLY_READY_TO_MIX)) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- Couldn't find coin for demotion\n", __func__); + return false; + } + // Keep only 1 input for demotion + vecTxDSInRet.resize(1); + } + + // Lock the selected coins immediately to prevent races with other concurrent CoinJoin + // sessions and record them so UnlockCoins() releases them on any failure path + LOCK(m_wallet->cs_wallet); + for (const auto& txdsin : vecTxDSInRet) { + m_wallet->LockCoin(txdsin.prevout); + m_vecRebalanceInputs.push_back(txdsin.prevout); + vecOutPointLocked.push_back(txdsin.prevout); + } + return true; +} + +void CCoinJoinClientSession::UnlockRebalanceInputs() +{ + if (m_vecRebalanceInputs.empty()) return; + { + LOCK(m_wallet->cs_wallet); + for (const auto& outpoint : m_vecRebalanceInputs) { + m_wallet->UnlockCoin(outpoint); + } + } + vecOutPointLocked.erase(std::remove_if(vecOutPointLocked.begin(), vecOutPointLocked.end(), + [&](const COutPoint& outpoint) { + return std::find(m_vecRebalanceInputs.begin(), m_vecRebalanceInputs.end(), + outpoint) != m_vecRebalanceInputs.end(); + }), + vecOutPointLocked.end()); + m_vecRebalanceInputs.clear(); +} + +bool CCoinJoinClientSession::JoinExistingQueue(CAmount nBalanceNeedsAnonymized, CConnman& connman, + int nTargetDenom, bool fPromotion, bool fDemotion) { if (!CCoinJoinClientOptions::IsEnabled()) return false; + // Promotion and demotion are mutually exclusive + assert(!(fPromotion && fDemotion)); + + // For promotion/demotion select and lock the inputs up front: the selection only depends + // on the target denomination, not on the queue, so there is no need to re-select per queue + std::vector vecRebalanceTxDSIn; + const bool fRebalance = (fPromotion || fDemotion) && nTargetDenom != 0; + if (fRebalance && !SelectRebalanceInputs(nTargetDenom, fPromotion, vecRebalanceTxDSIn)) { + strAutoDenomResult = _("Can't mix: no compatible inputs found!"); + return false; + } + const auto mnList = m_dmnman.GetListAtChainTip(); const int nWeightedMnCount = mnList.GetCounts().m_valid_weighted; @@ -1206,12 +1383,19 @@ bool CCoinJoinClientSession::JoinExistingQueue(CAmount nBalanceNeedsAnonymized, WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- trying queue: %s\n", dsq.ToString()); + // For promotion/demotion, we need a queue with the target denomination + if ((fPromotion || fDemotion) && nTargetDenom != 0 && dsq.nDenom != nTargetDenom) { + continue; // Skip queues with wrong denomination + } + std::vector vecTxDSInTmp; - // Try to match their denominations if possible, select exact number of denominations - if (!m_wallet->SelectTxDSInsByDenomination(dsq.nDenom, nBalanceNeedsAnonymized, vecTxDSInTmp)) { - WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- Couldn't match denomination %d (%s)\n", dsq.nDenom, CoinJoin::DenominationToString(dsq.nDenom)); - continue; + if (!fRebalance) { + // Standard mixing: try to match their denominations if possible + if (!m_wallet->SelectTxDSInsByDenomination(dsq.nDenom, nBalanceNeedsAnonymized, vecTxDSInTmp)) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- Couldn't match denomination %d (%s)\n", dsq.nDenom, CoinJoin::DenominationToString(dsq.nDenom)); + continue; + } } m_mn_metaman.AddUsedMasternode(dmn->proTxHash); @@ -1228,14 +1412,21 @@ bool CCoinJoinClientSession::JoinExistingQueue(CAmount nBalanceNeedsAnonymized, connman.AddPendingMasternode(dmn->proTxHash); SetState(POOL_STATE_QUEUE); nTimeLastSuccessfulStep = GetTime(); - WalletCJLogPrint(m_wallet, /* Continued */ - "CCoinJoinClientSession::JoinExistingQueue -- pending connection, masternode=%s, " - "nSessionDenom=%d (%s)\n", - dmn->proTxHash.ToString(), nSessionDenom.load(), CoinJoin::DenominationToString(nSessionDenom)); + // Set promotion/demotion session state; the rebalance inputs (if any) were already + // selected, locked and recorded by SelectRebalanceInputs above + m_fPromotion = fPromotion; + m_fDemotion = fDemotion; + if (!fRebalance) m_vecRebalanceInputs.clear(); + + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- pending %s connection, masternode=%s, nSessionDenom=%d (%s), %d inputs\n", + fPromotion ? "PROMOTION" : fDemotion ? "DEMOTION" : "mixing", + dmn->proTxHash.ToString(), nSessionDenom.load(), CoinJoin::DenominationToString(nSessionDenom), + vecTxDSInTmp.size() + m_vecRebalanceInputs.size()); strAutoDenomResult = _("Trying to connect…"); return true; } strAutoDenomResult = _("Failed to find mixing queue to join"); + UnlockRebalanceInputs(); return false; } @@ -1323,6 +1514,90 @@ bool CCoinJoinClientSession::StartNewQueue(CAmount nBalanceNeedsAnonymized, CCon return false; } +bool CCoinJoinClientSession::StartNewQueue(CAmount nBalanceNeedsAnonymized, CConnman& connman, + int nTargetDenom, bool fPromotion, bool fDemotion) +{ + assert(m_mn_metaman.IsValid()); + + if (!CCoinJoinClientOptions::IsEnabled()) return false; + if (nTargetDenom == 0) return false; + + // Promotion and demotion are mutually exclusive, and this overload needs one of them + assert(!(fPromotion && fDemotion)); + if (!fPromotion && !fDemotion) return false; + + // For promotion/demotion, verify we have the required coins before starting a queue. + // SelectRebalanceInputs locks them and records them in vecOutPointLocked/m_vecRebalanceInputs. + std::vector vecTxDSInTmp; + if (!SelectRebalanceInputs(nTargetDenom, fPromotion, vecTxDSInTmp)) { + return false; + } + + int nTries = 0; + const auto mnList = m_dmnman.GetListAtChainTip(); + const auto mnCounts = mnList.GetCounts(); + const int nMnCount = mnCounts.enabled(); + const int nWeightedMnCount = mnCounts.m_valid_weighted; + + while (nTries < 10) { + auto dmn = GetRandomNotUsedMasternode(); + if (!dmn) { + strAutoDenomResult = _("Can't find random Masternode."); + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- %s\n", strAutoDenomResult.original); + UnlockRebalanceInputs(); + return false; + } + + m_mn_metaman.AddUsedMasternode(dmn->proTxHash); + + // skip next mn payments winners + if (dmn->pdmnState->nLastPaidHeight + nWeightedMnCount < mnList.GetHeight() + WinnersToSkip()) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- skipping winner, masternode=%s\n", dmn->proTxHash.ToString()); + nTries++; + continue; + } + + if (m_mn_metaman.IsMixingThresholdExceeded(dmn->proTxHash, nMnCount)) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- too early to mix with node masternode=%s\n", + dmn->proTxHash.ToString()); + nTries++; + continue; + } + + if (connman.IsMasternodeOrDisconnectRequested(dmn->pdmnState->netInfo->GetPrimary())) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- skipping connection, masternode=%s\n", + dmn->proTxHash.ToString()); + nTries++; + continue; + } + + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- attempting %s connection, masternode=%s, tries=%d\n", + fPromotion ? "PROMOTION" : "DEMOTION", dmn->proTxHash.ToString(), nTries); + + nSessionDenom = nTargetDenom; + mixingMasternode = dmn; + connman.AddPendingMasternode(dmn->proTxHash); + pendingDsaRequest = CPendingDsaRequest(dmn->proTxHash, CCoinJoinAccept(nSessionDenom, txMyCollateral)); + SetState(POOL_STATE_QUEUE); + nTimeLastSuccessfulStep = GetTime(); + + // Store promotion/demotion state; the inputs were already selected, locked and + // recorded in m_vecRebalanceInputs by SelectRebalanceInputs above + m_fPromotion = fPromotion; + m_fDemotion = fDemotion; + + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- pending %s connection, masternode=%s, nSessionDenom=%d (%s), %zu inputs\n", + fPromotion ? "PROMOTION" : "DEMOTION", + dmn->proTxHash.ToString(), nSessionDenom.load(), CoinJoin::DenominationToString(nSessionDenom), + m_vecRebalanceInputs.size()); + strAutoDenomResult = _("Trying to connect…"); + return true; + } + strAutoDenomResult = _("Failed to start a new mixing queue"); + UnlockRebalanceInputs(); + return false; +} + bool CCoinJoinClientSession::ProcessPendingDsaRequest(CConnman& connman) { if (!pendingDsaRequest) return false; @@ -1333,6 +1608,7 @@ bool CCoinJoinClientSession::ProcessPendingDsaRequest(CConnman& connman) } else { WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- cannot find address to connect, masternode=%s\n", __func__, pendingDsaRequest.GetProTxHash().ToString()); + UnlockCoins(); WITH_LOCK(cs_coinjoin, SetNull()); return false; } @@ -1350,6 +1626,7 @@ bool CCoinJoinClientSession::ProcessPendingDsaRequest(CConnman& connman) } else if (pendingDsaRequest.IsExpired()) { WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- failed to connect, masternode=%s\n", __func__, pendingDsaRequest.GetProTxHash().ToString()); + UnlockCoins(); WITH_LOCK(cs_coinjoin, SetNull()); } @@ -1403,9 +1680,31 @@ bool CCoinJoinClientSession::SubmitDenominate(CConnman& connman) LOCK(m_wallet->cs_wallet); std::string strError; - std::vector vecTxDSIn; std::vector > vecPSInOutPairsTmp; + // Post-V24: Handle promotion/demotion entries + if (m_fPromotion || m_fDemotion) { + const bool fPrepared = m_fPromotion ? PreparePromotionEntry(strError, vecPSInOutPairsTmp) + : PrepareDemotionEntry(strError, vecPSInOutPairsTmp); + if (fPrepared) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::SubmitDenominate -- %s entry prepared, sending\n", + m_fPromotion ? "Promotion" : "Demotion"); + return SendDenominate(vecPSInOutPairsTmp, connman); + } + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::SubmitDenominate -- Prepare%sEntry failed: %s\n", + m_fPromotion ? "Promotion" : "Demotion", strError); + strAutoDenomResult = Untranslated(strError); + // The rebalance inputs were locked back when the session was queued; release them and + // reset the session right away instead of keeping them locked until CheckTimeout() + UnlockCoins(); + keyHolderStorage.ReturnAll(); + WITH_LOCK(cs_coinjoin, SetNull()); + return false; + } + + // Standard 1:1 mixing + std::vector vecTxDSIn; + if (!SelectDenominate(strError, vecTxDSIn)) { WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::SubmitDenominate -- SelectDenominate failed, error: %s\n", strError); return false; @@ -1532,6 +1831,131 @@ bool CCoinJoinClientSession::PrepareDenominate(int nMinRounds, int nMaxRounds, s return true; } +bool CCoinJoinClientSession::PreparePromotionEntry(std::string& strErrorRet, std::vector>& vecPSInOutPairsRet) +{ + AssertLockHeld(m_wallet->cs_wallet); + + vecPSInOutPairsRet.clear(); + + if (m_vecRebalanceInputs.size() != static_cast(CoinJoin::PROMOTION_RATIO)) { + strErrorRet = strprintf("Invalid promotion input count: %d (expected %d)", m_vecRebalanceInputs.size(), CoinJoin::PROMOTION_RATIO); + return false; + } + + // Session denom is the smaller denom (inputs), get the larger adjacent denom for output + const int nLargerDenom = CoinJoin::GetLargerAdjacentDenom(nSessionDenom); + if (nLargerDenom == 0) { + strErrorRet = "No larger adjacent denomination for promotion"; + return false; + } + const CAmount nLargerAmount = CoinJoin::DenominationToAmount(nLargerDenom); + + // Create 10 inputs from stored promotion inputs + for (const auto& outpoint : m_vecRebalanceInputs) { + const auto it = m_wallet->mapWallet.find(outpoint.hash); + if (it == m_wallet->mapWallet.end()) { + strErrorRet = "Promotion input not found in wallet"; + return false; + } + const wallet::CWalletTx& wtx = it->second; + if (outpoint.n >= wtx.tx->vout.size()) { + strErrorRet = "Invalid promotion input index"; + return false; + } + + // Validate the UTXO is still spendable + if (m_wallet->IsSpent(outpoint)) { + strErrorRet = "Promotion input has been spent"; + return false; + } + + CTxDSIn txdsin(CTxIn(outpoint), wtx.tx->vout[outpoint.n].scriptPubKey, + m_wallet->GetRealOutpointCoinJoinRounds(outpoint)); + + // Pair every input with an empty placeholder output - SendDenominate filters + // empty outputs, so only the real larger-denom output set below is submitted + vecPSInOutPairsRet.emplace_back(txdsin, CTxOut()); + } + + // Set the single real output (larger denomination) on the last pair + CScript scriptDenom = keyHolderStorage.AddKey(m_wallet.get()); + if (!vecPSInOutPairsRet.empty()) { + vecPSInOutPairsRet.back().second = CTxOut(nLargerAmount, scriptDenom); + } + + // NOTE: all inputs were locked and recorded in vecOutPointLocked by SelectRebalanceInputs + + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::PreparePromotionEntry -- Prepared %d inputs for promotion to %s\n", + vecPSInOutPairsRet.size(), CoinJoin::DenominationToString(nLargerDenom)); + + return true; +} + +bool CCoinJoinClientSession::PrepareDemotionEntry(std::string& strErrorRet, std::vector>& vecPSInOutPairsRet) +{ + AssertLockHeld(m_wallet->cs_wallet); + + vecPSInOutPairsRet.clear(); + + if (m_vecRebalanceInputs.size() != 1) { + strErrorRet = strprintf("Invalid demotion input count: %d (expected 1)", m_vecRebalanceInputs.size()); + return false; + } + + // Session denom is the smaller denom (outputs) + const CAmount nSmallerAmount = CoinJoin::DenominationToAmount(nSessionDenom); + const int nLargerDenom = CoinJoin::GetLargerAdjacentDenom(nSessionDenom); + if (nLargerDenom == 0) { + strErrorRet = "No larger adjacent denomination for demotion"; + return false; + } + + // Get the single input (larger denom) + const COutPoint& outpoint = m_vecRebalanceInputs[0]; + const auto it = m_wallet->mapWallet.find(outpoint.hash); + if (it == m_wallet->mapWallet.end()) { + strErrorRet = "Demotion input not found in wallet"; + return false; + } + const wallet::CWalletTx& wtx = it->second; + if (outpoint.n >= wtx.tx->vout.size()) { + strErrorRet = "Invalid demotion input index"; + return false; + } + + // Validate the UTXO is still spendable + if (m_wallet->IsSpent(outpoint)) { + strErrorRet = "Demotion input has been spent"; + return false; + } + + CTxDSIn txdsin(CTxIn(outpoint), wtx.tx->vout[outpoint.n].scriptPubKey, + m_wallet->GetRealOutpointCoinJoinRounds(outpoint)); + + // Create 10 outputs of smaller denomination + // For demotion: 1 input, 10 outputs + // The first pair has the real input, subsequent pairs have empty inputs + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + CScript scriptDenom = keyHolderStorage.AddKey(m_wallet.get()); + CTxOut txout(nSmallerAmount, scriptDenom); + + if (i == 0) { + // First entry has the real input + vecPSInOutPairsRet.emplace_back(txdsin, txout); + } else { + // Subsequent entries have empty inputs (will be filtered out when building entry) + vecPSInOutPairsRet.emplace_back(CTxDSIn(), txout); + } + } + + // NOTE: the input was locked and recorded in vecOutPointLocked by SelectRebalanceInputs + + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::PrepareDemotionEntry -- Prepared 1 input for demotion to %d x %s\n", + CoinJoin::PROMOTION_RATIO, CoinJoin::DenominationToString(nSessionDenom)); + + return true; +} + // Create collaterals by looping through inputs grouped by addresses bool CCoinJoinClientSession::MakeCollateralAmounts() { @@ -2004,3 +2428,32 @@ UniValue CCoinJoinClientManager::getJsonInfo() const obj.pushKV("sessions", arrSessions); return obj; } + +bool CCoinJoinClientManager::ShouldPromote(int nSmallerDenom, int nLargerDenom, const wallet::CoinJoinDenomCounts& counts) +{ + // Validate denominations are adjacent + if (!CoinJoin::AreAdjacentDenominations(nSmallerDenom, nLargerDenom)) { + return false; + } + + const int idxSmaller = CoinJoin::GetDenominationIndex(nSmallerDenom); + const int idxLarger = CoinJoin::GetDenominationIndex(nLargerDenom); + + return CoinJoin::ShouldPromoteDenoms(counts.total[idxSmaller], counts.total[idxLarger], + counts.fully_mixed[idxSmaller], CCoinJoinClientOptions::GetDenomsGoal()); +} + +bool CCoinJoinClientManager::ShouldDemote(int nLargerDenom, int nSmallerDenom, const wallet::CoinJoinDenomCounts& counts) +{ + // Validate denominations are adjacent + if (!CoinJoin::AreAdjacentDenominations(nLargerDenom, nSmallerDenom)) { + return false; + } + + const int idxLarger = CoinJoin::GetDenominationIndex(nLargerDenom); + const int idxSmaller = CoinJoin::GetDenominationIndex(nSmallerDenom); + + return CoinJoin::ShouldDemoteDenoms(counts.total[idxLarger], counts.total[idxSmaller], + CCoinJoinClientOptions::GetDenomsGoal()); +} + diff --git a/src/coinjoin/client.h b/src/coinjoin/client.h index 30eaa0d9dbe4..30b8016bbd6b 100644 --- a/src/coinjoin/client.h +++ b/src/coinjoin/client.h @@ -89,6 +89,11 @@ class CCoinJoinClientSession : public CCoinJoinBaseSession CKeyHolderStorage keyHolderStorage; // storage for keys used in PrepareDenominate + // Post-V24: Promotion/demotion session state + bool m_fPromotion{false}; // True if this session is promoting smaller -> larger denom + bool m_fDemotion{false}; // True if this session is demoting larger -> smaller denom + std::vector m_vecRebalanceInputs; // Selected inputs for promotion/demotion rebalancing + /// Create denominations bool CreateDenominated(CAmount nBalanceToDenominate); bool CreateDenominated(CAmount nBalanceToDenominate, const wallet::CompactTallyItem& tallyItem, bool fCreateMixingCollaterals) @@ -102,16 +107,35 @@ class CCoinJoinClientSession : public CCoinJoinBaseSession bool CreateCollateralTransaction(CMutableTransaction& txCollateral, std::string& strReason) EXCLUSIVE_LOCKS_REQUIRED(m_wallet->cs_wallet); - bool JoinExistingQueue(CAmount nBalanceNeedsAnonymized, CConnman& connman); + bool JoinExistingQueue(CAmount nBalanceNeedsAnonymized, CConnman& connman, + int nTargetDenom = 0, bool fPromotion = false, bool fDemotion = false); bool StartNewQueue(CAmount nBalanceNeedsAnonymized, CConnman& connman); + bool StartNewQueue(CAmount nBalanceNeedsAnonymized, CConnman& connman, + int nTargetDenom, bool fPromotion, bool fDemotion); CDeterministicMNCPtr GetRandomNotUsedMasternode(); + /// Post-V24: select and lock inputs for a promotion/demotion session. Locked outpoints are + /// recorded in m_vecRebalanceInputs and vecOutPointLocked so UnlockCoins() releases them on + /// any failure path. + bool SelectRebalanceInputs(int nTargetDenom, bool fPromotion, std::vector& vecTxDSInRet); + /// Post-V24: unlock and forget the inputs selected by SelectRebalanceInputs (session setup failed) + void UnlockRebalanceInputs(); + /// step 0: select denominated inputs and txouts bool SelectDenominate(std::string& strErrorRet, std::vector& vecTxDSInRet); /// step 1: prepare denominated inputs and outputs bool PrepareDenominate(int nMinRounds, int nMaxRounds, std::string& strErrorRet, const std::vector& vecTxDSIn, std::vector>& vecPSInOutPairsRet, bool fDryRun = false) EXCLUSIVE_LOCKS_REQUIRED(m_wallet->cs_wallet); + + /// Post-V24: prepare promotion entry (10 inputs of smaller denom -> 1 output of larger denom) + bool PreparePromotionEntry(std::string& strErrorRet, std::vector>& vecPSInOutPairsRet) + EXCLUSIVE_LOCKS_REQUIRED(m_wallet->cs_wallet); + + /// Post-V24: prepare demotion entry (1 input of larger denom -> 10 outputs of smaller denom) + bool PrepareDemotionEntry(std::string& strErrorRet, std::vector>& vecPSInOutPairsRet) + EXCLUSIVE_LOCKS_REQUIRED(m_wallet->cs_wallet); + /// step 2: send denominated inputs and outputs prepared in step 1 bool SendDenominate(const std::vector >& vecPSInOutPairsIn, CConnman& connman) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); @@ -256,6 +280,24 @@ class CCoinJoinClientManager : public interfaces::CoinJoin::Client bool isMixing() const override; bool startMixing() override; void stopMixing() override; + + /** + * Post-V24: Check if we should promote smaller denominations into larger ones + * @param nSmallerDenom The smaller denomination to promote from + * @param nLargerDenom The larger denomination to promote into + * @param counts Wallet denomination counts, e.g. from CWallet::GetDenominationCounts() + * @return true if promotion is recommended + */ + static bool ShouldPromote(int nSmallerDenom, int nLargerDenom, const wallet::CoinJoinDenomCounts& counts); + + /** + * Post-V24: Check if we should demote larger denominations into smaller ones + * @param nLargerDenom The larger denomination to demote from + * @param nSmallerDenom The smaller denomination to demote into + * @param counts Wallet denomination counts, e.g. from CWallet::GetDenominationCounts() + * @return true if demotion is recommended + */ + static bool ShouldDemote(int nLargerDenom, int nSmallerDenom, const wallet::CoinJoinDenomCounts& counts); }; #endif // BITCOIN_COINJOIN_CLIENT_H diff --git a/src/coinjoin/coinjoin.cpp b/src/coinjoin/coinjoin.cpp index 004fcc3d650a..d8c36d09afe6 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -88,24 +89,59 @@ bool CCoinJoinBroadcastTx::CheckSignature(const CBLSPublicKey& blsPubKey) const return true; } -bool CCoinJoinBroadcastTx::IsValidStructure() const +bool CCoinJoinBroadcastTx::IsExpired(const CBlockIndex* pindex, const chainlock::Chainlocks& clhandler) const { - // some trivial checks only + // expire confirmed DSTXes after ~1h since confirmation or chainlocked confirmation + if (!nConfirmedHeight.has_value() || pindex->nHeight < *nConfirmedHeight) return false; // not mined yet + if (pindex->nHeight - *nConfirmedHeight > 24) return true; // mined more than an hour ago + return clhandler.HasChainLock(pindex->nHeight, *pindex->phashBlock); +} + +bool CCoinJoinBroadcastTx::IsValidStructure(const CBlockIndex* pindex, const ChainstateManager& chainman, + bool* fPossiblyValidPostV24Ret) const +{ + if (fPossiblyValidPostV24Ret) *fPossiblyValidPostV24Ret = false; + + // some trivial checks only, activation-independent ones first if (masternodeOutpoint.IsNull() && m_protxHash.IsNull()) { return false; } - if (tx->vin.size() != tx->vout.size()) { - return false; - } + if (tx->vin.size() < static_cast(CoinJoin::GetMinPoolParticipants())) { return false; } - if (tx->vin.size() > CoinJoin::GetMaxPoolInputOutputCount()) { + + if (!std::ranges::all_of(tx->vout, [](const auto& txOut) { + return CoinJoin::IsDenominatedAmount(txOut.nValue) && txOut.scriptPubKey.IsPayToPublicKeyHash(); + })) { return false; } - return std::ranges::all_of(tx->vout, [](const auto& txOut) { - return CoinJoin::IsDenominatedAmount(txOut.nValue) && txOut.scriptPubKey.IsPayToPublicKeyHash(); - }); + + // Post-V24: allow unbalanced counts (promotion/demotion) and up to 200 inputs + // (20 participants * 10 inputs for promotions) + // Pre-V24: require balanced input/output counts (1:1 mixing only), max 180 inputs + // (20 participants * 9 entries) + // Note: For post-V24 unbalanced transactions (promotion/demotion), value sum validation + // (inputs == outputs) requires UTXO access and is performed in IsValidInOuts() when the + // transaction is processed. + const size_t nMaxInputsPreV24 = CoinJoin::GetMaxPoolParticipants() * COINJOIN_ENTRY_MAX_SIZE; + const size_t nMaxInOutsPostV24 = CoinJoin::GetMaxPoolParticipants() * CoinJoin::PROMOTION_RATIO; + const bool fValidPreV24 = tx->vin.size() == tx->vout.size() && tx->vin.size() <= nMaxInputsPreV24; + // Post-V24 the input/output counts no longer bound each other, so cap them independently; + // demotion entries produce up to PROMOTION_RATIO outputs each, mirroring promotion inputs + const bool fValidPostV24 = tx->vin.size() <= nMaxInOutsPostV24 && tx->vout.size() <= nMaxInOutsPostV24; + + const bool fV24Active = pindex && DeploymentActiveAt(*pindex, chainman, Consensus::DEPLOYMENT_V24); + if (fV24Active) { + return fValidPostV24; + } + + // Pre-V24: report whether the tx would be valid on a post-V24 tip so callers can avoid + // fully punishing relayers whose tip is ahead of ours around the activation boundary + if (!fValidPreV24 && fValidPostV24 && fPossiblyValidPostV24Ret) { + *fPossiblyValidPostV24Ret = true; + } + return fValidPreV24; } void CCoinJoinBaseSession::SetNull() @@ -204,27 +240,91 @@ std::string CCoinJoinBaseSession::GetStateString() const } } +bool CoinJoin::IsPromotionDemotionActive(const ChainstateManager& chainman) +{ + LOCK(::cs_main); + const CBlockIndex* pindex = chainman.ActiveChain().Tip(); + return pindex && DeploymentActiveAt(*pindex, chainman, Consensus::DEPLOYMENT_V24); +} + bool CCoinJoinBaseSession::IsValidInOuts(Chainstate& active_chainstate, const llmq::CInstantSendManager& isman, const CTxMemPool& mempool, const std::vector& vin, const std::vector& vout, int session_denom, PoolMessage& nMessageIDRet, - bool* fConsumeCollateralRet) + bool* fConsumeCollateralRet, bool fFinalTx) { std::set setScripPubKeys; nMessageIDRet = MSG_NOERR; if (fConsumeCollateralRet) *fConsumeCollateralRet = false; - if (vin.size() != vout.size()) { + // Check if V24 is active for promotion/demotion support + const bool fV24Active = CoinJoin::IsPromotionDemotionActive(active_chainstate.m_chainman); + + // Determine entry type based on input/output counts + // Standard: N inputs, N outputs (same denom) + // Promotion: PROMOTION_RATIO inputs of session denom, 1 output of larger adjacent denom + // Demotion: 1 input of larger adjacent denom, PROMOTION_RATIO outputs of session denom + // FinalTx: aggregate of the above across all participants - per-entry shapes don't apply + enum class EntryType { STANDARD, PROMOTION, DEMOTION, FINAL_TX, INVALID }; + EntryType entryType = EntryType::STANDARD; + + if (fFinalTx && fV24Active) { + entryType = EntryType::FINAL_TX; + } else if (vin.size() == vout.size()) { + entryType = EntryType::STANDARD; + } else if (fV24Active) { + if (vin.size() == static_cast(CoinJoin::PROMOTION_RATIO) && vout.size() == 1) { + entryType = EntryType::PROMOTION; + } else if (vin.size() == 1 && vout.size() == static_cast(CoinJoin::PROMOTION_RATIO)) { + entryType = EntryType::DEMOTION; + } else { + entryType = EntryType::INVALID; + } + } else { + // Pre-V24: only standard entries allowed LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::%s -- ERROR: inputs vs outputs size mismatch! %d vs %d\n", __func__, vin.size(), vout.size()); nMessageIDRet = ERR_SIZE_MISMATCH; if (fConsumeCollateralRet) *fConsumeCollateralRet = true; return false; } - auto checkTxOut = [&](const CTxOut& txout) { - if (int nDenom = CoinJoin::AmountToDenomination(txout.nValue); nDenom != session_denom) { - LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::IsValidInOuts -- incompatible denom %d (%s) != %d (%s)\n", - nDenom, CoinJoin::DenominationToString(nDenom), session_denom, - CoinJoin::DenominationToString(session_denom)); + if (entryType == EntryType::INVALID) { + LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::%s -- ERROR: invalid entry structure! %d inputs, %d outputs\n", __func__, vin.size(), vout.size()); + nMessageIDRet = ERR_SIZE_MISMATCH; + if (fConsumeCollateralRet) *fConsumeCollateralRet = true; + return false; + } + + // Validate promotion/demotion entries using dedicated validators + // and determine expected denominations for UTXO input validation + int nExpectedInputDenom = session_denom; + int nExpectedOutputDenom = session_denom; + // Final tx only: promotion outputs and demotion inputs use the larger adjacent + // denomination, so it is allowed in addition to the session denomination. + // 0 (never matched) for per-entry validation or when session_denom is the largest denom. + int nLargerDenom{0}; + + if (entryType == EntryType::PROMOTION) { + if (!CoinJoin::ValidatePromotionEntry(vin, vout, session_denom, nMessageIDRet)) { + if (fConsumeCollateralRet) *fConsumeCollateralRet = true; + return false; + } + nExpectedOutputDenom = CoinJoin::GetLargerAdjacentDenom(session_denom); + } else if (entryType == EntryType::DEMOTION) { + if (!CoinJoin::ValidateDemotionEntry(vin, vout, session_denom, nMessageIDRet)) { + if (fConsumeCollateralRet) *fConsumeCollateralRet = true; + return false; + } + nExpectedInputDenom = CoinJoin::GetLargerAdjacentDenom(session_denom); + } else if (entryType == EntryType::FINAL_TX) { + nLargerDenom = CoinJoin::GetLargerAdjacentDenom(session_denom); + } + + auto checkTxOut = [&](const CTxOut& txout, int nExpectedDenom) { + const int nDenom = CoinJoin::AmountToDenomination(txout.nValue); + + if (nDenom != nExpectedDenom && (nLargerDenom == 0 || nDenom != nLargerDenom)) { + LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::IsValidInOuts -- ERROR: incompatible denom %d (%s) != expected %d (%s)\n", + nDenom, CoinJoin::DenominationToString(nDenom), nExpectedDenom, CoinJoin::DenominationToString(nExpectedDenom)); nMessageIDRet = ERR_DENOM; if (fConsumeCollateralRet) *fConsumeCollateralRet = true; return false; @@ -235,23 +335,27 @@ bool CCoinJoinBaseSession::IsValidInOuts(Chainstate& active_chainstate, const ll if (fConsumeCollateralRet) *fConsumeCollateralRet = true; return false; } + // Check for duplicate scripts across all inputs and outputs (privacy requirement) if (!setScripPubKeys.insert(txout.scriptPubKey).second) { LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::IsValidInOuts -- ERROR: already have this script! scriptPubKey=%s\n", ScriptToAsmStr(txout.scriptPubKey)); nMessageIDRet = ERR_ALREADY_HAVE; if (fConsumeCollateralRet) *fConsumeCollateralRet = true; return false; } - // IsPayToPublicKeyHash() above already checks for scriptPubKey size, - // no need to double-check, hence no usage of ERR_NON_STANDARD_PUBKEY return true; }; CAmount nFees{0}; + size_t nLargerInputs{0}; + size_t nLargerOutputs{0}; for (const auto& txout : vout) { - if (!checkTxOut(txout)) { + if (!checkTxOut(txout, nExpectedOutputDenom)) { return false; } + if (nLargerDenom != 0 && CoinJoin::AmountToDenomination(txout.nValue) == nLargerDenom) { + ++nLargerOutputs; + } nFees -= txout.nValue; } @@ -275,21 +379,41 @@ bool CCoinJoinBaseSession::IsValidInOuts(Chainstate& active_chainstate, const ll return false; } - if (!checkTxOut(coin.out)) { + if (!checkTxOut(coin.out, nExpectedInputDenom)) { return false; } + if (nLargerDenom != 0 && CoinJoin::AmountToDenomination(coin.out.nValue) == nLargerDenom) { + ++nLargerInputs; + } + nFees += coin.out.nValue; } - // The same size and denom for inputs and outputs ensures their total value is also the same, - // no need to double-check. If not, we are doing something wrong, bail out. + // Value sum must match: inputs == outputs (no fees in CoinJoin) + // This holds for standard mixing (same denom) and promotion/demotion (value preserved) if (nFees != 0) { LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::%s -- ERROR: non-zero fees! fees: %lld\n", __func__, nFees); nMessageIDRet = ERR_FEES; return false; } + if (entryType == EntryType::FINAL_TX && + !CoinJoin::ValidateFinalTxComposition(vin.size(), vout.size(), nLargerInputs, nLargerOutputs)) { + LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::%s -- ERROR: inconsistent final tx composition! %d/%d total, %d/%d larger denom inputs/outputs\n", + __func__, vin.size(), vout.size(), nLargerInputs, nLargerOutputs); + nMessageIDRet = ERR_SIZE_MISMATCH; + return false; + } + + LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::%s -- Valid %s entry: %d inputs, %d outputs\n", + __func__, + entryType == EntryType::PROMOTION ? "PROMOTION" : + entryType == EntryType::DEMOTION ? "DEMOTION" : + entryType == EntryType::FINAL_TX ? "FINAL_TX" : + "STANDARD", + vin.size(), vout.size()); + return true; } @@ -436,11 +560,7 @@ CCoinJoinBroadcastTx CDSTXManager::GetDSTX(const uint256& hash) bool CDSTXManager::IsTxExpired(const CCoinJoinBroadcastTx& tx, const CBlockIndex* pindex) const { - // expire confirmed DSTXes after ~1h since confirmation or chainlocked - const auto& opt_confirmed_height = tx.GetConfirmedHeight(); - if (!opt_confirmed_height.has_value() || pindex->nHeight < *opt_confirmed_height) return false; // not mined yet - return (pindex->nHeight - *opt_confirmed_height > 24) || - m_chainlocks.HasChainLock(pindex->nHeight, *pindex->phashBlock); // mined more than an hour ago or chainlocked + return tx.IsExpired(pindex, m_chainlocks); } void CDSTXManager::CheckDSTXes(const CBlockIndex* pindex) @@ -513,3 +633,111 @@ void CDSTXManager::BlockDisconnected(const std::shared_ptr& pblock int CoinJoin::GetMinPoolParticipants() { return Params().PoolMinParticipants(); } int CoinJoin::GetMaxPoolParticipants() { return Params().PoolMaxParticipants(); } + +bool CoinJoin::ValidatePromotionEntry(const std::vector& vecTxIn, const std::vector& vecTxOut, + int nSessionDenom, PoolMessage& nMessageIDRet) +{ + // Promotion: 10 inputs of smaller denom → 1 output of larger denom + // Session denom is the smaller denom (inputs) + nMessageIDRet = MSG_NOERR; + + // Check input count + if (vecTxIn.size() != static_cast(PROMOTION_RATIO)) { + LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: wrong input count %zu, expected %d\n", + vecTxIn.size(), PROMOTION_RATIO); + nMessageIDRet = ERR_SIZE_MISMATCH; + return false; + } + + // Check output count + if (vecTxOut.size() != 1) { + LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: wrong output count %zu, expected 1\n", + vecTxOut.size()); + nMessageIDRet = ERR_SIZE_MISMATCH; + return false; + } + + // Get the larger adjacent denomination + const int nLargerDenom = GetLargerAdjacentDenom(nSessionDenom); + if (nLargerDenom == 0) { + LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: no larger adjacent denom for %s\n", + DenominationToString(nSessionDenom)); + nMessageIDRet = ERR_DENOM; + return false; + } + + // Validate output is at larger denomination + const int nOutputDenom = AmountToDenomination(vecTxOut[0].nValue); + if (nOutputDenom != nLargerDenom) { + LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: output denom %s != expected %s\n", + DenominationToString(nOutputDenom), DenominationToString(nLargerDenom)); + nMessageIDRet = ERR_DENOM; + return false; + } + + // Validate output is P2PKH + if (!vecTxOut[0].scriptPubKey.IsPayToPublicKeyHash()) { + LogPrint(BCLog::COINJOIN, "CoinJoin::ValidatePromotionEntry -- ERROR: output is not P2PKH\n"); + nMessageIDRet = ERR_INVALID_SCRIPT; + return false; + } + + return true; +} + +bool CoinJoin::ValidateDemotionEntry(const std::vector& vecTxIn, const std::vector& vecTxOut, + int nSessionDenom, PoolMessage& nMessageIDRet) +{ + // Demotion: 1 input of larger denom → 10 outputs of smaller denom + // Session denom is the smaller denom (outputs) + nMessageIDRet = MSG_NOERR; + + // Check input count + if (vecTxIn.size() != 1) { + LogPrint(BCLog::COINJOIN, "CoinJoin::ValidateDemotionEntry -- ERROR: wrong input count %zu, expected 1\n", + vecTxIn.size()); + nMessageIDRet = ERR_SIZE_MISMATCH; + return false; + } + + // Check output count + if (vecTxOut.size() != static_cast(PROMOTION_RATIO)) { + LogPrint(BCLog::COINJOIN, "CoinJoin::ValidateDemotionEntry -- ERROR: wrong output count %zu, expected %d\n", + vecTxOut.size(), PROMOTION_RATIO); + nMessageIDRet = ERR_SIZE_MISMATCH; + return false; + } + + // Validate all outputs are at session denomination and P2PKH + for (const auto& txout : vecTxOut) { + const int nDenom = AmountToDenomination(txout.nValue); + if (nDenom != nSessionDenom) { + LogPrint(BCLog::COINJOIN, "CoinJoin::ValidateDemotionEntry -- ERROR: output denom %s != session denom %s\n", + DenominationToString(nDenom), DenominationToString(nSessionDenom)); + nMessageIDRet = ERR_DENOM; + return false; + } + if (!txout.scriptPubKey.IsPayToPublicKeyHash()) { + LogPrint(BCLog::COINJOIN, "CoinJoin::ValidateDemotionEntry -- ERROR: output is not P2PKH\n"); + nMessageIDRet = ERR_INVALID_SCRIPT; + return false; + } + } + + return true; +} + +bool CoinJoin::ValidateFinalTxComposition(size_t nTotalInputs, size_t nTotalOutputs, + size_t nLargerInputs, size_t nLargerOutputs) +{ + // Aggregate consistency: every larger-denom output consumes PROMOTION_RATIO session-denom + // inputs (promotion) and every larger-denom input produces PROMOTION_RATIO session-denom + // outputs (demotion); the remainder on both sides are standard 1:1 inputs/outputs and + // must not be negative. Together with the zero-fee check in IsValidInOuts() this + // guarantees the final tx is composable from valid standard/promotion/demotion entries. + if (nLargerInputs > nTotalInputs || nLargerOutputs > nTotalOutputs) return false; + const size_t nSessionInputs = nTotalInputs - nLargerInputs; + const size_t nSessionOutputs = nTotalOutputs - nLargerOutputs; + return nSessionInputs >= nLargerOutputs * size_t(PROMOTION_RATIO) && + nSessionOutputs >= nLargerInputs * size_t(PROMOTION_RATIO); +} diff --git a/src/coinjoin/coinjoin.h b/src/coinjoin/coinjoin.h index 2f8f344e790d..1bb2eb67b77d 100644 --- a/src/coinjoin/coinjoin.h +++ b/src/coinjoin/coinjoin.h @@ -203,6 +203,15 @@ class CCoinJoinEntry } bool AddScriptSig(const CTxIn& txin); + + // Check if this is a standard mixing entry (not promotion/demotion) + // Standard: equal number of inputs and outputs + // Promotion: PROMOTION_RATIO inputs, 1 output + // Demotion: 1 input, PROMOTION_RATIO outputs + bool IsStandardMixingEntry() const + { + return vecTxDSIn.size() == vecTxOut.size(); + } }; @@ -317,7 +326,15 @@ class CCoinJoinBroadcastTx [[nodiscard]] const std::optional& GetConfirmedHeight() const { return nConfirmedHeight; } void SetConfirmedHeight(std::optional nConfirmedHeightIn) { assert(nConfirmedHeightIn == std::nullopt || *nConfirmedHeightIn > 0); nConfirmedHeight = nConfirmedHeightIn; } - [[nodiscard]] bool IsValidStructure() const; + [[nodiscard]] bool IsExpired(const CBlockIndex* pindex, const chainlock::Chainlocks& clhandler) const; + /** + * Trivial structural checks against the V24-dependent rules at pindex. When the tx fails + * only because V24 is not active at pindex but would be structurally valid on a post-V24 + * tip, fPossiblyValidPostV24Ret (if provided) is set to true so callers can tolerate tip + * skew around the activation boundary. + */ + [[nodiscard]] bool IsValidStructure(const CBlockIndex* pindex, const ChainstateManager& chainman, + bool* fPossiblyValidPostV24Ret = nullptr) const; }; // base class @@ -337,9 +354,17 @@ class CCoinJoinBaseSession virtual void SetNull() EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin); + /** + * Validate inputs/outputs of a single mixing entry or, with fFinalTx=true, of the + * aggregated final transaction. Post-V24 a final transaction concatenates standard (N:N), + * promotion (10:1) and demotion (1:10) entries, so per-entry shape rules don't apply to it; + * aggregate rules (allowed denominations, value balance, input/output consistency) are + * checked instead. session_denom is the caller's snapshot of the session denomination. + */ static bool IsValidInOuts(Chainstate& active_chainstate, const llmq::CInstantSendManager& isman, const CTxMemPool& mempool, const std::vector& vin, const std::vector& vout, - int session_denom, PoolMessage& nMessageIDRet, bool* fConsumeCollateralRet); + int session_denom, PoolMessage& nMessageIDRet, bool* fConsumeCollateralRet, + bool fFinalTx = false); public: // Atomic because the message-handling and scheduler threads write it while those threads and @@ -354,6 +379,20 @@ class CCoinJoinBaseSession int GetEntriesCount() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) { LOCK(cs_coinjoin); return vecEntries.size(); } int GetEntriesCountLocked() const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin) { return vecEntries.size(); } + + // Count only standard mixing entries (not promotion/demotion) for privacy threshold + int GetStandardEntriesCount() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) + { + LOCK(cs_coinjoin); + return std::count_if(vecEntries.begin(), vecEntries.end(), + [](const CCoinJoinEntry& entry) { return entry.IsStandardMixingEntry(); }); + } + + int GetStandardEntriesCountLocked() const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin) + { + return std::count_if(vecEntries.begin(), vecEntries.end(), + [](const CCoinJoinEntry& entry) { return entry.IsStandardMixingEntry(); }); + } }; class CoinJoinQueueManager @@ -417,9 +456,48 @@ namespace CoinJoin constexpr CAmount GetMaxPoolAmount() { return COINJOIN_ENTRY_MAX_SIZE * vecStandardDenominations.front(); } + /// Whether denomination promotion/demotion (post-V24) is active at the current chain tip + bool IsPromotionDemotionActive(const ChainstateManager& chainman); + /// If the collateral is valid given by a client bool IsCollateralValid(ChainstateManager& chainman, const llmq::CInstantSendManager& isman, const CTxMemPool& mempool, const CTransaction& txCollateral); + + /** + * Validate a promotion entry: 10 inputs of smaller denom → 1 output of larger denom + * @param vecTxIn The inputs for this entry + * @param vecTxOut The outputs for this entry + * @param nSessionDenom The session denomination (the smaller denom for promotion) + * @param nMessageIDRet Error message if validation fails + * @return true if valid promotion entry + */ + bool ValidatePromotionEntry(const std::vector& vecTxIn, const std::vector& vecTxOut, + int nSessionDenom, PoolMessage& nMessageIDRet); + + /** + * Validate a demotion entry: 1 input of larger denom → 10 outputs of smaller denom + * @param vecTxIn The inputs for this entry + * @param vecTxOut The outputs for this entry + * @param nSessionDenom The session denomination (the smaller denom for demotion outputs) + * @param nMessageIDRet Error message if validation fails + * @return true if valid demotion entry + */ + bool ValidateDemotionEntry(const std::vector& vecTxIn, const std::vector& vecTxOut, + int nSessionDenom, PoolMessage& nMessageIDRet); + + /** + * Check that a final transaction's aggregate input/output counts are composable from valid + * standard (N:N), promotion (PROMOTION_RATIO:1) and demotion (1:PROMOTION_RATIO) entries. + * Inputs/outputs at the larger adjacent denomination are counted separately from the + * session-denomination remainder; value balance is checked by the caller. + * @param nTotalInputs Total number of inputs in the final tx + * @param nTotalOutputs Total number of outputs in the final tx + * @param nLargerInputs Number of inputs at the larger adjacent denomination (demotions) + * @param nLargerOutputs Number of outputs at the larger adjacent denomination (promotions) + * @return true if the counts are consistent with a mix of valid entries + */ + bool ValidateFinalTxComposition(size_t nTotalInputs, size_t nTotalOutputs, + size_t nLargerInputs, size_t nLargerOutputs); } class CDSTXManager diff --git a/src/coinjoin/common.h b/src/coinjoin/common.h index dde5bf041c54..4a8ccbfa096e 100644 --- a/src/coinjoin/common.h +++ b/src/coinjoin/common.h @@ -47,6 +47,21 @@ constexpr std::array vecStandardDenominations{ constexpr std::array GetStandardDenominations() { return vecStandardDenominations; } constexpr CAmount GetSmallestDenomination() { return vecStandardDenominations.back(); } +/** + * Get the index of a denomination in vecStandardDenominations (0=largest, 4=smallest) + * Returns -1 if not a valid denomination + */ +constexpr int GetDenominationIndex(int nDenom) +{ + if (nDenom <= 0) return -1; + for (size_t i = 0; i < vecStandardDenominations.size(); ++i) { + if (nDenom == (1 << i)) { + return static_cast(i); + } + } + return -1; +} + /* Return a bitshifted integer representing a denomination in vecStandardDenominations or 0 if none was found @@ -74,9 +89,7 @@ constexpr CAmount DenominationToAmount(int nDenom) return 0; } - size_t nMaxDenoms = vecStandardDenominations.size(); - - if (nDenom >= (1 << nMaxDenoms) || nDenom < 0) { + if (nDenom >= (1 << vecStandardDenominations.size()) || nDenom < 0) { // out of bounds return -1; } @@ -86,16 +99,8 @@ constexpr CAmount DenominationToAmount(int nDenom) return -2; } - CAmount nDenomAmount{-3}; - - for (size_t i = 0; i < nMaxDenoms; ++i) { - if (nDenom & (1 << i)) { - nDenomAmount = vecStandardDenominations[i]; - break; - } - } - - return nDenomAmount; + const int idx = GetDenominationIndex(nDenom); + return idx >= 0 ? vecStandardDenominations[idx] : -3; } @@ -110,6 +115,74 @@ std::string DenominationToString(int nDenom); constexpr CAmount GetCollateralAmount() { return GetSmallestDenomination() / 10; } constexpr CAmount GetMaxCollateralAmount() { return GetCollateralAmount() * 4; } +// Promotion/demotion constants (post-V24 feature) +constexpr int PROMOTION_RATIO = 10; // 10 smaller denomination coins = 1 larger denomination coin +constexpr int GAP_THRESHOLD = 10; // Deficit gap required to trigger promotion/demotion + +/** + * Check if two denominations are adjacent (one step apart in the denom list) + * Used for validating promotion/demotion entries post-V24 + */ +constexpr bool AreAdjacentDenominations(int nDenom1, int nDenom2) +{ + int idx1 = GetDenominationIndex(nDenom1); + int idx2 = GetDenominationIndex(nDenom2); + if (idx1 < 0 || idx2 < 0) return false; + return (idx1 == idx2 + 1) || (idx1 == idx2 - 1); +} + +/** + * Get the larger adjacent denomination (returns 0 if none exists or invalid) + */ +constexpr int GetLargerAdjacentDenom(int nDenom) +{ + int idx = GetDenominationIndex(nDenom); + if (idx <= 0) return 0; // Already largest or invalid + return 1 << (idx - 1); +} + +/** + * Get the smaller adjacent denomination (returns 0 if none exists or invalid) + */ +constexpr int GetSmallerAdjacentDenom(int nDenom) +{ + int idx = GetDenominationIndex(nDenom); + if (idx < 0 || idx >= static_cast(vecStandardDenominations.size()) - 1) return 0; + return 1 << (idx + 1); +} + +/** + * Core promotion decision (post-V24): combine PROMOTION_RATIO fully-mixed coins of the + * smaller denomination into one coin of the larger adjacent denomination when the larger + * denomination is further from the per-denom goal by more than GAP_THRESHOLD (the gap + * prevents promote/demote oscillation). Callers resolve wallet counts and validate that + * the denominations are adjacent. + */ +constexpr bool ShouldPromoteDenoms(int nSmallerCount, int nLargerCount, int nSmallerFullyMixedCount, int nGoal) +{ + // Don't sacrifice a denomination that's still being built up + if (nSmallerCount < nGoal / 2) return false; + // A promotion consumes PROMOTION_RATIO fully-mixed coins + if (nSmallerFullyMixedCount < PROMOTION_RATIO) return false; + const int nSmallerDeficit = nSmallerCount < nGoal ? nGoal - nSmallerCount : 0; + const int nLargerDeficit = nLargerCount < nGoal ? nGoal - nLargerCount : 0; + return nLargerDeficit > nSmallerDeficit + GAP_THRESHOLD; +} + +/** + * Core demotion decision (post-V24): split one coin of the larger denomination into + * PROMOTION_RATIO coins of the smaller adjacent denomination when the smaller denomination + * is further from the per-denom goal by more than GAP_THRESHOLD. + */ +constexpr bool ShouldDemoteDenoms(int nLargerCount, int nSmallerCount, int nGoal) +{ + // Don't sacrifice a denomination that's still being built up + if (nLargerCount < nGoal / 2) return false; + const int nSmallerDeficit = nSmallerCount < nGoal ? nGoal - nSmallerCount : 0; + const int nLargerDeficit = nLargerCount < nGoal ? nGoal - nLargerCount : 0; + return nSmallerDeficit > nLargerDeficit + GAP_THRESHOLD; +} + constexpr bool IsCollateralAmount(CAmount nInputAmount) { // collateral input can be anything between 1x and "max" (including both) diff --git a/src/coinjoin/server.cpp b/src/coinjoin/server.cpp index 9479158a5d82..bcc4ca62d4cf 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -211,6 +212,9 @@ void CCoinJoinServer::ProcessDSVIN(CNode& peer, CDataStream& vRecv) LogPrint(BCLog::COINJOIN, "DSVIN -- txCollateral %s", entry.txCollateral->ToString()); /* Continued */ + // Note: unbalanced (promotion/demotion) entries are only valid post-V24; AddEntry -> + // IsValidInOuts rejects them pre-V24 and consumes the collateral to keep spam costly + PoolMessage nMessageID = MSG_NOERR; entry.addr = peer.addr; @@ -299,15 +303,26 @@ void CCoinJoinServer::CheckPool() // If we have an entry for each collateral, then create final tx if (nState == POOL_STATE_ACCEPTING_ENTRIES && static_cast(GetEntriesCount()) == vecSessionCollaterals.size()) { - LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n"); - CreateFinalTransaction(); + const int nStandardEntries = GetStandardEntriesCount(); + if (nStandardEntries >= CoinJoin::GetMinPoolParticipants()) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n"); + CreateFinalTransaction(); + return; + } + // The participant set is frozen once entries are being accepted, so this session can + // never reach the standard-mixer minimum anymore - reset instead of stalling everyone + // until timeout. Shouldn't happen given the promotion/demotion entry cap in AddEntry. + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- all entries received but insufficient standard mixers (%d), resetting session\n", nStandardEntries); + WITH_LOCK(cs_coinjoin, SetNull()); return; } // Check for Time Out // If we timed out while accepting entries, then if we have more than minimum, create final tx - if (nState == POOL_STATE_ACCEPTING_ENTRIES && CCoinJoinServer::HasTimedOut() && - GetEntriesCount() >= CoinJoin::GetMinPoolParticipants()) { + // PRIVACY: Only count standard mixing entries toward minimum participant threshold + // Promotion/demotion entries don't count - they get privacy from standard mixers + if (nState == POOL_STATE_ACCEPTING_ENTRIES && CCoinJoinServer::HasTimedOut() + && GetStandardEntriesCount() >= CoinJoin::GetMinPoolParticipants()) { // Punish misbehaving participants ChargeFees(); // Try to complete this session ignoring the misbehaving ones @@ -637,10 +652,14 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag session_denom = nSessionDenom; } - if (entry.vecTxDSIn.size() > COINJOIN_ENTRY_MAX_SIZE || entry.vecTxOut.size() > COINJOIN_ENTRY_MAX_SIZE) { + // Post-V24: promotion entries carry PROMOTION_RATIO (10) inputs and demotion entries + // PROMOTION_RATIO outputs; pre-V24 entries are capped at COINJOIN_ENTRY_MAX_SIZE (9) + const size_t nMaxEntrySize = CoinJoin::IsPromotionDemotionActive(m_chainman) ? size_t(CoinJoin::PROMOTION_RATIO) + : COINJOIN_ENTRY_MAX_SIZE; + if (entry.vecTxDSIn.size() > nMaxEntrySize || entry.vecTxOut.size() > nMaxEntrySize) { LogPrint(BCLog::COINJOIN, /* Continued */ "CCoinJoinServer::%s -- ERROR: too many inputs or outputs! inputs=%s/%s, outputs=%s/%s\n", __func__, - entry.vecTxDSIn.size(), COINJOIN_ENTRY_MAX_SIZE, entry.vecTxOut.size(), COINJOIN_ENTRY_MAX_SIZE); + entry.vecTxDSIn.size(), nMaxEntrySize, entry.vecTxOut.size(), nMaxEntrySize); nMessageIDRet = ERR_MAXIMUM; CTransactionRef txCollateralToConsume; @@ -667,6 +686,22 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag return false; } + // Post-V24: cap promotion/demotion entries so the session can still reach + // GetMinPoolParticipants() standard entries once every collateral is matched by an + // entry. The participant set is frozen while accepting entries, so without this cap + // the pool could fill up but never satisfy the standard-mixer minimum in CheckPool. + if (!entry.IsStandardMixingEntry()) { + const int nMaxNonStandardEntries = int(vecSessionCollaterals.size()) - CoinJoin::GetMinPoolParticipants(); + const int nNonStandardEntries = GetEntriesCount() - GetStandardEntriesCount(); + if (nNonStandardEntries >= nMaxNonStandardEntries) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- rejecting promotion/demotion entry, not enough standard mixer slots left! %d/%d\n", + __func__, nNonStandardEntries, nMaxNonStandardEntries); + // the entry itself is valid, the session just can't accept it - don't punish + nMessageIDRet = ERR_ENTRIES_FULL; + return false; + } + } + std::vector vin; vin.reserve(entry.vecTxDSIn.size()); for (const auto& txin : entry.vecTxDSIn) { diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 0e265c36bb2c..c398e3582dab 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -3514,6 +3514,7 @@ void PeerManagerImpl::ProcessGetCFCheckPt(CNode& node, Peer& peer, CDataStream& enum class DSTXValidationScore : int { NONE = 0, UNKNOWN_MASTERNODE = 1, + PREMATURE = 1, INVALID = 10, }; @@ -3528,10 +3529,6 @@ static DSTXValidationResult ValidateDSTX(CDeterministicMNManager& dmnman, CDSTXM { assert(mn_metaman.IsValid()); - if (!dstx.IsValidStructure()) { - LogPrint(BCLog::COINJOIN, "DSTX -- Invalid DSTX structure: %s\n", hashTx.ToString()); - return {DSTXValidationScore::INVALID, true}; - } if (dstxman.GetDSTX(hashTx)) { LogPrint(BCLog::COINJOIN, "DSTX -- Already have %s, skipping...\n", hashTx.ToString()); return {DSTXValidationScore::NONE, true}; // not an error @@ -3543,6 +3540,20 @@ static DSTXValidationResult ValidateDSTX(CDeterministicMNManager& dmnman, CDSTXM LOCK(cs_main); pindex = chainman.ActiveChain().Tip(); } + + bool fPossiblyValidPostV24{false}; + if (!dstx.IsValidStructure(pindex, chainman, &fPossiblyValidPostV24)) { + LogPrint(BCLog::COINJOIN, "DSTX -- Invalid DSTX structure: %s\n", hashTx.ToString()); + // A tx that is structurally valid under post-V24 rules may come from a masternode + // whose tip is ahead of ours around the activation boundary. Like the 24-block-deep + // masternode scan below, tolerate the skew: drop it with only a small penalty so + // honest relayers are unaffected while a peer flooding us still gets discouraged + // eventually. Anything else malformed keeps the full penalty. + if (fPossiblyValidPostV24) { + return {DSTXValidationScore::PREMATURE, true}; + } + return {DSTXValidationScore::INVALID, true}; + } // It could be that a MN is no longer in the list but its DSTX is not yet mined. // Try to find a MN up to 24 blocks deep to make sure such dstx-es are relayed and processed correctly. if (dstx.masternodeOutpoint.IsNull()) { diff --git a/src/test/coinjoin_inouts_tests.cpp b/src/test/coinjoin_inouts_tests.cpp index 0753a9f56d88..fcd9dd834f94 100644 --- a/src/test/coinjoin_inouts_tests.cpp +++ b/src/test/coinjoin_inouts_tests.cpp @@ -65,22 +65,23 @@ BOOST_AUTO_TEST_CASE(broadcasttx_isvalidstructure_good_and_bad) good.tx = MakeTransactionRef(mtx); good.m_protxHash = uint256::ONE; // at least one of (outpoint, protxhash) must be set } - BOOST_CHECK(good.IsValidStructure()); + // Pre-V24 behavior (nullptr pindex = pre-fork) + BOOST_CHECK(good.IsValidStructure(nullptr, *Assert(m_node.chainman))); // Bad: both identifiers null CCoinJoinBroadcastTx bad_ids = good; bad_ids.m_protxHash = uint256{}; bad_ids.masternodeOutpoint.SetNull(); - BOOST_CHECK(!bad_ids.IsValidStructure()); + BOOST_CHECK(!bad_ids.IsValidStructure(nullptr, *Assert(m_node.chainman))); - // Bad: vin/vout size mismatch + // Bad: vin/vout size mismatch (invalid pre-V24) CCoinJoinBroadcastTx bad_sizes = good; { CMutableTransaction mtx(*good.tx); mtx.vout.pop_back(); bad_sizes.tx = MakeTransactionRef(mtx); } - BOOST_CHECK(!bad_sizes.IsValidStructure()); + BOOST_CHECK(!bad_sizes.IsValidStructure(nullptr, *Assert(m_node.chainman))); // Bad: non-P2PKH output CCoinJoinBroadcastTx bad_script = good; @@ -89,7 +90,7 @@ BOOST_AUTO_TEST_CASE(broadcasttx_isvalidstructure_good_and_bad) mtx.vout[0].scriptPubKey = CScript() << OP_RETURN << std::vector{'x'}; bad_script.tx = MakeTransactionRef(mtx); } - BOOST_CHECK(!bad_script.IsValidStructure()); + BOOST_CHECK(!bad_script.IsValidStructure(nullptr, *Assert(m_node.chainman))); // Bad: non-denominated amount CCoinJoinBroadcastTx bad_amount = good; @@ -98,7 +99,7 @@ BOOST_AUTO_TEST_CASE(broadcasttx_isvalidstructure_good_and_bad) mtx.vout[0].nValue = 42; // not a valid denom bad_amount.tx = MakeTransactionRef(mtx); } - BOOST_CHECK(!bad_amount.IsValidStructure()); + BOOST_CHECK(!bad_amount.IsValidStructure(nullptr, *Assert(m_node.chainman))); } BOOST_AUTO_TEST_CASE(entry_addscriptsig_matches_and_rejects) diff --git a/src/wallet/coinjoin.cpp b/src/wallet/coinjoin.cpp index 0b773a70d436..bb09edf3d47f 100644 --- a/src/wallet/coinjoin.cpp +++ b/src/wallet/coinjoin.cpp @@ -46,6 +46,11 @@ bool CWallet::SetCoinJoinSalt(const uint256& cj_salt) } bool CWallet::SelectTxDSInsByDenomination(int nDenom, CAmount nValueMax, std::vector& vecTxDSInRet) +{ + return SelectTxDSInsByDenomination(nDenom, nValueMax, vecTxDSInRet, CoinType::ONLY_READY_TO_MIX); +} + +bool CWallet::SelectTxDSInsByDenomination(int nDenom, CAmount nValueMax, std::vector& vecTxDSInRet, CoinType nCoinType) { LOCK(cs_wallet); @@ -56,11 +61,11 @@ bool CWallet::SelectTxDSInsByDenomination(int nDenom, CAmount nValueMax, std::ve CAmount nDenomAmount{CoinJoin::DenominationToAmount(nDenom)}; CAmount nValueTotal{0}; - CCoinControl coin_control(CoinType::ONLY_READY_TO_MIX); + CCoinControl coin_control(nCoinType); std::set setRecentTxIds; std::vector vCoins{AvailableCoinsListUnspent(*this, &coin_control).all()}; - WalletCJLogPrint(this, "CWallet::%s -- vCoins.size(): %d\n", __func__, vCoins.size()); + WalletCJLogPrint(this, "CWallet::%s -- vCoins.size(): %d, CoinType: %d\n", __func__, vCoins.size(), static_cast(nCoinType)); Shuffle(vCoins.rbegin(), vCoins.rend(), FastRandomContext()); @@ -406,6 +411,67 @@ bool CWallet::IsFullyMixed(const COutPoint& outpoint) const return true; } +CoinJoinDenomCounts CWallet::GetDenominationCounts() const +{ + CoinJoinDenomCounts counts; + if (!CCoinJoinClientOptions::IsEnabled()) return counts; + + const auto& denoms = CoinJoin::GetStandardDenominations(); + + LOCK(cs_wallet); + for (const auto& outpoint : setWalletUTXO) { + const auto it{mapWallet.find(outpoint.hash)}; + if (it == mapWallet.end()) continue; + + if (IsSpent(outpoint) || IsLockedCoin(outpoint)) continue; + + const CAmount nValue = it->second.tx->vout[outpoint.n].nValue; + const auto denom_it = std::find(denoms.begin(), denoms.end(), nValue); + if (denom_it == denoms.end()) continue; + + // Skip unconfirmed or conflicted + if (GetTxDepthInMainChain(it->second) < 1) continue; + + const size_t idx = static_cast(denom_it - denoms.begin()); + counts.total[idx]++; + if (IsFullyMixed(outpoint)) counts.fully_mixed[idx]++; + } + + return counts; +} + +std::vector CWallet::SelectFullyMixedForPromotion(int nDenom, int nCount) const +{ + std::vector vecRet; + if (!CCoinJoinClientOptions::IsEnabled()) return vecRet; + + const CAmount nDenomAmount = CoinJoin::DenominationToAmount(nDenom); + if (nDenomAmount <= 0) return vecRet; + + LOCK(cs_wallet); + + // Mirror SelectTxDSInsByDenomination: spendable coins only, shuffled, and at most one + // coin per transaction. Inputs sharing a parent tx are publicly linked on-chain, and a + // demotion in particular leaves 10 sibling outputs of the target denomination that must + // never be promoted together - they would be identifiable as one group in the final tx. + CCoinControl coin_control(CoinType::ONLY_FULLY_MIXED); + std::set setRecentTxIds; + std::vector vCoins{AvailableCoinsListUnspent(*this, &coin_control).all()}; + + WalletCJLogPrint(this, "CWallet::%s -- vCoins.size(): %d\n", __func__, vCoins.size()); + + Shuffle(vCoins.rbegin(), vCoins.rend(), FastRandomContext()); + + for (const auto& out : vCoins) { + if (static_cast(vecRet.size()) >= nCount) break; + if (out.txout.nValue != nDenomAmount) continue; + if (!setRecentTxIds.insert(out.outpoint.hash).second) continue; // no duplicate txids + vecRet.push_back(out.outpoint); + } + + return vecRet; +} + void CWallet::RecalculateMixedCredit(const uint256 hash) { AssertLockHeld(cs_wallet); diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 059e7712c6c4..d62b40216c78 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -34,6 +35,7 @@ #include #include +#include #include #include #include @@ -281,6 +283,14 @@ enum class RescanStatus : uint8_t { }; class WalletRescanReserver; //forward declarations for ScanForWalletTransactions/RescanFromTime + +//! Per-denomination coin counts (post-V24 promotion/demotion feature), indexed the same way as +//! CoinJoin::vecStandardDenominations (index 0 = largest denomination). +struct CoinJoinDenomCounts { + std::array total{}; + std::array fully_mixed{}; +}; + /** * A CWallet maintains a set of transactions and balances, and provides the ability to create new transactions. */ @@ -583,6 +593,7 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati // Coin selection bool SelectTxDSInsByDenomination(int nDenom, CAmount nValueMax, std::vector& vecTxDSInRet); + bool SelectTxDSInsByDenomination(int nDenom, CAmount nValueMax, std::vector& vecTxDSInRet, CoinType nCoinType); bool SelectDenominatedAmounts(CAmount nValueMax, std::set& setAmountsRet) const; std::vector SelectCoinsGroupedByAddresses(bool fSkipDenominated = true, bool fAnonymizable = true, bool fSkipUnconfirmed = true, int nMaxOupointsPerAddress = -1) const; @@ -600,6 +611,22 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati bool IsDenominated(const COutPoint& outpoint) const; bool IsFullyMixed(const COutPoint& outpoint) const; + /** + * Count coins across all standard denominations in a single wallet scan + * (post-V24 promotion/demotion feature). + */ + CoinJoinDenomCounts GetDenominationCounts() const; + + /** + * Select fully-mixed coins for promotion (post-V24 feature). Selection mirrors + * SelectTxDSInsByDenomination: spendable coins only, shuffled, at most one coin + * per transaction. + * @param nDenom The denomination to select (bitshifted integer) + * @param nCount Maximum number of coins to select + * @return Vector of outpoints for selected coins + */ + std::vector SelectFullyMixedForPromotion(int nDenom, int nCount) const; + bool IsSpent(const COutPoint& outpoint) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); // Whether this or any known UTXO with the same single key has been spent. diff --git a/test/functional/p2p_dstx.py b/test/functional/p2p_dstx.py index ca56e6dbf0ac..fe9df990367b 100755 --- a/test/functional/p2p_dstx.py +++ b/test/functional/p2p_dstx.py @@ -4,10 +4,11 @@ # file COPYING or http://www.opensource.org/licenses/mit-license.php. """Test P2P CoinJoin broadcast transaction handling. -Verifies that DSTX messages with an unverifiable (unknown) masternode incur -only a small misbehavior penalty, while clearly malformed DSTXes get the -existing stronger penalty. Also exercises the cumulative behavior so that -a peer flooding unknown-MN DSTXes is eventually discouraged. +Verifies that DSTX messages with an unverifiable (unknown) masternode, or a +structure only valid once V24 activates, incur only a small misbehavior +penalty, while clearly malformed DSTXes get the existing stronger penalty. +Also exercises the cumulative behavior so that a peer flooding unknown-MN +DSTXes is eventually discouraged. """ import time @@ -38,6 +39,9 @@ UNKNOWN_MN_SCORE = 1 # Penalty applied when the DSTX itself is structurally bad / bad signature. INVALID_DSTX_SCORE = 10 +# Penalty applied when the DSTX is only structurally valid under post-V24 rules, which a +# relayer whose tip is ahead of ours at the activation boundary may legitimately send. +PREMATURE_DSTX_SCORE = 1 class P2PDSTXTest(BitcoinTestFramework): @@ -88,7 +92,7 @@ def run_test(self): self.log.info("Structurally invalid DSTX => stronger (+%d) misbehavior penalty", INVALID_DSTX_SCORE) peer_invalid = node.add_p2p_connection(P2PInterface()) bad = self.make_dstx(nonce=2) - bad.tx.vout.pop() # vin.size() != vout.size() trips IsValidStructure + bad.tx.vout[0].nValue += 1 # not a recognised denom, invalid either side of V24 with node.assert_debug_log([ "Invalid DSTX structure", "Misbehaving", @@ -97,6 +101,21 @@ def run_test(self): ]): peer_invalid.send_and_ping(msg_dstx(bad)) + self.log.info("Unbalanced DSTX pre-V24 => small (+%d) misbehavior penalty", PREMATURE_DSTX_SCORE) + # An unbalanced input/output count is only valid post-V24, so a masternode whose tip + # is ahead of ours at the activation boundary can relay one legitimately. It is still + # rejected here, but with the same tolerant penalty an unverifiable masternode gets. + peer_premature = node.add_p2p_connection(P2PInterface()) + premature = self.make_dstx(nonce=3) + premature.tx.vout.pop() # vin.size() != vout.size() is a promotion shape + with node.assert_debug_log([ + "Invalid DSTX structure", + "Misbehaving", + "(0 -> {})".format(PREMATURE_DSTX_SCORE), + "invalid dstx", + ]): + peer_premature.send_and_ping(msg_dstx(premature)) + self.log.info("A peer flooding unknown-MN DSTXes is eventually discouraged") peer_flood = node.add_p2p_connection(P2PInterface()) # +1 per unknown-MN DSTX, so DISCOURAGEMENT_THRESHOLD distinct DSTXes From 3d6d49a83d968114fa15cee3554175c7d25c0e83 Mon Sep 17 00:00:00 2001 From: Pasta Date: Thu, 9 Jul 2026 20:33:35 -0500 Subject: [PATCH 02/26] test: cover CoinJoin promotion/demotion validation and decision logic Extend coinjoin_inouts_tests with coverage for the post-V24 promotion/demotion feature: - ValidatePromotionEntry()/ValidateDemotionEntry() happy paths, wrong input/output counts, non-adjacent denominations and edge cases - deployment-aware IsValidStructure(): pre-V24 rejection of unbalanced transactions, the fPossiblyValidPostV24 fork-boundary reporting, and the independent post-V24 input/output caps - ValidateFinalTxComposition() aggregate arithmetic for mixed standard/promotion/demotion final transactions - denomination adjacency helpers, value preservation across all adjacent pairs (10 * smaller == larger), entry-type detection and standard-entry counting - ShouldPromote()/ShouldDemote() decision logic incl. mutual exclusivity and gap-threshold hysteresis - DSTX expiry height/chainlock logic via the extracted IsExpired() Exercising a genuinely V24-active CBlockIndex needs EHF activation machinery that unit tests can't set up; those acceptance paths are covered indirectly through the extracted helpers and still need functional-test coverage. --- src/test/coinjoin_inouts_tests.cpp | 998 +++++++++++++++++++++++++++++ 1 file changed, 998 insertions(+) diff --git a/src/test/coinjoin_inouts_tests.cpp b/src/test/coinjoin_inouts_tests.cpp index fcd9dd834f94..32443ee11f7c 100644 --- a/src/test/coinjoin_inouts_tests.cpp +++ b/src/test/coinjoin_inouts_tests.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -392,4 +393,1001 @@ BOOST_AUTO_TEST_CASE(queue_timeout_bounds) // Reset mock time SetMockTime(0s); } +BOOST_AUTO_TEST_CASE(broadcasttx_expiry_height_logic) +{ + // Build a valid-looking CCoinJoinBroadcastTx with confirmed height + CCoinJoinBroadcastTx dstx; + { + CMutableTransaction mtx; + const int participants = std::max(3, CoinJoin::GetMinPoolParticipants()); + for (int i = 0; i < participants; ++i) { + mtx.vin.emplace_back(COutPoint(uint256::TWO, i)); + mtx.vout.emplace_back(CoinJoin::GetSmallestDenomination(), P2PKHScript(static_cast(i))); + } + dstx.tx = MakeTransactionRef(mtx); + dstx.m_protxHash = uint256::ONE; + // mark as confirmed at height 100 + dstx.SetConfirmedHeight(100); + } + + // Minimal CBlockIndex with required fields + // Create a minimal block index to satisfy the interface + CBlockIndex index; + uint256 blk_hash = uint256S("03"); + index.nHeight = 125; // 125 - 100 == 25 > 24 → expired by height + index.phashBlock = &blk_hash; + BOOST_CHECK(dstx.IsExpired(&index, *Assert(m_node.chainlocks))); +} + +// Helper to create a denominated CTxIn with a specific denomination value +static CTxIn MakeDenomInput(uint8_t index) +{ + CTxIn in; + in.prevout = COutPoint(uint256::ONE, index); + return in; +} + +// Helper to create a denominated CTxOut +static CTxOut MakeDenomOutput(CAmount nAmount, uint8_t tag = 0x01) +{ + return CTxOut{nAmount, P2PKHScript(tag)}; +} + +BOOST_AUTO_TEST_CASE(validate_promotion_entry_valid) +{ + // Valid promotion: 10 inputs of 0.1 DASH → 1 output of 1.0 DASH + std::vector vecTxIn; + std::vector vecTxOut; + + // Get the 0.1 DASH denomination (index 2: 1 << 2 = 4) + const int nSmallerDenom = 1 << 2; // 0.1 DASH + const int nLargerDenom = 1 << 1; // 1.0 DASH + const CAmount nLargerAmount = CoinJoin::DenominationToAmount(nLargerDenom); + + BOOST_CHECK(CoinJoin::IsValidDenomination(nSmallerDenom)); + BOOST_CHECK(CoinJoin::IsValidDenomination(nLargerDenom)); + + // Create 10 inputs of smaller denomination + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + vecTxIn.push_back(MakeDenomInput(static_cast(i))); + } + + // Create 1 output of larger denomination + vecTxOut.push_back(MakeDenomOutput(nLargerAmount, 0x01)); + + PoolMessage nMessageID = MSG_NOERR; + BOOST_CHECK(CoinJoin::ValidatePromotionEntry(vecTxIn, vecTxOut, nSmallerDenom, nMessageID)); +} + +BOOST_AUTO_TEST_CASE(validate_promotion_entry_wrong_input_count) +{ + // Invalid: only 9 inputs instead of 10 + std::vector vecTxIn; + std::vector vecTxOut; + + const int nSmallerDenom = 1 << 2; // 0.1 DASH + const int nLargerDenom = 1 << 1; // 1.0 DASH + const CAmount nLargerAmount = CoinJoin::DenominationToAmount(nLargerDenom); + + // Create only 9 inputs + for (int i = 0; i < CoinJoin::PROMOTION_RATIO - 1; ++i) { + vecTxIn.push_back(MakeDenomInput(static_cast(i))); + } + + vecTxOut.push_back(MakeDenomOutput(nLargerAmount, 0x01)); + + PoolMessage nMessageID = MSG_NOERR; + BOOST_CHECK(!CoinJoin::ValidatePromotionEntry(vecTxIn, vecTxOut, nSmallerDenom, nMessageID)); +} + +BOOST_AUTO_TEST_CASE(validate_promotion_entry_wrong_output_count) +{ + // Invalid: 2 outputs instead of 1 + std::vector vecTxIn; + std::vector vecTxOut; + + const int nSmallerDenom = 1 << 2; + const int nLargerDenom = 1 << 1; + const CAmount nLargerAmount = CoinJoin::DenominationToAmount(nLargerDenom); + + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + vecTxIn.push_back(MakeDenomInput(static_cast(i))); + } + + // Two outputs instead of one + vecTxOut.push_back(MakeDenomOutput(nLargerAmount, 0x01)); + vecTxOut.push_back(MakeDenomOutput(nLargerAmount, 0x02)); + + PoolMessage nMessageID = MSG_NOERR; + BOOST_CHECK(!CoinJoin::ValidatePromotionEntry(vecTxIn, vecTxOut, nSmallerDenom, nMessageID)); +} + +BOOST_AUTO_TEST_CASE(validate_promotion_entry_non_adjacent_denoms) +{ + // Invalid: trying to promote 0.01 to 1.0 (not adjacent) + std::vector vecTxIn; + std::vector vecTxOut; + + const int nSmallerDenom = 1 << 3; // 0.01 DASH + const int nLargerDenom = 1 << 1; // 1.0 DASH (not adjacent to 0.01) + const CAmount nLargerAmount = CoinJoin::DenominationToAmount(nLargerDenom); + + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + vecTxIn.push_back(MakeDenomInput(static_cast(i))); + } + + vecTxOut.push_back(MakeDenomOutput(nLargerAmount, 0x01)); + + PoolMessage nMessageID = MSG_NOERR; + BOOST_CHECK(!CoinJoin::ValidatePromotionEntry(vecTxIn, vecTxOut, nSmallerDenom, nMessageID)); +} + +BOOST_AUTO_TEST_CASE(validate_demotion_entry_valid) +{ + // Valid demotion: 1 input of 1.0 DASH → 10 outputs of 0.1 DASH + std::vector vecTxIn; + std::vector vecTxOut; + + const int nSmallerDenom = 1 << 2; // 0.1 DASH + const CAmount nSmallerAmount = CoinJoin::DenominationToAmount(nSmallerDenom); + const int nLargerDenom = 1 << 1; // 1.0 DASH + + BOOST_CHECK(CoinJoin::IsValidDenomination(nSmallerDenom)); + BOOST_CHECK(CoinJoin::IsValidDenomination(nLargerDenom)); + BOOST_CHECK(CoinJoin::AreAdjacentDenominations(nSmallerDenom, nLargerDenom)); + + // 1 input of larger denomination + vecTxIn.push_back(MakeDenomInput(0)); + + // 10 outputs of smaller denomination + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + vecTxOut.push_back(MakeDenomOutput(nSmallerAmount, static_cast(i))); + } + + PoolMessage nMessageID = MSG_NOERR; + BOOST_CHECK(CoinJoin::ValidateDemotionEntry(vecTxIn, vecTxOut, nSmallerDenom, nMessageID)); +} + +BOOST_AUTO_TEST_CASE(validate_demotion_entry_wrong_input_count) +{ + // Invalid: 2 inputs instead of 1 + std::vector vecTxIn; + std::vector vecTxOut; + + const int nSmallerDenom = 1 << 2; + const CAmount nSmallerAmount = CoinJoin::DenominationToAmount(nSmallerDenom); + + // 2 inputs instead of 1 + vecTxIn.push_back(MakeDenomInput(0)); + vecTxIn.push_back(MakeDenomInput(1)); + + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + vecTxOut.push_back(MakeDenomOutput(nSmallerAmount, static_cast(i))); + } + + PoolMessage nMessageID = MSG_NOERR; + BOOST_CHECK(!CoinJoin::ValidateDemotionEntry(vecTxIn, vecTxOut, nSmallerDenom, nMessageID)); +} + +BOOST_AUTO_TEST_CASE(validate_demotion_entry_wrong_output_count) +{ + // Invalid: 9 outputs instead of 10 + std::vector vecTxIn; + std::vector vecTxOut; + + const int nSmallerDenom = 1 << 2; + const CAmount nSmallerAmount = CoinJoin::DenominationToAmount(nSmallerDenom); + + vecTxIn.push_back(MakeDenomInput(0)); + + // Only 9 outputs + for (int i = 0; i < CoinJoin::PROMOTION_RATIO - 1; ++i) { + vecTxOut.push_back(MakeDenomOutput(nSmallerAmount, static_cast(i))); + } + + PoolMessage nMessageID = MSG_NOERR; + BOOST_CHECK(!CoinJoin::ValidateDemotionEntry(vecTxIn, vecTxOut, nSmallerDenom, nMessageID)); +} + +BOOST_AUTO_TEST_CASE(denomination_adjacency_checks) +{ + // Test AreAdjacentDenominations function + // Denomination indices: 0=10.0, 1=1.0, 2=0.1, 3=0.01, 4=0.001 + + // Adjacent pairs should return true + BOOST_CHECK(CoinJoin::AreAdjacentDenominations(1 << 0, 1 << 1)); // 10.0 and 1.0 + BOOST_CHECK(CoinJoin::AreAdjacentDenominations(1 << 1, 1 << 2)); // 1.0 and 0.1 + BOOST_CHECK(CoinJoin::AreAdjacentDenominations(1 << 2, 1 << 3)); // 0.1 and 0.01 + BOOST_CHECK(CoinJoin::AreAdjacentDenominations(1 << 3, 1 << 4)); // 0.01 and 0.001 + + // Non-adjacent pairs should return false + BOOST_CHECK(!CoinJoin::AreAdjacentDenominations(1 << 0, 1 << 2)); // 10.0 and 0.1 (skip 1.0) + BOOST_CHECK(!CoinJoin::AreAdjacentDenominations(1 << 1, 1 << 3)); // 1.0 and 0.01 (skip 0.1) + BOOST_CHECK(!CoinJoin::AreAdjacentDenominations(1 << 0, 1 << 4)); // 10.0 and 0.001 + + // Same denomination is not adjacent to itself + BOOST_CHECK(!CoinJoin::AreAdjacentDenominations(1 << 2, 1 << 2)); + + // Invalid denominations + BOOST_CHECK(!CoinJoin::AreAdjacentDenominations(0, 1 << 1)); + BOOST_CHECK(!CoinJoin::AreAdjacentDenominations(1 << 1, 0)); + BOOST_CHECK(!CoinJoin::AreAdjacentDenominations(999, 1 << 1)); +} + +BOOST_AUTO_TEST_CASE(get_adjacent_denomination_helpers) +{ + // Test GetLargerAdjacentDenom and GetSmallerAdjacentDenom + // Indices: 0=10.0, 1=1.0, 2=0.1, 3=0.01, 4=0.001 + + // GetLargerAdjacentDenom + BOOST_CHECK_EQUAL(CoinJoin::GetLargerAdjacentDenom(1 << 1), 1 << 0); // 1.0 → 10.0 + BOOST_CHECK_EQUAL(CoinJoin::GetLargerAdjacentDenom(1 << 2), 1 << 1); // 0.1 → 1.0 + BOOST_CHECK_EQUAL(CoinJoin::GetLargerAdjacentDenom(1 << 3), 1 << 2); // 0.01 → 0.1 + BOOST_CHECK_EQUAL(CoinJoin::GetLargerAdjacentDenom(1 << 4), 1 << 3); // 0.001 → 0.01 + BOOST_CHECK_EQUAL(CoinJoin::GetLargerAdjacentDenom(1 << 0), 0); // 10.0 has no larger + BOOST_CHECK_EQUAL(CoinJoin::GetLargerAdjacentDenom(0), 0); // Invalid denominations + BOOST_CHECK_EQUAL(CoinJoin::GetLargerAdjacentDenom(999), 0); // Invalid denominations + + // GetSmallerAdjacentDenom + BOOST_CHECK_EQUAL(CoinJoin::GetSmallerAdjacentDenom(1 << 0), 1 << 1); // 10.0 → 1.0 + BOOST_CHECK_EQUAL(CoinJoin::GetSmallerAdjacentDenom(1 << 1), 1 << 2); // 1.0 → 0.1 + BOOST_CHECK_EQUAL(CoinJoin::GetSmallerAdjacentDenom(1 << 2), 1 << 3); // 0.1 → 0.01 + BOOST_CHECK_EQUAL(CoinJoin::GetSmallerAdjacentDenom(1 << 3), 1 << 4); // 0.01 → 0.001 + BOOST_CHECK_EQUAL(CoinJoin::GetSmallerAdjacentDenom(1 << 4), 0); // 0.001 has no smaller + BOOST_CHECK_EQUAL(CoinJoin::GetSmallerAdjacentDenom(0), 0); // Invalid denominations + BOOST_CHECK_EQUAL(CoinJoin::GetSmallerAdjacentDenom(999), 0); // Invalid denominations +} + +BOOST_AUTO_TEST_CASE(isvalidstructure_postfork_unbalanced_valid) +{ + // Post-V24: Unbalanced vin/vout (promotion: 10 inputs, 1 output) should be valid + // We need a mock pindex that signals V24 active - for this test we use nullptr which means pre-fork + // This test validates that the structure check correctly identifies promotion structure + + CCoinJoinBroadcastTx promo; + { + CMutableTransaction mtx; + // Promotion: 10 inputs of smaller denom -> 1 output of larger denom + const int nInputCount = CoinJoin::PROMOTION_RATIO; + const CAmount nLargerAmount = CoinJoin::DenominationToAmount(1 << 1); // 1.0 DASH + + for (int i = 0; i < nInputCount; ++i) { + CTxIn in; + in.prevout = COutPoint(uint256::ONE, static_cast(i)); + mtx.vin.push_back(in); + } + // 1 output of larger denom + CTxOut out{nLargerAmount, P2PKHScript(0x01)}; + mtx.vout.push_back(out); + + promo.tx = MakeTransactionRef(mtx); + promo.m_protxHash = uint256::ONE; + } + + // Pre-V24 (nullptr): unbalanced should fail, but be reported as possibly valid post-V24 + // so relaying peers aren't fully punished around the activation boundary + bool fPossiblyValidPostV24{false}; + BOOST_CHECK(!promo.IsValidStructure(nullptr, *Assert(m_node.chainman), &fPossiblyValidPostV24)); + BOOST_CHECK(fPossiblyValidPostV24); + + // Note: Post-V24 test would require a valid CBlockIndex with V24 deployment active + // which requires more setup. The above confirms pre-fork rejection works. +} + +BOOST_AUTO_TEST_CASE(isvalidstructure_demotion_structure) +{ + // Demotion: 1 input of larger denom -> 10 outputs of smaller denom + CCoinJoinBroadcastTx demo; + { + CMutableTransaction mtx; + const CAmount nSmallerAmount = CoinJoin::DenominationToAmount(1 << 2); // 0.1 DASH + + // 1 input + CTxIn in; + in.prevout = COutPoint(uint256::ONE, 0); + mtx.vin.push_back(in); + + // 10 outputs of smaller denom + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + CTxOut out{nSmallerAmount, P2PKHScript(static_cast(i))}; + mtx.vout.push_back(out); + } + + demo.tx = MakeTransactionRef(mtx); + demo.m_protxHash = uint256::ONE; + } + + // Pre-V24 (nullptr): unbalanced should fail. A single-entry demotion DSTX has just 1 + // input - below GetMinPoolParticipants() under either ruleset - so it must not be + // reported as possibly valid post-V24 either + bool fPossiblyValidPostV24{true}; + BOOST_CHECK(!demo.IsValidStructure(nullptr, *Assert(m_node.chainman), &fPossiblyValidPostV24)); + BOOST_CHECK(!fPossiblyValidPostV24); + + // An unbalanced tx that is garbage under post-V24 rules too (non-denominated output) + // must not be reported as possibly valid + CCoinJoinBroadcastTx garbage; + { + CMutableTransaction mtx; + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + CTxIn in; + in.prevout = COutPoint(uint256::ONE, static_cast(i)); + mtx.vin.push_back(in); + } + CTxOut out{1234567, P2PKHScript(0x01)}; // not a denomination + mtx.vout.push_back(out); + garbage.tx = MakeTransactionRef(mtx); + garbage.m_protxHash = uint256::ONE; + } + fPossiblyValidPostV24 = true; + BOOST_CHECK(!garbage.IsValidStructure(nullptr, *Assert(m_node.chainman), &fPossiblyValidPostV24)); + BOOST_CHECK(!fPossiblyValidPostV24); +} + +BOOST_AUTO_TEST_CASE(input_limit_prefork) +{ + // Pre-V24: max inputs = GetMaxPoolParticipants() * COINJOIN_ENTRY_MAX_SIZE + // Typically 20 * 9 = 180 + const size_t nMaxPreFork = CoinJoin::GetMaxPoolParticipants() * COINJOIN_ENTRY_MAX_SIZE; + BOOST_CHECK_EQUAL(nMaxPreFork, 180); + + // Transaction with exactly max inputs should be valid + CCoinJoinBroadcastTx maxValid; + { + CMutableTransaction mtx; + for (size_t i = 0; i < nMaxPreFork; ++i) { + CTxIn in; + in.prevout = COutPoint(uint256::ONE, static_cast(i)); + mtx.vin.push_back(in); + CTxOut out{CoinJoin::GetSmallestDenomination(), P2PKHScript(static_cast(i % 256))}; + mtx.vout.push_back(out); + } + maxValid.tx = MakeTransactionRef(mtx); + maxValid.m_protxHash = uint256::ONE; + } + BOOST_CHECK(maxValid.IsValidStructure(nullptr, *Assert(m_node.chainman))); + + // Transaction with max+1 inputs should be invalid + CCoinJoinBroadcastTx tooMany; + { + CMutableTransaction mtx; + for (size_t i = 0; i < nMaxPreFork + 1; ++i) { + CTxIn in; + in.prevout = COutPoint(uint256::ONE, static_cast(i)); + mtx.vin.push_back(in); + CTxOut out{CoinJoin::GetSmallestDenomination(), P2PKHScript(static_cast(i % 256))}; + mtx.vout.push_back(out); + } + tooMany.tx = MakeTransactionRef(mtx); + tooMany.m_protxHash = uint256::ONE; + } + BOOST_CHECK(!tooMany.IsValidStructure(nullptr, *Assert(m_node.chainman))); +} + +BOOST_AUTO_TEST_CASE(input_limit_postfork_constants) +{ + // Post-V24: max inputs = GetMaxPoolParticipants() * PROMOTION_RATIO + // Typically 20 * 10 = 200 + const size_t nMaxPostFork = CoinJoin::GetMaxPoolParticipants() * CoinJoin::PROMOTION_RATIO; + BOOST_CHECK_EQUAL(nMaxPostFork, 200); + + // Verify the increase from pre-fork + const size_t nMaxPreFork = CoinJoin::GetMaxPoolParticipants() * COINJOIN_ENTRY_MAX_SIZE; + BOOST_CHECK(nMaxPostFork > nMaxPreFork); + BOOST_CHECK_EQUAL(nMaxPostFork - nMaxPreFork, 20); // Increase of 20 +} + +BOOST_AUTO_TEST_CASE(output_limit_postfork) +{ + // Post-V24 the vin/vout counts no longer bound each other, so outputs get their own cap + // of GetMaxPoolParticipants() * PROMOTION_RATIO. Exercised through the + // fPossiblyValidPostV24 flag: a tx that busts the output cap must not be reported as + // possibly valid under post-V24 rules either. + const size_t nMaxPostFork = CoinJoin::GetMaxPoolParticipants() * CoinJoin::PROMOTION_RATIO; + const CAmount nSmallestDenom = CoinJoin::GetSmallestDenomination(); + + auto MakeUnbalancedTx = [&](size_t nInputs, size_t nOutputs) { + CCoinJoinBroadcastTx dstx; + CMutableTransaction mtx; + for (size_t i = 0; i < nInputs; ++i) { + CTxIn in; + in.prevout = COutPoint(uint256::ONE, static_cast(i)); + mtx.vin.push_back(in); + } + for (size_t i = 0; i < nOutputs; ++i) { + mtx.vout.push_back(CTxOut{nSmallestDenom, P2PKHScript(static_cast(i % 256))}); + } + dstx.tx = MakeTransactionRef(mtx); + dstx.m_protxHash = uint256::ONE; + return dstx; + }; + + // Unbalanced but within both caps: rejected pre-V24, possibly valid post-V24 + bool fPossiblyValidPostV24{false}; + const auto withinCaps = MakeUnbalancedTx(30, nMaxPostFork); + BOOST_CHECK(!withinCaps.IsValidStructure(nullptr, *Assert(m_node.chainman), &fPossiblyValidPostV24)); + BOOST_CHECK(fPossiblyValidPostV24); + + // One output over the cap: rejected under either ruleset + fPossiblyValidPostV24 = true; + const auto tooManyOutputs = MakeUnbalancedTx(30, nMaxPostFork + 1); + BOOST_CHECK(!tooManyOutputs.IsValidStructure(nullptr, *Assert(m_node.chainman), &fPossiblyValidPostV24)); + BOOST_CHECK(!fPossiblyValidPostV24); +} + +BOOST_AUTO_TEST_CASE(validate_final_tx_composition) +{ + constexpr size_t R = CoinJoin::PROMOTION_RATIO; + + // Standard-only session: N inputs, N outputs, no larger-denom in/outs + BOOST_CHECK(CoinJoin::ValidateFinalTxComposition(5, 5, 0, 0)); + + // 3 standard + 1 promotion: 3 + 10 inputs, 3 + 1 outputs (1 at larger denom) + BOOST_CHECK(CoinJoin::ValidateFinalTxComposition(3 + R, 3 + 1, 0, 1)); + + // 3 standard + 1 demotion: 3 + 1 inputs (1 at larger denom), 3 + 10 outputs + BOOST_CHECK(CoinJoin::ValidateFinalTxComposition(3 + 1, 3 + R, 1, 0)); + + // 3 standard + 1 promotion + 1 demotion + BOOST_CHECK(CoinJoin::ValidateFinalTxComposition(3 + R + 1, 3 + 1 + R, 1, 1)); + + // Promotion-only shape is composable (the standard-mixer minimum is enforced by the + // server in CheckPool/AddEntry, not by the composition check) + BOOST_CHECK(CoinJoin::ValidateFinalTxComposition(R, 1, 0, 1)); + + // Not enough session-denom inputs to back the promotion output + BOOST_CHECK(!CoinJoin::ValidateFinalTxComposition(R - 1, 1, 0, 1)); + + // Not enough session-denom outputs to back the demotion input + BOOST_CHECK(!CoinJoin::ValidateFinalTxComposition(1, R - 1, 1, 0)); + + // Larger-denom counts exceeding the totals are inconsistent + BOOST_CHECK(!CoinJoin::ValidateFinalTxComposition(5, 5, 6, 0)); + BOOST_CHECK(!CoinJoin::ValidateFinalTxComposition(5, 5, 0, 6)); + + // Two promotions with only one ratio's worth of session inputs + BOOST_CHECK(!CoinJoin::ValidateFinalTxComposition(R, 2, 0, 2)); +} + +BOOST_AUTO_TEST_CASE(promotion_demotion_value_preservation) +{ + // Verify that 10 smaller = 1 larger (value is preserved exactly) + // CoinJoin denominations are designed so that 10 * smaller == larger + // e.g., 10 * (0.1 DASH + 100 sat) = 1.0 DASH + 1000 sat + + const CAmount nSmallerAmount = CoinJoin::DenominationToAmount(1 << 2); // 0.1 DASH + const CAmount nLargerAmount = CoinJoin::DenominationToAmount(1 << 1); // 1.0 DASH + + // Value must match EXACTLY for promotion/demotion to preserve value + BOOST_CHECK_EQUAL(nSmallerAmount * CoinJoin::PROMOTION_RATIO, nLargerAmount); + + // Verify for all adjacent denomination pairs + for (int i = 0; i < 4; ++i) { + const int nLargerDenom = 1 << i; + const int nSmallerDenom = 1 << (i + 1); + const CAmount nLarger = CoinJoin::DenominationToAmount(nLargerDenom); + const CAmount nSmaller = CoinJoin::DenominationToAmount(nSmallerDenom); + + BOOST_CHECK(nLarger > nSmaller); + // The denominations are designed so 10 * smaller == larger exactly + BOOST_CHECK_EQUAL(nSmaller * CoinJoin::PROMOTION_RATIO, nLarger); + } +} + +BOOST_AUTO_TEST_CASE(isvalidstructure_mixed_session_postfork) +{ + // Post-V24: A transaction with mixed entry types should be valid + // (some participants doing 1:1, others doing promotion/demotion) + // This tests that the structure validation allows mixed input/output counts + + CCoinJoinBroadcastTx mixed; + { + CMutableTransaction mtx; + // Simulate a mixed session: + // - 3 standard participants: 3 inputs, 3 outputs (smallest denom) + // - 1 promotion participant: 10 inputs, 1 output + // Total: 13 inputs, 4 outputs + + const CAmount nSmallestDenom = CoinJoin::GetSmallestDenomination(); + const CAmount nSecondSmallest = CoinJoin::DenominationToAmount(1 << 3); // 0.01 DASH + + // Standard participants (3 x 1:1) + for (int i = 0; i < 3; ++i) { + CTxIn in; + in.prevout = COutPoint(uint256::ONE, static_cast(i)); + mtx.vin.push_back(in); + mtx.vout.push_back(CTxOut{nSmallestDenom, P2PKHScript(static_cast(i))}); + } + + // Promotion participant (10 inputs -> 1 output) + for (int i = 3; i < 13; ++i) { + CTxIn in; + in.prevout = COutPoint(uint256::ONE, static_cast(i)); + mtx.vin.push_back(in); + } + // One output for the promotion (larger denom) + mtx.vout.push_back(CTxOut{nSecondSmallest, P2PKHScript(0x0D)}); + + mixed.tx = MakeTransactionRef(mtx); + mixed.m_protxHash = uint256::ONE; + } + + // Pre-V24: Should fail (unbalanced) + BOOST_CHECK(!mixed.IsValidStructure(nullptr, *Assert(m_node.chainman))); + + // Note: Post-V24 testing requires a valid CBlockIndex with V24 deployment active + // which requires more complex test setup with chainstate. The above verifies + // that pre-fork rejection works correctly. +} + +BOOST_AUTO_TEST_CASE(entry_type_detection_logic) +{ + // Test the entry type detection logic used in IsValidInOuts + // Entry types: + // - STANDARD: vin.size() == vout.size() + // - PROMOTION: vin.size() == PROMOTION_RATIO && vout.size() == 1 + // - DEMOTION: vin.size() == 1 && vout.size() == PROMOTION_RATIO + // - INVALID: anything else + + auto detectEntryType = [](size_t vinSize, size_t voutSize) -> std::string { + if (vinSize == voutSize) { + return "STANDARD"; + } else if (vinSize == static_cast(CoinJoin::PROMOTION_RATIO) && voutSize == 1) { + return "PROMOTION"; + } else if (vinSize == 1 && voutSize == static_cast(CoinJoin::PROMOTION_RATIO)) { + return "DEMOTION"; + } + return "INVALID"; + }; + + // Standard entries + BOOST_CHECK_EQUAL(detectEntryType(1, 1), "STANDARD"); + BOOST_CHECK_EQUAL(detectEntryType(5, 5), "STANDARD"); + BOOST_CHECK_EQUAL(detectEntryType(9, 9), "STANDARD"); // Max standard entry + + // Promotion entries + BOOST_CHECK_EQUAL(detectEntryType(10, 1), "PROMOTION"); + + // Demotion entries + BOOST_CHECK_EQUAL(detectEntryType(1, 10), "DEMOTION"); + + // Invalid entries (not valid pre or post fork) + BOOST_CHECK_EQUAL(detectEntryType(9, 1), "INVALID"); // Wrong ratio + BOOST_CHECK_EQUAL(detectEntryType(1, 9), "INVALID"); // Wrong ratio + BOOST_CHECK_EQUAL(detectEntryType(5, 3), "INVALID"); // Random mismatch + BOOST_CHECK_EQUAL(detectEntryType(2, 10), "INVALID"); // Wrong input count for demotion + BOOST_CHECK_EQUAL(detectEntryType(10, 2), "INVALID"); // Wrong output count for promotion + BOOST_CHECK_EQUAL(detectEntryType(20, 2), "INVALID"); // 20:2 is not valid + BOOST_CHECK_EQUAL(detectEntryType(11, 1), "INVALID"); // 11:1 is not valid promotion +} + +BOOST_AUTO_TEST_CASE(validate_entry_session_denom_consistency) +{ + // Test that promotion/demotion validation enforces session denomination consistency + // For promotion: inputs must be session denom (smaller), output must be larger adjacent + // For demotion: input must be larger adjacent, outputs must be session denom (smaller) + + const int nSessionDenom = 1 << 2; // 0.1 DASH (session denom) + const int nLargerDenom = CoinJoin::GetLargerAdjacentDenom(nSessionDenom); + + BOOST_CHECK(nLargerDenom != 0); + BOOST_CHECK_EQUAL(nLargerDenom, 1 << 1); // 1.0 DASH + + // Create a valid promotion entry structure + std::vector promoVin; + std::vector promoVout; + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + promoVin.push_back(MakeDenomInput(static_cast(i))); + } + promoVout.push_back(MakeDenomOutput(CoinJoin::DenominationToAmount(nLargerDenom))); + + PoolMessage msg = MSG_NOERR; + BOOST_CHECK(CoinJoin::ValidatePromotionEntry(promoVin, promoVout, nSessionDenom, msg)); + + // Test with wrong session denom (largest denom can't promote) + const int nLargestDenom = 1 << 0; // 10 DASH + msg = MSG_NOERR; + BOOST_CHECK(!CoinJoin::ValidatePromotionEntry(promoVin, promoVout, nLargestDenom, msg)); + BOOST_CHECK(msg == ERR_DENOM); // No larger adjacent for 10 DASH + + // Create a valid demotion entry structure + std::vector demoVin; + std::vector demoVout; + demoVin.push_back(MakeDenomInput(0)); + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + demoVout.push_back(MakeDenomOutput(CoinJoin::DenominationToAmount(nSessionDenom), static_cast(i))); + } + + msg = MSG_NOERR; + BOOST_CHECK(CoinJoin::ValidateDemotionEntry(demoVin, demoVout, nSessionDenom, msg)); +} + +BOOST_AUTO_TEST_CASE(isvalidstructure_boundary_input_counts) +{ + // Test boundary conditions for input counts at the pre/post V24 limits + // Pre-V24: max 180 inputs (20 * 9) + // Post-V24: max 200 inputs (20 * 10) + + const size_t nPreForkMax = CoinJoin::GetMaxPoolParticipants() * COINJOIN_ENTRY_MAX_SIZE; + const size_t nPostForkMax = CoinJoin::GetMaxPoolParticipants() * CoinJoin::PROMOTION_RATIO; + + BOOST_CHECK_EQUAL(nPreForkMax, 180); + BOOST_CHECK_EQUAL(nPostForkMax, 200); + + // Create transaction with exactly 180 inputs (valid pre and post fork) + CCoinJoinBroadcastTx tx180; + { + CMutableTransaction mtx; + for (size_t i = 0; i < 180; ++i) { + mtx.vin.emplace_back(COutPoint(uint256::ONE, static_cast(i))); + mtx.vout.emplace_back(CoinJoin::GetSmallestDenomination(), P2PKHScript(static_cast(i % 256))); + } + tx180.tx = MakeTransactionRef(mtx); + tx180.m_protxHash = uint256::ONE; + } + BOOST_CHECK(tx180.IsValidStructure(nullptr, *Assert(m_node.chainman))); // Pre-fork: valid at boundary + + // Create transaction with 181 inputs (invalid pre-fork, would be valid post-fork) + CCoinJoinBroadcastTx tx181; + { + CMutableTransaction mtx; + for (size_t i = 0; i < 181; ++i) { + mtx.vin.emplace_back(COutPoint(uint256::ONE, static_cast(i))); + mtx.vout.emplace_back(CoinJoin::GetSmallestDenomination(), P2PKHScript(static_cast(i % 256))); + } + tx181.tx = MakeTransactionRef(mtx); + tx181.m_protxHash = uint256::ONE; + } + BOOST_CHECK(!tx181.IsValidStructure(nullptr, *Assert(m_node.chainman))); // Pre-fork: over limit + + // Transaction at post-fork boundary (200 inputs) + CCoinJoinBroadcastTx tx200; + { + CMutableTransaction mtx; + for (size_t i = 0; i < 200; ++i) { + mtx.vin.emplace_back(COutPoint(uint256::ONE, static_cast(i))); + mtx.vout.emplace_back(CoinJoin::GetSmallestDenomination(), P2PKHScript(static_cast(i % 256))); + } + tx200.tx = MakeTransactionRef(mtx); + tx200.m_protxHash = uint256::ONE; + } + // Pre-fork: over limit (200 > 180) + BOOST_CHECK(!tx200.IsValidStructure(nullptr, *Assert(m_node.chainman))); + + // Note: Testing post-fork behavior requires a CBlockIndex with V24 active via EHF +} + +// ============================================================================ +// Tests for the ShouldPromoteDenoms/ShouldDemoteDenoms decision logic that +// CCoinJoinClientManager::ShouldPromote/ShouldDemote dispatch to +// ============================================================================ + +namespace { + +// Adapters over the live decision functions; the promote adapter treats every +// coin as fully mixed so the count-based cases below isolate the deficit logic +// (the fully-mixed gate has its own test) +bool TestShouldPromote(int smallerCount, int largerCount, int goal) +{ + return CoinJoin::ShouldPromoteDenoms(smallerCount, largerCount, /*nSmallerFullyMixedCount=*/smallerCount, goal); +} + +bool TestShouldDemote(int largerCount, int smallerCount, int goal) +{ + return CoinJoin::ShouldDemoteDenoms(largerCount, smallerCount, goal); +} + +} // anonymous namespace + +BOOST_AUTO_TEST_CASE(should_promote_requires_fully_mixed_coins) +{ + // Deficit logic says promote (100 smaller vs 0 larger), but fewer than + // PROMOTION_RATIO fully-mixed coins must block the promotion + BOOST_CHECK(CoinJoin::ShouldPromoteDenoms(100, 0, CoinJoin::PROMOTION_RATIO, 100)); + BOOST_CHECK(!CoinJoin::ShouldPromoteDenoms(100, 0, CoinJoin::PROMOTION_RATIO - 1, 100)); + BOOST_CHECK(!CoinJoin::ShouldPromoteDenoms(100, 0, 0, 100)); +} + +BOOST_AUTO_TEST_CASE(should_promote_larger_deficit_greater) +{ + const int goal = 100; + + // 60 of smaller, 0 of larger -> should promote (0.1 has surplus, 1.0 needs help) + // smallerDeficit = 40, largerDeficit = 100, gap = 60 > GAP_THRESHOLD + BOOST_CHECK(TestShouldPromote(60, 0, goal)); + + // 100 of smaller, 0 of larger -> should promote (0.1 full, 1.0 empty) + // smallerDeficit = 0, largerDeficit = 100, gap = 100 > GAP_THRESHOLD + BOOST_CHECK(TestShouldPromote(100, 0, goal)); +} + +BOOST_AUTO_TEST_CASE(should_promote_below_half_goal_false) +{ + const int goal = 100; + + // 30 of smaller, 0 of larger -> should NOT promote (30 < 50 = halfGoal) + BOOST_CHECK(!TestShouldPromote(30, 0, goal)); + + // 49 of smaller, 0 of larger -> should NOT promote (49 < 50 = halfGoal) + BOOST_CHECK(!TestShouldPromote(49, 0, goal)); + + // 50 of smaller, 0 of larger -> CAN promote (50 >= 50 = halfGoal) + // smallerDeficit = 50, largerDeficit = 100, gap = 50 > GAP_THRESHOLD + BOOST_CHECK(TestShouldPromote(50, 0, goal)); +} + +BOOST_AUTO_TEST_CASE(should_promote_small_gap_false) +{ + const int goal = 100; + + // 90 smaller, 80 larger -> deficits are 10 and 20, gap = 10 + // 20 > 10 + 10 is false, so no promotion + BOOST_CHECK(!TestShouldPromote(90, 80, goal)); + + // 85 smaller, 80 larger -> deficits are 15 and 20, gap = 5 + // 20 > 15 + 10 is false, so no promotion + BOOST_CHECK(!TestShouldPromote(85, 80, goal)); +} + +BOOST_AUTO_TEST_CASE(should_demote_smaller_deficit_greater) +{ + const int goal = 100; + + // 60 of larger, 0 of smaller -> should demote (1.0 has surplus, 0.1 needs help) + // largerDeficit = 40, smallerDeficit = 100, gap = 60 > GAP_THRESHOLD + BOOST_CHECK(TestShouldDemote(60, 0, goal)); + + // 99 of larger, 0 of smaller -> should demote + // largerDeficit = 1, smallerDeficit = 100, gap = 99 > GAP_THRESHOLD + BOOST_CHECK(TestShouldDemote(99, 0, goal)); +} + +BOOST_AUTO_TEST_CASE(should_demote_below_half_goal_false) +{ + const int goal = 100; + + // 30 of larger, 0 of smaller -> should NOT demote (30 < 50 = halfGoal) + BOOST_CHECK(!TestShouldDemote(30, 0, goal)); + + // 49 of larger, 0 of smaller -> should NOT demote (49 < 50 = halfGoal) + BOOST_CHECK(!TestShouldDemote(49, 0, goal)); + + // 50 of larger, 0 of smaller -> CAN demote (50 >= 50 = halfGoal) + // largerDeficit = 50, smallerDeficit = 100, gap = 50 > GAP_THRESHOLD + BOOST_CHECK(TestShouldDemote(50, 0, goal)); +} + +BOOST_AUTO_TEST_CASE(promote_demote_mutually_exclusive) +{ + // Test various distributions - at most one should be true + auto testMutualExclusivity = [](int smallerCount, int largerCount) { + const int testGoal = 100; + bool promote = TestShouldPromote(smallerCount, largerCount, testGoal); + bool demote = TestShouldDemote(largerCount, smallerCount, testGoal); + // At most one can be true (XOR or neither) + BOOST_CHECK(!(promote && demote)); + return std::make_pair(promote, demote); + }; + + // Equal counts - neither + auto [p1, d1] = testMutualExclusivity(100, 100); + BOOST_CHECK(!p1 && !d1); + + // 90/90 - neither (close to goal, small gap) + auto [p2, d2] = testMutualExclusivity(90, 90); + BOOST_CHECK(!p2 && !d2); + + // Large imbalance toward smaller - promote only + auto [p3, d3] = testMutualExclusivity(100, 0); + BOOST_CHECK(p3 && !d3); + + // Large imbalance toward larger - demote only + auto [p4, d4] = testMutualExclusivity(0, 100); + BOOST_CHECK(!p4 && d4); + + // Mid-range cases from the plan + auto [p5, d5] = testMutualExclusivity(0, 60); // 0.1=0, 1.0=60 -> should demote + BOOST_CHECK(!p5 && d5); + + auto [p6, d6] = testMutualExclusivity(60, 0); // 0.1=60, 1.0=0 -> should promote + BOOST_CHECK(p6 && !d6); +} + +BOOST_AUTO_TEST_CASE(decision_logic_example_cases_from_plan) +{ + // Test the specific examples from the implementation plan + const int goal = 100; + + // | 1.0 count | 0.1 count | Action | + // | 99 | 0 | Demote | + BOOST_CHECK(TestShouldDemote(99, 0, goal)); + BOOST_CHECK(!TestShouldPromote(0, 99, goal)); // 0.1 has 0, can't promote + + // | 60 | 0 | Demote | + BOOST_CHECK(TestShouldDemote(60, 0, goal)); + + // | 30 | 0 | Nothing (1.0 < halfGoal) | + BOOST_CHECK(!TestShouldDemote(30, 0, goal)); + BOOST_CHECK(!TestShouldPromote(0, 30, goal)); + + // | 0 | 60 | Promote | + BOOST_CHECK(TestShouldPromote(60, 0, goal)); + + // | 0 | 30 | Nothing (0.1 < halfGoal) | + BOOST_CHECK(!TestShouldPromote(30, 0, goal)); + + // | 100 | 100 | Nothing (both at goal) | + BOOST_CHECK(!TestShouldPromote(100, 100, goal)); + BOOST_CHECK(!TestShouldDemote(100, 100, goal)); + + // | 90 | 90 | Nothing (deficits equal) | + BOOST_CHECK(!TestShouldPromote(90, 90, goal)); + BOOST_CHECK(!TestShouldDemote(90, 90, goal)); +} + +BOOST_AUTO_TEST_CASE(validate_promotion_entry_edge_cases) +{ + // Additional edge cases for ValidatePromotionEntry + + const int nSessionDenom = 1 << 2; // 0.1 DASH + const int nLargerDenom = CoinJoin::GetLargerAdjacentDenom(nSessionDenom); + const CAmount nLargerAmount = CoinJoin::DenominationToAmount(nLargerDenom); + + // Valid case: exactly 10 inputs, 1 output of correct larger denom + std::vector validVin; + std::vector validVout; + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + validVin.push_back(MakeDenomInput(static_cast(i))); + } + validVout.push_back(MakeDenomOutput(nLargerAmount)); + + PoolMessage msg = MSG_NOERR; + BOOST_CHECK(CoinJoin::ValidatePromotionEntry(validVin, validVout, nSessionDenom, msg)); + + // Invalid: 9 inputs (wrong count) + std::vector wrongCountVin; + for (int i = 0; i < 9; ++i) { + wrongCountVin.push_back(MakeDenomInput(static_cast(i))); + } + msg = MSG_NOERR; + BOOST_CHECK(!CoinJoin::ValidatePromotionEntry(wrongCountVin, validVout, nSessionDenom, msg)); + + // Invalid: 11 inputs (wrong count) + std::vector tooManyVin; + for (int i = 0; i < 11; ++i) { + tooManyVin.push_back(MakeDenomInput(static_cast(i))); + } + msg = MSG_NOERR; + BOOST_CHECK(!CoinJoin::ValidatePromotionEntry(tooManyVin, validVout, nSessionDenom, msg)); + + // Invalid: 2 outputs (should be 1) + std::vector twoOutputs; + twoOutputs.push_back(MakeDenomOutput(nLargerAmount / 2)); + twoOutputs.push_back(MakeDenomOutput(nLargerAmount / 2)); + msg = MSG_NOERR; + BOOST_CHECK(!CoinJoin::ValidatePromotionEntry(validVin, twoOutputs, nSessionDenom, msg)); + + // Invalid: output is wrong denomination + std::vector wrongDenomOut; + wrongDenomOut.push_back(MakeDenomOutput(CoinJoin::DenominationToAmount(nSessionDenom))); // Same denom, not larger + msg = MSG_NOERR; + BOOST_CHECK(!CoinJoin::ValidatePromotionEntry(validVin, wrongDenomOut, nSessionDenom, msg)); +} + +BOOST_AUTO_TEST_CASE(validate_demotion_entry_edge_cases) +{ + // Additional edge cases for ValidateDemotionEntry + + const int nSessionDenom = 1 << 2; // 0.1 DASH (output denom) + const CAmount nSessionAmount = CoinJoin::DenominationToAmount(nSessionDenom); + + // Verify the larger adjacent denom exists for this test to be meaningful + BOOST_CHECK(CoinJoin::GetLargerAdjacentDenom(nSessionDenom) != 0); + + // Valid case: 1 input of larger denom, 10 outputs of session denom + std::vector validVin; + validVin.push_back(MakeDenomInput(0)); + + std::vector validVout; + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + validVout.push_back(MakeDenomOutput(nSessionAmount, static_cast(i))); + } + + PoolMessage msg = MSG_NOERR; + BOOST_CHECK(CoinJoin::ValidateDemotionEntry(validVin, validVout, nSessionDenom, msg)); + + // Invalid: 2 inputs (should be 1) + std::vector twoInputs; + twoInputs.push_back(MakeDenomInput(0)); + twoInputs.push_back(MakeDenomInput(1)); + msg = MSG_NOERR; + BOOST_CHECK(!CoinJoin::ValidateDemotionEntry(twoInputs, validVout, nSessionDenom, msg)); + + // Invalid: 9 outputs (wrong count) + std::vector nineOutputs; + for (int i = 0; i < 9; ++i) { + nineOutputs.push_back(MakeDenomOutput(nSessionAmount, static_cast(i))); + } + msg = MSG_NOERR; + BOOST_CHECK(!CoinJoin::ValidateDemotionEntry(validVin, nineOutputs, nSessionDenom, msg)); + + // Test that demotion from smallest denomination (0.001 DASH) fails + // because there's no smaller denomination to demote to + const int nSmallestDenom = 1 << 4; // 0.001 DASH + BOOST_CHECK_EQUAL(CoinJoin::GetSmallerAdjacentDenom(nSmallestDenom), 0); // No smaller exists +} + +BOOST_AUTO_TEST_CASE(is_standard_mixing_entry) +{ + // Test IsStandardMixingEntry() method added for privacy protection + + // Standard entry: equal inputs and outputs + CCoinJoinEntry standard; + standard.vecTxDSIn.resize(3); + standard.vecTxOut.resize(3); + BOOST_CHECK(standard.IsStandardMixingEntry()); + + // Promotion entry: PROMOTION_RATIO inputs, 1 output + CCoinJoinEntry promotion; + promotion.vecTxDSIn.resize(CoinJoin::PROMOTION_RATIO); // 10 inputs + promotion.vecTxOut.resize(1); // 1 output + BOOST_CHECK(!promotion.IsStandardMixingEntry()); + + // Demotion entry: 1 input, PROMOTION_RATIO outputs + CCoinJoinEntry demotion; + demotion.vecTxDSIn.resize(1); // 1 input + demotion.vecTxOut.resize(CoinJoin::PROMOTION_RATIO); // 10 outputs + BOOST_CHECK(!demotion.IsStandardMixingEntry()); + + // Edge case: empty entry + CCoinJoinEntry empty; + BOOST_CHECK(empty.IsStandardMixingEntry()); // 0 == 0, technically standard + + // Edge case: 1:1 is standard + CCoinJoinEntry one_to_one; + one_to_one.vecTxDSIn.resize(1); + one_to_one.vecTxOut.resize(1); + BOOST_CHECK(one_to_one.IsStandardMixingEntry()); +} + +BOOST_AUTO_TEST_CASE(get_standard_entries_count) +{ + // Test GetStandardEntriesCount() methods for privacy threshold enforcement + // This is tested through CCoinJoinBaseSession which vecEntries is part of + // We verify the logic by testing IsStandardMixingEntry on various entry types + + // Mix of standard and promotion/demotion entries + std::vector entries; + + // 3 standard entries + for (int i = 0; i < 3; ++i) { + CCoinJoinEntry standard; + standard.vecTxDSIn.resize(5); + standard.vecTxOut.resize(5); + entries.push_back(standard); + } + + // 1 promotion entry + CCoinJoinEntry promotion; + promotion.vecTxDSIn.resize(CoinJoin::PROMOTION_RATIO); + promotion.vecTxOut.resize(1); + entries.push_back(promotion); + + // 1 demotion entry + CCoinJoinEntry demotion; + demotion.vecTxDSIn.resize(1); + demotion.vecTxOut.resize(CoinJoin::PROMOTION_RATIO); + entries.push_back(demotion); + + // Count standard entries manually (what GetStandardEntriesCount should return) + int standard_count = std::count_if(entries.begin(), entries.end(), + [](const CCoinJoinEntry& e) { return e.IsStandardMixingEntry(); }); + + BOOST_CHECK_EQUAL(standard_count, 3); // Only 3 standard, not 5 total + BOOST_CHECK_EQUAL(entries.size(), 5); // Total is 5 + + // Verify promotion and demotion are NOT counted + BOOST_CHECK(!promotion.IsStandardMixingEntry()); + BOOST_CHECK(!demotion.IsStandardMixingEntry()); +} BOOST_AUTO_TEST_SUITE_END() From 33702b4056c98ff6cc5b53dd5d61f37de5a40c43 Mon Sep 17 00:00:00 2001 From: Pasta Date: Thu, 9 Jul 2026 20:33:50 -0500 Subject: [PATCH 03/26] docs: add release notes for pr 7052 --- doc/release-notes-7052.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 doc/release-notes-7052.md diff --git a/doc/release-notes-7052.md b/doc/release-notes-7052.md new file mode 100644 index 000000000000..d800e3a509b3 --- /dev/null +++ b/doc/release-notes-7052.md @@ -0,0 +1,9 @@ +Wallet +------ + +- CoinJoin can now promote and demote between adjacent standard + denominations within a mixing session after V24 activation. + Promotion combines 10 inputs of one denomination into 1 output of the + next larger denomination, while demotion splits 1 input into 10 + outputs of the next smaller denomination. Pre-V24 behavior remains + unchanged. (#7052) From 9c29052a912845bac192f31a03eee00fe35cfe2c Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 1 Aug 2026 15:49:27 -0500 Subject: [PATCH 04/26] feat(coinjoin): gate rebalance sessions and require session-denom cover Bump PROTOCOL_VERSION to 70241 and fix each mixing session's rebalance capability at creation time from the creator's negotiated protocol version. The dsa message gains a version-gated flags field declaring which direction a participant will mix; peers below COINJOIN_REBALANCE_VERSION never see the new field, so their wire format is unchanged and DSQ relay is untouched. Without the version gate, a wallet running older software could be mixed into a session containing an unbalanced promotion/demotion entry post-V24 activation. It would fail IsValidInOuts on the final transaction, refuse to sign and risk having its collateral consumed by ChargeFees despite behaving honestly. Old clients are now rejected from rebalance-capable sessions at dsa time with ERR_VERSION - a message id they understand, received before any collateral is committed - and keep mixing normally in sessions without such entries. Mixing only conceals a participant when somebody else holds coins of the same size on the same side, so a session may finalize only when each side of the session denomination is occupied by nobody or by at least two participants; exactly one is the only forbidden count. Standard entries occupy both sides, promotions only the input side and demotions only the output side, and nothing is required at the larger adjacent denomination because a lone coin there has no group to hide. This replaces the earlier minimum-standard-entries proxy, which both rejected sessions that are perfectly private (two promoters and two demoters with no 1:1 mixer) and admitted ones that are not. Clients apply the same rule to the final transaction before signing, counting only coins at the session denomination. Counting foreign coins regardless of denomination would let a malicious masternode pad a transaction with larger-denomination coins that conceal nothing and still publish one linking a promotion's ten inputs, or a demotion's ten outputs, in plain sight. Declaring the direction up front also lets the masternode judge session coverage before taking collateral, so no participant is admitted and then refused at entry submission. Entries that occupy neither side, including empty ones, are now rejected outright rather than counting as standard mixers and diluting the coverage every participant relies on. Deviating from the declared direction costs the collateral: admission counted on the declared shapes for cover, so a participant declaring a promotion but submitting a standard entry could strip a side of its cover and force a fee-free session reset for everyone. Pre-V24 behavior is unchanged, including collateral consumption for unbalanced DSVIN spam. The declared shapes make entry admission load-bearing, so entries are bound one-to-one to the collaterals accepted at dsa time: a DSVIN collateral must exactly match an accepted session collateral, each collateral covers exactly one entry, and both conditions are re-checked atomically when the entry is committed. Without the binding, once the ready queue is public, anyone holding a valid collateral could fill entry slots belonging to the admitted participants - or one participant could fill several with disjoint inputs - finalizing a session that omits admitted participants while leaving their collaterals exposed to fee charging. m_mapDeclaredShapes is guarded by cs_coinjoin. It was written under the lock but read without it from the session-readiness path (reached by the scheduler thread, and by the message thread via a premature DSVIN while the session is still in queue state) and from entry admission, racing a scheduler-driven reset. IsSessionReady and GetDeclaredSideCounts now require the lock, ProcessDSVIN takes it, and AddEntry snapshots the declared shape under it before any validation that acquires cs_main. Co-Authored-By: Claude Fable 5 --- doc/release-notes-7052.md | 23 +++ src/coinjoin/client.cpp | 71 ++++++- src/coinjoin/coinjoin.cpp | 8 +- src/coinjoin/coinjoin.h | 63 ++++-- src/coinjoin/common.h | 53 +++++ src/coinjoin/server.cpp | 263 ++++++++++++++++-------- src/coinjoin/server.h | 33 ++- src/test/coinjoin_inouts_tests.cpp | 285 ++++++++++++++++++++------ src/test/net_tests.cpp | 4 +- src/version.h | 7 +- test/functional/test_framework/p2p.py | 5 +- 11 files changed, 624 insertions(+), 191 deletions(-) diff --git a/doc/release-notes-7052.md b/doc/release-notes-7052.md index d800e3a509b3..9cd4e0dfad91 100644 --- a/doc/release-notes-7052.md +++ b/doc/release-notes-7052.md @@ -1,3 +1,26 @@ +P2P and network changes +----------------------- + +- The protocol version was bumped to 70241. Mixing sessions that may + contain promotion/demotion entries are only formed by, and only admit, + peers at protocol 70241 or newer: the `dsa` message gained a + version-gated flags field declaring which mixing direction a + participant intends, and the session creator's protocol version fixes + the session's capability. Older clients are rejected from such sessions + at acceptance time (before any collateral is committed) and continue to + mix normally in sessions created by older peers, which newer clients + still join for standard mixing. (#7052) + +- A mixing session only completes once each side of its denomination is + occupied by nobody or by at least two participants, since coins are + only concealed by other coins of the same size on the same side. A + session that attracts a lone promotion or demotion participant and no + counterpart therefore waits, and expires in the queue stage without + charging anyone's collateral, rather than publishing a transaction that + would identify that participant's coins. Because admission relies on + the declared directions, a participant whose entry deviates from what + it declared has its collateral consumed. (#7052) + Wallet ------ diff --git a/src/coinjoin/client.cpp b/src/coinjoin/client.cpp index 7819a13bc73f..6c0f6680df5e 100644 --- a/src/coinjoin/client.cpp +++ b/src/coinjoin/client.cpp @@ -467,6 +467,9 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ if (!mixingMasternode) return false; + // Evaluated before taking cs_wallet (IsPromotionDemotionActive locks cs_main) + const bool fV24Active = CoinJoin::IsPromotionDemotionActive(active_chainstate.m_chainman); + LOCK(m_wallet->cs_wallet); LOCK(cs_coinjoin); @@ -489,8 +492,9 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ // Make sure all inputs/outputs are valid PoolMessage nMessageID{MSG_NOERR}; + CoinJoin::SessionDenomCounts denomCounts; if (!IsValidInOuts(active_chainstate, m_isman, mempool, finalMutableTransaction.vin, finalMutableTransaction.vout, - nSessionDenom, nMessageID, nullptr, /*fFinalTx=*/true)) { + nSessionDenom, nMessageID, nullptr, /*fFinalTx=*/true, &denomCounts)) { WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- ERROR! IsValidInOuts() failed: %s\n", __func__, CoinJoin::GetMessageByID(nMessageID).translated); UnlockCoins(); keyHolderStorage.ReturnAll(); @@ -548,6 +552,38 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ } } + // Post-V24: refuse to sign unless somebody else holds coins at the session denomination on + // whichever side we do. Only same-denomination coins conceal ours - coins at the larger + // adjacent denomination are told apart by amount - so a malicious masternode could + // otherwise pad the transaction with larger-denom coins and publish one that links our ten + // promotion inputs, or our ten demotion outputs, to each other in plain sight. The server + // never finalizes a session where a side has a lone occupant, so this never rejects an + // honest final transaction. + if (fV24Active) { + const CAmount nSessionAmount = CoinJoin::DenominationToAmount(nSessionDenom); + size_t nOwnSessionInputs{0}; + size_t nOwnSessionOutputs{0}; + for (const auto& entry : vecEntries) { + // Our own promotion inputs and standard inputs are at the session denomination; a + // demotion spends a single larger-denom coin, which needs no cover of its own. + if (entry.GetMixShape() != CoinJoin::MixShape::DEMOTION) nOwnSessionInputs += entry.vecTxDSIn.size(); + for (const auto& txout : entry.vecTxOut) { + if (txout.nValue == nSessionAmount) ++nOwnSessionOutputs; + } + } + + const bool fInputsCovered = nOwnSessionInputs == 0 || denomCounts.inputs > nOwnSessionInputs; + const bool fOutputsCovered = nOwnSessionOutputs == 0 || denomCounts.outputs > nOwnSessionOutputs; + if (!fInputsCovered || !fOutputsCovered) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- no cover at the session denom (inputs %d/%d, outputs %d/%d), refusing to sign!\n", + __func__, nOwnSessionInputs, denomCounts.inputs, nOwnSessionOutputs, denomCounts.outputs); + UnlockCoins(); + keyHolderStorage.ReturnAll(); + SetNull(); + return false; + } + } + // fill values for found outpoints m_wallet->chain().findCoins(coins); std::map signing_errors; @@ -1408,7 +1444,12 @@ bool CCoinJoinClientSession::JoinExistingQueue(CAmount nBalanceNeedsAnonymized, nSessionDenom = dsq.nDenom; mixingMasternode = dmn; - pendingDsaRequest = CPendingDsaRequest(dmn->proTxHash, CCoinJoinAccept(nSessionDenom, txMyCollateral)); + // Declare which side of the session denomination we will occupy so the masternode can + // tell whether the session can still cover both sides (rebalance sessions only) + const uint8_t nDsaFlags = fPromotion ? CCoinJoinAccept::FLAG_PROMOTION + : fDemotion ? CCoinJoinAccept::FLAG_DEMOTION : uint8_t{0}; + pendingDsaRequest = CPendingDsaRequest(dmn->proTxHash, + CCoinJoinAccept(nSessionDenom, txMyCollateral, nDsaFlags)); connman.AddPendingMasternode(dmn->proTxHash); SetState(POOL_STATE_QUEUE); nTimeLastSuccessfulStep = GetTime(); @@ -1577,7 +1618,12 @@ bool CCoinJoinClientSession::StartNewQueue(CAmount nBalanceNeedsAnonymized, CCon nSessionDenom = nTargetDenom; mixingMasternode = dmn; connman.AddPendingMasternode(dmn->proTxHash); - pendingDsaRequest = CPendingDsaRequest(dmn->proTxHash, CCoinJoinAccept(nSessionDenom, txMyCollateral)); + // This overload always starts a promotion/demotion session - declare which side of the + // session denomination we will occupy so the masternode can track session coverage + pendingDsaRequest = CPendingDsaRequest(dmn->proTxHash, + CCoinJoinAccept(nSessionDenom, txMyCollateral, + fPromotion ? CCoinJoinAccept::FLAG_PROMOTION + : CCoinJoinAccept::FLAG_DEMOTION)); SetState(POOL_STATE_QUEUE); nTimeLastSuccessfulStep = GetTime(); @@ -1613,7 +1659,16 @@ bool CCoinJoinClientSession::ProcessPendingDsaRequest(CConnman& connman) return false; } - bool fDone = connman.ForNode(mn_addr, [this, &connman](CNode* pnode) { + bool fMnTooOldForRebalance{false}; + bool fDone = connman.ForNode(mn_addr, [this, &connman, &fMnTooOldForRebalance](CNode* pnode) { + // Post-V24: a rebalance dsa carries a version-gated flags field the masternode must + // understand - and the masternode must be able to host a rebalance-capable session. + // If it negotiated an older protocol, abort and let DoAutomaticDenominating retry + // with another masternode instead of silently mixing without a reserved slot. + if (pendingDsaRequest.GetDSA().IsRebalance() && pnode->GetCommonVersion() < COINJOIN_REBALANCE_VERSION) { + fMnTooOldForRebalance = true; + return false; + } WalletCJLogPrint(m_wallet, "-- processing dsa queue for addr=%s\n", pnode->addr.ToStringAddrPort()); nTimeLastSuccessfulStep = GetTime(); CNetMsgMaker msgMaker(pnode->GetCommonVersion()); @@ -1621,6 +1676,14 @@ bool CCoinJoinClientSession::ProcessPendingDsaRequest(CConnman& connman) return true; }); + if (fMnTooOldForRebalance) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- masternode too old for a rebalance session, masternode=%s\n", + __func__, pendingDsaRequest.GetProTxHash().ToString()); + UnlockCoins(); + WITH_LOCK(cs_coinjoin, SetNull()); + return false; + } + if (fDone) { pendingDsaRequest = CPendingDsaRequest(); } else if (pendingDsaRequest.IsExpired()) { diff --git a/src/coinjoin/coinjoin.cpp b/src/coinjoin/coinjoin.cpp index d8c36d09afe6..05bd44a801ac 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -250,7 +250,8 @@ bool CoinJoin::IsPromotionDemotionActive(const ChainstateManager& chainman) bool CCoinJoinBaseSession::IsValidInOuts(Chainstate& active_chainstate, const llmq::CInstantSendManager& isman, const CTxMemPool& mempool, const std::vector& vin, const std::vector& vout, int session_denom, PoolMessage& nMessageIDRet, - bool* fConsumeCollateralRet, bool fFinalTx) + bool* fConsumeCollateralRet, bool fFinalTx, + CoinJoin::SessionDenomCounts* pDenomCountsRet) { std::set setScripPubKeys; nMessageIDRet = MSG_NOERR; @@ -406,6 +407,11 @@ bool CCoinJoinBaseSession::IsValidInOuts(Chainstate& active_chainstate, const ll return false; } + if (pDenomCountsRet) { + pDenomCountsRet->inputs = vin.size() - nLargerInputs; + pDenomCountsRet->outputs = vout.size() - nLargerOutputs; + } + LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::%s -- Valid %s entry: %d inputs, %d outputs\n", __func__, entryType == EntryType::PROMOTION ? "PROMOTION" : diff --git a/src/coinjoin/coinjoin.h b/src/coinjoin/coinjoin.h index 1bb2eb67b77d..45a2dc942ddd 100644 --- a/src/coinjoin/coinjoin.h +++ b/src/coinjoin/coinjoin.h @@ -139,23 +139,43 @@ class CCoinJoinStatusUpdate class CCoinJoinAccept { public: + //! dsa flags (post-V24): which kind of rebalance entry this participant intends to submit. + //! The direction matters to the masternode because a promotion only ever adds coins at the + //! session denomination to the input side and a demotion only to the output side, and each + //! side needs either nobody or at least two participants (see CoinJoin::MixSideCounts). + static constexpr uint8_t FLAG_PROMOTION{1 << 0}; + static constexpr uint8_t FLAG_DEMOTION{1 << 1}; + int nDenom{0}; CMutableTransaction txCollateral; + //! Only serialized between peers at or above COINJOIN_REBALANCE_VERSION; old peers + //! neither send nor receive it, so their wire format is unchanged + uint8_t nFlags{0}; CCoinJoinAccept() = default; - CCoinJoinAccept(int nDenom, CMutableTransaction txCollateral) : + CCoinJoinAccept(int nDenom, CMutableTransaction txCollateral, uint8_t nFlags = 0) : nDenom(nDenom), - txCollateral(std::move(txCollateral)){}; + txCollateral(std::move(txCollateral)), + nFlags(nFlags){}; SERIALIZE_METHODS(CCoinJoinAccept, obj) { READWRITE(obj.nDenom, obj.txCollateral); + if (s.GetVersion() >= COINJOIN_REBALANCE_VERSION) { + READWRITE(obj.nFlags); + } } + [[nodiscard]] bool IsPromotion() const { return (nFlags & FLAG_PROMOTION) != 0; } + [[nodiscard]] bool IsDemotion() const { return (nFlags & FLAG_DEMOTION) != 0; } + [[nodiscard]] bool IsRebalance() const { return IsPromotion() || IsDemotion(); } + //! A participant mixes in exactly one direction; anything else is malformed + [[nodiscard]] bool HasValidFlags() const { return !(IsPromotion() && IsDemotion()); } + friend bool operator==(const CCoinJoinAccept& a, const CCoinJoinAccept& b) { - return a.nDenom == b.nDenom && CTransaction(a.txCollateral) == CTransaction(b.txCollateral); + return a.nDenom == b.nDenom && a.nFlags == b.nFlags && CTransaction(a.txCollateral) == CTransaction(b.txCollateral); } }; @@ -204,14 +224,20 @@ class CCoinJoinEntry bool AddScriptSig(const CTxIn& txin); - // Check if this is a standard mixing entry (not promotion/demotion) - // Standard: equal number of inputs and outputs - // Promotion: PROMOTION_RATIO inputs, 1 output - // Demotion: 1 input, PROMOTION_RATIO outputs - bool IsStandardMixingEntry() const + /// Which side(s) of the session denomination this entry occupies, derived from its shape. + /// Empty and malformed entries are UNKNOWN; they occupy neither side and are rejected + /// before they reach the pool. + [[nodiscard]] CoinJoin::MixShape GetMixShape() const { - return vecTxDSIn.size() == vecTxOut.size(); + using CoinJoin::MixShape; + if (vecTxDSIn.empty() || vecTxOut.empty()) return MixShape::UNKNOWN; + if (vecTxDSIn.size() == vecTxOut.size()) return MixShape::STANDARD; + if (vecTxDSIn.size() == size_t(CoinJoin::PROMOTION_RATIO) && vecTxOut.size() == 1) return MixShape::PROMOTION; + if (vecTxDSIn.size() == 1 && vecTxOut.size() == size_t(CoinJoin::PROMOTION_RATIO)) return MixShape::DEMOTION; + return MixShape::UNKNOWN; } + + [[nodiscard]] bool IsStandardMixingEntry() const { return GetMixShape() == CoinJoin::MixShape::STANDARD; } }; @@ -364,7 +390,7 @@ class CCoinJoinBaseSession static bool IsValidInOuts(Chainstate& active_chainstate, const llmq::CInstantSendManager& isman, const CTxMemPool& mempool, const std::vector& vin, const std::vector& vout, int session_denom, PoolMessage& nMessageIDRet, bool* fConsumeCollateralRet, - bool fFinalTx = false); + bool fFinalTx = false, CoinJoin::SessionDenomCounts* pDenomCountsRet = nullptr); public: // Atomic because the message-handling and scheduler threads write it while those threads and @@ -380,18 +406,15 @@ class CCoinJoinBaseSession int GetEntriesCount() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) { LOCK(cs_coinjoin); return vecEntries.size(); } int GetEntriesCountLocked() const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin) { return vecEntries.size(); } - // Count only standard mixing entries (not promotion/demotion) for privacy threshold - int GetStandardEntriesCount() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) + /// Participants occupying each side of the session denomination among the entries received + CoinJoin::MixSideCounts GetMixSideCounts() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) { LOCK(cs_coinjoin); - return std::count_if(vecEntries.begin(), vecEntries.end(), - [](const CCoinJoinEntry& entry) { return entry.IsStandardMixingEntry(); }); - } - - int GetStandardEntriesCountLocked() const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin) - { - return std::count_if(vecEntries.begin(), vecEntries.end(), - [](const CCoinJoinEntry& entry) { return entry.IsStandardMixingEntry(); }); + CoinJoin::MixSideCounts counts; + for (const auto& entry : vecEntries) { + counts.Add(entry.GetMixShape()); + } + return counts; } }; diff --git a/src/coinjoin/common.h b/src/coinjoin/common.h index 4a8ccbfa096e..97a79f2c6a46 100644 --- a/src/coinjoin/common.h +++ b/src/coinjoin/common.h @@ -11,6 +11,7 @@ #include #include +#include #include /** Holds a mixing input @@ -119,6 +120,58 @@ constexpr CAmount GetMaxCollateralAmount() { return GetCollateralAmount() * 4; } constexpr int PROMOTION_RATIO = 10; // 10 smaller denomination coins = 1 larger denomination coin constexpr int GAP_THRESHOLD = 10; // Deficit gap required to trigger promotion/demotion +/** + * Which side(s) of the session denomination a participant occupies. A standard entry mixes at + * the session denomination on both sides. A promotion spends PROMOTION_RATIO session-denom + * inputs for one larger-denom output, so it only occupies the input side; a demotion is the + * mirror image. UNKNOWN is for empty or malformed entries, which occupy neither. + */ +enum class MixShape : uint8_t { UNKNOWN, STANDARD, PROMOTION, DEMOTION }; + +/** + * How many participants occupy each side of the session denomination. + * + * Mixing only conceals a participant when someone else holds coins of the same size on the + * same side: a group of session-denom coins is hidden by the other session-denom coins it + * could be confused with. A participant holding a single coin at the larger denomination has + * no group to hide, which is why only the session denomination is counted here. + */ +struct MixSideCounts { + int inputs{0}; //!< participants contributing session-denom inputs (standard + promotions) + int outputs{0}; //!< participants receiving session-denom outputs (standard + demotions) + + constexpr void Add(MixShape shape) + { + switch (shape) { + case MixShape::STANDARD: + ++inputs; + ++outputs; + break; + case MixShape::PROMOTION: + ++inputs; + break; + case MixShape::DEMOTION: + ++outputs; + break; + case MixShape::UNKNOWN: + break; + } + } + + /** + * Each side must be occupied by nobody or by at least two participants. Exactly one is the + * only forbidden count: that participant's session-denom coins would be the only ones of + * their size on that side and so would be trivially identifiable on-chain. + */ + [[nodiscard]] constexpr bool IsCovered() const { return inputs != 1 && outputs != 1; } +}; + +/// How many coins of a final transaction sit at the session denomination on each side +struct SessionDenomCounts { + size_t inputs{0}; + size_t outputs{0}; +}; + /** * Check if two denominations are adjacent (one step apart in the denom list) * Used for validating promotion/demotion entries post-V24 diff --git a/src/coinjoin/server.cpp b/src/coinjoin/server.cpp index bcc4ca62d4cf..8fa1cb223320 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -113,8 +113,8 @@ void CCoinJoinServer::ProcessDSACCEPT(CNode& peer, CDataStream& vRecv) PoolMessage nMessageID = MSG_NOERR; - bool fResult = nSessionID == 0 ? CreateNewSession(dsa, nMessageID) - : AddUserToExistingSession(dsa, nMessageID); + bool fResult = nSessionID == 0 ? CreateNewSession(dsa, peer.GetCommonVersion(), nMessageID) + : AddUserToExistingSession(dsa, peer.GetCommonVersion(), nMessageID); if (fResult) { LogPrint(BCLog::COINJOIN, "DSACCEPT -- is compatible, please submit!\n"); PushStatus(peer, STATUS_ACCEPTED, nMessageID); @@ -288,6 +288,8 @@ void CCoinJoinServer::SetNull() // MN side vecSessionCollaterals.clear(); setSessionCollateralPrevouts.clear(); + m_fRebalanceSession = false; + m_mapDeclaredShapes.clear(); CCoinJoinBaseSession::SetNull(); m_queueman.SetNull(); @@ -301,28 +303,31 @@ void CCoinJoinServer::CheckPool() if (int entries = GetEntriesCount(); entries != 0) LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- entries count %lu\n", entries); + // PRIVACY: a final transaction is only worth publishing when each side of the session + // denomination is occupied by nobody or by at least two participants; a lone participant + // on a side would have the only coins of that size there and be trivially identifiable. + const auto sides = GetMixSideCounts(); + // If we have an entry for each collateral, then create final tx - if (nState == POOL_STATE_ACCEPTING_ENTRIES && static_cast(GetEntriesCount()) == vecSessionCollaterals.size()) { - const int nStandardEntries = GetStandardEntriesCount(); - if (nStandardEntries >= CoinJoin::GetMinPoolParticipants()) { + if (nState == POOL_STATE_ACCEPTING_ENTRIES && size_t(GetEntriesCount()) == vecSessionCollaterals.size()) { + if (sides.IsCovered()) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n"); CreateFinalTransaction(); return; } // The participant set is frozen once entries are being accepted, so this session can - // never reach the standard-mixer minimum anymore - reset instead of stalling everyone - // until timeout. Shouldn't happen given the promotion/demotion entry cap in AddEntry. - LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- all entries received but insufficient standard mixers (%d), resetting session\n", nStandardEntries); + // never gain the missing counterparty - reset instead of stalling everyone until + // timeout. Shouldn't happen: admission checks the declared shapes up front. + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- all entries received but a session denom side is uncovered (%d in, %d out), resetting session\n", + sides.inputs, sides.outputs); WITH_LOCK(cs_coinjoin, SetNull()); return; } // Check for Time Out // If we timed out while accepting entries, then if we have more than minimum, create final tx - // PRIVACY: Only count standard mixing entries toward minimum participant threshold - // Promotion/demotion entries don't count - they get privacy from standard mixers - if (nState == POOL_STATE_ACCEPTING_ENTRIES && CCoinJoinServer::HasTimedOut() - && GetStandardEntriesCount() >= CoinJoin::GetMinPoolParticipants()) { + if (nState == POOL_STATE_ACCEPTING_ENTRIES && CCoinJoinServer::HasTimedOut() && sides.IsCovered() && + GetEntriesCount() >= CoinJoin::GetMinPoolParticipants()) { // Punish misbehaving participants ChargeFees(); // Try to complete this session ignoring the misbehaving ones @@ -635,48 +640,61 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag { AssertLockNotHeld(cs_coinjoin); - int session_id; - int session_denom; + const auto isSessionCollateral = [&entry](const CTransactionRef& txCollateral) { + return *entry.txCollateral == *txCollateral; + }; + const auto hasEntryForCollateral = [&entry](const CCoinJoinEntry& other) { + return *other.txCollateral == *entry.txCollateral; + }; + + CoinJoin::MixShape declaredShape{CoinJoin::MixShape::STANDARD}; + int session_denom{0}; { LOCK(cs_coinjoin); - if (nSessionID == 0 || nState != POOL_STATE_ACCEPTING_ENTRIES) { - nMessageIDRet = ERR_SESSION; - return false; - } - if (static_cast(GetEntriesCountLocked()) >= vecSessionCollaterals.size()) { + + if (size_t(GetEntriesCountLocked()) >= vecSessionCollaterals.size()) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: entries is full!\n", __func__); nMessageIDRet = ERR_ENTRIES_FULL; return false; } - session_id = nSessionID; session_denom = nSessionDenom; + + // Entries are keyed one-to-one to the collaterals accepted at dsa time: a collateral + // that never went through dsa acceptance cannot submit an entry and an accepted + // collateral covers exactly one entry. Otherwise, once the ready queue is public, + // anyone holding a valid collateral could fill entry slots (and with them the + // declared-shape cover) belonging to the admitted participants. Don't consume the + // collateral in either case - it may be a replay of an innocent participant's + // collateral rather than misbehavior by its owner. + if (std::ranges::none_of(vecSessionCollaterals, isSessionCollateral)) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: collateral %s was not accepted into this session!\n", + __func__, entry.txCollateral->GetHash().ToString()); + nMessageIDRet = ERR_SESSION; + return false; + } + if (std::ranges::any_of(vecEntries, hasEntryForCollateral)) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: already have an entry for collateral %s!\n", + __func__, entry.txCollateral->GetHash().ToString()); + nMessageIDRet = ERR_ALREADY_HAVE; + return false; + } + + if (const auto it = m_mapDeclaredShapes.find(entry.txCollateral->GetHash()); it != m_mapDeclaredShapes.end()) { + declaredShape = it->second; + } } + const bool fV24Active = CoinJoin::IsPromotionDemotionActive(m_chainman); + // Post-V24: promotion entries carry PROMOTION_RATIO (10) inputs and demotion entries // PROMOTION_RATIO outputs; pre-V24 entries are capped at COINJOIN_ENTRY_MAX_SIZE (9) - const size_t nMaxEntrySize = CoinJoin::IsPromotionDemotionActive(m_chainman) ? size_t(CoinJoin::PROMOTION_RATIO) - : COINJOIN_ENTRY_MAX_SIZE; + const size_t nMaxEntrySize = fV24Active ? size_t(CoinJoin::PROMOTION_RATIO) : COINJOIN_ENTRY_MAX_SIZE; if (entry.vecTxDSIn.size() > nMaxEntrySize || entry.vecTxOut.size() > nMaxEntrySize) { LogPrint(BCLog::COINJOIN, /* Continued */ "CCoinJoinServer::%s -- ERROR: too many inputs or outputs! inputs=%s/%s, outputs=%s/%s\n", __func__, entry.vecTxDSIn.size(), nMaxEntrySize, entry.vecTxOut.size(), nMaxEntrySize); nMessageIDRet = ERR_MAXIMUM; - - CTransactionRef txCollateralToConsume; - { - LOCK(cs_coinjoin); - if (IsCurrentSession(session_id, session_denom, POOL_STATE_ACCEPTING_ENTRIES)) { - const auto it = std::ranges::find_if(vecSessionCollaterals, [&entry](const auto& txCollateral) { - return *entry.txCollateral == *txCollateral; - }); - if (it != vecSessionCollaterals.end()) { - txCollateralToConsume = *it; - } - } - } - if (txCollateralToConsume) { - ConsumeCollateral(txCollateralToConsume); - } + ConsumeCollateral(entry.txCollateral); return false; } @@ -686,26 +704,51 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag return false; } - // Post-V24: cap promotion/demotion entries so the session can still reach - // GetMinPoolParticipants() standard entries once every collateral is matched by an - // entry. The participant set is frozen while accepting entries, so without this cap - // the pool could fill up but never satisfy the standard-mixer minimum in CheckPool. - if (!entry.IsStandardMixingEntry()) { - const int nMaxNonStandardEntries = int(vecSessionCollaterals.size()) - CoinJoin::GetMinPoolParticipants(); - const int nNonStandardEntries = GetEntriesCount() - GetStandardEntriesCount(); - if (nNonStandardEntries >= nMaxNonStandardEntries) { - LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- rejecting promotion/demotion entry, not enough standard mixer slots left! %d/%d\n", - __func__, nNonStandardEntries, nMaxNonStandardEntries); - // the entry itself is valid, the session just can't accept it - don't punish - nMessageIDRet = ERR_ENTRIES_FULL; + // An entry that occupies neither side of the session denomination (empty, or a shape that + // is neither standard nor promotion/demotion) contributes nothing and must never take up + // a participant slot - it would otherwise count toward the session denomination coverage + // without providing any. + if (entry.GetMixShape() == CoinJoin::MixShape::UNKNOWN) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: entry occupies no side of the session denom! inputs=%s, outputs=%s\n", + __func__, entry.vecTxDSIn.size(), entry.vecTxOut.size()); + nMessageIDRet = ERR_SIZE_MISMATCH; + ConsumeCollateral(entry.txCollateral); + return false; + } + + // Pre-V24, unbalanced entries deliberately fall through to IsValidInOuts so their + // collateral is consumed (anti-spam), same as before this feature existed + if (fV24Active) { + // Every entry must match the direction its participant declared in the dsa - standard + // entries included. Admission counted on the declared shapes to decide the session + // covers both sides of the session denomination, so a participant deviating (e.g. + // declaring a promotion but submitting a standard entry) could strip a side of its + // cover and force a fee-free session reset for everyone. Deviating from the declared + // direction therefore costs the collateral. + if (entry.GetMixShape() != declaredShape) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: entry shape doesn't match the declared direction! inputs=%s, outputs=%s\n", + __func__, entry.vecTxDSIn.size(), entry.vecTxOut.size()); + nMessageIDRet = ERR_SIZE_MISMATCH; + ConsumeCollateral(entry.txCollateral); return false; } } std::vector vin; - vin.reserve(entry.vecTxDSIn.size()); for (const auto& txin : entry.vecTxDSIn) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- txin=%s\n", __func__, txin.ToString()); + LOCK(cs_coinjoin); + for (const auto& inner_entry : vecEntries) { + if (std::ranges::any_of(inner_entry.vecTxDSIn, + [&txin](const auto& txdsin) { return txdsin.prevout == txin.prevout; })) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: already have this txin in entries\n", __func__); + nMessageIDRet = ERR_ALREADY_HAVE; + // Two peers sent the same input? Can't really say who is the malicious one here, + // could be that someone is picking someone else's inputs randomly trying to force + // collateral consumption. Do not punish. + return false; + } + } vin.emplace_back(txin); } @@ -714,49 +757,22 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag nMessageIDRet, &fConsumeCollateral)) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR! IsValidInOuts() failed: %s\n", __func__, CoinJoin::GetMessageByID(nMessageIDRet).translated); if (fConsumeCollateral) { - CTransactionRef txCollateralToConsume; - { - LOCK(cs_coinjoin); - if (IsCurrentSession(session_id, session_denom, POOL_STATE_ACCEPTING_ENTRIES)) { - const auto it = std::ranges::find_if(vecSessionCollaterals, [&entry](const auto& txCollateral) { - return *entry.txCollateral == *txCollateral; - }); - if (it != vecSessionCollaterals.end()) { - txCollateralToConsume = *it; - } - } - } - if (txCollateralToConsume) { - ConsumeCollateral(txCollateralToConsume); - } + ConsumeCollateral(entry.txCollateral); } return false; } { LOCK(cs_coinjoin); - if (!IsCurrentSession(session_id, session_denom, POOL_STATE_ACCEPTING_ENTRIES)) { + // cs_coinjoin was released around the UTXO checks above, so the scheduler thread may + // have reset the session in between; re-verify so admission stays atomic with the + // membership and duplicate-use checks. + if (std::ranges::none_of(vecSessionCollaterals, isSessionCollateral) || + std::ranges::any_of(vecEntries, hasEntryForCollateral)) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: session changed while validating the entry!\n", __func__); nMessageIDRet = ERR_SESSION; return false; } - if (static_cast(GetEntriesCountLocked()) >= vecSessionCollaterals.size()) { - nMessageIDRet = ERR_ENTRIES_FULL; - return false; - } - for (const auto& txin : vin) { - for (const auto& inner_entry : vecEntries) { - if (std::ranges::any_of(inner_entry.vecTxDSIn, - [&txin](const auto& txdsin) { return txdsin.prevout == txin.prevout; })) { - LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: already have this txin in entries\n", - __func__); - nMessageIDRet = ERR_ALREADY_HAVE; - // Two peers sent the same input? Can't really say who is the malicious one here, - // could be that someone is picking someone else's inputs randomly trying to force - // collateral consumption. Do not punish. - return false; - } - } - } vecEntries.push_back(entry); } @@ -843,13 +859,26 @@ void CCoinJoinServer::CommitSessionCollateral(const CMutableTransaction& txColla } } -bool CCoinJoinServer::IsCurrentSession(int session_id, int session_denom, PoolState state) const +//! Which side of the session denomination a participant will occupy, from its declared dsa +//! direction. A participant that declares nothing is mixing 1:1 and occupies both sides. +static CoinJoin::MixShape DeclaredShape(const CCoinJoinAccept& dsa) +{ + if (dsa.IsPromotion()) return CoinJoin::MixShape::PROMOTION; + if (dsa.IsDemotion()) return CoinJoin::MixShape::DEMOTION; + return CoinJoin::MixShape::STANDARD; +} + +CoinJoin::MixSideCounts CCoinJoinServer::GetDeclaredSideCounts() const { AssertLockHeld(cs_coinjoin); - return nSessionID == session_id && nSessionDenom == session_denom && nState == state; + CoinJoin::MixSideCounts counts; + for (const auto& [_, shape] : m_mapDeclaredShapes) { + counts.Add(shape); + } + return counts; } -bool CCoinJoinServer::CreateNewSession(const CCoinJoinAccept& dsa, PoolMessage& nMessageIDRet) +bool CCoinJoinServer::CreateNewSession(const CCoinJoinAccept& dsa, int nPeerVersion, PoolMessage& nMessageIDRet) { if (nSessionID != 0) return false; @@ -864,6 +893,23 @@ bool CCoinJoinServer::CreateNewSession(const CCoinJoinAccept& dsa, PoolMessage& return false; } + if (!dsa.HasValidFlags()) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CreateNewSession -- malformed dsa flags\n"); + nMessageIDRet = ERR_VERSION; + return false; + } + + // Post-V24: the creator's protocol version fixes the session version. Only sessions + // created by rebalance-capable peers may ever contain promotion/demotion entries, so + // clients that cannot validate an unbalanced final transaction are never mixed into one. + const bool fRebalanceSession = nPeerVersion >= COINJOIN_REBALANCE_VERSION && + CoinJoin::IsPromotionDemotionActive(m_chainman); + if (dsa.IsRebalance() && !fRebalanceSession) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CreateNewSession -- rejecting rebalance dsa, promotion/demotion not active\n"); + nMessageIDRet = ERR_VERSION; + return false; + } + { LOCK(cs_coinjoin); @@ -879,6 +925,8 @@ bool CCoinJoinServer::CreateNewSession(const CCoinJoinAccept& dsa, PoolMessage& nMessageIDRet = MSG_NOERR; nSessionID = GetRand(/*nMax=*/999999) + 1; nSessionDenom = dsa.nDenom; + m_fRebalanceSession = fRebalanceSession; + m_mapDeclaredShapes.emplace(dsa.txCollateral.GetHash(), DeclaredShape(dsa)); SetState(POOL_STATE_QUEUE); @@ -901,7 +949,7 @@ bool CCoinJoinServer::CreateNewSession(const CCoinJoinAccept& dsa, PoolMessage& return true; } -bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, PoolMessage& nMessageIDRet) +bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, int nPeerVersion, PoolMessage& nMessageIDRet) { int session_id; int session_denom; @@ -942,6 +990,38 @@ bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, PoolM return false; } + // Post-V24: never mix clients below COINJOIN_REBALANCE_VERSION into a session that may + // contain promotion/demotion entries - they cannot validate the unbalanced final + // transaction, would refuse to sign it and risk being charged their collateral. + // ERR_VERSION is within the message range old clients understand, so they reset + // immediately and move on to another queue. + if (m_fRebalanceSession && nPeerVersion < COINJOIN_REBALANCE_VERSION) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- rejecting peer with protocol=%d from rebalance session\n", + nPeerVersion); + nMessageIDRet = ERR_VERSION; + return false; + } + + // IsSessionReady() can now hold a full session back waiting for a missing counterparty, so + // the participant limit has to be enforced here rather than implied by session readiness + if ((int)vecSessionCollaterals.size() >= CoinJoin::GetMaxPoolParticipants()) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- session is full\n"); + nMessageIDRet = ERR_QUEUE_FULL; + return false; + } + + if (!dsa.HasValidFlags()) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- malformed dsa flags\n"); + nMessageIDRet = ERR_VERSION; + return false; + } + + if (dsa.IsRebalance() && !m_fRebalanceSession) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- rejecting rebalance dsa, not a rebalance session\n"); + nMessageIDRet = ERR_VERSION; + return false; + } + // Session collaterals are only ever test-accepted, never added to the mempool, so nothing // pins their identity: the same UTXO can be re-signed into arbitrarily many distinct txids. // Match on input prevouts so a resent or replayed dsa cannot be counted as a new participant. @@ -957,6 +1037,7 @@ bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, PoolM // count new user as accepted to an existing session nMessageIDRet = MSG_NOERR; + m_mapDeclaredShapes.emplace(dsa.txCollateral.GetHash(), DeclaredShape(dsa)); CommitSessionCollateral(dsa.txCollateral); LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- new user accepted, nSessionID: %d nSessionDenom: %d (%s) vecSessionCollaterals.size(): %d CoinJoin::GetMaxPoolParticipants(): %d\n", @@ -969,6 +1050,10 @@ bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, PoolM bool CCoinJoinServer::IsSessionReady() const { if (nState == POOL_STATE_QUEUE) { + // PRIVACY: don't start mixing until each side of the session denomination is occupied + // by nobody or by at least two participants. A session that never attracts the missing + // counterparty simply expires in queue state, where no collateral is charged. + if (!GetDeclaredSideCounts().IsCovered()) return false; if (static_cast(vecSessionCollaterals.size()) >= CoinJoin::GetMaxPoolParticipants()) { return true; } diff --git a/src/coinjoin/server.h b/src/coinjoin/server.h index 289e6ca876be..bf4eebe82043 100644 --- a/src/coinjoin/server.h +++ b/src/coinjoin/server.h @@ -12,6 +12,7 @@ #include #include +#include #include class CActiveMasternodeManager; @@ -43,6 +44,10 @@ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler const CMasternodeSync& m_mn_sync; const llmq::CInstantSendManager& m_isman; +protected: + // Session state and entry admission live in the protected section so unit tests can seed + // and drive them through a test subclass. + // Mixing uses collateral transactions to trust parties entering the pool // to behave honestly. If they don't it takes their money. std::vector vecSessionCollaterals; @@ -50,10 +55,27 @@ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler // reuses one of them can be rejected without rescanning them all. std::unordered_set setSessionCollateralPrevouts GUARDED_BY(cs_coinjoin); - bool fUnitTest; + // Post-V24: true when this session may contain promotion/demotion entries. Fixed at + // session creation from the creator's protocol version; only peers at or above + // COINJOIN_REBALANCE_VERSION are allowed into such a session, so clients that cannot + // validate an unbalanced final transaction never end up having to refuse to sign one. + bool m_fRebalanceSession{false}; + // The mixing direction each accepted participant declared in its dsa, keyed by collateral + // hash. Tells us which side of the session denomination a participant will occupy before + // its entry arrives, and entitles it (and only it) to submit an entry of that shape. + std::map m_mapDeclaredShapes GUARDED_BY(cs_coinjoin); + + /// Sides of the session denomination the accepted participants declared they will occupy + CoinJoin::MixSideCounts GetDeclaredSideCounts() const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin); /// Add a clients entry to the pool bool AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessageIDRet) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); + /// Record an accepted collateral and index its input prevouts + void CommitSessionCollateral(const CMutableTransaction& txCollateral) EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin); + +private: + bool fUnitTest; + /// Add signature to a txin bool AddScriptSig(const CTxIn& txin) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); @@ -72,13 +94,10 @@ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler /// Is this nDenom and txCollateral acceptable? bool IsAcceptableDSA(const CCoinJoinAccept& dsa, PoolMessage& nMessageIDRet) const; - bool IsCurrentSession(int session_id, int session_denom, PoolState state) const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin); - /// Record an accepted collateral and index its input prevouts - void CommitSessionCollateral(const CMutableTransaction& txCollateral) EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin); - bool CreateNewSession(const CCoinJoinAccept& dsa, PoolMessage& nMessageIDRet) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); - bool AddUserToExistingSession(const CCoinJoinAccept& dsa, PoolMessage& nMessageIDRet) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); + bool CreateNewSession(const CCoinJoinAccept& dsa, int nPeerVersion, PoolMessage& nMessageIDRet) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); + bool AddUserToExistingSession(const CCoinJoinAccept& dsa, int nPeerVersion, PoolMessage& nMessageIDRet) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); /// Do we have enough users to take entries? - bool IsSessionReady() const; + bool IsSessionReady() const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin); /// Check that all inputs are signed. (Are all inputs signed?) bool IsSignaturesComplete() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); diff --git a/src/test/coinjoin_inouts_tests.cpp b/src/test/coinjoin_inouts_tests.cpp index 32443ee11f7c..56cc05b0a173 100644 --- a/src/test/coinjoin_inouts_tests.cpp +++ b/src/test/coinjoin_inouts_tests.cpp @@ -181,8 +181,10 @@ class TestableCoinJoinServer : public CCoinJoinServer { public: using CCoinJoinServer::CCoinJoinServer; + using CCoinJoinServer::AddEntry; void EnterSigningState() { nState = POOL_STATE_SIGNING; } + void EnterAcceptingEntriesState() { nState = POOL_STATE_ACCEPTING_ENTRIES; } void SeedParticipant(const CService& addr) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) { @@ -191,6 +193,20 @@ class TestableCoinJoinServer : public CCoinJoinServer LOCK(cs_coinjoin); vecEntries.push_back(std::move(entry)); } + + void SeedSessionCollateral(const CMutableTransaction& txCollateral, CoinJoin::MixShape shape) + EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) + { + LOCK(cs_coinjoin); + m_mapDeclaredShapes.emplace(txCollateral.GetHash(), shape); + CommitSessionCollateral(txCollateral); + } + + void SeedEntry(CCoinJoinEntry entry) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) + { + LOCK(cs_coinjoin); + vecEntries.push_back(std::move(entry)); + } }; static std::unique_ptr MakePeer(NodeId id, uint32_t ipv4) @@ -326,6 +342,58 @@ BOOST_AUTO_TEST_CASE(validation_uses_session_denom_snapshot) BOOST_CHECK(consume_collateral); } +BOOST_AUTO_TEST_CASE(server_addentry_binds_entries_to_accepted_collaterals) +{ + CActiveMasternodeManager mn_activeman(*Assert(m_node.connman), *Assert(m_node.dmnman), MakeSecretKey()); + TestableCoinJoinServer server(m_node.peerman.get(), *Assert(m_node.chainman), *Assert(m_node.connman), + *Assert(m_node.dmnman), *Assert(m_node.dstxman), *Assert(m_node.mn_metaman), + *Assert(m_node.mempool), mn_activeman, *Assert(m_node.mn_sync), + *Assert(m_node.llmq_ctx->isman)); + + auto make_collateral = [](uint8_t tag) { + CMutableTransaction tx; + tx.vin.emplace_back(COutPoint(uint256::ONE, tag)); + tx.vout.emplace_back(COIN / 10, P2PKHScript(tag)); + return tx; + }; + // Distinct mixing inputs per entry so none of the checks below trip the duplicate-txin path. + auto make_entry = [](const CMutableTransaction& txCollateral, uint8_t tag) { + std::vector dsins{CTxDSIn(CTxIn(COutPoint(uint256S("aa"), tag)), P2PKHScript(tag), /*nRounds=*/0)}; + std::vector outs{CTxOut(COIN / 1000 + 1, P2PKHScript(tag))}; + return CCoinJoinEntry(dsins, outs, CTransaction(txCollateral)); + }; + + const CMutableTransaction collateral_a = make_collateral(1); + const CMutableTransaction collateral_b = make_collateral(2); + const CMutableTransaction collateral_unadmitted = make_collateral(3); + + server.SeedSessionCollateral(collateral_a, CoinJoin::MixShape::STANDARD); + server.SeedSessionCollateral(collateral_b, CoinJoin::MixShape::STANDARD); + server.EnterAcceptingEntriesState(); + + // A collateral that never went through dsa acceptance cannot submit an entry, even + // though the session still has free slots. + PoolMessage msg{MSG_NOERR}; + BOOST_CHECK(!server.AddEntry(make_entry(collateral_unadmitted, 0x10), msg)); + BOOST_CHECK_EQUAL(msg, ERR_SESSION); + BOOST_CHECK_EQUAL(server.GetEntriesCount(), 0); + + // An accepted collateral covers exactly one entry: a second entry reusing the + // collateral of an existing entry is rejected. + server.SeedEntry(make_entry(collateral_a, 0x11)); + msg = MSG_NOERR; + BOOST_CHECK(!server.AddEntry(make_entry(collateral_a, 0x12), msg)); + BOOST_CHECK_EQUAL(msg, ERR_ALREADY_HAVE); + BOOST_CHECK_EQUAL(server.GetEntriesCount(), 1); + + // An accepted, unused collateral passes both checks and proceeds to collateral + // validation (which fails here because the fake collateral has no UTXO backing). + msg = MSG_NOERR; + BOOST_CHECK(!server.AddEntry(make_entry(collateral_b, 0x13), msg)); + BOOST_CHECK_EQUAL(msg, ERR_INVALID_COLLATERAL); + BOOST_CHECK_EQUAL(server.GetEntriesCount(), 1); +} + BOOST_AUTO_TEST_CASE(entry_deserializes_vectors_through_wire_cap) { const size_t wire_cap{CoinJoin::GetMaxPoolInputOutputCount()}; @@ -831,8 +899,9 @@ BOOST_AUTO_TEST_CASE(validate_final_tx_composition) // 3 standard + 1 promotion + 1 demotion BOOST_CHECK(CoinJoin::ValidateFinalTxComposition(3 + R + 1, 3 + 1 + R, 1, 1)); - // Promotion-only shape is composable (the standard-mixer minimum is enforced by the - // server in CheckPool/AddEntry, not by the composition check) + // Promotion-only shape is composable (the side-coverage invariant - each side of the + // session denom occupied by nobody or at least two participants - is enforced by the + // server in IsSessionReady/CheckPool, not by the composition check) BOOST_CHECK(CoinJoin::ValidateFinalTxComposition(R, 1, 0, 1)); // Not enough session-denom inputs to back the promotion output @@ -1317,77 +1386,163 @@ BOOST_AUTO_TEST_CASE(validate_demotion_entry_edge_cases) BOOST_CHECK_EQUAL(CoinJoin::GetSmallerAdjacentDenom(nSmallestDenom), 0); // No smaller exists } -BOOST_AUTO_TEST_CASE(is_standard_mixing_entry) +// Build an entry of a given shape; only the input/output counts matter for classification +static CCoinJoinEntry MakeEntry(size_t nInputs, size_t nOutputs) { - // Test IsStandardMixingEntry() method added for privacy protection - - // Standard entry: equal inputs and outputs - CCoinJoinEntry standard; - standard.vecTxDSIn.resize(3); - standard.vecTxOut.resize(3); - BOOST_CHECK(standard.IsStandardMixingEntry()); - - // Promotion entry: PROMOTION_RATIO inputs, 1 output - CCoinJoinEntry promotion; - promotion.vecTxDSIn.resize(CoinJoin::PROMOTION_RATIO); // 10 inputs - promotion.vecTxOut.resize(1); // 1 output - BOOST_CHECK(!promotion.IsStandardMixingEntry()); - - // Demotion entry: 1 input, PROMOTION_RATIO outputs - CCoinJoinEntry demotion; - demotion.vecTxDSIn.resize(1); // 1 input - demotion.vecTxOut.resize(CoinJoin::PROMOTION_RATIO); // 10 outputs - BOOST_CHECK(!demotion.IsStandardMixingEntry()); - - // Edge case: empty entry - CCoinJoinEntry empty; - BOOST_CHECK(empty.IsStandardMixingEntry()); // 0 == 0, technically standard - - // Edge case: 1:1 is standard - CCoinJoinEntry one_to_one; - one_to_one.vecTxDSIn.resize(1); - one_to_one.vecTxOut.resize(1); - BOOST_CHECK(one_to_one.IsStandardMixingEntry()); + CCoinJoinEntry entry; + entry.vecTxDSIn.resize(nInputs); + entry.vecTxOut.resize(nOutputs); + return entry; } -BOOST_AUTO_TEST_CASE(get_standard_entries_count) +BOOST_AUTO_TEST_CASE(entry_mix_shape_classification) { - // Test GetStandardEntriesCount() methods for privacy threshold enforcement - // This is tested through CCoinJoinBaseSession which vecEntries is part of - // We verify the logic by testing IsStandardMixingEntry on various entry types - - // Mix of standard and promotion/demotion entries - std::vector entries; - - // 3 standard entries - for (int i = 0; i < 3; ++i) { - CCoinJoinEntry standard; - standard.vecTxDSIn.resize(5); - standard.vecTxOut.resize(5); - entries.push_back(standard); - } + using CoinJoin::MixShape; + constexpr size_t R = CoinJoin::PROMOTION_RATIO; - // 1 promotion entry - CCoinJoinEntry promotion; - promotion.vecTxDSIn.resize(CoinJoin::PROMOTION_RATIO); - promotion.vecTxOut.resize(1); - entries.push_back(promotion); + // Standard entries mix at the session denomination on both sides + BOOST_CHECK(MakeEntry(3, 3).GetMixShape() == MixShape::STANDARD); + BOOST_CHECK(MakeEntry(1, 1).GetMixShape() == MixShape::STANDARD); + BOOST_CHECK(MakeEntry(9, 9).GetMixShape() == MixShape::STANDARD); + BOOST_CHECK(MakeEntry(3, 3).IsStandardMixingEntry()); + + BOOST_CHECK(MakeEntry(R, 1).GetMixShape() == MixShape::PROMOTION); + BOOST_CHECK(MakeEntry(1, R).GetMixShape() == MixShape::DEMOTION); + BOOST_CHECK(!MakeEntry(R, 1).IsStandardMixingEntry()); + BOOST_CHECK(!MakeEntry(1, R).IsStandardMixingEntry()); + + // An empty entry occupies neither side - it must not pass as a standard mixer, or it + // could take a participant slot while providing no cover at all + BOOST_CHECK(MakeEntry(0, 0).GetMixShape() == MixShape::UNKNOWN); + BOOST_CHECK(!MakeEntry(0, 0).IsStandardMixingEntry()); + BOOST_CHECK(MakeEntry(R, 0).GetMixShape() == MixShape::UNKNOWN); + BOOST_CHECK(MakeEntry(0, R).GetMixShape() == MixShape::UNKNOWN); + + // Ratios that are neither balanced nor a valid promotion/demotion + BOOST_CHECK(MakeEntry(9, 1).GetMixShape() == MixShape::UNKNOWN); + BOOST_CHECK(MakeEntry(1, 9).GetMixShape() == MixShape::UNKNOWN); + BOOST_CHECK(MakeEntry(R + 1, 1).GetMixShape() == MixShape::UNKNOWN); + BOOST_CHECK(MakeEntry(R, 2).GetMixShape() == MixShape::UNKNOWN); +} - // 1 demotion entry - CCoinJoinEntry demotion; - demotion.vecTxDSIn.resize(1); - demotion.vecTxOut.resize(CoinJoin::PROMOTION_RATIO); - entries.push_back(demotion); +BOOST_AUTO_TEST_CASE(mix_side_counts_invariant) +{ + using CoinJoin::MixShape; + using CoinJoin::MixSideCounts; + + // Each side of the session denomination must be occupied by nobody or by at least two + // participants; exactly one is the only forbidden count. Standard entries occupy both + // sides, promotions only the input side, demotions only the output side. + const auto sides = [](std::initializer_list shapes) { + MixSideCounts counts; + for (const auto shape : shapes) counts.Add(shape); + return counts; + }; - // Count standard entries manually (what GetStandardEntriesCount should return) - int standard_count = std::count_if(entries.begin(), entries.end(), - [](const CCoinJoinEntry& e) { return e.IsStandardMixingEntry(); }); + // Allowed compositions + BOOST_CHECK(sides({MixShape::STANDARD, MixShape::STANDARD, MixShape::STANDARD}).IsCovered()); + BOOST_CHECK(sides({MixShape::STANDARD, MixShape::STANDARD, MixShape::PROMOTION}).IsCovered()); + BOOST_CHECK(sides({MixShape::STANDARD, MixShape::STANDARD, MixShape::DEMOTION}).IsCovered()); + // Rebalancers can cover each other without any 1:1 mixer present + BOOST_CHECK(sides({MixShape::PROMOTION, MixShape::PROMOTION}).IsCovered()); + BOOST_CHECK(sides({MixShape::DEMOTION, MixShape::DEMOTION}).IsCovered()); + BOOST_CHECK(sides({MixShape::PROMOTION, MixShape::PROMOTION, MixShape::DEMOTION, MixShape::DEMOTION}).IsCovered()); + // An empty session is trivially covered + BOOST_CHECK(sides({}).IsCovered()); + + // Forbidden: the lone promoter is the only participant with session-denom inputs, so its + // ten inputs would be identifiable as a group on-chain + BOOST_CHECK(!sides({MixShape::PROMOTION}).IsCovered()); + BOOST_CHECK(!sides({MixShape::PROMOTION, MixShape::DEMOTION, MixShape::DEMOTION}).IsCovered()); + // Mirror image: the lone demoter is the only one receiving session-denom outputs + BOOST_CHECK(!sides({MixShape::DEMOTION}).IsCovered()); + BOOST_CHECK(!sides({MixShape::DEMOTION, MixShape::PROMOTION, MixShape::PROMOTION}).IsCovered()); + // A single standard mixer alone is uncovered on both sides + BOOST_CHECK(!sides({MixShape::STANDARD}).IsCovered()); + // One standard mixer plus one promoter leaves the output side with a lone occupant + BOOST_CHECK(!sides({MixShape::STANDARD, MixShape::PROMOTION}).IsCovered()); + + // Entries that occupy no side never count toward coverage + const auto unknown_only = sides({MixShape::UNKNOWN, MixShape::UNKNOWN}); + BOOST_CHECK_EQUAL(unknown_only.inputs, 0); + BOOST_CHECK_EQUAL(unknown_only.outputs, 0); +} - BOOST_CHECK_EQUAL(standard_count, 3); // Only 3 standard, not 5 total - BOOST_CHECK_EQUAL(entries.size(), 5); // Total is 5 +BOOST_AUTO_TEST_CASE(dsa_rebalance_flag_version_gated_serialization) +{ + // The dsa flags field is only serialized between peers at or above + // COINJOIN_REBALANCE_VERSION; the wire format for older peers must be unchanged + CMutableTransaction txCollateral; + txCollateral.vin.emplace_back(COutPoint(uint256::ONE, 0)); + txCollateral.vout.emplace_back(CoinJoin::GetCollateralAmount(), P2PKHScript(0x01)); + + const CCoinJoinAccept dsaPlain(1 << 2, txCollateral); + const CCoinJoinAccept dsaPromotion(1 << 2, txCollateral, CCoinJoinAccept::FLAG_PROMOTION); + const CCoinJoinAccept dsaDemotion(1 << 2, txCollateral, CCoinJoinAccept::FLAG_DEMOTION); + + BOOST_CHECK(!dsaPlain.IsRebalance()); + BOOST_CHECK(dsaPromotion.IsPromotion() && !dsaPromotion.IsDemotion() && dsaPromotion.IsRebalance()); + BOOST_CHECK(dsaDemotion.IsDemotion() && !dsaDemotion.IsPromotion() && dsaDemotion.IsRebalance()); + + // A participant mixes in exactly one direction + BOOST_CHECK(dsaPlain.HasValidFlags()); + BOOST_CHECK(dsaPromotion.HasValidFlags()); + BOOST_CHECK(dsaDemotion.HasValidFlags()); + const CCoinJoinAccept dsaBoth(1 << 2, txCollateral, + CCoinJoinAccept::FLAG_PROMOTION | CCoinJoinAccept::FLAG_DEMOTION); + BOOST_CHECK(!dsaBoth.HasValidFlags()); + + // Old-version stream: the flags are silently dropped and the encoding matches a + // flag-less dsa byte for byte + { + CDataStream ssPlain(SER_NETWORK, COINJOIN_REBALANCE_VERSION - 1); + ssPlain << dsaPlain; + CDataStream ssRebalance(SER_NETWORK, COINJOIN_REBALANCE_VERSION - 1); + ssRebalance << dsaPromotion; + BOOST_CHECK(std::equal(ssPlain.begin(), ssPlain.end(), ssRebalance.begin(), ssRebalance.end())); + + CCoinJoinAccept dsaDecoded; + ssRebalance >> dsaDecoded; + BOOST_CHECK_EQUAL(dsaDecoded.nDenom, dsaPromotion.nDenom); + BOOST_CHECK(!dsaDecoded.IsRebalance()); + } - // Verify promotion and demotion are NOT counted - BOOST_CHECK(!promotion.IsStandardMixingEntry()); - BOOST_CHECK(!demotion.IsStandardMixingEntry()); + // New-version stream: each direction round-trips distinctly + for (const auto& dsa : {dsaPromotion, dsaDemotion}) { + CDataStream ss(SER_NETWORK, COINJOIN_REBALANCE_VERSION); + ss << dsa; + CCoinJoinAccept dsaDecoded; + ss >> dsaDecoded; + BOOST_CHECK_EQUAL(dsaDecoded.nDenom, dsa.nDenom); + BOOST_CHECK_EQUAL(dsaDecoded.IsPromotion(), dsa.IsPromotion()); + BOOST_CHECK_EQUAL(dsaDecoded.IsDemotion(), dsa.IsDemotion()); + } + + // New-version encoding is exactly one byte longer than the legacy encoding + { + CDataStream ssOld(SER_NETWORK, COINJOIN_REBALANCE_VERSION - 1); + ssOld << dsaPlain; + CDataStream ssNew(SER_NETWORK, COINJOIN_REBALANCE_VERSION); + ssNew << dsaPlain; + BOOST_CHECK_EQUAL(ssNew.size(), ssOld.size() + 1); + } +} + +BOOST_AUTO_TEST_CASE(honest_session_always_covers_both_sides) +{ + using CoinJoin::MixShape; + using CoinJoin::MixSideCounts; + + // Whatever mix of rebalancers joins, a session holding the minimum number of 1:1 mixers + // covers both sides, so the invariant never rejects an ordinary session + const int nMin = CoinJoin::GetMinPoolParticipants(); + BOOST_CHECK(nMin >= 2); + + for (int nPromoters = 0; nPromoters <= CoinJoin::GetMaxPoolParticipants() - nMin; ++nPromoters) { + MixSideCounts counts; + for (int i = 0; i < nMin; ++i) counts.Add(MixShape::STANDARD); + for (int i = 0; i < nPromoters; ++i) counts.Add(MixShape::PROMOTION); + BOOST_CHECK(counts.IsCovered()); + } } BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/net_tests.cpp b/src/test/net_tests.cpp index 051b7087821d..84e727b5df5b 100644 --- a/src/test/net_tests.cpp +++ b/src/test/net_tests.cpp @@ -1896,9 +1896,9 @@ BOOST_AUTO_TEST_CASE(v2_short_id_version_negotiation) BOOST_REQUIRE(ret && ret->empty()); tester_old.ReceiveMessage("platformban", msg_data_old); // long encoding - // New peer (v70240) - knows about PLATFORMBAN short ID 168 + // New peer (v70240+) - knows about PLATFORMBAN short ID 168 V2TransportTester tester_new(true); - // Uses PROTOCOL_VERSION (70240) by default + // Uses PROTOCOL_VERSION by default ret = tester_new.Interact(); BOOST_REQUIRE(ret && ret->empty()); diff --git a/src/version.h b/src/version.h index 9d3c08077940..fdc04635d490 100644 --- a/src/version.h +++ b/src/version.h @@ -11,7 +11,7 @@ */ -static const int PROTOCOL_VERSION = 70240; +static const int PROTOCOL_VERSION = 70241; //! initial proto version, to be increased after version/verack negotiation static const int INIT_PROTO_VERSION = 209; @@ -67,6 +67,11 @@ static const int QFCOMMIT_STALE_REPROP_BAN_VERSION = 70239; //! PLATFORMBAN added to v2 short IDs (short ID 168) static const int PLATFORMBAN_V2_SHORT_ID_VERSION = 70240; +//! CoinJoin denomination promotion/demotion (rebalance) sessions introduced in this version; +//! dsa messages gained a version-gated flags field and only peers at or above this version +//! may join rebalance-capable mixing sessions +static const int COINJOIN_REBALANCE_VERSION = 70241; + // Make sure that none of the values above collide with `ADDRV2_FORMAT`. #endif // BITCOIN_VERSION_H diff --git a/test/functional/test_framework/p2p.py b/test/functional/test_framework/p2p.py index 2cb93b37c780..a76d956a0301 100755 --- a/test/functional/test_framework/p2p.py +++ b/test/functional/test_framework/p2p.py @@ -102,8 +102,9 @@ # The minimum P2P version that this test framework supports MIN_P2P_VERSION_SUPPORTED = 60001 # The P2P version that this test framework implements and sends in its `version` message -# Version 70240 introduced PLATFORMBAN to v2 short IDs -P2P_VERSION = 70240 +# Version 70241 introduced CoinJoin rebalance (promotion/demotion) sessions and a +# version-gated flags field in the dsa message +P2P_VERSION = 70241 # The services that this test framework offers in its `version` message P2P_SERVICES = NODE_NETWORK | NODE_HEADERS_COMPRESSED # The P2P user agent string that this test framework sends in its `version` message From 363d305b02de9f22bbdbdc49288e3be66dc17915 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 1 Aug 2026 17:24:23 -0500 Subject: [PATCH 05/26] fix(coinjoin): tolerate one-block tip skew only at the V24 boundary Version gating keeps pre-70241 software out of rebalance sessions, but a 70241 client whose tip is one block behind the masternode at the V24 activation boundary would still validate the final transaction under pre-V24 rules, refuse to sign it and be charged its collateral. Final-tx validation (and the client-side cover check) now also accepts V24 activating in the block following our tip, so a masternode one block ahead cannot cost an honest client its collateral. Per-entry validation on the masternode side keeps using the strict tip state. The DSTX relay path applies the same boundary rule in the other direction: a DSTX that is only structurally valid under post-V24 rules earns the tolerant misbehavior score (1, like an unverifiable masternode) only when V24 activates at the next block, since a relayer one block ahead is the only honest source of such a transaction. Previously the tolerance applied throughout the entire pre-V24 period, letting a peer relay post-V24-shaped DSTXes with arbitrary masternode identities and signatures at a tenth of the normal invalid-DSTX penalty. Co-Authored-By: Claude Fable 5 --- src/coinjoin/client.cpp | 6 ++-- src/coinjoin/coinjoin.cpp | 17 ++++++--- src/coinjoin/coinjoin.h | 7 ++-- src/net_processing.cpp | 11 +++--- test/functional/p2p_dstx.py | 70 ++++++++++++++++++++++++++++++------- 5 files changed, 85 insertions(+), 26 deletions(-) diff --git a/src/coinjoin/client.cpp b/src/coinjoin/client.cpp index 6c0f6680df5e..f03d11c8518b 100644 --- a/src/coinjoin/client.cpp +++ b/src/coinjoin/client.cpp @@ -467,8 +467,10 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ if (!mixingMasternode) return false; - // Evaluated before taking cs_wallet (IsPromotionDemotionActive locks cs_main) - const bool fV24Active = CoinJoin::IsPromotionDemotionActive(active_chainstate.m_chainman); + // Evaluated before taking cs_wallet (IsPromotionDemotionActive locks cs_main). + // fNextBlock keeps this consistent with the final-tx leniency in IsValidInOuts: a + // masternode one block ahead at the V24 boundary must not get its final tx refused. + const bool fV24Active = CoinJoin::IsPromotionDemotionActive(active_chainstate.m_chainman, /*fNextBlock=*/true); LOCK(m_wallet->cs_wallet); LOCK(cs_coinjoin); diff --git a/src/coinjoin/coinjoin.cpp b/src/coinjoin/coinjoin.cpp index 05bd44a801ac..70203581e99d 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -240,11 +240,17 @@ std::string CCoinJoinBaseSession::GetStateString() const } } -bool CoinJoin::IsPromotionDemotionActive(const ChainstateManager& chainman) +bool CoinJoin::IsPromotionDemotionActive(const ChainstateManager& chainman, bool fNextBlock) { LOCK(::cs_main); const CBlockIndex* pindex = chainman.ActiveChain().Tip(); - return pindex && DeploymentActiveAt(*pindex, chainman, Consensus::DEPLOYMENT_V24); + if (pindex == nullptr) return false; + // With fNextBlock the deployment counts as active if it will be active in the block + // following our tip. Clients use this when validating a final transaction so that a + // masternode whose tip is one block ahead at the V24 boundary doesn't get its valid + // unbalanced final tx refused - refusing to sign would cost the client its collateral. + return fNextBlock ? DeploymentActiveAfter(pindex, chainman, Consensus::DEPLOYMENT_V24) + : DeploymentActiveAt(*pindex, chainman, Consensus::DEPLOYMENT_V24); } bool CCoinJoinBaseSession::IsValidInOuts(Chainstate& active_chainstate, const llmq::CInstantSendManager& isman, @@ -257,8 +263,11 @@ bool CCoinJoinBaseSession::IsValidInOuts(Chainstate& active_chainstate, const ll nMessageIDRet = MSG_NOERR; if (fConsumeCollateralRet) *fConsumeCollateralRet = false; - // Check if V24 is active for promotion/demotion support - const bool fV24Active = CoinJoin::IsPromotionDemotionActive(active_chainstate.m_chainman); + // Check if V24 is active for promotion/demotion support. Per-entry validation (masternode + // side) uses the strict tip state; final-tx validation (client side, before signing) also + // accepts activation at the next block so a masternode one block ahead at the boundary + // can't cost us our collateral. + const bool fV24Active = CoinJoin::IsPromotionDemotionActive(active_chainstate.m_chainman, /*fNextBlock=*/fFinalTx); // Determine entry type based on input/output counts // Standard: N inputs, N outputs (same denom) diff --git a/src/coinjoin/coinjoin.h b/src/coinjoin/coinjoin.h index 45a2dc942ddd..6e0f95be495c 100644 --- a/src/coinjoin/coinjoin.h +++ b/src/coinjoin/coinjoin.h @@ -479,8 +479,11 @@ namespace CoinJoin constexpr CAmount GetMaxPoolAmount() { return COINJOIN_ENTRY_MAX_SIZE * vecStandardDenominations.front(); } - /// Whether denomination promotion/demotion (post-V24) is active at the current chain tip - bool IsPromotionDemotionActive(const ChainstateManager& chainman); + /// Whether denomination promotion/demotion (post-V24) is active at the current chain tip. + /// With fNextBlock, additionally counts a deployment that activates in the block following + /// the tip - clients validating a final transaction use this to tolerate a masternode + /// whose tip is one block ahead around the activation boundary. + bool IsPromotionDemotionActive(const ChainstateManager& chainman, bool fNextBlock = false); /// If the collateral is valid given by a client bool IsCollateralValid(ChainstateManager& chainman, const llmq::CInstantSendManager& isman, diff --git a/src/net_processing.cpp b/src/net_processing.cpp index c398e3582dab..44092e77002e 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -3545,11 +3545,12 @@ static DSTXValidationResult ValidateDSTX(CDeterministicMNManager& dmnman, CDSTXM if (!dstx.IsValidStructure(pindex, chainman, &fPossiblyValidPostV24)) { LogPrint(BCLog::COINJOIN, "DSTX -- Invalid DSTX structure: %s\n", hashTx.ToString()); // A tx that is structurally valid under post-V24 rules may come from a masternode - // whose tip is ahead of ours around the activation boundary. Like the 24-block-deep - // masternode scan below, tolerate the skew: drop it with only a small penalty so - // honest relayers are unaffected while a peer flooding us still gets discouraged - // eventually. Anything else malformed keeps the full penalty. - if (fPossiblyValidPostV24) { + // whose tip is one block ahead of ours at the activation boundary. Like the + // 24-block-deep masternode scan below, tolerate the skew: drop it with only a small + // penalty so honest relayers are unaffected while a peer flooding us still gets + // discouraged eventually. Away from the boundary no honest peer relays such a tx, + // so it keeps the full penalty like anything else malformed. + if (fPossiblyValidPostV24 && CoinJoin::IsPromotionDemotionActive(chainman, /*fNextBlock=*/true)) { return {DSTXValidationScore::PREMATURE, true}; } return {DSTXValidationScore::INVALID, true}; diff --git a/test/functional/p2p_dstx.py b/test/functional/p2p_dstx.py index fe9df990367b..b9192c7a3cc3 100755 --- a/test/functional/p2p_dstx.py +++ b/test/functional/p2p_dstx.py @@ -5,10 +5,11 @@ """Test P2P CoinJoin broadcast transaction handling. Verifies that DSTX messages with an unverifiable (unknown) masternode, or a -structure only valid once V24 activates, incur only a small misbehavior -penalty, while clearly malformed DSTXes get the existing stronger penalty. -Also exercises the cumulative behavior so that a peer flooding unknown-MN -DSTXes is eventually discouraged. +structure that only becomes valid at the imminent V24 activation boundary, +incur only a small misbehavior penalty, while clearly malformed DSTXes - +including post-V24-shaped ones far from the boundary - get the existing +stronger penalty. Also exercises the cumulative behavior so that a peer +flooding unknown-MN DSTXes is eventually discouraged. """ import time @@ -31,6 +32,7 @@ OP_HASH160, ) from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import softfork_active # Default DISCOURAGEMENT_THRESHOLD in net_processing.h. DISCOURAGEMENT_THRESHOLD = 100 @@ -39,16 +41,24 @@ UNKNOWN_MN_SCORE = 1 # Penalty applied when the DSTX itself is structurally bad / bad signature. INVALID_DSTX_SCORE = 10 -# Penalty applied when the DSTX is only structurally valid under post-V24 rules, which a -# relayer whose tip is ahead of ours at the activation boundary may legitimately send. +# Penalty applied when the DSTX is only structurally valid under post-V24 rules and V24 +# activates at the next block, which a relayer whose tip is one block ahead of ours may +# legitimately send. PREMATURE_DSTX_SCORE = 1 +V24_ACTIVATION_HEIGHT = 100 class P2PDSTXTest(BitcoinTestFramework): def set_test_params(self): self.num_nodes = 1 self.setup_clean_chain = True - self.extra_args = [["-debug=net", "-debug=coinjoin"]] + self.extra_args = [[ + "-debug=net", + "-debug=coinjoin", + # Window 10, threshold 8/6, no EHF signal required: v24 becomes active at the + # first window boundary at or above V24_ACTIVATION_HEIGHT once locked in. + f"-vbparams=v24:0:999999999999:{V24_ACTIVATION_HEIGHT}:10:8:6:5:0", + ]] def make_dstx(self, nonce=0): tx = CTransaction() @@ -101,13 +111,34 @@ def run_test(self): ]): peer_invalid.send_and_ping(msg_dstx(bad)) - self.log.info("Unbalanced DSTX pre-V24 => small (+%d) misbehavior penalty", PREMATURE_DSTX_SCORE) - # An unbalanced input/output count is only valid post-V24, so a masternode whose tip - # is ahead of ours at the activation boundary can relay one legitimately. It is still - # rejected here, but with the same tolerant penalty an unverifiable masternode gets. + self.log.info("Unbalanced DSTX far from the V24 boundary => full (+%d) penalty", INVALID_DSTX_SCORE) + # An unbalanced input/output count is only valid post-V24. Far from the activation + # boundary no honest relayer produces one, so it gets the full structural penalty. + peer_unbalanced = node.add_p2p_connection(P2PInterface()) + unbalanced = self.make_dstx(nonce=3) + unbalanced.tx.vout.pop() # vin.size() != vout.size() is a promotion shape + with node.assert_debug_log([ + "Invalid DSTX structure", + "Misbehaving", + "(0 -> {})".format(INVALID_DSTX_SCORE), + "invalid dstx", + ]): + peer_unbalanced.send_and_ping(msg_dstx(unbalanced)) + + self.log.info("Mine to the last pre-V24 block") + # Advance one block at a time so we stop on the exact boundary tip: v24 rules are + # enforced for the next block but not yet at our tip. + while not softfork_active(node, "v24"): + self.bump_mocktime(156) + self.generate(node, 1) + + self.log.info("Unbalanced DSTX at the V24 boundary => small (+%d) misbehavior penalty", PREMATURE_DSTX_SCORE) + # A masternode whose tip is one block ahead of ours already applies post-V24 rules, + # so its DSTX is rejected with the same tolerant penalty an unverifiable masternode + # gets rather than the full structural penalty. peer_premature = node.add_p2p_connection(P2PInterface()) - premature = self.make_dstx(nonce=3) - premature.tx.vout.pop() # vin.size() != vout.size() is a promotion shape + premature = self.make_dstx(nonce=4) + premature.tx.vout.pop() with node.assert_debug_log([ "Invalid DSTX structure", "Misbehaving", @@ -116,6 +147,19 @@ def run_test(self): ]): peer_premature.send_and_ping(msg_dstx(premature)) + self.log.info("Unbalanced DSTX once V24 is active => structurally valid, unknown-MN path") + self.generate(node, 1) # v24 rules now apply at the tip itself + peer_active = node.add_p2p_connection(P2PInterface()) + active = self.make_dstx(nonce=5) + active.tx.vout.pop() + with node.assert_debug_log([ + "Can't find masternode", + "Misbehaving", + "(0 -> {})".format(UNKNOWN_MN_SCORE), + "invalid dstx", + ]): + peer_active.send_and_ping(msg_dstx(active)) + self.log.info("A peer flooding unknown-MN DSTXes is eventually discouraged") peer_flood = node.add_p2p_connection(P2PInterface()) # +1 per unknown-MN DSTX, so DISCOURAGEMENT_THRESHOLD distinct DSTXes From efff25c2c64905a6c2224c3d150d7411256633ab Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 1 Aug 2026 17:40:03 -0500 Subject: [PATCH 06/26] fix(coinjoin): only announce unbalanced DSTXes to peers that support them Pre-70241 software treats a promotion/demotion DSTX as structurally invalid, drops it and penalizes the relaying peer by 10 - enough repeated rebalance mixes would get honest relayers (including the originating masternode) discouraged by old peers, and the zero-fee transaction would never enter old mempools anyway. Both inventory drain sites now skip announcing an unbalanced DSTX to peers below COINJOIN_REBALANCE_VERSION; such peers see the transaction on block inclusion. Balanced DSTXes keep relaying to everyone so standard mixes retain their zero-fee propagation. --- doc/release-notes-7052.md | 5 ++++- src/net_processing.cpp | 29 ++++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/doc/release-notes-7052.md b/doc/release-notes-7052.md index 9cd4e0dfad91..e9840c889202 100644 --- a/doc/release-notes-7052.md +++ b/doc/release-notes-7052.md @@ -9,7 +9,10 @@ P2P and network changes the session's capability. Older clients are rejected from such sessions at acceptance time (before any collateral is committed) and continue to mix normally in sessions created by older peers, which newer clients - still join for standard mixing. (#7052) + still join for standard mixing. Unbalanced (promotion/demotion) DSTXes + are likewise only announced to peers at protocol 70241 or newer; older + peers would reject them as structurally invalid and penalize the + relayer, and instead see the transaction on block inclusion. (#7052) - A mixing session only completes once each side of its denomination is occupied by nobody or by at least two participants, since coins are diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 44092e77002e..b338fef7ae9f 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -3514,10 +3514,19 @@ void PeerManagerImpl::ProcessGetCFCheckPt(CNode& node, Peer& peer, CDataStream& enum class DSTXValidationScore : int { NONE = 0, UNKNOWN_MASTERNODE = 1, - PREMATURE = 1, + PREMATURE = 1, // deliberately shares UNKNOWN_MASTERNODE's weight: both are tip-skew tolerances INVALID = 10, }; +//! Whether a DSTX may be announced to a peer at the given negotiated protocol version. +//! Post-V24 promotion/demotion DSTXes are unbalanced and thus structurally invalid to peers +//! below COINJOIN_REBALANCE_VERSION - they would drop the transaction and penalize us for +//! relaying it. Such peers see the transaction on block inclusion instead. +static bool CanAnnounceDstxTo(const CCoinJoinBroadcastTx& dstx, int peer_version) +{ + return dstx.tx->vin.size() == dstx.tx->vout.size() || peer_version >= COINJOIN_REBALANCE_VERSION; +} + // do_return signals the caller to stop further processing of the DSTX. struct DSTXValidationResult { DSTXValidationScore score; @@ -6476,7 +6485,14 @@ bool PeerManagerImpl::SendMessages(CNode* pto) tx_relay->m_tx_inventory_to_send.erase(hash); if (tx_relay->m_bloom_filter && !tx_relay->m_bloom_filter->IsRelevantAndUpdate(*txinfo.tx)) continue; - int nInvType = m_dstxman.GetDSTX(hash) ? MSG_DSTX : MSG_TX; + int nInvType = MSG_TX; + if (const auto dstx = m_dstxman.GetDSTX(hash); dstx) { + if (!CanAnnounceDstxTo(dstx, pto->GetCommonVersion())) { + tx_relay->m_tx_inventory_known_filter.insert(hash); + continue; + } + nInvType = MSG_DSTX; + } tx_relay->m_tx_inventory_known_filter.insert(hash); queueAndMaybePushInv(CInv(nInvType, hash)); @@ -6537,6 +6553,14 @@ bool PeerManagerImpl::SendMessages(CNode* pto) continue; } if (tx_relay->m_bloom_filter && !tx_relay->m_bloom_filter->IsRelevantAndUpdate(*txinfo.tx)) continue; + int nInvType = MSG_TX; + if (const auto dstx = m_dstxman.GetDSTX(hash); dstx) { + if (!CanAnnounceDstxTo(dstx, pto->GetCommonVersion())) { + tx_relay->m_tx_inventory_known_filter.insert(hash); + continue; + } + nInvType = MSG_DSTX; + } // Send State(pto->GetId())->m_recently_announced_invs.insert(hash); nRelayedTransactions++; @@ -6553,7 +6577,6 @@ bool PeerManagerImpl::SendMessages(CNode* pto) g_relay_expiration.emplace_back(current_time + RELAY_TX_CACHE_TIME, ret.first); } } - int nInvType = m_dstxman.GetDSTX(hash) ? MSG_DSTX : MSG_TX; tx_relay->m_tx_inventory_known_filter.insert(hash); queueAndMaybePushInv(CInv(nInvType, hash)); } From 9cae6f431a420cbf8e6a5bfd60d921cd4b7cf1d0 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 1 Aug 2026 18:49:53 -0500 Subject: [PATCH 07/26] feat(coinjoin): reset mixing rounds on promotion/demotion outputs A conversion's 10:1 shape publicly clusters one participant's coins even inside a mixing transaction: an observer of the final tx knows ten of the session-denomination inputs (or outputs) belong to a single wallet. Since promotion inputs are fully mixed and the promoted output previously inherited their rounds, that cluster exited mixing permanently linked and never dispersed. Instead, treat converted coins as unmixed: GetRealOutpointCoinJoinRounds returns 0 rounds for an output whose own inputs in the same tx are at a different denomination, so promoted and demoted coins re-enter mixing at their new denomination and disperse normally. The rule is inert pre-V24 (standard mixing always matches denominations and CreateDenominated already hits the reset paths) and needs no activation gating. In exchange, both directions now require fully-mixed inputs: the demotion selector's ready-to-mix fallback is removed and ShouldDemoteDenoms gains a fully-mixed gate mirroring promotion, so only coins with protected histories are worth converting. --- doc/release-notes-7052.md | 7 ++++ src/coinjoin/client.cpp | 12 +++---- src/coinjoin/common.h | 8 +++-- src/test/coinjoin_inouts_tests.cpp | 16 ++++++--- src/wallet/coinjoin.cpp | 15 ++++++++ src/wallet/test/coinjoin_tests.cpp | 58 ++++++++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 12 deletions(-) diff --git a/doc/release-notes-7052.md b/doc/release-notes-7052.md index e9840c889202..e416bba1f9e2 100644 --- a/doc/release-notes-7052.md +++ b/doc/release-notes-7052.md @@ -33,3 +33,10 @@ Wallet next larger denomination, while demotion splits 1 input into 10 outputs of the next smaller denomination. Pre-V24 behavior remains unchanged. (#7052) + +- Conversions only spend fully-mixed coins, and their outputs start + mixing over from zero rounds. The 10:1 shape of a conversion publicly + clusters one participant's coins even inside a mixing transaction, so + a converted coin is not treated as mixed: it re-enters mixing at its + new denomination and disperses normally, while the histories of the + fully-mixed coins that fed the conversion remain protected. (#7052) diff --git a/src/coinjoin/client.cpp b/src/coinjoin/client.cpp index f03d11c8518b..46cfdb50a8d9 100644 --- a/src/coinjoin/client.cpp +++ b/src/coinjoin/client.cpp @@ -1333,16 +1333,16 @@ bool CCoinJoinClientSession::SelectRebalanceInputs(int nTargetDenom, bool fPromo return false; } } else { - // Demotion: select 1 coin of the larger adjacent denomination. Prefer fully-mixed - // coins, falling back to ready-to-mix - unlike promotion inputs, demotion outputs - // keep mixing afterwards, so spending a not-yet-mixed input is acceptable. + // Demotion: select 1 fully-mixed coin of the larger adjacent denomination. Like + // promotion inputs, the coin's history must already be protected: the demotion's + // 1:10 shape publicly clusters its outputs, which therefore start mixing over + // (see GetRealOutpointCoinJoinRounds), and only a fully-mixed input is worth that. const int nLargerDenom = CoinJoin::GetLargerAdjacentDenom(nTargetDenom); if (nLargerDenom == 0) { WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- No larger adjacent denom for demotion\n", __func__); return false; } - if (!m_wallet->SelectTxDSInsByDenomination(nLargerDenom, CoinJoin::DenominationToAmount(nLargerDenom), vecTxDSInRet, CoinType::ONLY_FULLY_MIXED) && - !m_wallet->SelectTxDSInsByDenomination(nLargerDenom, CoinJoin::DenominationToAmount(nLargerDenom), vecTxDSInRet, CoinType::ONLY_READY_TO_MIX)) { + if (!m_wallet->SelectTxDSInsByDenomination(nLargerDenom, CoinJoin::DenominationToAmount(nLargerDenom), vecTxDSInRet, CoinType::ONLY_FULLY_MIXED)) { WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- Couldn't find coin for demotion\n", __func__); return false; } @@ -2519,6 +2519,6 @@ bool CCoinJoinClientManager::ShouldDemote(int nLargerDenom, int nSmallerDenom, c const int idxSmaller = CoinJoin::GetDenominationIndex(nSmallerDenom); return CoinJoin::ShouldDemoteDenoms(counts.total[idxLarger], counts.total[idxSmaller], - CCoinJoinClientOptions::GetDenomsGoal()); + counts.fully_mixed[idxLarger], CCoinJoinClientOptions::GetDenomsGoal()); } diff --git a/src/coinjoin/common.h b/src/coinjoin/common.h index 97a79f2c6a46..8e5852deeae8 100644 --- a/src/coinjoin/common.h +++ b/src/coinjoin/common.h @@ -225,12 +225,16 @@ constexpr bool ShouldPromoteDenoms(int nSmallerCount, int nLargerCount, int nSma /** * Core demotion decision (post-V24): split one coin of the larger denomination into * PROMOTION_RATIO coins of the smaller adjacent denomination when the smaller denomination - * is further from the per-denom goal by more than GAP_THRESHOLD. + * is further from the per-denom goal by more than GAP_THRESHOLD. Like promotion, demotion + * only spends a fully-mixed coin: the conversion's public 1:10 shape clusters its outputs, + * which start mixing over, so only an input with a protected history is worth converting. */ -constexpr bool ShouldDemoteDenoms(int nLargerCount, int nSmallerCount, int nGoal) +constexpr bool ShouldDemoteDenoms(int nLargerCount, int nSmallerCount, int nLargerFullyMixedCount, int nGoal) { // Don't sacrifice a denomination that's still being built up if (nLargerCount < nGoal / 2) return false; + // A demotion consumes one fully-mixed coin + if (nLargerFullyMixedCount < 1) return false; const int nSmallerDeficit = nSmallerCount < nGoal ? nGoal - nSmallerCount : 0; const int nLargerDeficit = nLargerCount < nGoal ? nGoal - nLargerCount : 0; return nSmallerDeficit > nLargerDeficit + GAP_THRESHOLD; diff --git a/src/test/coinjoin_inouts_tests.cpp b/src/test/coinjoin_inouts_tests.cpp index 56cc05b0a173..a075311c690f 100644 --- a/src/test/coinjoin_inouts_tests.cpp +++ b/src/test/coinjoin_inouts_tests.cpp @@ -1133,9 +1133,9 @@ BOOST_AUTO_TEST_CASE(isvalidstructure_boundary_input_counts) namespace { -// Adapters over the live decision functions; the promote adapter treats every -// coin as fully mixed so the count-based cases below isolate the deficit logic -// (the fully-mixed gate has its own test) +// Adapters over the live decision functions; both adapters treat every coin as +// fully mixed so the count-based cases below isolate the deficit logic +// (the fully-mixed gates have their own tests) bool TestShouldPromote(int smallerCount, int largerCount, int goal) { return CoinJoin::ShouldPromoteDenoms(smallerCount, largerCount, /*nSmallerFullyMixedCount=*/smallerCount, goal); @@ -1143,7 +1143,7 @@ bool TestShouldPromote(int smallerCount, int largerCount, int goal) bool TestShouldDemote(int largerCount, int smallerCount, int goal) { - return CoinJoin::ShouldDemoteDenoms(largerCount, smallerCount, goal); + return CoinJoin::ShouldDemoteDenoms(largerCount, smallerCount, /*nLargerFullyMixedCount=*/largerCount, goal); } } // anonymous namespace @@ -1157,6 +1157,14 @@ BOOST_AUTO_TEST_CASE(should_promote_requires_fully_mixed_coins) BOOST_CHECK(!CoinJoin::ShouldPromoteDenoms(100, 0, 0, 100)); } +BOOST_AUTO_TEST_CASE(should_demote_requires_fully_mixed_coin) +{ + // Deficit logic says demote (100 larger vs 0 smaller), but without a fully-mixed + // coin of the larger denomination the demotion must be blocked + BOOST_CHECK(CoinJoin::ShouldDemoteDenoms(100, 0, /*nLargerFullyMixedCount=*/1, 100)); + BOOST_CHECK(!CoinJoin::ShouldDemoteDenoms(100, 0, /*nLargerFullyMixedCount=*/0, 100)); +} + BOOST_AUTO_TEST_CASE(should_promote_larger_deficit_greater) { const int goal = 100; diff --git a/src/wallet/coinjoin.cpp b/src/wallet/coinjoin.cpp index bb09edf3d47f..9a8d15a89afb 100644 --- a/src/wallet/coinjoin.cpp +++ b/src/wallet/coinjoin.cpp @@ -331,9 +331,24 @@ int CWallet::GetRealOutpointCoinJoinRounds(const COutPoint& outpoint, int nRound int nShortest = -10; // an initial value, should be no way to get this by calculations bool fDenomFound = false; + const int nOutputDenom = CoinJoin::AmountToDenomination(txOutRef->nValue); // only denoms here so let's look up for (const auto& txinNext : wtx->tx->vin) { if (InputIsMine(*this, txinNext)) { + // Post-V24: if our own inputs to this mixing tx are at a different denomination, + // this output was created by a promotion/demotion. The conversion's 10:1 shape + // publicly clusters the participant's coins, so the new coin starts mixing over + // instead of inheriting its inputs' rounds. Deciding on the first mismatch is + // safe because a wallet contributes at most one entry per mixing transaction + // (concurrent sessions never share a masternode), so all of its inputs in one + // tx share a single denomination. + const CWalletTx* wtxPrev{GetWalletTx(txinNext.prevout.hash)}; + if (wtxPrev != nullptr && txinNext.prevout.n < wtxPrev->tx->vout.size() && + CoinJoin::AmountToDenomination(wtxPrev->tx->vout[txinNext.prevout.n].nValue) != nOutputDenom) { + *nRoundsRef = 0; + WalletCJLogPrint(this, "%s UPDATED %-70s %3d (rebalance output)\n", __func__, outpoint.ToStringShort(), *nRoundsRef); + return *nRoundsRef; + } int n = GetRealOutpointCoinJoinRounds(txinNext.prevout, nRounds + 1); // denom found, find the shortest chain or initially assign nShortest with the first found value if (n >= 0 && (n < nShortest || nShortest == -10)) { diff --git a/src/wallet/test/coinjoin_tests.cpp b/src/wallet/test/coinjoin_tests.cpp index 69d39b262016..582593382a1c 100644 --- a/src/wallet/test/coinjoin_tests.cpp +++ b/src/wallet/test/coinjoin_tests.cpp @@ -494,6 +494,64 @@ BOOST_FIXTURE_TEST_CASE(coinjoin_newkeypool_stops_mixing_tests, CTransactionBuil })); } +BOOST_FIXTURE_TEST_CASE(coinjoin_rebalance_rounds_reset_tests, CTransactionBuilderTestSetup) +{ + // 0.100001 DASH and its larger adjacent denomination 1.00001 DASH; the standard + // denominations are constructed so PROMOTION_RATIO smaller coins equal one larger coin + constexpr CAmount nSmallerAmount{10000100}; + constexpr CAmount nLargerAmount{nSmallerAmount * CoinJoin::PROMOTION_RATIO}; + BOOST_REQUIRE(CoinJoin::IsDenominatedAmount(nSmallerAmount)); + BOOST_REQUIRE(CoinJoin::IsDenominatedAmount(nLargerAmount)); + + // PROMOTION_RATIO coins for the promotion plus one to demonstrate standard mixing + CompactTallyItem tallyItem = GetTallyItem(std::vector(CoinJoin::PROMOTION_RATIO + 1, nSmallerAmount)); + const CScript scriptOurs = GetScriptForRawPubKey(coinbaseKey.GetPubKey()); + + // The funding transactions pay fees, so their denominated outputs start at 0 rounds + BOOST_CHECK_EQUAL(wallet->GetRealOutpointCoinJoinRounds(tallyItem.outpoints[0]), 0); + + // Standard mixing shape (1 in -> 1 out, same denomination): rounds advance by one + CMutableTransaction mtxMix; + mtxMix.vin.emplace_back(tallyItem.outpoints[0]); + mtxMix.vout.emplace_back(nSmallerAmount, scriptOurs); + BOOST_REQUIRE(wallet->AddToWallet(MakeTransactionRef(mtxMix), TxStateInMempool{})); + const COutPoint outpointMixed{mtxMix.GetHash(), 0}; + BOOST_CHECK_EQUAL(wallet->GetRealOutpointCoinJoinRounds(outpointMixed), 1); + + // Promotion shape (PROMOTION_RATIO inputs -> 1 output at the larger adjacent + // denomination): the conversion's public 10:1 shape clusters the participant's coins, + // so the promoted output starts mixing over instead of inheriting its inputs' rounds + CMutableTransaction mtxPromo; + for (int i = 1; i <= CoinJoin::PROMOTION_RATIO; ++i) { + mtxPromo.vin.emplace_back(tallyItem.outpoints[i]); + } + mtxPromo.vout.emplace_back(nLargerAmount, scriptOurs); + BOOST_REQUIRE(wallet->AddToWallet(MakeTransactionRef(mtxPromo), TxStateInMempool{})); + const COutPoint outpointPromoted{mtxPromo.GetHash(), 0}; + BOOST_CHECK_EQUAL(wallet->GetRealOutpointCoinJoinRounds(outpointPromoted), 0); + + // A promoted coin re-enters mixing normally: one standard round at the new + // denomination advances it to 1 + CMutableTransaction mtxRemix; + mtxRemix.vin.emplace_back(outpointPromoted); + mtxRemix.vout.emplace_back(nLargerAmount, scriptOurs); + BOOST_REQUIRE(wallet->AddToWallet(MakeTransactionRef(mtxRemix), TxStateInMempool{})); + const COutPoint outpointRemixed{mtxRemix.GetHash(), 0}; + BOOST_CHECK_EQUAL(wallet->GetRealOutpointCoinJoinRounds(outpointRemixed), 1); + + // Demotion shape (1 input -> PROMOTION_RATIO outputs at the smaller adjacent + // denomination): the mirror image, every demoted output starts mixing over + CMutableTransaction mtxDemo; + mtxDemo.vin.emplace_back(outpointRemixed); + for (int i = 0; i < CoinJoin::PROMOTION_RATIO; ++i) { + mtxDemo.vout.emplace_back(nSmallerAmount, scriptOurs); + } + BOOST_REQUIRE(wallet->AddToWallet(MakeTransactionRef(mtxDemo), TxStateInMempool{})); + for (uint32_t n = 0; n < uint32_t(CoinJoin::PROMOTION_RATIO); ++n) { + BOOST_CHECK_EQUAL(wallet->GetRealOutpointCoinJoinRounds(COutPoint{mtxDemo.GetHash(), n}), 0); + } +} + BOOST_FIXTURE_TEST_CASE(CTransactionBuilderTest, CTransactionBuilderTestSetup) { // NOTE: Mock wallet version is FEATURE_BASE which means that it uses uncompressed pubkeys From 9e83d6a3e59a1e685f986c46fd18d4753f9a61af Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 17 Aug 2026 16:47:22 -0500 Subject: [PATCH 08/26] fix(coinjoin): don't announce oversized balanced DSTXes to legacy peers --- src/net_processing.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index b338fef7ae9f..840edfd345aa 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -3521,10 +3521,14 @@ enum class DSTXValidationScore : int { //! Whether a DSTX may be announced to a peer at the given negotiated protocol version. //! Post-V24 promotion/demotion DSTXes are unbalanced and thus structurally invalid to peers //! below COINJOIN_REBALANCE_VERSION - they would drop the transaction and penalize us for -//! relaying it. Such peers see the transaction on block inclusion instead. +//! relaying it. Pre-COINJOIN_REBALANCE_VERSION peers also enforce a smaller structural size +//! cap (GetMaxPoolInputOutputCount), so a balanced-but-oversized final transaction is likewise +//! invalid to them. Such peers see the transaction on block inclusion instead. static bool CanAnnounceDstxTo(const CCoinJoinBroadcastTx& dstx, int peer_version) { - return dstx.tx->vin.size() == dstx.tx->vout.size() || peer_version >= COINJOIN_REBALANCE_VERSION; + return (dstx.tx->vin.size() == dstx.tx->vout.size() && + dstx.tx->vin.size() <= CoinJoin::GetMaxPoolInputOutputCount()) || + peer_version >= COINJOIN_REBALANCE_VERSION; } // do_return signals the caller to stop further processing of the DSTX. From 88d4e6bc65ee5ddc1ed203c89d7334bcf62cb98a Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 17 Aug 2026 16:47:32 -0500 Subject: [PATCH 09/26] fix(coinjoin): guard against null prevtx in GetRealOutpointCoinJoinRounds --- src/wallet/coinjoin.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wallet/coinjoin.cpp b/src/wallet/coinjoin.cpp index 9a8d15a89afb..0a2b6705d49b 100644 --- a/src/wallet/coinjoin.cpp +++ b/src/wallet/coinjoin.cpp @@ -343,7 +343,7 @@ int CWallet::GetRealOutpointCoinJoinRounds(const COutPoint& outpoint, int nRound // (concurrent sessions never share a masternode), so all of its inputs in one // tx share a single denomination. const CWalletTx* wtxPrev{GetWalletTx(txinNext.prevout.hash)}; - if (wtxPrev != nullptr && txinNext.prevout.n < wtxPrev->tx->vout.size() && + if (wtxPrev != nullptr && wtxPrev->tx != nullptr && txinNext.prevout.n < wtxPrev->tx->vout.size() && CoinJoin::AmountToDenomination(wtxPrev->tx->vout[txinNext.prevout.n].nValue) != nOutputDenom) { *nRoundsRef = 0; WalletCJLogPrint(this, "%s UPDATED %-70s %3d (rebalance output)\n", __func__, outpoint.ToStringShort(), *nRoundsRef); From 972733700a356d2b86717e5fcbadb5b48fb16d71 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 17 Aug 2026 16:48:00 -0500 Subject: [PATCH 10/26] fix(coinjoin): keep relaying islocks when withholding a DSTX from legacy peers --- src/net_processing.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 840edfd345aa..08e3e1ead767 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -6490,15 +6490,21 @@ bool PeerManagerImpl::SendMessages(CNode* pto) if (tx_relay->m_bloom_filter && !tx_relay->m_bloom_filter->IsRelevantAndUpdate(*txinfo.tx)) continue; int nInvType = MSG_TX; + // A DSTX this peer can't accept is withheld, but its islock inv is still + // relayed below - on develop that islock inv was always delivered here, so + // gating the DSTX must not also suppress it. + bool fWithholdTx{false}; if (const auto dstx = m_dstxman.GetDSTX(hash); dstx) { if (!CanAnnounceDstxTo(dstx, pto->GetCommonVersion())) { - tx_relay->m_tx_inventory_known_filter.insert(hash); - continue; + fWithholdTx = true; + } else { + nInvType = MSG_DSTX; } - nInvType = MSG_DSTX; } tx_relay->m_tx_inventory_known_filter.insert(hash); - queueAndMaybePushInv(CInv(nInvType, hash)); + if (!fWithholdTx) { + queueAndMaybePushInv(CInv(nInvType, hash)); + } const auto islock = m_llmq_ctx->isman->GetInstantSendLockByTxid(hash); if (islock == nullptr) continue; From 50c62c99d234fa512fc06480edbe18f6a0ccde37 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 17 Aug 2026 16:48:10 -0500 Subject: [PATCH 11/26] fix(coinjoin): annotate m_fRebalanceSession as GUARDED_BY(cs_coinjoin) --- src/coinjoin/server.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coinjoin/server.h b/src/coinjoin/server.h index bf4eebe82043..89fee9e9e3f6 100644 --- a/src/coinjoin/server.h +++ b/src/coinjoin/server.h @@ -59,7 +59,7 @@ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler // session creation from the creator's protocol version; only peers at or above // COINJOIN_REBALANCE_VERSION are allowed into such a session, so clients that cannot // validate an unbalanced final transaction never end up having to refuse to sign one. - bool m_fRebalanceSession{false}; + bool m_fRebalanceSession GUARDED_BY(cs_coinjoin){false}; // The mixing direction each accepted participant declared in its dsa, keyed by collateral // hash. Tells us which side of the session denomination a participant will occupy before // its entry arrives, and entitles it (and only it) to submit an entry of that shape. From f7d2bbbc7e0f5cf19887c5b6579923c409bf75ba Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 18 Aug 2026 10:14:24 -0500 Subject: [PATCH 12/26] fix(coinjoin): bind entry admission and charging to the validated session AddEntry releases cs_coinjoin around the collateral and UTXO checks, so the scheduler thread can time the session out, build the final transaction and move to POOL_STATE_SIGNING while an entry is still being validated. The pool-state precondition and the IsCurrentSession() re-check that used to close that window were dropped, leaving only a collateral-membership and duplicate-entry test, neither of which notices the transition: vecSessionCollaterals is not pruned on finalization and the straggler has no entry yet. The entry is then appended to vecEntries after finalMutableTransaction was built without it, so its inputs can never be signed, the session stalls until the signing timeout, and ChargeFees consumes the collateral of a participant that was told STATUS_ACCEPTED. Restore the state gate on entry and re-check the session identity before admitting. Route every collateral-consumption path through ConsumeCollateralIfCurrentSession(), which re-verifies under the lock that the session is still live and still holds that collateral, so a session reset racing an in-flight entry no longer spends an uninvolved participant's collateral. Also derive the entry size cap and the declared-shape check from the session's own m_fRebalanceSession instead of re-reading the tip, and pass that capability into IsValidInOuts rather than having it recompute activation. A reorg across the V24 boundary could otherwise shrink the cap under an already-admitted participant and charge it for an entry that was legal when accepted. This drops a redundant cs_main acquisition per entry as well. --- src/coinjoin/client.cpp | 2 +- src/coinjoin/coinjoin.cpp | 10 +---- src/coinjoin/coinjoin.h | 10 ++++- src/coinjoin/server.cpp | 69 ++++++++++++++++++++++++------ src/coinjoin/server.h | 6 +++ src/test/coinjoin_inouts_tests.cpp | 8 ++-- 6 files changed, 78 insertions(+), 27 deletions(-) diff --git a/src/coinjoin/client.cpp b/src/coinjoin/client.cpp index 46cfdb50a8d9..c7d78351d148 100644 --- a/src/coinjoin/client.cpp +++ b/src/coinjoin/client.cpp @@ -496,7 +496,7 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ PoolMessage nMessageID{MSG_NOERR}; CoinJoin::SessionDenomCounts denomCounts; if (!IsValidInOuts(active_chainstate, m_isman, mempool, finalMutableTransaction.vin, finalMutableTransaction.vout, - nSessionDenom, nMessageID, nullptr, /*fFinalTx=*/true, &denomCounts)) { + nSessionDenom, fV24Active, nMessageID, nullptr, /*fFinalTx=*/true, &denomCounts)) { WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- ERROR! IsValidInOuts() failed: %s\n", __func__, CoinJoin::GetMessageByID(nMessageID).translated); UnlockCoins(); keyHolderStorage.ReturnAll(); diff --git a/src/coinjoin/coinjoin.cpp b/src/coinjoin/coinjoin.cpp index 70203581e99d..1a20a9a7c95a 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -255,20 +255,14 @@ bool CoinJoin::IsPromotionDemotionActive(const ChainstateManager& chainman, bool bool CCoinJoinBaseSession::IsValidInOuts(Chainstate& active_chainstate, const llmq::CInstantSendManager& isman, const CTxMemPool& mempool, const std::vector& vin, - const std::vector& vout, int session_denom, PoolMessage& nMessageIDRet, - bool* fConsumeCollateralRet, bool fFinalTx, + const std::vector& vout, int session_denom, bool fV24Active, + PoolMessage& nMessageIDRet, bool* fConsumeCollateralRet, bool fFinalTx, CoinJoin::SessionDenomCounts* pDenomCountsRet) { std::set setScripPubKeys; nMessageIDRet = MSG_NOERR; if (fConsumeCollateralRet) *fConsumeCollateralRet = false; - // Check if V24 is active for promotion/demotion support. Per-entry validation (masternode - // side) uses the strict tip state; final-tx validation (client side, before signing) also - // accepts activation at the next block so a masternode one block ahead at the boundary - // can't cost us our collateral. - const bool fV24Active = CoinJoin::IsPromotionDemotionActive(active_chainstate.m_chainman, /*fNextBlock=*/fFinalTx); - // Determine entry type based on input/output counts // Standard: N inputs, N outputs (same denom) // Promotion: PROMOTION_RATIO inputs of session denom, 1 output of larger adjacent denom diff --git a/src/coinjoin/coinjoin.h b/src/coinjoin/coinjoin.h index 6e0f95be495c..56ad652557d4 100644 --- a/src/coinjoin/coinjoin.h +++ b/src/coinjoin/coinjoin.h @@ -386,11 +386,17 @@ class CCoinJoinBaseSession * promotion (10:1) and demotion (1:10) entries, so per-entry shape rules don't apply to it; * aggregate rules (allowed denominations, value balance, input/output consistency) are * checked instead. session_denom is the caller's snapshot of the session denomination. + * + * fV24Active tells us whether promotion/demotion shapes are permitted here. It is passed in + * rather than derived from the tip so that it stays fixed for the lifetime of a session: the + * masternode passes the session's own capability and the client the boundary-tolerant tip + * check, and neither can be flipped mid-session by a reorg across the V24 boundary. */ static bool IsValidInOuts(Chainstate& active_chainstate, const llmq::CInstantSendManager& isman, const CTxMemPool& mempool, const std::vector& vin, const std::vector& vout, - int session_denom, PoolMessage& nMessageIDRet, bool* fConsumeCollateralRet, - bool fFinalTx = false, CoinJoin::SessionDenomCounts* pDenomCountsRet = nullptr); + int session_denom, bool fV24Active, PoolMessage& nMessageIDRet, + bool* fConsumeCollateralRet, bool fFinalTx = false, + CoinJoin::SessionDenomCounts* pDenomCountsRet = nullptr); public: // Atomic because the message-handling and scheduler threads write it while those threads and diff --git a/src/coinjoin/server.cpp b/src/coinjoin/server.cpp index 8fa1cb223320..f593faeb0e40 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -537,6 +537,31 @@ void CCoinJoinServer::ConsumeCollateral(const CTransactionRef& txref) const } } +void CCoinJoinServer::ConsumeCollateralIfCurrentSession(int session_id, const CTransactionRef& txref) const +{ + AssertLockNotHeld(cs_coinjoin); + + // cs_coinjoin is released around the collateral and UTXO checks in AddEntry, so by the time + // we get here the scheduler thread may have timed the session out and started another one. + // Charging then would spend the collateral of a participant that is no longer in any + // session - it may even be a replay of an innocent participant's collateral. + const bool fStillCurrent = WITH_LOCK(cs_coinjoin, return IsCurrentSession(session_id) && + std::ranges::any_of(vecSessionCollaterals, + [&txref](const CTransactionRef& ref) { return *ref == *txref; })); + if (!fStillCurrent) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- session changed, not consuming collateral %s\n", __func__, + txref->GetHash().ToString()); + return; + } + ConsumeCollateral(txref); +} + +bool CCoinJoinServer::IsCurrentSession(int session_id) const +{ + AssertLockHeld(cs_coinjoin); + return nSessionID != 0 && nSessionID == session_id && nState == POOL_STATE_ACCEPTING_ENTRIES; +} + bool CCoinJoinServer::HasTimedOut() const { if (nState == POOL_STATE_IDLE) return false; @@ -649,15 +674,29 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag CoinJoin::MixShape declaredShape{CoinJoin::MixShape::STANDARD}; int session_denom{0}; + int session_id{0}; + bool fRebalanceSession{false}; { LOCK(cs_coinjoin); + // Entries belong to a session that is actually collecting them. IsSessionReady() was + // checked by the caller before the entry was deserialized, so the state may already + // have moved on by now. + if (nSessionID == 0 || nState != POOL_STATE_ACCEPTING_ENTRIES) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: not accepting entries, nState=%d\n", __func__, + nState.load()); + nMessageIDRet = ERR_SESSION; + return false; + } + if (size_t(GetEntriesCountLocked()) >= vecSessionCollaterals.size()) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: entries is full!\n", __func__); nMessageIDRet = ERR_ENTRIES_FULL; return false; } session_denom = nSessionDenom; + session_id = nSessionID; + fRebalanceSession = m_fRebalanceSession; // Entries are keyed one-to-one to the collaterals accepted at dsa time: a collateral // that never went through dsa acceptance cannot submit an entry and an accepted @@ -684,17 +723,20 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag } } - const bool fV24Active = CoinJoin::IsPromotionDemotionActive(m_chainman); - + // Entry shape rules follow the session, not the current tip: m_fRebalanceSession was fixed + // when the session was created and the participants were admitted under it. Re-deriving them + // from the tip would let a reorg across the V24 boundary retroactively shrink the cap and + // charge an admitted participant for an entry that was legal when it was accepted. + // // Post-V24: promotion entries carry PROMOTION_RATIO (10) inputs and demotion entries // PROMOTION_RATIO outputs; pre-V24 entries are capped at COINJOIN_ENTRY_MAX_SIZE (9) - const size_t nMaxEntrySize = fV24Active ? size_t(CoinJoin::PROMOTION_RATIO) : COINJOIN_ENTRY_MAX_SIZE; + const size_t nMaxEntrySize = fRebalanceSession ? size_t(CoinJoin::PROMOTION_RATIO) : COINJOIN_ENTRY_MAX_SIZE; if (entry.vecTxDSIn.size() > nMaxEntrySize || entry.vecTxOut.size() > nMaxEntrySize) { LogPrint(BCLog::COINJOIN, /* Continued */ "CCoinJoinServer::%s -- ERROR: too many inputs or outputs! inputs=%s/%s, outputs=%s/%s\n", __func__, entry.vecTxDSIn.size(), nMaxEntrySize, entry.vecTxOut.size(), nMaxEntrySize); nMessageIDRet = ERR_MAXIMUM; - ConsumeCollateral(entry.txCollateral); + ConsumeCollateralIfCurrentSession(session_id, entry.txCollateral); return false; } @@ -712,13 +754,13 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: entry occupies no side of the session denom! inputs=%s, outputs=%s\n", __func__, entry.vecTxDSIn.size(), entry.vecTxOut.size()); nMessageIDRet = ERR_SIZE_MISMATCH; - ConsumeCollateral(entry.txCollateral); + ConsumeCollateralIfCurrentSession(session_id, entry.txCollateral); return false; } // Pre-V24, unbalanced entries deliberately fall through to IsValidInOuts so their // collateral is consumed (anti-spam), same as before this feature existed - if (fV24Active) { + if (fRebalanceSession) { // Every entry must match the direction its participant declared in the dsa - standard // entries included. Admission counted on the declared shapes to decide the session // covers both sides of the session denomination, so a participant deviating (e.g. @@ -729,7 +771,7 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: entry shape doesn't match the declared direction! inputs=%s, outputs=%s\n", __func__, entry.vecTxDSIn.size(), entry.vecTxOut.size()); nMessageIDRet = ERR_SIZE_MISMATCH; - ConsumeCollateral(entry.txCollateral); + ConsumeCollateralIfCurrentSession(session_id, entry.txCollateral); return false; } } @@ -754,10 +796,10 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag bool fConsumeCollateral{false}; if (!IsValidInOuts(m_chainman.ActiveChainstate(), m_isman, mempool, vin, entry.vecTxOut, session_denom, - nMessageIDRet, &fConsumeCollateral)) { + fRebalanceSession, nMessageIDRet, &fConsumeCollateral)) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR! IsValidInOuts() failed: %s\n", __func__, CoinJoin::GetMessageByID(nMessageIDRet).translated); if (fConsumeCollateral) { - ConsumeCollateral(entry.txCollateral); + ConsumeCollateralIfCurrentSession(session_id, entry.txCollateral); } return false; } @@ -765,9 +807,12 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag { LOCK(cs_coinjoin); // cs_coinjoin was released around the UTXO checks above, so the scheduler thread may - // have reset the session in between; re-verify so admission stays atomic with the - // membership and duplicate-use checks. - if (std::ranges::none_of(vecSessionCollaterals, isSessionCollateral) || + // have finalized or reset the session in between; re-verify so admission stays atomic + // with the state, membership and duplicate-use checks. Admitting into a session that + // already built its final transaction would add an entry no one can sign, stalling it + // until the signing timeout charges the honest participants. + if (!IsCurrentSession(session_id) || + std::ranges::none_of(vecSessionCollaterals, isSessionCollateral) || std::ranges::any_of(vecEntries, hasEntryForCollateral)) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: session changed while validating the entry!\n", __func__); nMessageIDRet = ERR_SESSION; diff --git a/src/coinjoin/server.h b/src/coinjoin/server.h index 89fee9e9e3f6..3aabc3378247 100644 --- a/src/coinjoin/server.h +++ b/src/coinjoin/server.h @@ -85,6 +85,12 @@ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler void ChargeRandomFees() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); /// Consume collateral in cases when peer misbehaved void ConsumeCollateral(const CTransactionRef& txref) const; + /// Consume collateral, but only while session_id is still the live session holding it + void ConsumeCollateralIfCurrentSession(int session_id, const CTransactionRef& txref) const + EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); + + /// Is session_id still the session we are accepting entries for? + bool IsCurrentSession(int session_id) const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin); /// Check for process void CheckPool(); diff --git a/src/test/coinjoin_inouts_tests.cpp b/src/test/coinjoin_inouts_tests.cpp index a075311c690f..133a14a7854f 100644 --- a/src/test/coinjoin_inouts_tests.cpp +++ b/src/test/coinjoin_inouts_tests.cpp @@ -327,8 +327,8 @@ BOOST_AUTO_TEST_CASE(validation_uses_session_denom_snapshot) // Outputs matching the captured denomination pass the denom check and fail // only later on the unknown input. - BOOST_CHECK(!InOutsChecker::IsValidInOuts(chainstate, isman, mempool, vin, vout, session_denom, message, - &consume_collateral)); + BOOST_CHECK(!InOutsChecker::IsValidInOuts(chainstate, isman, mempool, vin, vout, session_denom, + /*fV24Active=*/false, message, &consume_collateral)); BOOST_CHECK_EQUAL(message, ERR_MISSING_TX); BOOST_CHECK(!consume_collateral); @@ -336,8 +336,8 @@ BOOST_AUTO_TEST_CASE(validation_uses_session_denom_snapshot) // entry's collateral for consumption. const int other_denom{CoinJoin::AmountToDenomination(CoinJoin::GetStandardDenominations().front())}; BOOST_REQUIRE(other_denom != session_denom); - BOOST_CHECK(!InOutsChecker::IsValidInOuts(chainstate, isman, mempool, vin, vout, other_denom, message, - &consume_collateral)); + BOOST_CHECK(!InOutsChecker::IsValidInOuts(chainstate, isman, mempool, vin, vout, other_denom, + /*fV24Active=*/false, message, &consume_collateral)); BOOST_CHECK_EQUAL(message, ERR_DENOM); BOOST_CHECK(consume_collateral); } From 1ff74e99b0aa4dd1594eec062251c4838730023e Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 18 Aug 2026 10:17:17 -0500 Subject: [PATCH 13/26] fix(coinjoin): latch rebalance capability on admission, not on creator version m_fRebalanceSession was derived from the session creator's protocol version alone, without consulting whether its dsa actually declared a promotion or demotion. Post-V24, once most wallets are upgraded, nearly every session is opened by a rebalance-capable peer, so nearly every session was flagged rebalance-capable even when every participant was doing ordinary 1:1 mixing. AddUserToExistingSession then answers ERR_VERSION to any peer below COINJOIN_REBALANCE_VERSION, and since CCoinJoinQueue carries no rebalance marker such a peer cannot pre-filter queues: it connects, sends dsa, is rejected, and waits out a ~10s cooldown per attempt. Legacy mixing liquidity collapses to sessions that happen to be opened by other legacy clients. Latch the capability on the first participant that forces the question instead. A session becomes a rebalance session when a rebalance dsa is admitted, and acquires a legacy participant when a pre-rebalance peer is admitted; each side is refused only once the other is actually present. The invariant that matters -- never pair an unbalanced final transaction with a client that cannot validate one -- is preserved, while a 1:1 mixer of either vintage can join any session that has not committed the other way. Also move the HasValidFlags() check above the gating that reads dsa.IsRebalance(), so malformed flags cannot influence what the session latches. --- src/coinjoin/server.cpp | 70 ++++++++++++++++++++++++++--------------- src/coinjoin/server.h | 12 ++++--- 2 files changed, 53 insertions(+), 29 deletions(-) diff --git a/src/coinjoin/server.cpp b/src/coinjoin/server.cpp index f593faeb0e40..b69235965efc 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -289,6 +289,7 @@ void CCoinJoinServer::SetNull() vecSessionCollaterals.clear(); setSessionCollateralPrevouts.clear(); m_fRebalanceSession = false; + m_fHasLegacyParticipant = false; m_mapDeclaredShapes.clear(); CCoinJoinBaseSession::SetNull(); @@ -944,12 +945,12 @@ bool CCoinJoinServer::CreateNewSession(const CCoinJoinAccept& dsa, int nPeerVers return false; } - // Post-V24: the creator's protocol version fixes the session version. Only sessions - // created by rebalance-capable peers may ever contain promotion/demotion entries, so - // clients that cannot validate an unbalanced final transaction are never mixed into one. - const bool fRebalanceSession = nPeerVersion >= COINJOIN_REBALANCE_VERSION && - CoinJoin::IsPromotionDemotionActive(m_chainman); - if (dsa.IsRebalance() && !fRebalanceSession) { + // Post-V24: a promotion/demotion may only be declared once V24 is active and by a peer that + // speaks the rebalance protocol. The session takes its capability from what this dsa asks + // for, not from the creator's version alone - a rebalance-capable peer doing ordinary 1:1 + // mixing must not fence older clients out of the session it happens to open. + if (dsa.IsRebalance() && + (nPeerVersion < COINJOIN_REBALANCE_VERSION || !CoinJoin::IsPromotionDemotionActive(m_chainman))) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CreateNewSession -- rejecting rebalance dsa, promotion/demotion not active\n"); nMessageIDRet = ERR_VERSION; return false; @@ -970,7 +971,8 @@ bool CCoinJoinServer::CreateNewSession(const CCoinJoinAccept& dsa, int nPeerVers nMessageIDRet = MSG_NOERR; nSessionID = GetRand(/*nMax=*/999999) + 1; nSessionDenom = dsa.nDenom; - m_fRebalanceSession = fRebalanceSession; + m_fRebalanceSession = dsa.IsRebalance(); + m_fHasLegacyParticipant = nPeerVersion < COINJOIN_REBALANCE_VERSION; m_mapDeclaredShapes.emplace(dsa.txCollateral.GetHash(), DeclaredShape(dsa)); SetState(POOL_STATE_QUEUE); @@ -1024,6 +1026,11 @@ bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, int n return false; } + // Evaluated before taking cs_coinjoin: IsPromotionDemotionActive locks cs_main, and cs_main + // is never taken under cs_coinjoin elsewhere in this class. + const bool fRebalanceAcceptable = dsa.IsRebalance() && nPeerVersion >= COINJOIN_REBALANCE_VERSION && + CoinJoin::IsPromotionDemotionActive(m_chainman); + LOCK(cs_coinjoin); if (nSessionID != session_id || nSessionDenom != session_denom || nState != POOL_STATE_QUEUE) { @@ -1035,12 +1042,33 @@ bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, int n return false; } - // Post-V24: never mix clients below COINJOIN_REBALANCE_VERSION into a session that may - // contain promotion/demotion entries - they cannot validate the unbalanced final - // transaction, would refuse to sign it and risk being charged their collateral. - // ERR_VERSION is within the message range old clients understand, so they reset - // immediately and move on to another queue. - if (m_fRebalanceSession && nPeerVersion < COINJOIN_REBALANCE_VERSION) { + // Checked before the rebalance gating below, which reads dsa.IsRebalance() + if (!dsa.HasValidFlags()) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- malformed dsa flags\n"); + nMessageIDRet = ERR_VERSION; + return false; + } + + // Post-V24: a promotion/demotion entry and a client that cannot validate the resulting + // unbalanced final transaction must never end up in the same session - the old client would + // refuse to sign and risk being charged its collateral. The session commits to one or the + // other on the first participant that forces the question, so a legacy 1:1 mixer is only + // turned away once a rebalance participant is actually present, not because the session + // happened to be opened by an upgraded peer. ERR_VERSION is within the message range old + // clients understand, so they reset immediately and move on to another queue. + if (dsa.IsRebalance()) { + if (!fRebalanceAcceptable) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- rejecting rebalance dsa from protocol=%d, promotion/demotion not available\n", + nPeerVersion); + nMessageIDRet = ERR_VERSION; + return false; + } + if (m_fHasLegacyParticipant) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- rejecting rebalance dsa, session holds a pre-rebalance participant\n"); + nMessageIDRet = ERR_VERSION; + return false; + } + } else if (nPeerVersion < COINJOIN_REBALANCE_VERSION && m_fRebalanceSession) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- rejecting peer with protocol=%d from rebalance session\n", nPeerVersion); nMessageIDRet = ERR_VERSION; @@ -1055,18 +1083,6 @@ bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, int n return false; } - if (!dsa.HasValidFlags()) { - LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- malformed dsa flags\n"); - nMessageIDRet = ERR_VERSION; - return false; - } - - if (dsa.IsRebalance() && !m_fRebalanceSession) { - LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- rejecting rebalance dsa, not a rebalance session\n"); - nMessageIDRet = ERR_VERSION; - return false; - } - // Session collaterals are only ever test-accepted, never added to the mempool, so nothing // pins their identity: the same UTXO can be re-signed into arbitrarily many distinct txids. // Match on input prevouts so a resent or replayed dsa cannot be counted as a new participant. @@ -1082,6 +1098,10 @@ bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, int n // count new user as accepted to an existing session nMessageIDRet = MSG_NOERR; + // Latch what this participant commits the session to. The checks above guarantee the two + // never both become true. + m_fRebalanceSession |= dsa.IsRebalance(); + m_fHasLegacyParticipant |= nPeerVersion < COINJOIN_REBALANCE_VERSION; m_mapDeclaredShapes.emplace(dsa.txCollateral.GetHash(), DeclaredShape(dsa)); CommitSessionCollateral(dsa.txCollateral); diff --git a/src/coinjoin/server.h b/src/coinjoin/server.h index 3aabc3378247..92ffd6a9f9f6 100644 --- a/src/coinjoin/server.h +++ b/src/coinjoin/server.h @@ -55,11 +55,15 @@ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler // reuses one of them can be rejected without rescanning them all. std::unordered_set setSessionCollateralPrevouts GUARDED_BY(cs_coinjoin); - // Post-V24: true when this session may contain promotion/demotion entries. Fixed at - // session creation from the creator's protocol version; only peers at or above - // COINJOIN_REBALANCE_VERSION are allowed into such a session, so clients that cannot - // validate an unbalanced final transaction never end up having to refuse to sign one. + // Post-V24: true once a participant has been admitted that declared a promotion/demotion, + // i.e. the final transaction may come out unbalanced. Latched on admission rather than + // fixed by the creator's protocol version, so a session only commits to this once someone + // actually asks for it. bool m_fRebalanceSession GUARDED_BY(cs_coinjoin){false}; + // Post-V24: true once a participant below COINJOIN_REBALANCE_VERSION has been admitted. + // Such a peer cannot validate an unbalanced final transaction, so it must never share a + // session with a rebalance participant. Mutually exclusive with m_fRebalanceSession. + bool m_fHasLegacyParticipant GUARDED_BY(cs_coinjoin){false}; // The mixing direction each accepted participant declared in its dsa, keyed by collateral // hash. Tells us which side of the session denomination a participant will occupy before // its entry arrives, and entitles it (and only it) to submit an entry of that shape. From 032bc996c49664ba14cbaa36d3881978685e672a Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 18 Aug 2026 10:20:31 -0500 Subject: [PATCH 14/26] fix(coinjoin): widen the V24 DSTX skew tolerance and downgrade withheld DSTXes Two boundary problems in the DSTX relay path. The reduced PREMATURE penalty for a structurally-post-V24 DSTX applied only when IsPromotionDemotionActive(fNextBlock=true) held, i.e. only when V24 activates in the block immediately after our tip, while IsValidStructure gates on activation at the tip. That is a one-block-wide tolerance. A node two or more blocks behind at the activation height hands the full INVALID penalty to peers relaying perfectly honest promotion DSTXes; ten of them cross DISCOURAGEMENT_THRESHOLD and disconnect an honest peer, and nothing damps the accumulation because DSTX deliberately bypasses the recent-rejects filter. We cannot know how far ahead the sending peer is, so drop the lookahead condition: fPossiblyValidPostV24 already implies we are pre-V24 and the transaction is well-formed under post-V24 rules. PREMATURE still accumulates, so a peer actually flooding us is still discouraged. Separately, when CanAnnounceDstxTo refused a peer the hash was inserted into m_tx_inventory_known_filter with no inventory queued at all, marking the peer as knowing a transaction it would never be offered. Withholding MSG_DSTX from pre-rebalance peers is correct - they would score us for a malformed DSTX - but there is no reason to withhold the transaction itself: ProcessGetData already answers a MSG_TX getdata for a transaction that has a dstx entry, and no mempool policy treats it differently. Announce it as MSG_TX instead, so those peers stop missing rebalance transactions until block inclusion and no longer pay a getblocktxn round-trip for each one during compact-block reconstruction. --- src/net_processing.cpp | 53 +++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 08e3e1ead767..01efc30c6417 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -3518,12 +3518,12 @@ enum class DSTXValidationScore : int { INVALID = 10, }; -//! Whether a DSTX may be announced to a peer at the given negotiated protocol version. -//! Post-V24 promotion/demotion DSTXes are unbalanced and thus structurally invalid to peers -//! below COINJOIN_REBALANCE_VERSION - they would drop the transaction and penalize us for +//! Whether a DSTX may be announced as MSG_DSTX to a peer at the given negotiated protocol +//! version. Post-V24 promotion/demotion DSTXes are unbalanced and thus structurally invalid to +//! peers below COINJOIN_REBALANCE_VERSION - they would drop the transaction and penalize us for //! relaying it. Pre-COINJOIN_REBALANCE_VERSION peers also enforce a smaller structural size //! cap (GetMaxPoolInputOutputCount), so a balanced-but-oversized final transaction is likewise -//! invalid to them. Such peers see the transaction on block inclusion instead. +//! invalid to them. Callers announce such transactions as plain MSG_TX instead. static bool CanAnnounceDstxTo(const CCoinJoinBroadcastTx& dstx, int peer_version) { return (dstx.tx->vin.size() == dstx.tx->vout.size() && @@ -3557,13 +3557,15 @@ static DSTXValidationResult ValidateDSTX(CDeterministicMNManager& dmnman, CDSTXM bool fPossiblyValidPostV24{false}; if (!dstx.IsValidStructure(pindex, chainman, &fPossiblyValidPostV24)) { LogPrint(BCLog::COINJOIN, "DSTX -- Invalid DSTX structure: %s\n", hashTx.ToString()); - // A tx that is structurally valid under post-V24 rules may come from a masternode - // whose tip is one block ahead of ours at the activation boundary. Like the - // 24-block-deep masternode scan below, tolerate the skew: drop it with only a small - // penalty so honest relayers are unaffected while a peer flooding us still gets - // discouraged eventually. Away from the boundary no honest peer relays such a tx, - // so it keeps the full penalty like anything else malformed. - if (fPossiblyValidPostV24 && CoinJoin::IsPromotionDemotionActive(chainman, /*fNextBlock=*/true)) { + // fPossiblyValidPostV24 means the tx is well-formed under post-V24 rules and we are + // still pre-V24, i.e. it most likely comes from a peer whose tip has already crossed + // the activation boundary while ours has not. We cannot tell how far ahead that peer + // is, so don't tie the tolerance to a one-block lookahead: a node even a couple of + // blocks behind at activation would otherwise hand out the full penalty to every + // honest relayer and discourage its peers just as it is trying to catch up. Like the + // 24-block-deep masternode scan below this is a tip-skew tolerance, and PREMATURE + // still accumulates, so a peer genuinely flooding us is discouraged regardless. + if (fPossiblyValidPostV24) { return {DSTXValidationScore::PREMATURE, true}; } return {DSTXValidationScore::INVALID, true}; @@ -6490,21 +6492,15 @@ bool PeerManagerImpl::SendMessages(CNode* pto) if (tx_relay->m_bloom_filter && !tx_relay->m_bloom_filter->IsRelevantAndUpdate(*txinfo.tx)) continue; int nInvType = MSG_TX; - // A DSTX this peer can't accept is withheld, but its islock inv is still - // relayed below - on develop that islock inv was always delivered here, so - // gating the DSTX must not also suppress it. - bool fWithholdTx{false}; - if (const auto dstx = m_dstxman.GetDSTX(hash); dstx) { - if (!CanAnnounceDstxTo(dstx, pto->GetCommonVersion())) { - fWithholdTx = true; - } else { - nInvType = MSG_DSTX; - } + // A DSTX this peer would reject as malformed is announced as a plain + // transaction instead of being dropped: the peer still gets the transaction + // (ProcessGetData serves a NetMsgType::TX for it), just without the mixing + // metadata it cannot parse, so it is not left waiting for block inclusion. + if (const auto dstx = m_dstxman.GetDSTX(hash); dstx && CanAnnounceDstxTo(dstx, pto->GetCommonVersion())) { + nInvType = MSG_DSTX; } tx_relay->m_tx_inventory_known_filter.insert(hash); - if (!fWithholdTx) { - queueAndMaybePushInv(CInv(nInvType, hash)); - } + queueAndMaybePushInv(CInv(nInvType, hash)); const auto islock = m_llmq_ctx->isman->GetInstantSendLockByTxid(hash); if (islock == nullptr) continue; @@ -6564,11 +6560,10 @@ bool PeerManagerImpl::SendMessages(CNode* pto) } if (tx_relay->m_bloom_filter && !tx_relay->m_bloom_filter->IsRelevantAndUpdate(*txinfo.tx)) continue; int nInvType = MSG_TX; - if (const auto dstx = m_dstxman.GetDSTX(hash); dstx) { - if (!CanAnnounceDstxTo(dstx, pto->GetCommonVersion())) { - tx_relay->m_tx_inventory_known_filter.insert(hash); - continue; - } + // See the mempool-request path above: a DSTX this peer would reject as + // malformed is downgraded to a plain transaction announcement rather than + // withheld, so pre-rebalance peers still receive it. + if (const auto dstx = m_dstxman.GetDSTX(hash); dstx && CanAnnounceDstxTo(dstx, pto->GetCommonVersion())) { nInvType = MSG_DSTX; } // Send From 428eb9755d2dd057456eba5979430af0b653f034 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 18 Aug 2026 10:45:55 -0500 Subject: [PATCH 15/26] fix(coinjoin): recognize unbalanced mixing transactions as denominated is_denominate still required vin.size() == vout.size(), a rule that promotion (10 inputs to 1 output) and demotion (1 to 10) entries break. A wallet that takes part in such a session therefore reports its own mixing transaction as not denominated: transactionrecord.cpp skips the CoinJoinMixing branch, fAllFromMe and fAllToMe are both false because the other participants' coins are not ours, and the record falls through to TransactionRecord::Other. formatTxType renders that as an empty Type cell with a 0.00 amount and address "(n/a)". The CoinJoin filter in the transaction view stops matching it, and walletview only suppresses desktop popups for the four CoinJoin record types, so these transactions pop a notification even with CoinJoin popups turned off. Replace the count equality with a check that every output is a denominated amount. That holds for standard, promotion and demotion final transactions alike, and together with the existing zero-fee and own-denominated-input/output conditions still excludes ordinary payments, which carry a non-denominated payment or change output. --- src/wallet/interfaces.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index 4af755044de8..8544e138af56 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -39,6 +39,7 @@ #include #include +#include #include #include #include @@ -101,7 +102,13 @@ WalletTx MakeWalletTx(CWallet& wallet, const CWalletTx& wtx) result.is_platform_transfer = wtx.IsPlatformTransfer(); // The determination of is_denominate is based on simplified checks here because in this part of the code // we only want to know about mixing transactions belonging to this specific wallet. - result.is_denominate = wtx.tx->vin.size() == wtx.tx->vout.size() && // Number of inputs is same as number of outputs + // Post-V24 a session may contain promotion (10 inputs to 1 output) and demotion (1 to 10) + // entries, so the input and output counts of a mixing transaction no longer have to match. + // Every output being a denominated amount is what still sets one apart from an ordinary + // payment, which would have a non-denominated payment or change output. + const bool fAllOutputsDenominated = std::ranges::all_of( + wtx.tx->vout, [](const CTxOut& txout) { return CoinJoin::IsDenominatedAmount(txout.nValue); }); + result.is_denominate = fAllOutputsDenominated && (result.credit - result.debit) == 0 && // Transaction pays no tx fee fInputDenomFound && fOutputDenomFound; // At least 1 input and 1 output are denominated belonging to the provided wallet return result; From 6af461c83aae863948b58438bc3a4a5e6e4bad13 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 18 Aug 2026 10:46:04 -0500 Subject: [PATCH 16/26] fix(coinjoin): scale the rebalance gap threshold with the denoms goal ShouldPromoteDenoms and ShouldDemoteDenoms require the deficit gap to exceed GAP_THRESHOLD, a constant 10. Both deficits are clamped to [0, nGoal], so the largest gap two denominations can ever show is nGoal itself, which makes the comparison satisfiable only when nGoal > 10. MIN_COINJOIN_DENOMS_GOAL is also 10 and -coinjoindenomsgoal clamps to [10, 100000], so a user running the documented minimum gets promotion and demotion silently disabled: even a wallet holding the goal in smaller denoms and none of the larger never converts. There is no diagnostic, and the full GetDenominationCounts() wallet scan still runs on every maintenance tick once V24 is active. Derive the threshold from the goal instead. At the default goal of 50 it still evaluates to 10, so tuned behavior is unchanged; at the minimum goal it becomes 2, which is reachable while still damping oscillation. Add a test covering the minimum goal, which no existing case exercised - they all use 100. --- src/coinjoin/common.h | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/coinjoin/common.h b/src/coinjoin/common.h index 8e5852deeae8..d92192724fea 100644 --- a/src/coinjoin/common.h +++ b/src/coinjoin/common.h @@ -118,7 +118,16 @@ constexpr CAmount GetMaxCollateralAmount() { return GetCollateralAmount() * 4; } // Promotion/demotion constants (post-V24 feature) constexpr int PROMOTION_RATIO = 10; // 10 smaller denomination coins = 1 larger denomination coin -constexpr int GAP_THRESHOLD = 10; // Deficit gap required to trigger promotion/demotion +constexpr int GAP_DIVISOR = 5; // Deficit gap required to trigger promotion/demotion, as 1/N of the goal + +/** + * How far behind the other denomination a denomination has to be before converting is worth it; + * the gap is what keeps promotion and demotion from oscillating. It is a fraction of the goal + * rather than a constant because the largest gap two denominations can show is the goal itself: + * a constant of 10 was unsatisfiable at MIN_COINJOIN_DENOMS_GOAL (also 10) and silently + * disabled the feature there. At the default goal of 50 this still yields 10. + */ +constexpr int GetGapThreshold(int nGoal) { return nGoal / GAP_DIVISOR > 1 ? nGoal / GAP_DIVISOR : 1; } /** * Which side(s) of the session denomination a participant occupies. A standard entry mixes at @@ -207,7 +216,7 @@ constexpr int GetSmallerAdjacentDenom(int nDenom) /** * Core promotion decision (post-V24): combine PROMOTION_RATIO fully-mixed coins of the * smaller denomination into one coin of the larger adjacent denomination when the larger - * denomination is further from the per-denom goal by more than GAP_THRESHOLD (the gap + * denomination is further from the per-denom goal by more than the gap threshold (which * prevents promote/demote oscillation). Callers resolve wallet counts and validate that * the denominations are adjacent. */ @@ -219,13 +228,13 @@ constexpr bool ShouldPromoteDenoms(int nSmallerCount, int nLargerCount, int nSma if (nSmallerFullyMixedCount < PROMOTION_RATIO) return false; const int nSmallerDeficit = nSmallerCount < nGoal ? nGoal - nSmallerCount : 0; const int nLargerDeficit = nLargerCount < nGoal ? nGoal - nLargerCount : 0; - return nLargerDeficit > nSmallerDeficit + GAP_THRESHOLD; + return nLargerDeficit > nSmallerDeficit + GetGapThreshold(nGoal); } /** * Core demotion decision (post-V24): split one coin of the larger denomination into * PROMOTION_RATIO coins of the smaller adjacent denomination when the smaller denomination - * is further from the per-denom goal by more than GAP_THRESHOLD. Like promotion, demotion + * is further from the per-denom goal by more than the gap threshold. Like promotion, demotion * only spends a fully-mixed coin: the conversion's public 1:10 shape clusters its outputs, * which start mixing over, so only an input with a protected history is worth converting. */ @@ -237,7 +246,7 @@ constexpr bool ShouldDemoteDenoms(int nLargerCount, int nSmallerCount, int nLarg if (nLargerFullyMixedCount < 1) return false; const int nSmallerDeficit = nSmallerCount < nGoal ? nGoal - nSmallerCount : 0; const int nLargerDeficit = nLargerCount < nGoal ? nGoal - nLargerCount : 0; - return nSmallerDeficit > nLargerDeficit + GAP_THRESHOLD; + return nSmallerDeficit > nLargerDeficit + GetGapThreshold(nGoal); } constexpr bool IsCollateralAmount(CAmount nInputAmount) From e78b965015ede5b1223ef89cef288aa1f91c554b Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 18 Aug 2026 10:46:14 -0500 Subject: [PATCH 17/26] test(coinjoin): cover session finalization race and gap-threshold boundary Give the test server a session id when it enters a pool state, matching a real session -- AddEntry now rejects entries that do not belong to a live session, and the harness left nSessionID at 0. Add server_addentry_rejects_entries_once_the_session_finalized, which pins the race the admission fix closes: a straggler whose collateral is still committed and which has no entry yet must not be admitted once the pool has moved to POOL_STATE_SIGNING. Add gap_threshold_reachable_at_every_goal, covering MIN_COINJOIN_DENOMS_GOAL, which no existing case exercised, and asserting the default goal keeps the threshold of 10 it was tuned with. --- src/test/coinjoin_inouts_tests.cpp | 70 +++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/src/test/coinjoin_inouts_tests.cpp b/src/test/coinjoin_inouts_tests.cpp index 133a14a7854f..2522ca41dd8f 100644 --- a/src/test/coinjoin_inouts_tests.cpp +++ b/src/test/coinjoin_inouts_tests.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -183,8 +184,10 @@ class TestableCoinJoinServer : public CCoinJoinServer using CCoinJoinServer::CCoinJoinServer; using CCoinJoinServer::AddEntry; - void EnterSigningState() { nState = POOL_STATE_SIGNING; } - void EnterAcceptingEntriesState() { nState = POOL_STATE_ACCEPTING_ENTRIES; } + // A live session always carries a non-zero id, and AddEntry rejects entries that don't + // belong to one, so seed an id along with the state. + void EnterSigningState() { nSessionID = 1; nState = POOL_STATE_SIGNING; } + void EnterAcceptingEntriesState() { nSessionID = 1; nState = POOL_STATE_ACCEPTING_ENTRIES; } void SeedParticipant(const CService& addr) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) { @@ -394,6 +397,36 @@ BOOST_AUTO_TEST_CASE(server_addentry_binds_entries_to_accepted_collaterals) BOOST_CHECK_EQUAL(server.GetEntriesCount(), 1); } +BOOST_AUTO_TEST_CASE(server_addentry_rejects_entries_once_the_session_finalized) +{ + CActiveMasternodeManager mn_activeman(*Assert(m_node.connman), *Assert(m_node.dmnman), MakeSecretKey()); + TestableCoinJoinServer server(m_node.peerman.get(), *Assert(m_node.chainman), *Assert(m_node.connman), + *Assert(m_node.dmnman), *Assert(m_node.dstxman), *Assert(m_node.mn_metaman), + *Assert(m_node.mempool), mn_activeman, *Assert(m_node.mn_sync), + *Assert(m_node.llmq_ctx->isman)); + + CMutableTransaction txCollateral; + txCollateral.vin.emplace_back(COutPoint(uint256::ONE, 1)); + txCollateral.vout.emplace_back(COIN / 10, P2PKHScript(1)); + + std::vector dsins{CTxDSIn(CTxIn(COutPoint(uint256S("aa"), 1)), P2PKHScript(1), /*nRounds=*/0)}; + std::vector outs{CTxOut(COIN / 1000 + 1, P2PKHScript(1))}; + const CCoinJoinEntry entry(dsins, outs, CTransaction(txCollateral)); + + server.SeedSessionCollateral(txCollateral, CoinJoin::MixShape::STANDARD); + + // The scheduler thread can finalize a timed-out session while a straggler's entry is still + // being validated. Its collateral is still committed and it still has no entry, so the + // membership and duplicate checks alone would let it in - after the final transaction was + // already built without it, leaving inputs nobody can sign. + server.EnterSigningState(); + + PoolMessage msg{MSG_NOERR}; + BOOST_CHECK(!server.AddEntry(entry, msg)); + BOOST_CHECK_EQUAL(msg, ERR_SESSION); + BOOST_CHECK_EQUAL(server.GetEntriesCount(), 0); +} + BOOST_AUTO_TEST_CASE(entry_deserializes_vectors_through_wire_cap) { const size_t wire_cap{CoinJoin::GetMaxPoolInputOutputCount()}; @@ -1170,11 +1203,11 @@ BOOST_AUTO_TEST_CASE(should_promote_larger_deficit_greater) const int goal = 100; // 60 of smaller, 0 of larger -> should promote (0.1 has surplus, 1.0 needs help) - // smallerDeficit = 40, largerDeficit = 100, gap = 60 > GAP_THRESHOLD + // smallerDeficit = 40, largerDeficit = 100, gap = 60 > gap threshold (20 at this goal) BOOST_CHECK(TestShouldPromote(60, 0, goal)); // 100 of smaller, 0 of larger -> should promote (0.1 full, 1.0 empty) - // smallerDeficit = 0, largerDeficit = 100, gap = 100 > GAP_THRESHOLD + // smallerDeficit = 0, largerDeficit = 100, gap = 100 > gap threshold (20 at this goal) BOOST_CHECK(TestShouldPromote(100, 0, goal)); } @@ -1189,20 +1222,37 @@ BOOST_AUTO_TEST_CASE(should_promote_below_half_goal_false) BOOST_CHECK(!TestShouldPromote(49, 0, goal)); // 50 of smaller, 0 of larger -> CAN promote (50 >= 50 = halfGoal) - // smallerDeficit = 50, largerDeficit = 100, gap = 50 > GAP_THRESHOLD + // smallerDeficit = 50, largerDeficit = 100, gap = 50 > gap threshold (20 at this goal) BOOST_CHECK(TestShouldPromote(50, 0, goal)); } +BOOST_AUTO_TEST_CASE(gap_threshold_reachable_at_every_goal) +{ + // The gap between two denominations can never exceed the goal, so a threshold that equals + // the goal makes promotion/demotion unsatisfiable. Check the extreme imbalance converts at + // the smallest configurable goal, which a constant threshold of 10 silently disabled. + const int min_goal = MIN_COINJOIN_DENOMS_GOAL; + BOOST_CHECK(TestShouldPromote(min_goal, 0, min_goal)); + BOOST_CHECK(TestShouldDemote(min_goal, 0, min_goal)); + + // The gap still has to be cleared: at the minimum goal a one-coin difference is not enough + BOOST_CHECK(!TestShouldPromote(min_goal, min_goal - 1, min_goal)); + BOOST_CHECK(!TestShouldDemote(min_goal, min_goal - 1, min_goal)); + + // The default goal keeps the threshold it was tuned with + BOOST_CHECK_EQUAL(CoinJoin::GetGapThreshold(DEFAULT_COINJOIN_DENOMS_GOAL), 10); +} + BOOST_AUTO_TEST_CASE(should_promote_small_gap_false) { const int goal = 100; // 90 smaller, 80 larger -> deficits are 10 and 20, gap = 10 - // 20 > 10 + 10 is false, so no promotion + // 20 > 10 + 20 is false, so no promotion BOOST_CHECK(!TestShouldPromote(90, 80, goal)); // 85 smaller, 80 larger -> deficits are 15 and 20, gap = 5 - // 20 > 15 + 10 is false, so no promotion + // 20 > 15 + 20 is false, so no promotion BOOST_CHECK(!TestShouldPromote(85, 80, goal)); } @@ -1211,11 +1261,11 @@ BOOST_AUTO_TEST_CASE(should_demote_smaller_deficit_greater) const int goal = 100; // 60 of larger, 0 of smaller -> should demote (1.0 has surplus, 0.1 needs help) - // largerDeficit = 40, smallerDeficit = 100, gap = 60 > GAP_THRESHOLD + // largerDeficit = 40, smallerDeficit = 100, gap = 60 > gap threshold (20 at this goal) BOOST_CHECK(TestShouldDemote(60, 0, goal)); // 99 of larger, 0 of smaller -> should demote - // largerDeficit = 1, smallerDeficit = 100, gap = 99 > GAP_THRESHOLD + // largerDeficit = 1, smallerDeficit = 100, gap = 99 > gap threshold (20 at this goal) BOOST_CHECK(TestShouldDemote(99, 0, goal)); } @@ -1230,7 +1280,7 @@ BOOST_AUTO_TEST_CASE(should_demote_below_half_goal_false) BOOST_CHECK(!TestShouldDemote(49, 0, goal)); // 50 of larger, 0 of smaller -> CAN demote (50 >= 50 = halfGoal) - // largerDeficit = 50, smallerDeficit = 100, gap = 50 > GAP_THRESHOLD + // largerDeficit = 50, smallerDeficit = 100, gap = 50 > gap threshold (20 at this goal) BOOST_CHECK(TestShouldDemote(50, 0, goal)); } From 574831689159f179ec6ab6f84b1079fb434520c0 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 18 Aug 2026 10:46:23 -0500 Subject: [PATCH 18/26] refactor(coinjoin): use static_cast in new promotion/demotion code develop replaced the C-style and functional casts across Dash code in e45491863a87; the promotion/demotion work was written before that landed and reintroduced a handful of size_t(...) and (int)... casts in the same files. Convert them so the next sweep does not have to revisit these lines. No behavior change. --- src/coinjoin/coinjoin.h | 4 ++-- src/coinjoin/server.cpp | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/coinjoin/coinjoin.h b/src/coinjoin/coinjoin.h index 56ad652557d4..ad70edc14433 100644 --- a/src/coinjoin/coinjoin.h +++ b/src/coinjoin/coinjoin.h @@ -232,8 +232,8 @@ class CCoinJoinEntry using CoinJoin::MixShape; if (vecTxDSIn.empty() || vecTxOut.empty()) return MixShape::UNKNOWN; if (vecTxDSIn.size() == vecTxOut.size()) return MixShape::STANDARD; - if (vecTxDSIn.size() == size_t(CoinJoin::PROMOTION_RATIO) && vecTxOut.size() == 1) return MixShape::PROMOTION; - if (vecTxDSIn.size() == 1 && vecTxOut.size() == size_t(CoinJoin::PROMOTION_RATIO)) return MixShape::DEMOTION; + if (vecTxDSIn.size() == static_cast(CoinJoin::PROMOTION_RATIO) && vecTxOut.size() == 1) return MixShape::PROMOTION; + if (vecTxDSIn.size() == 1 && vecTxOut.size() == static_cast(CoinJoin::PROMOTION_RATIO)) return MixShape::DEMOTION; return MixShape::UNKNOWN; } diff --git a/src/coinjoin/server.cpp b/src/coinjoin/server.cpp index b69235965efc..4099a7c78b76 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -310,7 +310,7 @@ void CCoinJoinServer::CheckPool() const auto sides = GetMixSideCounts(); // If we have an entry for each collateral, then create final tx - if (nState == POOL_STATE_ACCEPTING_ENTRIES && size_t(GetEntriesCount()) == vecSessionCollaterals.size()) { + if (nState == POOL_STATE_ACCEPTING_ENTRIES && static_cast(GetEntriesCount()) == vecSessionCollaterals.size()) { if (sides.IsCovered()) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n"); CreateFinalTransaction(); @@ -690,7 +690,7 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag return false; } - if (size_t(GetEntriesCountLocked()) >= vecSessionCollaterals.size()) { + if (static_cast(GetEntriesCountLocked()) >= vecSessionCollaterals.size()) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: entries is full!\n", __func__); nMessageIDRet = ERR_ENTRIES_FULL; return false; @@ -731,7 +731,7 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag // // Post-V24: promotion entries carry PROMOTION_RATIO (10) inputs and demotion entries // PROMOTION_RATIO outputs; pre-V24 entries are capped at COINJOIN_ENTRY_MAX_SIZE (9) - const size_t nMaxEntrySize = fRebalanceSession ? size_t(CoinJoin::PROMOTION_RATIO) : COINJOIN_ENTRY_MAX_SIZE; + const size_t nMaxEntrySize = fRebalanceSession ? static_cast(CoinJoin::PROMOTION_RATIO) : COINJOIN_ENTRY_MAX_SIZE; if (entry.vecTxDSIn.size() > nMaxEntrySize || entry.vecTxOut.size() > nMaxEntrySize) { LogPrint(BCLog::COINJOIN, /* Continued */ "CCoinJoinServer::%s -- ERROR: too many inputs or outputs! inputs=%s/%s, outputs=%s/%s\n", __func__, @@ -1077,7 +1077,7 @@ bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, int n // IsSessionReady() can now hold a full session back waiting for a missing counterparty, so // the participant limit has to be enforced here rather than implied by session readiness - if ((int)vecSessionCollaterals.size() >= CoinJoin::GetMaxPoolParticipants()) { + if (static_cast(vecSessionCollaterals.size()) >= CoinJoin::GetMaxPoolParticipants()) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- session is full\n"); nMessageIDRet = ERR_QUEUE_FULL; return false; From 0dc132f038d211cf3b53d36752e7e3db6a6315d2 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 18 Aug 2026 10:47:08 -0500 Subject: [PATCH 19/26] docs: describe rebalance session gating and DSTX downgrade accurately Update the release notes for the two behavior changes made while fixing review findings: session rebalance capability now latches on the first participant that declares a direction rather than being fixed by the creator's protocol version, and a DSTX withheld from a pre-70241 peer is announced to it as a plain transaction rather than not at all. --- doc/release-notes-7052.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/doc/release-notes-7052.md b/doc/release-notes-7052.md index e416bba1f9e2..fd611b543701 100644 --- a/doc/release-notes-7052.md +++ b/doc/release-notes-7052.md @@ -1,18 +1,21 @@ P2P and network changes ----------------------- -- The protocol version was bumped to 70241. Mixing sessions that may - contain promotion/demotion entries are only formed by, and only admit, - peers at protocol 70241 or newer: the `dsa` message gained a +- The protocol version was bumped to 70241. The `dsa` message gained a version-gated flags field declaring which mixing direction a - participant intends, and the session creator's protocol version fixes - the session's capability. Older clients are rejected from such sessions - at acceptance time (before any collateral is committed) and continue to - mix normally in sessions created by older peers, which newer clients - still join for standard mixing. Unbalanced (promotion/demotion) DSTXes - are likewise only announced to peers at protocol 70241 or newer; older - peers would reject them as structurally invalid and penalize the - relayer, and instead see the transaction on block inclusion. (#7052) + participant intends. A session commits to carrying promotion/demotion + entries only once a participant is admitted that actually declared one, + and it becomes closed to pre-70241 clients only from that point on; + conversely, a session that has already admitted a pre-70241 client + refuses later promotion/demotion participants. Either way the refusal + happens at acceptance time, before any collateral is committed, so a + client doing ordinary 1:1 mixing is never turned away from a session + simply because of who opened it. Unbalanced (promotion/demotion) DSTXes + are only announced as `dstx` to peers at protocol 70241 or newer, since + older peers would reject them as structurally invalid and penalize the + relayer; those peers are sent a plain `tx` announcement instead, so + they still receive the transaction without the mixing metadata they + cannot parse. (#7052) - A mixing session only completes once each side of its denomination is occupied by nobody or by at least two participants, since coins are From 3646c95b40022581b84ebe68cfcd0debd278b852 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 18 Aug 2026 11:05:48 -0500 Subject: [PATCH 20/26] fix(coinjoin): tolerate DSTX tip skew from V24 lock-in, and address review feedback Refines the preceding fixes after a review pass. The DSTX skew tolerance is retied to the deployment's threshold state. The previous revision dropped the activation check entirely, which also gave the reduced penalty to unbalanced DSTXes sent long before V24 could activate. Gate on ThresholdState LOCKED_IN or ACTIVE instead: lock-in is the point from which a peer's tip can legitimately be past activation while ours is not, however many blocks ahead it happens to be, and before it no honest peer applies post-V24 rules. p2p_dstx.py covers both ends -- full penalty far from the boundary, reduced penalty at it. Remaining changes are non-functional: extract CCoinJoinServer::HasSessionCollateral so the membership predicate has one definition instead of three; annotate ConsumeCollateral as requiring cs_coinjoin not be held, since it takes cs_main; rename the IsValidInOuts parameter to fAllowRebalanceShapes, which is what it gates now that callers supply it, and name it at every call site; use std::max in GetGapThreshold; and drop a include that already provides. --- src/coinjoin/client.cpp | 2 +- src/coinjoin/coinjoin.cpp | 20 +++++++++++++++++--- src/coinjoin/coinjoin.h | 16 +++++++++++----- src/coinjoin/common.h | 3 ++- src/coinjoin/server.cpp | 25 ++++++++++++++----------- src/coinjoin/server.h | 7 +++++-- src/net_processing.cpp | 18 ++++++++++-------- src/test/coinjoin_inouts_tests.cpp | 4 ++-- src/wallet/interfaces.cpp | 1 - 9 files changed, 62 insertions(+), 34 deletions(-) diff --git a/src/coinjoin/client.cpp b/src/coinjoin/client.cpp index c7d78351d148..b0da5efd1155 100644 --- a/src/coinjoin/client.cpp +++ b/src/coinjoin/client.cpp @@ -496,7 +496,7 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ PoolMessage nMessageID{MSG_NOERR}; CoinJoin::SessionDenomCounts denomCounts; if (!IsValidInOuts(active_chainstate, m_isman, mempool, finalMutableTransaction.vin, finalMutableTransaction.vout, - nSessionDenom, fV24Active, nMessageID, nullptr, /*fFinalTx=*/true, &denomCounts)) { + nSessionDenom, /*fAllowRebalanceShapes=*/fV24Active, nMessageID, nullptr, /*fFinalTx=*/true, &denomCounts)) { WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- ERROR! IsValidInOuts() failed: %s\n", __func__, CoinJoin::GetMessageByID(nMessageID).translated); UnlockCoins(); keyHolderStorage.ReturnAll(); diff --git a/src/coinjoin/coinjoin.cpp b/src/coinjoin/coinjoin.cpp index 1a20a9a7c95a..e1d4b9221a94 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -253,9 +253,23 @@ bool CoinJoin::IsPromotionDemotionActive(const ChainstateManager& chainman, bool : DeploymentActiveAt(*pindex, chainman, Consensus::DEPLOYMENT_V24); } +bool CoinJoin::IsPromotionDemotionImminent(const ChainstateManager& chainman) +{ + LOCK(::cs_main); + const CBlockIndex* pindex = chainman.ActiveChain().Tip(); + if (pindex == nullptr) return false; + // LOCKED_IN means the deployment activates at the end of the current signalling period, so + // from here on a peer whose tip is ahead of ours may already be past activation - however + // many blocks ahead it happens to be. ACTIVE covers the tip-one-block-behind case. Before + // lock-in no honest peer can be applying post-V24 rules. + const ThresholdState state = + chainman.m_versionbitscache.State(pindex, chainman.GetConsensus(), Consensus::DEPLOYMENT_V24); + return state == ThresholdState::LOCKED_IN || state == ThresholdState::ACTIVE; +} + bool CCoinJoinBaseSession::IsValidInOuts(Chainstate& active_chainstate, const llmq::CInstantSendManager& isman, const CTxMemPool& mempool, const std::vector& vin, - const std::vector& vout, int session_denom, bool fV24Active, + const std::vector& vout, int session_denom, bool fAllowRebalanceShapes, PoolMessage& nMessageIDRet, bool* fConsumeCollateralRet, bool fFinalTx, CoinJoin::SessionDenomCounts* pDenomCountsRet) { @@ -271,11 +285,11 @@ bool CCoinJoinBaseSession::IsValidInOuts(Chainstate& active_chainstate, const ll enum class EntryType { STANDARD, PROMOTION, DEMOTION, FINAL_TX, INVALID }; EntryType entryType = EntryType::STANDARD; - if (fFinalTx && fV24Active) { + if (fFinalTx && fAllowRebalanceShapes) { entryType = EntryType::FINAL_TX; } else if (vin.size() == vout.size()) { entryType = EntryType::STANDARD; - } else if (fV24Active) { + } else if (fAllowRebalanceShapes) { if (vin.size() == static_cast(CoinJoin::PROMOTION_RATIO) && vout.size() == 1) { entryType = EntryType::PROMOTION; } else if (vin.size() == 1 && vout.size() == static_cast(CoinJoin::PROMOTION_RATIO)) { diff --git a/src/coinjoin/coinjoin.h b/src/coinjoin/coinjoin.h index ad70edc14433..3b6975ee5f66 100644 --- a/src/coinjoin/coinjoin.h +++ b/src/coinjoin/coinjoin.h @@ -387,14 +387,15 @@ class CCoinJoinBaseSession * aggregate rules (allowed denominations, value balance, input/output consistency) are * checked instead. session_denom is the caller's snapshot of the session denomination. * - * fV24Active tells us whether promotion/demotion shapes are permitted here. It is passed in - * rather than derived from the tip so that it stays fixed for the lifetime of a session: the - * masternode passes the session's own capability and the client the boundary-tolerant tip - * check, and neither can be flipped mid-session by a reorg across the V24 boundary. + * fAllowRebalanceShapes tells us whether promotion/demotion shapes are permitted here. It is + * passed in rather than derived from the tip so that it stays fixed for the lifetime of a + * session: the masternode passes the session's own capability and the client the + * boundary-tolerant tip check, and neither can be flipped mid-session by a reorg across the + * V24 boundary. */ static bool IsValidInOuts(Chainstate& active_chainstate, const llmq::CInstantSendManager& isman, const CTxMemPool& mempool, const std::vector& vin, const std::vector& vout, - int session_denom, bool fV24Active, PoolMessage& nMessageIDRet, + int session_denom, bool fAllowRebalanceShapes, PoolMessage& nMessageIDRet, bool* fConsumeCollateralRet, bool fFinalTx = false, CoinJoin::SessionDenomCounts* pDenomCountsRet = nullptr); @@ -491,6 +492,11 @@ namespace CoinJoin /// whose tip is one block ahead around the activation boundary. bool IsPromotionDemotionActive(const ChainstateManager& chainman, bool fNextBlock = false); + /// Whether the V24 deployment has locked in or activated as of our tip, i.e. peers whose + /// tips are ahead of ours may already be applying post-V24 rules. Used to soften relay + /// penalties around the activation boundary, where our own tip may lag by several blocks. + bool IsPromotionDemotionImminent(const ChainstateManager& chainman); + /// If the collateral is valid given by a client bool IsCollateralValid(ChainstateManager& chainman, const llmq::CInstantSendManager& isman, const CTxMemPool& mempool, const CTransaction& txCollateral); diff --git a/src/coinjoin/common.h b/src/coinjoin/common.h index d92192724fea..3a3b3177bc90 100644 --- a/src/coinjoin/common.h +++ b/src/coinjoin/common.h @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -127,7 +128,7 @@ constexpr int GAP_DIVISOR = 5; // Deficit gap required to trigger promoti * a constant of 10 was unsatisfiable at MIN_COINJOIN_DENOMS_GOAL (also 10) and silently * disabled the feature there. At the default goal of 50 this still yields 10. */ -constexpr int GetGapThreshold(int nGoal) { return nGoal / GAP_DIVISOR > 1 ? nGoal / GAP_DIVISOR : 1; } +constexpr int GetGapThreshold(int nGoal) { return std::max(nGoal / GAP_DIVISOR, 1); } /** * Which side(s) of the session denomination a participant occupies. A standard entry mixes at diff --git a/src/coinjoin/server.cpp b/src/coinjoin/server.cpp index 4099a7c78b76..0e90cc310dd6 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -546,9 +546,8 @@ void CCoinJoinServer::ConsumeCollateralIfCurrentSession(int session_id, const CT // we get here the scheduler thread may have timed the session out and started another one. // Charging then would spend the collateral of a participant that is no longer in any // session - it may even be a replay of an innocent participant's collateral. - const bool fStillCurrent = WITH_LOCK(cs_coinjoin, return IsCurrentSession(session_id) && - std::ranges::any_of(vecSessionCollaterals, - [&txref](const CTransactionRef& ref) { return *ref == *txref; })); + const bool fStillCurrent = + WITH_LOCK(cs_coinjoin, return IsCurrentSession(session_id) && HasSessionCollateral(txref)); if (!fStillCurrent) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- session changed, not consuming collateral %s\n", __func__, txref->GetHash().ToString()); @@ -563,6 +562,13 @@ bool CCoinJoinServer::IsCurrentSession(int session_id) const return nSessionID != 0 && nSessionID == session_id && nState == POOL_STATE_ACCEPTING_ENTRIES; } +bool CCoinJoinServer::HasSessionCollateral(const CTransactionRef& txref) const +{ + AssertLockHeld(cs_coinjoin); + return std::ranges::any_of(vecSessionCollaterals, + [&txref](const CTransactionRef& ref) { return *ref == *txref; }); +} + bool CCoinJoinServer::HasTimedOut() const { if (nState == POOL_STATE_IDLE) return false; @@ -666,9 +672,6 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag { AssertLockNotHeld(cs_coinjoin); - const auto isSessionCollateral = [&entry](const CTransactionRef& txCollateral) { - return *entry.txCollateral == *txCollateral; - }; const auto hasEntryForCollateral = [&entry](const CCoinJoinEntry& other) { return *other.txCollateral == *entry.txCollateral; }; @@ -706,7 +709,7 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag // declared-shape cover) belonging to the admitted participants. Don't consume the // collateral in either case - it may be a replay of an innocent participant's // collateral rather than misbehavior by its owner. - if (std::ranges::none_of(vecSessionCollaterals, isSessionCollateral)) { + if (!HasSessionCollateral(entry.txCollateral)) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: collateral %s was not accepted into this session!\n", __func__, entry.txCollateral->GetHash().ToString()); nMessageIDRet = ERR_SESSION; @@ -797,7 +800,7 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag bool fConsumeCollateral{false}; if (!IsValidInOuts(m_chainman.ActiveChainstate(), m_isman, mempool, vin, entry.vecTxOut, session_denom, - fRebalanceSession, nMessageIDRet, &fConsumeCollateral)) { + /*fAllowRebalanceShapes=*/fRebalanceSession, nMessageIDRet, &fConsumeCollateral)) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR! IsValidInOuts() failed: %s\n", __func__, CoinJoin::GetMessageByID(nMessageIDRet).translated); if (fConsumeCollateral) { ConsumeCollateralIfCurrentSession(session_id, entry.txCollateral); @@ -812,8 +815,7 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag // with the state, membership and duplicate-use checks. Admitting into a session that // already built its final transaction would add an entry no one can sign, stalling it // until the signing timeout charges the honest participants. - if (!IsCurrentSession(session_id) || - std::ranges::none_of(vecSessionCollaterals, isSessionCollateral) || + if (!IsCurrentSession(session_id) || !HasSessionCollateral(entry.txCollateral) || std::ranges::any_of(vecEntries, hasEntryForCollateral)) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: session changed while validating the entry!\n", __func__); nMessageIDRet = ERR_SESSION; @@ -1027,7 +1029,8 @@ bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, int n } // Evaluated before taking cs_coinjoin: IsPromotionDemotionActive locks cs_main, and cs_main - // is never taken under cs_coinjoin elsewhere in this class. + // is never taken under cs_coinjoin elsewhere in this class. dsa.IsRebalance() leads so that + // cs_main is only taken for rebalance dsas. const bool fRebalanceAcceptable = dsa.IsRebalance() && nPeerVersion >= COINJOIN_REBALANCE_VERSION && CoinJoin::IsPromotionDemotionActive(m_chainman); diff --git a/src/coinjoin/server.h b/src/coinjoin/server.h index 92ffd6a9f9f6..078afb2cf6a7 100644 --- a/src/coinjoin/server.h +++ b/src/coinjoin/server.h @@ -87,14 +87,17 @@ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler void ChargeFees() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); /// Rarely charge fees to pay miners void ChargeRandomFees() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); - /// Consume collateral in cases when peer misbehaved - void ConsumeCollateral(const CTransactionRef& txref) const; + /// Consume collateral in cases when peer misbehaved. Takes cs_main, which this class never + /// takes under cs_coinjoin. + void ConsumeCollateral(const CTransactionRef& txref) const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); /// Consume collateral, but only while session_id is still the live session holding it void ConsumeCollateralIfCurrentSession(int session_id, const CTransactionRef& txref) const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); /// Is session_id still the session we are accepting entries for? bool IsCurrentSession(int session_id) const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin); + /// Is txref one of the collaterals accepted into the current session? + bool HasSessionCollateral(const CTransactionRef& txref) const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin); /// Check for process void CheckPool(); diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 01efc30c6417..80c7a8a07f6f 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -3557,15 +3557,17 @@ static DSTXValidationResult ValidateDSTX(CDeterministicMNManager& dmnman, CDSTXM bool fPossiblyValidPostV24{false}; if (!dstx.IsValidStructure(pindex, chainman, &fPossiblyValidPostV24)) { LogPrint(BCLog::COINJOIN, "DSTX -- Invalid DSTX structure: %s\n", hashTx.ToString()); - // fPossiblyValidPostV24 means the tx is well-formed under post-V24 rules and we are + // fPossiblyValidPostV24 means the tx is well-formed under post-V24 rules while we are // still pre-V24, i.e. it most likely comes from a peer whose tip has already crossed - // the activation boundary while ours has not. We cannot tell how far ahead that peer - // is, so don't tie the tolerance to a one-block lookahead: a node even a couple of - // blocks behind at activation would otherwise hand out the full penalty to every - // honest relayer and discourage its peers just as it is trying to catch up. Like the - // 24-block-deep masternode scan below this is a tip-skew tolerance, and PREMATURE - // still accumulates, so a peer genuinely flooding us is discouraged regardless. - if (fPossiblyValidPostV24) { + // the activation boundary while ours has not. Tolerating only a one-block lookahead + // would leave a node a couple of blocks behind at activation handing the full penalty + // to every honest relayer, discouraging its peers just as it is trying to catch up. + // Tolerate from lock-in onwards instead, which is the whole window in which a peer can + // legitimately be ahead of us; before that no honest peer applies post-V24 rules, so + // such a tx keeps the full penalty like anything else malformed. Like the 24-block-deep + // masternode scan below this is a tip-skew tolerance, and PREMATURE still accumulates, + // so a peer genuinely flooding us is discouraged regardless. + if (fPossiblyValidPostV24 && CoinJoin::IsPromotionDemotionImminent(chainman)) { return {DSTXValidationScore::PREMATURE, true}; } return {DSTXValidationScore::INVALID, true}; diff --git a/src/test/coinjoin_inouts_tests.cpp b/src/test/coinjoin_inouts_tests.cpp index 2522ca41dd8f..1261022f5c0b 100644 --- a/src/test/coinjoin_inouts_tests.cpp +++ b/src/test/coinjoin_inouts_tests.cpp @@ -331,7 +331,7 @@ BOOST_AUTO_TEST_CASE(validation_uses_session_denom_snapshot) // Outputs matching the captured denomination pass the denom check and fail // only later on the unknown input. BOOST_CHECK(!InOutsChecker::IsValidInOuts(chainstate, isman, mempool, vin, vout, session_denom, - /*fV24Active=*/false, message, &consume_collateral)); + /*fAllowRebalanceShapes=*/false, message, &consume_collateral)); BOOST_CHECK_EQUAL(message, ERR_MISSING_TX); BOOST_CHECK(!consume_collateral); @@ -340,7 +340,7 @@ BOOST_AUTO_TEST_CASE(validation_uses_session_denom_snapshot) const int other_denom{CoinJoin::AmountToDenomination(CoinJoin::GetStandardDenominations().front())}; BOOST_REQUIRE(other_denom != session_denom); BOOST_CHECK(!InOutsChecker::IsValidInOuts(chainstate, isman, mempool, vin, vout, other_denom, - /*fV24Active=*/false, message, &consume_collateral)); + /*fAllowRebalanceShapes=*/false, message, &consume_collateral)); BOOST_CHECK_EQUAL(message, ERR_DENOM); BOOST_CHECK(consume_collateral); } diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index 8544e138af56..0b832bc01868 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -39,7 +39,6 @@ #include #include -#include #include #include #include From e6687d91b1d2b1b7596f1d343b93c52b550f9d99 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 18 Aug 2026 12:26:43 -0500 Subject: [PATCH 21/26] fix(coinjoin): tolerate arbitrary tip skew when validating a final transaction SignFinalTransaction allowed rebalance shapes only when V24 was active at our tip or would activate in the very next block. That one-block window is too narrow, because a wallet mixing 1:1 can be admitted to a session that already holds a rebalance participant: dsq does not advertise the session's capability, and the masternode admits standard participants on protocol version without consulting their activation state. A wallet two or more blocks behind at the boundary therefore applies pre-V24 rules to an honest unbalanced final transaction, fails the inputs-equal-outputs check, and refuses to sign - after which ChargeFees in POOL_STATE_SIGNING can select its collateral as a non-signer. Honest wallet, honest masternode, honest transaction, lost collateral. Use IsPromotionDemotionImminent here too, so the tolerance covers the whole window in which a masternode can legitimately be ahead of us rather than a single block. This is the same asymmetry already fixed on the DSTX relay path: the question is not whether V24 is active for us, but whether the peer that sent this could legitimately be past activation. The wallet still verifies its own inputs and outputs are present, that all amounts are valid denominations, that values balance with no fee, and that its side has cover, so nothing that protects it is relaxed. IsPromotionDemotionActive's fNextBlock parameter now has no caller passing true, so drop it. --- src/coinjoin/client.cpp | 13 +++++++------ src/coinjoin/coinjoin.cpp | 9 ++------- src/coinjoin/coinjoin.h | 5 +---- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/coinjoin/client.cpp b/src/coinjoin/client.cpp index b0da5efd1155..66c95ed442dc 100644 --- a/src/coinjoin/client.cpp +++ b/src/coinjoin/client.cpp @@ -467,10 +467,11 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ if (!mixingMasternode) return false; - // Evaluated before taking cs_wallet (IsPromotionDemotionActive locks cs_main). - // fNextBlock keeps this consistent with the final-tx leniency in IsValidInOuts: a - // masternode one block ahead at the V24 boundary must not get its final tx refused. - const bool fV24Active = CoinJoin::IsPromotionDemotionActive(active_chainstate.m_chainman, /*fNextBlock=*/true); + // Evaluated before taking cs_wallet (IsPromotionDemotionImminent locks cs_main). + // A session we joined for 1:1 mixing can still admit a rebalance participant, so the final + // tx may legitimately be unbalanced while our own tip is short of V24 - and refusing to + // sign risks our collateral. Tolerate the whole window a masternode can be ahead of us. + const bool fRebalanceShapesPossible = CoinJoin::IsPromotionDemotionImminent(active_chainstate.m_chainman); LOCK(m_wallet->cs_wallet); LOCK(cs_coinjoin); @@ -496,7 +497,7 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ PoolMessage nMessageID{MSG_NOERR}; CoinJoin::SessionDenomCounts denomCounts; if (!IsValidInOuts(active_chainstate, m_isman, mempool, finalMutableTransaction.vin, finalMutableTransaction.vout, - nSessionDenom, /*fAllowRebalanceShapes=*/fV24Active, nMessageID, nullptr, /*fFinalTx=*/true, &denomCounts)) { + nSessionDenom, /*fAllowRebalanceShapes=*/fRebalanceShapesPossible, nMessageID, nullptr, /*fFinalTx=*/true, &denomCounts)) { WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- ERROR! IsValidInOuts() failed: %s\n", __func__, CoinJoin::GetMessageByID(nMessageID).translated); UnlockCoins(); keyHolderStorage.ReturnAll(); @@ -561,7 +562,7 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ // promotion inputs, or our ten demotion outputs, to each other in plain sight. The server // never finalizes a session where a side has a lone occupant, so this never rejects an // honest final transaction. - if (fV24Active) { + if (fRebalanceShapesPossible) { const CAmount nSessionAmount = CoinJoin::DenominationToAmount(nSessionDenom); size_t nOwnSessionInputs{0}; size_t nOwnSessionOutputs{0}; diff --git a/src/coinjoin/coinjoin.cpp b/src/coinjoin/coinjoin.cpp index e1d4b9221a94..f459f7822544 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -240,17 +240,12 @@ std::string CCoinJoinBaseSession::GetStateString() const } } -bool CoinJoin::IsPromotionDemotionActive(const ChainstateManager& chainman, bool fNextBlock) +bool CoinJoin::IsPromotionDemotionActive(const ChainstateManager& chainman) { LOCK(::cs_main); const CBlockIndex* pindex = chainman.ActiveChain().Tip(); if (pindex == nullptr) return false; - // With fNextBlock the deployment counts as active if it will be active in the block - // following our tip. Clients use this when validating a final transaction so that a - // masternode whose tip is one block ahead at the V24 boundary doesn't get its valid - // unbalanced final tx refused - refusing to sign would cost the client its collateral. - return fNextBlock ? DeploymentActiveAfter(pindex, chainman, Consensus::DEPLOYMENT_V24) - : DeploymentActiveAt(*pindex, chainman, Consensus::DEPLOYMENT_V24); + return DeploymentActiveAt(*pindex, chainman, Consensus::DEPLOYMENT_V24); } bool CoinJoin::IsPromotionDemotionImminent(const ChainstateManager& chainman) diff --git a/src/coinjoin/coinjoin.h b/src/coinjoin/coinjoin.h index 3b6975ee5f66..497291492265 100644 --- a/src/coinjoin/coinjoin.h +++ b/src/coinjoin/coinjoin.h @@ -487,10 +487,7 @@ namespace CoinJoin constexpr CAmount GetMaxPoolAmount() { return COINJOIN_ENTRY_MAX_SIZE * vecStandardDenominations.front(); } /// Whether denomination promotion/demotion (post-V24) is active at the current chain tip. - /// With fNextBlock, additionally counts a deployment that activates in the block following - /// the tip - clients validating a final transaction use this to tolerate a masternode - /// whose tip is one block ahead around the activation boundary. - bool IsPromotionDemotionActive(const ChainstateManager& chainman, bool fNextBlock = false); + bool IsPromotionDemotionActive(const ChainstateManager& chainman); /// Whether the V24 deployment has locked in or activated as of our tip, i.e. peers whose /// tips are ahead of ours may already be applying post-V24 rules. Used to soften relay From f232f024953601ddc669d83dda53f14652abe988 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 18 Aug 2026 12:34:35 -0500 Subject: [PATCH 22/26] fix(coinjoin): keep the V24 tip-skew tolerance at one block Replaces the lock-in-based tolerance from the previous two commits. IsPromotionDemotionImminent treated the whole LOCKED_IN period as skew, but V24's nWindowSize is 4032 blocks -- roughly a week -- so what was described as a tip-skew tolerance was really a week-long amnesty for malformed DSTXes and unbalanced final transactions. Participants and masternodes in a live mixing session are within a block of each other in practice, so tolerate exactly that, consistently on both paths: a DSTX from a masternode one block ahead keeps the small PREMATURE penalty rather than the full structural one, and a final transaction that is unbalanced because we are one block short of activation is still signed. A node further behind than that takes the full penalty and risks the collateral -- at that point it has bigger problems than mixing. IsPromotionDemotionActive keeps its fNextBlock parameter, which both paths now use. --- src/coinjoin/client.cpp | 9 +++++---- src/coinjoin/coinjoin.cpp | 19 +++---------------- src/coinjoin/coinjoin.h | 11 +++++------ src/net_processing.cpp | 18 +++++++----------- 4 files changed, 20 insertions(+), 37 deletions(-) diff --git a/src/coinjoin/client.cpp b/src/coinjoin/client.cpp index 66c95ed442dc..da418a881cc4 100644 --- a/src/coinjoin/client.cpp +++ b/src/coinjoin/client.cpp @@ -467,11 +467,12 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ if (!mixingMasternode) return false; - // Evaluated before taking cs_wallet (IsPromotionDemotionImminent locks cs_main). + // Evaluated before taking cs_wallet (IsPromotionDemotionActive locks cs_main). // A session we joined for 1:1 mixing can still admit a rebalance participant, so the final - // tx may legitimately be unbalanced while our own tip is short of V24 - and refusing to - // sign risks our collateral. Tolerate the whole window a masternode can be ahead of us. - const bool fRebalanceShapesPossible = CoinJoin::IsPromotionDemotionImminent(active_chainstate.m_chainman); + // tx may legitimately be unbalanced while our own tip is one block short of V24 - refusing + // to sign it would cost us our collateral. Further behind than that is our own problem. + const bool fRebalanceShapesPossible = + CoinJoin::IsPromotionDemotionActive(active_chainstate.m_chainman, /*fNextBlock=*/true); LOCK(m_wallet->cs_wallet); LOCK(cs_coinjoin); diff --git a/src/coinjoin/coinjoin.cpp b/src/coinjoin/coinjoin.cpp index f459f7822544..f21877659ee1 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -240,26 +240,13 @@ std::string CCoinJoinBaseSession::GetStateString() const } } -bool CoinJoin::IsPromotionDemotionActive(const ChainstateManager& chainman) +bool CoinJoin::IsPromotionDemotionActive(const ChainstateManager& chainman, bool fNextBlock) { LOCK(::cs_main); const CBlockIndex* pindex = chainman.ActiveChain().Tip(); if (pindex == nullptr) return false; - return DeploymentActiveAt(*pindex, chainman, Consensus::DEPLOYMENT_V24); -} - -bool CoinJoin::IsPromotionDemotionImminent(const ChainstateManager& chainman) -{ - LOCK(::cs_main); - const CBlockIndex* pindex = chainman.ActiveChain().Tip(); - if (pindex == nullptr) return false; - // LOCKED_IN means the deployment activates at the end of the current signalling period, so - // from here on a peer whose tip is ahead of ours may already be past activation - however - // many blocks ahead it happens to be. ACTIVE covers the tip-one-block-behind case. Before - // lock-in no honest peer can be applying post-V24 rules. - const ThresholdState state = - chainman.m_versionbitscache.State(pindex, chainman.GetConsensus(), Consensus::DEPLOYMENT_V24); - return state == ThresholdState::LOCKED_IN || state == ThresholdState::ACTIVE; + return fNextBlock ? DeploymentActiveAfter(pindex, chainman, Consensus::DEPLOYMENT_V24) + : DeploymentActiveAt(*pindex, chainman, Consensus::DEPLOYMENT_V24); } bool CCoinJoinBaseSession::IsValidInOuts(Chainstate& active_chainstate, const llmq::CInstantSendManager& isman, diff --git a/src/coinjoin/coinjoin.h b/src/coinjoin/coinjoin.h index 497291492265..5bc042dc4e3d 100644 --- a/src/coinjoin/coinjoin.h +++ b/src/coinjoin/coinjoin.h @@ -487,12 +487,11 @@ namespace CoinJoin constexpr CAmount GetMaxPoolAmount() { return COINJOIN_ENTRY_MAX_SIZE * vecStandardDenominations.front(); } /// Whether denomination promotion/demotion (post-V24) is active at the current chain tip. - bool IsPromotionDemotionActive(const ChainstateManager& chainman); - - /// Whether the V24 deployment has locked in or activated as of our tip, i.e. peers whose - /// tips are ahead of ours may already be applying post-V24 rules. Used to soften relay - /// penalties around the activation boundary, where our own tip may lag by several blocks. - bool IsPromotionDemotionImminent(const ChainstateManager& chainman); + /// With fNextBlock, also counts a deployment that activates in the block following the tip: + /// the peer that sent us a rebalance-shaped transaction may be one block ahead of us at the + /// activation boundary, and treating its transaction as malformed would cost us either a + /// discouraged peer or, when signing, our collateral. + bool IsPromotionDemotionActive(const ChainstateManager& chainman, bool fNextBlock = false); /// If the collateral is valid given by a client bool IsCollateralValid(ChainstateManager& chainman, const llmq::CInstantSendManager& isman, diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 80c7a8a07f6f..8d8e4c7e201e 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -3557,17 +3557,13 @@ static DSTXValidationResult ValidateDSTX(CDeterministicMNManager& dmnman, CDSTXM bool fPossiblyValidPostV24{false}; if (!dstx.IsValidStructure(pindex, chainman, &fPossiblyValidPostV24)) { LogPrint(BCLog::COINJOIN, "DSTX -- Invalid DSTX structure: %s\n", hashTx.ToString()); - // fPossiblyValidPostV24 means the tx is well-formed under post-V24 rules while we are - // still pre-V24, i.e. it most likely comes from a peer whose tip has already crossed - // the activation boundary while ours has not. Tolerating only a one-block lookahead - // would leave a node a couple of blocks behind at activation handing the full penalty - // to every honest relayer, discouraging its peers just as it is trying to catch up. - // Tolerate from lock-in onwards instead, which is the whole window in which a peer can - // legitimately be ahead of us; before that no honest peer applies post-V24 rules, so - // such a tx keeps the full penalty like anything else malformed. Like the 24-block-deep - // masternode scan below this is a tip-skew tolerance, and PREMATURE still accumulates, - // so a peer genuinely flooding us is discouraged regardless. - if (fPossiblyValidPostV24 && CoinJoin::IsPromotionDemotionImminent(chainman)) { + // A tx that is structurally valid under post-V24 rules may come from a masternode whose + // tip is one block ahead of ours at the activation boundary. Like the 24-block-deep + // masternode scan below, tolerate that much skew: drop it with only a small penalty so + // honest relayers are unaffected while a peer flooding us still gets discouraged + // eventually. Beyond one block we treat it as malformed and take the full penalty; a + // node that far behind at activation has bigger problems than mixing. + if (fPossiblyValidPostV24 && CoinJoin::IsPromotionDemotionActive(chainman, /*fNextBlock=*/true)) { return {DSTXValidationScore::PREMATURE, true}; } return {DSTXValidationScore::INVALID, true}; From 123858d9a2461176160a51d24481fcbb3bff01f7 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Mon, 17 Aug 2026 17:34:42 +0300 Subject: [PATCH 23/26] fix(coinjoin): don't burn queue announcements a rebalance pass skips GetQueueItemAndTry() marks a queue tried before handing it out, and it only ever hands out a queue once. JoinExistingQueue() then dropped queues whose denomination didn't match the promotion/demotion target after they had already been marked, so a single rebalance pass consumed every pending announcement: the following demotion attempt, the remaining denomination pairs and the standard JoinExistingQueue() call all saw an empty list and fell back to StartNewQueue(). Move the denomination filter into GetQueueItemAndTry() so a queue it skips is left untried and fTried keeps meaning "we actually tried this queue". --- src/coinjoin/client.cpp | 11 +++-------- src/coinjoin/client.h | 2 +- src/coinjoin/coinjoin.cpp | 4 +++- src/coinjoin/coinjoin.h | 4 +++- src/test/coinjoin_queue_tests.cpp | 33 +++++++++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/coinjoin/client.cpp b/src/coinjoin/client.cpp index da418a881cc4..80498362a603 100644 --- a/src/coinjoin/client.cpp +++ b/src/coinjoin/client.cpp @@ -1403,7 +1403,7 @@ bool CCoinJoinClientSession::JoinExistingQueue(CAmount nBalanceNeedsAnonymized, // Look through the queues and see if anything matches CCoinJoinQueue dsq; - while (m_clientman.GetQueueItemAndTry(dsq)) { + while (m_clientman.GetQueueItemAndTry(dsq, fRebalance ? nTargetDenom : 0)) { auto dmn = mnList.GetValidMNByCollateral(dsq.masternodeOutpoint); if (!dmn) { @@ -1423,11 +1423,6 @@ bool CCoinJoinClientSession::JoinExistingQueue(CAmount nBalanceNeedsAnonymized, WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- trying queue: %s\n", dsq.ToString()); - // For promotion/demotion, we need a queue with the target denomination - if ((fPromotion || fDemotion) && nTargetDenom != 0 && dsq.nDenom != nTargetDenom) { - continue; // Skip queues with wrong denomination - } - std::vector vecTxDSInTmp; if (!fRebalance) { @@ -1737,9 +1732,9 @@ bool CCoinJoinClientManager::MarkAlreadyJoinedQueueAsTried(CCoinJoinQueue& dsq) return false; } -bool CCoinJoinClientManager::GetQueueItemAndTry(CCoinJoinQueue& dsq) const +bool CCoinJoinClientManager::GetQueueItemAndTry(CCoinJoinQueue& dsq, int nDenomFilter) const { - return m_queueman && m_queueman->GetQueueItemAndTry(dsq); + return m_queueman && m_queueman->GetQueueItemAndTry(dsq, nDenomFilter); } bool CCoinJoinClientSession::SubmitDenominate(CConnman& connman) diff --git a/src/coinjoin/client.h b/src/coinjoin/client.h index 30b8016bbd6b..0bc68d893501 100644 --- a/src/coinjoin/client.h +++ b/src/coinjoin/client.h @@ -250,7 +250,7 @@ class CCoinJoinClientManager : public interfaces::CoinJoin::Client bool TrySubmitDenominate(const uint256& proTxHash, CConnman& connman) EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); bool MarkAlreadyJoinedQueueAsTried(CCoinJoinQueue& dsq) const EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); - bool GetQueueItemAndTry(CCoinJoinQueue& dsq) const; + bool GetQueueItemAndTry(CCoinJoinQueue& dsq, int nDenomFilter = 0) const; void CheckTimeout() EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); diff --git a/src/coinjoin/coinjoin.cpp b/src/coinjoin/coinjoin.cpp index f21877659ee1..7293755d014e 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -206,7 +206,7 @@ bool CoinJoinQueueManager::TryAddQueue(CCoinJoinQueue dsq) return true; } -bool CoinJoinQueueManager::GetQueueItemAndTry(CCoinJoinQueue& dsqRet) +bool CoinJoinQueueManager::GetQueueItemAndTry(CCoinJoinQueue& dsqRet, int nDenomFilter) { TRY_LOCK(cs_vecqueue, lockDS); if (!lockDS) return false; // it's ok to fail here, we run this quite frequently @@ -214,6 +214,8 @@ bool CoinJoinQueueManager::GetQueueItemAndTry(CCoinJoinQueue& dsqRet) for (auto& dsq : vecCoinJoinQueue) { // only try each queue once if (dsq.fTried || dsq.IsTimeOutOfBounds()) continue; + // skip before marking as tried: a queue we never looked at must stay available + if (nDenomFilter != 0 && dsq.nDenom != nDenomFilter) continue; dsq.fTried = true; dsqRet = dsq; return true; diff --git a/src/coinjoin/coinjoin.h b/src/coinjoin/coinjoin.h index 5bc042dc4e3d..d6fa530274a6 100644 --- a/src/coinjoin/coinjoin.h +++ b/src/coinjoin/coinjoin.h @@ -440,7 +440,9 @@ class CoinJoinQueueManager void CheckQueue() EXCLUSIVE_LOCKS_REQUIRED(!cs_vecqueue); int GetQueueSize() const EXCLUSIVE_LOCKS_REQUIRED(!cs_vecqueue) { LOCK(cs_vecqueue); return vecCoinJoinQueue.size(); } - bool GetQueueItemAndTry(CCoinJoinQueue& dsqRet) EXCLUSIVE_LOCKS_REQUIRED(!cs_vecqueue); + //! nDenomFilter != 0 restricts the search to that denomination. Queues it skips are left + //! untried, so a rebalance attempt doesn't consume the announcements standard mixing needs. + bool GetQueueItemAndTry(CCoinJoinQueue& dsqRet, int nDenomFilter = 0) EXCLUSIVE_LOCKS_REQUIRED(!cs_vecqueue); bool HasQueue(const uint256& queueHash) EXCLUSIVE_LOCKS_REQUIRED(!cs_vecqueue) { diff --git a/src/test/coinjoin_queue_tests.cpp b/src/test/coinjoin_queue_tests.cpp index b4e89e4c717a..3440c8a1fd36 100644 --- a/src/test/coinjoin_queue_tests.cpp +++ b/src/test/coinjoin_queue_tests.cpp @@ -172,6 +172,39 @@ BOOST_AUTO_TEST_CASE(queuemanager_getqueueitem_marks_tried_once) BOOST_CHECK(!man.GetQueueItemAndTry(picked2)); } +BOOST_AUTO_TEST_CASE(queuemanager_getqueueitem_denom_filter_leaves_others_untried) +{ + CoinJoinQueueManager man; + const int denom_small = CoinJoin::AmountToDenomination(CoinJoin::GetSmallestDenomination()); + const int denom_other = CoinJoin::AmountToDenomination(CoinJoin::GetSmallestDenomination() * 10); + BOOST_REQUIRE(CoinJoin::IsValidDenomination(denom_small)); + BOOST_REQUIRE(CoinJoin::IsValidDenomination(denom_other)); + + const auto now{GetAdjustedTime()}; + man.AddQueue(MakeQueue(denom_other, now, /*fReady=*/false, COutPoint(uint256S("51"), 0))); + man.AddQueue(MakeQueue(denom_small, now, /*fReady=*/false, COutPoint(uint256S("52"), 0))); + + // A filtered search returns a queue of the requested denomination + CCoinJoinQueue picked; + BOOST_REQUIRE(man.GetQueueItemAndTry(picked, denom_small)); + BOOST_CHECK_EQUAL(picked.nDenom, denom_small); + + // ...which is now tried, so asking again for it finds nothing + CCoinJoinQueue picked_again; + BOOST_CHECK(!man.GetQueueItemAndTry(picked_again, denom_small)); + + // The queue the filter skipped was left untried, so an unfiltered search still finds it. + // Marking it tried here would starve standard mixing of every announcement a rebalance + // attempt looked past. + CCoinJoinQueue picked_unfiltered; + BOOST_REQUIRE(man.GetQueueItemAndTry(picked_unfiltered)); + BOOST_CHECK_EQUAL(picked_unfiltered.nDenom, denom_other); + + // Both have been tried by now + CCoinJoinQueue picked_none; + BOOST_CHECK(!man.GetQueueItemAndTry(picked_none)); +} + BOOST_AUTO_TEST_CASE(queuemanager_has_queue_from_masternode_readiness) { CoinJoinQueueManager man; From 26d0a987b42168c4288c051ebebf0013ebf73f5f Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Mon, 17 Aug 2026 17:35:33 +0300 Subject: [PATCH 24/26] fix(coinjoin): tell participants when an uncovered session is reset CheckPool() dropped a session whose entries are all in but leave a side of the session denomination uncovered without notifying anyone, unlike every other path that abandons a session. Participants kept their inputs locked and their collateral committed until their own timeout expired. Relay ERR_SESSION first, which makes the clients return their keys, unlock their coins and reset immediately. It has to precede SetNull(), which clears the entries the relay iterates. --- src/coinjoin/server.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/coinjoin/server.cpp b/src/coinjoin/server.cpp index 0e90cc310dd6..c6d588a2da80 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -321,6 +321,10 @@ void CCoinJoinServer::CheckPool() // timeout. Shouldn't happen: admission checks the declared shapes up front. LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- all entries received but a session denom side is uncovered (%d in, %d out), resetting session\n", sides.inputs, sides.outputs); + // Tell the participants before dropping them, so they release their inputs and collateral + // right away instead of waiting out their own timeout. Must precede SetNull(), which + // clears the entries this iterates. + RelayCompletedTransaction(ERR_SESSION); WITH_LOCK(cs_coinjoin, SetNull()); return; } From adb2765551d275cec1c725f02b943e543e6b7982 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Tue, 18 Aug 2026 21:07:00 +0300 Subject: [PATCH 25/26] fix(coinjoin): bound the sides of a post-V24 DSTX against each other Post-V24 IsValidStructure() only capped the input and output counts independently, putting no relation between them. Since the denominated-output check passes vacuously on an empty vout, a DSTX with 200 inputs and no outputs passed a gate that previously required equal counts, and was then stored and relayed. IsValidInOuts() does not run on DSTXes received from the network, so nothing else caught it. Add the relations a transaction composable from valid entries must satisfy, neither of which needs UTXO access. Neither side may exceed PROMOTION_RATIO times the other, since an all-promotion transaction carries at most PROMOTION_RATIO inputs per output and an all-demotion one the mirror. And with p promotions, d demotions and standard entries contributing equally to both sides, inputs - outputs is exactly (PROMOTION_RATIO - 1) * (p - d), so the difference between the sides is a multiple of that step. Balanced transactions above the pre-V24 count cap stay valid. A rebalance session caps every entry at PROMOTION_RATIO coins per side, standard ones included, so one promotion, one demotion and eighteen standard entries at that size come to 191 inputs and 191 outputs. Peers below COINJOIN_REBALANCE_VERSION cannot accept that, which is why CanAnnounceDstxTo withholds it from them rather than IsValidStructure rejecting it. Existing cases that stood in for a promotion shape by dropping an output relied on the looser rule: 3 inputs and 2 outputs is not composable from any session, so it is now rejected regardless of where the tip sits relative to activation. p2p_dstx.py builds a real promotion shape instead - one promotion plus two standard entries, 12 inputs and 3 outputs - which keeps all three of its unbalanced cases meaningful, including the post-activation one that expects the transaction to be structurally valid and reach the masternode lookup. output_limit_postfork asserted 30 inputs and 200 outputs would be valid post-V24, whose difference of 170 is not a multiple of PROMOTION_RATIO - 1; it now uses the input count of an all-demotion session at the output cap, and scales that shape past the cap for the rejection it means to test. --- src/coinjoin/coinjoin.cpp | 16 +++++-- src/test/coinjoin_inouts_tests.cpp | 73 ++++++++++++++++++++++++++++-- test/functional/p2p_dstx.py | 22 +++++---- 3 files changed, 95 insertions(+), 16 deletions(-) diff --git a/src/coinjoin/coinjoin.cpp b/src/coinjoin/coinjoin.cpp index 7293755d014e..5f104b853e71 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -127,9 +127,19 @@ bool CCoinJoinBroadcastTx::IsValidStructure(const CBlockIndex* pindex, const Cha const size_t nMaxInputsPreV24 = CoinJoin::GetMaxPoolParticipants() * COINJOIN_ENTRY_MAX_SIZE; const size_t nMaxInOutsPostV24 = CoinJoin::GetMaxPoolParticipants() * CoinJoin::PROMOTION_RATIO; const bool fValidPreV24 = tx->vin.size() == tx->vout.size() && tx->vin.size() <= nMaxInputsPreV24; - // Post-V24 the input/output counts no longer bound each other, so cap them independently; - // demotion entries produce up to PROMOTION_RATIO outputs each, mirroring promotion inputs - const bool fValidPostV24 = tx->vin.size() <= nMaxInOutsPostV24 && tx->vout.size() <= nMaxInOutsPostV24; + // Post-V24 the input/output counts no longer bound each other one-to-one, but they still + // bound each other: an all-promotion transaction carries at most PROMOTION_RATIO inputs per + // output and an all-demotion one the mirror image, so neither side may exceed PROMOTION_RATIO + // times the other. Beyond that, each promotion contributes PROMOTION_RATIO - 1 more inputs + // than outputs and each demotion the mirror, while standard entries contribute equally to + // both sides, so the difference between the sides of a transaction composable from valid + // entries is always a multiple of that step. + const size_t nSideDiff = tx->vin.size() > tx->vout.size() ? tx->vin.size() - tx->vout.size() + : tx->vout.size() - tx->vin.size(); + const bool fValidPostV24 = tx->vin.size() <= nMaxInOutsPostV24 && tx->vout.size() <= nMaxInOutsPostV24 && + tx->vin.size() <= tx->vout.size() * static_cast(CoinJoin::PROMOTION_RATIO) && + tx->vout.size() <= tx->vin.size() * static_cast(CoinJoin::PROMOTION_RATIO) && + nSideDiff % static_cast(CoinJoin::PROMOTION_RATIO - 1) == 0; const bool fV24Active = pindex && DeploymentActiveAt(*pindex, chainman, Consensus::DEPLOYMENT_V24); if (fV24Active) { diff --git a/src/test/coinjoin_inouts_tests.cpp b/src/test/coinjoin_inouts_tests.cpp index 1261022f5c0b..c3c2d90b84fc 100644 --- a/src/test/coinjoin_inouts_tests.cpp +++ b/src/test/coinjoin_inouts_tests.cpp @@ -903,15 +903,17 @@ BOOST_AUTO_TEST_CASE(output_limit_postfork) return dstx; }; - // Unbalanced but within both caps: rejected pre-V24, possibly valid post-V24 + // Outputs at the cap, inputs the matching count for an all-demotion session: rejected + // pre-V24, possibly valid post-V24 bool fPossiblyValidPostV24{false}; - const auto withinCaps = MakeUnbalancedTx(30, nMaxPostFork); + const auto withinCaps = MakeUnbalancedTx(nMaxPostFork / CoinJoin::PROMOTION_RATIO, nMaxPostFork); BOOST_CHECK(!withinCaps.IsValidStructure(nullptr, *Assert(m_node.chainman), &fPossiblyValidPostV24)); BOOST_CHECK(fPossiblyValidPostV24); - // One output over the cap: rejected under either ruleset + // The same shape scaled past the cap: rejected under either ruleset, and by the cap alone - + // it satisfies the input/output count relation (see isvalidstructure_postv24_count_relations) fPossiblyValidPostV24 = true; - const auto tooManyOutputs = MakeUnbalancedTx(30, nMaxPostFork + 1); + const auto tooManyOutputs = MakeUnbalancedTx(nMaxPostFork / CoinJoin::PROMOTION_RATIO + 1, nMaxPostFork + 10); BOOST_CHECK(!tooManyOutputs.IsValidStructure(nullptr, *Assert(m_node.chainman), &fPossiblyValidPostV24)); BOOST_CHECK(!fPossiblyValidPostV24); } @@ -1159,6 +1161,69 @@ BOOST_AUTO_TEST_CASE(isvalidstructure_boundary_input_counts) // Note: Testing post-fork behavior requires a CBlockIndex with V24 active via EHF } +BOOST_AUTO_TEST_CASE(isvalidstructure_postv24_count_relations) +{ + // The post-V24 branch needs a V24-active CBlockIndex, which this fixture has no chain for. + // Its verdict is observable all the same: on a pre-V24 tip IsValidStructure reports + // fPossiblyValidPostV24 exactly when a transaction fails the pre-V24 rules but passes the + // post-V24 ones, so for any shape that is invalid pre-V24 the flag *is* the post-V24 verdict. + // Every shape below is invalid pre-V24 - unbalanced, or balanced above the pre-V24 cap. + const size_t nMaxInOuts = static_cast(CoinJoin::GetMaxPoolParticipants()) * CoinJoin::PROMOTION_RATIO; + const size_t nPreV24Cap = static_cast(CoinJoin::GetMaxPoolParticipants()) * COINJOIN_ENTRY_MAX_SIZE; + + auto valid_post_v24 = [this](size_t nIn, size_t nOut) { + CMutableTransaction mtx; + for (size_t i = 0; i < nIn; ++i) { + mtx.vin.emplace_back(COutPoint(uint256::ONE, static_cast(i))); + } + for (size_t i = 0; i < nOut; ++i) { + mtx.vout.emplace_back(CoinJoin::GetSmallestDenomination(), P2PKHScript(static_cast(i % 256))); + } + CCoinJoinBroadcastTx dstx; + dstx.tx = MakeTransactionRef(mtx); + dstx.m_protxHash = uint256::ONE; + + bool fPossiblyValidPostV24{false}; + BOOST_REQUIRE(!dstx.IsValidStructure(nullptr, *Assert(m_node.chainman), &fPossiblyValidPostV24)); + return fPossiblyValidPostV24; + }; + + // Shapes a session can actually produce: p promotions and d demotions over standard entries + BOOST_CHECK(valid_post_v24(100, 10)); // 10 promotions + BOOST_CHECK(valid_post_v24(10, 100)); // 10 demotions + BOOST_CHECK(valid_post_v24(19, 10)); // 1 promotion + 9 standard 1:1 + BOOST_CHECK(valid_post_v24(nMaxInOuts, nMaxInOuts / 10)); // promotions at the cap + BOOST_CHECK(valid_post_v24(nMaxInOuts / 10, nMaxInOuts)); // demotions at the cap + + // Neither side may exceed PROMOTION_RATIO times the other, even when the difference between + // them is a legal multiple of PROMOTION_RATIO - 1 (36 and 36 here) + BOOST_CHECK(!valid_post_v24(39, 3)); + BOOST_CHECK(!valid_post_v24(3, 39)); + + // Every promotion adds PROMOTION_RATIO - 1 more inputs than outputs and every demotion the + // mirror, so a difference that is not a multiple of that step is not composable from entries + BOOST_CHECK(!valid_post_v24(4, 3)); + BOOST_CHECK(!valid_post_v24(199, 20)); + + // Either side over its own cap, with ratio and step both satisfied + BOOST_CHECK(!valid_post_v24(nMaxInOuts + 10, nMaxInOuts / 10 + 1)); + BOOST_CHECK(!valid_post_v24(nMaxInOuts / 10 + 1, nMaxInOuts + 10)); + + // No outputs at all: the denominated-output check above passes vacuously, so the count + // relation is what has to reject it + BOOST_CHECK(!valid_post_v24(3, 0)); + + // A balanced transaction above the pre-V24 cap is composable post-V24 and must stay valid: + // every entry of a rebalance session may carry PROMOTION_RATIO coins, so one promotion, one + // demotion and GetMaxPoolParticipants() - 2 standard entries at that size come to + // nMaxInOuts - (PROMOTION_RATIO - 1) on both sides. It exceeds the count peers below + // COINJOIN_REBALANCE_VERSION accept, which is why CanAnnounceDstxTo withholds it from them + // rather than IsValidStructure rejecting it. + const size_t nMaxBalanced = nMaxInOuts - (CoinJoin::PROMOTION_RATIO - 1); + BOOST_REQUIRE(nMaxBalanced > nPreV24Cap); + BOOST_CHECK(valid_post_v24(nMaxBalanced, nMaxBalanced)); +} + // ============================================================================ // Tests for the ShouldPromoteDenoms/ShouldDemoteDenoms decision logic that // CCoinJoinClientManager::ShouldPromote/ShouldDemote dispatch to diff --git a/test/functional/p2p_dstx.py b/test/functional/p2p_dstx.py index b9192c7a3cc3..5814037f518d 100755 --- a/test/functional/p2p_dstx.py +++ b/test/functional/p2p_dstx.py @@ -60,7 +60,7 @@ def set_test_params(self): f"-vbparams=v24:0:999999999999:{V24_ACTIVATION_HEIGHT}:10:8:6:5:0", ]] - def make_dstx(self, nonce=0): + def make_dstx(self, nonce=0, promotion=False): tx = CTransaction() # The nonce flows into one of the prevouts so each DSTX has a distinct # txid (and therefore is not deduped by dstxman.GetDSTX). @@ -75,6 +75,13 @@ def make_dstx(self, nonce=0): # CoinJoin::IsDenominatedAmount requires a recognised denom; the # smallest denom is 0.001 DASH + 0.0000001 fee == COIN//1000 + 1. tx.vout = [CTxOut(nValue=COIN // 1000 + 1, scriptPubKey=p2pkh) for _ in tx.vin] + if promotion: + # A promotion spends PROMOTION_RATIO (10) inputs for a single output, so the two sides + # of a transaction composable from valid entries differ by a multiple of + # PROMOTION_RATIO - 1. This turns one of the three standard 1:1 entries above into a + # promotion, giving 12 inputs and 3 outputs. An arbitrary unbalanced shape, such as + # simply dropping an output, is structurally invalid under either ruleset. + tx.vin += [CTxIn(COutPoint(hash=(nonce << 8) | (i + 4), n=0)) for i in range(9)] return CCoinJoinBroadcastTx( tx=tx, m_protxHash=1, @@ -112,11 +119,10 @@ def run_test(self): peer_invalid.send_and_ping(msg_dstx(bad)) self.log.info("Unbalanced DSTX far from the V24 boundary => full (+%d) penalty", INVALID_DSTX_SCORE) - # An unbalanced input/output count is only valid post-V24. Far from the activation - # boundary no honest relayer produces one, so it gets the full structural penalty. + # A promotion shape is only valid post-V24. Far from the activation boundary no honest + # relayer produces one, so it gets the full structural penalty. peer_unbalanced = node.add_p2p_connection(P2PInterface()) - unbalanced = self.make_dstx(nonce=3) - unbalanced.tx.vout.pop() # vin.size() != vout.size() is a promotion shape + unbalanced = self.make_dstx(nonce=3, promotion=True) with node.assert_debug_log([ "Invalid DSTX structure", "Misbehaving", @@ -137,8 +143,7 @@ def run_test(self): # so its DSTX is rejected with the same tolerant penalty an unverifiable masternode # gets rather than the full structural penalty. peer_premature = node.add_p2p_connection(P2PInterface()) - premature = self.make_dstx(nonce=4) - premature.tx.vout.pop() + premature = self.make_dstx(nonce=4, promotion=True) with node.assert_debug_log([ "Invalid DSTX structure", "Misbehaving", @@ -150,8 +155,7 @@ def run_test(self): self.log.info("Unbalanced DSTX once V24 is active => structurally valid, unknown-MN path") self.generate(node, 1) # v24 rules now apply at the tip itself peer_active = node.add_p2p_connection(P2PInterface()) - active = self.make_dstx(nonce=5) - active.tx.vout.pop() + active = self.make_dstx(nonce=5, promotion=True) with node.assert_debug_log([ "Can't find masternode", "Misbehaving", From d1fca890edaedce57c52336860f59235ca0b73a0 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 18 Aug 2026 20:00:58 -0500 Subject: [PATCH 26/26] fix(coinjoin): decide the pool state from one consistent snapshot CheckPool() sampled the session a piece at a time: GetMixSideCounts() and GetEntriesCount() each took and released cs_coinjoin separately, and vecSessionCollaterals.size() was read with no lock at all. Since CheckPool() runs on the scheduler thread while the message-handling thread appends entries, the side counts could be read before the last entry arrived and the entry count after it. The result describes no state the session was ever in: all entries accounted for, but a side of the session denomination apparently uncovered. CheckPool() then took that as the unreachable case and reset a session that was covered and about to finalize. The unlocked collateral read could also race SetNull() clearing the vector. Take session id, state, entry count, collateral count and side counts under a single lock, and decide from that snapshot. The actions themselves run without the lock, so the two that mutate the session -- finalizing and resetting -- revalidate that the snapshot still describes the live session before acting, since it may have timed out and been replaced in the meantime. --- src/coinjoin/coinjoin.h | 4 ++++ src/coinjoin/server.cpp | 50 ++++++++++++++++++++++++++++++----------- src/coinjoin/server.h | 15 ++++++++++++- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/coinjoin/coinjoin.h b/src/coinjoin/coinjoin.h index d6fa530274a6..637fdaec0bcf 100644 --- a/src/coinjoin/coinjoin.h +++ b/src/coinjoin/coinjoin.h @@ -417,6 +417,10 @@ class CCoinJoinBaseSession CoinJoin::MixSideCounts GetMixSideCounts() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) { LOCK(cs_coinjoin); + return GetMixSideCountsLocked(); + } + CoinJoin::MixSideCounts GetMixSideCountsLocked() const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin) + { CoinJoin::MixSideCounts counts; for (const auto& entry : vecEntries) { counts.Add(entry.GetMixShape()); diff --git a/src/coinjoin/server.cpp b/src/coinjoin/server.cpp index c6d588a2da80..15d0e9961a5c 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -301,60 +301,84 @@ void CCoinJoinServer::SetNull() // void CCoinJoinServer::CheckPool() { - if (int entries = GetEntriesCount(); entries != 0) - LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- entries count %lu\n", entries); + // Every decision below reads several pieces of session state at once, so take them as one + // snapshot. Sampling them separately let the message-handling thread commit an entry + // between two reads: the side counts could be read before the last entry arrived while the + // entry count was read after it, so a session that was about to finalize looked like one + // whose entries were all in but left a side uncovered - and got reset. Reading + // vecSessionCollaterals without the lock could also race SetNull() clearing it. + const auto snap = GetPoolSnapshot(); + + if (snap.entries != 0) + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- entries count %lu\n", snap.entries); // PRIVACY: a final transaction is only worth publishing when each side of the session // denomination is occupied by nobody or by at least two participants; a lone participant // on a side would have the only coins of that size there and be trivially identifiable. - const auto sides = GetMixSideCounts(); // If we have an entry for each collateral, then create final tx - if (nState == POOL_STATE_ACCEPTING_ENTRIES && static_cast(GetEntriesCount()) == vecSessionCollaterals.size()) { - if (sides.IsCovered()) { + if (snap.state == POOL_STATE_ACCEPTING_ENTRIES && snap.entries == snap.collaterals) { + if (snap.sides.IsCovered()) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n"); - CreateFinalTransaction(); + CreateFinalTransaction(snap.session_id); return; } // The participant set is frozen once entries are being accepted, so this session can // never gain the missing counterparty - reset instead of stalling everyone until // timeout. Shouldn't happen: admission checks the declared shapes up front. LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- all entries received but a session denom side is uncovered (%d in, %d out), resetting session\n", - sides.inputs, sides.outputs); + snap.sides.inputs, snap.sides.outputs); // Tell the participants before dropping them, so they release their inputs and collateral // right away instead of waiting out their own timeout. Must precede SetNull(), which // clears the entries this iterates. RelayCompletedTransaction(ERR_SESSION); - WITH_LOCK(cs_coinjoin, SetNull()); + // Only drop the session the snapshot described; the message-handling thread may have + // reset and restarted one while we were relaying. + WITH_LOCK(cs_coinjoin, if (IsCurrentSession(snap.session_id)) SetNull()); return; } // Check for Time Out // If we timed out while accepting entries, then if we have more than minimum, create final tx - if (nState == POOL_STATE_ACCEPTING_ENTRIES && CCoinJoinServer::HasTimedOut() && sides.IsCovered() && - GetEntriesCount() >= CoinJoin::GetMinPoolParticipants()) { + if (snap.state == POOL_STATE_ACCEPTING_ENTRIES && CCoinJoinServer::HasTimedOut() && snap.sides.IsCovered() && + snap.entries >= static_cast(CoinJoin::GetMinPoolParticipants())) { // Punish misbehaving participants ChargeFees(); // Try to complete this session ignoring the misbehaving ones - CreateFinalTransaction(); + CreateFinalTransaction(snap.session_id); return; } // If we have all the signatures, try to compile the transaction - if (nState == POOL_STATE_SIGNING && IsSignaturesComplete()) { + if (snap.state == POOL_STATE_SIGNING && IsSignaturesComplete()) { LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- SIGNING\n"); CommitFinalTransaction(); return; } } -void CCoinJoinServer::CreateFinalTransaction() +CCoinJoinServer::PoolSnapshot CCoinJoinServer::GetPoolSnapshot() const +{ + AssertLockNotHeld(cs_coinjoin); + LOCK(cs_coinjoin); + return PoolSnapshot{nSessionID, nState, vecEntries.size(), vecSessionCollaterals.size(), + GetMixSideCountsLocked()}; +} + +void CCoinJoinServer::CreateFinalTransaction(int session_id) { AssertLockNotHeld(cs_coinjoin); LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CreateFinalTransaction -- FINALIZE TRANSACTIONS\n"); LOCK(cs_coinjoin); + // The decision to finalize came from a snapshot taken before this lock, so make sure it + // still describes the live session - it may have timed out and been replaced in between. + if (!IsCurrentSession(session_id)) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CreateFinalTransaction -- session changed, not finalizing\n"); + return; + } + CMutableTransaction txNew; // make our new transaction diff --git a/src/coinjoin/server.h b/src/coinjoin/server.h index 078afb2cf6a7..080aae76716f 100644 --- a/src/coinjoin/server.h +++ b/src/coinjoin/server.h @@ -96,13 +96,26 @@ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler /// Is session_id still the session we are accepting entries for? bool IsCurrentSession(int session_id) const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin); + + //! One consistent view of the session, for decisions that read several pieces of its state. + //! Sampling them one at a time lets the message-handling thread commit an entry in between, + //! producing a mix of old and new values that describes no state the session was ever in. + struct PoolSnapshot { + int session_id{0}; + PoolState state{POOL_STATE_IDLE}; + size_t entries{0}; + size_t collaterals{0}; + CoinJoin::MixSideCounts sides; + }; + PoolSnapshot GetPoolSnapshot() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); /// Is txref one of the collaterals accepted into the current session? bool HasSessionCollateral(const CTransactionRef& txref) const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin); /// Check for process void CheckPool(); - void CreateFinalTransaction() EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); + /// Build and relay the final transaction, unless session_id is no longer the live session + void CreateFinalTransaction(int session_id) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); void CommitFinalTransaction() EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); /// Is this nDenom and txCollateral acceptable?