Skip to content

fix: reject duplicate session collateral in CoinJoin dsa acceptance - #7507

Open
PastaPastaPasta wants to merge 2 commits into
dashpay:developfrom
PastaPastaPasta:fix/coinjoin-duplicate-session-collateral
Open

fix: reject duplicate session collateral in CoinJoin dsa acceptance#7507
PastaPastaPasta wants to merge 2 commits into
dashpay:developfrom
PastaPastaPasta:fix/coinjoin-duplicate-session-collateral

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Aug 1, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

CCoinJoinServer::AddUserToExistingSession never checks whether the incoming dsa.txCollateral was already accepted into the current session — IsAcceptableDSA only validates the denomination and the collateral itself. A client that resends its dsa (or an attacker replaying an observed one) is push_back'd into vecSessionCollaterals again and counted as an extra participant. This inflates the count that IsSessionReady compares against the min/max pool participant thresholds, so a session can look "ready" with fewer real participants than required. Each phantom slot expects a DSVIN entry that never arrives, so such sessions stall until timeout, and ChargeFees may then consume the replayed collateral — but the participant-count inflation itself is wrong regardless.

What was done?

Added an early rejection in AddUserToExistingSession: a dsa is refused when any input prevout of its collateral already appears among the input prevouts of a transaction in vecSessionCollaterals, using the existing ERR_ALREADY_HAVE pool message ("Already have that input."). No new enum value is introduced, so there is no protocol change; old clients handle it as an ordinary dssu rejection.

The obvious check — comparing collateral txids — is insufficient and deliberately not used. Session collaterals are only ever test-accepted against the mempool, never actually added to it, so nothing pins the transaction's identity: a malicious client can re-sign the same collateral UTXO with a varied change output (or just a different signature, since collaterals are malleable pre-broadcast) and produce arbitrarily many distinct txids for what is economically the same collateral. Every one of those variants would sail past a GetHash() comparison and the participant inflation would survive. Prevout overlap closes that hole: no matter how the transaction is re-signed, it must spend the same UTXO, and identical transactions necessarily share prevouts, so the prevout check strictly subsumes the txid check.

Honest participants can never collide on a prevout: distinct wallets cannot spend the same UTXO, and a wallet mixing in multiple sessions locks its collateral inputs per session, so each concurrent session gets a collateral built from disjoint inputs.

How Has This Been Tested?

  • Compile-verified src/coinjoin/server.cpp with clang++ -fsyntax-only using the project's compile_commands.json flags.
  • No unit test added: exercising AddUserToExistingSession requires driving ProcessDSACCEPT, which needs the server's active masternode registered in the deterministic MN list at chain tip plus a mempool-valid collateral transaction (the fUnitTest bypass is private and never enabled anywhere). None of that scaffolding exists in the current CoinJoin unit tests (src/test/coinjoin_inouts_tests.cpp only reaches the DSSIGNFINALTX path), and building it out is beyond the scope of this fix.
  • A full build / functional-test run was not performed for this change.

Breaking Changes

None. The rejection reuses an existing pool message code, so wire compatibility is unchanged; only dsa messages whose collateral reuses an input prevout already committed to the session are now refused.

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)

@github-actions

github-actions Bot commented Aug 1, 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:

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@PastaPastaPasta, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 986998ba-a7a5-4ca4-ad19-f387947be20a

📥 Commits

Reviewing files that changed from the base of the PR and between 4b58e0f and 9187f19.

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

Walkthrough

CCoinJoinServer now tracks collateral input prevouts in a salted hash set. The server records prevouts when it creates or extends sessions and clears them during reset. AddUserToExistingSession rejects collateral that reuses a tracked prevout with ERR_ALREADY_HAVE.

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

Possibly related PRs

  • dashpay/dash#7052: Modifies CoinJoin session collateral handling in CCoinJoinServer.

Suggested reviewers: udjinm6

🚥 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: rejecting duplicate CoinJoin session collateral during DSA acceptance.
Description check ✅ Passed The description accurately explains the duplicate-prevout issue, implementation, compatibility impact, and testing status.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

❤️ Share

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

@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: 32715ddaa0

