Skip to content

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

Open
PastaPastaPasta wants to merge 9 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 9 commits into
dashpay:developfrom
PastaPastaPasta:fix/coinjoin-abort-fee-on-7537

Conversation

@PastaPastaPasta

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.
  • 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, and successful-session random charging.

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.

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.
The line-based lint-logs.py flags any LogPrint( line that carries neither a newline terminator nor an explicit /* Continued */ marker.
@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:

If these PRs merge first

This PR will likely need a rebase:

@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 3d5cf6b)
Canonical validated blockers: 2

@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 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 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

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: 4c9b2d43-ae3d-47f3-8cca-bcc4c1382ad1

📥 Commits

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

📒 Files selected for processing (6)
  • 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
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/coinjoin/coinjoin.h
  • src/coinjoin/client.cpp
  • src/coinjoin/server.h

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

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

Suggested reviewers: thepastaclaw

🚥 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 main change: charging collateral when a CoinJoin session aborts.
Description check ✅ Passed The description directly explains the abort-collateral problem, implementation, testing, and compatibility impact.
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 Outdated
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.

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.
@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
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
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']

Comment thread src/coinjoin/server.cpp
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']

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