Skip to content

fix: charge collateral when a CoinJoin session aborts - #7568

Open
PastaPastaPasta wants to merge 12 commits into
dashpay:developfrom
PastaPastaPasta:fix/coinjoin-abort-fee-on-7537
Open

fix: charge collateral when a CoinJoin session aborts#7568
PastaPastaPasta wants to merge 12 commits into
dashpay:developfrom
PastaPastaPasta:fix/coinjoin-abort-fee-on-7537

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Aug 10, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

CoinJoin participants can currently reserve a coordinator slot and then abort during entry submission or signing without necessarily losing collateral. In particular, the existing probabilistic policy exempts sessions where every participant is an offender, allowing coordinated non-cooperation to repeatedly kill sessions without cost.

This PR is intentionally built on #7537, which makes fee selection and session reset atomic. It should be reviewed and merged after that prerequisite.

What was done?

  • Split offender discovery from fee policy with explicit PROBABILISTIC and GUARANTEED_ON_ABORT modes.
  • Deduplicate signing offenders so participants with multiple unsigned inputs receive no extra selection weight.
  • Preserve the existing probabilistic policy when enough entries remain to finalize a mix.
  • Charge exactly one uniformly selected missing submitter or non-signer when non-cooperation forces a session reset.
  • Keep queue timeouts free: participants are not penalized before they are asked to submit entries.
  • Select the collateral and call SetNull() under cs_coinjoin, then consume the selected collateral after releasing the lock.
  • Add state-aware logs with participant count, offender count, and selected collateral txid.
  • Skip the guaranteed timeout charge for a session this coordinator already told its participants to abandon (session-wide STATUS_REJECTED): honest clients obey the abort, release their inputs, and stop signing, so they must not form the offender set. The participant whose invalid signature forced the abort is charged directly in ProcessDSSIGNFINALTX() instead.
  • Re-evaluate IsSignaturesComplete() when CheckTimeout() inspects a signing session, and commit a fully signed transaction instead of discarding it — the final DSSIGNFINALTX can land in a scheduler round whose CheckPool() already sampled the signatures as incomplete.
  • Remove an extra closing brace currently present in fix: data races and check-then-act races in the CoinJoin server #7537's server.cpp head so the stacked branch compiles.

How Has This Been Tested?

Built src/test/test_dash locally on macOS arm64 using the prebuilt depends prefix, then ran:

./src/test/test_dash --run_test=coinjoin_inouts_tests
test/lint/lint-whitespace.py
git diff --check

The unit coverage exercises queue timeouts, all/many/few/no missing entries, lone and multiple non-signers, deduplication of participants with several unsigned inputs, all-participant signing failure, timeout/reset atomicity, the recoverable probabilistic policy, successful-session random charging, committing a fully signed session at timeout, direct saboteur charging, and forgoing the guaranteed charge after a relayed abort.

Breaking Changes

No wire-format, wallet, database, persistent-format, or consensus change. Mixed-version operation remains safe; only upgraded masternodes apply the guaranteed failed-session fee.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation
  • I have assigned this pull request to a milestone (for repository code-owners and collaborators only)

This pull request was created by Codex.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Potential PR merge conflicts

This is advisory only. It does not block CI, but it marks PRs that will likely need a rebase depending on merge order.

If this PR merges first

These open PRs will likely need a rebase:

  • #7052: feat: coinjoin promotion / demotion Changed files: src/coinjoin/client.cpp, src/coinjoin/coinjoin.cpp, src/coinjoin/coinjoin.h, src/coinjoin/server.cpp, src/coinjoin/server.h, src/test/coinjoin_inouts_tests.cpp.

@PastaPastaPasta PastaPastaPasta changed the title fix(coinjoin): charge collateral when a session aborts fix: charge collateral when a CoinJoin session aborts Aug 10, 2026
@thepastaclaw

thepastaclaw commented Aug 10, 2026

Copy link
Copy Markdown

⛔ Blockers found — Opus deferred (commit 3fc743f)
Canonical validated blockers: 1

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2bc5dbd248

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/coinjoin/server.cpp
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) {
collateral_to_charge = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Exclude in-flight entries from guaranteed timeout penalties

When a valid DSVIN arrives before the deadline but AddEntry() is still blocked in IsCollateralValid() or IsValidInOuts() outside cs_coinjoin, the scheduler can observe fewer than the minimum committed entries here and classify that participant as a missing submitter. With three reservations, two committed entries, and the third validation in flight, this path deterministically selects and consumes the third participant's collateral, resets the session, and then makes the already-received entry fail its session revalidation. Track in-flight submissions or serialize this cutoff with entry processing so an honest pre-deadline submission cannot receive the guaranteed abort penalty.

AGENTS.md reference: AGENTS.md:L173-L175

Useful? React with 👍 / 👎.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

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: dd393908-9203-402f-a9c3-15b66f6e9c1e

📥 Commits

Reviewing files that changed from the base of the PR and between a67fc15 and 118d995.

📒 Files selected for processing (3)
  • src/coinjoin/server.cpp
  • src/coinjoin/server.h
  • src/test/coinjoin_inouts_tests.cpp
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/coinjoin/server.h
  • src/coinjoin/server.cpp

Walkthrough

CoinJoin session denomination is now atomic, and validation uses captured denomination values. Server collateral state is consolidated in a lock-protected container. Pool checks, message handling, entry admission, finalization, and timeout processing use session snapshots and lock coordination. Fee selection supports probabilistic and guaranteed-abort policies. Tests cover collateral selection, timeout ordering, in-flight messages, pool locking, session recovery, and concurrent denomination validation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Mergeability Score: ⚪ Minimal · up to 118d9

The PR adds collateral charging for non-cooperative aborted sessions while preserving free queue timeouts and expanding test coverage. No actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant CoinJoinClient
  participant CCoinJoinServer
  participant cs_coinjoin
  participant SessionCollaterals
  participant Mempool
  CoinJoinClient->>CCoinJoinServer: submit entry or signing message
  CCoinJoinServer->>cs_coinjoin: capture and validate session state
  CCoinJoinServer->>SessionCollaterals: check or store collateral
  CCoinJoinServer->>cs_coinjoin: finalize session or update state
  CCoinJoinServer->>Mempool: process final transaction
Loading

Possibly related PRs

  • dashpay/dash#7537: Implements related CoinJoin race-condition fixes across the same session state and server paths.
  • dashpay/dash#7566: Introduces related FeePolicy, collateral selection, and fee-charging changes.
  • dashpay/dash#7567: Covers related collateral fee-policy and abort-handling changes.

Suggested reviewers: udjinm6, knst

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: charging collateral when a CoinJoin session aborts.
Description check ✅ Passed The description directly explains the collateral, fee-policy, synchronization, and test changes in the pull request.
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

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

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (1)
src/coinjoin/coinjoin.h (1)

345-350: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Correct the logging explanation. tinyformat formats std::atomic<int> through its implicit conversion to int. LogPrint() accepts arguments by const reference, which avoids copying the non-copyable atomic. WalletCJLogPrint() forwards to CWallet::WalletLogPrintf, whose parameters are passed by value, so .load() is required to pass an int.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/coinjoin/coinjoin.h` around lines 345 - 350, Update the comments above
nSessionDenom to accurately explain that tinyformat uses the atomic’s implicit
int conversion, LogPrint() accepts it by const reference without copying, and
WalletCJLogPrint() forwards to CWallet::WalletLogPrintf with by-value
parameters, requiring an explicit .load().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/coinjoin/coinjoin.h`:
- Around line 345-350: Update the comments above nSessionDenom to accurately
explain that tinyformat uses the atomic’s implicit int conversion, LogPrint()
accepts it by const reference without copying, and WalletCJLogPrint() forwards
to CWallet::WalletLogPrintf with by-value parameters, requiring an explicit
.load().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7c98ea00-f5ed-46f1-b28d-035fcce2d4d4

📥 Commits

Reviewing files that changed from the base of the PR and between 1fbf489 and 2bc5dbd.

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

@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 guaranteed abort-fee policy can still consume collateral from honest submissions already being processed, and a separate TRY_LOCK interleaving can reset sessions that remain recoverable. The commit stack also contains four syntactically unbuildable intermediate commits, while the offender-deduplication behavior change is obscured by a refactor-only commit subject.
Source: reviewer backends: gpt-5.6-sol (general), gpt-5.6-sol (dash-core-commit-history); final verifier backend: gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and is 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)

