diff --git a/doc/release-notes-7052.md b/doc/release-notes-7052.md new file mode 100644 index 000000000000..fd611b543701 --- /dev/null +++ b/doc/release-notes-7052.md @@ -0,0 +1,45 @@ +P2P and network changes +----------------------- + +- The protocol version was bumped to 70241. The `dsa` message gained a + version-gated flags field declaring which mixing direction a + 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 + 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 +------ + +- 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) + +- 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 de82104b8910..80498362a603 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); @@ -452,6 +467,13 @@ bool CCoinJoinClientSession::SignFinalTransaction(CNode& peer, Chainstate& activ if (!mixingMasternode) return false; + // 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 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); @@ -474,8 +496,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)) { + 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(); @@ -533,6 +556,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 (fRebalanceShapesPossible) { + 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; @@ -916,7 +971,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 +1011,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 +1043,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 +1149,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,16 +1304,106 @@ 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 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)) { + 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; // 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) { @@ -1208,10 +1425,12 @@ bool CCoinJoinClientSession::JoinExistingQueue(CAmount nBalanceNeedsAnonymized, 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); @@ -1224,18 +1443,30 @@ 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(); - 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 +1554,95 @@ 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); + // 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(); + + // 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,11 +1653,21 @@ 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; } - 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()); @@ -1345,11 +1675,20 @@ 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()) { WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::%s -- failed to connect, masternode=%s\n", __func__, pendingDsaRequest.GetProTxHash().ToString()); + UnlockCoins(); WITH_LOCK(cs_coinjoin, SetNull()); } @@ -1393,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) @@ -1403,9 +1742,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 +1893,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 +2490,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], + counts.fully_mixed[idxLarger], CCoinJoinClientOptions::GetDenomsGoal()); +} + diff --git a/src/coinjoin/client.h b/src/coinjoin/client.h index 30eaa0d9dbe4..0bc68d893501 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); @@ -226,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); @@ -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..5f104b853e71 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -88,24 +89,69 @@ 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 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) { + 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() @@ -170,7 +216,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 @@ -178,6 +224,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; @@ -204,27 +252,91 @@ std::string CCoinJoinBaseSession::GetStateString() const } } +bool CoinJoin::IsPromotionDemotionActive(const ChainstateManager& chainman, bool fNextBlock) +{ + LOCK(::cs_main); + const CBlockIndex* pindex = chainman.ActiveChain().Tip(); + if (pindex == nullptr) return false; + return fNextBlock ? DeploymentActiveAfter(pindex, chainman, Consensus::DEPLOYMENT_V24) + : 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) + const std::vector& vout, int session_denom, bool fAllowRebalanceShapes, + PoolMessage& nMessageIDRet, bool* fConsumeCollateralRet, bool fFinalTx, + CoinJoin::SessionDenomCounts* pDenomCountsRet) { std::set setScripPubKeys; nMessageIDRet = MSG_NOERR; if (fConsumeCollateralRet) *fConsumeCollateralRet = false; - if (vin.size() != vout.size()) { + // 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 && fAllowRebalanceShapes) { + entryType = EntryType::FINAL_TX; + } else if (vin.size() == vout.size()) { + entryType = EntryType::STANDARD; + } 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)) { + 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 +347,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 +391,46 @@ 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; + } + + 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" : + entryType == EntryType::DEMOTION ? "DEMOTION" : + entryType == EntryType::FINAL_TX ? "FINAL_TX" : + "STANDARD", + vin.size(), vout.size()); + return true; } @@ -436,11 +577,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 +650,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..637fdaec0bcf 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); } }; @@ -203,6 +223,21 @@ class CCoinJoinEntry } bool AddScriptSig(const CTxIn& txin); + + /// 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 + { + using CoinJoin::MixShape; + if (vecTxDSIn.empty() || vecTxOut.empty()) return MixShape::UNKNOWN; + if (vecTxDSIn.size() == vecTxOut.size()) return MixShape::STANDARD; + 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; + } + + [[nodiscard]] bool IsStandardMixingEntry() const { return GetMixShape() == CoinJoin::MixShape::STANDARD; } }; @@ -317,7 +352,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 +380,24 @@ 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. + * + * 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, PoolMessage& nMessageIDRet, bool* fConsumeCollateralRet); + int session_denom, bool fAllowRebalanceShapes, 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 @@ -354,6 +412,21 @@ 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(); } + + /// 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 GetMixSideCountsLocked(); + } + CoinJoin::MixSideCounts GetMixSideCountsLocked() const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin) + { + CoinJoin::MixSideCounts counts; + for (const auto& entry : vecEntries) { + counts.Add(entry.GetMixShape()); + } + return counts; + } }; class CoinJoinQueueManager @@ -371,7 +444,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) { @@ -417,9 +492,52 @@ 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, 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, 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..3a3b3177bc90 100644 --- a/src/coinjoin/common.h +++ b/src/coinjoin/common.h @@ -10,7 +10,9 @@ #include #include +#include #include +#include #include /** Holds a mixing input @@ -47,6 +49,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 +91,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 +101,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 +117,139 @@ 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_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 std::max(nGoal / GAP_DIVISOR, 1); } + +/** + * 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 + */ +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 the gap threshold (which + * 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 + 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 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. + */ +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 + GetGapThreshold(nGoal); +} + 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..15d0e9961a5c 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -112,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); @@ -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; @@ -284,6 +288,9 @@ void CCoinJoinServer::SetNull() // MN side vecSessionCollaterals.clear(); setSessionCollateralPrevouts.clear(); + m_fRebalanceSession = false; + m_fHasLegacyParticipant = false; + m_mapDeclaredShapes.clear(); CCoinJoinBaseSession::SetNull(); m_queueman.SetNull(); @@ -294,42 +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. // 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(); + if (snap.state == POOL_STATE_ACCEPTING_ENTRIES && snap.entries == snap.collaterals) { + if (snap.sides.IsCovered()) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n"); + 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", + 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); + // 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() && - 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 @@ -517,6 +566,37 @@ 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) && HasSessionCollateral(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::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; @@ -620,44 +700,75 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag { AssertLockNotHeld(cs_coinjoin); - int session_id; - int session_denom; + const auto hasEntryForCollateral = [&entry](const CCoinJoinEntry& other) { + return *other.txCollateral == *entry.txCollateral; + }; + + 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 (static_cast(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; + 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 + // 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 (!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; + 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; + } } - if (entry.vecTxDSIn.size() > COINJOIN_ENTRY_MAX_SIZE || entry.vecTxOut.size() > COINJOIN_ENTRY_MAX_SIZE) { + // 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 = 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__, - 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; - { - 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); - } + ConsumeCollateralIfCurrentSession(session_id, entry.txCollateral); return false; } @@ -667,61 +778,77 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag return false; } + // 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; + 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 (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. + // 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; + ConsumeCollateralIfCurrentSession(session_id, 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); } bool fConsumeCollateral{false}; if (!IsValidInOuts(m_chainman.ActiveChainstate(), m_isman, mempool, vin, entry.vecTxOut, session_denom, - nMessageIDRet, &fConsumeCollateral)) { + /*fAllowRebalanceShapes=*/fRebalanceSession, 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); - } + ConsumeCollateralIfCurrentSession(session_id, 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 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) || !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; 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); } @@ -808,13 +935,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; @@ -829,6 +969,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: 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; + } + { LOCK(cs_coinjoin); @@ -844,6 +1001,9 @@ bool CCoinJoinServer::CreateNewSession(const CCoinJoinAccept& dsa, PoolMessage& nMessageIDRet = MSG_NOERR; nSessionID = GetRand(/*nMax=*/999999) + 1; nSessionDenom = dsa.nDenom; + m_fRebalanceSession = dsa.IsRebalance(); + m_fHasLegacyParticipant = nPeerVersion < COINJOIN_REBALANCE_VERSION; + m_mapDeclaredShapes.emplace(dsa.txCollateral.GetHash(), DeclaredShape(dsa)); SetState(POOL_STATE_QUEUE); @@ -866,7 +1026,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; @@ -896,6 +1056,12 @@ bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, PoolM return false; } + // Evaluated before taking cs_coinjoin: IsPromotionDemotionActive locks cs_main, and cs_main + // 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); + LOCK(cs_coinjoin); if (nSessionID != session_id || nSessionDenom != session_denom || nState != POOL_STATE_QUEUE) { @@ -907,6 +1073,47 @@ bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, PoolM return false; } + // 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; + 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 (static_cast(vecSessionCollaterals.size()) >= CoinJoin::GetMaxPoolParticipants()) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- session is full\n"); + nMessageIDRet = ERR_QUEUE_FULL; + 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. @@ -922,6 +1129,11 @@ bool CCoinJoinServer::AddUserToExistingSession(const CCoinJoinAccept& dsa, PoolM // 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); LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- new user accepted, nSessionID: %d nSessionDenom: %d (%s) vecSessionCollaterals.size(): %d CoinJoin::GetMaxPoolParticipants(): %d\n", @@ -934,6 +1146,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..080aae76716f 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,31 @@ 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 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. + 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); @@ -61,24 +87,43 @@ 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); + + //! 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? 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/net_processing.cpp b/src/net_processing.cpp index 0e265c36bb2c..8d8e4c7e201e 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -3514,9 +3514,23 @@ void PeerManagerImpl::ProcessGetCFCheckPt(CNode& node, Peer& peer, CDataStream& enum class DSTXValidationScore : int { NONE = 0, UNKNOWN_MASTERNODE = 1, + PREMATURE = 1, // deliberately shares UNKNOWN_MASTERNODE's weight: both are tip-skew tolerances INVALID = 10, }; +//! 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. 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() && + dstx.tx->vin.size() <= CoinJoin::GetMaxPoolInputOutputCount()) || + peer_version >= COINJOIN_REBALANCE_VERSION; +} + // do_return signals the caller to stop further processing of the DSTX. struct DSTXValidationResult { DSTXValidationScore score; @@ -3528,10 +3542,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 +3553,21 @@ 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 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}; + } // 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()) { @@ -6464,7 +6489,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; + // 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); queueAndMaybePushInv(CInv(nInvType, hash)); @@ -6525,6 +6557,13 @@ 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; + // 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 State(pto->GetId())->m_recently_announced_invs.insert(hash); nRelayedTransactions++; @@ -6541,7 +6580,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)); } diff --git a/src/test/coinjoin_inouts_tests.cpp b/src/test/coinjoin_inouts_tests.cpp index 0753a9f56d88..c3c2d90b84fc 100644 --- a/src/test/coinjoin_inouts_tests.cpp +++ b/src/test/coinjoin_inouts_tests.cpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -65,22 +67,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 +92,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 +101,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) @@ -179,8 +182,12 @@ class TestableCoinJoinServer : public CCoinJoinServer { public: using CCoinJoinServer::CCoinJoinServer; + using CCoinJoinServer::AddEntry; - void EnterSigningState() { nState = POOL_STATE_SIGNING; } + // 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) { @@ -189,6 +196,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) @@ -309,8 +330,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, + /*fAllowRebalanceShapes=*/false, message, &consume_collateral)); BOOST_CHECK_EQUAL(message, ERR_MISSING_TX); BOOST_CHECK(!consume_collateral); @@ -318,12 +339,94 @@ 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, + /*fAllowRebalanceShapes=*/false, message, &consume_collateral)); BOOST_CHECK_EQUAL(message, ERR_DENOM); 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(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()}; @@ -391,4 +494,1178 @@ 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; + }; + + // 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(nMaxPostFork / CoinJoin::PROMOTION_RATIO, nMaxPostFork); + BOOST_CHECK(!withinCaps.IsValidStructure(nullptr, *Assert(m_node.chainman), &fPossiblyValidPostV24)); + BOOST_CHECK(fPossiblyValidPostV24); + + // 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(nMaxPostFork / CoinJoin::PROMOTION_RATIO + 1, nMaxPostFork + 10); + 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 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 + 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 +} + +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 +// ============================================================================ + +namespace { + +// 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); +} + +bool TestShouldDemote(int largerCount, int smallerCount, int goal) +{ + return CoinJoin::ShouldDemoteDenoms(largerCount, smallerCount, /*nLargerFullyMixedCount=*/largerCount, 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_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; + + // 60 of smaller, 0 of larger -> should promote (0.1 has surplus, 1.0 needs help) + // 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 (20 at this goal) + 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 (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 + 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 + 20 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 (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 (20 at this goal) + 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 (20 at this goal) + 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 +} + +// Build an entry of a given shape; only the input/output counts matter for classification +static CCoinJoinEntry MakeEntry(size_t nInputs, size_t nOutputs) +{ + CCoinJoinEntry entry; + entry.vecTxDSIn.resize(nInputs); + entry.vecTxOut.resize(nOutputs); + return entry; +} + +BOOST_AUTO_TEST_CASE(entry_mix_shape_classification) +{ + using CoinJoin::MixShape; + constexpr size_t R = CoinJoin::PROMOTION_RATIO; + + // 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); +} + +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; + }; + + // 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_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()); + } + + // 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/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; 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/src/wallet/coinjoin.cpp b/src/wallet/coinjoin.cpp index 0b773a70d436..0a2b6705d49b 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()); @@ -326,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 && 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); + 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)) { @@ -406,6 +426,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/interfaces.cpp b/src/wallet/interfaces.cpp index 4af755044de8..0b832bc01868 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -101,7 +101,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; 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 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..5814037f518d 100755 --- a/test/functional/p2p_dstx.py +++ b/test/functional/p2p_dstx.py @@ -4,10 +4,12 @@ # 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 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 @@ -30,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 @@ -38,15 +41,26 @@ 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 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): + 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). @@ -61,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, @@ -88,7 +109,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 +118,52 @@ 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) + # 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, promotion=True) + 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=4, promotion=True) + 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("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, promotion=True) + 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 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