From c963c7b1a51b27fbc71df74e3bd6ca3d73086f90 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Fri, 30 Oct 2026 22:00:24 +0300 Subject: [PATCH 01/10] refactor(llmq): extract rotation quarter skip-list loops without behavior change Move the legacy quarter selection and snapshot rebuild loops into WalkLegacyQuarters and ReplayLegacyQuarters. The skip-list offsets relative to the first skipped position, which are negative after the walk wraps around the candidate list, are now computed in int64_t and narrowed explicitly instead of wrapping size_t and narrowing implicitly. That gives the same int values for every input, so their UBSan suppressions are no longer needed. Co-Authored-By: Claude Opus 5.5 --- src/llmq/utils.cpp | 192 ++++++++++++++++++------------ src/llmq/utils.h | 18 +++ src/test/llmq_utils_tests.cpp | 46 +++++++ test/sanitizer_suppressions/ubsan | 9 -- 4 files changed, 178 insertions(+), 87 deletions(-) diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 0d34b0c43dcf..f99a980c97a6 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -21,6 +21,8 @@ #include #include +#include +#include #include #include @@ -238,50 +240,21 @@ std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LL 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; - } - } - } - return quarterQuorumMembers; - } + 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_NO_SKIPPING_ENTRIES: // List holds entries to be kept case SnapshotSkipMode::MODE_ALL_SKIPPED: // Every node was skipped. Returning empty quarterQuorumMembers default: @@ -414,52 +387,37 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar ToString(sortedCombinedMnsList)); } - std::vector skipList; - size_t firstSkippedIndex = 0; - size_t idx{0}; + std::vector usedCounts; + usedCounts.reserve(nQuorums); for (const size_t i : util::irange(nQuorums)) { - auto usedMNsCount = MnsUsedAtHIndexed[i].GetCounts().total(); - bool updated{false}; - size_t initial_loop_idx = idx; - while (quarterQuorumMembers[i].size() < quarterSize && (usedMNsCount + quarterQuorumMembers[i].size() < sortedCombinedMnsList.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 (skip) { - if (firstSkippedIndex == 0) { - firstSkippedIndex = idx; - skipList.push_back(idx); - } else { - skipList.push_back(idx - firstSkippedIndex); - } - } - if (++idx == sortedCombinedMnsList.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); - } - // reset and try again - updated = false; - } + 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; + } + }; + auto 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{}; BuildQuorumSnapshot(llmqParams, util_params.m_chainman.GetConsensus(), util_params.m_chainman.m_blockman, allMns, - MnsUsedAtH, quorumSnapshot, skipList, util_params.m_base_index); + MnsUsedAtH, quorumSnapshot, walk->skip_list, util_params.m_base_index); util_params.m_qsnapman.StoreSnapshotForBlock(llmqParams.type, util_params.m_base_index, quorumSnapshot); } @@ -548,6 +506,84 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus 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(quorum_count)) { + auto usedMNsCount = used_counts[i]; + bool updated{false}; + size_t initial_loop_idx = idx; + while (result.quarters[i].size() < quarter_size && (usedMNsCount + result.quarters[i].size() < list_size)) { + bool skip{true}; + if (can_add(i, idx)) { + result.quarters[i].push_back(idx); + updated = true; + skip = false; + } + if (skip) { + if (firstSkippedIndex == 0) { + firstSkippedIndex = idx; + result.skip_list.push_back(idx); + } else { + result.skip_list.push_back( + static_cast(static_cast(idx) - static_cast(firstSkippedIndex))); + } + } + 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::nullopt; + } + // reset and try again + updated = false; + } + } + } + return result; +} + +std::vector> ReplayLegacyQuarters(size_t list_size, size_t quorum_count, size_t quarter_size, + Span skip_list) +{ + 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)); + } + } + + 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; + } + } + } + return quarters; +} + BlsCheck::BlsCheck() = default; BlsCheck::BlsCheck(CBLSSignature sig, std::vector pubkeys, uint256 msg_hash, std::string id_string) : diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 65c4c2682808..5d2b07cb728d 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -16,6 +17,7 @@ #include +#include #include #include #include @@ -43,6 +45,22 @@ struct UtilParameters { }; namespace utils { +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); + struct BlsCheck { CBLSSignature m_sig; std::vector m_pubkeys; diff --git a/src/test/llmq_utils_tests.cpp b/src/test/llmq_utils_tests.cpp index 0978a2e3ba3e..8e4d23f70725 100644 --- a/src/test/llmq_utils_tests.cpp +++ b/src/test/llmq_utils_tests.cpp @@ -15,14 +15,17 @@ #include #include #include +#include #include #include #include #include +#include #include #include +#include #include #include @@ -630,4 +633,47 @@ 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"); +} + BOOST_AUTO_TEST_SUITE_END() 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/ From 7445e79e839a1929ecb5b189a58dd6a079855047 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Fri, 30 Oct 2026 22:04:23 +0300 Subject: [PATCH 02/10] feat(llmq): add step-encoded quarter walk for rotating quorums Co-Authored-By: Claude Opus 5.5 --- src/llmq/utils.cpp | 60 +++++++++++++ src/llmq/utils.h | 9 ++ src/test/llmq_utils_tests.cpp | 158 ++++++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+) diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index f99a980c97a6..b68310524be0 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -584,6 +585,65 @@ std::vector> ReplayLegacyQuarters(size_t list_size, size_t q 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; + } + } + return result; +} + +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); + } + + 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}; + 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 { + quarters[i].push_back(step % list_size); + consecutive_skips = 0; + } + ++step; + } + } + if (next_skip != skip_steps.size()) return std::nullopt; + return quarters; +} + BlsCheck::BlsCheck() = default; BlsCheck::BlsCheck(CBLSSignature sig, std::vector pubkeys, uint256 msg_hash, std::string id_string) : diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 5d2b07cb728d..655113b28ce0 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -61,6 +61,15 @@ std::optional WalkLegacyQuarters(size_t list_size, size_t quo 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 or not fully consumed. +std::optional>> ReplayNewQuarters(size_t list_size, size_t quorum_count, + size_t quarter_size, Span skip_list); + struct BlsCheck { CBLSSignature m_sig; std::vector m_pubkeys; diff --git a/src/test/llmq_utils_tests.cpp b/src/test/llmq_utils_tests.cpp index 8e4d23f70725..56c426387769 100644 --- a/src/test/llmq_utils_tests.cpp +++ b/src/test/llmq_utils_tests.cpp @@ -24,6 +24,7 @@ #include +#include #include #include #include @@ -676,4 +677,161 @@ BOOST_AUTO_TEST_CASE(legacy_quarter_walk_pinned) 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()); +} + +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_SKIPPING_ENTRIES, + walk->skip_list}; + DataStream ss{}; + ss << snapshot; + llmq::CQuorumSnapshot decoded; + ss >> decoded; + BOOST_CHECK(decoded.mnSkipListMode == SnapshotSkipMode::MODE_SKIPPING_ENTRIES); + 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_SUITE_END() From 3826c2ad436a09c119277208f2be06195de58441 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Wed, 4 Nov 2026 17:00:55 +0300 Subject: [PATCH 03/10] feat: add -llmqtestdip0024params Co-Authored-By: Claude Opus 5.5 --- src/chainparams.cpp | 15 ++++++++++++++- src/kernel/chainparams.cpp | 3 ++- src/test/llmq_params_tests.cpp | 28 ++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) 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/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 5f894ffeaf29..7f44b7f2507b 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -1027,6 +1027,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 +1148,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/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); From 2b42fced2b3bba1bfdecd56b7b8376f03f2acd29 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Wed, 4 Nov 2026 17:00:55 +0300 Subject: [PATCH 04/10] feat(llmq): add a short-quarter rotation snapshot mode Add MODE_SHORT_QUARTERS for snapshots of cycles with a short or empty quarter, whose skip list holds the steps of WalkNewQuarters, and decode every snapshot by its mode: MODE_NO_SKIPPING and MODE_SKIPPING_ENTRIES keep the legacy replay, MODE_SHORT_QUARTERS uses ReplayNewQuarters. Nothing writes the new mode yet. LegacySkipListForWalk gives the skip list WalkLegacyQuarters stores for a step walk whose quarters are all full, which is the same walk: a builder can keep storing exactly the legacy snapshots for such cycles, including the ones the legacy replay rebuilds differently after wrapping around the candidate list. ReplayNewQuarters now also rejects a skip list that would repeat a position within one quarter, which the walk never produces. Co-Authored-By: Claude Opus 5.5 --- src/evo/snapshot.cpp | 2 +- src/llmq/snapshot.h | 3 +- src/llmq/utils.cpp | 40 ++++++++++++++++++++++-- src/llmq/utils.h | 7 ++++- src/test/llmq_utils_tests.cpp | 57 +++++++++++++++++++++++++++++++++-- 5 files changed, 102 insertions(+), 7 deletions(-) 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/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 b68310524be0..36bb5dc110b6 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -237,7 +237,9 @@ 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; } @@ -256,6 +258,16 @@ std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LL 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 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 default: @@ -629,12 +641,16 @@ std::optional>> ReplayNewQuarters(size_t list_si 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 { - quarters[i].push_back(step % list_size); + 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; } ++step; @@ -644,6 +660,26 @@ std::optional>> ReplayNewQuarters(size_t list_si return quarters; } +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 legacy; +} + BlsCheck::BlsCheck() = default; BlsCheck::BlsCheck(CBLSSignature sig, std::vector pubkeys, uint256 msg_hash, std::string id_string) : diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 655113b28ce0..944872cf2223 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -66,9 +66,14 @@ std::vector> ReplayLegacyQuarters(size_t list_size, size_t q //! 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 or not fully consumed. +//! 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); struct BlsCheck { CBLSSignature m_sig; diff --git a/src/test/llmq_utils_tests.cpp b/src/test/llmq_utils_tests.cpp index 56c426387769..cbd69deb6d79 100644 --- a/src/test/llmq_utils_tests.cpp +++ b/src/test/llmq_utils_tests.cpp @@ -805,6 +805,11 @@ BOOST_AUTO_TEST_CASE(quarter_walk_replay_rejects_malformed) 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) @@ -813,13 +818,13 @@ BOOST_AUTO_TEST_CASE(quarter_walk_snapshot_roundtrip) 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_SKIPPING_ENTRIES, + 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_SKIPPING_ENTRIES); + 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); @@ -834,4 +839,52 @@ BOOST_AUTO_TEST_CASE(quarter_walk_snapshot_roundtrip) 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}})); +} + BOOST_AUTO_TEST_SUITE_END() From f757aec856ebf9dd717947644f51127cdf24aa0c Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Tue, 3 Nov 2026 21:46:57 +0300 Subject: [PATCH 05/10] fix(llmq): build rotation quarters with the step walk A rotation cycle that can't fill a quarter selects fewer members, but the legacy snapshot can't record that, so every later rebuild of the quarter padded it by wrapping around the candidate list, adding members that were never selected and could repeat members of the quorum's other quarters. Builds with too few enabled masternodes or without progress stored no snapshot at all. Build every cycle's new quarters with WalkNewQuarters and always store a snapshot. Candidates are ordered and accepted exactly as before, so where every quarter fills, the step walk selects what the legacy walk would have, and the snapshot stores exactly what the legacy builder would have stored: the cycle is rebuilt as before. Only a cycle with a short or empty quarter, or with too few enabled masternodes, is stored in MODE_SHORT_QUARTERS and rebuilt as it was selected. Mainnet replays unchanged. Testnet's history includes such cycles (too few masternodes at h=838368, short quarters at h=901152-903168 and h=1366560-1367136); their base blocks are listed in llmqLegacyRotationCycles and keep the legacy walk. Co-Authored-By: Claude Opus 5.5 --- src/consensus/params.h | 3 + src/kernel/chainparams.cpp | 14 ++ src/llmq/utils.cpp | 203 +++++++++++++-------- src/llmq/utils.h | 13 ++ src/test/llmq_utils_tests.cpp | 321 ++++++++++++++++++++++++++++++++++ 5 files changed, 477 insertions(+), 77 deletions(-) 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/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 7f44b7f2507b..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; diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 36bb5dc110b6..8148b4da6d0e 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -33,7 +33,7 @@ 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) { @@ -327,17 +327,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); @@ -346,7 +346,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; } @@ -416,10 +416,19 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar return false; } }; - auto walk = llmq::utils::WalkLegacyQuarters(sortedCombinedMnsList.size(), nQuorums, quarterSize, usedCounts, - can_add); - if (!walk) { - return std::vector(nQuorums); + 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]) { @@ -429,84 +438,37 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar 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; } -// 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) { - const int cycleLength = llmqParams.dkgInterval; - if (!llmqParams.useRotation || cycleBaseHeight % llmqParams.dkgInterval != 0) { - ASSERT_IF_DEBUG(false); - return {}; - } - 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; - } - 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 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])); } - 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); - } - - 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()); - - 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 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])); @@ -720,6 +682,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; + } + prev_cycles[idx]->m_members = GetQuorumQuarterMembersBySnapshot(llmqParams, util_params.m_dmnman, + chainman.GetConsensus(), chainman.m_blockman, + prev_cycles[idx]->m_cycle_index, + prev_cycles[idx]->m_snap, + cycleBaseHeight); + } + + 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) @@ -839,6 +885,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 944872cf2223..7721d4f80379 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -17,6 +17,7 @@ #include +#include #include #include #include @@ -45,6 +46,8 @@ struct UtilParameters { }; namespace utils { +using QuorumMembers = std::vector; + struct QuarterWalkResult { //! Candidate positions chosen for each quorum's new quarter std::vector> quarters; @@ -75,6 +78,16 @@ std::optional>> ReplayNewQuarters(size_t list_si //! 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). 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_utils_tests.cpp b/src/test/llmq_utils_tests.cpp index cbd69deb6d79..57875190a345 100644 --- a/src/test/llmq_utils_tests.cpp +++ b/src/test/llmq_utils_tests.cpp @@ -3,12 +3,19 @@ // 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 @@ -21,9 +28,12 @@ #include #include #include +#include #include +#include +#include #include #include #include @@ -887,4 +897,315 @@ BOOST_AUTO_TEST_CASE(quarter_walk_legacy_skip_list) 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_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() From 5fe5c0b0aee93875619fdb063a148234e1ccdff6 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Tue, 3 Nov 2026 21:47:16 +0300 Subject: [PATCH 06/10] test: add a rotating quorum minimum size and known-prediction checks Let DashTestFramework tests lower the minimum size a rotating DKG needs, and let mine_quorum/mine_cycle_quorum require every upcoming DKG prediction to be known. feature_llmq_rotation.py now requires that. Co-Authored-By: Claude Opus 5.5 --- test/functional/feature_llmq_rotation.py | 6 +++--- .../test_framework/test_framework.py | 20 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) 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/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)) From 4befd4e9c3eca1b4817576d565817bdb12dabbc7 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Sat, 31 Oct 2026 17:06:55 +0300 Subject: [PATCH 07/10] llmq: log 16-character proTxHash prefixes in rotation composition logs The QuorumComposition, QuarterComposition, sortedCombinedMns and DKGComposition debug logs identified masternodes by the first 4 hex characters of their proTxHash, which collide often enough to make log-based checks of these compositions ambiguous. Use 16 characters. Co-Authored-By: Claude Opus 5.5 --- src/llmq/dkgsession.cpp | 2 +- src/llmq/utils.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/utils.cpp b/src/llmq/utils.cpp index 8148b4da6d0e..2d4548c5fb4f 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -39,7 +39,7 @@ 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(); } From 7dc6bb1de2d9d68dc96dc94cd52f7dd146dc07fc Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Tue, 3 Nov 2026 21:50:59 +0300 Subject: [PATCH 08/10] test: cover short rotation quarters Three masternodes can't fill the quarters of an 8-member llmq_test_dip0024, so cycles keep building short and empty quarters. Check that every rebuilt quarter is the one built, that no quorum repeats a member, that short quorums form and below-minSize ones don't, that qrinfo serves MODE_SHORT_QUARTERS snapshots, and that every member list survives a reorg of all the cycles and a restart. Co-Authored-By: Claude Opus 5.5 --- .../feature_llmq_rotation_short_quarters.py | 466 ++++++++++++++++++ test/functional/test_runner.py | 1 + 2 files changed, 467 insertions(+) create mode 100755 test/functional/feature_llmq_rotation_short_quarters.py 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_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 From 7be06e303f14c2b456aa42ce5a6a94231adf846b Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Mon, 9 Nov 2026 18:49:57 +0300 Subject: [PATCH 09/10] fix(llmq): treat a malformed short-quarter snapshot as unavailable A MODE_SHORT_QUARTERS snapshot whose skip list can't be replayed was rebuilt as empty quarters, so the next cycle was computed, and its snapshot stored, from history that doesn't exist. Report the cycle as not computable instead, like a skip list overflow, so predictions say its members are unknown and nothing is built on it. Co-Authored-By: Claude Opus 5.5 --- src/llmq/utils.cpp | 28 +++++++++++++++------------- src/llmq/utils.h | 3 ++- src/test/llmq_utils_tests.cpp | 22 ++++++++++++++++++++++ 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 2d4548c5fb4f..b82da3a0137f 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -188,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; @@ -264,7 +266,7 @@ std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LL 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 quarterQuorumMembers; + return std::nullopt; } return map_positions(*positions); } @@ -724,11 +726,11 @@ std::vector> ComputeRotationQuarters(const Consensu // assert(false); break; } - prev_cycles[idx]->m_members = GetQuorumQuarterMembersBySnapshot(llmqParams, util_params.m_dmnman, - chainman.GetConsensus(), chainman.m_blockman, - prev_cycles[idx]->m_cycle_index, - prev_cycles[idx]->m_snap, - cycleBaseHeight); + 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, diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 7721d4f80379..e669188183f1 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -79,7 +79,8 @@ std::optional>> ReplayNewQuarters(size_t list_si 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). GetAllQuorumMembers flattens this. +//! 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, diff --git a/src/test/llmq_utils_tests.cpp b/src/test/llmq_utils_tests.cpp index 57875190a345..9759db77b95b 100644 --- a/src/test/llmq_utils_tests.cpp +++ b/src/test/llmq_utils_tests.cpp @@ -1102,6 +1102,28 @@ BOOST_FIXTURE_TEST_CASE(rotation_candidate_conflicting_with_previous_member_is_s 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; From a9ea3dfa9df53c9c93970c049803b4fc63f9f16f Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Tue, 3 Nov 2026 21:51:45 +0300 Subject: [PATCH 10/10] doc: add release note for #8074 Co-Authored-By: Claude Opus 5.5 --- doc/release-notes-8074.md | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 doc/release-notes-8074.md 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.