🔴 3 blocking | 🟡 1 suggestion(s)

🤖 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:625-628: Exclude in-flight submissions from guaranteed timeout penalties
  `SelectCollateralToCharge()` only recognizes entries already committed to `vecEntries`, but `AddEntry()` releases `cs_coinjoin` while running `IsCollateralValid()` and `IsValidInOuts()` at lines 759-794. A valid `DSVIN` whose processing began before the deadline can therefore still be validating when the scheduler observes the timeout. With three reservations, two committed entries, and the third entry in flight, the third participant is classified as the only missing submitter, selected with certainty, and charged after `SetNull()` makes its final session revalidation fail. Signing has the same gap while `DSSIGNFINALTX` is being decoded or between its per-input `AddScriptSig()` calls. Track in-flight messages for the current session or serialize the timeout cutoff with their complete processing before applying a guaranteed collateral penalty.
- [BLOCKING] src/coinjoin/server.cpp:610-628: Preserve recoverable finalization when the preceding pool check is skipped
  `CheckTimeout()` assumes the immediately preceding `CheckPool()` handled every recoverable accepting-entry timeout, but `CheckPool()` uses a non-blocking `TRY_LOCK`. A message-handling thread can hold `cs_check_pool`, sample `HasTimedOut()` as false immediately before the deadline, and then release the mutex after the scheduler's `CheckPool()` has skipped it but before the scheduler calls `CheckTimeout()`. `CheckTimeout()` then acquires the mutex after the deadline and unconditionally resets the session. If the session has at least `GetMinPoolParticipants()` committed entries but fewer entries than reservations, it should enter `ChargeAndFinalize` and retain the probabilistic policy; this interleaving instead aborts it and applies `GUARANTEED_ON_ABORT`. Re-evaluate the full accepting-entry action after acquiring `cs_check_pool` rather than relying on a preceding check that may have been skipped or sampled an earlier time.
- [BLOCKING] src/coinjoin/server.cpp:634: Fold the stray-brace correction into its introducing commit
  Commit `67c9647ed03` introduces an extra closing brace immediately after `CCoinJoinServer::CheckTimeout()`. The unmatched brace remains in `264ba3fdfa7`, `76e366e1ceb`, and `9e199087637`, and is only removed by the final commit `2bc5dbd248b`. Those four intermediate commits are syntactically unbuildable and unusable as `git bisect` points. Amend `67c9647ed03` to omit the extra brace, remove the corrective deletion from the final commit, and rebase the intervening commits so every permanent-history state builds independently.
- [SUGGESTION] src/coinjoin/server.cpp:503-509: Make offender deduplication explicit in the commit history
  Commit `9e199087637` is titled `refactor: separate CoinJoin offender selection policy`, but it also changes observable behavior: signing offenders are added once per entry instead of once per unsigned input. This removes the extra random-selection weight previously assigned to participants with multiple unsigned inputs. Move the deduplication hunk into the behavioral fix commit or reword `9e199087637` and add a commit body explaining the weighting change so `git log` and `git blame` expose the functional change.

Comment thread src/coinjoin/server.cpp Outdated
Comment on lines +625 to +628
if (nState == POOL_STATE_ACCEPTING_ENTRIES || nState == POOL_STATE_SIGNING) {
collateral_to_charge = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT);
}
SetNull();

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: Exclude in-flight submissions from guaranteed timeout penalties

SelectCollateralToCharge() only recognizes entries already committed to vecEntries, but AddEntry() releases cs_coinjoin while running IsCollateralValid() and IsValidInOuts() at lines 759-794. A valid DSVIN whose processing began before the deadline can therefore still be validating when the scheduler observes the timeout. With three reservations, two committed entries, and the third entry in flight, the third participant is classified as the only missing submitter, selected with certainty, and charged after SetNull() makes its final session revalidation fail. Signing has the same gap while DSSIGNFINALTX is being decoded or between its per-input AddScriptSig() calls. Track in-flight messages for the current session or serialize the timeout cutoff with their complete processing before applying a guaranteed collateral penalty.

source: ['codex']

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 — Exclude in-flight submissions from guaranteed timeout penalties 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.

Comment thread src/coinjoin/server.cpp
Comment on lines +610 to +628
// CheckPool can be finalizing or committing on the message-handling thread. Skipping this tick
// keeps timeout reset and finalization/commit single-flight without blocking the scheduler.
TRY_LOCK(cs_check_pool, lock_check_pool);
if (!lock_check_pool) return;

LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckTimeout -- %s timed out -- resetting\n",
(nState == POOL_STATE_SIGNING) ? "Signing" : "Session");
ChargeFees();
WITH_LOCK(cs_coinjoin, SetNull());
CTransactionRef collateral_to_charge;
{
LOCK(cs_coinjoin);

// Too early to do anything. Recheck while holding the lock so selecting an offender and
// closing the session form one atomic cutoff for late entries and signatures.
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) {
collateral_to_charge = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT);
}
SetNull();

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: Preserve recoverable finalization when the preceding pool check is skipped

CheckTimeout() assumes the immediately preceding CheckPool() handled every recoverable accepting-entry timeout, but CheckPool() uses a non-blocking TRY_LOCK. A message-handling thread can hold cs_check_pool, sample HasTimedOut() as false immediately before the deadline, and then release the mutex after the scheduler's CheckPool() has skipped it but before the scheduler calls CheckTimeout(). CheckTimeout() then acquires the mutex after the deadline and unconditionally resets the session. If the session has at least GetMinPoolParticipants() committed entries but fewer entries than reservations, it should enter ChargeAndFinalize and retain the probabilistic policy; this interleaving instead aborts it and applies GUARANTEED_ON_ABORT. Re-evaluate the full accepting-entry action after acquiring cs_check_pool rather than relying on a preceding check that may have been skipped or sampled an earlier time.

source: ['codex']

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 — Preserve recoverable finalization when the preceding pool check is skipped 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.

Comment thread src/coinjoin/server.cpp
if (collateral_to_charge) {
ConsumeCollateral(collateral_to_charge);
}
}

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: Fold the stray-brace correction into its introducing commit

Commit 67c9647ed03 introduces an extra closing brace immediately after CCoinJoinServer::CheckTimeout(). The unmatched brace remains in 264ba3fdfa7, 76e366e1ceb, and 9e199087637, and is only removed by the final commit 2bc5dbd248b. Those four intermediate commits are syntactically unbuildable and unusable as git bisect points. Amend 67c9647ed03 to omit the extra brace, remove the corrective deletion from the final commit, and rebase the intervening commits so every permanent-history state builds independently.

source: ['codex']

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 — Fold the stray-brace correction into its introducing commit 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.

