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
14 changes: 13 additions & 1 deletion src/coinjoin/server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -373,11 +373,23 @@ void CCoinJoinServer::CreateFinalTransaction(int session_id)
LOCK(cs_coinjoin);

// The decision to finalize came from a snapshot taken before this lock, so make sure it
// still describes the live session - it may have timed out and been replaced in between.
// still describes an eligible live session. An entry can finish validation and commit while
// the timeout path charges fees, changing a covered side from empty to a lone participant.
// Check the live entries under the same lock used to build the transaction so that entry
// admission cannot invalidate the decision before the state moves to signing.
if (!IsCurrentSession(session_id)) {
LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CreateFinalTransaction -- session changed, not finalizing\n");
return;
}
const auto sides = GetMixSideCountsLocked();
if (vecEntries.size() < static_cast<size_t>(CoinJoin::GetMinPoolParticipants()) || !sides.IsCovered()) {
LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CreateFinalTransaction -- session no longer eligible, entries=%d, sides=%d/%d\n",
vecEntries.size(), sides.inputs, sides.outputs);
// Deliberately no timer refresh: on the timeout path the session stays timed out and
// the scheduler's next CheckTimeout() resets it. The missing side already had the full
// entry window, so waiting another one would only keep everyone else's coins locked.
return;
Comment on lines +385 to +391

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Rejected finalization is immediately reset by CheckTimeout

This return does not preserve the timed-out session in production. Schedule() invokes CheckPool() and then CheckTimeout() in the same callback. After CreateFinalTransaction() rejects the newly uncovered live state, nState remains POOL_STATE_ACCEPTING_ENTRIES and nTimeLastSuccessfulStep remains expired, so CheckTimeout() immediately invokes ChargeFees() a second time and calls SetNull(). The remaining admitted promoter therefore has almost no opportunity to restore coverage, contrary to the PR's stated behavior. Refresh or otherwise resolve the timeout before returning, and extend the regression test to exercise the CheckPool() followed by CheckTimeout() scheduler sequence rather than calling CreateFinalTransaction() alone.

Suggested change
if (vecEntries.size() < static_cast<size_t>(CoinJoin::GetMinPoolParticipants()) || !sides.IsCovered()) {
LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CreateFinalTransaction -- session no longer eligible, entries=%d, sides=%d/%d\n",
vecEntries.size(), sides.inputs, sides.outputs);
return;
if (vecEntries.size() < static_cast<size_t>(CoinJoin::GetMinPoolParticipants()) || !sides.IsCovered()) {
LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CreateFinalTransaction -- session no longer eligible, entries=%d, sides=%d/%d\n",
vecEntries.size(), sides.inputs, sides.outputs);
nTimeLastSuccessfulStep = GetTime();
return;
}

source: ['codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the mechanics: correct — Schedule() runs CheckPool() then CheckTimeout() in the same callback, a committed entry never refreshes nTimeLastSuccessfulStep (only SetState/SetNull write it server-side), so the refused session is reset by CheckTimeout() in the same tick. The wording "leave the session open" in the test comment was wrong and has been fixed in 531f117.

Declining the suggested timer refresh, though — resetting the timed-out session is the intended outcome, not a defect:

  • The fix's guarantee is "never publish an uncovered final transaction". That holds either way; what's at issue is only the disposition of a session that already blew its full 30s COINJOIN_QUEUE_TIMEOUT, and CheckTimeout() resetting such a session is the established cleanup path.
  • Admission requires the declared shapes to be covered, so a lone committed promoter implies at least one more admitted promoter who already had the entire entry window and didn't deliver. Refreshing nTimeLastSuccessfulStep would grant everyone another full window on the off chance that straggler shows up, keeping the present participants' inputs and collateral locked for 30 more seconds before the same reset. It would also stamp "last successful step" when nothing succeeded, and let the timeout that already fired be waived.
  • The double ChargeFees() roll in that tick is real but bounded: each call proceeds with 33% probability and consumes at most one collateral, from participants who genuinely never submitted.

531f117 documents the deliberate no-refresh at the early return and rewords the test comment to describe the actual behavior (refuse to build, stay out of POOL_STATE_SIGNING, session then falls to CheckTimeout()). Not extending the test to drive the full CheckPool()CheckTimeout() sequence: the unit under test is the finalization recheck, and the scheduler sequence would drag in ChargeFees/collateral-relay machinery without strengthening the assertion that matters.

A follow-up may relay ERR_SESSION on this path (mirroring the uncovered-full-entries branch in CheckPool()) so clients release their inputs immediately instead of waiting out their own lag timeout.


🤖 Posted autonomously by Claude on behalf of pasta.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Rejected finalization is immediately reset by CheckTimeout no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

}

