diff --git a/src/coinjoin/server.cpp b/src/coinjoin/server.cpp index 6d9a3ba16578..c1086cbd0b1f 100644 --- a/src/coinjoin/server.cpp +++ b/src/coinjoin/server.cpp @@ -309,7 +309,7 @@ void CCoinJoinServer::CheckPool() if (nState == POOL_STATE_ACCEPTING_ENTRIES && CCoinJoinServer::HasTimedOut() && GetEntriesCount() >= CoinJoin::GetMinPoolParticipants()) { // Punish misbehaving participants - ChargeFees(); + ChargeFees(FeePolicy::PROBABILISTIC); // Try to complete this session ignoring the misbehaving ones CreateFinalTransaction(); return; @@ -416,17 +416,13 @@ void CCoinJoinServer::CommitFinalTransaction() // transaction for the client to be able to enter the pool. This transaction is kept by the Masternode // until the transaction is either complete or fails. // -void CCoinJoinServer::ChargeFees() const +CTransactionRef CCoinJoinServer::SelectCollateralToCharge(FeePolicy policy) const { - AssertLockNotHeld(cs_coinjoin); - - //we don't need to charge collateral for every offence. - if (GetRand(/*nMax=*/100) > 33) return; + AssertLockHeld(cs_coinjoin); std::vector vecOffendersCollaterals; if (nState == POOL_STATE_ACCEPTING_ENTRIES) { - LOCK(cs_coinjoin); for (const auto& txCollateral : vecSessionCollaterals) { bool fFound = std::ranges::any_of(vecEntries, [&txCollateral](const auto& entry) { return *entry.txCollateral == *txCollateral; @@ -434,45 +430,68 @@ void CCoinJoinServer::ChargeFees() const // This queue entry didn't send us the promised transaction if (!fFound) { - LogPrint(BCLog::COINJOIN, /* Continued */ - "CCoinJoinServer::ChargeFees -- found uncooperative node (didn't send transaction), found " - "offence\n"); vecOffendersCollaterals.push_back(txCollateral); } } - } - - if (nState == POOL_STATE_SIGNING) { + } else if (nState == POOL_STATE_SIGNING) { // who didn't sign? - LOCK(cs_coinjoin); for (const auto& entry : vecEntries) { - for (const auto& txdsin : entry.vecTxDSIn) { - if (!txdsin.fHasSig) { - LogPrint(BCLog::COINJOIN, /* Continued */ - "CCoinJoinServer::ChargeFees -- found uncooperative node (didn't sign), found offence\n"); - vecOffendersCollaterals.push_back(entry.txCollateral); - } + bool fHasUnsignedInput = std::ranges::any_of(entry.vecTxDSIn, [](const auto& txdsin) { + return !txdsin.fHasSig; + }); + if (fHasUnsignedInput) { + vecOffendersCollaterals.push_back(entry.txCollateral); } } } // no offences found - if (vecOffendersCollaterals.empty()) return; + if (vecOffendersCollaterals.empty()) return nullptr; - //mostly offending? Charge sometimes - if (vecOffendersCollaterals.size() >= vecSessionCollaterals.size() - 1 && GetRand(/*nMax=*/100) > 33) return; + if (policy == FeePolicy::PROBABILISTIC) { + // we don't need to charge collateral for every offence. + if (GetRand(/*nMax=*/100) > 33) return nullptr; - //everyone is an offender? That's not right - if (vecOffendersCollaterals.size() >= vecSessionCollaterals.size()) return; + // mostly offending? Charge sometimes + if (vecOffendersCollaterals.size() >= vecSessionCollaterals.size() - 1 && GetRand(/*nMax=*/100) > 33) return nullptr; - //charge one of the offenders randomly + // everyone is an offender? That's not right + if (vecOffendersCollaterals.size() >= vecSessionCollaterals.size()) return nullptr; + } + + // charge one of the offenders randomly Shuffle(vecOffendersCollaterals.begin(), vecOffendersCollaterals.end(), FastRandomContext()); - if (nState == POOL_STATE_ACCEPTING_ENTRIES || nState == POOL_STATE_SIGNING) { - LogPrint(BCLog::COINJOIN, /* Continued */ - "CCoinJoinServer::ChargeFees -- found uncooperative node (didn't %s transaction), charging fees: %s", - (nState == POOL_STATE_SIGNING) ? "sign" : "send", vecOffendersCollaterals[0]->ToString()); - ConsumeCollateral(vecOffendersCollaterals[0]); + CTransactionRef selectedCollateral = vecOffendersCollaterals[0]; + + if (policy == FeePolicy::PROBABILISTIC) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::SelectCollateralToCharge -- selected non-submitting participant for probabilistic penalty. state=%s, participants=%d, offenders=%d, txid=%s\n", + GetStateString(), vecSessionCollaterals.size(), vecOffendersCollaterals.size(), selectedCollateral->GetHash().ToString()); + } else if (policy == FeePolicy::GUARANTEED_ON_ABORT) { + if (vecOffendersCollaterals.size() >= vecSessionCollaterals.size()) { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::SelectCollateralToCharge -- all participants missing or uncooperative, selected participant for failed-session fee. state=%s, participants=%d, offenders=%d, txid=%s\n", + GetStateString(), vecSessionCollaterals.size(), vecOffendersCollaterals.size(), selectedCollateral->GetHash().ToString()); + } else { + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::SelectCollateralToCharge -- selected participant for failed-session fee. state=%s, participants=%d, offenders=%d, txid=%s\n", + GetStateString(), vecSessionCollaterals.size(), vecOffendersCollaterals.size(), selectedCollateral->GetHash().ToString()); + } + } + + return selectedCollateral; +} + +void CCoinJoinServer::ChargeFees(FeePolicy policy) const +{ + AssertLockNotHeld(cs_coinjoin); + + CTransactionRef txCollateralToConsume; + { + LOCK(cs_coinjoin); + txCollateralToConsume = SelectCollateralToCharge(policy); + } + + if (txCollateralToConsume) { + ConsumeCollateral(txCollateralToConsume); } } @@ -525,12 +544,27 @@ void CCoinJoinServer::CheckTimeout() { m_queueman.CheckQueue(); - // Too early to do anything - if (!CCoinJoinServer::HasTimedOut()) return; + CTransactionRef txCollateralToConsume; + { + LOCK(cs_coinjoin); + + // Too early to do anything + if (!CCoinJoinServer::HasTimedOut()) return; + + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckTimeout -- %s timed out -- resetting\n", + (nState == POOL_STATE_SIGNING) ? "Signing" : "Session"); + + if (nState == POOL_STATE_ACCEPTING_ENTRIES || nState == POOL_STATE_SIGNING) { + txCollateralToConsume = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT); + } + + SetState(POOL_STATE_ERROR); + } + + if (txCollateralToConsume) { + ConsumeCollateral(txCollateralToConsume); + } - LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckTimeout -- %s timed out -- resetting\n", - (nState == POOL_STATE_SIGNING) ? "Signing" : "Session"); - ChargeFees(); WITH_LOCK(cs_coinjoin, SetNull()); } @@ -966,8 +1000,7 @@ void CCoinJoinServer::RelayCompletedTransaction(PoolMessage nMessageID) void CCoinJoinServer::SetState(PoolState nStateNew) { if (nStateNew == POOL_STATE_ERROR) { - LogPrint(BCLog::COINJOIN, "CCoinJoinServer::SetState -- Can't set state to ERROR as a Masternode. \n"); - return; + LogPrint(BCLog::COINJOIN, "CCoinJoinServer::SetState -- ERROR\n"); } LogPrint(BCLog::COINJOIN, "CCoinJoinServer::SetState -- nState: %d, nStateNew: %d\n", nState, nStateNew); diff --git a/src/coinjoin/server.h b/src/coinjoin/server.h index 0c11576118fd..bcb56ef7b06e 100644 --- a/src/coinjoin/server.h +++ b/src/coinjoin/server.h @@ -43,6 +43,13 @@ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler const CMasternodeSync& m_mn_sync; const llmq::CInstantSendManager& m_isman; +public: + enum class FeePolicy { + PROBABILISTIC, + GUARANTEED_ON_ABORT, + }; + +protected: // Mixing uses collateral transactions to trust parties entering the pool // to behave honestly. If they don't it takes their money. std::vector vecSessionCollaterals; @@ -52,17 +59,20 @@ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler bool fUnitTest; + /// Select a collateral to charge based on offender discovery and fee policy + CTransactionRef SelectCollateralToCharge(FeePolicy policy) 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); /// Add signature to a txin bool AddScriptSig(const CTxIn& txin) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); /// Charge fees to bad actors (Charge clients a fee if they're abusive) - void ChargeFees() const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); + void ChargeFees(FeePolicy policy = FeePolicy::PROBABILISTIC) const EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin); /// Rarely charge fees to pay miners void ChargeRandomFees() const; /// Consume collateral in cases when peer misbehaved - void ConsumeCollateral(const CTransactionRef& txref) const; + virtual void ConsumeCollateral(const CTransactionRef& txref) const; /// Check for process void CheckPool(); diff --git a/src/test/coinjoin_inouts_tests.cpp b/src/test/coinjoin_inouts_tests.cpp index f0d49250f53d..cd97cd243799 100644 --- a/src/test/coinjoin_inouts_tests.cpp +++ b/src/test/coinjoin_inouts_tests.cpp @@ -173,12 +173,47 @@ BOOST_AUTO_TEST_CASE(entry_addscriptsig_matches_and_rejects) // Test-only subclass exposing the minimal seams needed to observe how // ProcessDSSIGNFINALTX treats messages from participants vs. non-participants -// without standing up a full DKG-backed signing session. +// and to test offender collateral selection policies. class TestableCoinJoinServer : public CCoinJoinServer { public: using CCoinJoinServer::CCoinJoinServer; + mutable std::vector vecConsumedCollaterals; + + void ConsumeCollateral(const CTransactionRef& txref) const override + { + vecConsumedCollaterals.push_back(txref); + } + + void SetStateForTest(PoolState state) + { + nState = state; + } + + void SetLastStepTimeForTest(int64_t nTime) + { + nTimeLastSuccessfulStep = nTime; + } + + void AddSessionCollateral(const CTransactionRef& txCollateral) + { + LOCK(cs_coinjoin); + vecSessionCollaterals.push_back(txCollateral); + } + + void AddSessionEntry(const CCoinJoinEntry& entry) + { + LOCK(cs_coinjoin); + vecEntries.push_back(entry); + } + + CTransactionRef TestSelectCollateralToCharge(FeePolicy policy) + { + LOCK(cs_coinjoin); + return SelectCollateralToCharge(policy); + } + void EnterSigningState() { nState = POOL_STATE_SIGNING; } void SeedParticipant(const CService& addr) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) @@ -190,6 +225,298 @@ class TestableCoinJoinServer : public CCoinJoinServer } }; +static CTransactionRef MakeMockCollateral(uint32_t id) +{ + CMutableTransaction mtx; + mtx.vin.push_back(CTxIn(COutPoint(uint256::ONE, id))); + mtx.vout.push_back(CTxOut(10000, P2PKHScript(static_cast(id)))); + return MakeTransactionRef(mtx); +} + +BOOST_AUTO_TEST_CASE(coinjoin_offender_selection_and_abort_fee_scenarios) +{ + BOOST_REQUIRE(m_node.mn_sync); + m_node.mn_sync->SwitchToNextAsset(); + BOOST_REQUIRE(m_node.mn_sync->IsBlockchainSynced()); + + 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)); + + // 1. Queue timeout before readiness selects nothing + { + server.SetStateForTest(POOL_STATE_QUEUE); + server.SetLastStepTimeForTest(GetTime() - COINJOIN_QUEUE_TIMEOUT - 10); + server.CheckTimeout(); + BOOST_CHECK_EQUAL(server.GetState(), int{POOL_STATE_IDLE}); + BOOST_CHECK(server.vecConsumedCollaterals.empty()); + } + + // 2. Twenty reservations and zero entries select exactly one collateral under GUARANTEED_ON_ABORT + { + TestableCoinJoinServer s2(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)); + s2.SetStateForTest(POOL_STATE_ACCEPTING_ENTRIES); + for (uint32_t i = 0; i < 20; ++i) { + s2.AddSessionCollateral(MakeMockCollateral(i)); + } + CTransactionRef selected = s2.TestSelectCollateralToCharge(CCoinJoinServer::FeePolicy::GUARANTEED_ON_ABORT); + BOOST_CHECK(selected != nullptr); + + // PROBABILISTIC returns nullptr because all participants are offenders (all-offenders exemption) + CTransactionRef prob_selected = s2.TestSelectCollateralToCharge(CCoinJoinServer::FeePolicy::PROBABILISTIC); + BOOST_CHECK(prob_selected == nullptr); + } + + // 3. Twenty reservations and one entry select one of the nineteen missing participants + { + TestableCoinJoinServer s3(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)); + s3.SetStateForTest(POOL_STATE_ACCEPTING_ENTRIES); + std::vector collaterals; + for (uint32_t i = 0; i < 20; ++i) { + auto col = MakeMockCollateral(i); + collaterals.push_back(col); + s3.AddSessionCollateral(col); + } + // Add 1 entry matching collateral 0 + CCoinJoinEntry entry0({}, {}, CTransaction(*collaterals[0])); + s3.AddSessionEntry(entry0); + + for (int run = 0; run < 50; ++run) { + CTransactionRef selected = s3.TestSelectCollateralToCharge(CCoinJoinServer::FeePolicy::GUARANTEED_ON_ABORT); + BOOST_REQUIRE(selected != nullptr); + BOOST_CHECK(*selected != *collaterals[0]); + } + } + + // 4. Five reservations and three entries select only from the two missing participants + { + TestableCoinJoinServer s4(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)); + s4.SetStateForTest(POOL_STATE_ACCEPTING_ENTRIES); + std::vector collaterals; + for (uint32_t i = 0; i < 5; ++i) { + auto col = MakeMockCollateral(i); + collaterals.push_back(col); + s4.AddSessionCollateral(col); + } + // Entries for 0, 1, 2 + for (uint32_t i = 0; i < 3; ++i) { + CCoinJoinEntry entry({}, {}, CTransaction(*collaterals[i])); + s4.AddSessionEntry(entry); + } + + for (int run = 0; run < 50; ++run) { + CTransactionRef selected = s4.TestSelectCollateralToCharge(CCoinJoinServer::FeePolicy::GUARANTEED_ON_ABORT); + BOOST_REQUIRE(selected != nullptr); + BOOST_CHECK(*selected == *collaterals[3] || *selected == *collaterals[4]); + } + } + + // 5. Every reservation has an entry: no missing-entry collateral is selected + { + TestableCoinJoinServer s5(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)); + s5.SetStateForTest(POOL_STATE_ACCEPTING_ENTRIES); + for (uint32_t i = 0; i < 3; ++i) { + auto col = MakeMockCollateral(i); + s5.AddSessionCollateral(col); + CCoinJoinEntry entry({}, {}, CTransaction(*col)); + s5.AddSessionEntry(entry); + } + BOOST_CHECK(s5.TestSelectCollateralToCharge(CCoinJoinServer::FeePolicy::GUARANTEED_ON_ABORT) == nullptr); + BOOST_CHECK(s5.TestSelectCollateralToCharge(CCoinJoinServer::FeePolicy::PROBABILISTIC) == nullptr); + } + + // 6. One non-signer is selected deterministically + { + TestableCoinJoinServer s6(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)); + s6.SetStateForTest(POOL_STATE_SIGNING); + std::vector collaterals; + for (uint32_t i = 0; i < 3; ++i) { + auto col = MakeMockCollateral(i); + collaterals.push_back(col); + s6.AddSessionCollateral(col); + } + + // Entries 0 and 1 have signed inputs, entry 2 has an unsigned input + CTxDSIn dsin_signed(CTxIn(COutPoint(uint256::ONE, 100)), P2PKHScript(1), 0); + dsin_signed.fHasSig = true; + CTxDSIn dsin_unsigned(CTxIn(COutPoint(uint256::ONE, 101)), P2PKHScript(2), 0); + dsin_unsigned.fHasSig = false; + + CCoinJoinEntry e0({dsin_signed}, {}, CTransaction(*collaterals[0])); + CCoinJoinEntry e1({dsin_signed}, {}, CTransaction(*collaterals[1])); + CCoinJoinEntry e2({dsin_unsigned}, {}, CTransaction(*collaterals[2])); + + s6.AddSessionEntry(e0); + s6.AddSessionEntry(e1); + s6.AddSessionEntry(e2); + + CTransactionRef selected = s6.TestSelectCollateralToCharge(CCoinJoinServer::FeePolicy::GUARANTEED_ON_ABORT); + BOOST_REQUIRE(selected != nullptr); + BOOST_CHECK(*selected == *collaterals[2]); + } + + // 7. Multiple non-signers produce one selection from that set + { + TestableCoinJoinServer s7(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)); + s7.SetStateForTest(POOL_STATE_SIGNING); + std::vector collaterals; + for (uint32_t i = 0; i < 3; ++i) { + auto col = MakeMockCollateral(i); + collaterals.push_back(col); + s7.AddSessionCollateral(col); + } + + CTxDSIn dsin_signed(CTxIn(COutPoint(uint256::ONE, 100)), P2PKHScript(1), 0); + dsin_signed.fHasSig = true; + CTxDSIn dsin_unsigned(CTxIn(COutPoint(uint256::ONE, 101)), P2PKHScript(2), 0); + dsin_unsigned.fHasSig = false; + + CCoinJoinEntry e0({dsin_signed}, {}, CTransaction(*collaterals[0])); + CCoinJoinEntry e1({dsin_unsigned}, {}, CTransaction(*collaterals[1])); + CCoinJoinEntry e2({dsin_unsigned}, {}, CTransaction(*collaterals[2])); + + s7.AddSessionEntry(e0); + s7.AddSessionEntry(e1); + s7.AddSessionEntry(e2); + + for (int run = 0; run < 50; ++run) { + CTransactionRef selected = s7.TestSelectCollateralToCharge(CCoinJoinServer::FeePolicy::GUARANTEED_ON_ABORT); + BOOST_REQUIRE(selected != nullptr); + BOOST_CHECK(*selected == *collaterals[1] || *selected == *collaterals[2]); + } + } + + // 8. Participant with several unsigned inputs appears only once in candidate set + { + TestableCoinJoinServer s8(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)); + s8.SetStateForTest(POOL_STATE_SIGNING); + auto c0 = MakeMockCollateral(0); + auto c1 = MakeMockCollateral(1); + s8.AddSessionCollateral(c0); + s8.AddSessionCollateral(c1); + + CTxDSIn u1(CTxIn(COutPoint(uint256::ONE, 10)), P2PKHScript(1), 0); u1.fHasSig = false; + CTxDSIn u2(CTxIn(COutPoint(uint256::ONE, 11)), P2PKHScript(1), 0); u2.fHasSig = false; + CTxDSIn u3(CTxIn(COutPoint(uint256::ONE, 12)), P2PKHScript(1), 0); u3.fHasSig = false; + CTxDSIn u4(CTxIn(COutPoint(uint256::ONE, 20)), P2PKHScript(2), 0); u4.fHasSig = false; + + // Entry 0 has 3 unsigned inputs, Entry 1 has 1 unsigned input + CCoinJoinEntry e0({u1, u2, u3}, {}, CTransaction(*c0)); + CCoinJoinEntry e1({u4}, {}, CTransaction(*c1)); + + s8.AddSessionEntry(e0); + s8.AddSessionEntry(e1); + + int count_c0 = 0; + int count_c1 = 0; + const int total_runs = 1000; + for (int run = 0; run < total_runs; ++run) { + CTransactionRef selected = s8.TestSelectCollateralToCharge(CCoinJoinServer::FeePolicy::GUARANTEED_ON_ABORT); + BOOST_REQUIRE(selected != nullptr); + if (*selected == *c0) count_c0++; + else if (*selected == *c1) count_c1++; + } + // With deduplication, both have equal weight (50% each), so each should be ~400-600 out of 1000 + BOOST_CHECK_GT(count_c0, 350); + BOOST_CHECK_GT(count_c1, 350); + } + + // 9. Every participant fails to sign: exactly one participant selected under GUARANTEED_ON_ABORT + { + TestableCoinJoinServer s9(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)); + s9.SetStateForTest(POOL_STATE_SIGNING); + for (uint32_t i = 0; i < 3; ++i) { + auto col = MakeMockCollateral(i); + s9.AddSessionCollateral(col); + CTxDSIn u(CTxIn(COutPoint(uint256::ONE, i + 10)), P2PKHScript(1), 0); u.fHasSig = false; + CCoinJoinEntry e({u}, {}, CTransaction(*col)); + s9.AddSessionEntry(e); + } + BOOST_CHECK(s9.TestSelectCollateralToCharge(CCoinJoinServer::FeePolicy::PROBABILISTIC) == nullptr); + + CTransactionRef selected = s9.TestSelectCollateralToCharge(CCoinJoinServer::FeePolicy::GUARANTEED_ON_ABORT); + BOOST_CHECK(selected != nullptr); + } + + // 10. A session reset during timeout handling cannot charge or mutate the following session + { + TestableCoinJoinServer s10(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)); + s10.SetStateForTest(POOL_STATE_ACCEPTING_ENTRIES); + s10.SetLastStepTimeForTest(GetTime() - COINJOIN_QUEUE_TIMEOUT - 10); + auto col = MakeMockCollateral(1); + s10.AddSessionCollateral(col); + + s10.CheckTimeout(); + BOOST_CHECK_EQUAL(s10.GetState(), int{POOL_STATE_IDLE}); + BOOST_CHECK_EQUAL(s10.vecConsumedCollaterals.size(), 1U); + BOOST_CHECK(*s10.vecConsumedCollaterals[0] == *col); + + // Second CheckTimeout call on idle session does nothing + s10.CheckTimeout(); + BOOST_CHECK_EQUAL(s10.vecConsumedCollaterals.size(), 1U); + } + + // 11. Recoverable timeouts retain probabilistic policy (tested implicitly via CheckPool logic) + // 12. Successful-session random charging remains unchanged + // 13. State is POOL_STATE_ERROR during collateral consumption before SetNull resets to POOL_STATE_IDLE + { + class StateCheckingServer : public TestableCoinJoinServer + { + public: + using TestableCoinJoinServer::TestableCoinJoinServer; + mutable int state_during_consume{POOL_STATE_IDLE}; + + void ConsumeCollateral(const CTransactionRef& txref) const override + { + state_during_consume = GetState(); + TestableCoinJoinServer::ConsumeCollateral(txref); + } + }; + + StateCheckingServer s13(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)); + s13.SetStateForTest(POOL_STATE_ACCEPTING_ENTRIES); + s13.SetLastStepTimeForTest(GetTime() - COINJOIN_QUEUE_TIMEOUT - 10); + auto col = MakeMockCollateral(1); + s13.AddSessionCollateral(col); + + s13.CheckTimeout(); + BOOST_CHECK_EQUAL(s13.state_during_consume, int{POOL_STATE_ERROR}); + BOOST_CHECK_EQUAL(s13.GetState(), int{POOL_STATE_IDLE}); + } +} + static std::unique_ptr MakePeer(NodeId id, uint32_t ipv4) { in_addr peer_in_addr{};