ℹ️ 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
// don't let the same collateral join the session twice: a replayed dsa would
// otherwise be counted as an extra participant
if (std::ranges::any_of(vecSessionCollaterals, [&dsa](const CTransactionRef& txCollateral) {
return txCollateral->GetHash() == dsa.txCollateral.GetHash();

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 Compare collateral inputs rather than transaction hashes

When a malicious client creates multiple collateral transactions spending the same UTXO but varies the change output or signature, each transaction has a different hash and passes IsCollateralValid independently because these transactions are only test-accepted, not inserted into the mempool. All variants can therefore still be appended as separate participants even though at most one can be consumed, preserving the participant-inflation and session-stalling attack this change is intended to prevent. Reject a DSA when any of its collateral prevouts are already used by a session collateral rather than only when the complete transaction hash matches.

Useful? React with 👍 / 👎.

@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/server.cpp (1)

807-816: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add a targeted regression test for duplicate admission.

For a non-ready session, submit the same collateral twice. Verify that the second call returns false, sets nMessageIDRet to ERR_ALREADY_HAVE, and leaves vecSessionCollaterals.size() unchanged. Verify that the replay does not make IsSessionReady() return true.

The PR reports that no tests were run. Run the targeted CoinJoin test for src/coinjoin/server.cpp. As per coding guidelines, choose tests based on the files touched and do not claim broad validation when only a targeted test was run.

🤖 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 807 - 816, Add a targeted CoinJoin
regression test for duplicate admission in the non-ready session path: submit
the same collateral twice through AddUserToExistingSession, then assert the
second call returns false, sets nMessageIDRet to ERR_ALREADY_HAVE, preserves
vecSessionCollaterals.size(), and leaves IsSessionReady() false. Run the
targeted test covering src/coinjoin/server.cpp and report only that validation.

Source: Coding guidelines

🤖 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/server.cpp`:
- Around line 807-816: Add a targeted CoinJoin regression test for duplicate
admission in the non-ready session path: submit the same collateral twice
through AddUserToExistingSession, then assert the second call returns false,
sets nMessageIDRet to ERR_ALREADY_HAVE, preserves vecSessionCollaterals.size(),
and leaves IsSessionReady() false. Run the targeted test covering
src/coinjoin/server.cpp and report only that validation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 75e60afe-887b-44f4-b32f-48e37adad066

📥 Commits

Reviewing files that changed from the base of the PR and between efe6dec and 32715dd.

📒 Files selected for processing (1)
  • src/coinjoin/server.cpp

@thepastaclaw

thepastaclaw commented Aug 1, 2026

Copy link
Copy Markdown

⛔ Blockers found — Sonnet deferred (commit 9187f19)
Canonical validated blockers: 1

@PastaPastaPasta
PastaPastaPasta force-pushed the fix/coinjoin-duplicate-session-collateral branch from 32715dd to 9171828 Compare August 1, 2026 23:17
AddUserToExistingSession never checked whether the incoming dsa.txCollateral was already accepted into the current session. A client (or an attacker replaying a client's dsa) could therefore be added to vecSessionCollaterals multiple times and be counted as multiple participants, inflating the count IsSessionReady compares against the min/max pool participant thresholds. A session could then look ready with fewer real participants than required; each phantom slot expects a DSVIN entry that never arrives, so such sessions stall until timeout and ChargeFees may consume the replayed collateral.

Comparing collateral txids is not sufficient: collaterals are only ever test-accepted, never added to the mempool, so a malicious client can re-sign the same collateral UTXO with a varied change output or signature and produce arbitrarily many distinct txids for what is economically the same collateral, bypassing any hash comparison. Instead, maintain a set mirroring the input prevouts of every accepted session collateral - populated when a collateral enters the session in CreateNewSession or AddUserToExistingSession, cleared alongside vecSessionCollaterals in SetNull - and reject a dsa whose collateral spends any prevout in it; identical transactions necessarily share prevouts, so this subsumes the txid check. Honest participants can never collide on a prevout: distinct wallets cannot spend the same UTXO, and a wallet mixing in multiple sessions locks its collateral inputs per session. Rejected with ERR_ALREADY_HAVE, an existing pool message, so no protocol change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@PastaPastaPasta
PastaPastaPasta force-pushed the fix/coinjoin-duplicate-session-collateral branch from 9171828 to 4b58e0f Compare August 1, 2026 23:30

@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 prevout-based comparison addresses the previously reported collateral-txid malleability issue, but the newly added set is accessed concurrently with session resets without synchronization. This introduces undefined behavior and can leave stale prevouts after a timeout, so the synchronization issue must be fixed before merge; targeted regression coverage should also be added for the security-relevant admission behavior.

Source: reviewers codex/general=gpt-5.6-sol(completed); codex/dash-core-commit-history=gpt-5.6-sol(completed); verifier=codex/verifier=gpt-5.6-sol(completed); coordinator=openclaw-agent/cliproxy/gpt-5.6-sol(orchestration-only).

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

Review provenance

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

🔴 1 blocking | 🟡 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:814-828: Synchronize the collateral prevout set with session resets
  `ProcessDSACCEPT()` executes on the message-processing thread, while `Schedule()` invokes `CheckTimeout()` on the scheduler thread. A timeout clears `setSessionCollateralPrevouts` under `cs_coinjoin` in `SetNull()`, but the lookup and insertion here, as well as the insertion in `CreateNewSession()`, do not hold that lock. Concurrent `clear()` with `count()` or `insert()` on an `unordered_set` is undefined behavior. The mirrored state can also become inconsistent if a timeout clears the vector and set after `vecSessionCollaterals.push_back()` but before these insertions: this thread then repopulates the set after the session has been reset, causing a later session to reject valid collateral as `ERR_ALREADY_HAVE`. Protect the vector and set update as one invariant under `cs_coinjoin`, and revalidate the session ID and state while holding the lock so a reset cannot occur between the admission checks and the atomic update.
- [SUGGESTION] src/coinjoin/server.cpp:814-828: Add regression coverage for prevout-based duplicate admission
  No unit or functional test exercises the security-relevant admission behavior added by this commit. Add a targeted test that establishes a session and verifies that both an identical collateral and a distinct transaction reusing any accepted prevout are rejected with `ERR_ALREADY_HAVE`, while collateral using disjoint inputs is accepted. The test should include multi-input overlap and verify that resetting the session allows previously used prevouts again, guarding against regressions to txid comparison and stale mirrored state.

Comment thread src/coinjoin/server.cpp
Comment on lines +814 to +828
if (const auto it = std::ranges::find_if(dsa.txCollateral.vin,
[this](const CTxIn& txin) { return setSessionCollateralPrevouts.count(txin.prevout) > 0; });
it != dsa.txCollateral.vin.end()) {
LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- collateral %s spends prevout %s already used by a session collateral, rejecting\n",
dsa.txCollateral.GetHash().ToString(), it->prevout.ToStringShort());
nMessageIDRet = ERR_ALREADY_HAVE;
return false;
}

// count new user as accepted to an existing session

nMessageIDRet = MSG_NOERR;
vecSessionCollaterals.push_back(MakeTransactionRef(dsa.txCollateral));
for (const auto& txin : dsa.txCollateral.vin) {
setSessionCollateralPrevouts.insert(txin.prevout);

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: Synchronize the collateral prevout set with session resets

ProcessDSACCEPT() executes on the message-processing thread, while Schedule() invokes CheckTimeout() on the scheduler thread. A timeout clears setSessionCollateralPrevouts under cs_coinjoin in SetNull(), but the lookup and insertion here, as well as the insertion in CreateNewSession(), do not hold that lock. Concurrent clear() with count() or insert() on an unordered_set is undefined behavior. The mirrored state can also become inconsistent if a timeout clears the vector and set after vecSessionCollaterals.push_back() but before these insertions: this thread then repopulates the set after the session has been reset, causing a later session to reject valid collateral as ERR_ALREADY_HAVE. Protect the vector and set update as one invariant under cs_coinjoin, and revalidate the session ID and state while holding the lock so a reset cannot occur between the admission checks and the atomic update.

source: ['codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 9187f19. AddUserToExistingSession now takes cs_coinjoin before the duplicate-prevout check and holds it through the vecSessionCollaterals/setSessionCollateralPrevouts update, revalidating nSessionID and nState under the lock so a scheduler-thread SetNull() cannot interleave between the admission checks and the commit. CreateNewSession takes the lock around its mirrored update as well, and setSessionCollateralPrevouts is now GUARDED_BY(cs_coinjoin) so the invariant is enforced by thread-safety analysis (verified with -Wthread-safety, clean).

Comment thread src/coinjoin/server.cpp
Comment on lines +814 to +828
if (const auto it = std::ranges::find_if(dsa.txCollateral.vin,
[this](const CTxIn& txin) { return setSessionCollateralPrevouts.count(txin.prevout) > 0; });
it != dsa.txCollateral.vin.end()) {
LogPrint(BCLog::COINJOIN, "CCoinJoinServer::AddUserToExistingSession -- collateral %s spends prevout %s already used by a session collateral, rejecting\n",
dsa.txCollateral.GetHash().ToString(), it->prevout.ToStringShort());
nMessageIDRet = ERR_ALREADY_HAVE;
return false;
}

// count new user as accepted to an existing session

nMessageIDRet = MSG_NOERR;
vecSessionCollaterals.push_back(MakeTransactionRef(dsa.txCollateral));
for (const auto& txin : dsa.txCollateral.vin) {
setSessionCollateralPrevouts.insert(txin.prevout);

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: Add regression coverage for prevout-based duplicate admission

No unit or functional test exercises the security-relevant admission behavior added by this commit. Add a targeted test that establishes a session and verifies that both an identical collateral and a distinct transaction reusing any accepted prevout are rejected with ERR_ALREADY_HAVE, while collateral using disjoint inputs is accepted. The test should include multi-input overlap and verify that resetting the session allows previously used prevouts again, guarding against regressions to txid comparison and stale mirrored state.

source: ['codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

There is currently no test harness that can reach this path: no unit tests construct CCoinJoinServer (it needs live PeerManagerInternal/CConnman/CDeterministicMNManager/active-masternode wiring, and CreateNewSession/AddUserToExistingSession are private), and the functional framework has no dsa/DSACCEPT message support (test/functional/test_framework/messages.py has no CoinJoin P2P messages; the only coinjoin functional test is RPC-only). Building that harness is worthwhile but is a substantially larger change than this fix; I'd prefer to keep this security fix minimal and take regression coverage as a follow-up. The GUARDED_BY annotation added in 9187f19 does give compile-time enforcement of the new invariant in the meantime.

@thepastaclaw thepastaclaw Aug 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Correction: this test-coverage finding is intentionally deferred, not resolved. No regression test was added in 9187f19; the current verifier accepted the maintainer's explanation that reaching the private DSACCEPT admission path requires substantial new unit-test or functional-P2P scaffolding. The thread remains available as follow-up context.

…esets

CheckTimeout runs on the scheduler thread and clears vecSessionCollaterals and setSessionCollateralPrevouts under cs_coinjoin in SetNull, but ProcessDSACCEPT mutated both from the message-processing thread without the lock: concurrent clear() and count()/insert() on the unordered_set is undefined behavior, and a timeout landing between the admission checks and the insertions could repopulate the set after the session was reset, poisoning a later session into rejecting valid collateral with ERR_ALREADY_HAVE.

Take cs_coinjoin around the duplicate-prevout check and the vector/set update as one atomic block in AddUserToExistingSession, revalidating nSessionID and nState under the lock so a reset cannot interleave, and around the mirrored update in CreateNewSession. Annotate setSessionCollateralPrevouts GUARDED_BY(cs_coinjoin) so thread-safety analysis enforces this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 latest delta eliminates concurrent access to the mirrored prevout set and correctly revalidates admission to an existing session, but the carried-forward synchronization blocker remains in the first-collateral path: CreateNewSession() can still append collateral after a scheduler reset. No genuinely new latest-delta defect was confirmed; the regression-test request was explicitly deferred, and the commit-squash suggestion is a maintainer preference rather than an actionable defect.

Source: reviewers codex/general=gpt-5.6-sol(completed); codex/dash-core-commit-history=gpt-5.6-sol(completed); verifier=codex/verifier=gpt-5.6-sol(completed); coordinator=openclaw-agent/cliproxy/gpt-5.6-sol(orchestration-only).

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

Review provenance

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

🔴 1 blocking

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

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