Skip to content

fix: drop and score P2P objects the peer was never asked for - #7484

Open
PastaPastaPasta wants to merge 8 commits into
dashpay:developfrom
PastaPastaPasta:claude/penalize-obsolete-clsigs-07b3b0
Open

fix: drop and score P2P objects the peer was never asked for#7484
PastaPastaPasta wants to merge 8 commits into
dashpay:developfrom
PastaPastaPasta:claude/penalize-obsolete-clsigs-07b3b0

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Jul 25, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Several P2P object types travel invgetdata → object and are never pushed on their own. When such a message arrives that we never asked for, we process it anyway, and for some of them the rejection path costs the sender nothing at all.

The clearest case is CLSIG. ChainlockHandler::ProcessNewChainLock short-circuits with no misbehaviour penalty for any CLSIG at or below our best ChainLock — the height check runs before BLS verification, deliberately, to avoid a verification DoS. Because every distinct signature blob hashes differently, the seen-cache dedup above it never catches a varied blob, so a peer can repeat that work indefinitely for free: churning the (bounded) 1024-entry seen cache and competing with real LLMQ traffic, since CLSIG sits on the quorum priority queue (IsQuorumPriorityMessage).

QFCOMMITMENT has the same shape. Most of the checks in CQuorumBlockProcessor::ProcessMessage return without scoring the peer — deliberately, since we may just be lagging behind or on another chain — so an unsolicited sender can drive the block lookups and mineable-commitment probes repeatedly at no cost. The DKG messages are retained in the per-phase pending queues until a worker gets round to verifying their signatures.

The net layer already tracks, per peer, what we asked for, and governance objects and votes already authorise incoming payloads against it. These three object types simply weren't doing so — the underlying ReceivedResponse was called and its return value discarded.

What was done?

Authorise three object types against the request tracker, dropping and scoring anything we have no in-flight GETDATA for.

The existing PeerConsumeObjectRequest is too weak for this. It resolves to TxRequestTracker::ReceivedResponse, which succeeds for any non-COMPLETED announcement — including the CANDIDATE created the moment an INV is processed. The transition to REQUESTED only happens later, when SendMessages schedules the GETDATA. A peer could therefore send INV(hash) immediately followed by the payload and authorise itself, which defeats the gate entirely; completing a sole candidate also erases it, so the per-peer announcement cap does not bound the repetition. This is reachable in production, not just in tests: sends for masternode connections are deliberately batched at ~100 ms intervals.

So this adds TxRequestTracker::ReceivedRequestedResponse, which succeeds only in the REQUESTED state, exposed as PeerConsumeGetDataResponse. On failure it leaves the announcement untouched, so a premature payload is rejected without suppressing the legitimate GETDATA that follows. Governance keeps the existing announced-or-requested semantics via PeerConsumeObjectRequest, which is unchanged.