Comment thread src/coinjoin/server.cpp
Comment on lines +503 to +509
// who didn't sign? Include each participant once even if multiple inputs are unsigned.
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);
}
if (std::ranges::any_of(entry.vecTxDSIn, [](const auto& txdsin) { return !txdsin.fHasSig; })) {
LogPrint(BCLog::COINJOIN, /* Continued */
"CCoinJoinServer::SelectCollateralToCharge -- found uncooperative node (didn't sign), "
"found offence\n");
vecOffendersCollaterals.push_back(entry.txCollateral);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Suggestion: Make offender deduplication explicit in the commit history

Commit 9e199087637 is titled refactor: separate CoinJoin offender selection policy, but it also changes observable behavior: signing offenders are added once per entry instead of once per unsigned input. This removes the extra random-selection weight previously assigned to participants with multiple unsigned inputs. Move the deduplication hunk into the behavioral fix commit or reword 9e199087637 and add a commit body explaining the weighting change so git log and git blame expose the functional change.

source: ['codex']

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 — Make offender deduplication explicit in the commit history 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.

@PastaPastaPasta
PastaPastaPasta force-pushed the fix/coinjoin-abort-fee-on-7537 branch from 2bc5dbd to 3d5cf6b Compare August 10, 2026 03:30

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3d5cf6b6e0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/coinjoin/server.cpp Outdated
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) {
collateral_to_charge = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not charge peers that never received the final transaction

When RelayFinalTransaction() encounters one disconnected entry, it stops relaying and calls RelayStatus(STATUS_REJECTED), which tells every connected client to abort, but the server remains in POOL_STATE_SIGNING unless all entries are disconnected. At the subsequent timeout, this guaranteed policy classifies unsigned entries—including connected peers after the failed relay that never received DSFINALTX—as offenders and consumes one at random. Thus a single disconnect can cost an honest participant its collateral; this coordinator-side relay-failure path should reset without charging, or only peers that were successfully sent the final transaction should be eligible.

AGENTS.md reference: AGENTS.md:L168-L168

Useful? React with 👍 / 👎.

@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

All four prior verified findings are resolved at the current head, but two signing-timeout paths still block approval: guaranteed abort charging can penalize honest participants after the server tells them to stop, and a stale pool-check interleaving can discard a fully signed transaction. The stack also has two non-blocking history issues: the lint-only follow-up should be folded into its introducing commits, and the subtle timeout fixes should retain their rationale in commit bodies.
Source: reviewer backends: gpt-5.6-sol (general), gpt-5.6-sol (dash-core-commit-history); final verifier backend: gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and 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)

🔴 2 blocking | 🟡 2 suggestion(s)

2 additional finding(s) omitted (not in diff).

🤖 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:681-684: Do not guarantee a fee after the coordinator aborts signing
  The guaranteed timeout policy applies even after the server has instructed connected clients to stop signing. `RelayFinalTransaction()` calls `RelayStatus(STATUS_REJECTED)` when any entry is disconnected, and `ProcessDSSIGNFINALTX()` does the same after any `AddScriptSig()` failure. Connected clients process that rejection by entering `POOL_STATE_ERROR` and releasing their session resources, but the server remains in `POOL_STATE_SIGNING` unless every entry is disconnected. A malicious participant can exploit this by submitting its valid signature and then resubmitting it: the duplicate fails, every honest peer is told to abort, and the malicious participant is excluded from the unsigned-offender set. At timeout, this block then guarantees that one honest participant is charged. A failed `DSFINALTX` relay can similarly charge a connected entry that never received the transaction. Coordinator-originated signing aborts must reset without the guaranteed fee, or eligibility must be limited to participants that received the final transaction and were not subsequently instructed to abort.
- [BLOCKING] src/coinjoin/server.cpp:669-684: Commit complete signatures when rechecking a timeout
  `CheckTimeout()` re-evaluates recoverable accepting-entry sessions but does not re-evaluate `IsSignaturesComplete()` for signing sessions. A scheduler `CheckPool()` can acquire `cs_check_pool`, observe the signatures as incomplete, and release `cs_coinjoin`. The final on-time `DSSIGNFINALTX` can then add its signature, skip its own `CheckPool()` because the scheduler still owns `cs_check_pool`, and clear its in-flight guard. When the scheduler subsequently enters `CheckTimeout()` after the deadline, there are no unsigned offenders, but this block still calls `SetNull()` and discards the fully signed transaction. Re-evaluate the signing action under `cs_coinjoin`, then call `CommitFinalTransaction()` after releasing that lock, just as the accepting-entry action is re-evaluated and finalized.

In `<commit:aaa6d04>`:
- [SUGGESTION] <commit:aaa6d04>:1: Fold the lint-only follow-up into its introducing commits
  Commit `aaa6d0464a4` only adds `/* Continued */` markers to four `LogPrint` calls introduced earlier in this stack: one in `96cf3768cab` and three in `7443d022e09`. The linter rejects those unmarked calls, so retaining the separate correction leaves the introducing revisions as lint-failing bisect points and adds review-fix noise to permanent history. Fold each marker into the commit that introduced its call and drop `aaa6d0464a4`.

In `<commit:bb056c6>`:
- [SUGGESTION] <commit:bb056c6>:1: Preserve the rationale for the non-obvious CoinJoin fixes
  Commits `bb056c6d397`, `7da61132813`, and `3d5cf6b6e0f` have empty bodies despite changing subtle concurrency and fee-policy invariants. In particular, they serialize timeout reset with finalization, bind entry validation to a session denomination snapshot, and combine guaranteed abort charging with in-flight-message deferral and recoverable-timeout re-evaluation. Add concise bodies explaining the race or policy invariant each commit preserves so ordinary `git log`, blame, and bisect retain the reasoning currently available only from source comments and PR discussion.

Comment thread src/coinjoin/server.cpp Outdated
Comment on lines +681 to +684
if (nState == POOL_STATE_ACCEPTING_ENTRIES || nState == POOL_STATE_SIGNING) {
collateral_to_charge = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT);
}
SetNull();

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: Do not guarantee a fee after the coordinator aborts signing

The guaranteed timeout policy applies even after the server has instructed connected clients to stop signing. RelayFinalTransaction() calls RelayStatus(STATUS_REJECTED) when any entry is disconnected, and ProcessDSSIGNFINALTX() does the same after any AddScriptSig() failure. Connected clients process that rejection by entering POOL_STATE_ERROR and releasing their session resources, but the server remains in POOL_STATE_SIGNING unless every entry is disconnected. A malicious participant can exploit this by submitting its valid signature and then resubmitting it: the duplicate fails, every honest peer is told to abort, and the malicious participant is excluded from the unsigned-offender set. At timeout, this block then guarantees that one honest participant is charged. A failed DSFINALTX relay can similarly charge a connected entry that never received the transaction. Coordinator-originated signing aborts must reset without the guaranteed fee, or eligibility must be limited to participants that received the final transaction and were not subsequently instructed to abort.

source: ['codex']

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 — Do not guarantee a fee after the coordinator aborts signing 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.

Comment thread src/coinjoin/server.cpp Outdated
Comment on lines +669 to +684
if (nState == POOL_STATE_ACCEPTING_ENTRIES) {
const int entries{GetEntriesCountLocked()};
if ((!m_session_collaterals.empty() && size_t(entries) == m_session_collaterals.size()) ||
entries >= CoinJoin::GetMinPoolParticipants()) {
session_to_finalize = nSessionID;
charge_fees = size_t(entries) != m_session_collaterals.size();
}
}

