Skip to content

fix: recheck live CoinJoin side coverage before finalizing timeout session - #7622

Open
PastaPastaPasta wants to merge 1 commit into
dashpay:developfrom
PastaPastaPasta:fix/coinjoin-finalization-coverage
Open

fix: recheck live CoinJoin side coverage before finalizing timeout session#7622
PastaPastaPasta wants to merge 1 commit into
dashpay:developfrom
PastaPastaPasta:fix/coinjoin-finalization-coverage

Conversation

@PastaPastaPasta

Copy link
Copy Markdown
Member

Issue being fixed

Follow-up to #7052 (promotion/demotion). CheckPool decides to finalize a timed-out POOL_STATE_ACCEPTING_ENTRIES session from a snapshot taken before ChargeFees(), but AddEntry can commit a pending DSVIN while that call runs since the session is still accepting entries. For example, a snapshot of 3 demotions is covered (0/3), but if the admitted set also contains 2 promoters, a promoter committing mid-fee-charge changes live sides to 1/3. CreateFinalTransaction previously only rechecked session identity/state and then published an uncovered transaction with a lone promoter.

What was done?

  • In CCoinJoinServer::CreateFinalTransaction, recheck live GetMixSideCountsLocked().IsCovered() and the minimum entry count under the same cs_coinjoin lock used to build the final transaction and transition to POOL_STATE_SIGNING.
  • Add src/test/coinjoin_inouts_tests.cpp:server_finalization_rechecks_live_side_coverage reproducing the 3D + 1P = 1/3 regression (must stay in accepting state).

How Has This Been Tested?

  • ./src/test/test_dash --run_test=coinjoin_inouts_tests/server_finalization_rechecks_live_side_coverage
  • Local build make -j14

This pull request was created by Codex.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6453c2d3-8f48-456a-9f8d-d47d3a1202f4

📥 Commits

Reviewing files that changed from the base of the PR and between b6f953e and 531f117.

📒 Files selected for processing (2)
  • src/coinjoin/server.cpp
  • src/test/coinjoin_inouts_tests.cpp

Included review availability: Your plan provides up to 4 included reviews per hour; 1 remains after this review.


Walkthrough

CreateFinalTransaction now rechecks the live session under cs_coinjoin before building a final transaction. It rejects sessions with too few participants or incomplete promotion/demotion side coverage. The declaration moved within CCoinJoinServer without a signature change. Tests expose the method and verify that incomplete coverage retains all entries in the accepting state.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: 🟡 Moderate · up to 531f1

A timed-out session can be reset instead of cleanly remaining available for a final live-coverage check, which may prevent the remaining participant from restoring coverage and leave the session in the wrong state. This should be addressed or explicitly accepted before merging.

Suggested reviewers: knst

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: rechecking live CoinJoin side coverage before finalizing a timeout session.
Description check ✅ Passed The description directly explains the race condition, implementation, regression test, and validation steps.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Aug 19, 2026

Copy link
Copy Markdown

🔍 Review in progress — actively reviewing now (commit 531f117)
Stage: Codex precheck starting
ETA: complete ~16:35 UTC (median 21m across 30 recent reviews)
Running 19m · Last checked: 2026-08-23 16:30 UTC

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Final validation — Codex/Sol only (Phase 2 disabled)

The exact-head diff correctly revalidates the live participant count and side coverage while holding cs_coinjoin, preventing entry admission from invalidating eligibility before the session transitions to signing. The regression test directly exercises the uncovered 3-demotion/1-promotion state, and no in-scope defects were identified; the targeted test binary was not available in this worktree for execution.
Source: reviewers gpt-5.6-sol (general and dash-core-commit-history); final verifier gpt-5.6-sol. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol (not reviewer evidence).

Validated zero-blocker Codex/Sol precheck evidence was promoted to final because Phase 2 (Sonnet/Opus) is temporarily disabled. This is Codex/Sol-only final validation, not Codex + Sonnet/Opus coverage.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet/Opus: not run (Phase 2 disabled — temporary Codex/Sol-only final)
  • Secondary pass: disabled (temporary_phase2_sonnet_disable)

@PastaPastaPasta
PastaPastaPasta force-pushed the fix/coinjoin-finalization-coverage branch from b091d07 to 3df8074 Compare August 20, 2026 13:32

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Final validation — Codex/Sol only (Phase 2 disabled)

The exact-head change revalidates the live participant minimum and side-coverage invariant while holding cs_coinjoin, preventing a concurrently admitted entry from producing an uncovered final transaction. The regression test exercises the 3-demotion/1-promotion state and confirms that the session remains open; no in-scope defects were identified.
Source: reviewers gpt-5.6-sol (general and dash-core-commit-history); final verifier claude-opus-4-6. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol (not reviewer evidence).

Validated zero-blocker Codex/Sol precheck evidence was promoted to final because Phase 2 (Sonnet/Opus) is temporarily disabled. This is Codex/Sol-only final validation, not Codex + Sonnet/Opus coverage.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet/Opus: not run (Phase 2 disabled — temporary Codex/Sol-only final)
  • Secondary pass: disabled (temporary_phase2_sonnet_disable)

@PastaPastaPasta
PastaPastaPasta force-pushed the fix/coinjoin-finalization-coverage branch from 3df8074 to b6f953e Compare August 20, 2026 15:55
@PastaPastaPasta PastaPastaPasta changed the title fix(coinjoin): recheck live side coverage before finalizing timeout session fix: recheck live CoinJoin side coverage before finalizing timeout session Aug 20, 2026

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Preliminary review — Codex only

The live coverage recheck prevents construction of an uncovered final transaction, but its rejection path does not preserve the session in the production scheduler flow. Because the timeout remains expired, the immediately following timeout check charges fees again and resets the session, defeating the stated goal of waiting for the pending counterparty.
Source: reviewers gpt-5.6-sol (general and dash-core-commit-history); final verifier claude-opus-4-6. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol (not reviewer evidence).

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/coinjoin/server.cpp`:
- [BLOCKING] src/coinjoin/server.cpp:385-388: 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.

Comment thread src/coinjoin/server.cpp
Comment on lines +385 to +388
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;

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.

…ession

The timeout branch in CheckPool decides to finalize based on a snapshot
taken before ChargeFees, but entries can commit while that call runs
since the session stays in POOL_STATE_ACCEPTING_ENTRIES. This could
change a covered 0/3 side into an uncovered 1/3 side, which
CreateFinalTransaction previously accepted after only checking the
session id/state and then built from the live vecEntries.

Validate the live MixSideCounts and minimum entry count under the same
cs_coinjoin lock used to build the final transaction so the decision
cannot be invalidated between check and construction.

Add a unit test exercising the 3-demotion + 1-promotion (1/3)
regression that must stay in accepting state until the second
promoter arrives.
@PastaPastaPasta
PastaPastaPasta force-pushed the fix/coinjoin-finalization-coverage branch from b6f953e to 531f117 Compare August 23, 2026 15:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants