From 93909b4f447c1eae87ba681f0ecb1ffd0e5197c6 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 14 Jul 2026 22:43:48 -0500 Subject: [PATCH 1/9] perf: deserialize DKG network messages once Keep incoming DKG messages as exact raw wire bytes after cheap framing validation. The DKG worker remains the sole normal typed and BLS deserialization point, avoiding repeated point decoding on the message-handler thread. Preserve authenticated-only intake, parameter-derived size and framing checks, exact-wire inventory hashes, duplicate-before-quota ordering, worker preverification, malformed-message scoring in the matching phase, and own-message processing. Bound each raw message-type queue across all remote NodeIds to four times the quorum size, allowing two peers to use their full per-node quotas, with one reserved slot for the local phase message. New-round cleanup discards bounded stale raw bytes without deserializing them, so reconnect churn cannot create an unbounded synchronous BLS workload before quorum initialization. Add unit coverage for per-node and queue-wide caps, duplicate ordering, local-message capacity, and round clearing. Update the focused functional test to cover worker-deferred BLS rejection plus bounded late-message retention across reconnect-generated NodeIds and prompt next-round initialization. Co-Authored-By: Claude --- src/llmq/dkgsessionhandler.cpp | 38 ++- src/llmq/dkgsessionhandler.h | 44 +-- src/llmq/net_dkg.cpp | 308 +++++++++++++++++---- src/test/llmq_dkg_tests.cpp | 68 +++++ test/functional/feature_llmq_dkg_intake.py | 150 +++++++++- 5 files changed, 494 insertions(+), 114 deletions(-) diff --git a/src/llmq/dkgsessionhandler.cpp b/src/llmq/dkgsessionhandler.cpp index c9258ff353f9..b656f532e80e 100644 --- a/src/llmq/dkgsessionhandler.cpp +++ b/src/llmq/dkgsessionhandler.cpp @@ -29,18 +29,32 @@ void CDKGPendingMessages::PushPendingMessage(NodeId from, std::shared_ptr= maxMessagesPerNode) { + // Check duplicates before charging the per-node quota so a peer that + // resends the same hash cannot exhaust its budget with dupes. + if (seenMessages.count(hash) != 0) { + LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- already seen %s, peer=%d\n", __func__, hash.ToString(), from); + return; + } + + const auto node_it = messagesPerNode.find(from); + if (node_it != messagesPerNode.end() && node_it->second >= maxMessagesPerNode) { // TODO ban? LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- too many messages, peer=%d\n", __func__, 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); + const bool is_remote = from != -1; + if ((is_remote && pendingRemoteMessageCount >= maxPendingRemoteMessages) || + pendingMessages.size() >= maxPendingMessages) { + LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- pending queue full, peer=%d\n", __func__, from); return; } + messagesPerNode[from]++; + if (is_remote) { + pendingRemoteMessageCount++; + } + seenMessages.emplace(hash); pendingMessages.emplace_back(std::make_pair(from, std::move(pm))); } @@ -50,6 +64,9 @@ std::list CDKGPendingMessages::PopPendingMes std::list ret; while (!pendingMessages.empty() && ret.size() < maxCount) { + if (pendingMessages.front().first != -1) { + pendingRemoteMessageCount--; + } ret.emplace_back(std::move(pendingMessages.front())); pendingMessages.pop_front(); } @@ -57,20 +74,21 @@ std::list CDKGPendingMessages::PopPendingMes return ret; } -bool CDKGPendingMessages::HasSeen(const uint256& hash) const -{ - LOCK(cs_messages); - return seenMessages.count(hash) != 0; -} - void CDKGPendingMessages::Clear() { LOCK(cs_messages); pendingMessages.clear(); + pendingRemoteMessageCount = 0; messagesPerNode.clear(); seenMessages.clear(); } +bool CDKGPendingMessages::HasSeen(const uint256& hash) const +{ + LOCK(cs_messages); + return seenMessages.count(hash) != 0; +} + void CDKGSessionHandler::ClearPendingMessages() { pendingContributions.Clear(); diff --git a/src/llmq/dkgsessionhandler.h b/src/llmq/dkgsessionhandler.h index be55bfcbaa8a..c631f103126d 100644 --- a/src/llmq/dkgsessionhandler.h +++ b/src/llmq/dkgsessionhandler.h @@ -11,8 +11,6 @@ #include #include #include -#include -#include #include class CDataStream; @@ -47,7 +45,7 @@ enum class QuorumPhase { * main handler thread, we push them into a CDKGPendingMessages object and later pop+deserialize them in the DKG phase * handler thread. * - * Each message type has it's own instance of this class. + * Each message type has its own instance of this class. */ class CDKGPendingMessages { @@ -56,20 +54,31 @@ class CDKGPendingMessages private: const size_t maxMessagesPerNode; + const size_t maxPendingRemoteMessages; + const size_t maxPendingMessages; mutable Mutex cs_messages; std::list pendingMessages GUARDED_BY(cs_messages); + size_t pendingRemoteMessageCount GUARDED_BY(cs_messages){0}; std::map messagesPerNode GUARDED_BY(cs_messages); Uint256HashSet seenMessages GUARDED_BY(cs_messages); public: explicit CDKGPendingMessages(size_t _maxMessagesPerNode) : - maxMessagesPerNode(_maxMessagesPerNode) {}; + maxMessagesPerNode(_maxMessagesPerNode), + // Let two peers use their full quota while keeping reconnect-generated + // NodeIds from growing the queue without bound. + maxPendingRemoteMessages(_maxMessagesPerNode * 2), + // Reserve one slot for the message produced by this node during the + // matching phase. + maxPendingMessages(maxPendingRemoteMessages + 1) + { + } /** * 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. + * per-node or queue-wide capacity overflow, or duplicate hash. */ void PushPendingMessage(NodeId from, std::shared_ptr pm, const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); @@ -77,31 +86,6 @@ class CDKGPendingMessages std::list PopPendingMessages(size_t maxCount) EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); bool HasSeen(const uint256& hash) const EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); void Clear() EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); - - // Might return nullptr messages, which indicates that deserialization failed for some reason - template - std::vector>> PopAndDeserializeMessages(size_t maxCount) - EXCLUSIVE_LOCKS_REQUIRED(!cs_messages) - { - auto binaryMessages = PopPendingMessages(maxCount); - if (binaryMessages.empty()) { - return {}; - } - - std::vector>> ret; - ret.reserve(binaryMessages.size()); - for (const auto& bm : binaryMessages) { - auto msg = std::make_shared(); - try { - *bm.second >> *msg; - } catch (...) { - msg = nullptr; - } - ret.emplace_back(std::make_pair(bm.first, std::move(msg))); - } - - return ret; - } }; /** diff --git a/src/llmq/net_dkg.cpp b/src/llmq/net_dkg.cpp index e533aadf8777..8e757180e6e3 100644 --- a/src/llmq/net_dkg.cpp +++ b/src/llmq/net_dkg.cpp @@ -72,46 +72,195 @@ size_t MaxDKGMessageSize(std::string_view msg_type, const Consensus::LLMQParams& return cap < HARD_CEILING ? cap : HARD_CEILING; } -// Cheap, param-only structural validation of a pushed DKG message, run at intake -// before retention. Deserializes a COPY of the payload (leaving the caller's bytes -// intact for the pending queue and its inventory hash) and checks only safe upper -// bounds derived from quorum params: no member-list lookup and no signature -// verification, which remain on the DKG worker thread. Deserializing the copy does -// decompress the BLS points carried in the payload, but that work is bounded by -// the size cap applied just before this check. Rejects malformed or clearly -// oversized payloads before retention. -bool CheckDKGMessageStructure(std::string_view msg_type, const CDataStream& vRecv, const Consensus::LLMQParams& params) +bool SkipBytes(CDataStream& ds, size_t size) +{ + try { + ds.ignore(size); + return true; + } catch (const std::exception&) { + return false; + } +} + +std::optional ReadCompactSizeNoThrow(CDataStream& ds) +{ + try { + return ReadCompactSize(ds); + } catch (const std::exception&) { + return std::nullopt; + } +} + +bool SkipCommonDKGFields(CDataStream& ds) +{ + constexpr size_t PREFIX = 1 + 32 + 32; // llmqType + quorumHash + proTxHash + return SkipBytes(ds, PREFIX); +} + +// BLS encodings have fixed wire sizes, so intake only needs to establish that +// the bytes are present. Decoding and canonical/scheme validation remain on the +// DKG worker, where each object is materialized exactly once. +template +bool SkipBLSObject(CDataStream& ds) +{ + return SkipBytes(ds, BLSObject::SerSize); +} + +bool ReadAndCheckDynBitset(CDataStream& ds, uint64_t max_size, uint64_t& size_ret) +{ + const auto size = ReadCompactSizeNoThrow(ds); + if (!size.has_value() || size.value() > max_size) { + return false; + } + + const size_t byte_size = (size.value() + 7) / 8; + std::vector bytes(byte_size); + try { + ds.read(AsWritableBytes(Span{bytes})); + } catch (const std::exception&) { + return false; + } + + if (!bytes.empty() && bytes.size() * 8 != size.value()) { + const size_t rem = bytes.size() * 8 - size.value(); + const uint8_t mask = ~(uint8_t)(0xff >> rem); + if (bytes.back() & mask) { + return false; + } + } + + size_ret = size.value(); + return true; +} + +bool CheckContributionWireStructure(CDataStream& ds, const Consensus::LLMQParams& params) { const size_t size = params.size > 0 ? static_cast(params.size) : 0; const size_t min_size = params.minSize > 0 ? static_cast(params.minSize) : 0; const size_t threshold = params.threshold > 0 ? static_cast(params.threshold) : 0; - try { - CDataStream s(vRecv); // copy; deserialization does not advance the caller's stream - if (msg_type == NetMsgType::QCONTRIB) { - CDKGContribution qc; - s >> qc; - return qc.vvec != nullptr && qc.vvec->size() == threshold && - qc.contributions != nullptr && - qc.contributions->blobs.size() >= min_size && - qc.contributions->blobs.size() <= size; - } else if (msg_type == NetMsgType::QCOMPLAINT) { - CDKGComplaint qc; - s >> qc; - return qc.badMembers.size() == qc.complainForMembers.size() && - qc.badMembers.size() <= size; - } else if (msg_type == NetMsgType::QJUSTIFICATION) { - CDKGJustification qj; - s >> qj; - return qj.contributions.size() <= size; - } else if (msg_type == NetMsgType::QPCOMMITMENT) { - CDKGPrematureCommitment qc; - s >> qc; - return qc.validMembers.size() <= size; + + if (!SkipCommonDKGFields(ds)) { + return false; + } + + const auto vvec_size = ReadCompactSizeNoThrow(ds); + if (!vvec_size.has_value() || vvec_size.value() != threshold) { + return false; + } + for (uint64_t i = 0; i < vvec_size.value(); ++i) { + if (!SkipBLSObject(ds)) { + return false; } + } + + if (!SkipBLSObject(ds) || !SkipBytes(ds, 32)) { return false; - } catch (const std::exception&) { + } + + const auto blob_count = ReadCompactSizeNoThrow(ds); + if (!blob_count.has_value() || blob_count.value() < min_size || blob_count.value() > size) { return false; } + for (uint64_t i = 0; i < blob_count.value(); ++i) { + const auto blob_size = ReadCompactSizeNoThrow(ds); + if (!blob_size.has_value() || !SkipBytes(ds, blob_size.value())) { + return false; + } + } + + return SkipBLSObject(ds) && ds.empty(); +} + +bool CheckComplaintWireStructure(CDataStream& ds, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + uint64_t bad_members_size{0}; + uint64_t complain_for_members_size{0}; + return SkipCommonDKGFields(ds) && ReadAndCheckDynBitset(ds, size, bad_members_size) && + ReadAndCheckDynBitset(ds, size, complain_for_members_size) && + bad_members_size == complain_for_members_size && SkipBLSObject(ds) && ds.empty(); +} + +bool CheckJustificationWireStructure(CDataStream& ds, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + if (!SkipCommonDKGFields(ds)) { + return false; + } + + const auto contribution_count = ReadCompactSizeNoThrow(ds); + if (!contribution_count.has_value() || contribution_count.value() > size) { + return false; + } + for (uint64_t i = 0; i < contribution_count.value(); ++i) { + if (!SkipBytes(ds, 4) || !SkipBLSObject(ds)) { + return false; + } + } + + return SkipBLSObject(ds) && ds.empty(); +} + +bool CheckPrematureCommitmentWireStructure(CDataStream& ds, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + uint64_t valid_members_size{0}; + return SkipCommonDKGFields(ds) && ReadAndCheckDynBitset(ds, size, valid_members_size) && + SkipBLSObject(ds) && SkipBytes(ds, 32) && SkipBLSObject(ds) && + SkipBLSObject(ds) && ds.empty(); +} + +bool CheckDKGMessageWireStructure(std::string_view msg_type, const CDataStream& payload, const Consensus::LLMQParams& params) +{ + CDataStream ds(payload); + if (msg_type == NetMsgType::QCONTRIB) { + return CheckContributionWireStructure(ds, params); + } else if (msg_type == NetMsgType::QCOMPLAINT) { + return CheckComplaintWireStructure(ds, params); + } else if (msg_type == NetMsgType::QJUSTIFICATION) { + return CheckJustificationWireStructure(ds, params); + } else if (msg_type == NetMsgType::QPCOMMITMENT) { + return CheckPrematureCommitmentWireStructure(ds, params); + } + return false; +} + +// Param-only structural validation of a typed DKG message: checks only safe +// upper bounds derived from quorum params. Called by the DKG worker immediately +// after deserializing queued bytes and before PreVerifyMessage, so malformed +// structures never reach deeper validation. +template +bool CheckDKGMessageStructure(const Message& msg, const Consensus::LLMQParams& params); + +template <> +bool CheckDKGMessageStructure(const CDKGContribution& qc, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + const size_t min_size = params.minSize > 0 ? static_cast(params.minSize) : 0; + const size_t threshold = params.threshold > 0 ? static_cast(params.threshold) : 0; + return qc.vvec != nullptr && qc.vvec->size() == threshold && qc.contributions != nullptr && + qc.contributions->blobs.size() >= min_size && qc.contributions->blobs.size() <= size; +} + +template <> +bool CheckDKGMessageStructure(const CDKGComplaint& qc, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + return qc.badMembers.size() == qc.complainForMembers.size() && qc.badMembers.size() <= size; +} + +template <> +bool CheckDKGMessageStructure(const CDKGJustification& qj, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + return qj.contributions.size() <= size; +} + +template <> +bool CheckDKGMessageStructure(const CDKGPrematureCommitment& qc, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + return qc.validMembers.size() <= size; } // returns a set of NodeIds which sent invalid messages @@ -258,6 +407,10 @@ void RelayInvToParticipants(const CDKGSession& session, const CConnman& connman, template void EnqueueOwn(CDKGPendingMessages& pending, const Message& msg) { + // Own messages skip the wire path but still populate the pending queue so + // the DKG worker sees them alongside peer messages. The inventory hash is + // computed over the serialized form so it matches what remote peers would + // compute for the same message. CDataStream ds(SER_NETWORK, PROTOCOL_VERSION); ds << msg; auto pm = std::make_shared(std::move(ds)); @@ -267,10 +420,31 @@ void EnqueueOwn(CDKGPendingMessages& pending, const Message& msg) } template -bool ProcessPendingMessageBatch(const CConnman& connman, CDKGSession& session, CDKGPendingMessages& pendingMessages, - PeerManagerInternal& peerman, size_t maxCount) +bool DeserializeAndCheckDKGMessage(CDataStream& ds, const Consensus::LLMQParams& params, std::shared_ptr& msg) { - auto msgs = pendingMessages.PopAndDeserializeMessages(maxCount); + msg = std::make_shared(); + try { + ds >> *msg; + } catch (...) { + msg.reset(); + return false; + } + if (!ds.empty()) { + msg.reset(); + return false; + } + if (!CheckDKGMessageStructure(*msg, params)) { + msg.reset(); + return false; + } + return true; +} + +template +bool ProcessPendingMessageBatch(const CConnman& connman, CDKGSession& session, const Consensus::LLMQParams& params, + CDKGPendingMessages& pendingMessages, PeerManagerInternal& peerman, size_t maxCount) +{ + auto msgs = pendingMessages.PopPendingMessages(maxCount); if (msgs.empty()) { return false; } @@ -280,13 +454,14 @@ bool ProcessPendingMessageBatch(const CConnman& connman, CDKGSession& session, C for (const auto& p : msgs) { const NodeId& nodeId = p.first; - if (!p.second) { + std::shared_ptr msg; + if (!DeserializeAndCheckDKGMessage(*p.second, params, msg)) { LogPrint(BCLog::LLMQ_DKG, "%s -- failed to deserialize message, peer=%d\n", __func__, nodeId); peerman.PeerMisbehaving(nodeId, 100); continue; } bool ban = false; - if (!session.PreVerifyMessage(*p.second, ban)) { + if (!session.PreVerifyMessage(*msg, ban)) { if (ban) { LogPrint(BCLog::LLMQ_DKG, "%s -- banning node due to failed preverification, peer=%d\n", __func__, nodeId); peerman.PeerMisbehaving(nodeId, 100); @@ -294,7 +469,7 @@ bool ProcessPendingMessageBatch(const CConnman& connman, CDKGSession& session, C LogPrint(BCLog::LLMQ_DKG, "%s -- skipping message due to failed preverification, peer=%d\n", __func__, nodeId); continue; } - preverifiedMessages.emplace_back(p); + preverifiedMessages.emplace_back(nodeId, std::move(msg)); } if (preverifiedMessages.empty()) { return true; @@ -321,6 +496,7 @@ bool ProcessPendingMessageBatch(const CConnman& connman, CDKGSession& session, C return true; } + } // namespace @@ -404,10 +580,17 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre Consensus::LLMQType llmqType; uint256 quorumHash; - vRecv >> llmqType; - vRecv >> quorumHash; - vRecv.Rewind(sizeof(uint256)); - vRecv.Rewind(sizeof(uint8_t)); + try { + vRecv >> llmqType; + vRecv >> quorumHash; + } catch (const std::exception&) { + m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "malformed DKG message"); + return; + } + if (!vRecv.Rewind(sizeof(uint256)) || !vRecv.Rewind(sizeof(uint8_t))) { + m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "malformed DKG message"); + return; + } const auto& llmq_params_opt = Params().GetLLMQ(llmqType); if (!llmq_params_opt.has_value()) { @@ -458,30 +641,30 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "oversized DKG message"); return; } - - // Cheap structural pre-validation before retention. Validates a copy so the - // original bytes (and their inventory hash) are preserved for the worker. - if (!CheckDKGMessageStructure(msg_type, vRecv, llmq_params)) { + if (!CheckDKGMessageWireStructure(msg_type, vRecv, llmq_params)) { m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "malformed DKG message"); return; } + // Inventory hash is computed over the raw wire bytes before we consume + // them, matching what a peer would compute for the same payload. + CHashWriter hw(SER_GETHASH, 0); + hw.write(AsWritableBytes(Span{vRecv})); + const uint256 hash = hw.GetHash(); + int inv_type = 0; - if (msg_type == NetMsgType::QCONTRIB) + if (msg_type == NetMsgType::QCONTRIB) { inv_type = MSG_QUORUM_CONTRIB; - else if (msg_type == NetMsgType::QCOMPLAINT) + } else if (msg_type == NetMsgType::QCOMPLAINT) { inv_type = MSG_QUORUM_COMPLAINT; - else if (msg_type == NetMsgType::QJUSTIFICATION) + } else if (msg_type == NetMsgType::QJUSTIFICATION) { inv_type = MSG_QUORUM_JUSTIFICATION; - else if (msg_type == NetMsgType::QPCOMMITMENT) + } else if (msg_type == NetMsgType::QPCOMMITMENT) { inv_type = MSG_QUORUM_PREMATURE_COMMITMENT; + } Assume(inv_type != 0); // guarded by the early-return above auto pm = std::make_shared(std::move(vRecv)); - CHashWriter hw(SER_GETHASH, 0); - hw.write(AsWritableBytes(Span{*pm})); - const uint256 hash = hw.GetHash(); - const NodeId from = pfrom.GetId(); // DKG messages are only ever sent in reply to a GETDATA (see NetDKG::ProcessGetData), so one we @@ -697,6 +880,9 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) handler.WaitForNextPhase(std::nullopt, QuorumPhase::Initialized); + // Leftovers missed their matching phase and cannot be accepted by this + // round. Discard their raw bytes without materializing BLS objects on the + // critical path to initializing the next quorum. handler.ClearPendingMessages(); uint256 curQuorumHash = handler.GetCurrentQuorumHash(); @@ -742,8 +928,8 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) } }; auto fContributeWait = [this, curSession, &handler, &active] { - return ProcessPendingMessageBatch(active.connman, *curSession, handler.pendingContributions, - *m_peer_manager, 8); + return ProcessPendingMessageBatch(active.connman, *curSession, handler.params, + handler.pendingContributions, *m_peer_manager, 8); }; handler.HandlePhase(QuorumPhase::Contribute, QuorumPhase::Complain, curQuorumHash, 0.05, fContributeStart, fContributeWait); @@ -755,8 +941,8 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) } }; auto fComplainWait = [this, curSession, &handler, &active] { - return ProcessPendingMessageBatch(active.connman, *curSession, handler.pendingComplaints, - *m_peer_manager, 8); + return ProcessPendingMessageBatch(active.connman, *curSession, handler.params, + handler.pendingComplaints, *m_peer_manager, 8); }; handler.HandlePhase(QuorumPhase::Complain, QuorumPhase::Justify, curQuorumHash, 0.05, fComplainStart, fComplainWait); @@ -767,8 +953,8 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) } }; auto fJustifyWait = [this, curSession, &handler, &active] { - return ProcessPendingMessageBatch(active.connman, *curSession, handler.pendingJustifications, - *m_peer_manager, 8); + return ProcessPendingMessageBatch(active.connman, *curSession, handler.params, + handler.pendingJustifications, *m_peer_manager, 8); }; handler.HandlePhase(QuorumPhase::Justify, QuorumPhase::Commit, curQuorumHash, 0.05, fJustifyStart, fJustifyWait); @@ -779,7 +965,7 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) } }; auto fCommitWait = [this, curSession, &handler, &active] { - return ProcessPendingMessageBatch(active.connman, *curSession, + return ProcessPendingMessageBatch(active.connman, *curSession, handler.params, handler.pendingPrematureCommitments, *m_peer_manager, 8); }; diff --git a/src/test/llmq_dkg_tests.cpp b/src/test/llmq_dkg_tests.cpp index 9715a63f2719..bd34ce59672d 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,69 @@ BOOST_AUTO_TEST_CASE(llmq_dkgerror) BOOST_REQUIRE(GetSimulatedErrorRate(llmq::DKGError::type::_COUNT) == 0.0); } +BOOST_AUTO_TEST_CASE(pending_messages_local_first_uses_full_remote_allowance) +{ + using namespace llmq; + + auto make_message = [] { return std::make_shared(SER_NETWORK, PROTOCOL_VERSION); }; + auto make_hash = [](uint8_t value) { + uint256 hash; + hash.begin()[0] = value; + return hash; + }; + + CDKGPendingMessages pending{/*max_messages_per_node=*/2}; + pending.PushPendingMessage(/*from=*/-1, make_message(), make_hash(1)); + pending.PushPendingMessage(/*from=*/1, make_message(), make_hash(2)); + pending.PushPendingMessage(/*from=*/1, make_message(), make_hash(3)); + pending.PushPendingMessage(/*from=*/2, make_message(), make_hash(4)); + pending.PushPendingMessage(/*from=*/2, make_message(), make_hash(5)); + + BOOST_CHECK_EQUAL(pending.PopPendingMessages(6).size(), 5U); +} + +BOOST_AUTO_TEST_CASE(pending_messages_bounded_across_node_ids) +{ + using namespace llmq; + + auto make_message = [] { return std::make_shared(SER_NETWORK, PROTOCOL_VERSION); }; + auto make_hash = [](uint8_t value) { + uint256 hash; + hash.begin()[0] = value; + return hash; + }; + + CDKGPendingMessages pending{/*max_messages_per_node=*/2}; + pending.PushPendingMessage(/*from=*/1, make_message(), make_hash(1)); + pending.PushPendingMessage(/*from=*/1, make_message(), make_hash(2)); + + // One peer's full quota does not consume the queue-wide remote allowance. + pending.PushPendingMessage(/*from=*/2, make_message(), make_hash(3)); + pending.PushPendingMessage(/*from=*/2, make_message(), make_hash(4)); + BOOST_CHECK(pending.HasSeen(make_hash(3))); + BOOST_CHECK(pending.HasSeen(make_hash(4))); + + // Fresh NodeIds cannot bypass the queue-wide remote-message cap. + pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(5)); + pending.PushPendingMessage(/*from=*/4, make_message(), make_hash(6)); + BOOST_CHECK(!pending.HasSeen(make_hash(5))); + BOOST_CHECK(!pending.HasSeen(make_hash(6))); + + // Duplicates are rejected before charging the new NodeId's quota. + pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(1)); + pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(1)); + + BOOST_CHECK_EQUAL(pending.PopPendingMessages(5).size(), 4U); + pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(7)); + pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(8)); + pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(9)); + BOOST_CHECK(pending.HasSeen(make_hash(7))); + BOOST_CHECK(pending.HasSeen(make_hash(8))); + BOOST_CHECK(!pending.HasSeen(make_hash(9))); + + pending.Clear(); + pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(7)); + BOOST_CHECK(pending.HasSeen(make_hash(7))); +} + 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..507321b3c871 100755 --- a/test/functional/feature_llmq_dkg_intake.py +++ b/test/functional/feature_llmq_dkg_intake.py @@ -10,12 +10,17 @@ not MNAuth-verified are rejected before retention. - oversized DKG payloads are rejected (before deserialization / retention) even from a verified peer. - - structural pre-validation: malformed DKG payloads (valid quorum prefix, garbage - body) are rejected before retention even from a verified peer. + - structural pre-validation: truncated, trailing, or parametrically out-of-bounds + DKG payloads are rejected before retention even from a verified peer. + - BLS objects are not materialized at intake: a structurally plausible payload with + an invalid BLS encoding reaches the DKG worker and is rejected there. + - late, framing-valid messages are bounded across reconnect-generated NodeIds and + discarded without BLS materialization before the next round initializes. - 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). +The node must not crash; rejected malformed messages must be scored where the +matching worker still processes them. """ from test_framework.messages import ser_compact_size, ser_uint256 @@ -31,6 +36,8 @@ FAKE_PUBKEY = "8e7afdb849e5e2a085b035b62e21c0940c753f2d4501325743894c37162f287bccaffbedd60c36581dabbf127a22e43f" DKG_PUSH_TYPES = [b"qcontrib", b"qcomplaint", b"qjustify", b"qpcommit"] +VALID_BLS_PUBKEY = bytes.fromhex(FAKE_PUBKEY) +INVALID_NONZERO_BLS_PUBKEY = b"\xff" * 48 class msg_dkg_raw: @@ -48,10 +55,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 @@ -75,8 +84,14 @@ 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 worker and queue-boundary behavior. + 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,14 +100,14 @@ 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, vvec_pubkey=VALID_BLS_PUBKEY, 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_compact_size(2) + b"\x00" * (2 * 48) # BLSVerificationVector - r += b"\x00" * 48 # CBLSIESMultiRecipientBlobs::ephemeralPubKey + r += ser_uint256(protx_hash) + r += ser_compact_size(2) + vvec_pubkey + VALID_BLS_PUBKEY # BLSVerificationVector + r += VALID_BLS_PUBKEY # CBLSIESMultiRecipientBlobs::ephemeralPubKey r += b"\x00" * 32 # CBLSIESMultiRecipientBlobs::ivSeed r += ser_compact_size(blob_count) for _ in range(blob_count): @@ -100,9 +115,9 @@ 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) + def add_verified_peer(self, node, uacomment=None): + peer = node.add_p2p_connection(P2PInterface(), uacomment=uacomment) + peer_id = get_p2p_id(node, uacomment) assert node.mnauth(peer_id, FAKE_PROTX, FAKE_PUBKEY) return peer, peer_id @@ -120,6 +135,9 @@ 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_trailing_bytes_rejected(mn_node) + self.test_malformed_bls_pubkey_rejected_by_worker(mn_node) + self.test_late_messages_bounded_across_reconnects(mn_node) self.test_under_min_contribution_blobs_rejected(mn_node) self.test_unrequested_rejected(mn_node) @@ -163,6 +181,112 @@ def test_malformed_rejected(self, node): wait_for_banscore(node, peer_id, 100) node.disconnect_p2ps() + def test_trailing_bytes_rejected(self, node): + self.log.info("QCONTRIB with trailing bytes is rejected at intake (Misbehaving 100)") + peer, peer_id = self.add_verified_peer(node) + wait_for_banscore(node, peer_id, 0) + with node.assert_debug_log(["malformed DKG message"]): + peer.send_message(msg_dkg_raw( + b"qcontrib", + self.qcontrib_payload(blob_count=2) + b"\x00", + )) + peer.sync_with_ping() + 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).""" + cycle_length = 24 + skip_count = cycle_length - (self.nodes[0].getblockcount() % cycle_length) + self.generate(self.nodes[0], skip_count, sync_fun=lambda: self.sync_blocks(nodes)) + self.quorum_hash = self.nodes[0].getbestblockhash() + self.wait_for_quorum_phase(self.quorum_hash, 1, self.llmq_size, None, 0, self.mninfo) + + def test_malformed_bls_pubkey_rejected_by_worker(self, node): + self.log.info("QCONTRIB BLS decoding is deferred to the DKG worker (Misbehaving 100)") + nodes = [self.nodes[0]] + [mn.get_node(self) for mn in self.mninfo] + # Queue during Initialized; Contribute's matching drain deserializes and scores. + self._start_fresh_dkg_cycle(nodes) + + peer, peer_id = self.add_verified_peer(node) + wait_for_banscore(node, peer_id, 0) + with node.assert_debug_log( + ["failed to deserialize message"], + unexpected_msgs=["malformed DKG message"], + timeout=10, + ): + peer.send_message(msg_dkg_raw(b"qcontrib", self.qcontrib_payload( + blob_count=2, + vvec_pubkey=INVALID_NONZERO_BLS_PUBKEY, + ))) + peer.sync_with_ping() + wait_for_banscore(node, peer_id, 0) + self.move_blocks(nodes, 2) + wait_for_banscore(node, peer_id, 100) + node.disconnect_p2ps() + + def test_late_messages_bounded_across_reconnects(self, node): + self.log.info("Late QCONTRIB retention is bounded across reconnects and cleared without BLS decoding") + nodes = [self.nodes[0]] + [mn.get_node(self) for mn in self.mninfo] + cycle_length = 24 + self._start_fresh_dkg_cycle(nodes) + stage = self.nodes[0].getblockcount() % cycle_length + assert stage == 0, "expected DKG cycle base, got stage %d" % stage + # phaseBlocks=2: stage 0=Initialized, 2=Contribute, 4=Complain. + complain_stage = 4 + self.move_blocks(nodes, complain_stage - stage) + assert self.nodes[0].getblockcount() % cycle_length == complain_stage + + # Each transient connection gets a fresh NodeId. Unique proTxHash bytes + # avoid deduplication, so this specifically exercises the queue-wide cap + # rather than the per-NodeId quota. Keep the final accepted peer connected + # to verify that round-start clearing does not score stale BLS encodings. + queue_limit = 4 * self.llmq_size + retained_peer = None + retained_peer_id = None + for nonce in range(1, queue_limit + 1): + uacomment = "dkg-late-%d" % nonce + peer, peer_id = self.add_verified_peer(node, uacomment) + peer.send_message(msg_dkg_raw(b"qcontrib", self.qcontrib_payload( + blob_count=2, + vvec_pubkey=INVALID_NONZERO_BLS_PUBKEY, + protx_hash=nonce, + ))) + peer.sync_with_ping() + wait_for_banscore(node, peer_id, 0) + if nonce == queue_limit: + retained_peer = peer + retained_peer_id = peer_id + else: + peer.peer_disconnect() + peer.wait_for_disconnect() + + overflow_peer, overflow_peer_id = self.add_verified_peer(node, "dkg-late-overflow") + with node.assert_debug_log(["pending queue full"]): + overflow_peer.send_message(msg_dkg_raw(b"qcontrib", self.qcontrib_payload( + blob_count=2, + vvec_pubkey=INVALID_NONZERO_BLS_PUBKEY, + protx_hash=queue_limit + 1, + ))) + overflow_peer.sync_with_ping() + wait_for_banscore(node, overflow_peer_id, 0) + + # Crossing the phase boundary must clear a bounded raw queue and finish + # initializing the next session without deserializing stale BLS points. + remaining = cycle_length - (self.nodes[0].getblockcount() % cycle_length) + with node.assert_debug_log( + [], + unexpected_msgs=["malformed DKG message", "failed to deserialize message"], + 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) + assert retained_peer is not None + wait_for_banscore(node, retained_peer_id, 0) + wait_for_banscore(node, overflow_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) From 5f5e9569f29732cc55787be33806c90fab6c7240 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Mon, 27 Jul 2026 01:52:25 +0300 Subject: [PATCH 2/9] refactor: expose DKG intake framing checks and drop a bitset copy ReadAndCheckDynBitset materialized a byte vector per dynamic bitset just to inspect the final padding byte. Skip to the last byte instead -- CDataStream already bounds-checks ignore() and throws on overrun -- and return the bit count as an optional rather than a bool plus an out-parameter. Turn the typed CheckDKGMessageStructure explicit specializations into plain overloads and lift them, together with CheckDKGMessageWireStructure, out of the anonymous namespace so a fuzz target can reach them. Overloads fail at compile time rather than link time when a new message type is added. The header comment records that the walk mirrors the (Un)serialize implementations in llmq/dkgmessages.h by hand and must be kept in step with them. The walk still validates a copy of the payload rather than a view of it. The copy is a memcpy bounded by the size cap applied just before the call, which is noise next to the BLS point decompression this code path exists to avoid; eliminating it would need SpanReader::ignore(), which Dash does not have yet (bitcoin#28721 / bitcoin#34483) and which belongs in its own backport rather than here. No behaviour change: the walk accepts and rejects exactly the same payloads. --- src/llmq/dkgmessages.h | 7 ++++ src/llmq/net_dkg.cpp | 88 +++++++++++++++++++++++++----------------- src/llmq/net_dkg.h | 37 ++++++++++++++++++ 3 files changed, 96 insertions(+), 36 deletions(-) diff --git a/src/llmq/dkgmessages.h b/src/llmq/dkgmessages.h index 177b68e5d4d4..fcc480469ca0 100644 --- a/src/llmq/dkgmessages.h +++ b/src/llmq/dkgmessages.h @@ -17,6 +17,13 @@ #include namespace llmq { +/** + * @warning The wire encodings below are walked a second time, by hand, at + * network intake: see CheckDKGMessageWireStructure() in llmq/net_dkg.h. That + * walk validates framing without materializing BLS objects, so any change to + * the (Un)serialize implementations in this file must be mirrored there. + * src/test/fuzz/dkg_message_framing.cpp guards the two against divergence. + */ class CDKGContribution { public: diff --git a/src/llmq/net_dkg.cpp b/src/llmq/net_dkg.cpp index 8e757180e6e3..3d7d0886ac04 100644 --- a/src/llmq/net_dkg.cpp +++ b/src/llmq/net_dkg.cpp @@ -22,12 +22,16 @@ #include #include #include +#include #include +#include #include #include #include #include +#include + namespace llmq { namespace { @@ -106,31 +110,40 @@ bool SkipBLSObject(CDataStream& ds) return SkipBytes(ds, BLSObject::SerSize); } -bool ReadAndCheckDynBitset(CDataStream& ds, uint64_t max_size, uint64_t& size_ret) +// Mirrors DynamicBitSetFormatter::Unser (ReadCompactSize + ReadFixedBitSet), +// including ReadFixedBitSet's rejection of out-of-range padding bits, without +// materializing the std::vector. Returns the bit count on success. +std::optional ReadAndCheckDynBitset(CDataStream& ds, uint64_t max_size) { const auto size = ReadCompactSizeNoThrow(ds); if (!size.has_value() || size.value() > max_size) { - return false; + return std::nullopt; } const size_t byte_size = (size.value() + 7) / 8; - std::vector bytes(byte_size); + if (byte_size == 0) { + return size; + } + if (!SkipBytes(ds, byte_size - 1)) { + return std::nullopt; + } + + uint8_t last{0}; try { - ds.read(AsWritableBytes(Span{bytes})); + ds.read(AsWritableBytes(Span{&last, 1})); } catch (const std::exception&) { - return false; + return std::nullopt; } - if (!bytes.empty() && bytes.size() * 8 != size.value()) { - const size_t rem = bytes.size() * 8 - size.value(); + if (byte_size * 8 != size.value()) { + const size_t rem = byte_size * 8 - size.value(); const uint8_t mask = ~(uint8_t)(0xff >> rem); - if (bytes.back() & mask) { - return false; + if (last & mask) { + return std::nullopt; } } - size_ret = size.value(); - return true; + return size; } bool CheckContributionWireStructure(CDataStream& ds, const Consensus::LLMQParams& params) @@ -174,11 +187,16 @@ bool CheckContributionWireStructure(CDataStream& ds, const Consensus::LLMQParams bool CheckComplaintWireStructure(CDataStream& ds, const Consensus::LLMQParams& params) { const size_t size = params.size > 0 ? static_cast(params.size) : 0; - uint64_t bad_members_size{0}; - uint64_t complain_for_members_size{0}; - return SkipCommonDKGFields(ds) && ReadAndCheckDynBitset(ds, size, bad_members_size) && - ReadAndCheckDynBitset(ds, size, complain_for_members_size) && - bad_members_size == complain_for_members_size && SkipBLSObject(ds) && ds.empty(); + if (!SkipCommonDKGFields(ds)) { + return false; + } + const auto bad_members_size = ReadAndCheckDynBitset(ds, size); + if (!bad_members_size.has_value()) { + return false; + } + const auto complain_for_members_size = ReadAndCheckDynBitset(ds, size); + return complain_for_members_size.has_value() && bad_members_size.value() == complain_for_members_size.value() && + SkipBLSObject(ds) && ds.empty(); } bool CheckJustificationWireStructure(CDataStream& ds, const Consensus::LLMQParams& params) @@ -204,14 +222,20 @@ bool CheckJustificationWireStructure(CDataStream& ds, const Consensus::LLMQParam bool CheckPrematureCommitmentWireStructure(CDataStream& ds, const Consensus::LLMQParams& params) { const size_t size = params.size > 0 ? static_cast(params.size) : 0; - uint64_t valid_members_size{0}; - return SkipCommonDKGFields(ds) && ReadAndCheckDynBitset(ds, size, valid_members_size) && + return SkipCommonDKGFields(ds) && ReadAndCheckDynBitset(ds, size).has_value() && SkipBLSObject(ds) && SkipBytes(ds, 32) && SkipBLSObject(ds) && SkipBLSObject(ds) && ds.empty(); } -bool CheckDKGMessageWireStructure(std::string_view msg_type, const CDataStream& payload, const Consensus::LLMQParams& params) +} // namespace + +bool CheckDKGMessageWireStructure(std::string_view msg_type, const CDataStream& payload, + const Consensus::LLMQParams& params) { + // Copy so the caller's read position (and the bytes backing its inventory + // hash) survive the walk. The copy is a memcpy bounded by the size cap + // applied just before this call -- negligible next to the BLS point + // decompression that deserializing the payload here would have cost. CDataStream ds(payload); if (msg_type == NetMsgType::QCONTRIB) { return CheckContributionWireStructure(ds, params); @@ -225,15 +249,7 @@ bool CheckDKGMessageWireStructure(std::string_view msg_type, const CDataStream& return false; } -// Param-only structural validation of a typed DKG message: checks only safe -// upper bounds derived from quorum params. Called by the DKG worker immediately -// after deserializing queued bytes and before PreVerifyMessage, so malformed -// structures never reach deeper validation. -template -bool CheckDKGMessageStructure(const Message& msg, const Consensus::LLMQParams& params); - -template <> -bool CheckDKGMessageStructure(const CDKGContribution& qc, const Consensus::LLMQParams& params) +bool CheckDKGMessageStructure(const CDKGContribution& qc, const Consensus::LLMQParams& params) { const size_t size = params.size > 0 ? static_cast(params.size) : 0; const size_t min_size = params.minSize > 0 ? static_cast(params.minSize) : 0; @@ -242,27 +258,26 @@ bool CheckDKGMessageStructure(const CDKGContribution& qc, cons qc.contributions->blobs.size() >= min_size && qc.contributions->blobs.size() <= size; } -template <> -bool CheckDKGMessageStructure(const CDKGComplaint& qc, const Consensus::LLMQParams& params) +bool CheckDKGMessageStructure(const CDKGComplaint& qc, const Consensus::LLMQParams& params) { const size_t size = params.size > 0 ? static_cast(params.size) : 0; return qc.badMembers.size() == qc.complainForMembers.size() && qc.badMembers.size() <= size; } -template <> -bool CheckDKGMessageStructure(const CDKGJustification& qj, const Consensus::LLMQParams& params) +bool CheckDKGMessageStructure(const CDKGJustification& qj, const Consensus::LLMQParams& params) { const size_t size = params.size > 0 ? static_cast(params.size) : 0; return qj.contributions.size() <= size; } -template <> -bool CheckDKGMessageStructure(const CDKGPrematureCommitment& qc, const Consensus::LLMQParams& params) +bool CheckDKGMessageStructure(const CDKGPrematureCommitment& qc, const Consensus::LLMQParams& params) { const size_t size = params.size > 0 ? static_cast(params.size) : 0; return qc.validMembers.size() <= size; } +namespace { + // returns a set of NodeIds which sent invalid messages template std::unordered_set BatchVerifyMessageSigs(CDKGSession& session, @@ -646,8 +661,9 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre return; } - // Inventory hash is computed over the raw wire bytes before we consume - // them, matching what a peer would compute for the same payload. + // Inventory hash is computed over the raw wire bytes, matching what a peer + // would compute for the same payload. The framing walk above validated a + // copy, so vRecv's read position is untouched. CHashWriter hw(SER_GETHASH, 0); hw.write(AsWritableBytes(Span{vRecv})); const uint256 hash = hw.GetHash(); diff --git a/src/llmq/net_dkg.h b/src/llmq/net_dkg.h index 2b1d6988878a..a86f324e4d28 100644 --- a/src/llmq/net_dkg.h +++ b/src/llmq/net_dkg.h @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -25,7 +26,11 @@ class CMasternodeMetaMan; class CSporkManager; namespace llmq { class ActiveDKGSessionHandler; +class CDKGComplaint; +class CDKGContribution; class CDKGDebugManager; +class CDKGJustification; +class CDKGPrematureCommitment; class CDKGSessionManager; class CQuorumBlockProcessor; class CQuorumManager; @@ -34,6 +39,38 @@ class QuorumRole; } // namespace llmq namespace llmq { + +/** + * Framing-only validation of a raw DKG payload, run at intake before retention. + * Walks the wire encoding (CompactSize counts, dynamic bitsets, fixed-size BLS + * encodings) and checks truncation, trailing bytes, and the upper bounds derived + * from quorum params, without materializing any BLS object. Validates a copy, so + * @p payload is left untouched for the pending queue and its inventory hash. Typed deserialization + * -- and therefore BLS point decompression, canonical checks, and active-scheme + * handling -- happens exactly once, later, on the DKG worker thread. + * + * @warning This walk mirrors the (Un)serialize implementations in + * llmq/dkgmessages.h by hand. Any change to those must be reflected + * here; src/test/fuzz/dkg_message_framing.cpp asserts that this never + * rejects a payload the worker would accept. + * + * Exposed only so the fuzz target can reach it; production callers go through + * NetDKG::ProcessMessage. + */ +bool CheckDKGMessageWireStructure(std::string_view msg_type, const CDataStream& payload, + const Consensus::LLMQParams& params); + +/** + * Param-only structural validation of a typed DKG message: checks only safe + * upper bounds derived from quorum params. Run by the DKG worker immediately + * after deserializing queued bytes and before PreVerifyMessage, so malformed + * structures never reach deeper validation. + */ +bool CheckDKGMessageStructure(const CDKGContribution& qc, const Consensus::LLMQParams& params); +bool CheckDKGMessageStructure(const CDKGComplaint& qc, const Consensus::LLMQParams& params); +bool CheckDKGMessageStructure(const CDKGJustification& qj, const Consensus::LLMQParams& params); +bool CheckDKGMessageStructure(const CDKGPrematureCommitment& qc, const Consensus::LLMQParams& params); + /** * NetHandler responsible for DKG networking: * - QCONTRIB / QCOMPLAINT / QJUSTIFICATION / QPCOMMITMENT / QWATCH ProcessMessage From 97ef15508895166175117d29a508c7ae00af881b Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Mon, 27 Jul 2026 01:53:04 +0300 Subject: [PATCH 3/9] test: fuzz DKG intake framing against typed deserialization DKG intake validates framing without materializing BLS objects, then the worker deserializes the retained bytes exactly once. Those are two hand-maintained parsers over one wire format, so they can drift apart. The safety-critical direction is that framing must never reject a payload the worker would accept: honest DKG messages would then be dropped at intake and quorum formation would degrade. Assert exactly that, over all four message types, every entry in available_llmqs, and both BLS schemes. The converse is deliberately not asserted -- framing accepts payloads whose BLS points fail to decode so the worker can score the sender. Also build a well-formed message from fuzzer-chosen field values, serialize it, and require framing to accept our own serializer's output. That catches drift from an empty corpus, without needing the fuzzer to synthesize a valid BLS encoding by chance. Co-Authored-By: Claude Opus 5 --- src/Makefile.test.include | 1 + src/test/fuzz/dkg_message_framing.cpp | 157 ++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 src/test/fuzz/dkg_message_framing.cpp diff --git a/src/Makefile.test.include b/src/Makefile.test.include index faaee5aa1913..06f22cb4d05c 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -315,6 +315,7 @@ test_fuzz_fuzz_SOURCES = \ test/fuzz/decode_tx.cpp \ test/fuzz/descriptor_parse.cpp \ test/fuzz/deserialize.cpp \ + test/fuzz/dkg_message_framing.cpp \ test/fuzz/eval_script.cpp \ test/fuzz/fee_rate.cpp \ test/fuzz/fees.cpp \ diff --git a/src/test/fuzz/dkg_message_framing.cpp b/src/test/fuzz/dkg_message_framing.cpp new file mode 100644 index 000000000000..e6b1b6c06f07 --- /dev/null +++ b/src/test/fuzz/dkg_message_framing.cpp @@ -0,0 +1,157 @@ +// Copyright (c) 2025 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +namespace { +const std::array DKG_MSG_TYPES = { + NetMsgType::QCONTRIB, + NetMsgType::QCOMPLAINT, + NetMsgType::QJUSTIFICATION, + NetMsgType::QPCOMMITMENT, +}; + +//! Typed deserialization exactly as the DKG worker performs it: one pass, no +//! trailing bytes tolerated, followed by the param-bound structural check. +template +bool TypedDeserializeSucceeds(Span payload, const Consensus::LLMQParams& params) +{ + CDataStream ds{payload, SER_NETWORK, PROTOCOL_VERSION}; + Message msg; + try { + ds >> msg; + } catch (const std::exception&) { + return false; + } + return ds.empty() && llmq::CheckDKGMessageStructure(msg, params); +} + +bool TypedDeserializeSucceeds(std::string_view msg_type, Span payload, + const Consensus::LLMQParams& params) +{ + if (msg_type == NetMsgType::QCONTRIB) { + return TypedDeserializeSucceeds(payload, params); + } else if (msg_type == NetMsgType::QCOMPLAINT) { + return TypedDeserializeSucceeds(payload, params); + } else if (msg_type == NetMsgType::QJUSTIFICATION) { + return TypedDeserializeSucceeds(payload, params); + } else if (msg_type == NetMsgType::QPCOMMITMENT) { + return TypedDeserializeSucceeds(payload, params); + } + return false; +} + +//! Build a message that is well formed for @p params, serialize it, and require +//! the framing walk to accept the bytes our own serializer just produced. This +//! covers the equivalence from the other end: it does not depend on the fuzzer +//! synthesizing a valid BLS encoding by chance, so a framing/serializer drift is +//! caught even from an empty corpus. +template +void CheckSerializedMessageIsAccepted(std::string_view msg_type, const Message& msg, + const Consensus::LLMQParams& params) +{ + CDataStream ds{SER_NETWORK, PROTOCOL_VERSION}; + ds << msg; + assert(llmq::CheckDKGMessageStructure(msg, params)); + assert(llmq::CheckDKGMessageWireStructure(msg_type, ds, params)); +} + +void CheckWellFormedMessageIsAccepted(std::string_view msg_type, const Consensus::LLMQParams& params, + FuzzedDataProvider& provider) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + const size_t min_size = params.minSize > 0 ? static_cast(params.minSize) : 0; + const size_t threshold = params.threshold > 0 ? static_cast(params.threshold) : 0; + if (size == 0 || min_size > size) { + return; + } + + if (msg_type == NetMsgType::QCONTRIB) { + llmq::CDKGContribution qc; + qc.vvec = std::make_shared>(threshold); + qc.contributions = std::make_shared>(); + const size_t blobs = provider.ConsumeIntegralInRange(min_size, size); + qc.contributions->blobs.resize(blobs); + for (auto& blob : qc.contributions->blobs) { + blob.resize(provider.ConsumeIntegralInRange(0, 64)); + } + CheckSerializedMessageIsAccepted(msg_type, qc, params); + } else if (msg_type == NetMsgType::QCOMPLAINT) { + llmq::CDKGComplaint qc; + const size_t members = provider.ConsumeIntegralInRange(0, size); + qc.badMembers.assign(members, false); + qc.complainForMembers.assign(members, false); + for (size_t i = 0; i < members; ++i) { + qc.badMembers[i] = provider.ConsumeBool(); + qc.complainForMembers[i] = provider.ConsumeBool(); + } + CheckSerializedMessageIsAccepted(msg_type, qc, params); + } else if (msg_type == NetMsgType::QJUSTIFICATION) { + llmq::CDKGJustification qj; + qj.contributions.resize(provider.ConsumeIntegralInRange(0, size)); + CheckSerializedMessageIsAccepted(msg_type, qj, params); + } else if (msg_type == NetMsgType::QPCOMMITMENT) { + llmq::CDKGPrematureCommitment qc; + const size_t members = provider.ConsumeIntegralInRange(0, size); + qc.validMembers.assign(members, false); + for (size_t i = 0; i < members; ++i) { + qc.validMembers[i] = provider.ConsumeBool(); + } + CheckSerializedMessageIsAccepted(msg_type, qc, params); + } +} + +void initialize_dkg_message_framing() +{ + BLSInit(); +} +} // namespace + +/** + * DKG network intake validates framing without materializing BLS objects, then + * the DKG worker deserializes the retained bytes exactly once. Those are two + * hand-maintained parsers over one wire format, so they can drift apart. + * + * The safety-critical direction is that the framing walk must never reject a + * payload the worker would have accepted -- otherwise honest DKG messages are + * silently dropped at intake and quorum formation degrades. The converse is not + * asserted: framing deliberately accepts payloads whose BLS points fail to + * decode, so that the worker can score the sender. + */ +FUZZ_TARGET(dkg_message_framing, .init = initialize_dkg_message_framing) +{ + FuzzedDataProvider fuzzed_data_provider{buffer.data(), buffer.size()}; + + const std::string_view msg_type = DKG_MSG_TYPES.at( + fuzzed_data_provider.ConsumeIntegralInRange(0, DKG_MSG_TYPES.size() - 1)); + const Consensus::LLMQParams& params = Consensus::available_llmqs.at( + fuzzed_data_provider.ConsumeIntegralInRange(0, Consensus::available_llmqs.size() - 1)); + // Framing is scheme-independent (BLS wire sizes are fixed), but typed + // deserialization is not; cover both so the equivalence is checked on each. + bls::bls_legacy_scheme.store(fuzzed_data_provider.ConsumeBool()); + + CheckWellFormedMessageIsAccepted(msg_type, params, fuzzed_data_provider); + + const std::vector payload = fuzzed_data_provider.ConsumeRemainingBytes(); + + const CDataStream payload_stream{payload, SER_NETWORK, PROTOCOL_VERSION}; + const bool framing_ok = llmq::CheckDKGMessageWireStructure(msg_type, payload_stream, params); + const bool typed_ok = TypedDeserializeSucceeds(msg_type, payload, params); + + assert(!typed_ok || framing_ok); +} From 24507ab366b6c1fd7f4d3c4268d9db02cc9988cd Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Mon, 27 Jul 2026 01:53:04 +0300 Subject: [PATCH 4/9] fix: log DKG structure-check failures distinctly from decode failures The worker logged "failed to deserialize message" for two different outcomes: a payload whose BLS points or canonical encoding failed to decode, and one that decoded fine but violated a param-derived bound. Both score the sender 100, but they say different things about the peer, and the framing walk at intake has already ruled out plain malformed framing by the time either fires. Return an explicit result from DeserializeAndCheckDKGMessage and log the two cases separately. The out-parameter stays because the result now carries information the pointer cannot. Co-Authored-By: Claude Opus 5 --- src/llmq/net_dkg.cpp | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/llmq/net_dkg.cpp b/src/llmq/net_dkg.cpp index 3d7d0886ac04..d6cdf549b01a 100644 --- a/src/llmq/net_dkg.cpp +++ b/src/llmq/net_dkg.cpp @@ -434,25 +434,36 @@ void EnqueueOwn(CDKGPendingMessages& pending, const Message& msg) pending.PushPendingMessage(/*from=*/-1, std::move(pm), hw.GetHash()); } +// Outcome of the single typed deserialization pass on the DKG worker. Split from +// a plain bool so the caller can log why a queued payload was dropped: framing +// passed at intake, so a failure here is either a BLS/canonical/scheme decode +// error or a param-bound structural violation. +enum class DKGMessageDecodeResult { + Ok, + DeserializeFailed, + StructureInvalid, +}; + template -bool DeserializeAndCheckDKGMessage(CDataStream& ds, const Consensus::LLMQParams& params, std::shared_ptr& msg) +DKGMessageDecodeResult DeserializeAndCheckDKGMessage(CDataStream& ds, const Consensus::LLMQParams& params, + std::shared_ptr& msg) { msg = std::make_shared(); try { ds >> *msg; } catch (...) { msg.reset(); - return false; + return DKGMessageDecodeResult::DeserializeFailed; } if (!ds.empty()) { msg.reset(); - return false; + return DKGMessageDecodeResult::DeserializeFailed; } if (!CheckDKGMessageStructure(*msg, params)) { msg.reset(); - return false; + return DKGMessageDecodeResult::StructureInvalid; } - return true; + return DKGMessageDecodeResult::Ok; } template @@ -470,8 +481,13 @@ bool ProcessPendingMessageBatch(const CConnman& connman, CDKGSession& session, c for (const auto& p : msgs) { const NodeId& nodeId = p.first; std::shared_ptr msg; - if (!DeserializeAndCheckDKGMessage(*p.second, params, msg)) { - LogPrint(BCLog::LLMQ_DKG, "%s -- failed to deserialize message, peer=%d\n", __func__, nodeId); + const auto decode_result = DeserializeAndCheckDKGMessage(*p.second, params, msg); + if (decode_result != DKGMessageDecodeResult::Ok) { + if (decode_result == DKGMessageDecodeResult::StructureInvalid) { + LogPrint(BCLog::LLMQ_DKG, "%s -- message failed structure check, peer=%d\n", __func__, nodeId); + } else { + LogPrint(BCLog::LLMQ_DKG, "%s -- failed to deserialize message, peer=%d\n", __func__, nodeId); + } peerman.PeerMisbehaving(nodeId, 100); continue; } From 29d5c6f7c1d619fd47eee842eea77357389d42ea Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Mon, 27 Jul 2026 01:53:04 +0300 Subject: [PATCH 5/9] test: keep mocktime in step when skipping to a fresh DKG cycle _start_fresh_dkg_cycle generated up to a full cycle of blocks with plain generate(), leaving mocktime behind while the DKG phase clock advanced. Every other block move in this test uses move_blocks, which bumps mocktime first; use it here too so phase timing cannot drift. Hoist the hardcoded cycle length into a named module constant while touching both of its users. Co-Authored-By: Claude Opus 5 --- test/functional/feature_llmq_dkg_intake.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/test/functional/feature_llmq_dkg_intake.py b/test/functional/feature_llmq_dkg_intake.py index 507321b3c871..59501dfb0b88 100755 --- a/test/functional/feature_llmq_dkg_intake.py +++ b/test/functional/feature_llmq_dkg_intake.py @@ -39,6 +39,9 @@ VALID_BLS_PUBKEY = bytes.fromhex(FAKE_PUBKEY) INVALID_NONZERO_BLS_PUBKEY = b"\xff" * 48 +# LLMQ_TEST dkgInterval; phaseBlocks=2, so stage 0=Initialized, 2=Contribute, 4=Complain. +CYCLE_LENGTH = 24 + class msg_dkg_raw: """A DKG push message carrying an arbitrary raw payload (for adversarial intake tests).""" @@ -196,9 +199,9 @@ def test_trailing_bytes_rejected(self, node): def _start_fresh_dkg_cycle(self, nodes): """Land on the base block of a fresh DKG cycle (phase 1 / Initialized).""" - cycle_length = 24 - skip_count = cycle_length - (self.nodes[0].getblockcount() % cycle_length) - self.generate(self.nodes[0], skip_count, sync_fun=lambda: self.sync_blocks(nodes)) + 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) @@ -228,14 +231,12 @@ def test_malformed_bls_pubkey_rejected_by_worker(self, node): def test_late_messages_bounded_across_reconnects(self, node): self.log.info("Late QCONTRIB retention is bounded across reconnects and cleared without BLS decoding") nodes = [self.nodes[0]] + [mn.get_node(self) for mn in self.mninfo] - cycle_length = 24 self._start_fresh_dkg_cycle(nodes) - stage = self.nodes[0].getblockcount() % cycle_length + stage = self.nodes[0].getblockcount() % CYCLE_LENGTH assert stage == 0, "expected DKG cycle base, got stage %d" % stage - # phaseBlocks=2: stage 0=Initialized, 2=Contribute, 4=Complain. complain_stage = 4 self.move_blocks(nodes, complain_stage - stage) - assert self.nodes[0].getblockcount() % cycle_length == complain_stage + assert self.nodes[0].getblockcount() % CYCLE_LENGTH == complain_stage # Each transient connection gets a fresh NodeId. Unique proTxHash bytes # avoid deduplication, so this specifically exercises the queue-wide cap @@ -273,7 +274,7 @@ def test_late_messages_bounded_across_reconnects(self, node): # Crossing the phase boundary must clear a bounded raw queue and finish # initializing the next session without deserializing stale BLS points. - remaining = cycle_length - (self.nodes[0].getblockcount() % cycle_length) + remaining = CYCLE_LENGTH - (self.nodes[0].getblockcount() % CYCLE_LENGTH) with node.assert_debug_log( [], unexpected_msgs=["malformed DKG message", "failed to deserialize message"], From e906f3fbcf36bc42b29c61841c611fe34e3a1a88 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Mon, 27 Jul 2026 01:53:33 +0300 Subject: [PATCH 6/9] fix: key DKG pending-message quota by verified proTxHash CDKGPendingMessages charged its per-sender retention quota against the NodeId. A peer that disconnects and reconnects gets a fresh NodeId, so it started each connection with a full budget and could hold far more than size*2 messages per round by cycling connections. Key the quota by the sender's MNAuth-verified proTxHash instead. Every pusher on this path is already MNAuth-gated -- CMNAuth::ProcessMessage requires the proTxHash to resolve in the deterministic MN list and to carry a valid operator-key signature, and allows one MNAUTH per connection -- so the identity survives reconnects and is not attacker-inflatable. Own messages pass a null hash and stay exempt from the quota while remaining subject to the queue-wide caps. The map is deliberately not pruned when the worker drains the queue: the quota is cumulative for the round, so that draining does not refund retention slots. Its size is therefore bounded by the registered masternode set rather than by the queue caps, which the declaration now records. Co-Authored-By: Claude Opus 5 --- src/llmq/dkgsessionhandler.cpp | 26 +++-- src/llmq/dkgsessionhandler.h | 36 ++++-- src/llmq/net_dkg.cpp | 6 +- src/test/llmq_dkg_tests.cpp | 128 +++++++++++++-------- test/functional/feature_llmq_dkg_intake.py | 96 ++++++++++------ 5 files changed, 183 insertions(+), 109 deletions(-) diff --git a/src/llmq/dkgsessionhandler.cpp b/src/llmq/dkgsessionhandler.cpp index b656f532e80e..45f41bab08a0 100644 --- a/src/llmq/dkgsessionhandler.cpp +++ b/src/llmq/dkgsessionhandler.cpp @@ -12,7 +12,7 @@ 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 sender 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,32 +25,36 @@ 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); - // Check duplicates before charging the per-node quota so a peer that + // Check duplicates before charging the per-sender quota so a peer that // resends the same hash cannot exhaust its budget with dupes. if (seenMessages.count(hash) != 0) { LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- already seen %s, peer=%d\n", __func__, hash.ToString(), from); return; } - const auto node_it = messagesPerNode.find(from); - if (node_it != messagesPerNode.end() && node_it->second >= maxMessagesPerNode) { - // TODO ban? - LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- too many messages, peer=%d\n", __func__, from); - return; + const bool is_remote = from != -1; + if (is_remote) { + const auto sender_it = messagesPerSender.find(sender_protx); + if (sender_it != messagesPerSender.end() && sender_it->second >= maxMessagesPerSender) { + // TODO ban? + LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- too many messages from %s, peer=%d\n", __func__, + sender_protx.ToString(), from); + return; + } } - const bool is_remote = from != -1; if ((is_remote && pendingRemoteMessageCount >= maxPendingRemoteMessages) || pendingMessages.size() >= maxPendingMessages) { LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- pending queue full, peer=%d\n", __func__, from); return; } - messagesPerNode[from]++; if (is_remote) { + messagesPerSender[sender_protx]++; pendingRemoteMessageCount++; } @@ -79,7 +83,7 @@ void CDKGPendingMessages::Clear() LOCK(cs_messages); pendingMessages.clear(); pendingRemoteMessageCount = 0; - messagesPerNode.clear(); + messagesPerSender.clear(); seenMessages.clear(); } diff --git a/src/llmq/dkgsessionhandler.h b/src/llmq/dkgsessionhandler.h index c631f103126d..bb9c937ebca2 100644 --- a/src/llmq/dkgsessionhandler.h +++ b/src/llmq/dkgsessionhandler.h @@ -7,6 +7,7 @@ #include // for NodeId #include +#include #include #include @@ -15,7 +16,6 @@ class CDataStream; class CBlockIndex; -class uint256; namespace Consensus { struct LLMQParams; @@ -53,21 +53,32 @@ class CDKGPendingMessages using BinaryMessage = std::pair>; private: - const size_t maxMessagesPerNode; + const size_t maxMessagesPerSender; const size_t maxPendingRemoteMessages; const size_t maxPendingMessages; mutable Mutex cs_messages; std::list pendingMessages GUARDED_BY(cs_messages); size_t pendingRemoteMessageCount GUARDED_BY(cs_messages){0}; - std::map messagesPerNode GUARDED_BY(cs_messages); + // Keyed by the sender's MNAuth-verified proTxHash, not by NodeId: a peer that + // reconnects gets a fresh NodeId but keeps the same proTxHash, so its quota + // survives the reconnect instead of being reset. + // + // Entries deliberately live for the whole round rather than being released on + // pop: the quota is cumulative, so that a sender cannot regain retention slots + // simply by waiting for the worker to drain the queue. Size is therefore not + // bounded by the queue caps but by the number of distinct senders that get a + // message accepted in one round, which MNAuth pins to the registered + // masternode set (see CMNAuth::ProcessMessage: the proTxHash must resolve in + // the deterministic MN list and carry a valid operator-key signature). + std::map messagesPerSender GUARDED_BY(cs_messages); Uint256HashSet seenMessages GUARDED_BY(cs_messages); public: - explicit CDKGPendingMessages(size_t _maxMessagesPerNode) : - maxMessagesPerNode(_maxMessagesPerNode), - // Let two peers use their full quota while keeping reconnect-generated - // NodeIds from growing the queue without bound. - maxPendingRemoteMessages(_maxMessagesPerNode * 2), + explicit CDKGPendingMessages(size_t _maxMessagesPerSender) : + maxMessagesPerSender(_maxMessagesPerSender), + // Belt-and-braces bound on live queue occupancy. The per-sender quota is + // the primary limit; this only caps the total across distinct senders. + maxPendingRemoteMessages(_maxMessagesPerSender * 2), // Reserve one slot for the message produced by this node during the // matching phase. maxPendingMessages(maxPendingRemoteMessages + 1) @@ -76,12 +87,15 @@ class CDKGPendingMessages /** * Enqueue a serialized DKG message under @p from with content hash @p hash. + * @p sender_protx is the sender's MNAuth-verified proTxHash and keys the + * per-sender quota; pass a null hash for messages this node produced itself + * (@p from == -1), which are exempt from that quota. * Caller is responsible for hashing the payload and (for real peers) * routing the erase-request to PeerManager. Drops the message silently on - * per-node or queue-wide capacity overflow, or duplicate hash. + * per-sender or queue-wide capacity overflow, or duplicate hash. */ - 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 d6cdf549b01a..e63b500c4ee3 100644 --- a/src/llmq/net_dkg.cpp +++ b/src/llmq/net_dkg.cpp @@ -431,7 +431,7 @@ void EnqueueOwn(CDKGPendingMessages& pending, const Message& 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, /*sender_protx=*/uint256(), std::move(pm), hw.GetHash()); } // Outcome of the single typed deserialization pass on the DKG worker. Split from @@ -698,6 +698,8 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre auto pm = std::make_shared(std::move(vRecv)); const NodeId from = pfrom.GetId(); + // Non-null: DKG pushes from non-MNAuth-verified peers were rejected above. + const uint256 sender_protx = pfrom.GetVerifiedProRegTxHash(); // DKG messages are only ever sent in reply to a GETDATA (see NetDKG::ProcessGetData), so one we // never asked this peer for was pushed at us and must not reach the pending queues, where it @@ -733,7 +735,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); diff --git a/src/test/llmq_dkg_tests.cpp b/src/test/llmq_dkg_tests.cpp index bd34ce59672d..12220cd65705 100644 --- a/src/test/llmq_dkg_tests.cpp +++ b/src/test/llmq_dkg_tests.cpp @@ -26,69 +26,101 @@ 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_local_first_uses_full_remote_allowance) { using namespace llmq; - auto make_message = [] { return std::make_shared(SER_NETWORK, PROTOCOL_VERSION); }; - auto make_hash = [](uint8_t value) { - uint256 hash; - hash.begin()[0] = value; - return hash; - }; + const uint256 protx_a = MakeTestHash(0xa1); + const uint256 protx_b = MakeTestHash(0xb1); - CDKGPendingMessages pending{/*max_messages_per_node=*/2}; - pending.PushPendingMessage(/*from=*/-1, make_message(), make_hash(1)); - pending.PushPendingMessage(/*from=*/1, make_message(), make_hash(2)); - pending.PushPendingMessage(/*from=*/1, make_message(), make_hash(3)); - pending.PushPendingMessage(/*from=*/2, make_message(), make_hash(4)); - pending.PushPendingMessage(/*from=*/2, make_message(), make_hash(5)); + // maxMessagesPerSender=2 -> remote cap 4, plus one reserved local slot. + CDKGPendingMessages pending{/*max_messages_per_sender=*/2}; + pending.PushPendingMessage(/*from=*/-1, /*sender_protx=*/uint256(), MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(2)); + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(3)); + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(4)); + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(5)); BOOST_CHECK_EQUAL(pending.PopPendingMessages(6).size(), 5U); } -BOOST_AUTO_TEST_CASE(pending_messages_bounded_across_node_ids) +BOOST_AUTO_TEST_CASE(pending_messages_quota_survives_reconnect) { using namespace llmq; - auto make_message = [] { return std::make_shared(SER_NETWORK, PROTOCOL_VERSION); }; - auto make_hash = [](uint8_t value) { - uint256 hash; - hash.begin()[0] = value; - return hash; - }; - - CDKGPendingMessages pending{/*max_messages_per_node=*/2}; - pending.PushPendingMessage(/*from=*/1, make_message(), make_hash(1)); - pending.PushPendingMessage(/*from=*/1, make_message(), make_hash(2)); - - // One peer's full quota does not consume the queue-wide remote allowance. - pending.PushPendingMessage(/*from=*/2, make_message(), make_hash(3)); - pending.PushPendingMessage(/*from=*/2, make_message(), make_hash(4)); - BOOST_CHECK(pending.HasSeen(make_hash(3))); - BOOST_CHECK(pending.HasSeen(make_hash(4))); - - // Fresh NodeIds cannot bypass the queue-wide remote-message cap. - pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(5)); - pending.PushPendingMessage(/*from=*/4, make_message(), make_hash(6)); - BOOST_CHECK(!pending.HasSeen(make_hash(5))); - BOOST_CHECK(!pending.HasSeen(make_hash(6))); - - // Duplicates are rejected before charging the new NodeId's quota. - pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(1)); - pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(1)); + const uint256 protx_a = MakeTestHash(0xa1); - BOOST_CHECK_EQUAL(pending.PopPendingMessages(5).size(), 4U); - pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(7)); - pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(8)); - pending.PushPendingMessage(/*from=*/3, make_message(), make_hash(9)); - BOOST_CHECK(pending.HasSeen(make_hash(7))); - BOOST_CHECK(pending.HasSeen(make_hash(8))); - BOOST_CHECK(!pending.HasSeen(make_hash(9))); + CDKGPendingMessages pending{/*max_messages_per_sender=*/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 and the queue-wide cap is never reached. + 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-sender 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=*/3, make_message(), make_hash(7)); - BOOST_CHECK(pending.HasSeen(make_hash(7))); + pending.PushPendingMessage(/*from=*/4, protx_a, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(5))); +} + +BOOST_AUTO_TEST_CASE(pending_messages_bounded_across_senders) +{ + using namespace llmq; + + const uint256 protx_a = MakeTestHash(0xa1); + const uint256 protx_b = MakeTestHash(0xb1); + const uint256 protx_c = MakeTestHash(0xc1); + + CDKGPendingMessages pending{/*max_messages_per_sender=*/2}; + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(2)); + + // One sender's full quota does not consume the queue-wide remote allowance. + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(3)); + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(4)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(3))); + BOOST_CHECK(pending.HasSeen(MakeTestHash(4))); + + // Distinct senders cannot push past the queue-wide remote-message cap. + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(5))); + + // Duplicates are rejected before charging the sender's quota. + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(1)); + + BOOST_CHECK_EQUAL(pending.PopPendingMessages(5).size(), 4U); + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(6)); + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(7)); + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(8)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(6))); + BOOST_CHECK(pending.HasSeen(MakeTestHash(7))); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(8))); } BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/feature_llmq_dkg_intake.py b/test/functional/feature_llmq_dkg_intake.py index 59501dfb0b88..981905d4d133 100755 --- a/test/functional/feature_llmq_dkg_intake.py +++ b/test/functional/feature_llmq_dkg_intake.py @@ -14,7 +14,9 @@ DKG payloads are rejected before retention even from a verified peer. - BLS objects are not materialized at intake: a structurally plausible payload with an invalid BLS encoding reaches the DKG worker and is rejected there. - - late, framing-valid messages are bounded across reconnect-generated NodeIds and + - the per-sender retention quota is keyed by the MNAuth-verified proTxHash, so + reconnecting under a fresh NodeId does not refill it. + - late, framing-valid messages stay bounded across distinct senders and are discarded without BLS materialization before the next round initializes. - a well-formed DKG message that the peer never announced and was never asked for is dropped before retention, even from a verified peer. @@ -42,6 +44,12 @@ # 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 maxMessagesPerSender = params.size * 2, and the queue-wide remote cap is +# twice that again. Both are per message type. +MAX_MESSAGES_PER_SENDER_FACTOR = 2 +MAX_PENDING_REMOTE_FACTOR = 4 + class msg_dkg_raw: """A DKG push message carrying an arbitrary raw payload (for adversarial intake tests).""" @@ -118,10 +126,10 @@ def qcontrib_payload(self, blob_count, vvec_pubkey=VALID_BLS_PUBKEY, protx_hash= r += b"\x00" * 96 # sig return r - def add_verified_peer(self, node, uacomment=None): + 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, FAKE_PROTX, FAKE_PUBKEY) + assert node.mnauth(peer_id, protx, FAKE_PUBKEY) return peer, peer_id def run_test(self): @@ -140,7 +148,7 @@ def run_test(self): self.test_malformed_rejected(mn_node) self.test_trailing_bytes_rejected(mn_node) self.test_malformed_bls_pubkey_rejected_by_worker(mn_node) - self.test_late_messages_bounded_across_reconnects(mn_node) + self.test_late_messages_bounded(mn_node) self.test_under_min_contribution_blobs_rejected(mn_node) self.test_unrequested_rejected(mn_node) @@ -228,48 +236,62 @@ def test_malformed_bls_pubkey_rejected_by_worker(self, node): wait_for_banscore(node, peer_id, 100) node.disconnect_p2ps() - def test_late_messages_bounded_across_reconnects(self, node): - self.log.info("Late QCONTRIB retention is bounded across reconnects and cleared without BLS decoding") + def _send_late_qcontrib(self, node, peer, nonce): + """Send a framing-valid, BLS-invalid QCONTRIB that no on-time worker will drain.""" + peer.send_message(msg_dkg_raw(b"qcontrib", self.qcontrib_payload( + blob_count=2, + vvec_pubkey=INVALID_NONZERO_BLS_PUBKEY, + # Unique bytes per message so retention is bounded by the quotas rather + # than by duplicate-hash suppression. + protx_hash=nonce, + ))) + peer.sync_with_ping() + + def test_late_messages_bounded(self, node): + self.log.info("Late QCONTRIB retention is bounded per sender and queue-wide, then cleared without BLS decoding") 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 - # Each transient connection gets a fresh NodeId. Unique proTxHash bytes - # avoid deduplication, so this specifically exercises the queue-wide cap - # rather than the per-NodeId quota. Keep the final accepted peer connected - # to verify that round-start clearing does not score stale BLS encodings. - queue_limit = 4 * self.llmq_size - retained_peer = None + # Part 1: the per-sender quota is keyed by the verified proTxHash, so a peer + # that reconnects under a fresh NodeId keeps spending the same budget. + sender_quota = MAX_MESSAGES_PER_SENDER_FACTOR * self.llmq_size + nonce = 0 + for i in range(sender_quota): + nonce += 1 + peer, peer_id = self.add_verified_peer(node, "dkg-reconnect-%d" % i) + self._send_late_qcontrib(node, 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(node, quota_peer, nonce) + wait_for_banscore(node, quota_peer_id, 0) + + # Part 2: distinct senders each get their own quota, but the queue-wide cap + # still bounds total retention. Keep the last accepted peer connected to + # verify that round-start clearing does not score stale BLS encodings. + queue_limit = MAX_PENDING_REMOTE_FACTOR * self.llmq_size retained_peer_id = None - for nonce in range(1, queue_limit + 1): - uacomment = "dkg-late-%d" % nonce - peer, peer_id = self.add_verified_peer(node, uacomment) - peer.send_message(msg_dkg_raw(b"qcontrib", self.qcontrib_payload( - blob_count=2, - vvec_pubkey=INVALID_NONZERO_BLS_PUBKEY, - protx_hash=nonce, - ))) - peer.sync_with_ping() + for i in range(queue_limit - sender_quota): + nonce += 1 + peer, peer_id = self.add_verified_peer(node, "dkg-sender-%d" % i, protx="%064x" % (0xd0 + i)) + self._send_late_qcontrib(node, peer, nonce) wait_for_banscore(node, peer_id, 0) - if nonce == queue_limit: - retained_peer = peer - retained_peer_id = peer_id - else: - peer.peer_disconnect() - peer.wait_for_disconnect() - - overflow_peer, overflow_peer_id = self.add_verified_peer(node, "dkg-late-overflow") + retained_peer_id = peer_id + + nonce += 1 + overflow_peer, overflow_peer_id = self.add_verified_peer(node, "dkg-sender-over", protx="%064x" % 0xffff) with node.assert_debug_log(["pending queue full"]): - overflow_peer.send_message(msg_dkg_raw(b"qcontrib", self.qcontrib_payload( - blob_count=2, - vvec_pubkey=INVALID_NONZERO_BLS_PUBKEY, - protx_hash=queue_limit + 1, - ))) - overflow_peer.sync_with_ping() + self._send_late_qcontrib(node, overflow_peer, nonce) wait_for_banscore(node, overflow_peer_id, 0) # Crossing the phase boundary must clear a bounded raw queue and finish @@ -277,13 +299,13 @@ def test_late_messages_bounded_across_reconnects(self, node): remaining = CYCLE_LENGTH - (self.nodes[0].getblockcount() % CYCLE_LENGTH) with node.assert_debug_log( [], - unexpected_msgs=["malformed DKG message", "failed to deserialize message"], + unexpected_msgs=["message failed structure check", "failed to deserialize message"], 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) - assert retained_peer is not None + assert retained_peer_id is not None wait_for_banscore(node, retained_peer_id, 0) wait_for_banscore(node, overflow_peer_id, 0) node.disconnect_p2ps() From 2745e239660ae28ffcf2dc50e22850a5e5137351 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 27 Jul 2026 14:25:45 -0500 Subject: [PATCH 7/9] refactor: tighten DKG review follow-up Capture the MNAuth-verified proTxHash once at intake and reuse it for the per-sender quota, use the codebase's salted uint256 hash map for sender accounting, and retain the existing malformed-message assertion alongside the new worker failure classes. Also describe the exposed framing helpers as test-facing rather than fuzz-only. Co-Authored-By: Claude Opus 5 --- src/llmq/dkgsessionhandler.h | 4 ++-- src/llmq/net_dkg.cpp | 7 ++++--- src/llmq/net_dkg.h | 2 +- test/functional/feature_llmq_dkg_intake.py | 6 +++++- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/llmq/dkgsessionhandler.h b/src/llmq/dkgsessionhandler.h index bb9c937ebca2..1578f70f79e7 100644 --- a/src/llmq/dkgsessionhandler.h +++ b/src/llmq/dkgsessionhandler.h @@ -6,11 +6,11 @@ #define BITCOIN_LLMQ_DKGSESSIONHANDLER_H #include // for NodeId +#include #include #include #include -#include #include #include @@ -70,7 +70,7 @@ class CDKGPendingMessages // message accepted in one round, which MNAuth pins to the registered // masternode set (see CMNAuth::ProcessMessage: the proTxHash must resolve in // the deterministic MN list and carry a valid operator-key signature). - std::map messagesPerSender GUARDED_BY(cs_messages); + Uint256HashMap messagesPerSender GUARDED_BY(cs_messages); Uint256HashSet seenMessages GUARDED_BY(cs_messages); public: diff --git a/src/llmq/net_dkg.cpp b/src/llmq/net_dkg.cpp index e63b500c4ee3..48e3684fd54c 100644 --- a/src/llmq/net_dkg.cpp +++ b/src/llmq/net_dkg.cpp @@ -599,7 +599,10 @@ 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 and reuse: this identity also keys the per-sender retention quota + // below, which must not be able to observe a different value than the gate did. + const uint256 sender_protx = pfrom.GetVerifiedProRegTxHash(); + if (sender_protx.IsNull()) { m_peer_manager->PeerMisbehaving(pfrom.GetId(), 10, "DKG message from non-verified peer"); return; } @@ -698,8 +701,6 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre auto pm = std::make_shared(std::move(vRecv)); const NodeId from = pfrom.GetId(); - // Non-null: DKG pushes from non-MNAuth-verified peers were rejected above. - const uint256 sender_protx = pfrom.GetVerifiedProRegTxHash(); // DKG messages are only ever sent in reply to a GETDATA (see NetDKG::ProcessGetData), so one we // never asked this peer for was pushed at us and must not reach the pending queues, where it diff --git a/src/llmq/net_dkg.h b/src/llmq/net_dkg.h index a86f324e4d28..f56ff26908ef 100644 --- a/src/llmq/net_dkg.h +++ b/src/llmq/net_dkg.h @@ -54,7 +54,7 @@ namespace llmq { * here; src/test/fuzz/dkg_message_framing.cpp asserts that this never * rejects a payload the worker would accept. * - * Exposed only so the fuzz target can reach it; production callers go through + * Exposed only so tests can reach it; production callers go through * NetDKG::ProcessMessage. */ bool CheckDKGMessageWireStructure(std::string_view msg_type, const CDataStream& payload, diff --git a/test/functional/feature_llmq_dkg_intake.py b/test/functional/feature_llmq_dkg_intake.py index 981905d4d133..82564240697c 100755 --- a/test/functional/feature_llmq_dkg_intake.py +++ b/test/functional/feature_llmq_dkg_intake.py @@ -299,7 +299,11 @@ def test_late_messages_bounded(self, node): remaining = CYCLE_LENGTH - (self.nodes[0].getblockcount() % CYCLE_LENGTH) with node.assert_debug_log( [], - unexpected_msgs=["message failed structure check", "failed to deserialize message"], + unexpected_msgs=[ + "malformed DKG message", + "failed to deserialize message", + "message failed structure check", + ], timeout=60, ): self.move_blocks(nodes, remaining) From 2e547ecf85344dd912ac500788ff448600555949 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sun, 2 Aug 2026 20:38:33 -0500 Subject: [PATCH 8/9] test: request DKG messages before retention checks Develop now scores unsolicited DKG pushes before they can enter the pending queues. Retention-path coverage (worker-deferred BLS rejection, per-sender quota, queue-wide bounds) must complete inv -> getdata first so the messages are authorized; the unrequested rejection case continues to push bare. Co-Authored-By: Claude --- test/functional/feature_llmq_dkg_intake.py | 38 +++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/test/functional/feature_llmq_dkg_intake.py b/test/functional/feature_llmq_dkg_intake.py index 82564240697c..44c439ff1348 100755 --- a/test/functional/feature_llmq_dkg_intake.py +++ b/test/functional/feature_llmq_dkg_intake.py @@ -25,12 +25,21 @@ matching worker still processes them. """ -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. @@ -88,6 +97,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) @@ -223,14 +247,13 @@ def test_malformed_bls_pubkey_rejected_by_worker(self, node): wait_for_banscore(node, peer_id, 0) with node.assert_debug_log( ["failed to deserialize message"], - unexpected_msgs=["malformed DKG message"], + unexpected_msgs=["malformed DKG message", "unrequested DKG message"], timeout=10, ): - peer.send_message(msg_dkg_raw(b"qcontrib", self.qcontrib_payload( + send_requested_qcontrib(peer, self.qcontrib_payload( blob_count=2, vvec_pubkey=INVALID_NONZERO_BLS_PUBKEY, - ))) - peer.sync_with_ping() + )) wait_for_banscore(node, peer_id, 0) self.move_blocks(nodes, 2) wait_for_banscore(node, peer_id, 100) @@ -238,14 +261,13 @@ def test_malformed_bls_pubkey_rejected_by_worker(self, node): def _send_late_qcontrib(self, node, peer, nonce): """Send a framing-valid, BLS-invalid QCONTRIB that no on-time worker will drain.""" - peer.send_message(msg_dkg_raw(b"qcontrib", self.qcontrib_payload( + send_requested_qcontrib(peer, self.qcontrib_payload( blob_count=2, vvec_pubkey=INVALID_NONZERO_BLS_PUBKEY, # Unique bytes per message so retention is bounded by the quotas rather # than by duplicate-hash suppression. protx_hash=nonce, - ))) - peer.sync_with_ping() + )) def test_late_messages_bounded(self, node): self.log.info("Late QCONTRIB retention is bounded per sender and queue-wide, then cleared without BLS decoding") From 3091f3f06b43e7310ffaf515b41c993f9acaaae5 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sun, 2 Aug 2026 21:25:36 -0500 Subject: [PATCH 9/9] test: mirror DKG worker exception handling in fuzz target --- src/test/fuzz/dkg_message_framing.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/fuzz/dkg_message_framing.cpp b/src/test/fuzz/dkg_message_framing.cpp index e6b1b6c06f07..eb8f5af66c98 100644 --- a/src/test/fuzz/dkg_message_framing.cpp +++ b/src/test/fuzz/dkg_message_framing.cpp @@ -35,7 +35,7 @@ bool TypedDeserializeSucceeds(Span payload, const Consensus::LLMQ Message msg; try { ds >> msg; - } catch (const std::exception&) { + } catch (...) { return false; } return ds.empty() && llmq::CheckDKGMessageStructure(msg, params);