From a1e59f1b8b084b519016721aab0817f8de2413ca Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Fri, 3 Jul 2026 11:01:13 -0500 Subject: [PATCH 1/6] feat(rpc): expose upcoming DKG participation Dashmate currently uses quorum dkginfo/quorum dkgstatus to avoid unsafe restart/reindex operations around DKG windows, but Core only exposes global DKG timing. That forces Dashmate to treat "a DKG is soon" as "this node might be needed", which blocks harmless recovery operations for masternodes that are not actually selected for the upcoming DKG. This PR extends quorum dkginfo so tooling can ask whether a specific proTxHash is selected for an upcoming, already-determinable DKG. What was done? Added an optional quorum dkginfo proTxHash parameter, defaulting to the local active masternode when available. Added an upcoming_dkgs result array with quorum type, planned quorum height, blocks until start, known/unknown status, and per-proTxHash membership fields when prediction is possible. The RPC reports only upcoming sessions whose work block is already mined. Added utility helpers that compute future v20 quorum members from already-known DKG work blocks for both non-rotated and rotated quorum types. For rotated quorums, prediction uses the cycle base work block and historical quarter-rotation snapshots, without storing a snapshot for the future cycle base block. Shared the post-v20 work-block hash modifier helper with normal quorum selection, so prediction does not duplicate modifier logic. Snapshot the chain tip/work-block lookup consistently for the prediction path. Return structured unknown reasons instead of guessing for cases that cannot be predicted safely, including unavailable work blocks, unavailable rotated snapshots, and pre-v20 quorum selection. Co-authored-by: PastaClaw --- src/llmq/utils.cpp | 129 ++++++++++++---- src/llmq/utils.h | 8 + src/rpc/quorums.cpp | 138 +++++++++++++++++- test/functional/feature_llmq_rotation.py | 4 + .../test_framework/test_framework.py | 44 ++++++ 5 files changed, 291 insertions(+), 32 deletions(-) diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 33dad50279c8..1b90e43e2120 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -83,6 +83,18 @@ arith_uint256 calculateQuorumScore(const CDeterministicMNCPtr& dmn, const uint25 return UintToArith256(h); } +uint256 GetHashModifierFromWorkBlock(const Consensus::LLMQParams& llmqParams, const CBlockIndex* pWorkBlockIndex) +{ + auto cbcl = GetNonNullCoinbaseChainlock(pWorkBlockIndex); + if (cbcl.has_value()) { + // We have a non-null CL signature: calculate modifier using this CL signature + auto& [bestCLSignature, bestCLHeightDiff] = cbcl.value(); + return ::SerializeHash(std::make_tuple(llmqParams.type, pWorkBlockIndex->nHeight, bestCLSignature)); + } + // No non-null CL signature found in coinbase: calculate modifier using block hash only + return ::SerializeHash(std::make_pair(llmqParams.type, pWorkBlockIndex->GetBlockHash())); +} + uint256 GetHashModifier(const Consensus::LLMQParams& llmqParams, const Consensus::Params& consensus_params, gsl::not_null pCycleQuorumBaseBlockIndex) { @@ -91,14 +103,7 @@ uint256 GetHashModifier(const Consensus::LLMQParams& llmqParams, const Consensus if (DeploymentActiveAfter(pWorkBlockIndex, consensus_params, Consensus::DEPLOYMENT_V20)) { // v20 is active: calculate modifier using the new way. - auto cbcl = GetNonNullCoinbaseChainlock(pWorkBlockIndex); - if (cbcl.has_value()) { - // We have a non-null CL signature: calculate modifier using this CL signature - auto& [bestCLSignature, bestCLHeightDiff] = cbcl.value(); - return ::SerializeHash(std::make_tuple(llmqParams.type, pWorkBlockIndex->nHeight, bestCLSignature)); - } - // No non-null CL signature found in coinbase: calculate modifier using block hash only - return ::SerializeHash(std::make_pair(llmqParams.type, pWorkBlockIndex->GetBlockHash())); + return GetHashModifierFromWorkBlock(llmqParams, pWorkBlockIndex); } // v20 isn't active yet: calculate modifier using the usual way @@ -337,9 +342,12 @@ void BuildQuorumSnapshot(const Consensus::LLMQParams& llmqParams, const Consensu std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQParams& llmqParams, const llmq::UtilParameters& util_params, const CDeterministicMNList& allMns, - const PreviousQuorumQuarters& previousQuarters) + const PreviousQuorumQuarters& previousQuarters, + const uint256& modifier, + int cycleBaseHeight, + bool storeSnapshot) { - if (!llmqParams.useRotation || util_params.m_base_index->nHeight % llmqParams.dkgInterval != 0) { + if (!llmqParams.useRotation || cycleBaseHeight % llmqParams.dkgInterval != 0) { ASSERT_IF_DEBUG(false); return {}; } @@ -349,7 +357,6 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar size_t quorumSize = static_cast(llmqParams.size); auto quarterSize{quorumSize / 4}; - const auto modifier = GetHashModifier(llmqParams, util_params.m_chainman.GetConsensus(), util_params.m_base_index); if (allMns.GetCounts().enabled() < quarterSize) { return quarterQuorumMembers; @@ -358,8 +365,10 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar auto MnsUsedAtH = CDeterministicMNList(); std::vector MnsUsedAtHIndexed{nQuorums}; - bool skipRemovedMNs = DeploymentActiveAfter(util_params.m_base_index, util_params.m_chainman.GetConsensus(), - Consensus::DEPLOYMENT_V19) || + // V19 is a buried deployment, so its state at the cycle base is a pure height check that + // also works when the cycle base block is not mined yet + bool skipRemovedMNs = cycleBaseHeight + 1 >= + util_params.m_chainman.GetConsensus().DeploymentHeight(Consensus::DEPLOYMENT_V19) || (util_params.m_chainman.GetParams().NetworkIDString() == CBaseChainParams::TESTNET); for (const size_t idx : util::irange(nQuorums)) { @@ -399,7 +408,7 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar } if (LogAcceptDebug(BCLog::LLMQ)) { - LogPrint(BCLog::LLMQ, "%s h[%d] sortedCombinedMns[%s]\n", __func__, util_params.m_base_index->nHeight, + LogPrint(BCLog::LLMQ, "%s h[%d] sortedCombinedMns[%s]\n", __func__, cycleBaseHeight, ToString(sortedCombinedMnsList)); } @@ -445,35 +454,43 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar } } - llmq::CQuorumSnapshot quorumSnapshot{}; - BuildQuorumSnapshot(llmqParams, util_params.m_chainman.GetConsensus(), allMns, MnsUsedAtH, sortedCombinedMnsList, - quorumSnapshot, skipList, util_params.m_base_index); - util_params.m_qsnapman.StoreSnapshotForBlock(llmqParams.type, util_params.m_base_index, quorumSnapshot); + if (storeSnapshot) { + llmq::CQuorumSnapshot quorumSnapshot{}; + BuildQuorumSnapshot(llmqParams, util_params.m_chainman.GetConsensus(), allMns, MnsUsedAtH, sortedCombinedMnsList, + quorumSnapshot, skipList, util_params.m_base_index); + util_params.m_qsnapman.StoreSnapshotForBlock(llmqParams.type, util_params.m_base_index, quorumSnapshot); + } return quarterQuorumMembers; } +// Computes the members of all quorum indexes of the cycle starting at cycleBaseHeight, which +// is not required to be mined yet: everything is derived from the cycle's work block. Pass +// storeSnapshot=false when predicting a future cycle. std::vector ComputeQuorumMembersByQuarterRotation(const Consensus::LLMQParams& llmqParams, - const llmq::UtilParameters& util_params) + const llmq::UtilParameters& util_params, + gsl::not_null pWorkBlockIndex, + int cycleBaseHeight, const uint256& modifier, + bool storeSnapshot) { const int cycleLength = llmqParams.dkgInterval; - if (!llmqParams.useRotation || util_params.m_base_index->nHeight % llmqParams.dkgInterval != 0) { + if (!llmqParams.useRotation || cycleBaseHeight % llmqParams.dkgInterval != 0) { ASSERT_IF_DEBUG(false); return {}; } const auto nQuorums{static_cast(llmqParams.signingActiveQuorumCount)}; - const CBlockIndex* pWorkBlockIndex = util_params.m_base_index->GetAncestor(util_params.m_base_index->nHeight - - llmq::WORK_DIFF_DEPTH); CDeterministicMNList allMns = util_params.m_dmnman.GetListForBlock(pWorkBlockIndex); LogPrint(BCLog::LLMQ, "ComputeQuorumMembersByQuarterRotation llmqType[%d] nHeight[%d] allMns[%d]\n", - std23::to_underlying(llmqParams.type), util_params.m_base_index->nHeight, allMns.GetCounts().enabled()); + std23::to_underlying(llmqParams.type), cycleBaseHeight, allMns.GetCounts().enabled()); PreviousQuorumQuarters previousQuarters(nQuorums); auto prev_cycles{previousQuarters.GetCycles()}; for (size_t idx{0}; idx < prev_cycles.size(); idx++) { - prev_cycles[idx]->m_cycle_index = util_params.m_base_index->GetAncestor(util_params.m_base_index->nHeight - - (cycleLength * (idx + 1))); + prev_cycles[idx]->m_cycle_index = pWorkBlockIndex->GetAncestor(cycleBaseHeight - (cycleLength * (idx + 1))); + if (prev_cycles[idx]->m_cycle_index == nullptr) { + break; + } if (auto opt_snap = util_params.m_qsnapman.GetSnapshotForBlock(llmqParams.type, prev_cycles[idx]->m_cycle_index); opt_snap.has_value()) { prev_cycles[idx]->m_snap = opt_snap.value(); @@ -486,10 +503,11 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus util_params.m_chainman.GetConsensus(), prev_cycles[idx]->m_cycle_index, prev_cycles[idx]->m_snap, - util_params.m_base_index->nHeight); + cycleBaseHeight); } - auto newQuarterMembers = BuildNewQuorumQuarterMembers(llmqParams, util_params, allMns, previousQuarters); + auto newQuarterMembers = BuildNewQuorumQuarterMembers(llmqParams, util_params, allMns, previousQuarters, modifier, + cycleBaseHeight, storeSnapshot); // TODO: Check if it is triggered from outside (P2P, block validation) and maybe throw an exception // assert (!newQuarterMembers.empty()); @@ -500,7 +518,7 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus ss << strprintf(" %dCmns[%s]", idx, ToString(prev_cycles[idx]->m_members[i])); } ss << strprintf(" new[%s]", ToString(newQuarterMembers[i])); - LogPrint(BCLog::LLMQ, "QuarterComposition h[%d] i[%d]:%s\n", util_params.m_base_index->nHeight, i, ss.str()); + LogPrint(BCLog::LLMQ, "QuarterComposition h[%d] i[%d]:%s\n", cycleBaseHeight, i, ss.str()); } } @@ -513,7 +531,7 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus } std::move(newQuarterMembers[i].begin(), newQuarterMembers[i].end(), std::back_inserter(quorumMembers[i])); if (LogAcceptDebug(BCLog::LLMQ)) { - LogPrint(BCLog::LLMQ, "QuorumComposition h[%d] i[%d]: [%s]\n", util_params.m_base_index->nHeight, i, + LogPrint(BCLog::LLMQ, "QuorumComposition h[%d] i[%d]: [%s]\n", cycleBaseHeight, i, ToString(quorumMembers[i])); } } @@ -564,6 +582,51 @@ void BlsCheck::swap(BlsCheck& obj) std::swap(m_id_string, obj.m_id_string); } +std::optional> ComputeQuorumMembersFromWorkBlock( + Consensus::LLMQType llmqType, const UtilParameters& util_params, gsl::not_null pWorkBlockIndex, + int quorumHeight) +{ + const Consensus::Params& consensus{util_params.m_chainman.GetConsensus()}; + const auto& llmq_params_opt = util_params.m_chainman.GetParams().GetLLMQ(llmqType); + if (!llmq_params_opt.has_value()) { + ASSERT_IF_DEBUG(false); + return std::nullopt; + } + const auto& llmq_params = llmq_params_opt.value(); + + const int quorumIndex{quorumHeight % llmq_params.dkgInterval}; + const int cycleBaseHeight{quorumHeight - quorumIndex}; + if (quorumIndex < 0 || quorumIndex >= (llmq_params.useRotation ? llmq_params.signingActiveQuorumCount : 1) || + pWorkBlockIndex->nHeight != cycleBaseHeight - llmq::WORK_DIFF_DEPTH) { + ASSERT_IF_DEBUG(false); + return std::nullopt; + } + + if (!DeploymentActiveAfter(pWorkBlockIndex.get(), consensus, Consensus::DEPLOYMENT_V20)) { + // pre-v20 modifier calculation needs the future (cycle) quorum base block context, which isn't known yet. + return std::nullopt; + } + + const auto modifier = GetHashModifierFromWorkBlock(llmq_params, pWorkBlockIndex.get()); + + if (!llmq_params.useRotation) { + // Canonical member selection gates EvoOnly on V19 at the future quorum base block; V19 + // is a buried deployment, so that state is a pure height check even though the block + // itself is not mined yet. + const bool EvoOnly = llmq_params.type == consensus.llmqTypePlatform && + quorumHeight + 1 >= consensus.DeploymentHeight(Consensus::DEPLOYMENT_V19); + return CalculateQuorum(util_params.m_dmnman.GetListForBlock(pWorkBlockIndex), modifier, llmq_params.size, + EvoOnly); + } + + auto quorumMembers = ComputeQuorumMembersByQuarterRotation(llmq_params, util_params, pWorkBlockIndex, + cycleBaseHeight, modifier, /*storeSnapshot=*/false); + if (quorumMembers.empty()) { + return std::nullopt; + } + return quorumMembers[quorumIndex]; +} + QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) { static RecursiveMutex cs_members; @@ -627,7 +690,13 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame return quorumMembers; } - auto q = ComputeQuorumMembersByQuarterRotation(llmq_params, util_params.replace_index(pCycleQuorumBaseBlockIndex)); + const CBlockIndex* pWorkBlockIndex = pCycleQuorumBaseBlockIndex->GetAncestor(cycleQuorumBaseHeight - + WORK_DIFF_DEPTH); + const auto modifier = GetHashModifier(llmq_params, util_params.m_chainman.GetConsensus(), + pCycleQuorumBaseBlockIndex); + auto q = ComputeQuorumMembersByQuarterRotation(llmq_params, util_params.replace_index(pCycleQuorumBaseBlockIndex), + pWorkBlockIndex, cycleQuorumBaseHeight, modifier, + /*storeSnapshot=*/true); quorumMembers = q[quorumIndex]; LOCK(cs_indexed_members); diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 4e755c9c6257..3a19391a4783 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -16,6 +16,7 @@ #include +#include #include #include @@ -67,6 +68,13 @@ std::unordered_set CalcDeterministicWatchConnections(Consensus::LLMQType std::vector GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache = false); +// Predicts the members of a future v20 quorum from its already-known work block, before the +// quorum's own (cycle) base block exists on chain. Returns std::nullopt when V20 is not yet +// active at the work block: the pre-v20 modifier needs the future base block hash. +std::optional> ComputeQuorumMembersFromWorkBlock( + Consensus::LLMQType llmqType, const UtilParameters& util_params, gsl::not_null pWorkBlockIndex, + int quorumHeight); + Uint256HashSet GetQuorumConnections(const Consensus::LLMQParams& llmqParams, const CSporkManager& sporkman, const UtilParameters& util_params, const uint256& forMember, bool onlyOutbound); diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index 931c775ed9c0..f3ba5556d251 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -990,13 +990,33 @@ static RPCHelpMan quorum_dkginfo() "quorum dkginfo", "Return information regarding DKGs.\n", { - {}, + {"proTxHash", RPCArg::Type::STR_HEX, RPCArg::DefaultHint{"local active masternode proTxHash, if any"}, + "The proTxHash of the masternode to report upcoming DKG participation for. Empty string is treated as the default."}, }, RPCResult{ RPCResult::Type::OBJ, "", "", { {RPCResult::Type::NUM, "active_dkgs", "Total number of active DKG sessions this node is participating in right now"}, {RPCResult::Type::NUM, "next_dkg", "The number of blocks until the next potential DKG session"}, + {RPCResult::Type::ARR, "upcoming_dkgs", /*optional=*/true, "Upcoming DKG sessions for the given proTxHash whose work block is already mined. For rotated quorums all indices in a cycle share the cycle base work block", + { + {RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::NUM, "llmqType", "The type of the quorum"}, + {RPCResult::Type::STR, "llmqTypeName", "The name of the quorum type"}, + {RPCResult::Type::NUM, "quorumIndex", "The quorum index within the DKG interval"}, + {RPCResult::Type::NUM, "quorumHeight", "The height at which the quorum session starts"}, + {RPCResult::Type::NUM, "blocksUntilStart", "The number of blocks until the quorum session starts"}, + {RPCResult::Type::STR_HEX, "proTxHash", "The proTxHash this entry was computed for"}, + {RPCResult::Type::BOOL, "known", "Whether participation could be determined"}, + {RPCResult::Type::STR, "reason", /*optional=*/true, "Why participation could not be determined"}, + {RPCResult::Type::BOOL, "isMember", /*optional=*/true, "Whether the masternode is a member of the upcoming quorum"}, + {RPCResult::Type::NUM, "memberIndex", /*optional=*/true, "The member index, or -1 if not a member"}, + {RPCResult::Type::NUM, "memberCount", /*optional=*/true, "The number of members in the upcoming quorum"}, + {RPCResult::Type::NUM, "workBlockHeight", /*optional=*/true, "The height of the work block used to compute membership"}, + {RPCResult::Type::STR_HEX, "workBlockHash", /*optional=*/true, "The hash of the work block used to compute membership"}, + }}, + }}, } }, RPCExamples{""}, @@ -1012,7 +1032,9 @@ static RPCHelpMan quorum_dkginfo() ret.pushKV("active_dkgs", dkgdbgman.GetSessionCount()); const ChainstateManager& chainman = EnsureChainman(node); - const int nTipHeight{WITH_LOCK(cs_main, return chainman.ActiveChain().Height())}; + const CBlockIndex* const pindexTip = WITH_LOCK(cs_main, return chainman.ActiveChain().Tip()); + CHECK_NONFATAL(pindexTip); + const int nTipHeight{pindexTip->nHeight}; auto minNextDKG = [](const Consensus::Params& consensusParams, int nTipHeight) { int minDkgWindow{std::numeric_limits::max()}; for (const auto& params: consensusParams.llmqs) { @@ -1025,6 +1047,118 @@ static RPCHelpMan quorum_dkginfo() }; ret.pushKV("next_dkg", minNextDKG(Params().GetConsensus(), nTipHeight)); + const auto quorum_type_known_enabled = [&](Consensus::LLMQType llmq_type, int quorum_base_height) { + const int quorum_base_predecessor_height{quorum_base_height - 1}; + if (quorum_base_predecessor_height <= nTipHeight) { + const CBlockIndex* const pQuorumBasePredecessor = pindexTip->GetAncestor(quorum_base_predecessor_height); + return pQuorumBasePredecessor != nullptr && chainman.IsQuorumTypeEnabled(llmq_type, pQuorumBasePredecessor); + } + + // The future predecessor is not mined yet, but both DIP0024 conditions are pure height + // checks (buried deployment); evaluate them at the future height and let pindexTip stand + // in for the rest (versionbits TESTDUMMY is monotonic once active). + const auto& consensus = Params().GetConsensus(); + return chainman.IsQuorumTypeEnabled(llmq_type, pindexTip, + /*optDIP0024IsActive=*/quorum_base_height >= + consensus.DeploymentHeight(Consensus::DEPLOYMENT_DIP0024), + /*optHaveDIP0024Quorums=*/quorum_base_predecessor_height >= + consensus.DIP0024QuorumsHeight); + }; + + uint256 proTxHash; + if (!request.params[0].isNull() && !request.params[0].get_str().empty()) { + proTxHash = ParseHashV(request.params[0], "proTxHash"); + } else if (node.active_ctx) { + proTxHash = node.active_ctx->nodeman->GetProTxHash(); + } + + if (!proTxHash.IsNull()) { + UniValue upcoming(UniValue::VARR); + for (const auto& llmq_params : Params().GetConsensus().llmqs) { + // Whether a rotated cycle applies at the *upcoming* cycle base is what matters here, + // not whether the tip's own current cycle is rotated. Iterate every possible index + // for any llmq type that supports rotation and decide per-entry below. + const int quorums_num = llmq_params.useRotation ? llmq_params.signingActiveQuorumCount : 1; + + for (const int quorumIndex : util::irange(quorums_num)) { + int quorumHeight = nTipHeight - (nTipHeight % llmq_params.dkgInterval) + quorumIndex; + if (quorumHeight <= nTipHeight) { + quorumHeight += llmq_params.dkgInterval; + } + const int cycleBaseHeight{quorumHeight - quorumIndex}; + if (!quorum_type_known_enabled(llmq_params.type, cycleBaseHeight)) { + continue; + } + + // IsQuorumRotationEnabled gates on DIP0024 (a buried deployment) at the block + // preceding the cycle base, so the upcoming cycle's rotation state is a pure + // height check. A rotation-capable type without rotation active at its cycle + // base has no canonical member selection, so skip it (this can only happen + // around DIP0024 activation). + const Consensus::Params& consensus{Params().GetConsensus()}; + if (llmq_params.useRotation && + (cycleBaseHeight < 1 || cycleBaseHeight < consensus.DeploymentHeight(Consensus::DEPLOYMENT_DIP0024))) { + continue; + } + + UniValue obj(UniValue::VOBJ); + obj.pushKV("llmqType", static_cast(llmq_params.type)); + obj.pushKV("llmqTypeName", std::string(llmq_params.name)); + obj.pushKV("quorumIndex", quorumIndex); + obj.pushKV("quorumHeight", quorumHeight); + obj.pushKV("blocksUntilStart", quorumHeight - nTipHeight); + obj.pushKV("proTxHash", proTxHash.ToString()); + + // All indices of a rotated cycle share the work block of their cycle base, so + // gate availability on the work block height rather than each index's start + // height; for non-rotated types cycleBaseHeight == quorumHeight anyway + // workHeight cannot be negative: the upcoming base is a positive multiple of + // dkgInterval, and every dkgInterval exceeds WORK_DIFF_DEPTH + const int workHeight{cycleBaseHeight - llmq::WORK_DIFF_DEPTH}; + if (workHeight > nTipHeight) { + continue; + } + + const CBlockIndex* const pWorkBlockIndex = pindexTip->GetAncestor(workHeight); + if (!DeploymentActiveAfter(pWorkBlockIndex, Params().GetConsensus(), Consensus::DEPLOYMENT_V20)) { + obj.pushKV("known", false); + obj.pushKV("reason", "pre-v20 quorum selection needs future quorum base block hash"); + upcoming.push_back(obj); + continue; + } + + const LLMQContext& llmq_ctx = EnsureLLMQContext(node); + const auto predicted_members = llmq::utils::ComputeQuorumMembersFromWorkBlock( + llmq_params.type, + {*CHECK_NONFATAL(node.dmnman), *CHECK_NONFATAL(llmq_ctx.qsnapman), chainman, pindexTip}, + pWorkBlockIndex, quorumHeight); + if (!predicted_members.has_value()) { + obj.pushKV("known", false); + obj.pushKV("reason", "quorum members could not be computed"); + upcoming.push_back(obj); + continue; + } + const auto& members = *predicted_members; + + obj.pushKV("known", true); + int memberIndex{-1}; + for (size_t i = 0; i < members.size(); ++i) { + if (members[i]->proTxHash == proTxHash) { + memberIndex = (int)i; + break; + } + } + obj.pushKV("isMember", memberIndex != -1); + obj.pushKV("memberIndex", memberIndex); + obj.pushKV("memberCount", (int)members.size()); + obj.pushKV("workBlockHeight", pWorkBlockIndex->nHeight); + obj.pushKV("workBlockHash", pWorkBlockIndex->GetBlockHash().ToString()); + upcoming.push_back(obj); + } + } + ret.pushKV("upcoming_dkgs", upcoming); + } + return ret; }, }; diff --git a/test/functional/feature_llmq_rotation.py b/test/functional/feature_llmq_rotation.py index 813016c5b9ef..76b72a6238fe 100755 --- a/test/functional/feature_llmq_rotation.py +++ b/test/functional/feature_llmq_rotation.py @@ -80,6 +80,10 @@ def run_test(self): assert_equal(dkg_info['active_dkgs'], 0) assert_equal(dkg_info['next_dkg'], next_dkg) + mn = self.mninfo[0] + self.log.info("Check that an empty proTxHash falls back to the local masternode's own proTxHash") + assert_equal(mn.get_node(self).quorum("dkginfo", ""), mn.get_node(self).quorum("dkginfo", mn.proTxHash)) + #Mine 2 quorums so that Chainlocks can be available: Need them to include CL in CbTx as soon as v20 activates self.log.info("Mining 2 quorums") h_0 = self.mine_quorum() diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index fc5030a62bc4..22825c7e8d6b 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -2136,6 +2136,28 @@ def move_blocks(self, nodes, num_blocks): self.bump_mocktime(1, nodes=nodes) self.generate(self.nodes[0], num_blocks, sync_fun=lambda: self.sync_blocks(nodes)) + def get_upcoming_dkg_predictions(self, node, llmq_type): + # 'quorum dkginfo' upcoming DKG entries of the given type per registered masternode + return {mn.proTxHash: [d for d in node.quorum("dkginfo", mn.proTxHash)["upcoming_dkgs"] + if d["llmqType"] == llmq_type] + for mn in self.mninfo} + + def verify_upcoming_dkg_predictions(self, predictions, quorum_info): + members = set(m["proTxHash"] for m in quorum_info["members"]) + work_height = quorum_info["height"] - quorum_info["quorumIndex"] - 8 + for proTxHash, entries in predictions.items(): + [entry] = [d for d in entries if d["quorumHeight"] == quorum_info["height"]] + assert_equal(entry["proTxHash"], proTxHash) + if not entry["known"]: + # no prediction was possible (e.g. pre-v20); the RPC must tell why + assert "reason" in entry + continue + assert_equal(entry["workBlockHeight"], work_height) + assert_equal(entry["workBlockHash"], self.nodes[0].getblockhash(work_height)) + assert_equal(entry["memberCount"], len(members)) + assert_equal(entry["isMember"], entry["memberIndex"] != -1) + assert_equal(entry["isMember"], proTxHash in members) + def mine_quorum(self, llmq_type_name="llmq_test", llmq_type=100, expected_connections=None, expected_members=None, expected_contributions=None, expected_complaints=0, expected_justifications=0, expected_commitments=None, mninfos_online=None, mninfos_valid=None, skip_maturity=False): spork21_active = self.nodes[0].spork('show')['SPORK_21_QUORUM_ALL_CONNECTED'] <= 1 spork23_active = self.nodes[0].spork('show')['SPORK_23_QUORUM_POSE'] <= 1 @@ -2161,7 +2183,14 @@ def mine_quorum(self, llmq_type_name="llmq_test", llmq_type=100, expected_connec # move forward to next DKG llmq_cycle_len = 24 + work_diff_depth = 8 skip_count = llmq_cycle_len - (self.nodes[0].getblockcount() % llmq_cycle_len) + # a tip already within the work-diff window of the upcoming DKG means its members are + # predictable via 'quorum dkginfo'; capture now and verify once the quorum is formed + predictions = None + if skip_count <= work_diff_depth: + self.log.info(f"Capturing upcoming DKG membership predictions {skip_count} blocks ahead") + predictions = self.get_upcoming_dkg_predictions(mninfos_online[0].get_node(self), llmq_type) if skip_count != 0: self.bump_mocktime(1) self.generate(self.nodes[0], skip_count, sync_fun=lambda: self.sync_blocks(nodes)) @@ -2216,6 +2245,8 @@ def mine_quorum(self, llmq_type_name="llmq_test", llmq_type=100, expected_connec new_quorum = self.nodes[0].quorum("list", 1)[llmq_type_name][0] assert_equal(q, new_quorum) quorum_info = self.nodes[0].quorum("info", llmq_type, new_quorum) + if predictions is not None: + self.verify_upcoming_dkg_predictions(predictions, quorum_info) if not skip_maturity: # Mine 8 (SIGN_HEIGHT_OFFSET) more blocks to make sure that the new quorum gets eligible for signing sessions @@ -2253,6 +2284,12 @@ def mine_cycle_quorum(self): skip_count = llmq_cycle_len - (cur_block % llmq_cycle_len) # move forward to next 3 DKG rounds for the first quorum extra_blocks = 0 if self.cycle_quorum_is_ready else llmq_cycle_len * 3 + # a tip already within the work-diff window of the cycle base means both quorums of + # the cycle are predictable via 'quorum dkginfo'; capture and verify once they form + predictions = None + if extra_blocks + skip_count <= 8: + self.log.info(f"Capturing upcoming DKG membership predictions {skip_count} blocks ahead") + predictions = self.get_upcoming_dkg_predictions(mninfos_online[0].get_node(self), llmq_type) self.move_blocks(nodes, extra_blocks + skip_count) self.log.info('Moved from block %d to %d' % (cur_block, self.nodes[0].getblockcount())) @@ -2268,6 +2305,9 @@ def mine_cycle_quorum(self): if spork23_active: self.wait_for_masternode_probes(q_0, mninfos_online, wait_proc=lambda: self.bump_mocktime(1), llmq_type_name=llmq_type_name) + # the cycle's work block is mined, so index-1 participation is already predictable + predictions_mid_cycle = self.get_upcoming_dkg_predictions(mninfos_online[0].get_node(self), llmq_type) + self.move_blocks(nodes, 1) q_1 = self.nodes[0].getbestblockhash() @@ -2339,6 +2379,10 @@ def mine_cycle_quorum(self): quorum_info_0 = self.nodes[0].quorum("info", llmq_type, q_0) quorum_info_1 = self.nodes[0].quorum("info", llmq_type, q_1) + if predictions is not None: + self.verify_upcoming_dkg_predictions(predictions, quorum_info_0) + self.verify_upcoming_dkg_predictions(predictions, quorum_info_1) + self.verify_upcoming_dkg_predictions(predictions_mid_cycle, quorum_info_1) # Mine 8 (SIGN_HEIGHT_OFFSET) more blocks to make sure that the new quorum gets eligible for signing sessions self.generate(self.nodes[0], 8, sync_fun=lambda: self.sync_blocks(nodes)) From 1f745e1f8dbd3b46109b535cf90d9d9bfd4d0e11 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Thu, 9 Jul 2026 01:13:20 +0700 Subject: [PATCH 2/6] fix: check memberIndex in functional tests too --- test/functional/test_framework/test_framework.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index 22825c7e8d6b..003e0646d371 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -2143,7 +2143,8 @@ def get_upcoming_dkg_predictions(self, node, llmq_type): for mn in self.mninfo} def verify_upcoming_dkg_predictions(self, predictions, quorum_info): - members = set(m["proTxHash"] for m in quorum_info["members"]) + # 'quorum info' lists members in member-index order + members = [m["proTxHash"] for m in quorum_info["members"]] work_height = quorum_info["height"] - quorum_info["quorumIndex"] - 8 for proTxHash, entries in predictions.items(): [entry] = [d for d in entries if d["quorumHeight"] == quorum_info["height"]] @@ -2155,7 +2156,7 @@ def verify_upcoming_dkg_predictions(self, predictions, quorum_info): assert_equal(entry["workBlockHeight"], work_height) assert_equal(entry["workBlockHash"], self.nodes[0].getblockhash(work_height)) assert_equal(entry["memberCount"], len(members)) - assert_equal(entry["isMember"], entry["memberIndex"] != -1) + assert_equal(entry["memberIndex"], members.index(proTxHash) if proTxHash in members else -1) assert_equal(entry["isMember"], proTxHash in members) def mine_quorum(self, llmq_type_name="llmq_test", llmq_type=100, expected_connections=None, expected_members=None, expected_contributions=None, expected_complaints=0, expected_justifications=0, expected_commitments=None, mninfos_online=None, mninfos_valid=None, skip_maturity=False): From 2629a3b3570684c56763026f96d195fb2c1a64e6 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Thu, 9 Jul 2026 01:58:58 +0700 Subject: [PATCH 3/6] fix: current rotated prediction path can still return a known result after missing historical quarter data It never happens in real-life scenarios, only some corner cases such as: - first few rotated cycles after DIP0024 activation / before 3 prior rotated snapshots exist - devnet/regtest with DIP0024 active from height 1 - node with incomplete/corrupt/rebuilt EvoDB snapshot data Though, review agents are concerned about that issue very much, so it should be fixed. --- src/llmq/utils.cpp | 10 ++++++---- src/llmq/utils.h | 3 ++- src/rpc/quorums.cpp | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 1b90e43e2120..5668f4678fb5 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -471,7 +471,7 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus const llmq::UtilParameters& util_params, gsl::not_null pWorkBlockIndex, int cycleBaseHeight, const uint256& modifier, - bool storeSnapshot) + bool predicting) { const int cycleLength = llmqParams.dkgInterval; if (!llmqParams.useRotation || cycleBaseHeight % llmqParams.dkgInterval != 0) { @@ -489,12 +489,14 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus for (size_t idx{0}; idx < prev_cycles.size(); idx++) { prev_cycles[idx]->m_cycle_index = pWorkBlockIndex->GetAncestor(cycleBaseHeight - (cycleLength * (idx + 1))); if (prev_cycles[idx]->m_cycle_index == nullptr) { + if (predicting) return {}; break; } if (auto opt_snap = util_params.m_qsnapman.GetSnapshotForBlock(llmqParams.type, prev_cycles[idx]->m_cycle_index); opt_snap.has_value()) { prev_cycles[idx]->m_snap = opt_snap.value(); } else { + if (predicting) return {}; // TODO: Check if it is triggered from outside (P2P, block validation) and maybe throw an exception // assert(false); break; @@ -507,7 +509,7 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus } auto newQuarterMembers = BuildNewQuorumQuarterMembers(llmqParams, util_params, allMns, previousQuarters, modifier, - cycleBaseHeight, storeSnapshot); + cycleBaseHeight, /*storeSnapshot=*/!predicting); // TODO: Check if it is triggered from outside (P2P, block validation) and maybe throw an exception // assert (!newQuarterMembers.empty()); @@ -620,7 +622,7 @@ std::optional> ComputeQuorumMembersFromWorkBlo } auto quorumMembers = ComputeQuorumMembersByQuarterRotation(llmq_params, util_params, pWorkBlockIndex, - cycleBaseHeight, modifier, /*storeSnapshot=*/false); + cycleBaseHeight, modifier, /*predicting=*/true); if (quorumMembers.empty()) { return std::nullopt; } @@ -696,7 +698,7 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame pCycleQuorumBaseBlockIndex); auto q = ComputeQuorumMembersByQuarterRotation(llmq_params, util_params.replace_index(pCycleQuorumBaseBlockIndex), pWorkBlockIndex, cycleQuorumBaseHeight, modifier, - /*storeSnapshot=*/true); + /*predicting=*/false); quorumMembers = q[quorumIndex]; LOCK(cs_indexed_members); diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 3a19391a4783..01ab95316117 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -70,7 +70,8 @@ std::vector GetAllQuorumMembers(Consensus::LLMQType llmqTy // Predicts the members of a future v20 quorum from its already-known work block, before the // quorum's own (cycle) base block exists on chain. Returns std::nullopt when V20 is not yet -// active at the work block: the pre-v20 modifier needs the future base block hash. +// active at the work block (the pre-v20 modifier needs the future base block hash) or, for +// rotated types, when the historical snapshots needed for quarter rotation are not available. std::optional> ComputeQuorumMembersFromWorkBlock( Consensus::LLMQType llmqType, const UtilParameters& util_params, gsl::not_null pWorkBlockIndex, int quorumHeight); diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index f3ba5556d251..9f460549932a 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -1134,7 +1134,7 @@ static RPCHelpMan quorum_dkginfo() pWorkBlockIndex, quorumHeight); if (!predicted_members.has_value()) { obj.pushKV("known", false); - obj.pushKV("reason", "quorum members could not be computed"); + obj.pushKV("reason", "rotated quorum snapshots are not available yet"); upcoming.push_back(obj); continue; } From 4534eb882c2ed6a9e61cf511fbc51a87309255f0 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Fri, 10 Jul 2026 20:13:44 +0700 Subject: [PATCH 4/6] refactor: re-use existing strings for rpc help for `quorum dkginfo` output Co-authored-by: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> --- src/rpc/quorums.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index 9f460549932a..b8b13bfb4776 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -1002,16 +1002,16 @@ static RPCHelpMan quorum_dkginfo() { {RPCResult::Type::OBJ, "", "", { - {RPCResult::Type::NUM, "llmqType", "The type of the quorum"}, + GetRpcResult("llmqType"), {RPCResult::Type::STR, "llmqTypeName", "The name of the quorum type"}, - {RPCResult::Type::NUM, "quorumIndex", "The quorum index within the DKG interval"}, + GetRpcResult("quorumIndex"), {RPCResult::Type::NUM, "quorumHeight", "The height at which the quorum session starts"}, {RPCResult::Type::NUM, "blocksUntilStart", "The number of blocks until the quorum session starts"}, - {RPCResult::Type::STR_HEX, "proTxHash", "The proTxHash this entry was computed for"}, + GetRpcResult("proTxHash"), {RPCResult::Type::BOOL, "known", "Whether participation could be determined"}, {RPCResult::Type::STR, "reason", /*optional=*/true, "Why participation could not be determined"}, {RPCResult::Type::BOOL, "isMember", /*optional=*/true, "Whether the masternode is a member of the upcoming quorum"}, - {RPCResult::Type::NUM, "memberIndex", /*optional=*/true, "The member index, or -1 if not a member"}, + GetRpcResult("memberIndex"), {RPCResult::Type::NUM, "memberCount", /*optional=*/true, "The number of members in the upcoming quorum"}, {RPCResult::Type::NUM, "workBlockHeight", /*optional=*/true, "The height of the work block used to compute membership"}, {RPCResult::Type::STR_HEX, "workBlockHash", /*optional=*/true, "The hash of the work block used to compute membership"}, From d913a48b2b3d308d1c2a0c988f3af8563b5c5acf Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Fri, 10 Jul 2026 20:25:59 +0700 Subject: [PATCH 5/6] feat: clean up a bit dkginfo implementation for further quorums --- src/rpc/quorums.cpp | 31 +++++++------------ .../test_framework/test_framework.py | 3 -- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index b8b13bfb4776..a6a4f2ee1f94 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -998,21 +998,18 @@ static RPCHelpMan quorum_dkginfo() { {RPCResult::Type::NUM, "active_dkgs", "Total number of active DKG sessions this node is participating in right now"}, {RPCResult::Type::NUM, "next_dkg", "The number of blocks until the next potential DKG session"}, + GetRpcResult("proTxHash"), {RPCResult::Type::ARR, "upcoming_dkgs", /*optional=*/true, "Upcoming DKG sessions for the given proTxHash whose work block is already mined. For rotated quorums all indices in a cycle share the cycle base work block", { {RPCResult::Type::OBJ, "", "", { GetRpcResult("llmqType"), - {RPCResult::Type::STR, "llmqTypeName", "The name of the quorum type"}, GetRpcResult("quorumIndex"), {RPCResult::Type::NUM, "quorumHeight", "The height at which the quorum session starts"}, {RPCResult::Type::NUM, "blocksUntilStart", "The number of blocks until the quorum session starts"}, - GetRpcResult("proTxHash"), {RPCResult::Type::BOOL, "known", "Whether participation could be determined"}, {RPCResult::Type::STR, "reason", /*optional=*/true, "Why participation could not be determined"}, {RPCResult::Type::BOOL, "isMember", /*optional=*/true, "Whether the masternode is a member of the upcoming quorum"}, - GetRpcResult("memberIndex"), - {RPCResult::Type::NUM, "memberCount", /*optional=*/true, "The number of members in the upcoming quorum"}, {RPCResult::Type::NUM, "workBlockHeight", /*optional=*/true, "The height of the work block used to compute membership"}, {RPCResult::Type::STR_HEX, "workBlockHash", /*optional=*/true, "The hash of the work block used to compute membership"}, }}, @@ -1032,6 +1029,7 @@ static RPCHelpMan quorum_dkginfo() ret.pushKV("active_dkgs", dkgdbgman.GetSessionCount()); const ChainstateManager& chainman = EnsureChainman(node); + const auto& consensus = chainman.GetParams().GetConsensus(); const CBlockIndex* const pindexTip = WITH_LOCK(cs_main, return chainman.ActiveChain().Tip()); CHECK_NONFATAL(pindexTip); const int nTipHeight{pindexTip->nHeight}; @@ -1045,7 +1043,7 @@ static RPCHelpMan quorum_dkginfo() } return minDkgWindow; }; - ret.pushKV("next_dkg", minNextDKG(Params().GetConsensus(), nTipHeight)); + ret.pushKV("next_dkg", minNextDKG(consensus, nTipHeight)); const auto quorum_type_known_enabled = [&](Consensus::LLMQType llmq_type, int quorum_base_height) { const int quorum_base_predecessor_height{quorum_base_height - 1}; @@ -1057,7 +1055,6 @@ static RPCHelpMan quorum_dkginfo() // The future predecessor is not mined yet, but both DIP0024 conditions are pure height // checks (buried deployment); evaluate them at the future height and let pindexTip stand // in for the rest (versionbits TESTDUMMY is monotonic once active). - const auto& consensus = Params().GetConsensus(); return chainman.IsQuorumTypeEnabled(llmq_type, pindexTip, /*optDIP0024IsActive=*/quorum_base_height >= consensus.DeploymentHeight(Consensus::DEPLOYMENT_DIP0024), @@ -1073,8 +1070,9 @@ static RPCHelpMan quorum_dkginfo() } if (!proTxHash.IsNull()) { + ret.pushKV("proTxHash", proTxHash.ToString()); UniValue upcoming(UniValue::VARR); - for (const auto& llmq_params : Params().GetConsensus().llmqs) { + for (const auto& llmq_params : consensus.llmqs) { // Whether a rotated cycle applies at the *upcoming* cycle base is what matters here, // not whether the tip's own current cycle is rotated. Iterate every possible index // for any llmq type that supports rotation and decide per-entry below. @@ -1095,7 +1093,6 @@ static RPCHelpMan quorum_dkginfo() // height check. A rotation-capable type without rotation active at its cycle // base has no canonical member selection, so skip it (this can only happen // around DIP0024 activation). - const Consensus::Params& consensus{Params().GetConsensus()}; if (llmq_params.useRotation && (cycleBaseHeight < 1 || cycleBaseHeight < consensus.DeploymentHeight(Consensus::DEPLOYMENT_DIP0024))) { continue; @@ -1103,11 +1100,9 @@ static RPCHelpMan quorum_dkginfo() UniValue obj(UniValue::VOBJ); obj.pushKV("llmqType", static_cast(llmq_params.type)); - obj.pushKV("llmqTypeName", std::string(llmq_params.name)); obj.pushKV("quorumIndex", quorumIndex); obj.pushKV("quorumHeight", quorumHeight); obj.pushKV("blocksUntilStart", quorumHeight - nTipHeight); - obj.pushKV("proTxHash", proTxHash.ToString()); // All indices of a rotated cycle share the work block of their cycle base, so // gate availability on the work block height rather than each index's start @@ -1120,7 +1115,7 @@ static RPCHelpMan quorum_dkginfo() } const CBlockIndex* const pWorkBlockIndex = pindexTip->GetAncestor(workHeight); - if (!DeploymentActiveAfter(pWorkBlockIndex, Params().GetConsensus(), Consensus::DEPLOYMENT_V20)) { + if (!DeploymentActiveAfter(pWorkBlockIndex, consensus, Consensus::DEPLOYMENT_V20)) { obj.pushKV("known", false); obj.pushKV("reason", "pre-v20 quorum selection needs future quorum base block hash"); upcoming.push_back(obj); @@ -1138,19 +1133,17 @@ static RPCHelpMan quorum_dkginfo() upcoming.push_back(obj); continue; } - const auto& members = *predicted_members; obj.pushKV("known", true); - int memberIndex{-1}; - for (size_t i = 0; i < members.size(); ++i) { - if (members[i]->proTxHash == proTxHash) { - memberIndex = (int)i; + + bool is_member{false}; + for (const auto& member : *predicted_members) { + if (member->proTxHash == proTxHash) { + is_member = true; break; } } - obj.pushKV("isMember", memberIndex != -1); - obj.pushKV("memberIndex", memberIndex); - obj.pushKV("memberCount", (int)members.size()); + obj.pushKV("isMember", is_member); obj.pushKV("workBlockHeight", pWorkBlockIndex->nHeight); obj.pushKV("workBlockHash", pWorkBlockIndex->GetBlockHash().ToString()); upcoming.push_back(obj); diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index 003e0646d371..164f3153e67c 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -2148,15 +2148,12 @@ def verify_upcoming_dkg_predictions(self, predictions, quorum_info): work_height = quorum_info["height"] - quorum_info["quorumIndex"] - 8 for proTxHash, entries in predictions.items(): [entry] = [d for d in entries if d["quorumHeight"] == quorum_info["height"]] - assert_equal(entry["proTxHash"], proTxHash) if not entry["known"]: # no prediction was possible (e.g. pre-v20); the RPC must tell why assert "reason" in entry continue assert_equal(entry["workBlockHeight"], work_height) assert_equal(entry["workBlockHash"], self.nodes[0].getblockhash(work_height)) - assert_equal(entry["memberCount"], len(members)) - assert_equal(entry["memberIndex"], members.index(proTxHash) if proTxHash in members else -1) assert_equal(entry["isMember"], proTxHash in members) def mine_quorum(self, llmq_type_name="llmq_test", llmq_type=100, expected_connections=None, expected_members=None, expected_contributions=None, expected_complaints=0, expected_justifications=0, expected_commitments=None, mninfos_online=None, mninfos_valid=None, skip_maturity=False): From 3035e5163bfefa78addcc032b6a226f185d14756 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Sun, 12 Jul 2026 15:30:18 +0700 Subject: [PATCH 6/6] fix: add 'optional' for proTxHash in quorum dkginfo --- src/rpc/quorums.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index a6a4f2ee1f94..6a7260523492 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -998,7 +998,7 @@ static RPCHelpMan quorum_dkginfo() { {RPCResult::Type::NUM, "active_dkgs", "Total number of active DKG sessions this node is participating in right now"}, {RPCResult::Type::NUM, "next_dkg", "The number of blocks until the next potential DKG session"}, - GetRpcResult("proTxHash"), + GetRpcResult("proTxHash", /*optional=*/true), {RPCResult::Type::ARR, "upcoming_dkgs", /*optional=*/true, "Upcoming DKG sessions for the given proTxHash whose work block is already mined. For rotated quorums all indices in a cycle share the cycle base work block", { {RPCResult::Type::OBJ, "", "",