CMutableTransaction txNew;

Expand Down
4 changes: 2 additions & 2 deletions src/coinjoin/server.h
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler
bool AddEntry(const CCoinJoinEntry& entry, PoolMessage& nMessageIDRet) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin);
/// Record an accepted collateral and index its input prevouts
void CommitSessionCollateral(const CMutableTransaction& txCollateral) EXCLUSIVE_LOCKS_REQUIRED(cs_coinjoin);
/// Build and relay the final transaction if the live session is still eligible
void CreateFinalTransaction(int session_id) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin);

private:
bool fUnitTest;
Expand Down Expand Up @@ -114,8 +116,6 @@ class CCoinJoinServer : public CCoinJoinBaseSession, public NetHandler
/// Check for process
void CheckPool();

/// Build and relay the final transaction, unless session_id is no longer the live session
void CreateFinalTransaction(int session_id) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin);
void CommitFinalTransaction() EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin);

/// Is this nDenom and txCollateral acceptable?
Expand Down
41 changes: 40 additions & 1 deletion src/test/coinjoin_inouts_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,9 @@ BOOST_AUTO_TEST_CASE(entry_addscriptsig_matches_and_rejects)
class TestableCoinJoinServer : public CCoinJoinServer
{
public:
using CCoinJoinServer::CCoinJoinServer;
using CCoinJoinServer::AddEntry;
using CCoinJoinServer::CCoinJoinServer;
using CCoinJoinServer::CreateFinalTransaction;

// A live session always carries a non-zero id, and AddEntry rejects entries that don't
// belong to one, so seed an id along with the state.
Expand Down Expand Up @@ -427,6 +428,44 @@ BOOST_AUTO_TEST_CASE(server_addentry_rejects_entries_once_the_session_finalized)
BOOST_CHECK_EQUAL(server.GetEntriesCount(), 0);
}

BOOST_AUTO_TEST_CASE(server_finalization_rechecks_live_side_coverage)
{
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.isman));

const auto make_entry = [](CoinJoin::MixShape shape, uint32_t tag) {
const size_t input_count{shape == CoinJoin::MixShape::PROMOTION ? size_t{CoinJoin::PROMOTION_RATIO} : 1};
const size_t output_count{shape == CoinJoin::MixShape::DEMOTION ? size_t{CoinJoin::PROMOTION_RATIO} : 1};
std::vector<CTxDSIn> inputs;
std::vector<CTxOut> outputs;
for (size_t i{0}; i < input_count; ++i) {
inputs.emplace_back(CTxIn{COutPoint{uint256::ONE, tag + static_cast<uint32_t>(i)}}, P2PKHScript(), 0);
}
for (size_t i{0}; i < output_count; ++i) {
outputs.emplace_back(CoinJoin::GetSmallestDenomination(), P2PKHScript(static_cast<uint8_t>(tag + i)));
}
return CCoinJoinEntry{inputs, outputs, CTransaction{CMutableTransaction{}}};
};

server.SeedEntry(make_entry(CoinJoin::MixShape::DEMOTION, 0));
server.SeedEntry(make_entry(CoinJoin::MixShape::DEMOTION, 10));
server.SeedEntry(make_entry(CoinJoin::MixShape::DEMOTION, 20));
server.SeedEntry(make_entry(CoinJoin::MixShape::PROMOTION, 30));
server.EnterAcceptingEntriesState();

// A timeout snapshot could have observed only the three demotions as covered (0/3), then
// this first promotion could commit while ChargeFees() ran. Finalization must use the live
// 1/3 side counts and refuse to build the uncovered transaction, staying out of
// POOL_STATE_SIGNING; the still-timed-out session is then reset by the scheduler's
// regular CheckTimeout() pass instead of leaking a lone promoter on-chain.
server.CreateFinalTransaction(/*session_id=*/1);
BOOST_CHECK_EQUAL(server.GetState(), int{POOL_STATE_ACCEPTING_ENTRIES});
BOOST_CHECK_EQUAL(server.GetEntriesCount(), 4);
}

BOOST_AUTO_TEST_CASE(entry_deserializes_vectors_through_wire_cap)
{
const size_t wire_cap{CoinJoin::GetMaxPoolInputOutputCount()};
Expand Down