if (session_to_finalize == 0) {
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) {
collateral_to_charge = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT);
}
SetNull();

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: Commit complete signatures when rechecking a timeout

CheckTimeout() re-evaluates recoverable accepting-entry sessions but does not re-evaluate IsSignaturesComplete() for signing sessions. A scheduler CheckPool() can acquire cs_check_pool, observe the signatures as incomplete, and release cs_coinjoin. The final on-time DSSIGNFINALTX can then add its signature, skip its own CheckPool() because the scheduler still owns cs_check_pool, and clear its in-flight guard. When the scheduler subsequently enters CheckTimeout() after the deadline, there are no unsigned offenders, but this block still calls SetNull() and discards the fully signed transaction. Re-evaluate the signing action under cs_coinjoin, then call CommitFinalTransaction() after releasing that lock, just as the accepting-entry action is re-evaluated and finalized.

source: ['codex']

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 — Commit complete signatures when rechecking a timeout 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.

@PastaPastaPasta
PastaPastaPasta force-pushed the fix/coinjoin-abort-fee-on-7537 branch from 3d5cf6b to 9777136 Compare August 12, 2026 13:34

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (4)
src/test/coinjoin_inouts_tests.cpp (4)

750-781: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Narrow the size_t values passed to MakeCollateral and MakePeer.

MakeCollateral(uint32_t) receives the size_t loop variable i at Line 762, and MakePeer(NodeId, uint32_t) receives 0x0a000001 + i at Line 766. Both conversions are implicit narrowing. Add explicit casts so the intent is clear and no compiler in CI reports a conversion warning.

♻️ Proposed change
-        const auto collateral = MakeCollateral(i);
+        const auto collateral = MakeCollateral(static_cast<uint32_t>(i));
@@
-        auto peer = MakePeer(i, 0x0a000001 + i);
+        auto peer = MakePeer(static_cast<NodeId>(i), static_cast<uint32_t>(0x0a000001 + i));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/coinjoin_inouts_tests.cpp` around lines 750 - 781, In
server_timeout_rechecks_recoverable_session, explicitly narrow the size_t loop
variable when passing it to MakeCollateral(uint32_t), and explicitly cast the
computed address value passed to MakePeer(NodeId, uint32_t). Preserve the
existing loop behavior while eliminating implicit conversion warnings.

188-244: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add negative lock annotations to the new helpers that take cs_coinjoin.

SeedParticipant at Line 246 declares EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin), and RelayAbortForTest at Line 240 follows that pattern. The other new helpers (ResetForTest, SetTimedOutForTest, AddCollateralForTest, AddEntryForTest, SelectForTest, EnterSigningState, SetFinalTransactionForTest, SeedTimedOutSession, MarkMessageInFlightForTest) also acquire cs_coinjoin but declare no negative capability. Clang thread-safety analysis then cannot detect a caller that already holds cs_coinjoin.

♻️ Example annotation for the affected helpers
-    void ResetForTest(PoolState state)
+    void ResetForTest(PoolState state) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin)
     {
         LOCK(cs_coinjoin);

Also applies to: 253-275

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/coinjoin_inouts_tests.cpp` around lines 188 - 244, Add
EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) annotations to ResetForTest,
SetTimedOutForTest, AddCollateralForTest, AddEntryForTest, SelectForTest,
EnterSigningState, SetFinalTransactionForTest, SeedTimedOutSession, and
MarkMessageInFlightForTest, matching the existing annotations on SeedParticipant
and RelayAbortForTest. Keep each helper’s internal cs_coinjoin locking
unchanged.

321-424: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider splitting this test case into separate scenarios.

server_abort_fee_selects_unique_offenders verifies nine distinct behaviours in one test case: queue timeout, all-missing submitters, one submitter, partial submitters, all submitters, a lone non-signer, weighting across multiple non-signers, the probabilistic gate, and ChargeRandomFees. A failure in an early scenario stops the later ones, and the failure output does not name the scenario. Separate BOOST_AUTO_TEST_CASE blocks per policy scenario would isolate failures. The server construction can move into a small helper to avoid repetition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/coinjoin_inouts_tests.cpp` around lines 321 - 424, Split
server_abort_fee_selects_unique_offenders into separate BOOST_AUTO_TEST_CASE
blocks covering each policy scenario, including timeout handling, submission
coverage, non-signer selection and weighting, the probabilistic gate, and
ChargeRandomFees. Move shared server setup into a small helper to avoid
repetition, and retain each scenario’s existing assertions and setup behavior.

609-624: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer an exact integer constant for the expected mempool delta.

static_cast<CAmount>(0.1 * COIN) uses floating point for a consensus-adjacent amount comparison. Use COIN / 10 to keep the expectation exact and independent of double rounding.

♻️ Proposed change
-    BOOST_CHECK_EQUAL(delta, static_cast<CAmount>(0.1 * COIN));
+    BOOST_CHECK_EQUAL(delta, COIN / 10);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/coinjoin_inouts_tests.cpp` around lines 609 - 624, Replace the
floating-point expected amount in the delta assertion after ApplyDelta with the
exact integer expression COIN / 10, while preserving the existing CAmount
comparison and test behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/test/coinjoin_inouts_tests.cpp`:
- Around line 750-781: In server_timeout_rechecks_recoverable_session,
explicitly narrow the size_t loop variable when passing it to
MakeCollateral(uint32_t), and explicitly cast the computed address value passed
to MakePeer(NodeId, uint32_t). Preserve the existing loop behavior while
eliminating implicit conversion warnings.
- Around line 188-244: Add EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) annotations to
ResetForTest, SetTimedOutForTest, AddCollateralForTest, AddEntryForTest,
SelectForTest, EnterSigningState, SetFinalTransactionForTest,
SeedTimedOutSession, and MarkMessageInFlightForTest, matching the existing
annotations on SeedParticipant and RelayAbortForTest. Keep each helper’s
internal cs_coinjoin locking unchanged.
- Around line 321-424: Split server_abort_fee_selects_unique_offenders into
separate BOOST_AUTO_TEST_CASE blocks covering each policy scenario, including
timeout handling, submission coverage, non-signer selection and weighting, the
probabilistic gate, and ChargeRandomFees. Move shared server setup into a small
helper to avoid repetition, and retain each scenario’s existing assertions and
setup behavior.
- Around line 609-624: Replace the floating-point expected amount in the delta
assertion after ApplyDelta with the exact integer expression COIN / 10, while
preserving the existing CAmount comparison and test behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ec59951d-1248-4c0c-a6ba-d5f91c7b66eb

📥 Commits

Reviewing files that changed from the base of the PR and between 3d5cf6b and 9777136.

📒 Files selected for processing (3)
  • src/coinjoin/server.cpp
  • src/coinjoin/server.h
  • src/test/coinjoin_inouts_tests.cpp
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/coinjoin/server.h
  • src/coinjoin/server.cpp

@thephez

thephez commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

@PastaPastaPasta this could result in CoinJoin participants being penalized for masternode connectivity issues, right?

@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 Codex revalidation found no actionable defects: the two signing-timeout blockers are fixed, and the rewritten commit stack resolves both history suggestions. All four prior verified findings are resolved at 9777136, so this preliminary gate approves the PR for the next review phase.
Source: reviewer backends: gpt-5.6-sol (general), gpt-5.6-sol (dash-core-commit-history); final verifier backend: gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and 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-abort-fee-on-7537 branch from 9777136 to 4618c53 Compare August 12, 2026 18:15

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4618c53e4b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/coinjoin/server.cpp Outdated
Comment on lines +703 to +707
SetNull();
}

LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckTimeout -- %s timed out -- resetting\n",
(nState == POOL_STATE_SIGNING) ? "Signing" : "Session");
ChargeFees();
WITH_LOCK(cs_coinjoin, SetNull());
if (collateral_to_charge) {
ConsumeCollateral(collateral_to_charge);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Block new sessions until the penalty is consumed

In the scheduler's CheckTimeout() path, SetNull() makes the server accept a replacement session before ConsumeCollateral() has acquired cs_main. A peer can therefore resubmit the selected collateral during this window: CreateNewSession() may test-accept and record it first, after which the old timeout broadcasts the collateral and spends its inputs. The replacement session is then built around an invalid collateral, so that participant cannot submit its entry and the new mix may time out or exclude it. Keep admission closed until consumption finishes, or track pending penalty collateral so it cannot be admitted.

AGENTS.md reference: AGENTS.md:L178-L180

Useful? React with 👍 / 👎.

@PastaPastaPasta
PastaPastaPasta force-pushed the fix/coinjoin-abort-fee-on-7537 branch from 4618c53 to a67fc15 Compare August 12, 2026 19:07

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🧹 Nitpick comments (5)
src/coinjoin/server.cpp (1)

536-612: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a unit test for the guaranteed-abort path after a relayed abort.

CheckTimeout() skips collateral selection when m_relayed_abort is set. RelayStatus(STATUS_REJECTED) and the disconnect path both set that flag. The test helpers shown in src/test/coinjoin_inouts_tests.cpp seed timed-out sessions and relay rejections, so a case that relays a rejection and then asserts that no collateral is consumed is cheap to add.

As per coding guidelines: "Add small tests proving invariants when changing consensus, serialization, masternode, LLMQ, InstantSend, ChainLocks, governance, EvoDB, credit-pool, Platform, BLS, quorum prediction, or timing/shutdown behavior."

Also applies to: 666-708

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/coinjoin/server.cpp` around lines 536 - 612, Add a unit test in the
existing coinjoin input/output test helpers covering a timed-out session that
receives RelayStatus(STATUS_REJECTED), then verify CheckTimeout() takes the
guaranteed-abort path without consuming or selecting collateral. Reuse the
established session-seeding and relay-rejection setup, and assert the collateral
state remains unchanged after the timeout check.

