diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 33dad50279c8..5668f4678fb5 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,39 +454,49 @@ 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 predicting) { 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) { + 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; @@ -486,10 +505,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=*/!predicting); // TODO: Check if it is triggered from outside (P2P, block validation) and maybe throw an exception // assert (!newQuarterMembers.empty()); @@ -500,7 +520,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 +533,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 +584,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, /*predicting=*/true); + 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 +692,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, + /*predicting=*/false); quorumMembers = q[quorumIndex]; LOCK(cs_indexed_members); diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 4e755c9c6257..01ab95316117 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -16,6 +16,7 @@ #include +#include #include #include @@ -67,6 +68,14 @@ 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) 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); + 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..6a7260523492 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -990,13 +990,30 @@ 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"}, + 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, "", "", + { + GetRpcResult("llmqType"), + 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::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, "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 +1029,10 @@ 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 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}; auto minNextDKG = [](const Consensus::Params& consensusParams, int nTipHeight) { int minDkgWindow{std::numeric_limits::max()}; for (const auto& params: consensusParams.llmqs) { @@ -1023,7 +1043,114 @@ 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}; + 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). + 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()) { + 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. + 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). + 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("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("isMember", is_member); + 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..164f3153e67c 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -2136,6 +2136,26 @@ 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): + # '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"]] + 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["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 +2181,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 +2243,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 +2282,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 +2303,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 +2377,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))