Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions doc/release-notes-7812.md
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 0 additions & 5 deletions src/llmq/debug.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 0 additions & 2 deletions src/llmq/debug.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
159 changes: 90 additions & 69 deletions src/rpc/quorums.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <llmq/context.h>
#include <llmq/debug.h>
#include <llmq/dkgsession.h>
#include <llmq/dkgsessionhandler.h>
#include <llmq/observer.h>
#include <llmq/options.h>
#include <llmq/quorumproofs.h>
Expand All @@ -38,8 +39,10 @@
#include <rpc/server_util.h>
#include <rpc/util.h>
#include <util/check.h>
#include <util/std23.h>
#include <validation.h>

#include <algorithm>
#include <iomanip>
#include <map>
#include <optional>
Expand Down Expand Up @@ -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",
Expand All @@ -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());
Expand All @@ -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};
Expand All @@ -1089,86 +1086,110 @@ static RPCHelpMan quorum_dkginfo()
proTxHash = node.active_ctx->nodeman->GetProTxHash();
}

struct Participation {
const CBlockIndex* work_block;
std::optional<bool> 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<Participation> {
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<int>(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);
}

Expand Down
54 changes: 51 additions & 3 deletions test/functional/feature_llmq_rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading