From 62e8beeff424ffe77f00eadddefb32160e974a0d Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Fri, 3 Jul 2026 11:01:13 -0500 Subject: [PATCH 1/2] rpc: expose upcoming DKG participation Add optional proTxHash support to quorum dkginfo and report upcoming DKG sessions whose work block is already available. Use the already-mined work block to predict non-rotated v20 quorum membership, while reporting unknown membership when the future quorum base's deployment state cannot be determined from the current tip. This keeps the RPC from returning known membership based on a V19 state that may change before the future base block. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/llmq/utils.cpp | 60 ++++++++++-- src/llmq/utils.h | 12 +++ src/rpc/quorums.cpp | 112 ++++++++++++++++++++++- test/functional/feature_llmq_evo.py | 42 ++++++++- test/functional/feature_llmq_rotation.py | 89 +++++++++++++++++- 5 files changed, 302 insertions(+), 13 deletions(-) diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 33dad50279c8..85d738899fc1 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 @@ -564,6 +569,45 @@ void BlsCheck::swap(BlsCheck& obj) std::swap(m_id_string, obj.m_id_string); } +std::optional> ComputeNonRotatedQuorumMembersFromWorkBlock( + Consensus::LLMQType llmqType, const CChainParams& chainparams, const CDeterministicMNList& mn_list, + gsl::not_null pWorkBlockIndex, int quorumHeight, + gsl::not_null pDeploymentTipIndex) +{ + const auto& llmq_params_opt = chainparams.GetLLMQ(llmqType); + if (!llmq_params_opt.has_value()) { + ASSERT_IF_DEBUG(false); + return std::nullopt; + } + const auto& llmq_params = llmq_params_opt.value(); + if (llmq_params.useRotation || quorumHeight % llmq_params.dkgInterval != 0) { + ASSERT_IF_DEBUG(false); + return std::nullopt; + } + + if (!DeploymentActiveAfter(pWorkBlockIndex.get(), chainparams.GetConsensus(), Consensus::DEPLOYMENT_V20)) { + // pre-v20 modifier calculation needs the future quorum base block hash, which isn't known yet. + return std::nullopt; + } + + // Canonical member selection gates EvoOnly on the future quorum base block. Buried + // deployments are monotonic and the deployment-tip context is at or before the future + // quorum base block on our chain, so V19 active at the tip implies V19 active there. + // If it is not active at the tip, we cannot know whether it will activate between the + // tip and the future quorum base block, so refuse to predict. + const bool is_platform_llmq = (llmq_params.type == chainparams.GetConsensus().llmqTypePlatform); + const bool v19_active_at_tip = DeploymentActiveAfter(pDeploymentTipIndex.get(), chainparams.GetConsensus(), + Consensus::DEPLOYMENT_V19); + if (is_platform_llmq && !v19_active_at_tip) { + return std::nullopt; + } + const bool EvoOnly = is_platform_llmq && v19_active_at_tip; + + const auto modifier = GetHashModifierFromWorkBlock(llmq_params, pWorkBlockIndex.get()); + + return CalculateQuorum(mn_list, modifier, llmq_params.size, EvoOnly); +} + QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) { static RecursiveMutex cs_members; diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 4e755c9c6257..4e44b20956e4 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -16,10 +16,13 @@ #include +#include #include #include class CBlockIndex; +class CChainParams; +class CDeterministicMNList; class CDeterministicMNManager; class ChainstateManager; class CSporkManager; @@ -67,6 +70,15 @@ 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 non-rotated v20 quorum from its already-known work block, +// before the quorum's own base block exists on chain. Returns std::nullopt when prediction is +// not possible (e.g. V20 not yet active at the work block, or V19 activation state at the +// future quorum base block cannot be confirmed from pDeploymentTipIndex). +std::optional> ComputeNonRotatedQuorumMembersFromWorkBlock( + Consensus::LLMQType llmqType, const CChainParams& chainparams, const CDeterministicMNList& mn_list, + gsl::not_null pWorkBlockIndex, int quorumHeight, + gsl::not_null pDeploymentTipIndex); + 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..5e12560a4eb2 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, i.e. sessions starting within the work-diff depth", + { + {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,92 @@ static RPCHelpMan quorum_dkginfo() }; ret.pushKV("next_dkg", minNextDKG(Params().GetConsensus(), nTipHeight)); + 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& type : llmq::GetEnabledQuorumTypes(chainman, pindexTip)) { + const auto llmq_params_opt = Params().GetLLMQ(type); + CHECK_NONFATAL(llmq_params_opt.has_value()); + const auto& llmq_params = llmq_params_opt.value(); + bool rotation_enabled = llmq::IsQuorumRotationEnabled(llmq_params, pindexTip); + int quorums_num = rotation_enabled ? 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; + } + if (quorumHeight <= nTipHeight || quorumHeight - nTipHeight > llmq::WORK_DIFF_DEPTH) { + 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()); + + if (llmq_params.useRotation) { + obj.pushKV("known", false); + obj.pushKV("reason", "rotated quorum prediction is not exposed yet"); + upcoming.push_back(obj); + continue; + } + + int workHeight = quorumHeight - llmq::WORK_DIFF_DEPTH; + if (workHeight < 0 || workHeight > nTipHeight) { + obj.pushKV("known", false); + obj.pushKV("reason", "work block is not available yet"); + upcoming.push_back(obj); + 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; + } + + auto mnList = CHECK_NONFATAL(node.dmnman)->GetListForBlock(pWorkBlockIndex); + auto predicted_members = llmq::utils::ComputeNonRotatedQuorumMembersFromWorkBlock( + llmq_params.type, Params(), mnList, pWorkBlockIndex, quorumHeight, pindexTip); + if (!predicted_members.has_value()) { + obj.pushKV("known", false); + obj.pushKV("reason", "deployment state at upcoming quorum base block is not yet known"); + 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_evo.py b/test/functional/feature_llmq_evo.py index abafa410e920..6f28756d3dd9 100755 --- a/test/functional/feature_llmq_evo.py +++ b/test/functional/feature_llmq_evo.py @@ -84,8 +84,48 @@ def run_test(self): self.test_masternode_count(expected_mns_count=2, expected_evo_count=i+1) self.dynamically_evo_update_service(evo_info) + self.log.info("Test 'quorum dkginfo' upcoming platform DKG participation uses only EvoNodes") + nodes = [self.nodes[0]] + [mn.get_node(self) for mn in self.mninfo] + # Mirrors llmq_test_platform.dkgInterval (src/llmq/params.h) and + # llmq::WORK_DIFF_DEPTH (src/llmq/snapshot.h): the quorum work block is + # mined work_diff_depth blocks before the quorum height, so mine up to + # that point to make the upcoming DKG predictable. + dkg_interval = 24 + work_diff_depth = 8 + tip = self.nodes[0].getblockcount() + blocks_to_next_dkg = dkg_interval - (tip % dkg_interval) + blocks_to_mine = blocks_to_next_dkg - work_diff_depth + if blocks_to_mine > 0: + self.generate(self.nodes[0], blocks_to_mine, sync_fun=lambda: self.sync_blocks(nodes)) + + tip = self.nodes[0].getblockcount() + expected_quorum_height = tip - (tip % dkg_interval) + dkg_interval + predicted_members = {} + for mn in self.mninfo: + # The RPC reports DKGs within llmq::WORK_DIFF_DEPTH blocks of the + # tip, exactly covering the upcoming DKG. + dkg_info = mn.get_node(self).quorum("dkginfo", mn.proTxHash) + upcoming_platform = [d for d in dkg_info["upcoming_dkgs"] if d["llmqType"] == 106] + assert_equal(len(upcoming_platform), 1) + predicted_platform = upcoming_platform[0] + assert_equal(predicted_platform["llmqTypeName"], "llmq_test_platform") + assert_equal(predicted_platform["quorumIndex"], 0) + assert_equal(predicted_platform["quorumHeight"], expected_quorum_height) + assert_equal(predicted_platform["known"], True) + assert_equal(predicted_platform["memberCount"], 3) + predicted_members[mn.proTxHash] = predicted_platform["isMember"] + + predicted_member_hashes = set(proTxHash for proTxHash, is_member in predicted_members.items() if is_member) + assert_equal(len(predicted_member_hashes), 3) + assert_equal(predicted_member_hashes.issubset(set(evo_protxhash_list)), True) + self.log.info("Test llmq_platform are formed only with EvoNodes") - for _ in range(3): + quorum_i_hash = self.mine_quorum(llmq_type_name='llmq_test_platform', llmq_type=106) + quorum_info = self.nodes[0].quorum("info", 106, quorum_i_hash) + assert_equal(quorum_info["height"], expected_quorum_height) + assert_equal(predicted_member_hashes, set(extract_quorum_members(quorum_info))) + self.test_quorum_members_are_evo_nodes(quorum_i_hash, llmq_type=106) + for _ in range(2): quorum_i_hash = self.mine_quorum(llmq_type_name='llmq_test_platform', llmq_type=106) self.test_quorum_members_are_evo_nodes(quorum_i_hash, llmq_type=106) diff --git a/test/functional/feature_llmq_rotation.py b/test/functional/feature_llmq_rotation.py index 813016c5b9ef..f683a8bff7e7 100755 --- a/test/functional/feature_llmq_rotation.py +++ b/test/functional/feature_llmq_rotation.py @@ -103,6 +103,30 @@ def run_test(self): assert_equal(dkg_info['next_dkg'], next_dkg) assert_equal(nonzero_dkgs, 4) # 1 quorums 4 nodes + self.log.info("Test 'quorum dkginfo' upcoming_dkgs before v20 (no prediction possible)") + pre_v20_mn = self.mninfo[0] + # upcoming_dkgs only reports DKGs starting within + # llmq::WORK_DIFF_DEPTH blocks of the tip, so mine up to that point. + nodes = [self.nodes[0]] + [mn.get_node(self) for mn in self.mninfo] + work_diff_depth = 8 + tip = self.nodes[0].getblockcount() + blocks_to_mine = 24 - (tip % 24) - work_diff_depth + if blocks_to_mine > 0: + self.generate(self.nodes[0], blocks_to_mine, sync_fun=lambda: self.sync_blocks(nodes)) + tip = self.nodes[0].getblockcount() + next_dkg = 24 - (tip % 24) + dkg_info = pre_v20_mn.get_node(self).quorum("dkginfo", pre_v20_mn.proTxHash) + # old fields must remain unchanged next to the new upcoming_dkgs array + assert_equal(dkg_info['next_dkg'], next_dkg) + upcoming_dkgs = dkg_info['upcoming_dkgs'] + assert_greater_than_or_equal(len(upcoming_dkgs), 1) + for entry in upcoming_dkgs: + assert_equal(entry['proTxHash'], pre_v20_mn.proTxHash) + assert_greater_than_or_equal(entry['quorumHeight'], tip + 1) + assert_equal(entry['blocksUntilStart'], entry['quorumHeight'] - tip) + assert_equal(entry['known'], False) + assert 'reason' in entry + expectedDeleted = [] expectedNew = [h_100_0, h_100_1] quorumList = self.test_getmnlistdiff_quorums(b_h_0, b_h_1, {}, expectedDeleted, expectedNew, testQuorumsCLSigs=False) @@ -231,8 +255,12 @@ def run_test(self): hmc_base_blockhash = self.nodes[0].getblockhash(block_count - (block_count % 24) - 24 - 8) best_block_hash = self.nodes[0].getbestblockhash() rpc_qr_info = self.nodes[0].quorum("rotationinfo", best_block_hash, False, [hmc_base_blockhash]) - rpc_qr_info_repeated_base = self.nodes[0].quorum("rotationinfo", best_block_hash, False, - [hmc_base_blockhash, hmc_base_blockhash]) + rpc_qr_info_repeated_base = self.nodes[0].quorum( + "rotationinfo", + best_block_hash, + False, + [hmc_base_blockhash, hmc_base_blockhash], + ) assert_equal(rpc_qr_info_repeated_base, rpc_qr_info) assert_equal(rpc_qr_info["mnListDiffTip"]["blockHash"], best_block_hash) assert_equal(rpc_qr_info["mnListDiffTip"]["baseBlockHash"], rpc_qr_info["mnListDiffH"]["blockHash"]) @@ -244,6 +272,63 @@ def run_test(self): assert_equal(rpc_qr_info["mnListDiffAtHMinus2C"]["baseBlockHash"], rpc_qr_info["mnListDiffAtHMinus3C"]["blockHash"]) assert_equal(rpc_qr_info["mnListDiffAtHMinus3C"]["baseBlockHash"], genesis_blockhash) + self.log.info("Test 'quorum dkginfo' RPC upcoming DKG participation fields") + active_mn = self.mninfo[0] + + # Move close enough to the next non-rotated type-100 DKG that its work block + # (8 blocks before the quorum session starts) is already available. + nodes = [self.nodes[0]] + [mn.get_node(self) for mn in self.mninfo] + dkg_interval_100 = 24 + tip = self.nodes[0].getblockcount() + blocks_to_next_100_dkg = dkg_interval_100 - (tip % dkg_interval_100) + blocks_to_mine = blocks_to_next_100_dkg - 8 + if blocks_to_mine > 0: + self.generate(self.nodes[0], blocks_to_mine, sync_fun=lambda: self.sync_blocks(nodes)) + + tip = self.nodes[0].getblockcount() + expected_quorum_height = tip - (tip % dkg_interval_100) + dkg_interval_100 + + # An empty proTxHash falls back to the local masternode's own proTxHash + dkg_info_default = active_mn.get_node(self).quorum("dkginfo", "") + default_upcoming_100 = [d for d in dkg_info_default["upcoming_dkgs"] if d["llmqType"] == 100] + assert_equal(len(default_upcoming_100), 1) + assert_equal(default_upcoming_100[0]["proTxHash"], active_mn.proTxHash) + + # Rotated quorums cannot be predicted before their snapshots exist + upcoming_all = active_mn.get_node(self).quorum("dkginfo", active_mn.proTxHash)["upcoming_dkgs"] + rotated_entries = [d for d in upcoming_all if d["llmqType"] == llmq_type] + assert_greater_than_or_equal(len(rotated_entries), 1) + for entry in rotated_entries: + assert_equal(entry["known"], False) + assert_equal(entry["reason"], "rotated quorum prediction is not exposed yet") + + predicted_members = {} + for mn in self.mninfo: + dkg_info = mn.get_node(self).quorum("dkginfo", mn.proTxHash) + upcoming_100 = [d for d in dkg_info["upcoming_dkgs"] if d["llmqType"] == 100] + assert_equal(len(upcoming_100), 1) + predicted_100 = upcoming_100[0] + assert_equal(predicted_100["llmqTypeName"], "llmq_test") + assert_equal(predicted_100["quorumIndex"], 0) + assert_equal(predicted_100["quorumHeight"], expected_quorum_height) + assert_equal(predicted_100["blocksUntilStart"], expected_quorum_height - tip) + assert_equal(predicted_100["proTxHash"], mn.proTxHash) + assert_equal(predicted_100["known"], True) + assert_equal(predicted_100["workBlockHeight"], expected_quorum_height - 8) + assert_equal(predicted_100["workBlockHash"], self.nodes[0].getblockhash(expected_quorum_height - 8)) + assert_equal(predicted_100["memberCount"], self.llmq_size) + assert_equal(predicted_100["isMember"], predicted_100["memberIndex"] != -1) + predicted_members[mn.proTxHash] = predicted_100["isMember"] + assert_equal(sum(predicted_members.values()), self.llmq_size) + + self.log.info("Mine the predicted type-100 quorum and compare selected members") + quorum_hash = self.mine_quorum() + quorum_info = self.nodes[0].quorum("info", 100, quorum_hash) + assert_equal(quorum_info["height"], expected_quorum_height) + actual_members = set(extract_quorum_members(quorum_info)) + predicted_member_hashes = set(proTxHash for proTxHash, is_member in predicted_members.items() if is_member) + assert_equal(predicted_member_hashes, actual_members) + def test_getmnlistdiff_quorums(self, baseBlockHash, blockHash, baseQuorumList, expectedDeleted, expectedNew, testQuorumsCLSigs = True): d = self.test_getmnlistdiff_base(baseBlockHash, blockHash, testQuorumsCLSigs) From 8bd59731e550b8f6ad7e2d67a502477bfec645d7 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 6 Jul 2026 20:26:06 -0500 Subject: [PATCH 2/2] rpc: predict rotated DKG participation Extend quorum dkginfo upcoming membership prediction to rotated LLMQs by reusing the known cycle work block, historical quarter snapshots, and an explicit deployment-tip context. Predict the upcoming cycle's DIP0024 state rather than the tip's current cycle state, and return known=false when future deployment state or rotated snapshots are not yet available. The same deployment-tip context is threaded through the rotated helper so V19-dependent skipRemovedMNs behavior matches the future cycle base without claiming certainty before the tip proves it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/llmq/utils.cpp | 127 +++++++++++++++++++++-- src/llmq/utils.h | 8 ++ src/rpc/quorums.cpp | 127 +++++++++++++++++++---- test/functional/feature_llmq_rotation.py | 47 ++++++--- 4 files changed, 264 insertions(+), 45 deletions(-) diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 85d738899fc1..eb090b56bc58 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -342,9 +342,13 @@ 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, + gsl::not_null pDeploymentIndex, + 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 {}; } @@ -354,7 +358,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; @@ -363,7 +366,7 @@ 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(), + bool skipRemovedMNs = DeploymentActiveAfter(pDeploymentIndex.get(), util_params.m_chainman.GetConsensus(), Consensus::DEPLOYMENT_V19) || (util_params.m_chainman.GetParams().NetworkIDString() == CBaseChainParams::TESTNET); @@ -404,7 +407,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)); } @@ -450,14 +453,31 @@ 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; } +std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQParams& llmqParams, + const llmq::UtilParameters& util_params, + const CDeterministicMNList& allMns, + const PreviousQuorumQuarters& previousQuarters) +{ + if (!llmqParams.useRotation || util_params.m_base_index->nHeight % llmqParams.dkgInterval != 0) { + ASSERT_IF_DEBUG(false); + return {}; + } + const auto modifier = GetHashModifier(llmqParams, util_params.m_chainman.GetConsensus(), util_params.m_base_index); + return BuildNewQuorumQuarterMembers(llmqParams, util_params, allMns, previousQuarters, modifier, + util_params.m_base_index, util_params.m_base_index->nHeight, + /*storeSnapshot=*/true); +} + std::vector ComputeQuorumMembersByQuarterRotation(const Consensus::LLMQParams& llmqParams, const llmq::UtilParameters& util_params) { @@ -608,6 +628,95 @@ std::optional> ComputeNonRotatedQuorumMembersF return CalculateQuorum(mn_list, modifier, llmq_params.size, EvoOnly); } +std::optional> ComputeRotatedQuorumMembersFromWorkBlock( + Consensus::LLMQType llmqType, const UtilParameters& util_params, gsl::not_null pWorkBlockIndex, + int quorumHeight, gsl::not_null pDeploymentTipIndex) +{ + 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(); + if (!llmq_params.useRotation) { + ASSERT_IF_DEBUG(false); + return std::nullopt; + } + + const int quorumIndex{quorumHeight % llmq_params.dkgInterval}; + if (quorumIndex < 0 || quorumIndex >= llmq_params.signingActiveQuorumCount) { + ASSERT_IF_DEBUG(false); + return std::nullopt; + } + const int cycleBaseHeight{quorumHeight - quorumIndex}; + if (cycleBaseHeight % llmq_params.dkgInterval != 0 || + pWorkBlockIndex->nHeight != cycleBaseHeight - llmq::WORK_DIFF_DEPTH) { + ASSERT_IF_DEBUG(false); + return std::nullopt; + } + + if (!DeploymentActiveAfter(pWorkBlockIndex.get(), util_params.m_chainman.GetConsensus(), Consensus::DEPLOYMENT_V20)) { + // pre-v20 modifier calculation needs the future cycle base block context, which isn't known yet. + return std::nullopt; + } + + // Canonical BuildNewQuorumQuarterMembers gates skipRemovedMNs on V19 at the future cycle + // base block. Deployments are monotonic and the deployment tip is at or before that base + // on our chain, so V19 active at the tip implies V19 active there. If it is not active at + // the tip, the state at the future cycle base is unknown, so refuse to predict. + const bool v19_active_at_tip = DeploymentActiveAfter(pDeploymentTipIndex.get(), util_params.m_chainman.GetConsensus(), + Consensus::DEPLOYMENT_V19); + if (!v19_active_at_tip && util_params.m_chainman.GetParams().NetworkIDString() != CBaseChainParams::TESTNET) { + return std::nullopt; + } + + const auto nQuorums{static_cast(llmq_params.signingActiveQuorumCount)}; + PreviousQuorumQuarters previousQuarters(nQuorums); + auto prev_cycles{previousQuarters.GetCycles()}; + for (size_t idx{0}; idx < prev_cycles.size(); idx++) { + const int previousCycleHeight{cycleBaseHeight - llmq_params.dkgInterval * static_cast(idx + 1)}; + if (previousCycleHeight < 0) { + return std::nullopt; + } + prev_cycles[idx]->m_cycle_index = pWorkBlockIndex->GetAncestor(previousCycleHeight); + if (prev_cycles[idx]->m_cycle_index == nullptr) { + return std::nullopt; + } + if (auto opt_snap = util_params.m_qsnapman.GetSnapshotForBlock(llmq_params.type, prev_cycles[idx]->m_cycle_index); + opt_snap.has_value()) { + prev_cycles[idx]->m_snap = opt_snap.value(); + } else { + return std::nullopt; + } + prev_cycles[idx]->m_members = GetQuorumQuarterMembersBySnapshot(llmq_params, util_params.m_dmnman, + util_params.m_chainman.GetConsensus(), + prev_cycles[idx]->m_cycle_index, + prev_cycles[idx]->m_snap, + cycleBaseHeight); + } + + CDeterministicMNList allMns = util_params.m_dmnman.GetListForBlock(pWorkBlockIndex); + const auto modifier = GetHashModifierFromWorkBlock(llmq_params, pWorkBlockIndex.get()); + auto newQuarterMembers = BuildNewQuorumQuarterMembers(llmq_params, util_params, allMns, previousQuarters, modifier, + pDeploymentTipIndex, cycleBaseHeight, + /*storeSnapshot=*/false); + if (newQuarterMembers.size() != nQuorums) { + return std::nullopt; + } + + QuorumMembers quorumMembers; + for (auto* prev_cycle : prev_cycles | std::views::reverse) { + quorumMembers.insert(quorumMembers.end(), + prev_cycle->m_members[quorumIndex].begin(), + prev_cycle->m_members[quorumIndex].end()); + } + quorumMembers.insert(quorumMembers.end(), + newQuarterMembers[quorumIndex].begin(), + newQuarterMembers[quorumIndex].end()); + + return quorumMembers; +} + QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) { static RecursiveMutex cs_members; diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 4e44b20956e4..ce2beca7eb79 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -79,6 +79,14 @@ std::optional> ComputeNonRotatedQuorumMembersF gsl::not_null pWorkBlockIndex, int quorumHeight, gsl::not_null pDeploymentTipIndex); +// Predicts the members of a future rotated v20 quorum from its already-known cycle work block, +// before the next cycle base block exists on chain. Returns std::nullopt when the historical +// snapshots needed for quarter rotation are not available, or when the deployment state at the +// future cycle base cannot be confirmed from pDeploymentTipIndex. +std::optional> ComputeRotatedQuorumMembersFromWorkBlock( + Consensus::LLMQType llmqType, const UtilParameters& util_params, gsl::not_null pWorkBlockIndex, + int quorumHeight, gsl::not_null pDeploymentTipIndex); + 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 5e12560a4eb2..2733e0347f8a 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"}, - {RPCResult::Type::ARR, "upcoming_dkgs", /*optional=*/true, "Upcoming DKG sessions for the given proTxHash whose work block is already mined, i.e. sessions starting within the work-diff depth", + {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, "", "", { @@ -1047,6 +1047,48 @@ 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); + } + + const auto& consensus = Params().GetConsensus(); + const bool dip0020_active{quorum_base_height >= consensus.DeploymentHeight(Consensus::DEPLOYMENT_DIP0020)}; + const bool dip0024_active{quorum_base_height >= consensus.DeploymentHeight(Consensus::DEPLOYMENT_DIP0024)}; + + // For future predecessors, only include types whose activation state is deterministic + // from buried deployment heights or already-active versionbits state. + switch (llmq_type) { + case Consensus::LLMQType::LLMQ_DEVNET: + case Consensus::LLMQType::LLMQ_TEST: + case Consensus::LLMQType::LLMQ_TEST_PLATFORM: + case Consensus::LLMQType::LLMQ_400_60: + case Consensus::LLMQType::LLMQ_400_85: + case Consensus::LLMQType::LLMQ_DEVNET_PLATFORM: + return true; + case Consensus::LLMQType::LLMQ_50_60: + case Consensus::LLMQType::LLMQ_TEST_INSTANTSEND: + return chainman.IsQuorumTypeEnabled(llmq_type, pindexTip, dip0024_active, + quorum_base_predecessor_height >= consensus.DIP0024QuorumsHeight); + case Consensus::LLMQType::LLMQ_TEST_V17: + return DeploymentActiveAfter(pindexTip, chainman, Consensus::DEPLOYMENT_TESTDUMMY); + case Consensus::LLMQType::LLMQ_100_67: + return dip0020_active; + case Consensus::LLMQType::LLMQ_25_67: { + constexpr int TESTNET_LLMQ_25_67_ACTIVATION_HEIGHT{847000}; + return quorum_base_predecessor_height >= TESTNET_LLMQ_25_67_ACTIVATION_HEIGHT; + } + case Consensus::LLMQType::LLMQ_60_75: + case Consensus::LLMQType::LLMQ_DEVNET_DIP0024: + case Consensus::LLMQType::LLMQ_TEST_DIP0024: + return dip0024_active; + default: + return false; + } + }; + uint256 proTxHash; if (!request.params[0].isNull() && !request.params[0].get_str().empty()) { proTxHash = ParseHashV(request.params[0], "proTxHash"); @@ -1056,19 +1098,42 @@ static RPCHelpMan quorum_dkginfo() if (!proTxHash.IsNull()) { UniValue upcoming(UniValue::VARR); - for (const auto& type : llmq::GetEnabledQuorumTypes(chainman, pindexTip)) { - const auto llmq_params_opt = Params().GetLLMQ(type); - CHECK_NONFATAL(llmq_params_opt.has_value()); - const auto& llmq_params = llmq_params_opt.value(); - bool rotation_enabled = llmq::IsQuorumRotationEnabled(llmq_params, pindexTip); - int quorums_num = rotation_enabled ? llmq_params.signingActiveQuorumCount : 1; + 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; } - if (quorumHeight <= nTipHeight || quorumHeight - nTipHeight > llmq::WORK_DIFF_DEPTH) { + const int cycleBaseHeight{quorumHeight - quorumIndex}; + if (!quorum_type_known_enabled(llmq_params.type, cycleBaseHeight)) { + continue; + } + + // Determine whether the *upcoming* cycle is rotated. IsQuorumRotationEnabled + // checks DIP0024 at cycleBaseHeight - 1. Use that actual block when it is + // known; otherwise use the deterministic buried DIP0024 activation height. + const Consensus::Params& consensus{Params().GetConsensus()}; + std::optional rotation_predicted; + if (!llmq_params.useRotation) { + rotation_predicted = false; + } else if (cycleBaseHeight < 1) { + rotation_predicted = false; + } else if (const int cycleBasePredecessorHeight{cycleBaseHeight - 1}; + cycleBasePredecessorHeight <= nTipHeight) { + const CBlockIndex* const pCycleBasePredecessor = pindexTip->GetAncestor(cycleBasePredecessorHeight); + CHECK_NONFATAL(pCycleBasePredecessor); + rotation_predicted = DeploymentActiveAfter(pCycleBasePredecessor, consensus, + Consensus::DEPLOYMENT_DIP0024); + } else if (cycleBaseHeight >= consensus.DeploymentHeight(Consensus::DEPLOYMENT_DIP0024)) { + rotation_predicted = true; + } + + if (rotation_predicted.has_value() && !*rotation_predicted && quorumIndex > 0) { continue; } @@ -1080,15 +1145,20 @@ static RPCHelpMan quorum_dkginfo() obj.pushKV("blocksUntilStart", quorumHeight - nTipHeight); obj.pushKV("proTxHash", proTxHash.ToString()); - if (llmq_params.useRotation) { + if (!rotation_predicted.has_value()) { obj.pushKV("known", false); - obj.pushKV("reason", "rotated quorum prediction is not exposed yet"); + obj.pushKV("reason", "DIP0024 activation state at upcoming cycle base is not yet known"); upcoming.push_back(obj); continue; } + // Rotated quorums share the work block of their cycle base, so gate + // availability on the work block height rather than each index's start height + const int workHeight{(*rotation_predicted ? cycleBaseHeight : quorumHeight) - llmq::WORK_DIFF_DEPTH}; + if (workHeight > nTipHeight) { + continue; + } - int workHeight = quorumHeight - llmq::WORK_DIFF_DEPTH; - if (workHeight < 0 || workHeight > nTipHeight) { + if (workHeight < 0) { obj.pushKV("known", false); obj.pushKV("reason", "work block is not available yet"); upcoming.push_back(obj); @@ -1103,14 +1173,31 @@ static RPCHelpMan quorum_dkginfo() continue; } - auto mnList = CHECK_NONFATAL(node.dmnman)->GetListForBlock(pWorkBlockIndex); - auto predicted_members = llmq::utils::ComputeNonRotatedQuorumMembersFromWorkBlock( - llmq_params.type, Params(), mnList, pWorkBlockIndex, quorumHeight, pindexTip); - if (!predicted_members.has_value()) { - obj.pushKV("known", false); - obj.pushKV("reason", "deployment state at upcoming quorum base block is not yet known"); - upcoming.push_back(obj); - continue; + std::optional> predicted_members; + if (*rotation_predicted) { + const LLMQContext& llmq_ctx = EnsureLLMQContext(node); + predicted_members = llmq::utils::ComputeRotatedQuorumMembersFromWorkBlock( + llmq_params.type, + {*CHECK_NONFATAL(node.dmnman), *CHECK_NONFATAL(llmq_ctx.qsnapman), chainman, pindexTip}, + pWorkBlockIndex, quorumHeight, pindexTip); + if (!predicted_members.has_value()) { + obj.pushKV("known", false); + obj.pushKV("reason", "rotated quorum snapshots or deployment state not available"); + upcoming.push_back(obj); + continue; + } + } else { + auto mnList = CHECK_NONFATAL(node.dmnman)->GetListForBlock(pWorkBlockIndex); + predicted_members = llmq::utils::ComputeNonRotatedQuorumMembersFromWorkBlock(llmq_params.type, + Params(), mnList, + pWorkBlockIndex, + quorumHeight, pindexTip); + if (!predicted_members.has_value()) { + obj.pushKV("known", false); + obj.pushKV("reason", "deployment state at upcoming quorum base block is not yet known"); + upcoming.push_back(obj); + continue; + } } const auto& members = *predicted_members; diff --git a/test/functional/feature_llmq_rotation.py b/test/functional/feature_llmq_rotation.py index f683a8bff7e7..afa7d716c66c 100755 --- a/test/functional/feature_llmq_rotation.py +++ b/test/functional/feature_llmq_rotation.py @@ -294,15 +294,8 @@ def run_test(self): assert_equal(len(default_upcoming_100), 1) assert_equal(default_upcoming_100[0]["proTxHash"], active_mn.proTxHash) - # Rotated quorums cannot be predicted before their snapshots exist - upcoming_all = active_mn.get_node(self).quorum("dkginfo", active_mn.proTxHash)["upcoming_dkgs"] - rotated_entries = [d for d in upcoming_all if d["llmqType"] == llmq_type] - assert_greater_than_or_equal(len(rotated_entries), 1) - for entry in rotated_entries: - assert_equal(entry["known"], False) - assert_equal(entry["reason"], "rotated quorum prediction is not exposed yet") - predicted_members = {} + predicted_rotated_members = {0: {}, 1: {}} for mn in self.mninfo: dkg_info = mn.get_node(self).quorum("dkginfo", mn.proTxHash) upcoming_100 = [d for d in dkg_info["upcoming_dkgs"] if d["llmqType"] == 100] @@ -319,15 +312,37 @@ def run_test(self): assert_equal(predicted_100["memberCount"], self.llmq_size) assert_equal(predicted_100["isMember"], predicted_100["memberIndex"] != -1) predicted_members[mn.proTxHash] = predicted_100["isMember"] - assert_equal(sum(predicted_members.values()), self.llmq_size) - self.log.info("Mine the predicted type-100 quorum and compare selected members") - quorum_hash = self.mine_quorum() - quorum_info = self.nodes[0].quorum("info", 100, quorum_hash) - assert_equal(quorum_info["height"], expected_quorum_height) - actual_members = set(extract_quorum_members(quorum_info)) - predicted_member_hashes = set(proTxHash for proTxHash, is_member in predicted_members.items() if is_member) - assert_equal(predicted_member_hashes, actual_members) + upcoming_rotated = [d for d in dkg_info["upcoming_dkgs"] if d["llmqType"] == llmq_type] + # All indices of the upcoming cycle share the cycle base work block, so + # every rotated quorumIndex must be reported once that work block is mined + assert_equal([d["quorumIndex"] for d in upcoming_rotated], [0, 1]) + for predicted_rotated in upcoming_rotated: + quorum_index = predicted_rotated["quorumIndex"] + assert_equal(predicted_rotated["llmqTypeName"], llmq_type_name) + assert_equal(predicted_rotated["quorumHeight"], expected_quorum_height + quorum_index) + assert_equal(predicted_rotated["blocksUntilStart"], expected_quorum_height + quorum_index - tip) + assert_equal(predicted_rotated["proTxHash"], mn.proTxHash) + assert_equal(predicted_rotated["known"], True) + assert_equal(predicted_rotated["workBlockHeight"], expected_quorum_height - 8) + assert_equal(predicted_rotated["workBlockHash"], self.nodes[0].getblockhash(expected_quorum_height - 8)) + assert_equal(predicted_rotated["memberCount"], self.llmq_size_dip0024) + assert_equal(predicted_rotated["isMember"], predicted_rotated["memberIndex"] != -1) + predicted_rotated_members[quorum_index][mn.proTxHash] = predicted_rotated["isMember"] + assert_equal(sum(predicted_members.values()), self.llmq_size) + for quorum_index in [0, 1]: + assert_equal(sum(predicted_rotated_members[quorum_index].values()), self.llmq_size_dip0024) + + self.log.info("Mine the predicted rotated quorums and compare selected members") + quorum_infos_rotated = self.mine_cycle_quorum() + assert_equal(quorum_infos_rotated[0]["height"], expected_quorum_height) + for quorum_index, quorum_info_rotated in enumerate(quorum_infos_rotated): + assert_equal(quorum_info_rotated["quorumIndex"], quorum_index) + actual_rotated_members = set(extract_quorum_members(quorum_info_rotated)) + predicted_rotated_member_hashes = set( + proTxHash for proTxHash, is_member in predicted_rotated_members[quorum_index].items() if is_member + ) + assert_equal(predicted_rotated_member_hashes, actual_rotated_members) def test_getmnlistdiff_quorums(self, baseBlockHash, blockHash, baseQuorumList, expectedDeleted, expectedNew, testQuorumsCLSigs = True): d = self.test_getmnlistdiff_base(baseBlockHash, blockHash, testQuorumsCLSigs)