diff --git a/doc/release-notes-8074.md b/doc/release-notes-8074.md new file mode 100644 index 000000000000..8633c6f6117a --- /dev/null +++ b/doc/release-notes-8074.md @@ -0,0 +1,45 @@ +Consensus changes +----------------- + +- A rotating LLMQ (DIP0024) cycle that can't fill a quarter, because too few eligible masternodes are left, now + keeps the members it actually selected: the quarter stays short or empty when it is rebuilt from the cycle's + quorum snapshot. Previously, rebuilding a stored short quarter padded it by wrapping around the candidate list, + adding members that were never selected and possibly repeating members of the quorum's other quarters. With too + few enabled masternodes, or when the selection walk made no progress, the builder instead returned empty + quarters without storing a snapshot, and later cycles stopped loading their older quarters at the missing + snapshot. Cycles whose quarters all fill are selected, stored and rebuilt exactly as before. Quorum sizes, + minimum sizes and thresholds are unchanged: the existing `minSize`, DKG and commitment rules still decide + whether a quorum with short quarters forms. +- There is no activation: the new rules apply to every cycle. Mainnet history replays unchanged under them. The + testnet cycles whose selection differs (base blocks at h=838368, h=901152-903168 and h=1366560-1367136) keep + the old rules. Short or empty quarters only occur when very few masternodes are eligible. Older and upgraded + nodes can then derive different quorum members, either in that cycle or when its quarters are reused in later + cycles, and reject each other's commitments, so nodes have to upgrade before such a cycle occurs. +- Devnets and regtest chains that already had a cycle with a short or empty quarter are not supported across + this upgrade and have to be started over. Their stored snapshots, or missing snapshots, can give upgraded nodes + different members than nodes that reindex or sync from scratch, and those can reject historical commitments + whose members change, so reindexing alone is not a guaranteed recovery. + +Notes for SPV and other clients that rebuild rotating quorums from `qrinfo` +------------------------------------------------------------------------------- + +The serialized `CQuorumSnapshot` and `qrinfo` formats don't change, but `mnSkipListMode` gains a value, +`MODE_SHORT_QUARTERS` (4). Snapshots in `MODE_NO_SKIPPING` (0) and `MODE_SKIPPING_ENTRIES` (1) keep their +meaning and are decoded as before. Nodes store `MODE_SHORT_QUARTERS` only for cycles with a short or empty +quarter, so clients must decode it before such a cycle occurs: + +- Candidate list: as before, `activeQuorumMembers` refers to the work block's valid masternodes with a + confirmed registration (non-null `confirmedHash`), sorted by score. The candidates are the unmarked ones + followed by the marked ones, each part in that order. +- Walk: a step counter starts at 0 and counts every candidate visited in the cycle, across all quorum indices + in order. The candidate at step `t` is `list[t % list.size()]`. For each quorum index, a visited candidate is + skipped if `t` is a skip step and added otherwise. An index's quarter ends when it has `quarterSize` members + or after `list.size()` consecutive skips; the next index continues at the next step. An empty candidate list + gives empty quarters and an empty skip list. +- Skip steps: `mnSkipList` holds the steps at which a candidate was skipped. The first entry is the absolute + step of the first skip; each later entry is the positive difference from the previous skip step. A snapshot + is malformed if an entry is negative (or a later entry is zero), if a skip step is never reached by the walk, + or if a quarter would contain the same position twice. + +For cycles they compute successfully, outside predictions, nodes now store a snapshot even when no masternodes +are eligible. The exempt testnet cycles keep the old storage behaviour. diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 9e06cfb41278..685d246f758e 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -158,7 +158,7 @@ static void ReadRegTestBudgetArgs(const ArgsManager& args, CChainParams::RegTest static void ReadRegTestLLMQTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& options, const Consensus::LLMQType llmqType) { - assert(llmqType == Consensus::LLMQType::LLMQ_TEST || llmqType == Consensus::LLMQType::LLMQ_TEST_INSTANTSEND || llmqType == Consensus::LLMQType::LLMQ_TEST_PLATFORM); + assert(llmqType == Consensus::LLMQType::LLMQ_TEST || llmqType == Consensus::LLMQType::LLMQ_TEST_INSTANTSEND || llmqType == Consensus::LLMQType::LLMQ_TEST_PLATFORM || llmqType == Consensus::LLMQType::LLMQ_TEST_DIP0024); std::string cmd_param{"-llmqtestparams"}, llmq_name{"LLMQ_TEST"}; if (llmqType == Consensus::LLMQType::LLMQ_TEST_INSTANTSEND) { @@ -169,6 +169,10 @@ static void ReadRegTestLLMQTestArgs(const ArgsManager& args, CChainParams::RegTe cmd_param = "-llmqtestplatformparams"; llmq_name = "LLMQ_TEST_PLATFORM"; } + if (llmqType == Consensus::LLMQType::LLMQ_TEST_DIP0024) { + cmd_param = "-llmqtestdip0024params"; + llmq_name = "LLMQ_TEST_DIP0024"; + } if (!args.IsArgSet(cmd_param)) return; @@ -188,6 +192,13 @@ static void ReadRegTestLLMQTestArgs(const ArgsManager& args, CChainParams::RegTe throw std::runtime_error(strprintf("Invalid %s size (%d), must not exceed the maximum supported quorum size (%d)", llmq_name, size, Consensus::MAX_LLMQ_SIZE)); } + if (llmqType == Consensus::LLMQType::LLMQ_TEST_DIP0024 && (size <= 0 || size % 4 != 0)) { + throw std::runtime_error(strprintf("Invalid %s size (%d), must be a positive multiple of 4", llmq_name, size)); + } + if (llmqType == Consensus::LLMQType::LLMQ_TEST_DIP0024 && (threshold < 2 || threshold > size)) { + throw std::runtime_error(strprintf("Invalid %s threshold (%d), must be at least 2 and not exceed the size (%d)", + llmq_name, threshold, size)); + } LogPrintf("Setting %s parameters to size=%ld, threshold=%ld\n", llmq_name, size, threshold); options.llmq_test_parameters[llmqType] = CChainParams::LLMQParameters{size, threshold}; } @@ -209,6 +220,7 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti ReadRegTestLLMQTestArgs(args, options, Consensus::LLMQType::LLMQ_TEST); ReadRegTestLLMQTestArgs(args, options, Consensus::LLMQType::LLMQ_TEST_INSTANTSEND); ReadRegTestLLMQTestArgs(args, options, Consensus::LLMQType::LLMQ_TEST_PLATFORM); + ReadRegTestLLMQTestArgs(args, options, Consensus::LLMQType::LLMQ_TEST_DIP0024); ReadRegTestLLMQInstantSendDIP0024Args(args, options); } @@ -342,6 +354,7 @@ void SetupChainParamsOptions(ArgsManager& argsman) argsman.AddArg("-llmqtestinstantsenddip0024=", "Override the default LLMQ type used for InstantSendDIP0024. Used mainly to test Platform. (default: llmq_test_dip0024, regtest-only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); argsman.AddArg("-llmqtestinstantsendparams=:", "Override the default LLMQ size for the LLMQ_TEST_INSTANTSEND quorums (default: 3:2, regtest-only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); argsman.AddArg("-llmqtestparams=:", "Override the default LLMQ size for the LLMQ_TEST quorum (default: 3:2, regtest-only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); + argsman.AddArg("-llmqtestdip0024params=:", "Override the default LLMQ size for the LLMQ_TEST_DIP0024 quorum and set its minSize to (default: size 4, threshold 3, minSize 4, regtest-only; size must be a positive multiple of 4 and threshold between 2 and size)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); argsman.AddArg("-llmqtestplatformparams=:", "Override the default LLMQ size for the LLMQ_TEST_PLATFORM quorum (default: 3:2, regtest-only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); argsman.AddArg("-minimumdifficultyblocks=", "The number of blocks that can be mined with the minimum difficulty at the start of a chain (default: 0, devnet-only)", ArgsManager::ALLOW_ANY, OptionsCategory::CHAINPARAMS); argsman.AddArg("-powtargetspacing=", "Override the default PowTargetSpacing value in seconds (default: 2.5 minutes, devnet-only)", ArgsManager::ALLOW_ANY | ArgsManager::DISALLOW_NEGATION, OptionsCategory::CHAINPARAMS); diff --git a/src/consensus/params.h b/src/consensus/params.h index 59384abc67da..e0312949376e 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -205,6 +205,9 @@ struct Params { LLMQType llmqTypeDIP0024InstantSend{LLMQType::LLMQ_NONE}; LLMQType llmqTypePlatform{LLMQType::LLMQ_NONE}; LLMQType llmqTypeMnhf{LLMQType::LLMQ_NONE}; + //! Base blocks of the rotation cycles that keep the legacy quarter walk: the ones whose quarters it + //! built differently from the step walk + std::vector llmqLegacyRotationCycles; int DeploymentHeight(BuriedDeployment dep) const { diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp index 04cffdba85e8..74697b4ec5f9 100644 --- a/src/evo/snapshot.cpp +++ b/src/evo/snapshot.cpp @@ -306,7 +306,7 @@ void EvoSnapshot::Validate(bool require_canonical_order) const !cycle_hashes.insert(entry.cycle_base_block_hash).second || !historical_hashes.contains(entry.work_block_hash) || entry.snapshot.mnSkipListMode < SnapshotSkipMode::MODE_NO_SKIPPING || - entry.snapshot.mnSkipListMode > SnapshotSkipMode::MODE_ALL_SKIPPED || + entry.snapshot.mnSkipListMode > SnapshotSkipMode::MODE_SHORT_QUARTERS || entry.snapshot.activeQuorumMembers.size() > EVO_SNAPSHOT_MAX_MNS || entry.snapshot.mnSkipList.size() > EVO_SNAPSHOT_MAX_SKIPLIST_ENTRIES || // Only the first entry is an absolute index; later entries are diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 5f894ffeaf29..1036c7808134 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -505,6 +505,20 @@ class CTestNetParams : public CChainParams { consensus.llmqTypeDIP0024InstantSend = Consensus::LLMQType::LLMQ_60_75; consensus.llmqTypePlatform = Consensus::LLMQType::LLMQ_25_67; consensus.llmqTypeMnhf = Consensus::LLMQType::LLMQ_50_60; + consensus.llmqLegacyRotationCycles = { + uint256S("0x000000c2b743330194753dd04612d315c6d6fa82bb2dd8133390d322665652a8"), // 838368 + uint256S("0x0000019b49d8472cf7a61eebcde5b3ae76899fda0eb6977b4c4b89e83a61c5d6"), // 901152 + uint256S("0x0000002ebb49b371fc4048530d4a86120ad7df05494f4f6796486794e074cbb0"), // 901440 + uint256S("0x0000006bf3f118e89b51caabdf52ffbf25307fa3de29dbaaf4fd8885767b4a9b"), // 901728 + uint256S("0x000000c62c4ec85e413a688ab555063cdb20f286b93f2ea30baa57a3ead62c32"), // 902016 + uint256S("0x00000032576582b5908013c96bbccbeecb29266aaa65db56ef2a7a3f52d2e7f1"), // 902304 + uint256S("0x0000008725d64d01965d95ae510dfcc74f00cfec09ac152e0b2e0eb2899b4e41"), // 902592 + uint256S("0x00000130a955271036c1661e1892549d22b0089c03619446066cda443dd44dc8"), // 902880 + uint256S("0x00000273417c2fdb13dcf3c35fcab3ace02605c67bd7b6c37df4f75038c357fc"), // 903168 + uint256S("0x0000007dba91b5abb6003250d435f1e84e28999fa46486f67fb47e5a91eebca3"), // 1366560 + uint256S("0x000000bbf499d2303e41488c13296197a01647d7e1712e64390c8bbbf7e70906"), // 1366848 + uint256S("0x0000017466c3129af87638058f3abc27096061a8b06d0894095ad7b640f46c79"), // 1367136 + }; fDefaultConsistencyChecks = false; fRequireRoutableExternalIP = true; @@ -1027,6 +1041,7 @@ class CRegTestParams : public CChainParams { UpdateLLMQTestParameters(opts, Consensus::LLMQType::LLMQ_TEST); UpdateLLMQTestParameters(opts, Consensus::LLMQType::LLMQ_TEST_INSTANTSEND); UpdateLLMQTestParameters(opts, Consensus::LLMQType::LLMQ_TEST_PLATFORM); + UpdateLLMQTestParameters(opts, Consensus::LLMQType::LLMQ_TEST_DIP0024); UpdateLLMQInstantSendDIP0024(opts); // V20 features for CbTx (credit pool, CL) have no meaning without masternodes assert(consensus.V20Height >= consensus.DIP0003Height); @@ -1147,7 +1162,7 @@ class CRegTestParams : public CChainParams { */ void UpdateLLMQTestParameters(const RegTestOptions& opts, const Consensus::LLMQType llmqType) { - assert(llmqType == Consensus::LLMQType::LLMQ_TEST || llmqType == Consensus::LLMQType::LLMQ_TEST_INSTANTSEND || llmqType == Consensus::LLMQType::LLMQ_TEST_PLATFORM); + assert(llmqType == Consensus::LLMQType::LLMQ_TEST || llmqType == Consensus::LLMQType::LLMQ_TEST_INSTANTSEND || llmqType == Consensus::LLMQType::LLMQ_TEST_PLATFORM || llmqType == Consensus::LLMQType::LLMQ_TEST_DIP0024); const auto it = opts.llmq_test_parameters.find(llmqType); if (it == opts.llmq_test_parameters.end()) return; diff --git a/src/llmq/dkgsession.cpp b/src/llmq/dkgsession.cpp index 848afca9f1ee..1f532d35ac45 100644 --- a/src/llmq/dkgsession.cpp +++ b/src/llmq/dkgsession.cpp @@ -123,7 +123,7 @@ bool CDKGSession::Init(const uint256& _myProTxHash, int _quorumIndex) const CBlockIndex* pCycleQuorumBaseBlockIndex = m_quorum_base_block_index->GetAncestor(cycleQuorumBaseHeight); std::stringstream ss; for (const auto& mn : members) { - ss << mn->dmn->proTxHash.ToString().substr(0, 4) << " | "; + ss << mn->dmn->proTxHash.ToString().substr(0, 16) << " | "; } logger.Batch("DKGComposition h[%d] i[%d] DKG:[%s]", pCycleQuorumBaseBlockIndex->nHeight, quorumIndex, ss.str()); } diff --git a/src/llmq/snapshot.h b/src/llmq/snapshot.h index 725072ad84e6..39516e9f4abd 100644 --- a/src/llmq/snapshot.h +++ b/src/llmq/snapshot.h @@ -32,7 +32,8 @@ enum class SnapshotSkipMode : int { MODE_NO_SKIPPING = 0, MODE_SKIPPING_ENTRIES = 1, MODE_NO_SKIPPING_ENTRIES = 2, - MODE_ALL_SKIPPED = 3 + MODE_ALL_SKIPPED = 3, + MODE_SHORT_QUARTERS = 4, // Some quarter is short or empty; the list holds the walk steps at which a candidate was skipped }; template<> struct is_serializable_enum : std::true_type {}; diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 0d34b0c43dcf..b82da3a0137f 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -21,6 +21,9 @@ #include #include +#include +#include +#include #include #include @@ -30,13 +33,13 @@ std::optional> GetNonNullCoinbaseChainlock(const node::BlockManager& blockman, const CBlockIndex* pindex); namespace { -using QuorumMembers = std::vector; +using llmq::utils::QuorumMembers; std::string ToString(const QuorumMembers& members) { std::stringstream ss; for (const auto& mn : members) { - ss << mn->proTxHash.ToString().substr(0, 4) << "|"; + ss << mn->proTxHash.ToString().substr(0, 16) << "|"; } return ss.str(); } @@ -185,16 +188,18 @@ QuorumMembers CalculateQuorum(List&& mn_list, const uint256& modifier, size_t ma return result; } -std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LLMQParams& llmqParams, - CDeterministicMNManager& dmnman, - const Consensus::Params& consensus_params, - const node::BlockManager& blockman, - const CBlockIndex* pCycleQuorumBaseBlockIndex, - const llmq::CQuorumSnapshot& snapshot, int nHeight) +// nullopt if the snapshot's skip list is malformed +std::optional> GetQuorumQuarterMembersBySnapshot(const Consensus::LLMQParams& llmqParams, + CDeterministicMNManager& dmnman, + const Consensus::Params& consensus_params, + const node::BlockManager& blockman, + const CBlockIndex* pCycleQuorumBaseBlockIndex, + const llmq::CQuorumSnapshot& snapshot, + int nHeight) { if (!llmqParams.useRotation || pCycleQuorumBaseBlockIndex->nHeight % llmqParams.dkgInterval != 0) { ASSERT_IF_DEBUG(false); - return {}; + return std::vector{}; } std::vector sortedCombinedMns; @@ -234,53 +239,36 @@ std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LL std::vector quarterQuorumMembers(numQuorums); - if (sortedCombinedMns.empty()) { + const bool legacy_mode{snapshot.mnSkipListMode == SnapshotSkipMode::MODE_NO_SKIPPING || + snapshot.mnSkipListMode == SnapshotSkipMode::MODE_SKIPPING_ENTRIES}; + if (legacy_mode && sortedCombinedMns.empty()) { return quarterQuorumMembers; } - switch (snapshot.mnSkipListMode) { - case SnapshotSkipMode::MODE_NO_SKIPPING: { - auto itm = sortedCombinedMns.begin(); + const auto map_positions = [&](const std::vector>& positions) { for (const size_t i : util::irange(numQuorums)) { - while (quarterQuorumMembers[i].size() < quarterSize) { - quarterQuorumMembers[i].push_back(*itm); - itm++; - if (itm == sortedCombinedMns.end()) { - itm = sortedCombinedMns.begin(); - } + for (const size_t pos : positions[i]) { + quarterQuorumMembers[i].push_back(sortedCombinedMns[pos]); } } return quarterQuorumMembers; - } - case SnapshotSkipMode::MODE_SKIPPING_ENTRIES: // List holds entries to be skipped - { - size_t first_entry_index{0}; - std::vector processesdSkipList; - for (const auto& s : snapshot.mnSkipList) { - if (first_entry_index == 0) { - first_entry_index = s; - processesdSkipList.push_back(s); - } else { - processesdSkipList.push_back(first_entry_index + s); - } - } + }; - int idx = 0; - auto itsk = processesdSkipList.begin(); - for (const size_t i : util::irange(numQuorums)) { - while (quarterQuorumMembers[i].size() < quarterSize) { - if (itsk != processesdSkipList.end() && idx == *itsk) { - itsk++; - } else { - quarterQuorumMembers[i].push_back(sortedCombinedMns[idx]); - } - idx++; - if (idx == static_cast(sortedCombinedMns.size())) { - idx = 0; - } - } + switch (snapshot.mnSkipListMode) { + case SnapshotSkipMode::MODE_NO_SKIPPING: + return map_positions(llmq::utils::ReplayLegacyQuarters(sortedCombinedMns.size(), numQuorums, quarterSize, {})); + case SnapshotSkipMode::MODE_SKIPPING_ENTRIES: // List holds entries to be skipped + return map_positions(llmq::utils::ReplayLegacyQuarters(sortedCombinedMns.size(), numQuorums, quarterSize, + snapshot.mnSkipList)); + case SnapshotSkipMode::MODE_SHORT_QUARTERS: { + const auto positions = llmq::utils::ReplayNewQuarters(sortedCombinedMns.size(), numQuorums, quarterSize, + snapshot.mnSkipList); + if (!positions) { + LogPrintf("GetQuorumQuarterMembersBySnapshot -- malformed skip list in snapshot, llmqType[%d] h[%d] from[%d]\n", + std23::to_underlying(llmqParams.type), pCycleQuorumBaseBlockIndex->nHeight, nHeight); + return std::nullopt; } - return quarterQuorumMembers; + return map_positions(*positions); } case SnapshotSkipMode::MODE_NO_SKIPPING_ENTRIES: // List holds entries to be kept case SnapshotSkipMode::MODE_ALL_SKIPPED: // Every node was skipped. Returning empty quarterQuorumMembers @@ -341,17 +329,17 @@ 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 uint256& modifier, - int cycleBaseHeight, - bool storeSnapshot) +// nullopt only when the step walk's skip list overflows; no snapshot is stored then +std::optional> BuildNewQuorumQuarterMembers(const Consensus::LLMQParams& llmqParams, + const llmq::UtilParameters& util_params, + const CDeterministicMNList& allMns, + const PreviousQuorumQuarters& previousQuarters, + const uint256& modifier, int cycleBaseHeight, + bool storeSnapshot, bool step_walk) { if (!llmqParams.useRotation || cycleBaseHeight % llmqParams.dkgInterval != 0) { ASSERT_IF_DEBUG(false); - return {}; + return std::vector{}; } size_t nQuorums = static_cast(llmqParams.signingActiveQuorumCount); @@ -360,7 +348,7 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar size_t quorumSize = static_cast(llmqParams.size); auto quarterSize{quorumSize / 4}; - if (allMns.GetCounts().enabled() < quarterSize) { + if (!step_walk && allMns.GetCounts().enabled() < quarterSize) { return quarterQuorumMembers; } @@ -414,140 +402,248 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar ToString(sortedCombinedMnsList)); } - std::vector skipList; + std::vector usedCounts; + usedCounts.reserve(nQuorums); + for (const size_t i : util::irange(nQuorums)) { + usedCounts.push_back(MnsUsedAtHIndexed[i].GetCounts().total()); + } + + const auto can_add = [&](size_t i, size_t pos) { + if (MnsUsedAtHIndexed[i].HasMN(sortedCombinedMnsList[pos]->proTxHash)) return false; + try { + // NOTE: AddMN is the one that can throw exceptions, must be exicuted first + MnsUsedAtHIndexed[i].AddMN(sortedCombinedMnsList[pos]); + return true; + } catch (const std::runtime_error& e) { + return false; + } + }; + std::optional walk; + if (step_walk) { + walk = llmq::utils::WalkNewQuarters(sortedCombinedMnsList.size(), nQuorums, quarterSize, can_add); + if (!walk) { + LogPrintf("%s -- skip list overflow, llmqType[%d] h[%d]\n", __func__, std23::to_underlying(llmqParams.type), + cycleBaseHeight); + return std::nullopt; + } + } else { + walk = llmq::utils::WalkLegacyQuarters(sortedCombinedMnsList.size(), nQuorums, quarterSize, usedCounts, can_add); + if (!walk) { + return std::vector(nQuorums); + } + } + for (const size_t i : util::irange(nQuorums)) { + for (const size_t pos : walk->quarters[i]) { + quarterQuorumMembers[i].push_back(sortedCombinedMnsList[pos]); + } + } + + if (storeSnapshot) { + llmq::CQuorumSnapshot quorumSnapshot{}; + // With every quarter full, the step walk selected what the legacy walk would have, so store what it would + // have stored and the cycle is rebuilt exactly as before + bool short_quarters{false}; + if (step_walk) { + short_quarters = std::ranges::any_of(walk->quarters, [&](const auto& q) { return q.size() < quarterSize; }); + if (!short_quarters) { + walk->skip_list = llmq::utils::LegacySkipListForWalk(sortedCombinedMnsList.size(), *walk); + } + } + BuildQuorumSnapshot(llmqParams, util_params.m_chainman.GetConsensus(), util_params.m_chainman.m_blockman, allMns, + MnsUsedAtH, quorumSnapshot, walk->skip_list, util_params.m_base_index); + if (short_quarters) quorumSnapshot.mnSkipListMode = SnapshotSkipMode::MODE_SHORT_QUARTERS; + util_params.m_qsnapman.StoreSnapshotForBlock(llmqParams.type, util_params.m_base_index, quorumSnapshot); + } + + return quarterQuorumMembers; +} + +std::vector ComputeQuorumMembersByQuarterRotation(const Consensus::LLMQParams& llmqParams, + const llmq::UtilParameters& util_params, + gsl::not_null pWorkBlockIndex, + int cycleBaseHeight, const uint256& modifier, + bool predicting) +{ + auto quarters = llmq::utils::ComputeRotationQuarters(llmqParams, util_params, pWorkBlockIndex, cycleBaseHeight, + modifier, predicting); + std::vector quorumMembers(quarters.size()); + for (const size_t i : util::irange(quarters.size())) { + for (auto& quarter : quarters[i]) { + std::move(quarter.begin(), quarter.end(), std::back_inserter(quorumMembers[i])); + } + if (LogAcceptDebug(BCLog::LLMQ)) { + LogPrint(BCLog::LLMQ, "QuorumComposition h[%d] i[%d]: [%s]\n", cycleBaseHeight, i, + ToString(quorumMembers[i])); + } + } + + return quorumMembers; +} +} // anonymous namespace + +namespace llmq { +namespace utils { +std::optional WalkLegacyQuarters(size_t list_size, size_t quorum_count, size_t quarter_size, + const std::vector& used_counts, + const std::function& can_add) +{ + QuarterWalkResult result; + result.quarters.resize(quorum_count); size_t firstSkippedIndex = 0; size_t idx{0}; - for (const size_t i : util::irange(nQuorums)) { - auto usedMNsCount = MnsUsedAtHIndexed[i].GetCounts().total(); + for (const size_t i : util::irange(quorum_count)) { + auto usedMNsCount = used_counts[i]; bool updated{false}; size_t initial_loop_idx = idx; - while (quarterQuorumMembers[i].size() < quarterSize && (usedMNsCount + quarterQuorumMembers[i].size() < sortedCombinedMnsList.size())) { + while (result.quarters[i].size() < quarter_size && (usedMNsCount + result.quarters[i].size() < list_size)) { bool skip{true}; - if (!MnsUsedAtHIndexed[i].HasMN(sortedCombinedMnsList[idx]->proTxHash)) { - try { - // NOTE: AddMN is the one that can throw exceptions, must be exicuted first - MnsUsedAtHIndexed[i].AddMN(sortedCombinedMnsList[idx]); - quarterQuorumMembers[i].push_back(sortedCombinedMnsList[idx]); - updated = true; - skip = false; - } catch (const std::runtime_error& e) { - } + if (can_add(i, idx)) { + result.quarters[i].push_back(idx); + updated = true; + skip = false; } if (skip) { if (firstSkippedIndex == 0) { firstSkippedIndex = idx; - skipList.push_back(idx); + result.skip_list.push_back(idx); } else { - skipList.push_back(idx - firstSkippedIndex); + result.skip_list.push_back( + static_cast(static_cast(idx) - static_cast(firstSkippedIndex))); } } - if (++idx == sortedCombinedMnsList.size()) { + if (++idx == list_size) { idx = 0; } if (idx == initial_loop_idx) { // we made full "while" loop if (!updated) { // there are not enough MNs, there is nothing we can do here - return std::vector(nQuorums); + return std::nullopt; } // reset and try again updated = false; } } } - - if (storeSnapshot) { - llmq::CQuorumSnapshot quorumSnapshot{}; - BuildQuorumSnapshot(llmqParams, util_params.m_chainman.GetConsensus(), util_params.m_chainman.m_blockman, allMns, - MnsUsedAtH, quorumSnapshot, skipList, util_params.m_base_index); - util_params.m_qsnapman.StoreSnapshotForBlock(llmqParams.type, util_params.m_base_index, quorumSnapshot); - } - - return quarterQuorumMembers; + return result; } -// 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, - gsl::not_null pWorkBlockIndex, - int cycleBaseHeight, const uint256& modifier, - bool predicting) +std::vector> ReplayLegacyQuarters(size_t list_size, size_t quorum_count, size_t quarter_size, + Span skip_list) { - const int cycleLength = llmqParams.dkgInterval; - if (!llmqParams.useRotation || cycleBaseHeight % llmqParams.dkgInterval != 0) { - ASSERT_IF_DEBUG(false); - return {}; + int64_t first_entry_index{0}; + std::vector processesdSkipList; + for (const auto& s : skip_list) { + if (first_entry_index == 0) { + first_entry_index = s; + processesdSkipList.push_back(s); + } else { + processesdSkipList.push_back(static_cast(first_entry_index + s)); + } } - const auto nQuorums{static_cast(llmqParams.signingActiveQuorumCount)}; - CDeterministicMNList allMns = util_params.m_dmnman.GetListForBlock(pWorkBlockIndex); - LogPrint(BCLog::LLMQ, "ComputeQuorumMembersByQuarterRotation llmqType[%d] nHeight[%d] allMns[%d]\n", - 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 = pWorkBlockIndex->GetAncestor(cycleBaseHeight - (cycleLength * (idx + 1))); - if (prev_cycles[idx]->m_cycle_index == nullptr) { - if (predicting) return {}; - break; + std::vector> quarters(quorum_count); + int idx = 0; + auto itsk = processesdSkipList.begin(); + for (const size_t i : util::irange(quorum_count)) { + while (quarters[i].size() < quarter_size) { + if (itsk != processesdSkipList.end() && idx == *itsk) { + itsk++; + } else { + quarters[i].push_back(idx); + } + idx++; + if (idx == static_cast(list_size)) { + idx = 0; + } } - 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; + } + return quarters; +} + +std::optional WalkNewQuarters(size_t list_size, size_t quorum_count, size_t quarter_size, + const std::function& can_add) +{ + QuarterWalkResult result; + result.quarters.resize(quorum_count); + size_t step{0}; + size_t prev_skip_step{0}; + for (const size_t i : util::irange(quorum_count)) { + size_t consecutive_skips{0}; + while (list_size > 0 && result.quarters[i].size() < quarter_size && consecutive_skips < list_size) { + const size_t pos = step % list_size; + if (can_add(i, pos)) { + result.quarters[i].push_back(pos); + consecutive_skips = 0; + } else { + const size_t delta = step - prev_skip_step; + if (delta > static_cast(std::numeric_limits::max())) return std::nullopt; + result.skip_list.push_back(static_cast(delta)); + prev_skip_step = step; + ++consecutive_skips; + } + ++step; } - prev_cycles[idx]->m_members = GetQuorumQuarterMembersBySnapshot(llmqParams, util_params.m_dmnman, - util_params.m_chainman.GetConsensus(), - util_params.m_chainman.m_blockman, - prev_cycles[idx]->m_cycle_index, - prev_cycles[idx]->m_snap, - cycleBaseHeight); } + return result; +} - 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()); +std::optional>> ReplayNewQuarters(size_t list_size, size_t quorum_count, + size_t quarter_size, Span skip_list) +{ + std::vector skip_steps; + skip_steps.reserve(skip_list.size()); + for (const int entry : skip_list) { + if (skip_steps.empty() ? entry < 0 : entry <= 0) return std::nullopt; + const size_t delta = static_cast(entry); + if (!skip_steps.empty() && delta > std::numeric_limits::max() - skip_steps.back()) return std::nullopt; + skip_steps.push_back(skip_steps.empty() ? delta : skip_steps.back() + delta); + } - if (LogAcceptDebug(BCLog::LLMQ)) { - for (const size_t i : util::irange(nQuorums)) { - std::stringstream ss; - for (size_t idx = prev_cycles.size(); idx > 0; --idx) { - const size_t cycle = idx - 1; - ss << strprintf(" %dCmns[%s]", cycle, ToString(prev_cycles[cycle]->m_members[i])); + std::vector> quarters(quorum_count); + size_t step{0}; + size_t next_skip{0}; + for (const size_t i : util::irange(quorum_count)) { + size_t consecutive_skips{0}; + std::vector in_quarter(list_size, false); + while (list_size > 0 && quarters[i].size() < quarter_size && consecutive_skips < list_size) { + if (next_skip < skip_steps.size() && skip_steps[next_skip] == step) { + ++next_skip; + ++consecutive_skips; + } else { + const size_t pos = step % list_size; + if (in_quarter[pos]) return std::nullopt; + in_quarter[pos] = true; + quarters[i].push_back(pos); + consecutive_skips = 0; } - ss << strprintf(" new[%s]", ToString(newQuarterMembers[i])); - LogPrint(BCLog::LLMQ, "QuarterComposition h[%d] i[%d]:%s\n", cycleBaseHeight, i, ss.str()); + ++step; } } + if (next_skip != skip_steps.size()) return std::nullopt; + return quarters; +} - std::vector quorumMembers(nQuorums); - for (const size_t i : util::irange(nQuorums)) { - // Move elements from previous quarters into quorumMembers - for (auto* prev_cycle : prev_cycles | std::views::reverse) { - std::move(prev_cycle->m_members[i].begin(), prev_cycle->m_members[i].end(), - std::back_inserter(quorumMembers[i])); - } - 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", cycleBaseHeight, i, - ToString(quorumMembers[i])); +std::vector LegacySkipListForWalk(size_t list_size, const QuarterWalkResult& walk) +{ + // Same encoding as WalkLegacyQuarters: the first skipped position, then positions relative to it + std::vector legacy; + legacy.reserve(walk.skip_list.size()); + size_t first_skipped{0}; + size_t step{0}; + for (size_t k{0}; k < walk.skip_list.size(); ++k) { + step = k == 0 ? static_cast(walk.skip_list[k]) : step + static_cast(walk.skip_list[k]); + const size_t pos{step % list_size}; + if (first_skipped == 0) { + first_skipped = pos; + legacy.push_back(static_cast(pos)); + } else { + legacy.push_back(static_cast(static_cast(pos) - static_cast(first_skipped))); } } - - return quorumMembers; + return legacy; } -} // anonymous namespace -namespace llmq { -namespace utils { BlsCheck::BlsCheck() = default; BlsCheck::BlsCheck(CBLSSignature sig, std::vector pubkeys, uint256 msg_hash, std::string id_string) : @@ -588,6 +684,90 @@ void BlsCheck::swap(BlsCheck& obj) noexcept std::swap(m_id_string, obj.m_id_string); } +// Computes the quarters 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. No +// snapshot is stored when predicting a future cycle. +std::vector> ComputeRotationQuarters(const Consensus::LLMQParams& llmqParams, + const UtilParameters& util_params, + gsl::not_null pWorkBlockIndex, + int cycleBaseHeight, const uint256& modifier, + bool predicting) +{ + const int cycleLength = llmqParams.dkgInterval; + if (!llmqParams.useRotation || cycleBaseHeight % llmqParams.dkgInterval != 0) { + ASSERT_IF_DEBUG(false); + return {}; + } + const auto nQuorums{static_cast(llmqParams.signingActiveQuorumCount)}; + const ChainstateManager& chainman{util_params.m_chainman}; + // A predicted cycle's base may not be mined yet, and then it can't be one of the historical legacy cycles + const CBlockIndex* cycle_base{predicting ? util_params.m_base_index->GetAncestor(cycleBaseHeight) + : util_params.m_base_index.get()}; + const bool step_walk{cycle_base == nullptr || !IsLegacyRotationCycle(chainman.GetConsensus(), *cycle_base)}; + + CDeterministicMNList allMns = util_params.m_dmnman.GetListForBlock(pWorkBlockIndex); + LogPrint(BCLog::LLMQ, "ComputeQuorumMembersByQuarterRotation llmqType[%d] nHeight[%d] allMns[%d]\n", + 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 = 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; + } + auto members = GetQuorumQuarterMembersBySnapshot(llmqParams, util_params.m_dmnman, chainman.GetConsensus(), + chainman.m_blockman, prev_cycles[idx]->m_cycle_index, + prev_cycles[idx]->m_snap, cycleBaseHeight); + if (!members) return {}; + prev_cycles[idx]->m_members = std::move(*members); + } + + auto newQuarterMembers = BuildNewQuorumQuarterMembers(llmqParams, util_params, allMns, previousQuarters, modifier, + cycleBaseHeight, /*storeSnapshot=*/!predicting, step_walk); + if (!newQuarterMembers) { + return {}; + } + // TODO: Check if it is triggered from outside (P2P, block validation) and maybe throw an exception + // assert (!newQuarterMembers->empty()); + + if (LogAcceptDebug(BCLog::LLMQ)) { + for (const size_t i : util::irange(nQuorums)) { + std::stringstream ss; + for (size_t idx = prev_cycles.size(); idx > 0; --idx) { + const size_t cycle = idx - 1; + ss << strprintf(" %dCmns[%s]", cycle, ToString(prev_cycles[cycle]->m_members[i])); + } + ss << strprintf(" new[%s]", ToString((*newQuarterMembers)[i])); + LogPrint(BCLog::LLMQ, "QuarterComposition h[%d] i[%d]:%s\n", cycleBaseHeight, i, ss.str()); + } + } + + std::vector> quarters(nQuorums); + for (const size_t i : util::irange(nQuorums)) { + quarters[i] = {std::move(previousQuarters.quarterHMinus3C.m_members[i]), + std::move(previousQuarters.quarterHMinus2C.m_members[i]), + std::move(previousQuarters.quarterHMinusC.m_members[i]), std::move((*newQuarterMembers)[i])}; + } + return quarters; +} + +bool IsLegacyRotationCycle(const Consensus::Params& params, const CBlockIndex& cycle_base) +{ + return std::ranges::find(params.llmqLegacyRotationCycles, cycle_base.GetBlockHash()) != + params.llmqLegacyRotationCycles.end(); +} + std::optional> ComputeQuorumMembersFromWorkBlock( Consensus::LLMQType llmqType, const UtilParameters& util_params, gsl::not_null pWorkBlockIndex, int quorumHeight) @@ -707,6 +887,9 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame auto q = ComputeQuorumMembersByQuarterRotation(llmq_params, util_params.replace_index(pCycleQuorumBaseBlockIndex), pWorkBlockIndex, cycleQuorumBaseHeight, modifier, /*predicting=*/false); + if (q.empty()) { + return {}; + } quorumMembers = q[quorumIndex]; LOCK(cs_indexed_members); diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 65c4c2682808..e669188183f1 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -16,6 +17,8 @@ #include +#include +#include #include #include #include @@ -43,6 +46,49 @@ struct UtilParameters { }; namespace utils { +using QuorumMembers = std::vector; + +struct QuarterWalkResult { + //! Candidate positions chosen for each quorum's new quarter + std::vector> quarters; + //! Skip list in the format stored in CQuorumSnapshot + std::vector skip_list; +}; + +//! Legacy builder loop over positions; returns quarters and the legacy skip list (incl. the +//! firstSkippedIndex == 0 quirk); nullopt where the legacy code returns empty quarters early. +std::optional WalkLegacyQuarters(size_t list_size, size_t quorum_count, size_t quarter_size, + const std::vector& used_counts, + const std::function& can_add); +//! Legacy MODE_SKIPPING_ENTRIES / MODE_NO_SKIPPING rebuild loop over positions. +std::vector> ReplayLegacyQuarters(size_t list_size, size_t quorum_count, size_t quarter_size, + Span skip_list); + +//! Step-encoded walk: skip_list holds the steps (visits across all quorum indices) at which a candidate was +//! skipped, the first absolute and each later one as a positive delta. can_add(quorum_index, position) returns +//! true if the candidate joins that index and must record the addition. nullopt if a delta doesn't fit in int. +std::optional WalkNewQuarters(size_t list_size, size_t quorum_count, size_t quarter_size, + const std::function& can_add); +//! Inverse of WalkNewQuarters; nullopt if skip_list is malformed, not fully consumed or would repeat a +//! position within a quarter. +std::optional>> ReplayNewQuarters(size_t list_size, size_t quorum_count, + size_t quarter_size, Span skip_list); +//! The skip list WalkLegacyQuarters stores for the same walk, given a WalkNewQuarters result whose quarters are +//! all full. ReplayLegacyQuarters doesn't always rebuild such a walk exactly once it wraps around the list, but +//! that is how legacy snapshots have always been rebuilt. +std::vector LegacySkipListForWalk(size_t list_size, const QuarterWalkResult& walk); + +//! Per quorum index, the four quarters in assembly order (H-3C, H-2C, H-C, new). Empty outer vector when +//! the cycle can't be computed (skip list overflow, or a previous cycle's snapshot is malformed). +//! GetAllQuorumMembers flattens this. +std::vector> ComputeRotationQuarters(const Consensus::LLMQParams& llmqParams, + const UtilParameters& util_params, + gsl::not_null pWorkBlockIndex, + int cycleBaseHeight, const uint256& modifier, + bool predicting); +//! Whether the cycle starting at cycle_base was built with the legacy walk, before the step walk replaced it +bool IsLegacyRotationCycle(const Consensus::Params& params, const CBlockIndex& cycle_base); + struct BlsCheck { CBLSSignature m_sig; std::vector m_pubkeys; diff --git a/src/test/llmq_params_tests.cpp b/src/test/llmq_params_tests.cpp index fda9c6fdf58e..2e18c8c6e808 100644 --- a/src/test/llmq_params_tests.cpp +++ b/src/test/llmq_params_tests.cpp @@ -229,6 +229,34 @@ BOOST_AUTO_TEST_CASE(llmq_params_size_override_bounds_test) std::runtime_error); } + // LLMQ_TEST_DIP0024 rotates in quarters, so its size must be a positive multiple of 4 + { + const auto params = ChainParamsWithLLMQOverride(ChainType::REGTEST, "-llmqtestdip0024params", 8, 2); + const auto& llmqs = params->GetConsensus().llmqs; + const auto it = std::ranges::find_if(llmqs, [](const auto& llmq) { return llmq.type == LLMQType::LLMQ_TEST_DIP0024; }); + BOOST_REQUIRE(it != llmqs.end()); + BOOST_CHECK_EQUAL(it->size, 8); + BOOST_CHECK_EQUAL(it->minSize, 2); + BOOST_CHECK_EQUAL(it->threshold, 2); + BOOST_CHECK_EQUAL(it->dkgBadVotesThreshold, 2); + + ArgsManager malformed; + malformed.ForceSetArg("-llmqtestdip0024params", "8"); + BOOST_CHECK_THROW(CreateChainParams(malformed, ChainType::REGTEST), std::runtime_error); + + for (const int bad_size : {0, -4, 6, kMax + 4}) { + BOOST_CHECK_THROW(ChainParamsWithLLMQOverride(ChainType::REGTEST, "-llmqtestdip0024params", bad_size, 2), + std::runtime_error); + } + // the minimum size is the threshold, which a DKG of size members could never reach, and a DKG needs a + // threshold of at least 2 + for (const int bad_threshold : {9, 1, 0, -1}) { + BOOST_CHECK_THROW(ChainParamsWithLLMQOverride(ChainType::REGTEST, "-llmqtestdip0024params", 8, bad_threshold), + std::runtime_error); + } + BOOST_CHECK_NO_THROW(ChainParamsWithLLMQOverride(ChainType::REGTEST, "-llmqtestdip0024params", 8, 8)); + } + // Same bounds for the devnet override const auto devnet_small = ChainParamsWithLLMQOverride(ChainType::DEVNET, "-llmqdevnetparams", 12, 6); BOOST_CHECK_EQUAL(LLMQSize(*devnet_small, LLMQType::LLMQ_DEVNET), 12); diff --git a/src/test/llmq_utils_tests.cpp b/src/test/llmq_utils_tests.cpp index 0978a2e3ba3e..9759db77b95b 100644 --- a/src/test/llmq_utils_tests.cpp +++ b/src/test/llmq_utils_tests.cpp @@ -3,26 +3,40 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include #include +#include #include #include #include #include +#include +#include +#include +#include +#include #include #include #include #include #include #include +#include #include #include #include #include +#include +#include #include +#include +#include +#include #include +#include #include #include @@ -630,4 +644,590 @@ BOOST_AUTO_TEST_CASE(deterministic_connection_symmetry_test) BOOST_CHECK(uniqueResults.size() >= 2 && uniqueResults.size() <= 3); } +BOOST_AUTO_TEST_CASE(legacy_quarter_walk_pinned) +{ + using Positions = std::vector>; + const auto walk = [](size_t list_size, size_t quorums, size_t quarter, const std::vector& used, + const std::set& rejected) { + return llmq::utils::WalkLegacyQuarters(list_size, quorums, quarter, used, + [&](size_t, size_t pos) { return rejected.count(pos) == 0; }); + }; + + // (a) no skips + auto res = walk(6, 2, 2, {0, 0}, {}); + BOOST_REQUIRE(res.has_value()); + BOOST_CHECK(res->quarters == (Positions{{0, 1}, {2, 3}})); + BOOST_CHECK(res->skip_list.empty()); + BOOST_CHECK(llmq::utils::ReplayLegacyQuarters(5, 3, 2, {}) == (Positions{{0, 1}, {2, 3}, {4, 0}})); + + // (b) first skip at position 0 leaves firstSkippedIndex at 0, so the second entry stays absolute + // and becomes the new base; the third is relative to that base + res = walk(8, 1, 4, {0}, {0, 2, 5}); + BOOST_REQUIRE(res.has_value()); + BOOST_CHECK(res->quarters == (Positions{{1, 3, 4, 6}})); + BOOST_CHECK((res->skip_list == std::vector{0, 2, 3})); + // the decoder has the same first_entry_index == 0 quirk and reproduces the positions + BOOST_CHECK(llmq::utils::ReplayLegacyQuarters(8, 1, 4, res->skip_list) == res->quarters); + + // (c) the builder stops early once no unused candidate is left, the replay pads by wrapping + res = walk(5, 2, 3, {0, 3}, {}); + BOOST_REQUIRE(res.has_value()); + BOOST_CHECK(res->quarters == (Positions{{0, 1, 2}, {3, 4}})); + BOOST_CHECK(res->skip_list.empty()); + BOOST_CHECK(llmq::utils::ReplayLegacyQuarters(5, 2, 3, res->skip_list) == (Positions{{0, 1, 2}, {3, 4, 0}})); + + // a full pass without progress gives up + BOOST_CHECK(!walk(3, 1, 2, {0}, {0, 1, 2}).has_value()); + + // (d) serialized snapshot holding (b)'s skip list + llmq::CQuorumSnapshot snapshot{{true, false, true, true, false, false, false, true}, + SnapshotSkipMode::MODE_SKIPPING_ENTRIES, {0, 2, 3}}; + DataStream ss{}; + ss << snapshot; + BOOST_CHECK_EQUAL(HexStr(ss), "01000000" "08" "8d" "03" "00000000" "02000000" "03000000"); +} + +namespace { +using Positions = std::vector>; + +// can_add that rejects positions already used by the index (and records additions) or failing for index 0 +struct StepWalkFixture { + std::vector> used; + std::set failing_for_index0; + + StepWalkFixture(size_t list_size, size_t quorums) : used(quorums, std::vector(list_size, false)) {} + + bool CanAdd(size_t i, size_t pos) + { + if (used[i][pos] || (i == 0 && failing_for_index0.count(pos) != 0)) return false; + used[i][pos] = true; + return true; + } + std::optional Walk(size_t list_size, size_t quorums, size_t quarter) + { + return llmq::utils::WalkNewQuarters(list_size, quorums, quarter, + [this](size_t i, size_t pos) { return CanAdd(i, pos); }); + } +}; +} // namespace + +BOOST_AUTO_TEST_CASE(quarter_walk_roundtrip_exhaustive) +{ + for (size_t list_size = 0; list_size <= 5; ++list_size) { + for (size_t quorums = 1; quorums <= 3; ++quorums) { + for (size_t quarter = 1; quarter <= 2; ++quarter) { + const size_t used_combos = size_t{1} << (list_size * quorums); + for (size_t used_mask = 0; used_mask < used_combos; ++used_mask) { + for (size_t fail_mask = 0; fail_mask < (size_t{1} << list_size); ++fail_mask) { + StepWalkFixture fx(list_size, quorums); + for (size_t i = 0; i < quorums; ++i) { + for (size_t p = 0; p < list_size; ++p) { + fx.used[i][p] = (used_mask >> (i * list_size + p)) & 1; + } + } + for (size_t p = 0; p < list_size; ++p) { + if ((fail_mask >> p) & 1) fx.failing_for_index0.insert(p); + } + const auto walk = fx.Walk(list_size, quorums, quarter); + BOOST_REQUIRE(walk.has_value()); + const auto replayed = llmq::utils::ReplayNewQuarters(list_size, quorums, quarter, walk->skip_list); + BOOST_REQUIRE(replayed.has_value()); + BOOST_CHECK(*replayed == walk->quarters); + BOOST_CHECK_LE(walk->skip_list.size(), 2 * list_size * quorums); + BOOST_REQUIRE_EQUAL(walk->quarters.size(), quorums); + for (size_t i = 0; i < quorums; ++i) { + const auto& q = walk->quarters[i]; + BOOST_CHECK_LE(q.size(), quarter); + BOOST_CHECK_EQUAL(std::set(q.begin(), q.end()).size(), q.size()); + if (q.size() < quarter) { + for (size_t p = 0; p < list_size; ++p) { + // fx.used now includes the additions + BOOST_CHECK(fx.used[i][p] || (i == 0 && fx.failing_for_index0.count(p) != 0)); + } + } + } + } + } + } + } + } +} + +BOOST_AUTO_TEST_CASE(quarter_walk_collision_case) +{ + // the legacy encoding records skips [0] for both of these + StepWalkFixture index1_has_0(1, 2); + index1_has_0.used[1][0] = true; + const auto a = index1_has_0.Walk(1, 2, 1); + BOOST_REQUIRE(a.has_value()); + BOOST_CHECK(a->quarters == (Positions{{0}, {}})); + + StepWalkFixture index0_has_0(1, 2); + index0_has_0.used[0][0] = true; + const auto b = index0_has_0.Walk(1, 2, 1); + BOOST_REQUIRE(b.has_value()); + BOOST_CHECK(b->quarters == (Positions{{}, {0}})); + + BOOST_CHECK(a->skip_list != b->skip_list); + BOOST_CHECK(llmq::utils::ReplayNewQuarters(1, 2, 1, a->skip_list) == a->quarters); + BOOST_CHECK(llmq::utils::ReplayNewQuarters(1, 2, 1, b->skip_list) == b->quarters); +} + +BOOST_AUTO_TEST_CASE(quarter_walk_addmn_failure_short) +{ + StepWalkFixture fx(5, 1); + fx.failing_for_index0 = {1, 2, 3}; + const auto walk = fx.Walk(5, 1, 3); + BOOST_REQUIRE(walk.has_value()); + BOOST_CHECK(walk->quarters == (Positions{{0, 4}})); + BOOST_CHECK(llmq::utils::ReplayNewQuarters(5, 1, 3, walk->skip_list) == walk->quarters); +} + +BOOST_AUTO_TEST_CASE(quarter_walk_empty_list) +{ + StepWalkFixture fx(0, 3); + const auto walk = fx.Walk(0, 3, 2); + BOOST_REQUIRE(walk.has_value()); + BOOST_CHECK(walk->quarters == (Positions{{}, {}, {}})); + BOOST_CHECK(walk->skip_list.empty()); + BOOST_CHECK(llmq::utils::ReplayNewQuarters(0, 3, 2, {}) == walk->quarters); +} + +BOOST_AUTO_TEST_CASE(quarter_walk_replay_rejects_malformed) +{ + const auto replay = [](const std::vector& skips) { + return llmq::utils::ReplayNewQuarters(4, 2, 2, skips); + }; + BOOST_CHECK(!replay({-1}).has_value()); + BOOST_CHECK(!replay({0, 0}).has_value()); + BOOST_CHECK(!replay({3, -1}).has_value()); + BOOST_CHECK(!replay({0, std::numeric_limits::max(), 1}).has_value()); + + // a valid list with one extra trailing entry is not fully consumed + BOOST_CHECK(replay({1}).has_value()); + BOOST_CHECK(!replay({1, 1, 1, 1, 1, 1, 1, 1, 1, 1}).has_value()); + StepWalkFixture fx(4, 2); + fx.failing_for_index0 = {1}; + const auto walk = fx.Walk(4, 2, 2); + BOOST_REQUIRE(walk.has_value()); + BOOST_REQUIRE(!walk->skip_list.empty()); + BOOST_CHECK(llmq::utils::ReplayNewQuarters(4, 2, 2, walk->skip_list).has_value()); + auto extended = walk->skip_list; + extended.push_back(1000); // a skip step the walk never reaches + BOOST_CHECK(!llmq::utils::ReplayNewQuarters(4, 2, 2, extended).has_value()); + + // wrapping around within one quarter would repeat a position, which the walk never does + BOOST_CHECK(!llmq::utils::ReplayNewQuarters(2, 1, 3, {}).has_value()); + BOOST_CHECK(!llmq::utils::ReplayNewQuarters(3, 1, 3, std::vector{1}).has_value()); + BOOST_CHECK(llmq::utils::ReplayNewQuarters(2, 1, 2, {}) == (Positions{{0, 1}})); +} + +BOOST_AUTO_TEST_CASE(quarter_walk_snapshot_roundtrip) +{ + const auto check = [](StepWalkFixture& fx, size_t list_size, size_t quorums, size_t quarter) { + const auto walk = fx.Walk(list_size, quorums, quarter); + BOOST_REQUIRE(walk.has_value()); + BOOST_REQUIRE(!walk->skip_list.empty()); + llmq::CQuorumSnapshot snapshot{std::vector(list_size, true), SnapshotSkipMode::MODE_SHORT_QUARTERS, + walk->skip_list}; + DataStream ss{}; + ss << snapshot; + llmq::CQuorumSnapshot decoded; + ss >> decoded; + BOOST_CHECK(decoded.mnSkipListMode == SnapshotSkipMode::MODE_SHORT_QUARTERS); + const auto replayed = llmq::utils::ReplayNewQuarters(list_size, quorums, quarter, decoded.mnSkipList); + BOOST_REQUIRE(replayed.has_value()); + BOOST_CHECK(*replayed == walk->quarters); + }; + + StepWalkFixture collision(1, 2); + collision.used[0][0] = true; + check(collision, 1, 2, 1); + + StepWalkFixture failure(5, 1); + failure.failing_for_index0 = {1, 2, 3}; + check(failure, 5, 1, 3); +} + +BOOST_AUTO_TEST_CASE(quarter_walk_legacy_skip_list) +{ + // Wherever the legacy walk fills every quarter, the step walk selects the same positions and + // LegacySkipListForWalk gives the skip list the legacy walk stores, including after wrapping around the + // list, where the legacy replay can rebuild something else + size_t full{0}; + size_t lossy{0}; + for (size_t list_size = 1; list_size <= 5; ++list_size) { + for (size_t quorums = 1; quorums <= 3; ++quorums) { + for (size_t quarter = 1; quarter <= 2; ++quarter) { + for (size_t used_mask = 0; used_mask < (size_t{1} << (list_size * quorums)); ++used_mask) { + StepWalkFixture fx(list_size, quorums); + std::vector used_counts(quorums, 0); + for (size_t i = 0; i < quorums; ++i) { + for (size_t p = 0; p < list_size; ++p) { + fx.used[i][p] = (used_mask >> (i * list_size + p)) & 1; + used_counts[i] += fx.used[i][p]; + } + } + StepWalkFixture legacy_fx{fx}; + const auto legacy = llmq::utils::WalkLegacyQuarters(list_size, quorums, quarter, used_counts, + [&](size_t i, size_t pos) { return legacy_fx.CanAdd(i, pos); }); + if (!legacy || std::ranges::any_of(legacy->quarters, [&](const auto& q) { return q.size() < quarter; })) continue; + ++full; + const auto walk = fx.Walk(list_size, quorums, quarter); + BOOST_REQUIRE(walk.has_value()); + BOOST_CHECK(walk->quarters == legacy->quarters); + BOOST_CHECK(llmq::utils::LegacySkipListForWalk(list_size, *walk) == legacy->skip_list); + if (llmq::utils::ReplayLegacyQuarters(list_size, quorums, quarter, legacy->skip_list) != legacy->quarters) ++lossy; + } + } + } + } + BOOST_CHECK_GT(full, 0U); + BOOST_CHECK_GT(lossy, 0U); + + // the smallest lossy case: the only skip is position 0 on the second pass, which the legacy replay + // consumes on the first + StepWalkFixture fx(3, 2); + fx.used[1][0] = true; + const auto walk = fx.Walk(3, 2, 2); + BOOST_REQUIRE(walk.has_value()); + BOOST_CHECK(walk->quarters == (Positions{{0, 1}, {2, 1}})); + const auto legacy_list = llmq::utils::LegacySkipListForWalk(3, *walk); + BOOST_CHECK(legacy_list == std::vector{0}); + BOOST_CHECK(llmq::utils::ReplayLegacyQuarters(3, 2, 2, legacy_list) == (Positions{{1, 2}, {0, 1}})); +} + +namespace { +// Regtest chain with v19/v20 active from height 109 +struct RotationChainSetup : public TestChainSetup { + RotationChainSetup() : + TestChainSetup(103, ChainType::REGTEST, + {"-dip3params=100:500", "-testactivationheight=v19@109", "-testactivationheight=v20@109", + "-testactivationheight=mn_rr@109"}), + chainman(*Assert(m_node.chainman)), + dmnman(*Assert(m_node.dmnman)), + qsnapman(*Assert(Assert(m_node.llmq_ctx)->qsnapman)), + llmq_params(*Assert(Params().GetLLMQ(Consensus::LLMQType::LLMQ_TEST_DIP0024))), + utxos(BuildSimpleUtxoMap(m_coinbase_txns)), + coinbase_pk(GetScriptForRawPubKey(coinbaseKey.GetPubKey())) + { + MineTo(110); + } + + const CBlockIndex* Tip() const { return WITH_LOCK(::cs_main, return chainman.ActiveChain().Tip()); } + + void Mine(const std::vector& txs = {}) + { + CreateAndProcessBlock(txs, coinbase_pk); + dmnman.UpdatedBlockTip(Tip()); + } + + void MineTo(int height) + { + while (Tip()->nHeight < height) + Mine(); + } + + CMutableTransaction RegisterTx(int port, CBLSSecretKey& operator_key) + { + CKey owner_key; + return CreateProRegTx(chainman, utxos, port, GenerateRandomAddress(), coinbaseKey, owner_key, operator_key); + } + + CMutableTransaction RevokeTx(const uint256& pro_tx_hash, const CBLSSecretKey& operator_key) + { + CProUpRevTx pro_tx; + pro_tx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false); + pro_tx.proTxHash = pro_tx_hash; + CMutableTransaction tx; + tx.nVersion = 3; + tx.nType = TRANSACTION_PROVIDER_UPDATE_REVOKE; + const auto spent = FundTransaction(chainman, tx, utxos, coinbase_pk, 1 * COIN); + pro_tx.inputsHash = CalcTxInputsHash(CTransaction(tx)); + pro_tx.sig = operator_key.Sign(::SerializeHash(pro_tx), bls::bls_legacy_scheme); + SetTxPayload(tx, pro_tx); + SignTransaction(tx, spent, coinbaseKey); + return tx; + } + + CMutableTransaction UpServTx(const uint256& pro_tx_hash, const CBLSSecretKey& operator_key, int port) + { + CProUpServTx pro_tx; + pro_tx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false); + pro_tx.netInfo = NetInfoInterface::MakeNetInfo(pro_tx.nVersion); + pro_tx.proTxHash = pro_tx_hash; + BOOST_REQUIRE(pro_tx.netInfo->AddEntry(NetInfoPurpose::CORE_P2P, strprintf("1.1.1.1:%d", port)) == + NetInfoStatus::Success); + CMutableTransaction tx; + tx.nVersion = 3; + tx.nType = TRANSACTION_PROVIDER_UPDATE_SERVICE; + const auto spent = FundTransaction(chainman, tx, utxos, coinbase_pk, 1 * COIN); + pro_tx.inputsHash = CalcTxInputsHash(CTransaction(tx)); + pro_tx.sig = operator_key.Sign(::SerializeHash(pro_tx), bls::bls_legacy_scheme); + SetTxPayload(tx, pro_tx); + SignTransaction(tx, spent, coinbaseKey); + return tx; + } + + std::vector> Quarters(int cycle_height) + { + const CBlockIndex* base = Assert(Tip()->GetAncestor(cycle_height)); + const CBlockIndex* work = Assert(base->GetAncestor(cycle_height - llmq::WORK_DIFF_DEPTH)); + // v20 modifier of a work block without a coinbase chainlock + BOOST_REQUIRE(!GetNonNullCoinbaseChainlock(chainman.m_blockman, work).has_value()); + const uint256 modifier = ::SerializeHash(std::make_pair(llmq_params.type, work->GetBlockHash())); + return llmq::utils::ComputeRotationQuarters(llmq_params, {dmnman, qsnapman, chainman, base}, work, cycle_height, + modifier, /*predicting=*/false); + } + + size_t CountStoredSnapshots() + { + const std::string prefix{"llmq_S"}; + std::unique_ptr it(Assert(m_node.evodb)->GetRawDB().NewIterator()); + size_t count{0}; + for (it->Seek(prefix); it->Valid(); it->Next()) { + std::pair key; + if (!it->GetKey(key) || key.first != prefix) break; + ++count; + } + return count; + } + + ChainstateManager& chainman; + CDeterministicMNManager& dmnman; + llmq::CQuorumSnapshotManager& qsnapman; + const Consensus::LLMQParams llmq_params; + SimpleUTXOMap utxos; + const CScript coinbase_pk; +}; + +std::vector ProTxHashes(const llmq::utils::QuorumMembers& members) +{ + std::vector ret; + for (const auto& dmn : members) + ret.push_back(dmn->proTxHash); + return ret; +} +} // namespace + +BOOST_FIXTURE_TEST_CASE(rotation_builder_matches_rebuild, RotationChainSetup) +{ + // Two masternodes for quarters of one member each, first eligible at cycle 144: by its third cycle + // every index has used both + CBLSSecretKey op1, op2; + Mine({RegisterTx(1, op1), RegisterTx(2, op2)}); + const int cycle{192}; + MineTo(cycle); + + const size_t quarter_size{static_cast(llmq_params.size / 4)}; + const auto built = Quarters(cycle); + BOOST_REQUIRE_EQUAL(built.size(), static_cast(llmq_params.signingActiveQuorumCount)); + BOOST_REQUIRE(std::ranges::any_of(built, [&](const auto& q) { return q[3].size() < quarter_size; })); + const auto snapshot = qsnapman.GetSnapshotForBlock(llmq_params.type, Tip()->GetAncestor(cycle)); + BOOST_REQUIRE(snapshot.has_value()); + BOOST_CHECK(snapshot->mnSkipListMode == SnapshotSkipMode::MODE_SHORT_QUARTERS); + + MineTo(cycle + llmq_params.dkgInterval); + const auto next = Quarters(cycle + llmq_params.dkgInterval); + BOOST_REQUIRE_EQUAL(next.size(), built.size()); + for (size_t i = 0; i < built.size(); ++i) { + const auto expected = ProTxHashes(built[i][3]); + const auto rebuilt = ProTxHashes(next[i][2]); + BOOST_CHECK_EQUAL_COLLECTIONS(rebuilt.begin(), rebuilt.end(), expected.begin(), expected.end()); + } +} + +namespace { +using CycleQuarters = std::vector>; + +// Mines to cycle and builds its quarters; no index may repeat a masternode, and the new quarters of the previous +// cycle, if built, must be rebuilt unchanged +void BuildAndCheckCycle(RotationChainSetup& setup, std::map& built, int cycle) +{ + setup.MineTo(cycle); + auto& quarters = built[cycle] = setup.Quarters(cycle); + BOOST_REQUIRE_EQUAL(quarters.size(), static_cast(setup.llmq_params.signingActiveQuorumCount)); + for (const auto& q : quarters) { + std::vector composition; + for (const auto& quarter : q) { + const auto hashes = ProTxHashes(quarter); + composition.insert(composition.end(), hashes.begin(), hashes.end()); + } + BOOST_CHECK_EQUAL(std::set(composition.begin(), composition.end()).size(), composition.size()); + } + if (const auto prev = built.find(cycle - setup.llmq_params.dkgInterval); prev != built.end()) { + for (size_t i = 0; i < quarters.size(); ++i) { + const auto expected = ProTxHashes(prev->second[i][3]); + const auto rebuilt = ProTxHashes(quarters[i][2]); + BOOST_CHECK_EQUAL_COLLECTIONS(rebuilt.begin(), rebuilt.end(), expected.begin(), expected.end()); + } + } +} +} // namespace + +BOOST_FIXTURE_TEST_CASE(rotation_candidate_conflicting_with_previous_member_is_skipped, RotationChainSetup) +{ + // Three masternodes for quarters of one member each: cycle 144 leaves the third unused. Before cycle 168's + // work block, index 0's member moves to a new address and the unused one takes its former address. The + // unused candidate is visited first at 168, but conflicts with the historical address of index 0's previous + // member, so index 0 skips it, as the legacy walk did. + std::map> mns; + std::vector regs; + for (const int port : {1, 2, 3}) { + CBLSSecretKey op; + regs.push_back(RegisterTx(port, op)); + mns.emplace(regs.back().GetHash(), std::make_pair(port, op)); + } + Mine(regs); + + std::map built; + BuildAndCheckCycle(*this, built, 144); + std::set unused; + for (const auto& [hash, _] : mns) { + unused.insert(hash); + } + for (const auto& q : built[144]) { + BOOST_REQUIRE_EQUAL(q[3].size(), 1U); + unused.erase(q[3][0]->proTxHash); + } + BOOST_REQUIRE_EQUAL(unused.size(), 1U); + const uint256 moved{built[144][0][3][0]->proTxHash}; + const uint256 newcomer{*unused.begin()}; + Mine({UpServTx(moved, mns.at(moved).second, 10)}); + Mine({UpServTx(newcomer, mns.at(newcomer).second, mns.at(moved).first)}); + + BuildAndCheckCycle(*this, built, 168); + BOOST_REQUIRE_EQUAL(built[168][0][3].size(), 1U); + BOOST_CHECK(built[168][0][3][0]->proTxHash != newcomer); + BuildAndCheckCycle(*this, built, 192); +} + +BOOST_FIXTURE_TEST_CASE(rotation_malformed_snapshot_is_unavailable, RotationChainSetup) +{ + // Cycle 192 has short quarters (see rotation_builder_matches_rebuild), so it is stored in MODE_SHORT_QUARTERS + CBLSSecretKey op1, op2; + Mine({RegisterTx(1, op1), RegisterTx(2, op2)}); + const int cycle{192}; + MineTo(cycle); + BOOST_REQUIRE_EQUAL(Quarters(cycle).size(), static_cast(llmq_params.signingActiveQuorumCount)); + const CBlockIndex* base = Tip()->GetAncestor(cycle); + auto snapshot = qsnapman.GetSnapshotForBlock(llmq_params.type, base); + BOOST_REQUIRE(snapshot.has_value()); + BOOST_REQUIRE(snapshot->mnSkipListMode == SnapshotSkipMode::MODE_SHORT_QUARTERS); + + // a negative first step is malformed: the next cycle can't be computed and stores no snapshot + snapshot->mnSkipList = {-1}; + qsnapman.StoreSnapshotForBlock(llmq_params.type, base, *snapshot); + const int next{cycle + llmq_params.dkgInterval}; + MineTo(next); + BOOST_CHECK(Quarters(next).empty()); + BOOST_CHECK(!qsnapman.GetSnapshotForBlock(llmq_params.type, Tip()->GetAncestor(next)).has_value()); +} + +BOOST_FIXTURE_TEST_CASE(rotation_zero_candidates_store_snapshot, RotationChainSetup) +{ + CBLSSecretKey op; + const auto reg = RegisterTx(1, op); + Mine({reg}); + Mine({RevokeTx(reg.GetHash(), op)}); + const int cycle{120}; + MineTo(cycle); + + const CBlockIndex* base = Tip()->GetAncestor(cycle); + const auto work_list = dmnman.GetListForBlock(base->GetAncestor(cycle - llmq::WORK_DIFF_DEPTH)); + BOOST_REQUIRE_EQUAL(work_list.GetCounts().total(), 1U); + BOOST_REQUIRE_EQUAL(work_list.GetCounts().enabled(), 0U); + + const auto quarters = Quarters(cycle); + BOOST_CHECK(qsnapman.GetSnapshotForBlock(llmq_params.type, base).has_value()); + BOOST_REQUIRE_EQUAL(quarters.size(), static_cast(llmq_params.signingActiveQuorumCount)); + for (const auto& q : quarters) { + for (const auto& quarter : q) { + BOOST_CHECK(quarter.empty()); + } + } +} + +BOOST_FIXTURE_TEST_CASE(rotation_prediction_writes_no_snapshot, RotationChainSetup) +{ + CBLSSecretKey op1, op2; + Mine({RegisterTx(1, op1), RegisterTx(2, op2)}); + const int cycle{192}; + MineTo(cycle - llmq::WORK_DIFF_DEPTH); + + const size_t before{CountStoredSnapshots()}; + BOOST_REQUIRE_GT(before, 0U); + const CBlockIndex* work = Tip(); + const auto members = llmq::utils::ComputeQuorumMembersFromWorkBlock(llmq_params.type, + {dmnman, qsnapman, chainman, work}, work, cycle); + BOOST_REQUIRE(members.has_value()); + BOOST_CHECK_EQUAL(CountStoredSnapshots(), before); +} + +namespace { +struct TestnetSetup : public TestingSetup { + TestnetSetup() : TestingSetup(ChainType::TESTNET) {} +}; +} // namespace + +BOOST_FIXTURE_TEST_CASE(rotation_legacy_cycle_dispatch, TestnetSetup) +{ + // A synthetic testnet chain below DIP3, so the work block has no masternodes: the legacy walk returns + // early without a snapshot, the step walk stores a MODE_SHORT_QUARTERS one. Only the cycle base's hash + // decides which runs. + auto& chainman = *Assert(m_node.chainman); + auto& dmnman = *Assert(m_node.dmnman); + auto& qsnapman = *Assert(Assert(m_node.llmq_ctx)->qsnapman); + const auto& consensus = chainman.GetConsensus(); + const Consensus::LLMQParams llmq_params{*Assert(Params().GetLLMQ(consensus.llmqTypeDIP0024InstantSend))}; + const int cycle{llmq_params.dkgInterval}; + BOOST_REQUIRE_LT(cycle, consensus.DIP0003Height); + const auto build = [&](const uint256& base_hash) { + std::vector hashes(cycle + 1); + std::vector chain(cycle + 1); + for (int h = 0; h <= cycle; ++h) { + hashes[h] = h == cycle ? base_hash : ArithToUint256(arith_uint256{static_cast(h) + 1}); + chain[h].nHeight = h; + chain[h].phashBlock = &hashes[h]; + chain[h].pprev = h > 0 ? &chain[h - 1] : nullptr; + chain[h].BuildSkip(); + } + const CBlockIndex* base = &chain[cycle]; + const CBlockIndex* work = Assert(base->GetAncestor(cycle - llmq::WORK_DIFF_DEPTH)); + const auto quarters = llmq::utils::ComputeRotationQuarters(llmq_params, {dmnman, qsnapman, chainman, base}, work, + cycle, uint256::ONE, /*predicting=*/false); + BOOST_REQUIRE_EQUAL(quarters.size(), static_cast(llmq_params.signingActiveQuorumCount)); + for (const auto& q : quarters) { + for (const auto& quarter : q) { + BOOST_CHECK(quarter.empty()); + } + } + return qsnapman.GetSnapshotForBlock(llmq_params.type, base); + }; + + BOOST_CHECK(!build(consensus.llmqLegacyRotationCycles.front()).has_value()); + const auto snapshot = build(ArithToUint256(arith_uint256{1000000})); + BOOST_REQUIRE(snapshot.has_value()); + BOOST_CHECK(snapshot->mnSkipListMode == SnapshotSkipMode::MODE_SHORT_QUARTERS); +} + +BOOST_AUTO_TEST_CASE(rotation_legacy_cycles) +{ + const auto testnet = CreateChainParams(*m_node.args, ChainType::TESTNET); + const auto& legacy = testnet->GetConsensus().llmqLegacyRotationCycles; + BOOST_REQUIRE_EQUAL(legacy.size(), 12U); + CBlockIndex index; + for (const auto& hash : legacy) { + index.phashBlock = &hash; + BOOST_CHECK(llmq::utils::IsLegacyRotationCycle(testnet->GetConsensus(), index)); + } + const uint256 other{uint256::ONE}; + index.phashBlock = &other; + BOOST_CHECK(!llmq::utils::IsLegacyRotationCycle(testnet->GetConsensus(), index)); + const auto mainnet = CreateChainParams(*m_node.args, ChainType::MAIN); + BOOST_CHECK(mainnet->GetConsensus().llmqLegacyRotationCycles.empty()); + index.phashBlock = &legacy.front(); + BOOST_CHECK(!llmq::utils::IsLegacyRotationCycle(mainnet->GetConsensus(), index)); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/feature_llmq_rotation.py b/test/functional/feature_llmq_rotation.py index aef5f2804c6b..8214cae9bede 100755 --- a/test/functional/feature_llmq_rotation.py +++ b/test/functional/feature_llmq_rotation.py @@ -152,7 +152,7 @@ def run_test(self): self.generate(self.nodes[0], 1, sync_fun=lambda: self.sync_blocks(nodes)) self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) - (quorum_info_0_0, quorum_info_0_1) = self.mine_cycle_quorum() + (quorum_info_0_0, quorum_info_0_1) = self.mine_cycle_quorum(require_known_predictions=True) assert self.test_quorum_listextended(quorum_info_0_0, llmq_type_name) assert self.test_quorum_listextended(quorum_info_0_1, llmq_type_name) quorum_members_0_0 = extract_quorum_members(quorum_info_0_0) @@ -173,7 +173,7 @@ def run_test(self): self.log.info("Wait for chainlock") self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) - (quorum_info_1_0, quorum_info_1_1) = self.mine_cycle_quorum() + (quorum_info_1_0, quorum_info_1_1) = self.mine_cycle_quorum(require_known_predictions=True) assert self.test_quorum_listextended(quorum_info_1_0, llmq_type_name) assert self.test_quorum_listextended(quorum_info_1_1, llmq_type_name) quorum_members_1_0 = extract_quorum_members(quorum_info_1_0) @@ -206,7 +206,7 @@ def run_test(self): self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) self.log.info("Mine a quorum to invalidate") - (quorum_info_3_0, quorum_info_3_1) = self.mine_cycle_quorum() + (quorum_info_3_0, quorum_info_3_1) = self.mine_cycle_quorum(require_known_predictions=True) new_quorum_list = self.nodes[0].quorum("list", llmq_type) assert_equal(len(new_quorum_list[llmq_type_name]), len(quorum_list[llmq_type_name]) + 2) diff --git a/test/functional/feature_llmq_rotation_short_quarters.py b/test/functional/feature_llmq_rotation_short_quarters.py new file mode 100755 index 000000000000..5c408c4bbd45 --- /dev/null +++ b/test/functional/feature_llmq_rotation_short_quarters.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test rotating LLMQ quarters that can't be filled. + +Three masternodes can't fill the quarters of an llmq_test_dip0024 of size 8 (quarter size 2), so +cycles keep building short and empty quarters. Every rebuilt quarter has to be the one built, and +a quorum keeps the members it actually got: it forms if they meet minSize (3), else it doesn't. +The result has to survive a reorg and a restart. +""" + +import re + +from test_framework.authproxy import JSONRPCException +from test_framework.masternodes import check_banned +from test_framework.test_framework import DashTestFramework, throttled +from test_framework.util import assert_equal, assert_greater_than, force_finish_mnsync + +LLMQ_TYPE = 103 +LLMQ_NAME = "llmq_test_dip0024" +LLMQ_SIZE = 8 +LLMQ_MIN_SIZE = 3 # -llmqtestdip0024params sets minSize = threshold +QUARTER_SIZE = LLMQ_SIZE // 4 +QUORUM_COUNT = 2 +CYCLE = 24 +WORK_DIFF_DEPTH = 8 +MINING_WINDOW_END = 20 +MODE_SHORT_QUARTERS = 4 +SPORK_OFF = 4070908800 +# proTxHash characters in the composition logs +LOG_PREFIX_LEN = 16 + +LOG_ENTRY_START = re.compile(r"^\d{4}-\d{2}-\d{2}T") +QUARTER_COMPOSITION = re.compile( + r"QuarterComposition h\[(\d+)\] i\[(\d+)\]: 2Cmns\[([^\]]*)\] 1Cmns\[([^\]]*)\] 0Cmns\[([^\]]*)\] new\[([^\]]*)\]") +CANDIDATES = re.compile(r"BuildNewQuorumQuarterMembers h\[(\d+)\] sortedCombinedMns\[([^\]]*)\]") +DKG_COMPOSITION = re.compile(r"DKGComposition h\[(\d+)\] i\[(\d+)\] DKG:\[([^\]]*)\]") +RECEIVED = {2: "receivedContributions", 3: "receivedComplaints", 4: "receivedJustifications", 5: "receivedPrematureCommitments"} +SENT = {"receivedContributions": "sentContributions", "receivedComplaints": "sentComplaint", + "receivedJustifications": "sentJustification", "receivedPrematureCommitments": "sentPrematureCommitment"} + + +def split_prefixes(text, separator): + return [p.strip() for p in text.split(separator) if p.strip()] + + +class LogScanner: + """Collects the debug.log entries of a node that contain marker. A batched log call (CBatchedLogger) + prefixes only its first line, so an entry is a prefixed line plus the unprefixed lines after it. + An entry is complete once the next one has started.""" + + def __init__(self, path, marker): + self.path = path + self.marker = marker + self.offset = 0 + self.pending = None + self.entries = [] + + def scan(self): + with open(self.path, "rb") as f: + f.seek(self.offset) + data = f.read() + end = data.rfind(b"\n") + 1 + self.offset += end + for line in data[:end].decode("utf-8", errors="replace").splitlines(): + if LOG_ENTRY_START.match(line): + if self.pending is not None and self.marker in self.pending[0]: + self.entries.append("\n".join(self.pending)) + self.pending = [line] + elif self.pending is not None: + self.pending.append(line) + return self.entries + + +class LLMQRotationShortQuartersTest(DashTestFramework): + def add_options(self, parser): + self.add_wallet_options(parser) + + def set_test_params(self): + extra_args = [[f"-llmqtestdip0024params={LLMQ_SIZE}:{LLMQ_MIN_SIZE}"] for _ in range(4)] + self.set_dash_test_params(4, 3, extra_args=extra_args) + self.llmq_size_dip0024 = LLMQ_SIZE + self.llmq_min_size_dip0024 = LLMQ_MIN_SIZE + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + + # ---- log based views of the member selection ---- + + def prefix_map(self): + """The 16-character proTxHash prefixes used in logs, mapped to the registered proTxHashes""" + prefixes = {mn.proTxHash[:LOG_PREFIX_LEN]: mn.proTxHash for mn in self.mninfo} + assert_equal(len(prefixes), len(self.mninfo)) + return prefixes + + def quarter_logs(self): + """(cycle, index) -> every distinct (2C, 1C, 0C, new) composition node0 logged for it, in proTxHashes""" + result = {} + for entry in self.quarter_scanner.scan(): + m = QUARTER_COMPOSITION.search(entry) + assert m is not None, entry + quarters = tuple(tuple(self.prefixes[p] for p in split_prefixes(m.group(g), "|")) for g in range(3, 7)) + result.setdefault((int(m.group(1)), int(m.group(2))), []) + if quarters not in result[(int(m.group(1)), int(m.group(2)))]: + result[(int(m.group(1)), int(m.group(2)))].append(quarters) + return result + + def cycle_quarters(self, cycle): + """Per index, the quarters of the cycle's quorum: built 3, 2 and 1 cycles before and the new one""" + logs = self.quarter_logs() + result = [] + for i in range(QUORUM_COUNT): + assert (cycle, i) in logs, f"no QuarterComposition h[{cycle}] i[{i}]" + # every computation of a cycle (block processing, cold caches, reorgs) must agree + assert_equal(len(logs[(cycle, i)]), 1) + result.append(logs[(cycle, i)][0]) + return result + + def built_quarters(self, cycle): + """Per index, the quarter the builder selected for the cycle""" + return [q[3] for q in self.cycle_quarters(cycle)] + + def dkg_logs(self): + """(cycle, index) -> (members, aborted) of the DKG the logging masternode initialized""" + result = {} + for entry in self.dkg_scanner.scan(): + m = DKG_COMPOSITION.search(entry) + assert m is not None, entry + members = [self.prefixes[p] for p in split_prefixes(m.group(3), "|")] + result[(int(m.group(1)), int(m.group(2)))] = (members, "not enough members" in entry) + return result + + def dkg_composition(self, cycle, index): + """The members the DKG of the cycle's quorum index ran with, in member order, and whether its init + was aborted for having fewer than minSize members""" + self.wait_until(lambda: (cycle, index) in self.dkg_logs()) + return self.dkg_logs()[(cycle, index)] + + # ---- chain helpers ---- + + def live_masternodes(self): + return [mn for mn in self.online if not check_banned(self.nodes[0], mn)] + + def mined_quorum(self, quorum_hash): + try: + return self.nodes[0].quorum("info", LLMQ_TYPE, quorum_hash) + except JSONRPCException as e: + assert_equal(e.error["message"], "quorum not found") + return None + + def all_online_nodes(self): + return [self.nodes[0]] + [mn.get_node(self) for mn in self.online] + + def mine_blocks(self, count): + self.move_blocks(self.all_online_nodes(), count) + + def wait_for_masternode_connections(self): + bump = throttled(lambda: self.bump_mocktime(1)) + + def connected(): + bump() + for mn in self.online: + peers = {p.get("verified_proregtx_hash") for p in mn.get_node(self).getpeerinfo()} + if any(other.proTxHash not in peers for other in self.online if other is not mn): + return False + return True + self.wait_until(connected) + + def predictions(self, cycle): + """'quorum dkginfo' entries of both indexes of the cycle for every registered masternode. Asked on a + masternode, so that node0 only logs the member selection of block processing.""" + node = self.mninfo[0].get_node(self) + result = {} + for mn in self.mninfo: + entries = [d for d in node.quorum("dkginfo", mn.proTxHash)["upcoming_dkgs"] if d["llmqType"] == LLMQ_TYPE] + for i in range(QUORUM_COUNT): + [entry] = [d for d in entries if d["quorumHeight"] == cycle + i] + result[(mn.proTxHash, i)] = entry + return result + + # ---- DKG driving ---- + + def wait_for_dkg_connections(self, quorum_hash, rotating_members, cycle_base): + """DKG messages are relayed only once and only to the members connected at that moment, so every live + member has to be connected to its live outbound relay members before it sends anything""" + live = {mn.proTxHash for mn in self.live_masternodes()} + bump = throttled(lambda: self.bump_mocktime(1)) + + def check(): + for mn in self.live_masternodes(): + required = {"llmq_test"} if cycle_base else set() + if mn.proTxHash in rotating_members: + required.add(LLMQ_NAME) + for qc in mn.get_node(self).quorum("dkgstatus")["quorumConnections"]: + if qc.get("quorumHash") != quorum_hash or not qc.get("quorumConnections"): + continue + if qc["llmqType"] == LLMQ_NAME and mn.proTxHash not in rotating_members: + continue + if any(c["outbound"] and not c["connected"] and c["proTxHash"] in live for c in qc["quorumConnections"]): + bump() + return False + required.discard(qc["llmqType"]) + if required: + bump() + return False + return True + self.wait_until(check) + + def wait_for_dkg_phase(self, quorum_hash, phase, rotating_members, cycle_base): + """Wait until every live member of every llmq type with a DKG at quorum_hash is in phase and, from phase 2 + on, holds every message of the phase its members report having sent. Of llmq_test_dip0024 only the + members in rotating_members count. At a cycle base llmq_test runs with every live masternode.""" + received = RECEIVED.get(phase) + live = {mn.proTxHash for mn in self.live_masternodes()} + + def check(): + members = {} + statuses = {} + for mn in self.live_masternodes(): + dkgstatus = mn.get_node(self).quorum("dkgstatus") + for qc in dkgstatus["quorumConnections"]: + if qc.get("quorumHash") == quorum_hash and qc["llmqType"] != LLMQ_NAME: + members.setdefault(qc["llmqType"], set()).update(c["proTxHash"] for c in qc["quorumConnections"]) + for qs in dkgstatus["session"]: + if qs["status"]["quorumHash"] != quorum_hash: + continue + if qs["llmqType"] == LLMQ_NAME and mn.proTxHash not in rotating_members: + continue + statuses.setdefault(qs["llmqType"], []).append(qs["status"]) + if rotating_members: + members[LLMQ_NAME] = set(rotating_members) + if cycle_base: + members["llmq_test"] = members.get("llmq_test", set()) | live + for llmq_type, type_members in members.items(): + type_statuses = statuses.get(llmq_type, []) + if len(type_statuses) < len(type_members & live): + return False + if any(s["phase"] != phase for s in type_statuses): + return False + if received is not None: + expected = sum(s[SENT[received]] for s in type_statuses) + if any(s[received] < expected for s in type_statuses): + return False + return True + self.wait_until(check) + + def set_dkg_enabled(self, enabled): + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0 if enabled else SPORK_OFF) + self.wait_for_sporks_same() + + def mine_rotation_cycle(self, on_composition=None): + """Drive the next DKG cycle and return its llmq_test_dip0024 quorums by index, None for an index that + mined none. on_composition(cycle, index, members) runs as soon as an index's DKG has initialized, + before anything waits on it. llmq_test and the other non-rotating types run with the live masternodes. + Every rotating index whose composition meets minSize gets its connections, each phase and its messages + before the chain moves on, and has to mine a commitment if its live members reach the threshold. + 'quorum dkginfo' must have predicted both indexes' members.""" + node = self.nodes[0] + height = node.getblockcount() + cycle = height + CYCLE - height % CYCLE + if cycle - 1 > height: + self.mine_blocks(cycle - 1 - height) + if node.spork("show")["SPORK_17_QUORUM_DKG_ENABLED"] != 0: + self.set_dkg_enabled(True) + predictions = self.predictions(cycle) + self.mine_blocks(1) + assert_equal(node.getblockcount(), cycle) + + live = {mn.proTxHash for mn in self.live_masternodes()} + compositions = {} + rotating = {} + forms = {} + for step in range(12): + index = step % 2 + quorum_hash = node.getblockhash(cycle + index) + phase = step // 2 + 1 + if step < QUORUM_COUNT: + members, aborted = self.dkg_composition(cycle, index) + compositions[index] = members + # a DKG with fewer members than minSize never starts + assert_equal(aborted, len(members) < LLMQ_MIN_SIZE) + if on_composition is not None: + on_composition(cycle, index, members) + rotating[index] = set() if aborted else set(members) + forms[index] = not aborted and len(set(members) & live) >= LLMQ_MIN_SIZE + self.wait_for_dkg_phase(quorum_hash, phase, rotating[index], index == 0) + self.wait_for_dkg_connections(quorum_hash, rotating[index], index == 0) + elif index == 0 or rotating[index]: + self.wait_for_dkg_phase(quorum_hash, phase, rotating[index], index == 0) + if step == 10: + self.wait_for_quorum_commitment(quorum_hash, self.live_masternodes(), llmq_type=100) + self.wait_for_quorum_commitments_on_miner(quorum_hash, self.live_masternodes()) + if step == 11: + for i in range(QUORUM_COUNT): + if forms[i]: + self.wait_for_quorum_commitment(node.getblockhash(cycle + i), self.live_masternodes(), llmq_type=LLMQ_TYPE) + self.wait_for_quorum_commitments_on_miner(node.getblockhash(cycle + i), self.live_masternodes()) + self.mine_blocks(1) + + # past the mining window every commitment that was going to be mined is + self.mine_blocks(cycle + MINING_WINDOW_END + 1 - node.getblockcount()) + quorums = {} + for i in range(QUORUM_COUNT): + quorums[i] = self.mined_quorum(node.getblockhash(cycle + i)) + assert_equal(quorums[i] is not None, forms[i]) + if quorums[i] is not None: + assert_equal([m["proTxHash"] for m in quorums[i]["members"]], compositions[i]) + self.recorded[quorums[i]["quorumHash"]] = quorums[i]["members"] + self.compositions[cycle] = compositions + self.log.info(f"Cycle {cycle}: " + + ", ".join(f"i[{i}] DKG:{[p[:4] for p in m]} quorum:{quorums[i] is not None}" for i, m in compositions.items())) + + for (proTxHash, i), entry in predictions.items(): + assert entry["known"], entry + assert_equal(entry["workBlockHash"], node.getblockhash(cycle - WORK_DIFF_DEPTH)) + assert_equal(entry["isMember"], proTxHash in compositions[i]) + return quorums + + # ---- checks ---- + + def run_test(self): + node = self.nodes[0] + self.prefixes = self.prefix_map() + self.quarter_scanner = LogScanner(node.debug_log_path, "QuarterComposition") + self.dkg_scanner = LogScanner(self.mninfo[0].get_node(self).debug_log_path, "DKGComposition") + self.online = self.mninfo.copy() + self.recorded = {} + self.compositions = {} + + # no ChainLocks, so the cycles can be reorged, and no InstantSend + node.sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) + node.sporkupdate("SPORK_19_CHAINLOCKS_ENABLED", SPORK_OFF) + node.sporkupdate("SPORK_2_INSTANTSEND_ENABLED", SPORK_OFF) + self.wait_for_sporks_same() + + self.test_short_quorums() + self.test_below_minimum() + self.test_reorg() + self.test_restart() + + def check_quarters(self, cycle, index, members): + """The oracle: every quarter of a cycle's quorum, as its DKG used it, is the quarter built for it""" + quarters = self.cycle_quarters(cycle)[index] + logs = self.quarter_logs() + for k in (3, 2, 1): + built_at = cycle - k * CYCLE + if (built_at, index) in logs: + built = self.built_quarters(built_at)[index] + assert_equal(quarters[3 - k], built) + if len(built) < QUARTER_SIZE: + self.short_rebuilds += 1 + assert_equal(len(set(members)), len(members)) + assert_equal(members, [m for quarter in quarters for m in quarter]) + + def check_short_quorums(self, quorums): + for quorum in quorums.values(): + if quorum is None: + continue + members = [m["proTxHash"] for m in quorum["members"]] + assert_equal(len(set(members)), len(members)) + if len(members) < LLMQ_SIZE: + self.short_quorums += 1 + + def snapshot_modes(self): + """The skip list modes of the snapshots 'quorum rotationinfo' (qrinfo) serves for the tip's cycles""" + info = self.nodes[0].quorum("rotationinfo", self.nodes[0].getbestblockhash(), True, []) + snapshots = [info[f"quorumSnapshotAtHMinus{k}C"] for k in ("", "2", "3", "4")] + return {s["mnSkipListMode"] for s in snapshots} + + def test_short_quorums(self): + self.log.info("Rebuilt quarters are the ones built, no quorum repeats a member and short quorums form") + self.mine_until_mns_confirmed_for_next_dkg() + self.first_cycle = None + self.short_rebuilds = 0 + self.short_quorums = 0 + modes = set() + for _ in range(5): + self.check_short_quorums(self.mine_rotation_cycle(on_composition=self.check_quarters)) + if self.first_cycle is None: + self.first_cycle = max(self.compositions) + modes |= self.snapshot_modes() + # cycles with short quarters are stored in their own mode + assert MODE_SHORT_QUARTERS in modes, modes + # a short quarter is where the legacy rebuild would have padded + assert_greater_than(self.short_rebuilds, 0) + assert_greater_than(self.short_quorums, 0) + + def test_below_minimum(self): + self.log.info("A quorum below minSize does not form") + node = self.nodes[0] + # as feature_llmq_simplepose.py: isolate a masternode until it is PoSe-banned + mn = self.mninfo[2] + mn.get_node(self).setnetworkactive(False) + self.wait_until(lambda: mn.get_node(self).getconnectioncount() == 0) + self.online.remove(mn) + for _ in range(3): + self.check_short_quorums(self.mine_rotation_cycle(on_composition=self.check_quarters)) + if check_banned(node, mn): + break + assert check_banned(node, mn) + + below_minimum = None + for _ in range(6): + self.check_short_quorums(self.mine_rotation_cycle(on_composition=self.check_quarters)) + cycle = max(self.compositions) + for i, members in self.compositions[cycle].items(): + if len(members) < LLMQ_MIN_SIZE: + # mine_rotation_cycle checked that its init aborted and no commitment was mined + below_minimum = (cycle, i) + if below_minimum is not None: + break + assert below_minimum is not None, "no below-minimum cycle" + self.log.info(f"Below minimum: {below_minimum}") + + def check_recorded_quorums(self): + """Every recorded quorum still has its members""" + for quorum_hash, members in self.recorded.items(): + info = self.nodes[0].quorum("info", LLMQ_TYPE, quorum_hash) + assert_equal([(m["proTxHash"], m["valid"]) for m in info["members"]], + [(m["proTxHash"], m["valid"]) for m in members]) + + def check_recomputed_quarters(self, quarter_entries): + """Every cycle's quarters were computed again since the first quarter_entries log entries, and every + computation of a cycle agreed with the earlier ones (cycle_quarters checks)""" + recomputed = {int(QUARTER_COMPOSITION.search(e).group(1)) for e in self.quarter_scanner.scan()[quarter_entries:]} + assert set(self.compositions) <= recomputed, set(self.compositions) - recomputed + for cycle in self.compositions: + self.cycle_quarters(cycle) + + def reorg_cycles(self): + """Disconnect every block from the first driven cycle's base on and connect them again""" + nodes = self.all_online_nodes() + tip = self.nodes[0].getbestblockhash() + first = self.nodes[0].getblockhash(self.first_cycle) + for n in nodes: + n.invalidateblock(first) + for n in nodes: + assert_equal(n.getblockcount(), self.first_cycle - 1) + for n in nodes: + n.reconsiderblock(first) + self.wait_until(lambda: all(n.getbestblockhash() == tip for n in nodes)) + + def test_reorg(self): + self.log.info("A reorg of every driven cycle keeps every member list") + quarter_entries = len(self.quarter_scanner.scan()) + self.reorg_cycles() + self.check_recorded_quorums() + self.check_recomputed_quarters(quarter_entries) + + def test_restart(self): + self.log.info("A restart (cold caches) keeps every member list") + quarter_entries = len(self.quarter_scanner.scan()) + self.restart_node(0, extra_args=self.extra_args[0]) + force_finish_mnsync(self.nodes[0]) + self.restart_masternodes(mns=self.online) + # 'quorum info' rebuilds the recorded quorums from the stored snapshots + self.check_recorded_quorums() + # quorums that never formed are only rebuilt when their cycle is disconnected and connected again + self.reorg_cycles() + self.check_recorded_quorums() + self.check_recomputed_quarters(quarter_entries) + + +if __name__ == '__main__': + LLMQRotationShortQuartersTest().main() diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index 6c27bffc28b6..513f842ec7c4 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -1586,6 +1586,8 @@ def set_dash_test_params(self, num_nodes, masterodes_count, extra_args=None, evo self.llmq_size = 3 self.llmq_threshold = 2 self.llmq_size_dip0024 = 4 + # minimum number of distinct members for a rotating DKG to run + self.llmq_min_size_dip0024 = self.llmq_size_dip0024 # This is nRequestTimeout in dash-q-recovery thread self.quorum_data_thread_request_timeout_seconds = 10 @@ -2255,7 +2257,7 @@ def check_dkg_session(): # one that enters late has missed the messages sent so far, so every member has to be # in before the DKG moves on. A DKG with fewer members than the type's minimum size # never starts. - min_size = {"llmq_test_dip0024": self.llmq_size_dip0024, "llmq_test_v17": 2}.get(llmq_type, self.llmq_threshold) + min_size = {"llmq_test_dip0024": self.llmq_min_size_dip0024, "llmq_test_v17": 2}.get(llmq_type, self.llmq_threshold) if len(type_members) >= min_size and len(statuses.get(llmq_type, [])) < len(type_members & online): return False if check_received_messages is not None: @@ -2368,12 +2370,14 @@ def get_upcoming_dkg_predictions(self, node, llmq_type): if d["llmqType"] == llmq_type] for mn in self.mninfo} - def verify_upcoming_dkg_predictions(self, predictions, quorum_info): + def verify_upcoming_dkg_predictions(self, predictions, quorum_info, require_known=False): # '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 require_known: + assert entry["known"], entry if not entry["known"]: # no prediction was possible (e.g. pre-v20); the RPC must tell why assert "reason" in entry @@ -2382,7 +2386,7 @@ def verify_upcoming_dkg_predictions(self, predictions, quorum_info): 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): + 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, require_known_predictions=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 @@ -2476,7 +2480,7 @@ def mine_quorum(self, llmq_type_name="llmq_test", llmq_type=100, expected_connec 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) + self.verify_upcoming_dkg_predictions(predictions, quorum_info, require_known_predictions) if not skip_maturity: self.wait_for_offset_quorum_commitments(q, mninfos_online) @@ -2552,7 +2556,7 @@ def mine_quorum_single_member(self): return (set(new_quorums_list) - set(quorums)).pop() - def mine_cycle_quorum(self): + def mine_cycle_quorum(self, require_known_predictions=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 @@ -2681,9 +2685,9 @@ 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) + self.verify_upcoming_dkg_predictions(predictions, quorum_info_0, require_known_predictions) + self.verify_upcoming_dkg_predictions(predictions, quorum_info_1, require_known_predictions) + self.verify_upcoming_dkg_predictions(predictions_mid_cycle, quorum_info_1, require_known_predictions) # 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)) diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index dbfe209d58a2..63b156d6825a 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -134,6 +134,7 @@ 'feature_llmq_simplepose.py --disable-spork23', # NOTE: needs dash_hash to pass 'feature_llmq_simplepose_capabilities.py --disable-spork23', # NOTE: needs dash_hash to pass 'feature_llmq_rotation.py', # NOTE: needs dash_hash to pass + 'feature_llmq_rotation_short_quarters.py', # NOTE: needs dash_hash to pass 'feature_llmq_evo.py', # NOTE: needs dash_hash to pass 'feature_llmq_dkgerrors.py', # NOTE: needs dash_hash to pass 'feature_llmq_dkg_intake.py', # NOTE: needs dash_hash to pass diff --git a/test/sanitizer_suppressions/ubsan b/test/sanitizer_suppressions/ubsan index 518a7447034f..e86a79cced5a 100644 --- a/test/sanitizer_suppressions/ubsan +++ b/test/sanitizer_suppressions/ubsan @@ -52,13 +52,6 @@ unsigned-integer-overflow:compressor.cpp unsigned-integer-overflow:crypto/ unsigned-integer-overflow:hash.cpp unsigned-integer-overflow:lcg.h -# The quorum snapshot skip list stores offsets relative to the first skipped -# index, and the index wraps around the sorted MN list, so an offset can be -# negative. Encoding wraps size_t and narrows into vector, decoding wraps -# it back; the two cancel and the absolute index is recovered exactly. Per -# symbol rather than per file so the rest of llmq/utils.cpp stays covered. -unsigned-integer-overflow:BuildNewQuorumQuarterMembers -unsigned-integer-overflow:GetQuorumQuarterMembersBySnapshot unsigned-integer-overflow:policy/fees.cpp unsigned-integer-overflow:prevector.h unsigned-integer-overflow:EvalScript @@ -67,7 +60,6 @@ unsigned-integer-overflow:bitset_detail::PopCount implicit-integer-sign-change:addrman.h implicit-integer-sign-change:compat/stdin.cpp implicit-integer-sign-change:CBlockPolicyEstimator::processBlockTx -implicit-integer-sign-change:GetQuorumQuarterMembersBySnapshot implicit-integer-sign-change:compressor.h implicit-integer-sign-change:crypto/ implicit-integer-sign-change:policy/fees.cpp @@ -79,7 +71,6 @@ implicit-integer-sign-change:util/strencodings.cpp implicit-integer-sign-change:util/strencodings.h implicit-integer-sign-change:validation.cpp implicit-signed-integer-truncation,implicit-integer-sign-change:test/skiplist_tests.cpp -implicit-signed-integer-truncation,implicit-integer-sign-change:BuildNewQuorumQuarterMembers implicit-signed-integer-truncation:addrman.h implicit-signed-integer-truncation:crypto/ implicit-unsigned-integer-truncation:crypto/