From 4b7e21fbc1828809b5872cd7527b511c2e02606d Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 12 Aug 2026 08:49:25 -0500 Subject: [PATCH 1/2] fix: bound pending DKG message retention per proTxHash The DKG pending-message retention quota was keyed by NodeId, which resets on reconnect: a single masternode identity could retain an unbounded number of pending messages across reconnects. Key the quota by the sender's proTxHash instead, which survives reconnects and is pinned to registered masternode identities by the MNAuth gate (develop already rejects pushed DKG messages from peers without a verified proRegTxHash). The quota is cumulative for the round and not refunded on pop, so draining the queue does not regain retention slots. Also check the duplicate-hash set before charging the quota so resent hashes don't burn budget, and do not mark a quota-dropped hash as seen so another peer with remaining budget can re-deliver it. Locally produced messages (from=-1) are enqueued under this node's own proTxHash and charged like any other sender's. Extracted from the DKG intake redesign in #7557; the deserialize-once/framing changes there are deliberately not included. --- src/llmq/dkgsessionhandler.cpp | 29 ++++++++++++++++++++--------- src/llmq/dkgsessionhandler.h | 26 ++++++++++++++++---------- src/llmq/net_dkg.cpp | 18 ++++++++++-------- 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/llmq/dkgsessionhandler.cpp b/src/llmq/dkgsessionhandler.cpp index c9258ff353f9..7745a7890afb 100644 --- a/src/llmq/dkgsessionhandler.cpp +++ b/src/llmq/dkgsessionhandler.cpp @@ -6,13 +6,14 @@ #include #include +#include #include namespace llmq { CDKGSessionHandler::CDKGSessionHandler(const Consensus::LLMQParams& _params) : params{_params}, - // we allow size*2 messages as we need to make sure we see bad behavior (double messages) + // we allow size*2 messages per proTx as we need to make sure we see bad behavior (double messages) pendingContributions{(size_t)_params.size * 2}, pendingComplaints{(size_t)_params.size * 2}, pendingJustifications{(size_t)_params.size * 2}, @@ -25,22 +26,32 @@ CDKGSessionHandler::CDKGSessionHandler(const Consensus::LLMQParams& _params) : CDKGSessionHandler::~CDKGSessionHandler() = default; -void CDKGPendingMessages::PushPendingMessage(NodeId from, std::shared_ptr pm, const uint256& hash) +void CDKGPendingMessages::PushPendingMessage(NodeId from, const uint256& sender_protx, + std::shared_ptr pm, const uint256& hash) { LOCK(cs_messages); - if (messagesPerNode[from] >= maxMessagesPerNode) { - // TODO ban? - LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- too many messages, peer=%d\n", __func__, from); + // Check duplicates before the quota so resent hashes don't burn budget + if (seenMessages.count(hash) != 0) { + LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- already seen %s, peer=%d\n", __func__, hash.ToString(), from); return; } - messagesPerNode[from]++; - if (!seenMessages.emplace(hash).second) { - LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- already seen %s, peer=%d\n", __func__, hash.ToString(), from); + // Callers always pass an identity (the MNAuth gate for remote, our own + // proTxHash for local); drop rather than share a null-keyed quota bucket + if (!Assume(!sender_protx.IsNull())) { + return; + } + auto& count = messagesPerProTx[sender_protx]; + if (count >= maxMessagesPerProTx) { + // TODO ban? + LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- too many messages from %s, peer=%d\n", __func__, + sender_protx.ToString(), from); return; } + count++; + seenMessages.emplace(hash); pendingMessages.emplace_back(std::make_pair(from, std::move(pm))); } @@ -67,7 +78,7 @@ void CDKGPendingMessages::Clear() { LOCK(cs_messages); pendingMessages.clear(); - messagesPerNode.clear(); + messagesPerProTx.clear(); seenMessages.clear(); } diff --git a/src/llmq/dkgsessionhandler.h b/src/llmq/dkgsessionhandler.h index be55bfcbaa8a..57e5ecaef322 100644 --- a/src/llmq/dkgsessionhandler.h +++ b/src/llmq/dkgsessionhandler.h @@ -9,7 +9,6 @@ #include #include -#include #include #include #include @@ -55,24 +54,31 @@ class CDKGPendingMessages using BinaryMessage = std::pair>; private: - const size_t maxMessagesPerNode; + const size_t maxMessagesPerProTx; mutable Mutex cs_messages; std::list pendingMessages GUARDED_BY(cs_messages); - std::map messagesPerNode GUARDED_BY(cs_messages); + // Keyed by proTxHash rather than NodeId so the quota survives reconnects, + // and cumulative for the round (not refunded on pop) so draining the queue + // does not regain retention slots. MNAuth pins keys to registered MNs. + Uint256HashMap messagesPerProTx GUARDED_BY(cs_messages); Uint256HashSet seenMessages GUARDED_BY(cs_messages); public: - explicit CDKGPendingMessages(size_t _maxMessagesPerNode) : - maxMessagesPerNode(_maxMessagesPerNode) {}; + explicit CDKGPendingMessages(size_t _maxMessagesPerProTx) : + maxMessagesPerProTx(_maxMessagesPerProTx) {}; /** * Enqueue a serialized DKG message under @p from with content hash @p hash. - * Caller is responsible for hashing the payload and (for real peers) - * routing the erase-request to PeerManager. Drops the message silently on - * per-node capacity overflow or duplicate hash. + * @p sender_protx keys the per-proTx quota: the sender's MNAuth-verified + * proTxHash for remote messages, or this node's own for messages it + * produced itself (@p from == -1). Drops the message silently on quota + * overflow or duplicate hash; quota-dropped messages are not marked seen, + * so another peer with budget can re-deliver them. Caller is responsible + * for hashing the payload and (for real peers) routing the erase-request + * to PeerManager. */ - void PushPendingMessage(NodeId from, std::shared_ptr pm, const uint256& hash) - EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); + void PushPendingMessage(NodeId from, const uint256& sender_protx, std::shared_ptr pm, + const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); std::list PopPendingMessages(size_t maxCount) EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); bool HasSeen(const uint256& hash) const EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); diff --git a/src/llmq/net_dkg.cpp b/src/llmq/net_dkg.cpp index a75f9da8dd5b..b42b0fdf021e 100644 --- a/src/llmq/net_dkg.cpp +++ b/src/llmq/net_dkg.cpp @@ -256,14 +256,14 @@ void RelayInvToParticipants(const CDKGSession& session, const CConnman& connman, } template -void EnqueueOwn(CDKGPendingMessages& pending, const Message& msg) +void EnqueueOwn(CDKGPendingMessages& pending, const uint256& own_protx, const Message& msg) { CDataStream ds(SER_NETWORK, PROTOCOL_VERSION); ds << msg; auto pm = std::make_shared(std::move(ds)); CHashWriter hw(SER_GETHASH, 0); hw.write(AsWritableBytes(Span{*pm})); - pending.PushPendingMessage(/*from=*/-1, std::move(pm), hw.GetHash()); + pending.PushPendingMessage(/*from=*/-1, own_protx, std::move(pm), hw.GetHash()); } template @@ -392,7 +392,9 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre // attacker-controlled payloads, so they must originate from an MNAuth-verified // masternode. qwatch is unauthenticated (any peer can set it via QWATCH) and is // only meaningful for pull/observation paths; it must not bypass this gate. - if (pfrom.GetVerifiedProRegTxHash().IsNull()) { + // Read once: the same value keys the retention quota below + const uint256 sender_protx = pfrom.GetVerifiedProRegTxHash(); + if (sender_protx.IsNull()) { m_peer_manager->PeerMisbehaving(pfrom.GetId(), 10, "DKG message from non-verified peer"); return; } @@ -524,7 +526,7 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre break; } Assume(pending != nullptr); - pending->PushPendingMessage(from, std::move(pm), hash); + pending->PushPendingMessage(from, sender_protx, std::move(pm), hash); }); if (!dispatched) { LogPrintf("NetDKG -- no session handlers for quorumIndex [%d]\n", quorumIndex); @@ -744,7 +746,7 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) // Contribute auto fContributeStart = [curSession, &handler]() { if (auto qc = curSession->Contribute(); qc) { - EnqueueOwn(handler.pendingContributions, *qc); + EnqueueOwn(handler.pendingContributions, curSession->ProTx(), *qc); } }; auto fContributeWait = [this, curSession, &handler, &active] { @@ -757,7 +759,7 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) // Complain auto fComplainStart = [curSession, &handler, &active]() { if (auto qc = curSession->VerifyAndComplain(active.connman); qc) { - EnqueueOwn(handler.pendingComplaints, *qc); + EnqueueOwn(handler.pendingComplaints, curSession->ProTx(), *qc); } }; auto fComplainWait = [this, curSession, &handler, &active] { @@ -769,7 +771,7 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) // Justify auto fJustifyStart = [curSession, &handler]() { if (auto qj = curSession->VerifyAndJustify(); qj) { - EnqueueOwn(handler.pendingJustifications, *qj); + EnqueueOwn(handler.pendingJustifications, curSession->ProTx(), *qj); } }; auto fJustifyWait = [this, curSession, &handler, &active] { @@ -781,7 +783,7 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) // Commit auto fCommitStart = [curSession, &handler]() { if (auto qc = curSession->VerifyAndCommit(); qc) { - EnqueueOwn(handler.pendingPrematureCommitments, *qc); + EnqueueOwn(handler.pendingPrematureCommitments, curSession->ProTx(), *qc); } }; auto fCommitWait = [this, curSession, &handler, &active] { From cf852838357edaa1236273d4ba4404a7053144c7 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 12 Aug 2026 08:49:36 -0500 Subject: [PATCH 2/2] test: cover per-proTx DKG pending-message quotas Unit tests: the quota is charged per proTxHash (surviving NodeId changes and not refunded on pop), duplicates are rejected before charging the quota, distinct proTxHashes have independent budgets, and locally produced messages share the quota path. Functional test: extend feature_llmq_dkg_intake.py with a late-message scenario proving that a masternode identity reconnecting under fresh NodeIds cannot retain more than maxMessagesPerProTx contributions, that quota drops are silent (banscore stays 0), that a distinct proTx keeps its own budget, and that round-start clearing discards retained messages without them ever reaching a worker. Ported from #7557, adapted to the pre-framing intake (well-formed zero-BLS payloads instead of BLS-invalid ones, since develop still deserializes a copy at intake). --- src/test/llmq_dkg_tests.cpp | 97 ++++++++++++++++ test/functional/feature_llmq_dkg_intake.py | 125 +++++++++++++++++++-- 2 files changed, 212 insertions(+), 10 deletions(-) diff --git a/src/test/llmq_dkg_tests.cpp b/src/test/llmq_dkg_tests.cpp index 9715a63f2719..127d9eaffaf2 100644 --- a/src/test/llmq_dkg_tests.cpp +++ b/src/test/llmq_dkg_tests.cpp @@ -3,6 +3,9 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include +#include +#include #include #include @@ -23,4 +26,98 @@ BOOST_AUTO_TEST_CASE(llmq_dkgerror) BOOST_REQUIRE(GetSimulatedErrorRate(llmq::DKGError::type::_COUNT) == 0.0); } +namespace { +std::shared_ptr MakeDKGMessage() +{ + return std::make_shared(SER_NETWORK, PROTOCOL_VERSION); +} + +uint256 MakeTestHash(uint8_t value) +{ + uint256 hash; + hash.begin()[0] = value; + return hash; +} +} // namespace + +BOOST_AUTO_TEST_CASE(pending_messages_own_messages_share_quota_path) +{ + using namespace llmq; + + const uint256 own_protx = MakeTestHash(0xee); + + // Own messages (from=-1) are enqueued under this node's proTxHash and + // charged like any other sender's. + CDKGPendingMessages pending{/*_maxMessagesPerProTx=*/2}; + pending.PushPendingMessage(/*from=*/-1, own_protx, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/-1, own_protx, MakeDKGMessage(), MakeTestHash(2)); + pending.PushPendingMessage(/*from=*/-1, own_protx, MakeDKGMessage(), MakeTestHash(3)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(2))); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(3))); + + BOOST_CHECK_EQUAL(pending.PopPendingMessages(3).size(), 2U); +} + +BOOST_AUTO_TEST_CASE(pending_messages_quota_survives_reconnect) +{ + using namespace llmq; + + const uint256 protx_a = MakeTestHash(0xa1); + + CDKGPendingMessages pending{/*_maxMessagesPerProTx=*/2}; + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(2)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(1))); + BOOST_CHECK(pending.HasSeen(MakeTestHash(2))); + + // Reconnecting mints a fresh NodeId but keeps the proTxHash, so the quota + // is already spent. + pending.PushPendingMessage(/*from=*/2, protx_a, MakeDKGMessage(), MakeTestHash(3)); + pending.PushPendingMessage(/*from=*/3, protx_a, MakeDKGMessage(), MakeTestHash(4)); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(3))); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(4))); + + // Draining frees queue slots but does not refund the per-proTx quota. + BOOST_CHECK_EQUAL(pending.PopPendingMessages(5).size(), 2U); + pending.PushPendingMessage(/*from=*/4, protx_a, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(5))); + + // A new round resets everything. + pending.Clear(); + pending.PushPendingMessage(/*from=*/4, protx_a, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(5))); +} + +BOOST_AUTO_TEST_CASE(pending_messages_quota_is_per_protx) +{ + using namespace llmq; + + const uint256 protx_a = MakeTestHash(0xa1); + const uint256 protx_b = MakeTestHash(0xb1); + + CDKGPendingMessages pending{/*_maxMessagesPerProTx=*/2}; + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(2)); + + // Duplicates are rejected before charging the quota. + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(2)); + + // One proTx's spent quota has no effect on another's. + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(3)); + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(4)); + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(3))); + BOOST_CHECK(pending.HasSeen(MakeTestHash(4))); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(5))); + + // A quota-dropped hash is not marked seen, so a sender with remaining + // budget can still deliver it. + const uint256 protx_c = MakeTestHash(0xc1); + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(5))); + + BOOST_CHECK_EQUAL(pending.PopPendingMessages(6).size(), 5U); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/feature_llmq_dkg_intake.py b/test/functional/feature_llmq_dkg_intake.py index 6d955f262f73..514ef954cd43 100755 --- a/test/functional/feature_llmq_dkg_intake.py +++ b/test/functional/feature_llmq_dkg_intake.py @@ -12,18 +12,29 @@ from a verified peer. - structural pre-validation: malformed DKG payloads (valid quorum prefix, garbage body) are rejected before retention even from a verified peer. + - the per-proTx retention quota is keyed by the MNAuth-verified proTxHash, so + reconnecting under a fresh NodeId does not refill it. - a well-formed DKG message that the peer never announced and was never asked for is dropped before retention, even from a verified peer. The node must not crash; the sending peer must be scored (Misbehaving). """ -from test_framework.messages import ser_compact_size, ser_uint256 +from test_framework.messages import ( + CInv, + hash256, + msg_inv, + ser_compact_size, + ser_uint256, + uint256_from_str, +) from test_framework.p2p import P2PInterface from test_framework.test_framework import DashTestFramework from test_framework.util import wait_until_helper LLMQ_TEST = 100 +# protocol.h GetInventoryType: MSG_QUORUM_CONTRIB +MSG_QUORUM_CONTRIB = 23 # A masternode protx/operator-pubkey pair accepted by the regtest-only `mnauth` # debug RPC, used to mark a P2P connection as MNAuth-verified without BLS signing. @@ -32,6 +43,13 @@ DKG_PUSH_TYPES = [b"qcontrib", b"qcomplaint", b"qjustify", b"qpcommit"] +# LLMQ_TEST dkgInterval; phaseBlocks=2, so stage 0=Initialized, 2=Contribute, 4=Complain. +CYCLE_LENGTH = 24 + +# Mirrors CDKGPendingMessages in src/llmq/dkgsessionhandler.h: the handler is built +# with maxMessagesPerProTx = params.size * 2, per message type. +MAX_MESSAGES_PER_PROTX_FACTOR = 2 + class msg_dkg_raw: """A DKG push message carrying an arbitrary raw payload (for adversarial intake tests).""" @@ -48,10 +66,12 @@ def __repr__(self): return "msg_dkg_raw(type=%s, len=%d)" % (self.msgtype, len(self.payload)) -def get_p2p_id(node): +def get_p2p_id(node, uacomment=None): def get_id(): for p in node.getpeerinfo(): for p2p in node.p2ps: + if uacomment is not None and p2p.uacomment != uacomment: + continue if p["subver"] == p2p.strSubVer: return p["id"] return None @@ -68,6 +88,21 @@ def get_score(): wait_until_helper(lambda: get_score() == expected_score, timeout=10) +def send_requested_qcontrib(peer, payload): + """Announce, wait for GETDATA, then deliver a QCONTRIB payload. + + DKG objects only travel inv -> getdata -> object. NetDKG::ProcessMessage scores + unsolicited pushes before retention, so tests that need the message to reach the + pending queue must complete a real request first. Inventory hash matches + CHashWriter(SER_GETHASH, 0) over the raw wire bytes. + """ + inv_hash = uint256_from_str(hash256(payload)) + peer.send_message(msg_inv([CInv(MSG_QUORUM_CONTRIB, inv_hash)])) + peer.wait_for_getdata([inv_hash]) + peer.send_message(msg_dkg_raw(b"qcontrib", payload)) + peer.sync_with_ping() + + class DkgIntakeTest(DashTestFramework): def add_options(self, parser): self.add_wallet_options(parser) @@ -75,8 +110,9 @@ def add_options(self, parser): def set_test_params(self): # -whitelist keeps the adversarial peer connected even after it crosses the # discouragement threshold, so banscore stays observable for the score==100 cases. - # -debug=net surfaces the Misbehaving reason strings in debug.log. - extra_args = [["-whitelist=127.0.0.1", "-debug=net", "-deprecatedrpc=banscore"]] * 4 + # -debug=net surfaces the Misbehaving reason strings in debug.log, while + # -debug=llmq-dkg exposes queue-boundary behavior (quota drops). + extra_args = [["-whitelist=127.0.0.1", "-debug=net", "-debug=llmq-dkg", "-deprecatedrpc=banscore"]] * 4 self.set_dash_test_params(4, 3, extra_args=extra_args) def quorum_hash_prefix(self): @@ -85,12 +121,12 @@ def quorum_hash_prefix(self): # real in-progress quorum and reach the size/structural checks. return bytes([LLMQ_TEST]) + ser_uint256(int(self.quorum_hash, 16)) - def qcontrib_payload(self, blob_count): + def qcontrib_payload(self, blob_count, protx_hash=0): # CDKGContribution: llmqType, quorumHash, proTxHash, vvec, contributions, sig. # LLMQ_TEST uses threshold=2/minSize=2 by default, so blob_count=1 is # well-formed enough to deserialize but below the contribution lower bound. r = self.quorum_hash_prefix() - r += ser_uint256(0) # proTxHash + r += ser_uint256(protx_hash) r += ser_compact_size(2) + b"\x00" * (2 * 48) # BLSVerificationVector r += b"\x00" * 48 # CBLSIESMultiRecipientBlobs::ephemeralPubKey r += b"\x00" * 32 # CBLSIESMultiRecipientBlobs::ivSeed @@ -100,10 +136,10 @@ def qcontrib_payload(self, blob_count): r += b"\x00" * 96 # sig return r - def add_verified_peer(self, node): - peer = node.add_p2p_connection(P2PInterface()) - peer_id = get_p2p_id(node) - assert node.mnauth(peer_id, FAKE_PROTX, FAKE_PUBKEY) + def add_verified_peer(self, node, uacomment=None, protx=FAKE_PROTX): + peer = node.add_p2p_connection(P2PInterface(), uacomment=uacomment) + peer_id = get_p2p_id(node, uacomment) + assert node.mnauth(peer_id, protx, FAKE_PUBKEY) return peer, peer_id def run_test(self): @@ -120,6 +156,7 @@ def run_test(self): self.test_unverified_sender_rejected(mn_node) self.test_oversized_rejected(mn_node) self.test_malformed_rejected(mn_node) + self.test_late_messages_bounded(mn_node) self.test_under_min_contribution_blobs_rejected(mn_node) self.test_unrequested_rejected(mn_node) @@ -163,6 +200,74 @@ def test_malformed_rejected(self, node): wait_for_banscore(node, peer_id, 100) node.disconnect_p2ps() + def _start_fresh_dkg_cycle(self, nodes): + """Land on the base block of a fresh DKG cycle (phase 1 / Initialized).""" + skip_count = CYCLE_LENGTH - (self.nodes[0].getblockcount() % CYCLE_LENGTH) + # move_blocks (not plain generate) so mocktime keeps up with the DKG phase clock. + self.move_blocks(nodes, skip_count) + self.quorum_hash = self.nodes[0].getbestblockhash() + self.wait_for_quorum_phase(self.quorum_hash, 1, self.llmq_size, None, 0, self.mninfo) + + def _send_late_qcontrib(self, peer, nonce): + """Send a well-formed QCONTRIB that no on-time worker will drain. + + Unique proTxHash bytes per message so retention is bounded by the quotas + rather than by duplicate-hash suppression. + """ + send_requested_qcontrib(peer, self.qcontrib_payload(blob_count=2, protx_hash=nonce)) + + def test_late_messages_bounded(self, node): + self.log.info("Late QCONTRIB retention is bounded per proTx, then cleared at round start") + nodes = [self.nodes[0]] + [mn.get_node(self) for mn in self.mninfo] + self._start_fresh_dkg_cycle(nodes) + stage = self.nodes[0].getblockcount() % CYCLE_LENGTH + assert stage == 0, "expected DKG cycle base, got stage %d" % stage + # Park in Complain so nothing drains pendingContributions for the rest of the round. + complain_stage = 4 + self.move_blocks(nodes, complain_stage - stage) + assert self.nodes[0].getblockcount() % CYCLE_LENGTH == complain_stage + + # The per-proTx quota is keyed by the verified proTxHash, so a peer that + # reconnects under a fresh NodeId keeps spending the same budget. + protx_quota = MAX_MESSAGES_PER_PROTX_FACTOR * self.llmq_size + nonce = 0 + for i in range(protx_quota): + nonce += 1 + peer, peer_id = self.add_verified_peer(node, "dkg-reconnect-%d" % i) + self._send_late_qcontrib(peer, nonce) + wait_for_banscore(node, peer_id, 0) + peer.peer_disconnect() + peer.wait_for_disconnect() + + nonce += 1 + quota_peer, quota_peer_id = self.add_verified_peer(node, "dkg-reconnect-over") + with node.assert_debug_log(["too many messages from %s" % FAKE_PROTX]): + self._send_late_qcontrib(quota_peer, nonce) + wait_for_banscore(node, quota_peer_id, 0) + + # A distinct proTx has its own quota. Keep it connected to verify that + # round-start clearing does not score its retained message. + nonce += 1 + retained_peer, retained_peer_id = self.add_verified_peer(node, "dkg-retained", protx="%064x" % 0xd0) + self._send_late_qcontrib(retained_peer, nonce) + wait_for_banscore(node, retained_peer_id, 0) + + # Crossing the round boundary must clear the raw queue; the retained + # messages must never reach a worker (whose preverification would score + # their unknown proTxHashes). + remaining = CYCLE_LENGTH - (self.nodes[0].getblockcount() % CYCLE_LENGTH) + with node.assert_debug_log( + [], + unexpected_msgs=["failed preverification"], + timeout=60, + ): + self.move_blocks(nodes, remaining) + self.quorum_hash = self.nodes[0].getbestblockhash() + self.wait_for_quorum_phase(self.quorum_hash, 1, self.llmq_size, None, 0, self.mninfo) + wait_for_banscore(node, retained_peer_id, 0) + wait_for_banscore(node, quota_peer_id, 0) + node.disconnect_p2ps() + def test_under_min_contribution_blobs_rejected(self, node): self.log.info("QCONTRIB with fewer than minSize encrypted blobs is rejected (Misbehaving 100)") peer, peer_id = self.add_verified_peer(node)