diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 33dad50279c8..eb090b56bc58 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,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 {}; } @@ -349,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; @@ -358,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); @@ -399,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)); } @@ -445,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) { @@ -564,6 +589,134 @@ 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); +} + +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 4e755c9c6257..ce2beca7eb79 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,23 @@ 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); + +// 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 931c775ed9c0..2733e0347f8a 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -990,13 +990,33 @@ static RPCHelpMan quorum_dkginfo() "quorum dkginfo", "Return information regarding DKGs.\n", { - {}, + {"proTxHash", RPCArg::Type::STR_HEX, RPCArg::DefaultHint{"local active masternode proTxHash, if any"}, + "The proTxHash of the masternode to report upcoming DKG participation for. Empty string is treated as the default."}, }, RPCResult{ RPCResult::Type::OBJ, "", "", { {RPCResult::Type::NUM, "active_dkgs", "Total number of active DKG sessions this node is participating in right now"}, {RPCResult::Type::NUM, "next_dkg", "The number of blocks until the next potential DKG session"}, + {RPCResult::Type::ARR, "upcoming_dkgs", /*optional=*/true, "Upcoming DKG sessions for the given proTxHash whose work block is already mined. For rotated quorums all indices in a cycle share the cycle base work block", + { + {RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::NUM, "llmqType", "The type of the quorum"}, + {RPCResult::Type::STR, "llmqTypeName", "The name of the quorum type"}, + {RPCResult::Type::NUM, "quorumIndex", "The quorum index within the DKG interval"}, + {RPCResult::Type::NUM, "quorumHeight", "The height at which the quorum session starts"}, + {RPCResult::Type::NUM, "blocksUntilStart", "The number of blocks until the quorum session starts"}, + {RPCResult::Type::STR_HEX, "proTxHash", "The proTxHash this entry was computed for"}, + {RPCResult::Type::BOOL, "known", "Whether participation could be determined"}, + {RPCResult::Type::STR, "reason", /*optional=*/true, "Why participation could not be determined"}, + {RPCResult::Type::BOOL, "isMember", /*optional=*/true, "Whether the masternode is a member of the upcoming quorum"}, + {RPCResult::Type::NUM, "memberIndex", /*optional=*/true, "The member index, or -1 if not a member"}, + {RPCResult::Type::NUM, "memberCount", /*optional=*/true, "The number of members in the upcoming quorum"}, + {RPCResult::Type::NUM, "workBlockHeight", /*optional=*/true, "The height of the work block used to compute membership"}, + {RPCResult::Type::STR_HEX, "workBlockHash", /*optional=*/true, "The hash of the work block used to compute membership"}, + }}, + }}, } }, RPCExamples{""}, @@ -1012,7 +1032,9 @@ static RPCHelpMan quorum_dkginfo() ret.pushKV("active_dkgs", dkgdbgman.GetSessionCount()); const ChainstateManager& chainman = EnsureChainman(node); - const int nTipHeight{WITH_LOCK(cs_main, return chainman.ActiveChain().Height())}; + const CBlockIndex* const pindexTip = WITH_LOCK(cs_main, return chainman.ActiveChain().Tip()); + CHECK_NONFATAL(pindexTip); + const int nTipHeight{pindexTip->nHeight}; auto minNextDKG = [](const Consensus::Params& consensusParams, int nTipHeight) { int minDkgWindow{std::numeric_limits::max()}; for (const auto& params: consensusParams.llmqs) { @@ -1025,6 +1047,179 @@ 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"); + } else if (node.active_ctx) { + proTxHash = node.active_ctx->nodeman->GetProTxHash(); + } + + if (!proTxHash.IsNull()) { + UniValue upcoming(UniValue::VARR); + for (const auto& llmq_params : Params().GetConsensus().llmqs) { + // Whether a rotated cycle applies at the *upcoming* cycle base is what matters here, + // not whether the tip's own current cycle is rotated. Iterate every possible index + // for any llmq type that supports rotation and decide per-entry below. + const int quorums_num = llmq_params.useRotation ? llmq_params.signingActiveQuorumCount : 1; + + for (const int quorumIndex : util::irange(quorums_num)) { + int quorumHeight = nTipHeight - (nTipHeight % llmq_params.dkgInterval) + quorumIndex; + if (quorumHeight <= nTipHeight) { + quorumHeight += llmq_params.dkgInterval; + } + const int cycleBaseHeight{quorumHeight - quorumIndex}; + if (!quorum_type_known_enabled(llmq_params.type, cycleBaseHeight)) { + continue; + } + + // 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; + } + + 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 (!rotation_predicted.has_value()) { + obj.pushKV("known", false); + 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; + } + + if (workHeight < 0) { + 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; + } + + 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; + + 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..afa7d716c66c 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,78 @@ 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) + + 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] + 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"] + + 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)