Source: Coding guidelines

src/test/coinjoin_inouts_tests.cpp (4)

180-199: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Confirm that consumed_collaterals is never written from two threads.

consumed_collaterals is a plain mutable std::vector with no synchronization. In server_timeout_does_not_reset_during_pool_check, a second thread runs HoldPoolCheck, which only takes cs_check_pool and does not consume collateral, so the current tests appear safe. If a future test lets a helper thread reach ConsumeCollateral, the vector will race. Guarding it with cs_coinjoin or a dedicated mutex would make the invariant explicit.

Also applies to: 220-251, 260-345, 819-841

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/coinjoin_inouts_tests.cpp` around lines 180 - 199, The test-only
TestableCoinJoinServer::consumed_collaterals vector is unsynchronized and could
race if ConsumeCollateral runs on another thread. Protect accesses in
ConsumeCollateral, ResetForTest, and all relevant test assertions with
cs_coinjoin or a dedicated mutex, preserving the existing lifecycle-test
behavior.

355-364: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Clarify the unsigned_inputs semantics in MakeEntry.

unsigned_inputs == 0 produces one signed input, and unsigned_inputs == N produces N unsigned inputs. The parameter therefore controls both the input count and the signature flag. A short comment or a distinct parameter name would make the call sites at Lines 429, 438, and 539 easier to read.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/coinjoin_inouts_tests.cpp` around lines 355 - 364, Clarify the dual
semantics of unsigned_inputs in MakeEntry: zero creates one signed input, while
a positive value creates that many unsigned inputs. Add a concise comment or
rename the parameter to explicitly communicate this behavior, keeping the
existing input-count and fHasSig logic unchanged.

366-486: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated server construction into a fixture helper.

Eight test cases repeat the same CActiveMasternodeManager plus TestableCoinJoinServer construction with ten identical arguments. A helper that returns the configured server would remove the duplication and keep future constructor changes to one site.

