diff --git a/doc/release-notes-7052.md b/doc/release-notes-7052.md new file mode 100644 index 000000000000..d800e3a509b3 --- /dev/null +++ b/doc/release-notes-7052.md @@ -0,0 +1,9 @@ +Wallet +------ + +- CoinJoin can now promote and demote between adjacent standard + denominations within a mixing session after V24 activation. + Promotion combines 10 inputs of one denomination into 1 output of the + next larger denomination, while demotion splits 1 input into 10 + outputs of the next smaller denomination. Pre-V24 behavior remains + unchanged. (#7052) diff --git a/src/coinjoin/client.cpp b/src/coinjoin/client.cpp index a1bd10b14a4a..d5a169c7f3d9 100644 --- a/src/coinjoin/client.cpp +++ b/src/coinjoin/client.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -22,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -291,6 +293,23 @@ void CCoinJoinClientSession::SetNull() mixingMasternode = nullptr; pendingDsaRequest = CPendingDsaRequest(); + // Post-V24: Unlock promotion/demotion inputs before clearing state + // These coins were locked in JoinExistingQueue/StartNewQueue but may not + // have been added to vecOutPointLocked yet if the session failed early + if (!m_vecRebalanceInputs.empty()) { + // Add to vecOutPointLocked so UnlockCoins() will handle them properly + // with its retry mechanism if the wallet is locked + for (const auto& outpoint : m_vecRebalanceInputs) { + // Only add if not already in the list (avoid duplicates) + if (std::find(vecOutPointLocked.begin(), vecOutPointLocked.end(), outpoint) == vecOutPointLocked.end()) { + vecOutPointLocked.push_back(outpoint); + } + } + } + m_fPromotion = false; + m_fDemotion = false; + m_vecRebalanceInputs.clear(); + CCoinJoinBaseSession::SetNull(); } @@ -490,13 +509,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); @@ -961,6 +988,62 @@ bool CCoinJoinClientSession::DoAutomaticDenominating(ChainstateManager& chainman } } // LOCK(m_wallet->cs_wallet); + // Post-V24: Check if we should promote or demote denominations + // This helps maintain optimal denomination distribution as coins are spent + bool fV24Active{false}; + { + LOCK(::cs_main); + const CBlockIndex* pindex = chainman.ActiveChain().Tip(); + fV24Active = pindex && DeploymentActiveAt(*pindex, Params().GetConsensus(), Consensus::DEPLOYMENT_V24); + } + + if (fV24Active) { + // 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 (int i = 0; i < 4; ++i) { + int nLargerDenom = 1 << i; // Larger denomination (e.g., 10 DASH) + int nSmallerDenom = 1 << (i + 1); // Smaller denomination (e.g., 1 DASH) + + // Check if we should promote smaller -> larger + if (m_clientman.ShouldPromote(nSmallerDenom, nLargerDenom)) { + // Verify we have enough fully-mixed coins for promotion + auto vecCoins = m_wallet->SelectFullyMixedForPromotion(nSmallerDenom, CoinJoin::PROMOTION_RATIO); + if (static_cast(vecCoins.size()) >= CoinJoin::PROMOTION_RATIO) { + 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)) { + 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; + } + } + } + } + // Always attempt to join an existing queue if (JoinExistingQueue(nBalanceNeedsAnonymized, connman)) { return true; @@ -1072,11 +1155,15 @@ static int WinnersToSkip() ? 1 : 8; } -bool CCoinJoinClientSession::JoinExistingQueue(CAmount nBalanceNeedsAnonymized, CConnman& connman) +bool CCoinJoinClientSession::JoinExistingQueue(CAmount nBalanceNeedsAnonymized, CConnman& connman, + int nTargetDenom, bool fPromotion, bool fDemotion) { if (!CCoinJoinClientOptions::IsEnabled()) return false; if (m_queueman == nullptr) return false; + // Promotion and demotion are mutually exclusive + assert(!(fPromotion && fDemotion)); + const auto mnList = m_dmnman.GetListAtChainTip(); const int nWeightedMnCount = mnList.GetCounts().m_valid_weighted; @@ -1102,12 +1189,76 @@ bool CCoinJoinClientSession::JoinExistingQueue(CAmount nBalanceNeedsAnonymized, WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- trying queue: %s\n", dsq.ToString()); + // For promotion/demotion, we need a queue with the target denomination + if ((fPromotion || fDemotion) && nTargetDenom != 0 && dsq.nDenom != nTargetDenom) { + continue; // Skip queues with wrong denomination + } + std::vector vecTxDSInTmp; - // Try to match their denominations if possible, select exact number of denominations - if (!m_wallet->SelectTxDSInsByDenomination(dsq.nDenom, nBalanceNeedsAnonymized, vecTxDSInTmp)) { - WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- Couldn't match denomination %d (%s)\n", dsq.nDenom, CoinJoin::DenominationToString(dsq.nDenom)); - continue; + if (fPromotion && nTargetDenom != 0) { + // 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::JoinExistingQueue -- Not enough fully-mixed coins for promotion\n"); + continue; + } + // 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()) { + const wallet::CWalletTx& wtx = it->second; + if (outpoint.n >= wtx.tx->vout.size()) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- invalid outpoint index %u for tx %s\n", outpoint.n, outpoint.hash.ToString()); + continue; + } + CTxDSIn txdsin(CTxIn(outpoint), wtx.tx->vout[outpoint.n].scriptPubKey, + m_wallet->GetRealOutpointCoinJoinRounds(outpoint)); + vecTxDSInTmp.push_back(txdsin); + } + } + if (static_cast(vecTxDSInTmp.size()) < CoinJoin::PROMOTION_RATIO) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- Failed to build promotion inputs\n"); + continue; + } + // Lock promotion inputs immediately to prevent race conditions + // with other concurrent CoinJoin sessions + for (const auto& outpoint : vecCoins) { + m_wallet->LockCoin(outpoint); + } + } else if (fDemotion && nTargetDenom != 0) { + // Demotion: select 1 coin of the larger denomination + const int nLargerDenom = CoinJoin::GetLargerAdjacentDenom(nTargetDenom); + if (nLargerDenom == 0) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- No larger adjacent denom for demotion\n"); + continue; + } + // Select 1 coin of the larger denomination + // Prefer fully-mixed coins first (they're "done" mixing), then fall back to ready-to-mix + // According to plan: "Splitting allows unmixed" - demotion can use any denominated coin + if (!m_wallet->SelectTxDSInsByDenomination(nLargerDenom, CoinJoin::DenominationToAmount(nLargerDenom), vecTxDSInTmp, CoinType::ONLY_FULLY_MIXED)) { + // No fully-mixed coins available, try ready-to-mix + if (!m_wallet->SelectTxDSInsByDenomination(nLargerDenom, CoinJoin::DenominationToAmount(nLargerDenom), vecTxDSInTmp, CoinType::ONLY_READY_TO_MIX)) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- Couldn't find coin for demotion\n"); + continue; + } + } + // Keep only 1 input for demotion + if (vecTxDSInTmp.size() > 1) { + vecTxDSInTmp.resize(1); + } + // Lock demotion input immediately to prevent race conditions + if (!vecTxDSInTmp.empty()) { + LOCK(m_wallet->cs_wallet); + m_wallet->LockCoin(vecTxDSInTmp[0].prevout); + } + } else { + // 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_clientman.AddUsedMasternode(dmn->proTxHash); @@ -1124,9 +1275,33 @@ bool CCoinJoinClientSession::JoinExistingQueue(CAmount nBalanceNeedsAnonymized, connman.AddPendingMasternode(dmn->proTxHash); SetState(POOL_STATE_QUEUE); nTimeLastSuccessfulStep = GetTime(); - WalletCJLogPrint(m_wallet, /* Continued */ - "CCoinJoinClientSession::JoinExistingQueue -- pending connection, masternode=%s, nSessionDenom=%d (%s)\n", - dmn->proTxHash.ToString(), nSessionDenom, CoinJoin::DenominationToString(nSessionDenom)); + + // Set promotion/demotion session state + m_fPromotion = fPromotion; + m_fDemotion = fDemotion; + + // Store promotion inputs for use in PreparePromotionEntry + if (fPromotion) { + m_vecRebalanceInputs.clear(); + for (const auto& txdsin : vecTxDSInTmp) { + m_vecRebalanceInputs.push_back(txdsin.prevout); + } + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- pending PROMOTION connection, masternode=%s, nSessionDenom=%d (%s), %d inputs\n", + dmn->proTxHash.ToString(), nSessionDenom, CoinJoin::DenominationToString(nSessionDenom), + m_vecRebalanceInputs.size()); + } else if (fDemotion) { + // For demotion, store the single input + m_vecRebalanceInputs.clear(); + if (!vecTxDSInTmp.empty()) { + m_vecRebalanceInputs.push_back(vecTxDSInTmp[0].prevout); + } + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- pending DEMOTION connection, masternode=%s, nSessionDenom=%d (%s)\n", + dmn->proTxHash.ToString(), nSessionDenom, CoinJoin::DenominationToString(nSessionDenom)); + } else { + m_vecRebalanceInputs.clear(); + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::JoinExistingQueue -- pending connection, masternode=%s, nSessionDenom=%d (%s)\n", + dmn->proTxHash.ToString(), nSessionDenom, CoinJoin::DenominationToString(nSessionDenom)); + } strAutoDenomResult = _("Trying to connect…"); return true; } @@ -1216,6 +1391,139 @@ 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; + + // For promotion/demotion, verify we have the required coins before starting a queue + std::vector vecTxDSInTmp; + + if (fPromotion) { + // Promotion: need 10 fully-mixed coins of the target (smaller) denomination + auto vecCoins = m_wallet->SelectFullyMixedForPromotion(nTargetDenom, CoinJoin::PROMOTION_RATIO); + if (static_cast(vecCoins.size()) < CoinJoin::PROMOTION_RATIO) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- Not enough fully-mixed coins for promotion\n"); + return false; + } + // Convert to CTxDSIn for storage + 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()) { + const wallet::CWalletTx& wtx = it->second; + if (outpoint.n >= wtx.tx->vout.size()) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- invalid outpoint index %u for tx %s\n", outpoint.n, outpoint.hash.ToString()); + continue; + } + CTxDSIn txdsin(CTxIn(outpoint), wtx.tx->vout[outpoint.n].scriptPubKey, + m_wallet->GetRealOutpointCoinJoinRounds(outpoint)); + vecTxDSInTmp.push_back(txdsin); + } + } + if (static_cast(vecTxDSInTmp.size()) < CoinJoin::PROMOTION_RATIO) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- Failed to build promotion inputs\n"); + return false; + } + // Lock promotion inputs immediately to prevent race conditions + for (const auto& outpoint : vecCoins) { + m_wallet->LockCoin(outpoint); + } + } else if (fDemotion) { + // Demotion: need 1 coin of the larger denomination + const int nLargerDenom = CoinJoin::GetLargerAdjacentDenom(nTargetDenom); + if (nLargerDenom == 0) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- No larger adjacent denom for demotion\n"); + return false; + } + // Prefer fully-mixed coins first, then fall back to ready-to-mix + if (!m_wallet->SelectTxDSInsByDenomination(nLargerDenom, CoinJoin::DenominationToAmount(nLargerDenom), vecTxDSInTmp, CoinType::ONLY_FULLY_MIXED)) { + if (!m_wallet->SelectTxDSInsByDenomination(nLargerDenom, CoinJoin::DenominationToAmount(nLargerDenom), vecTxDSInTmp, CoinType::ONLY_READY_TO_MIX)) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- Couldn't find coin for demotion\n"); + return false; + } + } + if (vecTxDSInTmp.size() > 1) { + vecTxDSInTmp.resize(1); + } + // Lock demotion input immediately to prevent race conditions + if (!vecTxDSInTmp.empty()) { + LOCK(m_wallet->cs_wallet); + m_wallet->LockCoin(vecTxDSInTmp[0].prevout); + } + } else { + // Neither promotion nor demotion - shouldn't use this overload + return false; + } + + int nTries = 0; + const auto mnList = m_dmnman.GetListAtChainTip(); + const int nMnCount = mnList.GetValidMNsCount(); + const int nWeightedMnCount = mnList.GetValidWeightedMNsCount(); + + while (nTries < 10) { + auto dmn = m_clientman.GetRandomNotUsedMasternode(); + if (!dmn) { + strAutoDenomResult = _("Can't find random Masternode."); + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- %s\n", strAutoDenomResult.original); + return false; + } + + m_clientman.AddUsedMasternode(dmn->proTxHash); + + // skip next mn payments winners + if (dmn->pdmnState->nLastPaidHeight + nWeightedMnCount < mnList.GetHeight() + WinnersToSkip()) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- skipping winner, masternode=%s\n", dmn->proTxHash.ToString()); + nTries++; + continue; + } + + if (m_mn_metaman.IsMixingThresholdExceeded(dmn->proTxHash, nMnCount)) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- too early to mix with node masternode=%s\n", + dmn->proTxHash.ToString()); + nTries++; + continue; + } + + if (connman.IsMasternodeOrDisconnectRequested(dmn->pdmnState->netInfo->GetPrimary())) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- skipping connection, masternode=%s\n", + dmn->proTxHash.ToString()); + nTries++; + continue; + } + + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- attempting %s connection, masternode=%s, tries=%d\n", + fPromotion ? "PROMOTION" : "DEMOTION", dmn->proTxHash.ToString(), nTries); + + nSessionDenom = nTargetDenom; + mixingMasternode = dmn; + connman.AddPendingMasternode(dmn->proTxHash); + pendingDsaRequest = CPendingDsaRequest(dmn->proTxHash, CCoinJoinAccept(nSessionDenom, txMyCollateral)); + SetState(POOL_STATE_QUEUE); + nTimeLastSuccessfulStep = GetTime(); + + // Store promotion/demotion state and inputs + m_fPromotion = fPromotion; + m_fDemotion = fDemotion; + m_vecRebalanceInputs.clear(); + for (const auto& txdsin : vecTxDSInTmp) { + m_vecRebalanceInputs.push_back(txdsin.prevout); + } + + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::StartNewQueue -- pending %s connection, masternode=%s, nSessionDenom=%d (%s), %zu inputs\n", + fPromotion ? "PROMOTION" : "DEMOTION", + dmn->proTxHash.ToString(), nSessionDenom, CoinJoin::DenominationToString(nSessionDenom), + m_vecRebalanceInputs.size()); + strAutoDenomResult = _("Trying to connect…"); + return true; + } + strAutoDenomResult = _("Failed to start a new mixing queue"); + return false; +} + bool CCoinJoinClientSession::ProcessPendingDsaRequest(CConnman& connman) { if (!pendingDsaRequest) return false; @@ -1293,9 +1601,32 @@ 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) { + if (PreparePromotionEntry(strError, vecPSInOutPairsTmp)) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::SubmitDenominate -- Promotion entry prepared, sending\n"); + return SendDenominate(vecPSInOutPairsTmp, connman); + } + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::SubmitDenominate -- PreparePromotionEntry failed: %s\n", strError); + strAutoDenomResult = Untranslated(strError); + return false; + } + + if (m_fDemotion) { + if (PrepareDemotionEntry(strError, vecPSInOutPairsTmp)) { + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::SubmitDenominate -- Demotion entry prepared, sending\n"); + return SendDenominate(vecPSInOutPairsTmp, connman); + } + WalletCJLogPrint(m_wallet, "CCoinJoinClientSession::SubmitDenominate -- PrepareDemotionEntry failed: %s\n", strError); + strAutoDenomResult = Untranslated(strError); + 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; @@ -1422,6 +1753,148 @@ 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)); + + // For promotion, outputs are created but only 1 matters (the larger denom) + // We'll use empty CTxOut for all but the first to signal "no output for this input" + // Actually, for promotion entry: 10 inputs, 1 output + // We need to pair each input with an "empty" output, except the last gets the real output + vecPSInOutPairsRet.emplace_back(txdsin, CTxOut()); + } + + // Now set the single output (larger denomination) on the last entry + CScript scriptDenom = keyHolderStorage.AddKey(m_wallet.get()); + if (!vecPSInOutPairsRet.empty()) { + // Replace the last output with the actual promotion output + vecPSInOutPairsRet.back().second = CTxOut(nLargerAmount, scriptDenom); + } + + // Lock all inputs (should already be locked from JoinExistingQueue/StartNewQueue) + // Add to vecOutPointLocked for proper cleanup in SetNull() + for (const auto& [txDsIn, txDsOut] : vecPSInOutPairsRet) { + if (!m_wallet->IsLockedCoin(txDsIn.prevout)) { + // Defensive: lock if not already locked + m_wallet->LockCoin(txDsIn.prevout); + } + vecOutPointLocked.push_back(txDsIn.prevout); + } + + 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); + } + } + + // Lock the input (should already be locked from JoinExistingQueue/StartNewQueue) + // Add to vecOutPointLocked for proper cleanup in SetNull() + if (!m_wallet->IsLockedCoin(txdsin.prevout)) { + // Defensive: lock if not already locked + m_wallet->LockCoin(txdsin.prevout); + } + vecOutPointLocked.push_back(txdsin.prevout); + + 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() { @@ -1893,6 +2366,70 @@ void CCoinJoinClientManager::GetJsonInfo(UniValue& obj) const obj.pushKV("sessions", arrSessions); } +bool CCoinJoinClientManager::ShouldPromote(int nSmallerDenom, int nLargerDenom) const +{ + // Validate denominations are adjacent + if (!CoinJoin::AreAdjacentDenominations(nSmallerDenom, nLargerDenom)) { + return false; + } + + const int nGoal = CCoinJoinClientOptions::GetDenomsGoal(); + const int nHalfGoal = nGoal / 2; + + const int nSmallerCount = m_wallet->CountCoinsByDenomination(nSmallerDenom, /*fFullyMixedOnly=*/false); + const int nLargerCount = m_wallet->CountCoinsByDenomination(nLargerDenom, /*fFullyMixedOnly=*/false); + + // Don't sacrifice a denomination that's still being built up + if (nSmallerCount < nHalfGoal) { + return false; + } + + // Calculate how far each is from goal (0 if at or above goal) + const int nSmallerDeficit = std::max(0, nGoal - nSmallerCount); + const int nLargerDeficit = std::max(0, nGoal - nLargerCount); + + // Promote if: + // 1. Smaller denom has at least half the goal (above check) + // 2. Larger denomination is further from goal (needs more help) + // 3. Gap exceeds threshold to prevent oscillation + // 4. Have 10 fully-mixed coins to promote + const int nFullyMixedCount = m_wallet->CountCoinsByDenomination(nSmallerDenom, /*fFullyMixedOnly=*/true); + if (nFullyMixedCount < CoinJoin::PROMOTION_RATIO) { + return false; + } + + return (nLargerDeficit > nSmallerDeficit + CoinJoin::GAP_THRESHOLD); +} + +bool CCoinJoinClientManager::ShouldDemote(int nLargerDenom, int nSmallerDenom) const +{ + // Validate denominations are adjacent + if (!CoinJoin::AreAdjacentDenominations(nLargerDenom, nSmallerDenom)) { + return false; + } + + const int nGoal = CCoinJoinClientOptions::GetDenomsGoal(); + const int nHalfGoal = nGoal / 2; + + const int nLargerCount = m_wallet->CountCoinsByDenomination(nLargerDenom, /*fFullyMixedOnly=*/false); + const int nSmallerCount = m_wallet->CountCoinsByDenomination(nSmallerDenom, /*fFullyMixedOnly=*/false); + + // Don't sacrifice a denomination that's still being built up + if (nLargerCount < nHalfGoal) { + return false; + } + + // Calculate how far each is from goal (0 if at or above goal) + const int nSmallerDeficit = std::max(0, nGoal - nSmallerCount); + const int nLargerDeficit = std::max(0, nGoal - nLargerCount); + + // Demote if: + // 1. Larger denom has at least half the goal (above check) + // 2. Smaller denomination is further from goal (needs more help) + // 3. Gap exceeds threshold to prevent oscillation + return (nSmallerDeficit > nLargerDeficit + CoinJoin::GAP_THRESHOLD); +} + CoinJoinWalletManager::CoinJoinWalletManager(ChainstateManager& chainman, CDeterministicMNManager& dmnman, CMasternodeMetaMan& mn_metaman, const CTxMemPool& mempool, const CMasternodeSync& mn_sync, const llmq::CInstantSendManager& isman, diff --git a/src/coinjoin/client.h b/src/coinjoin/client.h index 5ecf433010f1..f3672978e9d8 100644 --- a/src/coinjoin/client.h +++ b/src/coinjoin/client.h @@ -143,6 +143,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) @@ -156,8 +161,11 @@ 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); /// step 0: select denominated inputs and txouts bool SelectDenominate(std::string& strErrorRet, std::vector& vecTxDSInRet); @@ -165,6 +173,15 @@ class CCoinJoinClientSession : public CCoinJoinBaseSession 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); @@ -315,6 +332,22 @@ class CCoinJoinClientManager EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); void GetJsonInfo(UniValue& obj) const EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions); + + /** + * 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 + * @return true if promotion is recommended + */ + bool ShouldPromote(int nSmallerDenom, int nLargerDenom) const; + + /** + * 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 + * @return true if demotion is recommended + */ + bool ShouldDemote(int nLargerDenom, int nSmallerDenom) const; }; #endif // BITCOIN_COINJOIN_CLIENT_H diff --git a/src/coinjoin/coinjoin.cpp b/src/coinjoin/coinjoin.cpp index 6391e73f2bc5..24545212edfb 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -82,24 +83,54 @@ bool CCoinJoinBroadcastTx::CheckSignature(const CBLSPublicKey& blsPubKey) const return true; } -bool CCoinJoinBroadcastTx::IsValidStructure() const +bool CCoinJoinBroadcastTx::IsExpired(const CBlockIndex* pindex, const llmq::CChainLocksHandler& clhandler) const +{ + // 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 { // some trivial checks only if (masternodeOutpoint.IsNull() && m_protxHash.IsNull()) { return false; } - if (tx->vin.size() != tx->vout.size()) { + + const bool fV24Active = pindex && DeploymentActiveAt(*pindex, Params().GetConsensus(), Consensus::DEPLOYMENT_V24); + + // Pre-V24: require balanced input/output counts (1:1 mixing only) + // Post-V24: allow unbalanced counts (promotion/demotion) + if (!fV24Active && tx->vin.size() != tx->vout.size()) { return false; } + if (tx->vin.size() < size_t(CoinJoin::GetMinPoolParticipants())) { return false; } - if (tx->vin.size() > CoinJoin::GetMaxPoolParticipants() * COINJOIN_ENTRY_MAX_SIZE) { + + // Post-V24: allow up to 200 inputs (20 participants * 10 inputs for promotions) + // Pre-V24: max 180 inputs (20 participants * 9 entries) + const size_t nMaxInputs = fV24Active + ? CoinJoin::GetMaxPoolParticipants() * CoinJoin::PROMOTION_RATIO + : CoinJoin::GetMaxPoolParticipants() * COINJOIN_ENTRY_MAX_SIZE; + + if (tx->vin.size() > nMaxInputs) { return false; } - return std::ranges::all_of(tx->vout, [](const auto& txOut) { + + if (!std::ranges::all_of(tx->vout, [](const auto& txOut) { return CoinJoin::IsDenominatedAmount(txOut.nValue) && txOut.scriptPubKey.IsPayToPublicKeyHash(); - }); + })) { + return false; + } + + // 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. + + return true; } void CCoinJoinBaseSession::SetNull() @@ -185,17 +216,71 @@ bool CCoinJoinBaseSession::IsValidInOuts(CChainState& active_chainstate, const l nMessageIDRet = MSG_NOERR; if (fConsumeCollateralRet) *fConsumeCollateralRet = false; - if (vin.size() != vout.size()) { + // Check if V24 is active for promotion/demotion support + bool fV24Active{false}; + { + LOCK(::cs_main); + const CBlockIndex* pindex = active_chainstate.m_chain.Tip(); + fV24Active = pindex && DeploymentActiveAt(*pindex, Params().GetConsensus(), Consensus::DEPLOYMENT_V24); + } + + // 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 + enum class EntryType { STANDARD, PROMOTION, DEMOTION, INVALID }; + EntryType entryType = EntryType::STANDARD; + + if (vin.size() == vout.size()) { + entryType = EntryType::STANDARD; + } else if (fV24Active) { + if (vin.size() == static_cast(CoinJoin::PROMOTION_RATIO) && vout.size() == 1) { + entryType = EntryType::PROMOTION; + } else if (vin.size() == 1 && vout.size() == static_cast(CoinJoin::PROMOTION_RATIO)) { + entryType = EntryType::DEMOTION; + } else { + entryType = EntryType::INVALID; + } + } else { + // Pre-V24: only standard entries allowed LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::%s -- ERROR: inputs vs outputs size mismatch! %d vs %d\n", __func__, vin.size(), vout.size()); nMessageIDRet = ERR_SIZE_MISMATCH; if (fConsumeCollateralRet) *fConsumeCollateralRet = true; return false; } - auto checkTxOut = [&](const CTxOut& txout) { - if (int nDenom = CoinJoin::AmountToDenomination(txout.nValue); nDenom != nSessionDenom) { - LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::IsValidInOuts -- ERROR: incompatible denom %d (%s) != nSessionDenom %d (%s)\n", - nDenom, CoinJoin::DenominationToString(nDenom), nSessionDenom, CoinJoin::DenominationToString(nSessionDenom)); + 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 = nSessionDenom; + int nExpectedOutputDenom = nSessionDenom; + + if (entryType == EntryType::PROMOTION) { + if (!CoinJoin::ValidatePromotionEntry(vin, vout, nSessionDenom, nMessageIDRet)) { + if (fConsumeCollateralRet) *fConsumeCollateralRet = true; + return false; + } + nExpectedOutputDenom = CoinJoin::GetLargerAdjacentDenom(nSessionDenom); + } else if (entryType == EntryType::DEMOTION) { + if (!CoinJoin::ValidateDemotionEntry(vin, vout, nSessionDenom, nMessageIDRet)) { + if (fConsumeCollateralRet) *fConsumeCollateralRet = true; + return false; + } + nExpectedInputDenom = CoinJoin::GetLargerAdjacentDenom(nSessionDenom); + } + + auto checkTxOut = [&](const CTxOut& txout, int nExpectedDenom) { + const int nDenom = CoinJoin::AmountToDenomination(txout.nValue); + + if (nDenom != nExpectedDenom) { + 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; @@ -206,21 +291,20 @@ bool CCoinJoinBaseSession::IsValidInOuts(CChainState& active_chainstate, const l 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}; for (const auto& txout : vout) { - if (!checkTxOut(txout)) { + if (!checkTxOut(txout, nExpectedOutputDenom)) { return false; } nFees -= txout.nValue; @@ -246,21 +330,26 @@ bool CCoinJoinBaseSession::IsValidInOuts(CChainState& active_chainstate, const l return false; } - if (!checkTxOut(coin.out)) { + if (!checkTxOut(coin.out, nExpectedInputDenom)) { return false; } 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; } + LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::%s -- Valid %s entry: %d inputs, %d outputs\n", + __func__, + entryType == EntryType::PROMOTION ? "PROMOTION" : (entryType == EntryType::DEMOTION ? "DEMOTION" : "STANDARD"), + vin.size(), vout.size()); + return true; } @@ -484,3 +573,96 @@ 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; +} diff --git a/src/coinjoin/coinjoin.h b/src/coinjoin/coinjoin.h index 7cfd1c09efaf..43d41e6e42bc 100644 --- a/src/coinjoin/coinjoin.h +++ b/src/coinjoin/coinjoin.h @@ -173,6 +173,15 @@ class CCoinJoinEntry } bool AddScriptSig(const CTxIn& txin); + + // Check if this is a standard mixing entry (not promotion/demotion) + // Standard: equal number of inputs and outputs + // Promotion: PROMOTION_RATIO inputs, 1 output + // Demotion: 1 input, PROMOTION_RATIO outputs + bool IsStandardMixingEntry() const + { + return vecTxDSIn.size() == vecTxOut.size(); + } }; @@ -284,7 +293,8 @@ 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 llmq::CChainLocksHandler& clhandler) const; + [[nodiscard]] bool IsValidStructure(const CBlockIndex* pindex) const; }; // base class @@ -319,6 +329,20 @@ class CCoinJoinBaseSession int GetEntriesCount() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) { LOCK(cs_coinjoin); return vecEntries.size(); } int GetEntriesCountLocked() const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin) { return vecEntries.size(); } + + // Count only standard mixing entries (not promotion/demotion) for privacy threshold + int GetStandardEntriesCount() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) + { + LOCK(cs_coinjoin); + return std::count_if(vecEntries.begin(), vecEntries.end(), + [](const CCoinJoinEntry& entry) { return entry.IsStandardMixingEntry(); }); + } + + int GetStandardEntriesCountLocked() const EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin) + { + return std::count_if(vecEntries.begin(), vecEntries.end(), + [](const CCoinJoinEntry& entry) { return entry.IsStandardMixingEntry(); }); + } }; // base class @@ -367,6 +391,28 @@ namespace CoinJoin /// 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); } class CDSTXManager diff --git a/src/coinjoin/common.h b/src/coinjoin/common.h index 6c7abfba2266..7cd52efe7d78 100644 --- a/src/coinjoin/common.h +++ b/src/coinjoin/common.h @@ -110,6 +110,57 @@ std::string DenominationToString(int nDenom); constexpr CAmount GetCollateralAmount() { return GetSmallestDenomination() / 10; } constexpr CAmount GetMaxCollateralAmount() { return GetCollateralAmount() * 4; } +// Promotion/demotion constants (post-V24 feature) +constexpr int PROMOTION_RATIO = 10; // 10 smaller denomination coins = 1 larger denomination coin +constexpr int GAP_THRESHOLD = 10; // Deficit gap required to trigger promotion/demotion + +/** + * 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; +} + +/** + * 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); +} + 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 4abeb79ba526..f5af994a9a8b 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -5,6 +5,8 @@ #include #include +#include +#include #include #include #include @@ -223,6 +225,22 @@ void CCoinJoinServer::ProcessDSVIN(CNode& peer, CDataStream& vRecv) LogPrint(BCLog::COINJOIN, "DSVIN -- txCollateral %s", entry.txCollateral->ToString()); /* Continued */ + // Post-V24: Check if unbalanced entries (promotion/demotion) are allowed + if (entry.vecTxDSIn.size() != entry.vecTxOut.size()) { + // This is a promotion or demotion entry - requires V24 activation + bool fV24Active{false}; + { + LOCK(::cs_main); + const CBlockIndex* pindex = m_chainman.ActiveChain().Tip(); + fV24Active = pindex && DeploymentActiveAt(*pindex, Params().GetConsensus(), Consensus::DEPLOYMENT_V24); + } + if (!fV24Active) { + LogPrint(BCLog::COINJOIN, "DSVIN -- promotion/demotion entry rejected: V24 not active\n"); + PushStatus(peer, STATUS_REJECTED, ERR_MODE); + return; + } + } + PoolMessage nMessageID = MSG_NOERR; entry.addr = peer.addr; @@ -280,15 +298,20 @@ void CCoinJoinServer::CheckPool() // If we have an entry for each collateral, then create final tx if (nState == POOL_STATE_ACCEPTING_ENTRIES && size_t(GetEntriesCount()) == vecSessionCollaterals.size()) { - LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n"); - CreateFinalTransaction(); - return; + if (GetStandardEntriesCount() >= CoinJoin::GetMinPoolParticipants()) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- FINALIZE TRANSACTIONS\n"); + CreateFinalTransaction(); + return; + } + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckPool -- all entries received but insufficient standard mixers (%d), waiting for timeout\n", GetStandardEntriesCount()); } // Check for Time Out // If we timed out while accepting entries, then if we have more than minimum, create final tx - if (nState == POOL_STATE_ACCEPTING_ENTRIES && CCoinJoinServer::HasTimedOut() && - GetEntriesCount() >= CoinJoin::GetMinPoolParticipants()) { + // PRIVACY: Only count standard mixing entries toward minimum participant threshold + // Promotion/demotion entries don't count - they get privacy from standard mixers + if (nState == POOL_STATE_ACCEPTING_ENTRIES && CCoinJoinServer::HasTimedOut() + && GetStandardEntriesCount() >= CoinJoin::GetMinPoolParticipants()) { // Punish misbehaving participants ChargeFees(); // Try to complete this session ignoring the misbehaving ones @@ -599,8 +622,18 @@ bool CCoinJoinServer::AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessag return false; } - if (entry.vecTxDSIn.size() > COINJOIN_ENTRY_MAX_SIZE) { - LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: too many inputs! %d/%d\n", __func__, entry.vecTxDSIn.size(), COINJOIN_ENTRY_MAX_SIZE); + // Post-V24: allow up to PROMOTION_RATIO (10) inputs for promotion entries + // Pre-V24: max COINJOIN_ENTRY_MAX_SIZE (9) inputs + bool fV24Active{false}; + { + LOCK(::cs_main); + const CBlockIndex* pindex = m_chainman.ActiveChain().Tip(); + fV24Active = pindex && DeploymentActiveAt(*pindex, Params().GetConsensus(), Consensus::DEPLOYMENT_V24); + } + const size_t nMaxEntryInputs = fV24Active ? CoinJoin::PROMOTION_RATIO : COINJOIN_ENTRY_MAX_SIZE; + + if (entry.vecTxDSIn.size() > nMaxEntryInputs) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::%s -- ERROR: too many inputs! %d/%d\n", __func__, entry.vecTxDSIn.size(), nMaxEntryInputs); nMessageIDRet = ERR_MAXIMUM; ConsumeCollateral(entry.txCollateral); return false; diff --git a/src/net_processing.cpp b/src/net_processing.cpp index cfa5845a78c5..f802485ac025 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -3556,10 +3556,6 @@ std::pair static ValidateDSTX(CDeterministicMN { assert(mn_metaman.IsValid()); - if (!dstx.IsValidStructure()) { - LogPrint(BCLog::COINJOIN, "DSTX -- Invalid DSTX structure: %s\n", hashTx.ToString()); - return {false, true}; - } if (dstxman.GetDSTX(hashTx)) { LogPrint(BCLog::COINJOIN, "DSTX -- Already have %s, skipping...\n", hashTx.ToString()); return {true, true}; // not an error @@ -3571,6 +3567,11 @@ std::pair static ValidateDSTX(CDeterministicMN LOCK(cs_main); pindex = chainman.ActiveChain().Tip(); } + + if (!dstx.IsValidStructure(pindex)) { + LogPrint(BCLog::COINJOIN, "DSTX -- Invalid DSTX structure: %s\n", hashTx.ToString()); + return {false, true}; + } // It could be that a MN is no longer in the list but its DSTX is not yet mined. // Try to find a MN up to 24 blocks deep to make sure such dstx-es are relayed and processed correctly. if (dstx.masternodeOutpoint.IsNull()) { diff --git a/src/test/coinjoin_inouts_tests.cpp b/src/test/coinjoin_inouts_tests.cpp index 8a41bd710c58..e84e217f3f7a 100644 --- a/src/test/coinjoin_inouts_tests.cpp +++ b/src/test/coinjoin_inouts_tests.cpp @@ -10,9 +10,10 @@ #include #include +#include #include #include -#include +#include #include