diff --git a/src/coinjoin/server.cpp b/src/coinjoin/server.cpp index b06652025431..ca1a93c94472 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -284,7 +284,10 @@ void CCoinJoinServer::ProcessDSSIGNFINALTX(CNode& peer, CDataStream& vRecv) if (!AddScriptSig(txin)) { LogPrint(BCLog::COINJOIN, "DSSIGNFINALTX -- AddScriptSig() failed at %d/%d, session: %d\n", nTxInIndex, nTxInsCount, session_id); - RelayStatus(STATUS_REJECTED); + // Only the sender is at fault, so only the sender is rejected. A session-wide + // rejection would let one participant abort the session for everyone, and those + // that obeyed it would be counted as non-signers at the timeout. + PushStatus(peer, STATUS_REJECTED, ERR_INVALID_INPUT); return; } LogPrint(BCLog::COINJOIN, "DSSIGNFINALTX -- AddScriptSig() %d/%d success\n", nTxInIndex, nTxInsCount); @@ -540,56 +543,66 @@ CTransactionRef CCoinJoinServer::SelectCollateralToCharge() //we don't need to charge collateral for every offence. if (GetRand(/*nMax=*/100) > 33) return {}; - std::vector vecOffendersCollaterals; const PoolState state{nState}; - const size_t nSessionCollaterals{m_session_collaterals.size()}; + auto offenders{FindSessionOffenders(state, m_session_collaterals.txs(), vecEntries)}; + if (!offenders.collaterals.empty()) { + LogPrint(BCLog::COINJOIN, /* Continued */ + "CCoinJoinServer::SelectCollateralToCharge -- found %d of %d participants uncooperative (didn't %s)\n", + offenders.collaterals.size(), offenders.participants, + (state == POOL_STATE_SIGNING) ? "sign" : "send transaction"); + } + + switch (GetOffenderChargeRate(offenders)) { + case OffenderChargeRate::NONE: + return {}; + case OffenderChargeRate::REDUCED: + if (GetRand(/*nMax=*/100) > 33) return {}; + break; + case OffenderChargeRate::NORMAL: + break; + } + + // charge one of the offenders randomly + Shuffle(offenders.collaterals.begin(), offenders.collaterals.end(), FastRandomContext()); + + LogPrint(BCLog::COINJOIN, /* Continued */ + "CCoinJoinServer::SelectCollateralToCharge -- found uncooperative node (didn't %s transaction), charging " + "fees: %s", + (state == POOL_STATE_SIGNING) ? "sign" : "send", offenders.collaterals[0]->ToString()); + m_collaterals_being_charged.push_back(offenders.collaterals[0]); + return offenders.collaterals[0]; +} +CoinJoinSessionOffenders FindSessionOffenders(PoolState state, const std::vector& session_collaterals, + const std::vector& entries) +{ + CoinJoinSessionOffenders offenders; if (state == POOL_STATE_ACCEPTING_ENTRIES) { - for (const auto& txCollateral : m_session_collaterals.txs()) { - bool fFound = std::ranges::any_of(vecEntries, [&txCollateral](const auto& entry) { + offenders.participants = session_collaterals.size(); + for (const auto& txCollateral : session_collaterals) { + // This queue entry didn't send us the promised transaction + const bool fFound = std::ranges::any_of(entries, [&txCollateral](const auto& entry) { return *entry.txCollateral == *txCollateral; }); - - // This queue entry didn't send us the promised transaction - if (!fFound) { - LogPrint(BCLog::COINJOIN, /* Continued */ - "CCoinJoinServer::SelectCollateralToCharge -- found uncooperative node (didn't send " - "transaction), found offence\n"); - vecOffendersCollaterals.push_back(txCollateral); - } + if (!fFound) offenders.collaterals.push_back(txCollateral); } } else if (state == POOL_STATE_SIGNING) { - // who didn't sign? - for (const auto& entry : vecEntries) { - for (const auto& txdsin : entry.vecTxDSIn) { - if (!txdsin.fHasSig) { - LogPrint(BCLog::COINJOIN, /* Continued */ - "CCoinJoinServer::SelectCollateralToCharge -- found uncooperative node (didn't sign), " - "found offence\n"); - vecOffendersCollaterals.push_back(entry.txCollateral); - } + offenders.participants = entries.size(); + for (const auto& entry : entries) { + if (std::ranges::any_of(entry.vecTxDSIn, [](const auto& txdsin) { return !txdsin.fHasSig; })) { + offenders.collaterals.push_back(entry.txCollateral); } } } + return offenders; +} - // no offences found - if (vecOffendersCollaterals.empty()) return {}; - - //mostly offending? Charge sometimes - if (vecOffendersCollaterals.size() >= nSessionCollaterals - 1 && GetRand(/*nMax=*/100) > 33) return {}; - - //everyone is an offender? That's not right - if (vecOffendersCollaterals.size() >= nSessionCollaterals) return {}; - - //charge one of the offenders randomly - Shuffle(vecOffendersCollaterals.begin(), vecOffendersCollaterals.end(), FastRandomContext()); - - LogPrint(BCLog::COINJOIN, /* Continued */ - "CCoinJoinServer::SelectCollateralToCharge -- found uncooperative node (didn't %s transaction), charging " - "fees: %s", - (state == POOL_STATE_SIGNING) ? "sign" : "send", vecOffendersCollaterals[0]->ToString()); - m_collaterals_being_charged.push_back(vecOffendersCollaterals[0]); - return vecOffendersCollaterals[0]; +OffenderChargeRate GetOffenderChargeRate(const CoinJoinSessionOffenders& offenders) +{ + const size_t failed{offenders.collaterals.size()}; + if (failed == 0 || failed >= offenders.participants) return OffenderChargeRate::NONE; + if (failed == offenders.participants - 1) return OffenderChargeRate::REDUCED; + return OffenderChargeRate::NORMAL; } /* @@ -1308,17 +1321,16 @@ void CCoinJoinServer::RelayFinalTransaction(const CTransaction& txFinal) __func__, nSessionID, nSessionDenom, CoinJoin::DenominationToString(nSessionDenom)); // final mixing tx with empty signatures should be relayed to mixing participants only - for (const auto& entry : vecEntries) { - bool fOk = connman.ForNode(entry.addr, [&txFinal, this](CNode* pnode) { + const bool all_reached = std::ranges::all_of(vecEntries, [&txFinal, this](const auto& entry) { + return connman.ForNode(entry.addr, [&txFinal, this](CNode* pnode) { CNetMsgMaker msgMaker(pnode->GetCommonVersion()); connman.PushMessage(pnode, msgMaker.Make(NetMsgType::DSFINALTX, nSessionID.load(), txFinal)); return true; }); - if (!fOk) { - // no such node? maybe this client disconnected or our own connection went down - RelayStatus(STATUS_REJECTED); - break; - } + }); + if (!all_reached) { + // no such node? maybe this client disconnected or our own connection went down + RelayStatus(STATUS_REJECTED); } } @@ -1358,11 +1370,11 @@ void CCoinJoinServer::RelayStatus(PoolStatusUpdate nStatusUpdate, PoolMessage nM }); } - if (nDisconnected == vecEntries.size()) { - // all clients disconnected, there is probably some issues with our own connection - // do not charge any fees, just reset the pool - SetNull(); - } + // The participants told to terminate release their inputs and stop, so the session can no + // longer complete. End it now without charging anyone: at the timeout they would look like + // participants that never sent or signed, and a peer we cannot reach is no evidence of + // misbehavior - our own connection may be the one that failed. + SetNull(); } void CCoinJoinServer::RelayCompletedTransaction(int session_id, const std::vector& participants, diff --git a/src/coinjoin/server.h b/src/coinjoin/server.h index d5abb3d218b7..c74f34acbe49 100644 --- a/src/coinjoin/server.h +++ b/src/coinjoin/server.h @@ -49,6 +49,34 @@ struct CoinJoinSessionProgress { */ bool ShouldResetTimedOutSession(const CoinJoinSessionProgress& session); +/** The participants of a session that did not do their part at its current stage */ +struct CoinJoinSessionOffenders { + //! The collateral of each participant that failed, once per participant + std::vector collaterals; + //! How many participants were expected to act at this stage + size_t participants{0}; +}; + +/** + * Who failed to do their part at this stage of a session? While entries are being accepted, every + * admitted participant had to send one. While signing, only the participants whose entries made it + * into the final transaction were asked to sign: a session finalized on timeout still holds the + * collaterals of those that never sent one. A participant counts once however many of its inputs + * are unsigned. + */ +CoinJoinSessionOffenders FindSessionOffenders(PoolState state, const std::vector& session_collaterals, + const std::vector& entries); + +/** How readily one of a session's offenders may be charged, on top of the random sampling every charge goes through */ +enum class OffenderChargeRate { + //! No offence, or every participant failed, which points at the masternode rather than at any one of them + NONE, + //! All but one participant failed: charge only some of the time + REDUCED, + NORMAL, +}; +OffenderChargeRate GetOffenderChargeRate(const CoinJoinSessionOffenders& offenders); + /** Used to keep track of current status of mixing pool */ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler diff --git a/src/test/coinjoin_inouts_tests.cpp b/src/test/coinjoin_inouts_tests.cpp index c5dea2930c42..ec86770a8640 100644 --- a/src/test/coinjoin_inouts_tests.cpp +++ b/src/test/coinjoin_inouts_tests.cpp @@ -17,6 +17,7 @@ #include #include