diff --git a/doc/release-notes-7812.md b/doc/release-notes-7812.md new file mode 100644 index 000000000000..8308dc60c2fc --- /dev/null +++ b/doc/release-notes-7812.md @@ -0,0 +1,9 @@ +Updated RPCs +------------ + +- `quorum dkginfo`: `active_dkgs` now counts the DKG sessions that the given or + local masternode is a member of, from each session's start block through the + end of its Commit phase. Previously it counted local DKG debug records, which + appear only after the DKG worker starts a session and remain after it ends. + Sessions whose membership cannot be determined are counted. Without a + proTxHash, `active_dkgs` is 0. (#7812) diff --git a/src/llmq/debug.cpp b/src/llmq/debug.cpp index 11d81976243e..ed75cd11c57d 100644 --- a/src/llmq/debug.cpp +++ b/src/llmq/debug.cpp @@ -118,11 +118,6 @@ CDKGDebugManager::CDKGDebugManager(CDeterministicMNManager& dmnman, CQuorumSnaps CDKGDebugManager::~CDKGDebugManager() = default; -size_t CDKGDebugManager::GetSessionCount() const -{ - return WITH_LOCK(cs_lockStatus, return localStatus.sessions.size()); -} - UniValue CDKGDebugManager::ToJson(int detailLevel) const { LOCK(cs_lockStatus); diff --git a/src/llmq/debug.h b/src/llmq/debug.h index 85c20c7b4c7f..1a50f7396093 100644 --- a/src/llmq/debug.h +++ b/src/llmq/debug.h @@ -123,8 +123,6 @@ class CDKGDebugManager //! Mark the local session as aborted. void MarkAborted(Consensus::LLMQType llmqType, int quorumIndex) EXCLUSIVE_LOCKS_REQUIRED(!cs_lockStatus); - size_t GetSessionCount() const - EXCLUSIVE_LOCKS_REQUIRED(!cs_lockStatus); [[nodiscard]] static RPCResult GetJsonHelp(const std::string& key, bool optional, bool inner_optional = false); [[nodiscard]] UniValue ToJson(int detailLevel) const EXCLUSIVE_LOCKS_REQUIRED(!cs_lockStatus); diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index 2100cbca2668..e5d811e381c2 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -38,8 +39,10 @@ #include #include #include +#include #include +#include #include #include #include @@ -1011,12 +1014,12 @@ static RPCHelpMan 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."}, + "The proTxHash of the masternode to report current and 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, "active_dkgs", "Number of DKG sessions between their start block and the end of their Commit phase that the masternode is a member of. Sessions whose membership cannot be determined are counted. 0 if there is no proTxHash"}, {RPCResult::Type::NUM, "next_dkg", "The number of blocks until the next potential DKG session"}, 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", @@ -1043,11 +1046,6 @@ static RPCHelpMan quorum_dkginfo() if (!node.active_ctx && !node.observer_ctx) { throw JSONRPCError(RPC_INTERNAL_ERROR, "Only available in masternode or watch-only mode."); } - const auto& dkgdbgman = *(node.active_ctx ? node.active_ctx->dkgdbgman.get() : node.observer_ctx->dkgdbgman.get()); - - UniValue ret(UniValue::VOBJ); - 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()); @@ -1063,7 +1061,6 @@ static RPCHelpMan quorum_dkginfo() } return minDkgWindow; }; - 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}; @@ -1089,86 +1086,110 @@ static RPCHelpMan quorum_dkginfo() proTxHash = node.active_ctx->nodeman->GetProTxHash(); } + struct Participation { + const CBlockIndex* work_block; + std::optional is_member; + const char* unknown_reason{nullptr}; + }; + // Membership of proTxHash in the session starting at quorumHeight, or nullopt if no such + // session exists or its work block is not mined yet + const auto get_participation = [&](const Consensus::LLMQParams& llmq_params, int quorumIndex, + int quorumHeight) -> std::optional { + const int cycleBaseHeight{quorumHeight - quorumIndex}; + if (!quorum_type_known_enabled(llmq_params.type, cycleBaseHeight)) { + return std::nullopt; + } + + // IsQuorumRotationEnabled gates on DIP0024 (a buried deployment) at the block + // preceding the cycle base, so the 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). + if (llmq_params.useRotation && + (cycleBaseHeight < 1 || cycleBaseHeight < consensus.DeploymentHeight(Consensus::DEPLOYMENT_DIP0024))) { + return std::nullopt; + } + + // 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 + const int workHeight{cycleBaseHeight - llmq::WORK_DIFF_DEPTH}; + if (workHeight < 0 || workHeight > nTipHeight) { + return std::nullopt; + } + + const CBlockIndex* const pWorkBlockIndex = pindexTip->GetAncestor(workHeight); + if (!DeploymentActiveAfter(pWorkBlockIndex, consensus, Consensus::DEPLOYMENT_V20)) { + return Participation{pWorkBlockIndex, std::nullopt, "pre-v20 quorum selection needs future quorum base block hash"}; + } + + const LLMQContext& llmq_ctx = EnsureLLMQContext(node); + const auto members = llmq::utils::ComputeQuorumMembersFromWorkBlock( + llmq_params.type, + {*CHECK_NONFATAL(node.dmnman), *CHECK_NONFATAL(llmq_ctx.qsnapman), chainman, pindexTip}, + pWorkBlockIndex, quorumHeight); + if (!members.has_value()) { + return Participation{pWorkBlockIndex, std::nullopt, "rotated quorum snapshots are not available yet"}; + } + return Participation{pWorkBlockIndex, std::ranges::any_of(*members, [&](const auto& dmn) { + return dmn->proTxHash == proTxHash; + })}; + }; + + int active_dkgs{0}; + UniValue upcoming(UniValue::VARR); if (!proTxHash.IsNull()) { - ret.pushKV("proTxHash", proTxHash.ToString()); - UniValue upcoming(UniValue::VARR); 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. + // Evaluate rotation at each session's own cycle base, not the tip's. Iterate every + // possible index for any rotation-capable llmq type and decide per entry. const int quorums_num = llmq_params.useRotation ? llmq_params.signingActiveQuorumCount : 1; + // Members exchange DKG messages from the session's start block through its Commit + // phase. Each rotated index ends on its own schedule, not at the shared mining window. + const int active_blocks{std23::to_underlying(llmq::QuorumPhase::Commit) * llmq_params.dkgPhaseBlocks}; 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 -= llmq_params.dkgInterval; } - const int cycleBaseHeight{quorumHeight - quorumIndex}; - if (!quorum_type_known_enabled(llmq_params.type, cycleBaseHeight)) { - continue; + + // Derived from membership rather than local DKG state, so a session counts + // before the DKG worker initializes it. Unknown membership counts too. + if (nTipHeight - quorumHeight < active_blocks) { + if (const auto current = get_participation(llmq_params, quorumIndex, quorumHeight)) { + active_dkgs += current->is_member.value_or(true); + } } - // 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). - if (llmq_params.useRotation && - (cycleBaseHeight < 1 || cycleBaseHeight < consensus.DeploymentHeight(Consensus::DEPLOYMENT_DIP0024))) { + const int nextQuorumHeight{quorumHeight + llmq_params.dkgInterval}; + const auto next = get_participation(llmq_params, quorumIndex, nextQuorumHeight); + if (!next) { continue; } UniValue obj(UniValue::VOBJ); obj.pushKV("llmqType", static_cast(llmq_params.type)); obj.pushKV("quorumIndex", quorumIndex); - obj.pushKV("quorumHeight", quorumHeight); - obj.pushKV("blocksUntilStart", quorumHeight - nTipHeight); - - // 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, 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); - 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", "rotated quorum snapshots are not available yet"); - upcoming.push_back(obj); - continue; - } - - obj.pushKV("known", true); - - bool is_member{false}; - for (const auto& member : *predicted_members) { - if (member->proTxHash == proTxHash) { - is_member = true; - break; - } + obj.pushKV("quorumHeight", nextQuorumHeight); + obj.pushKV("blocksUntilStart", nextQuorumHeight - nTipHeight); + obj.pushKV("known", next->is_member.has_value()); + if (next->is_member.has_value()) { + obj.pushKV("isMember", *next->is_member); + obj.pushKV("workBlockHeight", next->work_block->nHeight); + obj.pushKV("workBlockHash", next->work_block->GetBlockHash().ToString()); + } else { + obj.pushKV("reason", next->unknown_reason); } - obj.pushKV("isMember", is_member); - obj.pushKV("workBlockHeight", pWorkBlockIndex->nHeight); - obj.pushKV("workBlockHash", pWorkBlockIndex->GetBlockHash().ToString()); upcoming.push_back(obj); } } + } + + UniValue ret(UniValue::VOBJ); + ret.pushKV("active_dkgs", active_dkgs); + ret.pushKV("next_dkg", minNextDKG(consensus, nTipHeight)); + if (!proTxHash.IsNull()) { + ret.pushKV("proTxHash", proTxHash.ToString()); ret.pushKV("upcoming_dkgs", upcoming); } diff --git a/test/functional/feature_llmq_rotation.py b/test/functional/feature_llmq_rotation.py index c709f5f48534..b1f1c3e4947e 100755 --- a/test/functional/feature_llmq_rotation.py +++ b/test/functional/feature_llmq_rotation.py @@ -81,6 +81,7 @@ def run_test(self): llmq_type_name="llmq_test_dip0024" self.test_node = self.nodes[0].add_p2p_connection(TestP2PConn()) + self.test_unknown_active_membership() # Connect all nodes to node1 so that we always have the whole network connected # Otherwise only masternode connections will be established between nodes, which won't propagate TXs/blocks @@ -118,12 +119,13 @@ def run_test(self): tip = self.nodes[0].getblockcount() next_dkg = 24 - (tip % 24) assert next_dkg < 24 - nonzero_dkgs = 0 + stale_sessions = 0 for mn in self.mninfo: dkg_info = mn.get_node(self).quorum("dkginfo") - nonzero_dkgs += dkg_info['active_dkgs'] + assert_equal(dkg_info['active_dkgs'], 0) + stale_sessions += len(mn.get_node(self).quorum("dkgstatus")["session"]) assert_equal(dkg_info['next_dkg'], next_dkg) - assert_equal(nonzero_dkgs, 4) # 1 quorums 4 nodes + assert stale_sessions > 0 # Expired debug sessions do not contribute to the count. expectedDeleted = [] expectedNew = [h_100_0, h_100_1] @@ -279,6 +281,52 @@ def run_test(self): assert_equal(rpc_qr_info["mnListDiffAtHMinus3C"]["baseBlockHash"], genesis_blockhash) self.test_getqrinfo_base_block_hashes_limit(int(best_block_hash, 16), int(hmc_base_blockhash, 16)) + self.test_active_rotated_membership() + + def test_unknown_active_membership(self): + self.log.info("Unknown active membership is counted conservatively before v20") + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 4070908800) + self.wait_for_sporks_same() + tip = self.nodes[0].getblockcount() + start_height = tip + 24 - tip % 24 + assert start_height < self.v20_height + self.generate(self.nodes[0], start_height - tip - 1) + node = self.mninfo[0].get_node(self) + upcoming = node.quorum("dkginfo")["upcoming_dkgs"] + assert upcoming + assert all(d["reason"] == "pre-v20 quorum selection needs future quorum base block hash" for d in upcoming) + self.generate(self.nodes[0], 1) + info = node.quorum("dkginfo") + assert info["active_dkgs"] > 0 + assert_equal(info["active_dkgs"], self.expected_active_dkgs(upcoming, start_height)) + assert_equal(node.quorum("dkgstatus")["session"], []) + self.generate(self.nodes[0], 13) + assert_equal(node.quorum("dkginfo")["active_dkgs"], 0) + + def test_active_rotated_membership(self): + self.log.info("Active rotated membership is counted without DKG initialization") + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 4070908800) + self.wait_for_sporks_same() + node = self.mninfo[0].get_node(self) + tip = self.nodes[0].getblockcount() + start_height = tip + 24 - tip % 24 + self.generate(self.nodes[0], start_height - tip - 1) + upcoming = {mn.proTxHash: node.quorum("dkginfo", mn.proTxHash)["upcoming_dkgs"] for mn in self.mninfo} + + # Each index's Commit phase ends after 10 blocks, independently of the + # shared mining window starting 12 blocks after the cycle base. + for offset in (0, 1, 9, 10, 11, 12, 13): + height = start_height + offset + self.generate(self.nodes[0], height - self.nodes[0].getblockcount()) + counts = [node.quorum("dkginfo", mn.proTxHash)["active_dkgs"] for mn in self.mninfo] + assert_equal(counts, [self.expected_active_dkgs(upcoming[mn.proTxHash], height) for mn in self.mninfo]) + # All other enabled regtest types share the cycle base, so only index 1 remains at offset 10. + if offset == 10: + assert sum(counts) > 0 + elif offset > 10: + assert_equal(sum(counts), 0) + sessions = node.quorum("dkgstatus")["session"] + assert all(d["status"]["quorumHeight"] < start_height for d in sessions) def test_duplicate_rotated_commitment(self, quorum_info, llmq_type): self.log.info("A block mining one rotated commitment twice instead of its sibling is rejected") diff --git a/test/functional/rpc_quorum.py b/test/functional/rpc_quorum.py index 6be534da2d95..3f08ac2d9589 100755 --- a/test/functional/rpc_quorum.py +++ b/test/functional/rpc_quorum.py @@ -25,6 +25,8 @@ def set_test_params(self): self.set_dash_llmq_test_params(1, 1) def run_test(self): + self.test_active_dkg_membership() + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) self.wait_for_sporks_same() self.mine_until_mns_confirmed_for_next_dkg() @@ -37,5 +39,41 @@ def run_test(self): if member["proTxHash"] == mn.proTxHash: assert_equal(member['addresses']['core_p2p'][0], f'127.0.0.1:{mn.nodePort}') + def test_active_dkg_membership(self): + # Keep DKG disabled so no local session record can hide a reporting gap. + # Membership must remain counted for the whole current window anyway. + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 4070908800) + self.wait_for_sporks_same() + self.mine_until_mns_confirmed_for_next_dkg() + mn = self.mninfo[0] + node = mn.get_node(self) + other = "01" * 32 + tip = self.nodes[0].getblockcount() + start_height = tip + 24 - tip % 24 + self.generate(self.nodes[0], start_height - tip - 1) + info = node.quorum("dkginfo") + assert_equal(info["active_dkgs"], 0) + upcoming = info["upcoming_dkgs"] + other_upcoming = node.quorum("dkginfo", other)["upcoming_dkgs"] + [llmq_test] = [d for d in upcoming if d["llmqType"] == 100] + assert_equal(llmq_test["blocksUntilStart"], 1) + assert_equal(llmq_test["known"], True) + assert_equal(llmq_test["isMember"], True) + [other_llmq_test] = [d for d in other_upcoming if d["llmqType"] == 100] + assert_equal(other_llmq_test["known"], True) + assert_equal(other_llmq_test["isMember"], False) + + for offset in (0, 1, 9, 10): + height = start_height + offset + self.generate(self.nodes[0], height - self.nodes[0].getblockcount()) + info = node.quorum("dkginfo") + if offset < 10: + assert info["active_dkgs"] > 0 + assert_equal(info["active_dkgs"], self.expected_active_dkgs(upcoming, height)) + assert_equal(node.quorum("dkginfo", other)["active_dkgs"], self.expected_active_dkgs(other_upcoming, height)) + assert_equal(node.quorum("dkgstatus")["session"], []) + assert all(d["blocksUntilStart"] > 0 for d in info["upcoming_dkgs"]) + + if __name__ == '__main__': RPCMasternodeTest().main() diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index e96dbc09f983..eadf1ba3c127 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -2218,6 +2218,11 @@ def get_upcoming_dkg_predictions(self, node, llmq_type): if d["llmqType"] == llmq_type] for mn in self.mninfo} + def expected_active_dkgs(self, upcoming, height): + # 'quorum dkginfo' active_dkgs at height from earlier upcoming_dkgs entries. Every regtest + # LLMQ type has dkgPhaseBlocks = 2, so a session is active through Commit for 10 blocks. + return sum(0 <= height - d["quorumHeight"] < 10 and (not d["known"] or d["isMember"]) for d in upcoming) + def verify_upcoming_dkg_predictions(self, predictions, quorum_info): # 'quorum info' lists members in member-index order members = [m["proTxHash"] for m in quorum_info["members"]]