Also applies to: 488-524, 526-555, 654-677, 679-718, 720-775, 777-817, 819-841, 843-863, 865-885

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/coinjoin_inouts_tests.cpp` around lines 366 - 486, Extract the
repeated CActiveMasternodeManager and TestableCoinJoinServer setup from the
affected test cases into a shared fixture helper that returns a configured
TestableCoinJoinServer. Update each listed test, including
server_abort_fee_selects_unique_offenders, to use the helper while preserving
the existing node dependencies and test behavior; keep constructor arguments
centralized in that helper.

709-716: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace the duplicated prioritisation amount with the production constant.

The test hardcodes static_cast<CAmount>(0.1 * COIN). The production code in CommitFinalTransaction applies the same value. If the production value changes, this assertion silently encodes the old policy. Reference the shared constant instead.

🔍 Verification script
#!/bin/bash
# Locate the prioritisation delta used by CommitFinalTransaction.
rg -nP -C4 'PrioritiseTransaction|0\.1\s*\*\s*COIN' src/coinjoin
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/coinjoin_inouts_tests.cpp` around lines 709 - 716, Update the delta
assertion in the coinjoin test to use the shared production prioritisation
constant applied by CommitFinalTransaction instead of hardcoding
static_cast<CAmount>(0.1 * COIN). Keep the existing delta calculation and
validation unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/coinjoin/server.cpp`:
- Around line 348-404: Update CCoinJoinServer::CheckPool’s locked snapshot so
the timeout-driven ChargeAndFinalize action is selected only when
m_inflight_session does not equal the current nSessionID. Preserve normal
finalization when all entries are present, but defer charging/finalizing while
the current session has an in-flight submission, matching the guard used by
CheckTimeout().
- Around line 578-587: Update the probabilistic collateral check using
nSessionCollaterals and vecOffendersCollaterals so it cannot evaluate
nSessionCollaterals - 1 when the size is zero. Add an explicit nonzero guard or
use a signed comparison, while preserving the intended “mostly offending”
behavior when entries exist without collaterals.

---

Nitpick comments:
In `@src/coinjoin/server.cpp`:
- Around line 536-612: Add a unit test in the existing coinjoin input/output
test helpers covering a timed-out session that receives
RelayStatus(STATUS_REJECTED), then verify CheckTimeout() takes the
guaranteed-abort path without consuming or selecting collateral. Reuse the
established session-seeding and relay-rejection setup, and assert the collateral
state remains unchanged after the timeout check.

In `@src/test/coinjoin_inouts_tests.cpp`:
- Around line 180-199: The test-only
TestableCoinJoinServer::consumed_collaterals vector is unsynchronized and could
race if ConsumeCollateral runs on another thread. Protect accesses in
ConsumeCollateral, ResetForTest, and all relevant test assertions with
cs_coinjoin or a dedicated mutex, preserving the existing lifecycle-test
behavior.
- Around line 355-364: Clarify the dual semantics of unsigned_inputs in
MakeEntry: zero creates one signed input, while a positive value creates that
many unsigned inputs. Add a concise comment or rename the parameter to
explicitly communicate this behavior, keeping the existing input-count and
fHasSig logic unchanged.
- Around line 366-486: Extract the repeated CActiveMasternodeManager and
TestableCoinJoinServer setup from the affected test cases into a shared fixture
helper that returns a configured TestableCoinJoinServer. Update each listed
test, including server_abort_fee_selects_unique_offenders, to use the helper
while preserving the existing node dependencies and test behavior; keep
constructor arguments centralized in that helper.
- Around line 709-716: Update the delta assertion in the coinjoin test to use
the shared production prioritisation constant applied by CommitFinalTransaction
instead of hardcoding static_cast<CAmount>(0.1 * COIN). Keep the existing delta
calculation and validation unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c9e11abb-3443-427e-ac0d-4a4598969547

📥 Commits

Reviewing files that changed from the base of the PR and between 9777136 and a67fc15.

📒 Files selected for processing (5)
  • src/coinjoin/coinjoin.cpp
  • src/coinjoin/coinjoin.h
  • src/coinjoin/server.cpp
  • src/coinjoin/server.h
  • src/test/coinjoin_inouts_tests.cpp
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/coinjoin/coinjoin.h
  • src/coinjoin/server.h

Comment thread src/coinjoin/server.cpp
Comment thread src/coinjoin/server.cpp

@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 timeout handling still has two in-scope concurrency defects: an on-time in-flight entry can be excluded and probabilistically charged during recoverable finalization, and a selected timeout collateral can be admitted into a replacement session before the penalty transaction consumes it. The unsigned-underflow report is not actionable because the locked production invariants ensure a nonempty offender set always belongs to a session with at least one collateral.
Source: reviewer backend model gpt-5.6-sol (general); final verifier backend model gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and 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)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 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:382-385: Defer timeout finalization while an entry is in flight
  `ProcessDSVIN()` marks an on-time entry in flight before deserialization and validation, but this timeout-driven `ChargeAndFinalize` branch does not inspect that marker. If the existing committed entries already meet the minimum, the scheduler can select the still-validating participant as a missing submitter, potentially charge its collateral, and move the session to signing. The pending `AddEntry()` then fails its session-state revalidation, excluding an entry that arrived before the deadline. Require `m_inflight_session != nSessionID` for this timeout branch, matching `CheckTimeout()`; normal finalization when every reservation already has an entry should remain unchanged.
- [BLOCKING] src/coinjoin/server.cpp:703-707: Keep new-session admission closed until the penalty is consumed
  `SetNull()` clears the timed-out session and makes the server idle before `ConsumeCollateral()` acquires `cs_main`. During that interval, the message-handling thread can test-accept the selected collateral in `IsAcceptableDSA()` and record it in a replacement session through `CreateNewSession()`. The timeout thread can then consume and relay that collateral, leaving the replacement session with a reservation whose inputs are spent; its participant cannot submit a valid entry, and the new mix can stall or abort. Keep admission closed until consumption finishes, or maintain a pending-penalty prevout set that both new- and existing-session admission reject until the penalty attempt completes.

Comment thread src/coinjoin/server.cpp Outdated
Comment on lines +382 to +385
} else if (CCoinJoinServer::HasTimedOut() && entries >= CoinJoin::GetMinPoolParticipants()) {
// We timed out while accepting entries but still have more than the minimum, so
// punish the misbehaving participants and complete the session without them
action = Action::ChargeAndFinalize;

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: Defer timeout finalization while an entry is in flight

ProcessDSVIN() marks an on-time entry in flight before deserialization and validation, but this timeout-driven ChargeAndFinalize branch does not inspect that marker. If the existing committed entries already meet the minimum, the scheduler can select the still-validating participant as a missing submitter, potentially charge its collateral, and move the session to signing. The pending AddEntry() then fails its session-state revalidation, excluding an entry that arrived before the deadline. Require m_inflight_session != nSessionID for this timeout branch, matching CheckTimeout(); normal finalization when every reservation already has an entry should remain unchanged.

Suggested change
} else if (CCoinJoinServer::HasTimedOut() && entries >= CoinJoin::GetMinPoolParticipants()) {
// We timed out while accepting entries but still have more than the minimum, so
// punish the misbehaving participants and complete the session without them
action = Action::ChargeAndFinalize;
} else if (CCoinJoinServer::HasTimedOut() && entries >= CoinJoin::GetMinPoolParticipants() &&
m_inflight_session != nSessionID) {

source: ['coderabbit']

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 — Defer timeout finalization while an entry is in flight 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.

Comment thread src/coinjoin/server.cpp Outdated
Comment on lines +703 to +707
SetNull();
}

LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckTimeout -- %s timed out -- resetting\n",
(nState == POOL_STATE_SIGNING) ? "Signing" : "Session");
ChargeFees();
WITH_LOCK(cs_coinjoin, SetNull());
if (collateral_to_charge) {
ConsumeCollateral(collateral_to_charge);

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: Keep new-session admission closed until the penalty is consumed

SetNull() clears the timed-out session and makes the server idle before ConsumeCollateral() acquires cs_main. During that interval, the message-handling thread can test-accept the selected collateral in IsAcceptableDSA() and record it in a replacement session through CreateNewSession(). The timeout thread can then consume and relay that collateral, leaving the replacement session with a reservation whose inputs are spent; its participant cannot submit a valid entry, and the new mix can stall or abort. Keep admission closed until consumption finishes, or maintain a pending-penalty prevout set that both new- and existing-session admission reject until the penalty attempt completes.

source: ['codex']

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 — Keep new-session admission closed until the penalty is consumed 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.

@PastaPastaPasta
PastaPastaPasta force-pushed the fix/coinjoin-abort-fee-on-7537 branch from a67fc15 to 4556612 Compare August 12, 2026 20:38

@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 in-flight timeout-finalization defect is fixed at the current head: CheckPool() now defers ChargeAndFinalize while the current session has a message in flight. One blocking race remains because CheckTimeout() exposes the idle state before consuming the selected penalty collateral, allowing that collateral to be reserved by a replacement session before its inputs are spent.
Source: reviewer backend model gpt-5.6-sol (general); final verifier backend model gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and 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)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 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:706-710: Keep new-session admission closed until the penalty is consumed
  (existing thread: https://github.com/dashpay/dash/pull/7568#discussion_r3770096427)
  `SetNull()` clears the timed-out session and exposes `POOL_STATE_IDLE` before `ConsumeCollateral()` acquires `cs_main`. A concurrent `DSACCEPT` can therefore pass `IsAcceptableDSA()` while the selected collateral is still unspent and commit it to a replacement session through `CreateNewSession()`; the timeout thread can then consume that collateral, leaving the replacement session with a reservation whose inputs are already spent. The participant cannot subsequently submit a valid entry, so the replacement mix can stall or abort. Keep admission closed until the consumption attempt completes without introducing a `cs_coinjoin` to `cs_main` lock inversion, or retain the selected collateral's prevouts in a pending-penalty set that both new- and existing-session admission reject until consumption finishes.

@PastaPastaPasta
PastaPastaPasta force-pushed the fix/coinjoin-abort-fee-on-7537 branch from 4556612 to 118d995 Compare August 12, 2026 22:05

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 118d995b42

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/coinjoin/server.cpp
// the collateral cannot be re-committed while its penalty spend is in flight.
MarkPendingCharge(collateral_to_charge);
}
RelayStatus(STATUS_REJECTED);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Revalidate the session before relaying a signature abort

When an extra or duplicate DSSIGNFINALTX passes the initial state check after all required signatures are already stored, the scheduler's CheckPool() can concurrently commit and reset that session because its commit path does not defer for m_inflight_session. AddScriptSig() then fails against the cleared entries, and this unconditional call sets m_relayed_abort while the server is idle; CreateNewSession() does not clear that flag, so the next aborted session skips its guaranteed collateral charge. Recheck that nSessionID == *session_id and the state is still POOL_STATE_SIGNING before charging or relaying the abort.

AGENTS.md reference: AGENTS.md:L178-L180

Useful? React with 👍 / 👎.

nSessionDenom was the one CCoinJoinBaseSession field that was neither atomic nor guarded, while its siblings nState, nSessionID and nTimeLastSuccessfulStep are all std::atomic.

On the server it is written by the message-handling thread in CreateNewSession() and by the scheduler thread in SetNull(), and read without any lock by CheckForCompleteQueue(), AddUserToExistingSession(), IsValidInOuts(), the relay logging, and by RPC threads via GetJsonInfo(). Concurrent unsynchronized access to a plain int is a data race: benign on the hardware we support, but formally UB and reportable by TSan.
CheckPool() and CheckForCompleteQueue() read nState, the entry count and the collateral count under separate lock acquisitions (or none at all) and then acted on the result, so a scheduler-thread SetNull() could land between the samples.

CheckPool() is the worst case: it sampled nState, then took and released cs_coinjoin for GetEntriesCount(), then read vecSessionCollaterals.size() unlocked. A SetNull() in between made an already-reset session read as '0 entries == 0 collaterals' and get finalized, putting a dead session back into POOL_STATE_SIGNING and rejecting every new dsa until the 15s signing timeout expired. It now decides from one snapshot and acts afterwards, and CreateFinalTransaction()/CommitFinalTransaction() revalidate nSessionID because the decision is made with the lock released.

CheckPool() also runs on both the scheduler thread and the message-handling thread, so two concurrent calls could both finalize: clients would receive DSFINALTX twice, sign twice, and the duplicate signatures make AddScriptSig() fail and abort the session for everyone. A TRY_LOCK-only cs_check_pool makes it single-shot without ever blocking msghand.

SetState() and IsSessionReady() now require cs_coinjoin, so a transition and the session data it describes can only be observed together; this is what makes the existing revalidation blocks in CreateNewSession()/AddUserToExistingSession() effective. CheckForCompleteQueue() performs its transition under the lock and moves BLS signing and dsq relay outside it. ChargeFees() samples nState once instead of three times, which previously let it select 'didn't send' offenders and then charge and log them as 'didn't sign'.
vecSessionCollaterals had no GUARDED_BY and was reached from both threads with no lock at all: the message-handling thread read it in ProcessDSACCEPT(), IsSessionReady() and AddEntry(), while the scheduler thread read it in CheckPool(), CheckForCompleteQueue(), ChargeFees() and ChargeRandomFees(). The only synchronized access was the clear() in SetNull(). Committing a collateral therefore raced every one of those reads.

The worst of them was ChargeRandomFees(), which iterated the vector by reference while calling ConsumeCollateral() - a cs_main mempool submission - for each element. A concurrent SetNull() destroys the CTransactionRefs the loop is walking, so this was a use-after-free and not just a torn size read. It now works from a copy taken under the lock, which also keeps cs_coinjoin from being held across cs_main.

The transactions and their prevout index are now a single SessionCollaterals member so they cannot drift apart, and GUARDED_BY on that member makes every access - including the calls on it - checked by -Wthread-safety. Reintroducing an unlocked read is now a compile error rather than a review finding.
AddEntry() checked its bound, then ran IsCollateralValid() and IsValidInOuts() - both of which take cs_main and can block behind block validation - and only then took cs_coinjoin again to push_back. A scheduler-thread CheckTimeout() in that window calls SetNull(), so the entry was committed to a session that no longer existed.

The consequence outlives the window: vecEntries keeps the orphaned entry while vecSessionCollaterals is empty, so the next session starts one entry ahead of its own participant count. CheckPool()'s entries == collaterals test then fires early and finalizes a transaction containing an input from the dead session, which nobody present will sign, stalling the new session to its signing timeout and charging its honest participants in ChargeFees().

The bound check and the push_back now share one lock scope, and the session identity captured before validation is rechecked inside it, so an entry can only ever be committed to the session it was validated for.
CheckTimeout() reset the pool while CheckPool() could be finalizing or committing the very same session on the message-handling thread: HasTimedOut() was tested without any lock and the reset could land between CheckPool()'s decision and its execution, clearing a live session mid-step. CheckTimeout() now takes the same cs_check_pool guard as CheckPool() - with TRY_LOCK, so a contended scheduler tick is skipped instead of blocking the message-handling thread - which makes timeout resets and finalize/commit single-flight.

Offender selection also moves under cs_coinjoin, into SelectCollateralToCharge(), and into the same lock scope that closes the corresponding admission path: CheckTimeout() selects and resets atomically, and CreateFinalTransaction() selects and transitions to SIGNING atomically, so an entry that crossed the cutoff on time can no longer be charged as missing. The collateral is consumed only after the lock is released, because ConsumeCollateral() takes cs_main and mempool submission must not run under cs_coinjoin.
AddEntry() validated a submission against the live nSessionDenom while holding no session lock: IsValidInOuts() runs long cs_main work, and a scheduler-thread SetNull() in that window zeroes the denomination, so every output of an honest, on-time entry compared unequal to denom 0 and the ERR_DENOM path consumed that participant's collateral for a reset it could not have known about.

IsValidInOuts() now takes the denomination as a parameter and AddEntry() passes the snapshot captured under cs_coinjoin alongside the session id it already revalidates before committing, so validation, punishment and commit are all bound to the same session. AddEntry() also rejects submissions up front when the pool is no longer accepting entries, instead of relying on the entries-full bound alone.
The scheduler calls CheckPool() and CheckTimeout() separately, leaving a gap in which the final collateral, entry, or signature can arrive after CheckPool() takes its snapshot. The message thread then skips its own CheckPool() while the scheduler holds cs_check_pool, and an unconditional timeout reset would discard a session that can now advance.

Recheck readiness, finalizability, and signature completeness under cs_coinjoin before resetting. Actionable work takes priority and is picked up by the next scheduler tick.
Completion relay previously read and mutated live session state after CommitFinalTransaction() released cs_coinjoin. If all old participants disconnected, RelayCompletedTransaction() could reset the session, allowing a replacement session to open before random charging and the unconditional tail reset; those operations could then charge or clear the replacement.

Capture the committed transaction, participants, and collaterals under cs_coinjoin. Relay and charge only those snapshots, keep completion notification side-effect-free, and reset only the matching signing session. The invalid-transaction path now also notifies captured participants before reset.
Split offender discovery from fee selection so callers can preserve the existing probabilistic policy or request guaranteed charging when a session aborts.

Count each signing participant once even if multiple inputs remain unsigned. The previous per-input list gave participants with multiple inputs extra random-selection weight.
A timed-out session was abandoned with only the probabilistic charge, which also skips charging entirely when every participant offended. An attacker who reserved all the slots of a session - or was its sole non-cooperator - could abort session after session at little to no expected cost. Timeout aborts now use FeePolicy::GUARANTEED_ON_ABORT, which always charges exactly one collateral from the offender set; the finalize-with-stragglers path keeps the historical probabilistic policy.

Guaranteeing the charge makes an existing race punitive, so it is closed: a DSVIN or DSSIGNFINALTX that passed its state check before the deadline can still be validating - including long cs_main work - when the scheduler crosses the timeout, and its sender would be charged as a no-show for a submission the server was actively processing. Such messages now mark themselves in flight under cs_coinjoin at the timeout cutoff, and CheckTimeout() defers while one is pending; InFlightMessageGuard clears the mark on every exit path.

The can-advance recheck already keeps a finalizable or committable session out of this reset path, so by the time the charge runs the session has conclusively failed, and the charge is restricted to the states with an identifiable offender set: accepting entries or signing.

Selecting the offender and resetting happen under cs_coinjoin, but the mempool submission that actually spends the collateral cannot run under it: in that window admission is already open again, and the still-unspent collateral would be accepted into a replacement session that the charge then breaks - its reservation can never be backed by a valid entry, and re-charging it later fails because the funds are gone. A selected collateral therefore reserves its prevouts in m_pending_charges before the reset, admission rejects any collateral spending a reserved prevout, and the reservation is released once the submission has settled and the mempool's own view takes over.
… session

The scheduler's CheckPool() can sample the signatures as incomplete while holding cs_check_pool, the DSSIGNFINALTX that completes them in that window skips its own CheckPool() round because the guard is busy, and the same scheduler pass then enters CheckTimeout() past the deadline with the in-flight mark already cleared. Before the can-advance recheck this reset threw the fully signed transaction away, and with every input signed there was not even an offender to charge for the abort.

Pin the required behavior down: CheckTimeout() defers - charging nobody and destroying nothing - and the next CheckPool() round commits the transaction. The commit attempt is made observable through the mempool prioritisation CommitFinalTransaction() applies before submitting.
…hans

The guaranteed timeout charge assumed a session that dies in SIGNING dies because someone withheld a signature. It can also die because this coordinator told every participant to stand down: a session-wide STATUS_REJECTED - relayed when a signature submission fails validation, or when the final transaction cannot be delivered to a participant - puts honest clients into POOL_STATE_ERROR, where they release their inputs and never sign. At the timeout that followed, exactly those obedient clients formed the didn't-sign offender set and one of them was guaranteed to be charged. A participant who resubmitted its own valid signature could weaponize that: the duplicate fails AddScriptSig(), aborts the session for everyone, and an honest peer pays for it, every session.

RelayStatus() now records the session-wide rejection in m_relayed_abort (cleared by SetNull()), and CheckTimeout() skips the guaranteed charge for a session this coordinator already told its participants to abandon. The saboteur does not go free: a signature that fails AddScriptSig() comes from a verified participant of the live signing session and has no honest cause, so ProcessDSSIGNFINALTX() charges that sender's collateral at the moment it forces the abort. The immediate charge reserves the sender's prevouts in m_pending_charges like the timeout charge does, so a reset that follows the abort cannot re-admit the collateral while its penalty spend is still in flight.
@PastaPastaPasta
PastaPastaPasta force-pushed the fix/coinjoin-abort-fee-on-7537 branch from 118d995 to 3fc743f Compare August 12, 2026 23:03

@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

One blocking concurrency defect remains in the signature failure path: a stale DSSIGNFINALTX can relay an abort after the completed session has reset, causing the following session to inherit m_relayed_abort and skip its guaranteed abort charge. The prior admission race is fixed by reserving selected collateral prevouts in m_pending_charges until collateral consumption settles.
Source: reviewer backend model gpt-5.6-sol (general); final verifier backend model gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and 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)
  • 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:294-310: Revalidate the session before relaying a signature abort
  The failure path does not verify that the session captured in `session_id` is still the current signing session before charging collateral or calling `RelayStatus(STATUS_REJECTED)`. After an extra or duplicate signature passes the initial state check, `CheckPool()` can observe that all required signatures are complete and commit the session without consulting `m_inflight_session`. If the commit resets the session before this message reaches the failure block, `AddScriptSig()` fails against the cleared entries, the collateral lookup finds nothing, and the unconditional `RelayStatus()` sets `m_relayed_abort` while the server is idle. `CreateNewSession()` does not clear that flag, so the next session inherits it and `CheckTimeout()` suppresses its guaranteed abort charge. Revalidate `nSessionID == *session_id` and `nState == POOL_STATE_SIGNING` under `cs_coinjoin` before mutating abort state.

Comment thread src/coinjoin/server.cpp
Comment on lines +294 to +310
LOCK(cs_coinjoin);
// The sender is a verified participant of this signing session, so a signature
// that fails validation - a duplicate, an invalid script or an input that is not
// in the pool - is the sender's own doing, and the abort it forces on everyone
// else identifies the sender as the offender to charge. The participants this
// abort orphans must not pay for it at the timeout that follows.
const auto it = std::ranges::find_if(vecEntries,
[&peer](const auto& entry) { return entry.addr == peer.addr; });
if (it != vecEntries.end()) {
collateral_to_charge = it->txCollateral;
// The submission below runs outside cs_coinjoin, and the reset that can follow
// this abort would reopen admission before it settles: reserve the charge so
// the collateral cannot be re-committed while its penalty spend is in flight.
MarkPendingCharge(collateral_to_charge);
}
RelayStatus(STATUS_REJECTED);
}

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: Revalidate the session before relaying a signature abort

The failure path does not verify that the session captured in session_id is still the current signing session before charging collateral or calling RelayStatus(STATUS_REJECTED). After an extra or duplicate signature passes the initial state check, CheckPool() can observe that all required signatures are complete and commit the session without consulting m_inflight_session. If the commit resets the session before this message reaches the failure block, AddScriptSig() fails against the cleared entries, the collateral lookup finds nothing, and the unconditional RelayStatus() sets m_relayed_abort while the server is idle. CreateNewSession() does not clear that flag, so the next session inherits it and CheckTimeout() suppresses its guaranteed abort charge. Revalidate nSessionID == *session_id and nState == POOL_STATE_SIGNING under cs_coinjoin before mutating abort state.

Suggested change
LOCK(cs_coinjoin);
// The sender is a verified participant of this signing session, so a signature
// that fails validation - a duplicate, an invalid script or an input that is not
// in the pool - is the sender's own doing, and the abort it forces on everyone
// else identifies the sender as the offender to charge. The participants this
// abort orphans must not pay for it at the timeout that follows.
const auto it = std::ranges::find_if(vecEntries,
[&peer](const auto& entry) { return entry.addr == peer.addr; });
if (it != vecEntries.end()) {
collateral_to_charge = it->txCollateral;
// The submission below runs outside cs_coinjoin, and the reset that can follow
// this abort would reopen admission before it settles: reserve the charge so
// the collateral cannot be re-committed while its penalty spend is in flight.
MarkPendingCharge(collateral_to_charge);
}
RelayStatus(STATUS_REJECTED);
}
{
LOCK(cs_coinjoin);
if (nSessionID != *session_id || nState != POOL_STATE_SIGNING) {
LogPrint(BCLog::COINJOIN,
"DSSIGNFINALTX -- session %d is gone or no longer signing\n", *session_id);
return;
}
// The sender is a verified participant of this signing session, so a signature
// that fails validation - a duplicate, an invalid script or an input that is not
// in the pool - is the sender's own doing, and the abort it forces on everyone
// else identifies the sender as the offender to charge. The participants this
// abort orphans must not pay for it at the timeout that follows.
const auto it = std::ranges::find_if(vecEntries,
[&peer](const auto& entry) { return entry.addr == peer.addr; });
if (it != vecEntries.end()) {
collateral_to_charge = it->txCollateral;
// The submission below runs outside cs_coinjoin, and the reset that can follow
// this abort would reopen admission before it settles: reserve the charge so
// the collateral cannot be re-committed while its penalty spend is in flight.
MarkPendingCharge(collateral_to_charge);
}
RelayStatus(STATUS_REJECTED);
}

source: ['codex']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants