Skip to content

fix: bound DKG pending message queues across NodeId reconnects - #7524

Open
PastaPastaPasta wants to merge 1 commit into
dashpay:developfrom
PastaPastaPasta:sec/v017
Open

fix: bound DKG pending message queues across NodeId reconnects#7524
PastaPastaPasta wants to merge 1 commit into
dashpay:developfrom
PastaPastaPasta:sec/v017

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Aug 2, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

v23.1.8 added param-derived limits for individual DKG messages, but aggregate pending-queue retention was still bounded only by messagesPerNode[from] >= maxMessagesPerNode. NodeId is an ephemeral per-connection identifier, and disconnecting a peer did not release its counter, queued payloads, or queued seenMessages hashes.

A peer could therefore reconnect under fresh NodeId values and repeatedly obtain a new quota while prior payloads remained resident. Observer-mode handlers are especially exposed because they enqueue DKG traffic but do not advance rounds and clear the queues.

What was done?

  • Rebase the fix onto current develop, preserving the v23.1.8 per-message structural and size checks.
  • Retain the per-connection message-count quota and add a queue-wide limit based on actual serialized payload bytes. Each message-type budget is derived from the quorum size and that type's maximum well-formed serialized size.
  • Reject duplicate hashes and empty or individually over-budget remote payloads before quota accounting or eviction, so replay traffic cannot evict another queued message.
  • On byte pressure, evict the oldest payload belonging to the peer currently retaining the most bytes.
  • Add a protocol-handler finalization hook and remove a disconnecting peer's unprocessed payloads, quota, and corresponding queued hashes.
  • Keep locally generated DKG contributions exempt from peer-driven limits.

How Has This Been Tested?

  • make -C src -j8 test/test_dash
  • ./src/test/test_dash --run_test=llmq_dkg_pending_tests (8 test cases)
  • git diff upstream/develop...HEAD --check

The focused tests cover per-node quotas, byte bounds across fresh NodeId values, oversized and empty payload rejection, duplicate replay behavior, byte-based eviction, local-message retention, and disconnect cleanup/accounting.

Breaking Changes

None.

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

@PastaPastaPasta
PastaPastaPasta force-pushed the sec/v017 branch 2 times, most recently from ee9c5e9 to b988249 Compare August 3, 2026 15:01
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

@PastaPastaPasta PastaPastaPasta changed the title fix: bound DKG pending message queue across NodeId reconnects fix: bound DKG pending message queues across NodeId reconnects Aug 4, 2026
CDKGPendingMessages limited intake per NodeId, but NodeId is an ephemeral per-connection identifier. Reconnecting peers could therefore obtain a fresh quota while previously queued payloads remained resident; observer-mode handlers could retain that state indefinitely.

Bound each message-type queue by actual serialized payload bytes derived from its quorum parameters while retaining the per-connection count limit. Reject duplicates and invalid sizes before applying capacity pressure, evict the oldest payload from the peer with the largest occupancy when necessary, and keep locally generated DKG messages exempt.

Add a protocol-handler finalization hook that releases a disconnecting peer's unprocessed payloads, quota, and corresponding seen hashes. Cover byte accounting, reconnect bounds, duplicate replay, eviction, local messages, and disconnect cleanup with focused unit tests.
@PastaPastaPasta
PastaPastaPasta marked this pull request as ready for review August 4, 2026 13:30
@github-actions

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

@thepastaclaw

thepastaclaw commented Aug 4, 2026

Copy link
Copy Markdown

⛔ Blockers found — Sonnet deferred (commit b26434d)
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: b26434df46

ℹ️ 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 on lines +223 to +229
for (auto it = pendingMessages.begin(); it != pendingMessages.end();) {
if (it->from == nodeId) {
// Free the content-hash slot too; otherwise a reconnecting attacker
// can grow seenMessages without bound even after payloads are dropped
// (especially in observer mode where Clear() never runs). The hash is
// stored alongside the payload, so no re-hashing happens here.
it = EraseEntry(it);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve queued DKG messages after relay disconnects

When a requested, structurally valid DKG message is queued for a later phase and its delivery peer disconnects before the worker pops it, this loop erases both the only retained payload and its hash. NetDKG::ProcessMessage authenticates the connection but does not require the delivery peer's identity to match the message's signed proTxHash, so that peer may merely be a relay and its disconnect does not invalidate the contribution, complaint, justification, or commitment. Normal connection churn can therefore make a valid message disappear and potentially prevent a DKG round from completing if no peer announces it again; release the connection quota on disconnect but leave the globally byte-bounded payload queued.

AGENTS.md reference: AGENTS.md:L164-L164

Useful? React with 👍 / 👎.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds size-based limits and byte accounting to DKG pending message queues in CDKGPendingMessages. It rejects invalid or oversized peer messages, evicts the greediest peer's queued messages when capacity is exceeded, and tracks per-peer and total queued bytes. A new NetHandler::FinalizeNode virtual hook is called from PeerManagerImpl on peer disconnect and propagated through NetDKG to remove disconnected nodes' queued state. A new test suite validates the queue limits and cleanup behavior.

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

Possibly related PRs

  • dashpay/dash#7401: Both PRs modify CDKGPendingMessages DKG queue handling, including duplicate tracking, quotas, and pending-message limits.
  • dashpay/dash#7484: Both changes affect DKG message intake: the retrieved PR gates unsolicited messages before PushPendingMessage, while this PR hardens that pending queue's size, duplicate, and eviction handling.

Suggested reviewers: knst

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.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 and concisely describes the main fix: bounding DKG pending-message queues across NodeId reconnects.
Description check ✅ Passed The description directly explains the queue-growth issue, implemented fixes, testing, and absence of breaking 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

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

133-136: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the cs_main lock contract.

PeerManagerImpl::FinalizeNode invokes this callback while it holds cs_main. State that implementations must not re-enter PeerManager from this callback. This prevents lock re-entry in future handlers.

As per coding guidelines, reserve comments for non-obvious invariants.

Proposed update
     //! Called when a peer is fully disconnected. Handlers may free any
     //! per-NodeId state keyed on the ephemeral connection id.
+    //! Called with ::cs_main held. Implementations must not re-enter PeerManager.
     virtual void FinalizeNode(NodeId /*nodeid*/) {}
🤖 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/net_processing.h` around lines 133 - 136, Update the documentation for
the virtual FinalizeNode callback to state that it is invoked while cs_main is
held and implementations must not re-enter PeerManager from the callback. Keep
the existing disconnect and per-NodeId state guidance intact.

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.

Inline comments:
In `@src/llmq/dkgsessionhandler.cpp`:
- Around line 173-183: Update PopPendingMessages so hashes for permanently
popped payloads are removed from seenMessages, using the popped message’s
identifying hash before or as its queue entry is released. Preserve hash
tracking for messages that remain queued, and ensure the cleanup also covers
popped entries from disconnected nodes.

---

Nitpick comments:
In `@src/net_processing.h`:
- Around line 133-136: Update the documentation for the virtual FinalizeNode
callback to state that it is invoked while cs_main is held and implementations
must not re-enter PeerManager from the callback. Keep the existing disconnect
and per-NodeId state guidance intact.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 962eb816-05c8-438f-85ce-7f157c84d412

📥 Commits

Reviewing files that changed from the base of the PR and between dba0e1a and b26434d.

📒 Files selected for processing (8)
  • src/Makefile.test.include
  • src/llmq/dkgsessionhandler.cpp
  • src/llmq/dkgsessionhandler.h
  • src/llmq/net_dkg.cpp
  • src/llmq/net_dkg.h
  • src/net_processing.cpp
  • src/net_processing.h
  • src/test/llmq_dkg_pending_tests.cpp

Comment on lines +173 to +183
auto& front = pendingMessages.front();
ret.emplace_back(front.from, std::move(front.msg));
// Popped messages are handed to the DKG session; their content hash stays
// in seenMessages so AlreadyHave() keeps suppressing re-requests.
if (front.from >= 0) {
pendingBytes -= front.bytes;
}
if (auto qit = queuedBytesPerNode.find(front.from); qit != queuedBytesPerNode.end()) {
qit->second -= front.bytes;
if (qit->second == 0) queuedBytesPerNode.erase(qit);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find every release path for seenMessages and the callers of ClearPendingMessages.
rg -n 'seenMessages' src/llmq/
rg -n -C4 'ClearPendingMessages|HasSeen\(' src/llmq/

Repository: dashpay/dash

Length of output: 5545


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== dkgsessionhandler.h outline =="
ast-grep outline src/llmq/dkgsessionhandler.h --view expanded | sed -n '1,220p'

echo "== dkgsessionhandler.cpp relevant ranges =="
cat -n src/llmq/dkgsessionhandler.cpp | sed -n '1,270p'

echo "== net_dkg relevant ranges around RemoveNode and round start =="
cat -n src/llmq/net_dkg.cpp | sed -n '530,585p'
cat -n src/llmq/net_dkg.cpp | sed -n '655,685p'

Repository: dashpay/dash

Length of output: 15993


Pop seenMessages when messages leave the queue permanently.

PopPendingMessages moves each payload out while leaving its 32-byte hash in seenMessages. RemoveNode only deletes hashes for still-queued entries, so disconnected/reconnected peers can exhaust seenMessages over active phases with no payload-count cap per node. Add a release path for processed payload hashes; for popped payloads, either delete the hash or bound seenMessages independently of pendingBytes.

🤖 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/llmq/dkgsessionhandler.cpp` around lines 173 - 183, Update
PopPendingMessages so hashes for permanently popped payloads are removed from
seenMessages, using the popped message’s identifying hash before or as its queue
entry is released. Preserve hash tracking for messages that remain queued, and
ensure the cleanup also covers popped entries from disconnected nodes.

@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 byte accounting and eviction path is generally coherent, but both reported blockers are confirmed. Disconnect cleanup can discard valid relayed DKG messages, while hashes retained after dequeue escape all new memory bounds, so the PR does not yet safely achieve its liveness and reconnect-memory goals.

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)

🔴 2 blocking

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

In `src/llmq/dkgsessionhandler.cpp`:
- [BLOCKING] src/llmq/dkgsessionhandler.cpp:206-234: Preserve valid relayed messages after delivery-peer disconnects
  `PendingMessage::from` is the NodeId of the connection that delivered the payload, not the quorum member identified by the message's signed `proTxHash`. `NetDKG::ProcessGetData` allows any active node that previously accepted an object to serve it, so the delivery peer may only be a relay. If that relay disconnects before the phase worker pops the message, `RemoveNode()` deletes the otherwise valid, self-contained payload. While it was queued, `AlreadyHave()` returned true, and `SendMessages()` may consequently have called `ForgetTxHash()` for other peers' announcements, so removing the payload does not guarantee another download. Keep queued payloads and their hashes after disconnect and release only the connection-scoped count quota; the new queue-wide byte cap already bounds retained payload memory until processing or eviction.
- [BLOCKING] src/llmq/dkgsessionhandler.cpp:167-184: Bound hashes retained after messages leave the byte-accounted queue
  `PopPendingMessages()` removes a remote payload from `pendingBytes` and `queuedBytesPerNode` but deliberately leaves its hash in `seenMessages`. A later `RemoveNode()` then returns without touching that hash because the NodeId no longer has queued bytes, while also releasing the connection's count quota. An authenticated peer can repeatedly announce requested, unique, structurally valid messages that the active phase worker pops but rejects without a ban—for example, messages naming a known but non-current quorum base mapped to the same handler—and reconnect under fresh NodeIds. The payload-byte counter remains near zero while `seenMessages` grows without a parameter-derived bound until the handler's next DKG round calls `Clear()`. Track retained hashes under a separate bound, or remove rejected hashes after worker verification while relying on the session's accepted-object state for valid messages.

Comment on lines +206 to +234
void CDKGPendingMessages::RemoveNode(NodeId nodeId)
{
// Own/local enqueues use from=-1 and are not tied to a peer disconnect.
if (nodeId < 0) {
return;
}

LOCK(cs_messages);
messagesPerNode.erase(nodeId);

// Runs under ::cs_main (via PeerManagerImpl::FinalizeNode), so skip the list
// scan entirely for the overwhelmingly common case of a peer that never
// queued a DKG message.
if (queuedBytesPerNode.find(nodeId) == queuedBytesPerNode.end()) {
return;
}

for (auto it = pendingMessages.begin(); it != pendingMessages.end();) {
if (it->from == nodeId) {
// Free the content-hash slot too; otherwise a reconnecting attacker
// can grow seenMessages without bound even after payloads are dropped
// (especially in observer mode where Clear() never runs). The hash is
// stored alongside the payload, so no re-hashing happens here.
it = EraseEntry(it);
} else {
++it;
}
}
}

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 valid relayed messages after delivery-peer disconnects

PendingMessage::from is the NodeId of the connection that delivered the payload, not the quorum member identified by the message's signed proTxHash. NetDKG::ProcessGetData allows any active node that previously accepted an object to serve it, so the delivery peer may only be a relay. If that relay disconnects before the phase worker pops the message, RemoveNode() deletes the otherwise valid, self-contained payload. While it was queued, AlreadyHave() returned true, and SendMessages() may consequently have called ForgetTxHash() for other peers' announcements, so removing the payload does not guarantee another download. Keep queued payloads and their hashes after disconnect and release only the connection-scoped count quota; the new queue-wide byte cap already bounds retained payload memory until processing or eviction.

Suggested change
void CDKGPendingMessages::RemoveNode(NodeId nodeId)
{
// Own/local enqueues use from=-1 and are not tied to a peer disconnect.
if (nodeId < 0) {
return;
}
LOCK(cs_messages);
messagesPerNode.erase(nodeId);
// Runs under ::cs_main (via PeerManagerImpl::FinalizeNode), so skip the list
// scan entirely for the overwhelmingly common case of a peer that never
// queued a DKG message.
if (queuedBytesPerNode.find(nodeId) == queuedBytesPerNode.end()) {
return;
}
for (auto it = pendingMessages.begin(); it != pendingMessages.end();) {
if (it->from == nodeId) {
// Free the content-hash slot too; otherwise a reconnecting attacker
// can grow seenMessages without bound even after payloads are dropped
// (especially in observer mode where Clear() never runs). The hash is
// stored alongside the payload, so no re-hashing happens here.
it = EraseEntry(it);
} else {
++it;
}
}
}
void CDKGPendingMessages::RemoveNode(NodeId nodeId)
{
// Own/local enqueues use from=-1 and are not tied to a peer disconnect.
if (nodeId < 0) {
return;
}
LOCK(cs_messages);
// The delivery connection may only be relaying a message signed by another
// quorum member. Release its connection-scoped quota, but retain the bounded
// payload until normal processing or queue eviction removes it.
messagesPerNode.erase(nodeId);
}

source: ['codex']

Comment on lines 167 to 184
@@ -50,7 +170,17 @@ std::list<CDKGPendingMessages::BinaryMessage> CDKGPendingMessages::PopPendingMes

std::list<BinaryMessage> ret;
while (!pendingMessages.empty() && ret.size() < maxCount) {
ret.emplace_back(std::move(pendingMessages.front()));
auto& front = pendingMessages.front();
ret.emplace_back(front.from, std::move(front.msg));
// Popped messages are handed to the DKG session; their content hash stays
// in seenMessages so AlreadyHave() keeps suppressing re-requests.
if (front.from >= 0) {
pendingBytes -= front.bytes;
}
if (auto qit = queuedBytesPerNode.find(front.from); qit != queuedBytesPerNode.end()) {
qit->second -= front.bytes;
if (qit->second == 0) queuedBytesPerNode.erase(qit);
}
pendingMessages.pop_front();

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: Bound hashes retained after messages leave the byte-accounted queue

PopPendingMessages() removes a remote payload from pendingBytes and queuedBytesPerNode but deliberately leaves its hash in seenMessages. A later RemoveNode() then returns without touching that hash because the NodeId no longer has queued bytes, while also releasing the connection's count quota. An authenticated peer can repeatedly announce requested, unique, structurally valid messages that the active phase worker pops but rejects without a ban—for example, messages naming a known but non-current quorum base mapped to the same handler—and reconnect under fresh NodeIds. The payload-byte counter remains near zero while seenMessages grows without a parameter-derived bound until the handler's next DKG round calls Clear(). Track retained hashes under a separate bound, or remove rejected hashes after worker verification while relying on the session's accepted-object state for valid messages.

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