The three call sites:

  • CLSIGnet_processing.cpp. Authorised after the spork-19 gate, so a CLSIG dropped while ChainLocks are disabled does not burn a later retransmit.
  • QCONTRIB / QCOMPLAINT / QJUSTIFICATION / QPCOMMITMENTllmq/net_dkg.cpp. The check is placed last, after the MNAuth, size and structural checks, so every existing rejection path and its score is unchanged. Replaces the previous PeerEraseObjectRequest call, which discarded the answer.
  • QFCOMMITMENTllmq/blockprocessor.cpp. Runs before any of the lookups above — the opposite placement from the DKG site, deliberately. There the pre-existing checks are cheap (MNAuth, size, structure) and gating last preserves their heavier penalties; here the pre-existing checks are the expensive part — block-index lookups and mineable-commitment probes under ::cs_main — so an unsolicited sender must be turned away before reaching them. (The gate takes ::cs_main too, but only for a tracker probe: a brief, bounded hold rather than chain-state traversal on the sender's behalf.) This knowingly softens the penalty for unsolicited garbage from 100 to 10 — such a peer now survives ten messages instead of one — but each of those messages is now a deserialize-hash-drop instead of chain lookups, and anything we actually requested still faces the full existing penalties. CQuorumBlockProcessor holds no PeerManagerInternal reference (net_processing already includes this header, so taking one would make the dependency circular), so the check is passed in as a predicate from the call site. Replaces the m_to_erase round-trip, which resolved to the weaker ReceivedResponse.

A shared UNREQUESTED_OBJECT_MISBEHAVIOR_SCORE{10} lives in msg_result.h, matching the existing "moderate" penalties nearby (mnauth from an unknown masternode, a DKG message from a non-verified peer, an invalid CLSIG).

Late and superseded answers are not misbehaviour

(The four commits addressing this are authored by UdjinM6, from review.)

A strict in-flight-only gate conflates an unsolicited push with an honest answer to a GETDATA of ours whose tracker entry is simply gone. Two routine paths remove it: expiry — a REQUESTED announcement past GetObjectInterval() (only 5s for MSG_CLSIG) is completed, and deleted outright when it was the sole announcement for its hash, the common case; and ForgetTxHash — accepting the object from any source (a ChainLock we signed ourselves, or one submitted over RPC) erases every peer's announcement, stranding an in-flight request. Neither is misbehaviour, but both would have been scored 10 — and since the score does not decay within a connection, a run of them discourages the peer's address. On the long-lived masternode connections these object types travel over, that is the wrong outcome.

So SendMessages additionally records, per peer, the GETDATA-only objects we actually sent a GETDATA for, and PeerConsumeGetDataResponse now reports REQUESTED, LATE or UNREQUESTED; only UNREQUESTED is scored, and LATE is processed as before. Only we ever write to that record, so a peer still cannot authorise its own payload by announcing it first. The record is bounded on three axes, each closing an abuse: consumption — an entry is erased by the answer it authorises, so one GETDATA buys exactly one accepted object and the induced payload cannot be replayed; time — entries age out after two request intervals of the requested type, so an unanswered request cannot be banked and redeemed later; type — both the match and the window come from the type we requested, never from the answer, so announcing a hash as MSG_CLSIG and answering with a DKG message is not authorised. The 256-entry per-peer LRU cap is only a memory ceiling: early eviction costs the grace for the oldest requests, never correctness.

The gated types are now named once in IsGetDataOnlyObject, together with why the other inv-driven types are excluded.

What the gate does not close

For the DKG messages and QFCOMMITMENT the gate is the substance of the fix: those payloads were retained in pending queues or driving block lookups before any cheap check could reject them, and requiring a GETDATA bounds that work outright.

For CLSIG the picture is narrower than the motivation above may suggest. The gate closes the push path, but a peer can still get itself solicited: announce the hash, and on non-masternode connections SendMessages runs after every processed message, so the GETDATA goes out almost immediately (masternode connections batch sends at ~100 ms, same outcome slightly later). A stale solicited CLSIG — at or below our best ChainLock — is then still processed and still costs the sender nothing, since ProcessNewChainLock's height check deliberately exits without penalty. So for the stale-CLSIG churn scenario specifically, the gate adds one INV and one GETDATA round-trip per blob rather than eliminating the free work; what it does guarantee is that nothing reaches the handler without our request, and that each request pays for exactly one payload. Closing the remainder means scoring stale solicited CLSIGs or rate-limiting per-peer CLSIG requests — both touch honest relay timing (a peer lagging behind us is normal) and are left as a follow-up.

Deliberately not gated

PLATFORMBAN looks like it belongs in this set — it is only ever pushed from ProcessGetData in-tree — but it has no local ingress: no RPC, no internal producer. The originating Dash Platform node injects a ban by pushing the P2P message straight to a Dash Core peer, so the first hop is always unsolicited by design. p2p_platform_ban.py covers this. A comment at the handler records the reasoning so the omission does not read as an oversight.

The remaining inv-driven types were considered and left alone because each has a legitimate unsolicited-push path that would need a carve-out rather than a plain gate: QSIGREC (proactive relay to peers that sent QSENDRECSIGS), MSG_DSQ (SENDDSQUEUE / WantsDSQ::ALL), ISDLOCK (pushed alongside MERKLEBLOCK for BIP37 clients), and SPORK (bulk push in reply to GETSPORKS). MSG_TX / MSG_DSTX keep upstream Bitcoin semantics.

How Has This Been Tested?

Built with autotools on macOS (aarch64-apple-darwin).

New coverage:

  • src/test/llmq_chainlock_tests.cppunrequested_clsig_is_dropped_and_scored. An unsolicited peer's CLSIG never reaches ProcessNewChainLock, asserted via the handler's AlreadyHave (i.e. the hash was never recorded in the seen cache). It is sent twice and charged for both: without the gate the second copy would hit the seen-cache dedup and cost nothing, so this is what distinguishes the gate from the pre-existing invalid-CLSIG penalty. A peer whose GETDATA is in flight gets through, and its score is asserted exactly, so the gate charging an authorised peer as well would fail the test.
  • src/test/net_tests.cpppeer_getdata_response_requires_an_inflight_request. A bare announcement is rejected and leaves the candidate intact; the same inv authorises exactly once after SendMessages has issued the GETDATA.
  • src/test/llmq_chainlock_tests.cpp also covers the INV-then-payload race directly: announcing and immediately pushing the CLSIG is scored, and the identical payload is only accepted after SendMessages runs. It also gains an end-to-end replay: the same CLSIG, accepted once, is scored when sent again.
  • test/functional/feature_llmq_dkg_intake.py — a well-formed QCONTRIB that passes every earlier check (verified sender, known quorum, size, structure) and is rejected purely for being unrequested.
  • src/test/net_tests.cpp — four cases pinning the late-answer grace and its bounds: the expiry and ForgetTxHash paths each yield LATE for the first answer (asserting the tracker record is gone entirely, not merely COMPLETED) and UNREQUESTED for a second copy; a hash announced as MSG_CLSIG and answered as MSG_QUORUM_CONTRIB inside the window is not authorised; and an answer one second past the two-interval boundary is UNREQUESTED. The boundary cases are expressed in the CLSIG request-interval constants so they sit exactly on the edge — relaxing <= to < fails them.
  • src/test/fuzz/txrequest.cppReceivedRequestedResponse is modelled in the harness as a new command, asserting against the naive model that the call completes an announcement precisely when it was REQUESTED and leaves everything else, notably an INV-created CANDIDATE, untouched.

Both unit tests were mutation-checked: disabling the CLSIG gate fails 3 assertions, and relaxing ReceivedRequestedResponse back to the ReceivedResponse semantics fails 4.

Full make check passes. Functional tests run: feature_llmq_chainlocks.py, feature_llmq_signing.py (both variants), feature_llmq_rotation.py, feature_llmq_is_cl_conflicts.py, feature_llmq_dkgerrors.py, feature_llmq_dkg_intake.py, rpc_verifychainlock.py, p2p_platform_ban.py — all pass. lint-circular-dependencies.py, lint-python.py and lint-whitespace.py are clean.

Re-verified at the current head, i.e. with the four review follow-up commits included: full make check, feature_llmq_chainlocks.py, feature_llmq_dkg_intake.py and p2p_platform_ban.py all pass, the fuzz harness TU compiles, and the three lints above are clean.

p2p_platform_ban.py is what caught the PLATFORMBAN case: it was gated in an earlier revision of this branch and the test failed, which is what prompted checking for a local ingress.

Breaking Changes

None for peers running Dash Core. All three message types have been inv/getdata-only since they were introduced (CLSIG since "Implement and enforce ChainLocks", 2019), so no released version pushes them unsolicited.

Third-party software that pushes these messages rather than waiting for our GETDATA will now be scored 10 per message and disconnected after ten of them. No such producer exists for these three types — that property is exactly what separates them from PLATFORMBAN.

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

🤖 Generated with Claude Code

@thepastaclaw

thepastaclaw commented Jul 25, 2026

Copy link
Copy Markdown

🕓 Ready for review — 9 ahead in queue (commit 5c35e96)
Queue position: 10/12 · 2 reviews active
ETA: start ~06:39 UTC · complete ~07:00 UTC (median 20m across 30 recent reviews; 2 slots)
Queued 4h 11m ago · Last checked: 2026-07-29 05:00 UTC

@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: 0ad2759288

ℹ️ 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/net_processing.cpp Outdated
// since every distinct signature blob hashes differently, an unsolicited peer could
// otherwise repeat that free work indefinitely. Consume after the spork gate so a
// CLSIG dropped while ChainLocks are disabled does not burn a later retransmit.
if (!WITH_LOCK(::cs_main, return PeerConsumeObjectRequest(pfrom.GetId(), clsig_inv))) {

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 Require an actual GETDATA before accepting object payloads

When a malicious peer sends an INV, waits for it to be processed, and then pushes the matching payload before receiving any GETDATA, this condition accepts it without scoring: PeerConsumeObjectRequest delegates to TxRequestTracker::ReceivedResponse, which returns true for every non-COMPLETED announcement, including a merely CANDIDATE one (src/txrequest.cpp:660-669). Because completing the sole candidate also removes it, the peer can repeat this with unlimited distinct stale CLSIGs and still churn the seen cache for free; the same bypass applies to the new DKG and QFCOMMITMENT gates. Check specifically for the REQUESTED state rather than the existence of any announcement, and update the test that currently treats INV alone as authorization.

AGENTS.md reference: AGENTS.md:L160-L161

Useful? React with 👍 / 👎.

@coderabbitai

coderabbitai Bot commented Jul 25, 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: 7ad7bb2d-685f-426b-8a8e-dc807469b95f

📥 Commits

Reviewing files that changed from the base of the PR and between d2228d5 and 5c35e96.

📒 Files selected for processing (5)
  • src/Makefile.test.include
  • src/test/llmq_blockprocessor_tests.cpp
  • src/test/llmq_chainlock_tests.cpp
  • src/test/util/net.cpp
  • src/test/util/net.h
💤 Files with no reviewable changes (1)
  • src/test/llmq_chainlock_tests.cpp

Walkthrough

Message handling now requires outstanding GETDATA consumption for quorum commitments, DKG payloads, and CLSIG messages. Unrequested objects are rejected and assigned a misbehavior score of 10. Recent request tracking distinguishes requested, late, and unrequested responses, while PlatformBan remains exempt. Tests cover request timing, unsolicited rejection, accepted CLSIG messages, replay handling, and DKG banscore changes.

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

Sequence Diagram(s)

sequenceDiagram
  participant Peer
  participant PeerManagerImpl
  participant TxRequestTracker
  participant LLMQHandler
  Peer->>PeerManagerImpl: Send CLSIG, DKG, or quorum object
  PeerManagerImpl->>TxRequestTracker: Consume matching GETDATA request
  TxRequestTracker-->>PeerManagerImpl: REQUESTED, LATE, or UNREQUESTED
  PeerManagerImpl->>LLMQHandler: Process accepted object
  PeerManagerImpl-->>Peer: Score and drop unrequested object
Loading

Possibly related PRs

  • dashpay/dash#7314: Both changes modify the DKG message intake path with GETDATA authorization handling.

Suggested reviewers: udjinm6

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.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 and scoring unsolicited P2P objects.
Description check ✅ Passed The description directly matches the implemented gating, scoring, and test coverage changes.
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.

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

🤖 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/msg_result.h`:
- Around line 17-29: Correct the documentation for
UNREQUESTED_OBJECT_MISBEHAVIOR_SCORE by removing PLATFORMBAN from the list of
GETDATA-only object types and clarifying that it is intentionally handled
without a solicitation gate in ProcessPlatformBanMessage. Keep the existing
rationale for the remaining object types unchanged.
🪄 Autofix (Beta)

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: f2dd58bc-43e8-4791-a61f-233f79889532

📥 Commits

Reviewing files that changed from the base of the PR and between 6d04c60 and 0ad2759.

📒 Files selected for processing (7)
  • src/llmq/blockprocessor.cpp
  • src/llmq/blockprocessor.h
  • src/llmq/net_dkg.cpp
  • src/msg_result.h
  • src/net_processing.cpp
  • src/test/llmq_chainlock_tests.cpp
  • test/functional/feature_llmq_dkg_intake.py

Comment thread src/msg_result.h Outdated

@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 three new solicitation gates use a tracker operation that accepts an INV-created candidate before any GETDATA is sent, so an attacker can still reach the protected CLSIG, DKG, and quorum-commitment processing paths without being scored. The current CLSIG test explicitly exercises this bypass. The PR also contains a contradictory PLATFORMBAN comment and a corrective commit that should be folded into the commits it amends.

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) | 💬 1 nitpick(s)

1 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/net_processing.cpp`:
- [BLOCKING] src/net_processing.cpp:5564: An INV announcement bypasses all three unsolicited-object gates
  `PeerConsumeObjectRequest()` delegates to `TxRequestTracker::ReceivedResponse()`, which returns true for every non-`COMPLETED` entry, including the `CANDIDATE` created immediately when an INV is processed. The transition to `REQUESTED` only occurs later when `SendMessages()` schedules GETDATA. A peer can therefore send `INV(hash)` followed by the matching payload before GETDATA and pass the gate. The CLSIG test demonstrates exactly this path: it calls `AnnounceInv()` and then sends the CLSIG without calling `SendMessages()`, yet expects the object to reach `ProcessNewChainLock()`. This is reachable through the production scheduler as well because sends for masternode connections are intentionally skipped between most message-processing iterations and emitted only every 100 ms. The same predicate is used by the DKG gate at `src/llmq/net_dkg.cpp:490` and the QFCOMMITMENT gate at `src/llmq/blockprocessor.cpp:80`. Completing a sole candidate removes it from the tracker, so the per-peer announcement cap does not prevent repeating INV-plus-payload pairs. Add a distinct tracker operation that succeeds only for `REQUESTED` entries and use it for these GETDATA-only object types, while preserving the existing announcement-or-request semantics for governance. Tests should reject an INV immediately followed by a payload and accept it only after `SendMessages()` has issued GETDATA.

In `<commit:0ad2759288e>`:
- [SUGGESTION] <commit:0ad2759288e>:1: Fold the corrective follow-up into its originating commits
  Commit `0ad2759288e` corrects the CLSIG test introduced by `b8977068872` and adds locking-contract documentation for the callback introduced by `40353a8ccbd`. These are amendments to two separate logical changes rather than one standalone change. Fold the test hunk into the CLSIG commit and the header hunk into the quorum-commitment commit so each originating commit is complete and the final history does not retain review-fix noise.

Comment thread src/net_processing.cpp Outdated
// since every distinct signature blob hashes differently, an unsolicited peer could
// otherwise repeat that free work indefinitely. Consume after the spork gate so a
// CLSIG dropped while ChainLocks are disabled does not burn a later retransmit.
if (!WITH_LOCK(::cs_main, return PeerConsumeObjectRequest(pfrom.GetId(), clsig_inv))) {

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: An INV announcement bypasses all three unsolicited-object gates

PeerConsumeObjectRequest() delegates to TxRequestTracker::ReceivedResponse(), which returns true for every non-COMPLETED entry, including the CANDIDATE created immediately when an INV is processed. The transition to REQUESTED only occurs later when SendMessages() schedules GETDATA. A peer can therefore send INV(hash) followed by the matching payload before GETDATA and pass the gate. The CLSIG test demonstrates exactly this path: it calls AnnounceInv() and then sends the CLSIG without calling SendMessages(), yet expects the object to reach ProcessNewChainLock(). This is reachable through the production scheduler as well because sends for masternode connections are intentionally skipped between most message-processing iterations and emitted only every 100 ms. The same predicate is used by the DKG gate at src/llmq/net_dkg.cpp:490 and the QFCOMMITMENT gate at src/llmq/blockprocessor.cpp:80. Completing a sole candidate removes it from the tracker, so the per-peer announcement cap does not prevent repeating INV-plus-payload pairs. Add a distinct tracker operation that succeeds only for REQUESTED entries and use it for these GETDATA-only object types, while preserving the existing announcement-or-request semantics for governance. Tests should reject an INV immediately followed by a payload and accept it only after SendMessages() has issued GETDATA.

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 — An INV announcement bypasses all three unsolicited-object gates 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/msg_result.h Outdated
Comment on lines +20 to +26
* Several object types (CLSIG, QFCOMMITMENT, the DKG messages, PLATFORMBAN) are only ever sent in
* reply to a GETDATA, so a message with no matching announcement is unsolicited by definition and
* is dropped before it can cost us any work. The score is deliberately tiny because the per-object
* request expires after GetObjectInterval() -- as little as 5s for MSG_CLSIG -- after which an
* honest peer answering our GETDATA late is indistinguishable from one that was never asked. At
* this score a peer is only discouraged after DISCOURAGEMENT_THRESHOLD such messages, which bounds
* the abuse a single connection can sustain without penalising ordinary lag.

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: Remove PLATFORMBAN from the GETDATA-only score documentation

This comment says PLATFORMBAN is only sent in response to GETDATA and is dropped when unsolicited, but ProcessPlatformBanMessage() explicitly documents that its originating first hop is unsolicited by design and deliberately has no solicitation gate. The constant is not used for PLATFORMBAN. Document that carve-out here so future maintenance does not incorrectly add a gate that rejects legitimate platform ban ingress.

Suggested change
* Several object types (CLSIG, QFCOMMITMENT, the DKG messages, PLATFORMBAN) are only ever sent in
* reply to a GETDATA, so a message with no matching announcement is unsolicited by definition and
* is dropped before it can cost us any work. The score is deliberately tiny because the per-object
* request expires after GetObjectInterval() -- as little as 5s for MSG_CLSIG -- after which an
* honest peer answering our GETDATA late is indistinguishable from one that was never asked. At
* this score a peer is only discouraged after DISCOURAGEMENT_THRESHOLD such messages, which bounds
* the abuse a single connection can sustain without penalising ordinary lag.
* CLSIG, QFCOMMITMENT, and the DKG messages are expected only in reply to GETDATA and are
* dropped when their solicitation check fails. PLATFORMBAN is excluded because its originating
* first hop is unsolicited by design; see ProcessPlatformBanMessage(). The score is deliberately

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 — Remove PLATFORMBAN from the GETDATA-only score documentation 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 claude/penalize-obsolete-clsigs-07b3b0 branch from 0ad2759 to d01d5f0 Compare July 26, 2026 03:25
@github-actions

github-actions Bot commented Jul 26, 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 and others added 3 commits July 26, 2026 21:38
A CLSIG is only ever sent in reply to a GETDATA, so a peer we have no in-flight request with is sending it unsolicited. Until now such a message was processed anyway, and ProcessNewChainLock returns without any penalty for a CLSIG at or below our best ChainLock -- the height check short-circuits before verification, deliberately, to avoid a verification DoS. Since every distinct signature blob hashes differently, the seen-cache dedup above it never catches a varied blob, so a peer could repeat that free work indefinitely: churning the bounded seen cache and competing with real LLMQ traffic on the quorum priority queue.

Authorize on the request tracker, which already records per peer what we asked for. ReceivedResponse is too weak for this: it also accepts a CANDIDATE, which exists from the moment an INV is processed, so a peer could authorize its own payload by sending INV and the object back to back before SendMessages ever issued the GETDATA. Add ReceivedRequestedResponse, which succeeds only in the REQUESTED state and leaves the announcement untouched otherwise, so a premature payload is rejected without preventing the normal GETDATA.

Also record why PLATFORMBAN is deliberately left ungated: it has no local ingress, so Dash Platform's push is always the first hop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QCONTRIB/QCOMPLAINT/QJUSTIFICATION/QPCOMMITMENT travel inv -> getdata only (see NetDKG::ProcessGetData and RelayInvToParticipants), so one we have no in-flight request for was never asked for. Such a message was previously retained in the per-phase pending queue until a worker got around to verifying its signature.

Authorize on PeerConsumeGetDataResponse before the message reaches the queues, replacing the PeerEraseObjectRequest call that discarded the same answer. A bare announcement does not qualify, so a peer cannot authorize its own payload by sending INV first. The check is placed last, after the MNAuth, size and structural checks, so the existing rejection paths and their scores are unchanged; feature_llmq_dkg_intake.py gains a case for a well-formed but unrequested QCONTRIB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QFCOMMITMENT is only ever sent in reply to a GETDATA (commitments are announced via AddMineableCommitment -> PeerRelayInv), so one we have no in-flight request for was never asked for. Most of the checks in ProcessMessage reject without scoring the peer -- deliberately, since we may just be lagging behind or on another chain -- so an unsolicited sender could repeat the block lookups and mineable-commitment probes indefinitely at no cost.

Authorize on PeerConsumeGetDataResponse before any of that work, replacing the m_to_erase round-trip that resolved to the weaker ReceivedResponse. CQuorumBlockProcessor holds no PeerManagerInternal reference (net_processing already includes this header, so taking one would make the dependency circular), so the check is passed in as a predicate from the call site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PastaPastaPasta
PastaPastaPasta force-pushed the claude/penalize-obsolete-clsigs-07b3b0 branch from d01d5f0 to 0225574 Compare July 27, 2026 02:46

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

This PR fixes a real DoS gap: three GETDATA-only P2P object types (CLSIG, QFCOMMITMENT, DKG push messages) were previously processed without confirming an in-flight request existed. The current head resolves the prior-review blocker by introducing TxRequestTracker::ReceivedRequestedResponse, which strictly requires State::REQUESTED and correctly rejects a bare INV-created CANDIDATE, closing the INV-then-payload bypass confirmed present at the prior reviewed SHA (d01d5f0). Two new Sonnet-only findings remain in scope: the QFCOMMITMENT gate lacks a dedicated regression test unlike its CLSIG/DKG siblings, and net_dkg.cpp uses UNREQUESTED_OBJECT_MISBEHAVIOR_SCORE via a transitive include rather than a direct one.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: claude-sonnet-5 — final-verifier
  • Sonnet reviewers: claude-sonnet-5 — general (completed), claude-sonnet-5 — dash-core-commit-history (completed)

🟡 1 suggestion(s) | 💬 1 nitpick(s)

1 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/llmq/blockprocessor.cpp`:
- [SUGGESTION] src/llmq/blockprocessor.cpp:74-85: No dedicated test exercises the new QFCOMMITMENT solicitation gate
  The CLSIG gate got a focused unit test (unrequested_clsig_is_dropped_and_scored, including the INV-then-payload race) and the DKG gate got both a unit test (peer_getdata_response_requires_an_inflight_request) and a functional test (test_unrequested_rejected in feature_llmq_dkg_intake.py). The third call site — CQuorumBlockProcessor::ProcessMessage's new consume_request check at blockprocessor.cpp:82 — has no equivalent. A search of src/test/ and test/functional/ confirms no test constructs a CQuorumBlockProcessor and calls ProcessMessage with an unrequested QFCOMMITMENT, and no functional test sends a raw QFCOMMITMENT the node never asked for. net_tests.cpp's peer_getdata_response_requires_an_inflight_request exercises the shared tracker primitive via MSG_SPORK, which gives confidence in the primitive itself but not that the predicate is wired correctly at this call site (e.g. firing before rather than after the block/mineable-commitment lookups it's meant to gate, as the commit message claims). Given this touches consensus-adjacent llmq code, a small addition — a synthetic unrequested CFinalCommitment with a predicate returning false, asserting the MisbehavingError and that no lookups occurred — would close this gap.

Comment on lines 74 to +85
vRecv >> qc;

// A QFCOMMITMENT is only ever sent in reply to a GETDATA (see ProcessGetData), so one we have no
// in-flight request for was never asked for. Drop it up front: most of the checks below reject
// without scoring the peer -- deliberately, since we may just be lagging behind -- so an
// unsolicited peer could otherwise repeat the block lookups and map probes indefinitely. A bare
// announcement deliberately does not qualify: it would let the peer authorise its own payload by
// sending INV first.
if (!consume_request(CInv{MSG_QUORUM_FINAL_COMMITMENT, ::SerializeHash(qc)})) {
LogPrint(BCLog::LLMQ, "CQuorumBlockProcessor::%s -- unrequested commitment from peer=%d\n", __func__,
peer.GetId());
return MisbehavingError{UNREQUESTED_OBJECT_MISBEHAVIOR_SCORE, "unrequested quorum commitment"};

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: No dedicated test exercises the new QFCOMMITMENT solicitation gate

The CLSIG gate got a focused unit test (unrequested_clsig_is_dropped_and_scored, including the INV-then-payload race) and the DKG gate got both a unit test (peer_getdata_response_requires_an_inflight_request) and a functional test (test_unrequested_rejected in feature_llmq_dkg_intake.py). The third call site — CQuorumBlockProcessor::ProcessMessage's new consume_request check at blockprocessor.cpp:82 — has no equivalent. A search of src/test/ and test/functional/ confirms no test constructs a CQuorumBlockProcessor and calls ProcessMessage with an unrequested QFCOMMITMENT, and no functional test sends a raw QFCOMMITMENT the node never asked for. net_tests.cpp's peer_getdata_response_requires_an_inflight_request exercises the shared tracker primitive via MSG_SPORK, which gives confidence in the primitive itself but not that the predicate is wired correctly at this call site (e.g. firing before rather than after the block/mineable-commitment lookups it's meant to gate, as the commit message claims). Given this touches consensus-adjacent llmq code, a small addition — a synthetic unrequested CFinalCommitment with a predicate returning false, asserting the MisbehavingError and that no lookups occurred — would close this gap.

source: ['claude']

@UdjinM6

UdjinM6 commented Jul 28, 2026

Copy link
Copy Markdown

UdjinM6 added 4 commits July 28, 2026 17:11
llmq_chainlock_tests.cpp carried a byte-for-byte copy of the helper in
net_tests.cpp, differing only in the base address it derives the peer
address from. Move it to test/util/net, where the other net test helpers
already live, and drop both copies.

Also drop the test/util/validation.h include added alongside that copy:
nothing in llmq_chainlock_tests.cpp uses TestChainState.
The harness compares TxRequestTracker against a naive model of the same
state machine, but ReceivedRequestedResponse was added without a matching
operation, so the new transition had no coverage there.

Add it as command 11. The model asserts the stricter contract directly:
the call completes an announcement precisely when it was REQUESTED, and
leaves everything else -- notably a CANDIDATE, which exists from the
moment an inv is processed -- untouched.
The solicitation gate treats "no in-flight request" as "never asked for",
but that conflates an unsolicited push with an honest answer to a GETDATA
of ours whose tracker entry is simply gone. Two routine paths remove it:

  - Expiry. SetTimePoint turns an overdue REQUESTED announcement into
    COMPLETED, and MakeCompleted deletes the record outright when it was
    the last non-COMPLETED one for its hash -- the common case, since most
    objects are announced by one peer before any other. The interval is 5s
    for MSG_CLSIG, so a slightly slow peer leaves no trace at all.

  - ForgetTxHash. Accepting an object from any source erases every peer's
    announcement of it, stranding an in-flight request. Reachable whenever
    the object turns up locally while a GETDATA is out: a ChainLock we
    sign ourselves, or one submitted over RPC.

Neither is misbehaviour, but both were scored 10. The score does not decay
within a connection and MaybeDiscourageAndDisconnect exempts only NoBan,
manual and local peers, so a run of them discourages the peer's address.
On the long-lived connections these object types travel over -- DKG and
ChainLock traffic between masternodes -- that is the wrong outcome.

Record, per peer, the GETDATA-only objects we actually asked them for, and
report the outcome as REQUESTED, LATE or UNREQUESTED. Only UNREQUESTED is
scored; LATE is processed as before. Only we ever write to that record, so
a peer cannot authorise its own payload by announcing it first, and the
strict REQUESTED check is unchanged for everything else.

The record is bounded on three axes, and each closes a way to abuse it:

  - Consumption. Each entry is erased by the answer it authorises, on the
    REQUESTED and LATE paths alike, so one GETDATA buys exactly one
    accepted object. A grace that was not consumed would let a peer induce
    a single request and then replay that payload indefinitely, unscored.
    That is not theoretical: CQuorumBlockProcessor::ProcessMessage has no
    payload dedup ahead of its ::cs_main block lookup, and
    PushPendingMessage counts a message against the sender's DKG quota
    before the seenMessages check.

  - Time. Entries age out after RECENT_OBJECT_REQUEST_TTL_INTERVALS of the
    per-type request interval, so a peer cannot bank an unanswered request
    and redeem it much later. The window is expressed in GetObjectInterval()
    because that is already this node's statement of how long it will wait
    before asking someone else: one interval to answer, one more before the
    answer stops counting as an answer.

  - Type. The record keeps the type we asked for, and both the match and
    the window come from it rather than from the answer. The type in an INV
    is whatever the peer said it was and is not bound to the payload until
    that payload arrives, so a peer can announce the hash of a DKG message
    as MSG_CLSIG -- no collision needed, it picks the hash -- and answer
    with the DKG message. Keying on the hash alone would authorise that,
    bypassing the per-type AlreadyHave suppression that would otherwise
    have stopped us requesting it at all.

MAX_RECENT_OBJECT_REQUESTS is therefore only a memory ceiling. The TTL
governs lifetime, and evicting early costs the grace for the oldest
requests -- degrading them to the behaviour of the gate without this record
-- never correctness.

The gated types were implicit across three files; name them once in
IsGetDataOnlyObject, together with why the other inv-driven types are
excluded. Also correct the DKG comment, which read as though the gate ran
before all meaningful work: it runs last, on purpose, so the existing
rejections keep their heavier penalties, and so it bounds retention and
signature verification rather than parsing.
The fix is only worth having if the cases it exists for are pinned, and
only safe if the ways it could be abused are pinned too. Four cases in
net_tests, one addition in llmq_chainlock_tests:

  - expired_getdata_response_is_late_not_unrequested drives the sole
    announcer through expiry, asserts the tracker record is gone entirely
    rather than merely COMPLETED, and that the answer is still LATE. The
    second copy is UNREQUESTED: one GETDATA, one answer.

  - forgotten_getdata_response_is_late_not_unrequested does the same for
    the ForgetTxHash path.

  - getdata_response_grace_does_not_cross_inv_types announces a hash as
    MSG_CLSIG and answers with MSG_QUORUM_CONTRIB inside the window that
    type would have earned, which must not be authorised.

  - getdata_response_grace_expires answers one second past the boundary the
    first case sits on, so the two pin it from either side.

The two boundary cases use CLSIG_REQUEST_INTERVAL and CLSIG_LATE_GRACE
rather than round numbers, so they sit exactly on the edge instead of
straddling it: relaxing the comparison from <= to < fails the first, and
neither would notice with looser timings. Both are kept short of
TIMEOUT_INTERVAL, or MaybeSendPing marks the peer for disconnection and
SendMessages returns before the getdata block.

unrequested_clsig_is_dropped_and_scored gains an end-to-end replay: the
same CLSIG, accepted once, is scored when sent again.
@PastaPastaPasta

Copy link
Copy Markdown
Member Author

The CLSIG and DKG gates each had a test, but nothing drove an unsolicited qfcommit through PeerManagerImpl::ProcessMessage. That left the predicate injection -- the one gate that cannot reach PeerConsumeGetDataResponse directly, because CQuorumBlockProcessor holds no PeerManagerInternal -- covered only by the compiler.

The new case goes through the real dispatch path and pins: an unsolicited commitment is scored UNREQUESTED_OBJECT_MISBEHAVIOR_SCORE for every copy, never once; a null one costs the same 10 rather than the 100 below the gate, which is what places the gate ahead of that check; an INV alone does not authorise the payload; the same payload after SendMessages has issued the GETDATA is not scored at all; and a replay of it is unsolicited again. The commitment names a quorum block we do not have, the one rejection below the gate that deliberately carries no penalty, so every point scored can only have come from the gate.

Verified by neutering the lambda in net_processing to return true unconditionally: four of the five assertions fail, including the null-commitment one at 100 instead of 10.

SendMessage, AnnounceInv and MisbehaviorScore move from llmq_chainlock_tests.cpp to test/util/net alongside MakeTestPeer rather than being copied a second time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants