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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
557 changes: 543 additions & 14 deletions src/coinjoin/client.cpp

Large diffs are not rendered by default.

35 changes: 34 additions & 1 deletion src/coinjoin/client.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<COutPoint> m_vecPromotionInputs; // Selected inputs for promotion (10 coins)

/// Create denominations
bool CreateDenominated(CAmount nBalanceToDenominate);
bool CreateDenominated(CAmount nBalanceToDenominate, const wallet::CompactTallyItem& tallyItem, bool fCreateMixingCollaterals)
Expand All @@ -156,15 +161,27 @@ 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<CTxDSIn>& vecTxDSInRet);
/// step 1: prepare denominated inputs and outputs
bool PrepareDenominate(int nMinRounds, int nMaxRounds, std::string& strErrorRet, const std::vector<CTxDSIn>& vecTxDSIn,
std::vector<std::pair<CTxDSIn, CTxOut>>& 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<std::pair<CTxDSIn, CTxOut>>& 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<std::pair<CTxDSIn, CTxOut>>& vecPSInOutPairsRet)
EXCLUSIVE_LOCKS_REQUIRED(m_wallet->cs_wallet);

/// step 2: send denominated inputs and outputs prepared in step 1
bool SendDenominate(const std::vector<std::pair<CTxDSIn, CTxOut> >& vecPSInOutPairsIn, CConnman& connman) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin);

Expand Down Expand Up @@ -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
216 changes: 200 additions & 16 deletions src/coinjoin/coinjoin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#include <chain.h>
#include <chainparams.h>
#include <deploymentstatus.h>
#include <txmempool.h>
#include <util/moneystr.h>
#include <util/system.h>
Expand Down Expand Up @@ -89,24 +90,47 @@ bool CCoinJoinBroadcastTx::IsExpired(const CBlockIndex* pindex, const llmq::CCha
return clhandler.HasChainLock(pindex->nHeight, *pindex->phashBlock);
}

bool CCoinJoinBroadcastTx::IsValidStructure() const
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 ranges::all_of(tx->vout, [] (const auto& txOut){

// All outputs must be valid denominations and P2PKH
if (!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()
Expand Down Expand Up @@ -192,17 +216,80 @@ 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<size_t>(CoinJoin::PROMOTION_RATIO) && vout.size() == 1) {
entryType = EntryType::PROMOTION;
} else if (vin.size() == 1 && vout.size() == static_cast<size_t>(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;
}

const int nLargerAdjacentDenom = CoinJoin::GetLargerAdjacentDenom(nSessionDenom);

// Determine expected denominations based on entry type
int nExpectedInputDenom = nSessionDenom;
int nExpectedOutputDenom = nSessionDenom;

if (entryType == EntryType::PROMOTION) {
// Promotion: inputs = session denom (smaller), output = larger adjacent
nExpectedInputDenom = nSessionDenom;
nExpectedOutputDenom = nLargerAdjacentDenom;
if (nLargerAdjacentDenom == 0) {
LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::%s -- ERROR: no larger adjacent denom for promotion\n", __func__);
nMessageIDRet = ERR_DENOM;
if (fConsumeCollateralRet) *fConsumeCollateralRet = true;
return false;
}
} else if (entryType == EntryType::DEMOTION) {
// Demotion: input = larger adjacent, outputs = session denom (smaller)
nExpectedInputDenom = nLargerAdjacentDenom;
nExpectedOutputDenom = nSessionDenom;
if (nLargerAdjacentDenom == 0) {
LogPrint(BCLog::COINJOIN, "CCoinJoinBaseSession::%s -- ERROR: no larger adjacent denom for demotion\n", __func__);
nMessageIDRet = ERR_DENOM;
if (fConsumeCollateralRet) *fConsumeCollateralRet = true;
return false;
}
}

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;
Expand All @@ -213,21 +300,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;
Expand All @@ -253,21 +339,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;
}

Expand Down Expand Up @@ -487,3 +578,96 @@ void CDSTXManager::BlockDisconnected(const std::shared_ptr<const CBlock>& pblock

int CoinJoin::GetMinPoolParticipants() { return Params().PoolMinParticipants(); }
int CoinJoin::GetMaxPoolParticipants() { return Params().PoolMaxParticipants(); }

bool CoinJoin::ValidatePromotionEntry(const std::vector<CTxIn>& vecTxIn, const std::vector<CTxOut>& 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<size_t>(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<CTxIn>& vecTxIn, const std::vector<CTxOut>& 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<size_t>(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;
}
Loading
Loading