From 04864108fef1b151b3f39f8f81e25f8b05de214c Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 12 Aug 2026 19:03:49 -0500 Subject: [PATCH 01/14] feat: add the evo snapshot v3 canonical bounded codec and context-free validation First code PR of the assumeutxo M4 series (#7579 decomposition): the versioned interchange format for Dash's evo state alongside a UTXO snapshot - canonical serialization, DoS-bounded validating decode, and every validation invariant that needs no chain context. Chain-aware building/validation and dump/load integration follow in the next PRs of the series. Canonical ordering exists because snapshot content is hashed and cross-checked; per-object serializers are reused through a bounded stream wrapper, with bespoke code only at container level (ordering, bounds, per-entry budgets); decode-time checks deliberately stay out of the trusted hot EvoDB deserializers. AssumeutxoData gains the EvoSnapshotHash anchor the format is pinned by. Includes the aggregate rotation skip-list bound (lists accumulate across every quorum index and wrap the combined MN list), the CRangesSet bounded unserializer, and a vendored-immer shift-base ubsan suppression reachable only through the deliberately hash-colliding test fixtures. Co-Authored-By: Claude Fable 5 --- src/Makefile.am | 3 + src/Makefile.test.include | 1 + src/chainparams.cpp | 4 +- src/chainparams.h | 7 + src/evo/deterministicmns.cpp | 24 ++ src/evo/deterministicmns.h | 7 + src/evo/snapshot.cpp | 327 +++++++++++++++ src/evo/snapshot.h | 599 ++++++++++++++++++++++++++++ src/streams.h | 1 + src/test/evo_netinfo_tests.cpp | 24 ++ src/test/evo_snapshot_tests.cpp | 637 ++++++++++++++++++++++++++++++ src/util/ranges_set.h | 48 ++- test/sanitizer_suppressions/ubsan | 4 + 13 files changed, 1682 insertions(+), 4 deletions(-) create mode 100644 src/evo/snapshot.cpp create mode 100644 src/evo/snapshot.h create mode 100644 src/test/evo_snapshot_tests.cpp diff --git a/src/Makefile.am b/src/Makefile.am index c09876eca3fc..8938dc0eeeed 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -235,6 +235,7 @@ BITCOIN_CORE_H = \ evo/providertx_service.h \ evo/simplifiedmns.h \ evo/smldiff.h \ + evo/snapshot.h \ evo/specialtx.h \ evo/specialtx_filter.h \ evo/specialtxman.h \ @@ -542,6 +543,7 @@ libbitcoin_node_a_SOURCES = \ evo/evodb.cpp \ evo/mnauth.cpp \ evo/mnhftx.cpp \ + evo/snapshot.cpp \ evo/providertx.cpp \ evo/providertx_service.cpp \ evo/simplifiedmns.cpp \ @@ -1280,6 +1282,7 @@ libdashkernel_la_SOURCES = \ evo/providertx_util.cpp \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ + evo/snapshot.cpp \ evo/specialtx.cpp \ evo/specialtx_filter.cpp \ evo/specialtxman.cpp \ diff --git a/src/Makefile.test.include b/src/Makefile.test.include index a3c8cb3aead3..2b637b7f75e5 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -117,6 +117,7 @@ BITCOIN_TESTS =\ test/evo_mnauth_tests.cpp \ test/evo_mnhf_tests.cpp \ test/evo_netinfo_tests.cpp \ + test/evo_snapshot_tests.cpp \ test/evo_simplifiedmns_tests.cpp \ test/evo_trivialvalidation.cpp \ test/evo_utils_tests.cpp \ diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 3d391b691e52..565d2486bd97 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -882,11 +882,11 @@ class CRegTestParams : public CChainParams { m_assumeutxo_data = MapAssumeutxo{ { 110, - {AssumeutxoHash{uint256S("0x9b2a277a3e3b979f1a539d57e949495d7f8247312dbc32bce6619128c192b44b")}, 110}, + {AssumeutxoHash{uint256S("0x9b2a277a3e3b979f1a539d57e949495d7f8247312dbc32bce6619128c192b44b")}, EvoSnapshotHash{uint256{}}, 110}, }, { 200, - {AssumeutxoHash{uint256S("0x8a5bdd92252fc6b24663244bbe958c947bb036dc1f94ccd15439f48d8d1cb4e3")}, 200}, + {AssumeutxoHash{uint256S("0x8a5bdd92252fc6b24663244bbe958c947bb036dc1f94ccd15439f48d8d1cb4e3")}, EvoSnapshotHash{uint256{}}, 200}, }, }; diff --git a/src/chainparams.h b/src/chainparams.h index 69b4baaa61fa..fa974ba40cb7 100644 --- a/src/chainparams.h +++ b/src/chainparams.h @@ -34,6 +34,10 @@ struct AssumeutxoHash : public BaseHash { explicit AssumeutxoHash(const uint256& hash) : BaseHash(hash) {} }; +struct EvoSnapshotHash : public BaseHash { + explicit EvoSnapshotHash(const uint256& hash) : BaseHash(hash) {} +}; + /** * Holds configuration for use during UTXO snapshot load and validation. The contents * here are security critical, since they dictate which UTXO snapshots are recognized @@ -43,6 +47,9 @@ struct AssumeutxoData { //! The expected hash of the deserialized UTXO set. const AssumeutxoHash hash_serialized; + //! The expected single-SHA256 hash of the canonical Dash evo section. + const EvoSnapshotHash evo_hash; + //! Used to populate the nChainTx value, which is used during BlockManager::LoadBlockIndex(). //! //! We need to hardcode the value here because this is computed cumulatively using block data, diff --git a/src/evo/deterministicmns.cpp b/src/evo/deterministicmns.cpp index d652efaec732..a2abc2dda9d0 100644 --- a/src/evo/deterministicmns.cpp +++ b/src/evo/deterministicmns.cpp @@ -393,6 +393,30 @@ void CDeterministicMNList::ApplyDiff(gsl::not_null pindex, c } } +void CDeterministicMNList::ApplyDiffForSnapshot(const uint256& block_hash, int height, + uint32_t total_registered_count, + const CDeterministicMNListDiff& diff) +{ + if (height < 0) throw std::runtime_error("negative historical MN-list height"); + blockHash = block_hash; + nHeight = height; + + for (const auto& id : diff.removedMns) { + auto dmn = GetMNByInternalId(id); + if (!dmn) throw std::runtime_error(strprintf("%s: can't find a removed masternode, id=%d", __func__, id)); + RemoveMN(dmn->proTxHash); + } + for (const auto& dmn : diff.addedMNs) { + AddMN(dmn, /*fBumpTotalCount=*/false); + } + for (const auto& p : diff.updatedMNs) { + auto dmn = GetMNByInternalId(p.first); + if (!dmn) throw std::runtime_error(strprintf("%s: can't find an updated masternode, id=%d", __func__, p.first)); + UpdateMN(*dmn, p.second); + } + nTotalRegisteredCount = total_registered_count; +} + void CDeterministicMNList::AddMN(const CDeterministicMNCPtr& dmn, bool fBumpTotalCount) { assert(dmn != nullptr); diff --git a/src/evo/deterministicmns.h b/src/evo/deterministicmns.h index 4fb5dee91aef..b92612ce5116 100644 --- a/src/evo/deterministicmns.h +++ b/src/evo/deterministicmns.h @@ -339,6 +339,8 @@ class CDeterministicMNList assert(nHeight >= 0); return nHeight; } + /** Snapshot hashing also covers the pre-DIP3 default list (height -1). */ + [[nodiscard]] int GetHeightForSnapshotCodec() const noexcept { return nHeight; } void SetHeight(int _height) { assert(_height >= 0); @@ -423,6 +425,11 @@ class CDeterministicMNList void ApplyDiff(gsl::not_null pindex, const CDeterministicMNListDiff& diff) EXCLUSIVE_LOCKS_REQUIRED(!m_cached_sml_mutex); + /** Apply a snapshot-local historical diff without dereferencing block data. */ + void ApplyDiffForSnapshot(const uint256& block_hash, int height, uint32_t total_registered_count, + const CDeterministicMNListDiff& diff) + EXCLUSIVE_LOCKS_REQUIRED(!m_cached_sml_mutex); + void AddMN(const CDeterministicMNCPtr& dmn, bool fBumpTotalCount = true) EXCLUSIVE_LOCKS_REQUIRED(!m_cached_sml_mutex); void UpdateMN(const CDeterministicMN& oldDmn, const std::shared_ptr& pdmnState) EXCLUSIVE_LOCKS_REQUIRED(!m_cached_sml_mutex); diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp new file mode 100644 index 000000000000..26e1987557cd --- /dev/null +++ b/src/evo/snapshot.cpp @@ -0,0 +1,327 @@ +// 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. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace evo { +namespace { + +template +std::vector Sorted(std::vector values) +{ + std::sort(values.begin(), values.end(), [](const T& a, const T& b) { + if constexpr (std::is_same_v) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + } else if constexpr (std::is_same_v) { + return a.cycle_base_block_hash < b.cycle_base_block_hash; + } else if constexpr (std::is_same_v) { + return std::tie(a.height, a.block_hash) > std::tie(b.height, b.block_hash); + } else if constexpr (std::is_same_v) { + return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); + } else { + return a.llmq_type < b.llmq_type; + } + }); + return values; +} + +template +bool IsStrictlySorted(const std::vector& values) +{ + return std::adjacent_find(values.begin(), values.end(), [](const T& a, const T& b) { + if constexpr (std::is_same_v) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) >= + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + } else if constexpr (std::is_same_v) { + return !(a.cycle_base_block_hash < b.cycle_base_block_hash); + } else if constexpr (std::is_same_v) { + return !(std::tie(a.height, a.block_hash) > std::tie(b.height, b.block_hash)); + } else if constexpr (std::is_same_v) { + return !(std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash)); + } else { + return a.llmq_type >= b.llmq_type; + } + }) == values.end(); +} + +void ValidateCommitments(const CQuorumSnapshotData& data, const std::vector& commitments, + std::set& quorum_hashes, bool require_canonical_order) +{ + if (require_canonical_order && !IsStrictlySorted(commitments)) { + throw std::ios_base::failure("noncanonical evo quorum commitments"); + } + std::set quorum_indexes; + const auto& params{SnapshotLLMQParams(data.llmq_type)}; + for (const auto& entry : commitments) { + const bool known_version{ + entry.commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION || + entry.commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION || + entry.commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION || + entry.commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION}; + const bool indexed{entry.commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION || + entry.commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION}; + if (!entry.commitment.VerifySizes(params)) throw std::ios_base::failure("invalid evo quorum commitment sizes"); + if (!known_version) throw std::ios_base::failure("unknown evo quorum commitment version"); + if (entry.quorum_base_block_hash.IsNull() || entry.work_block_hash.IsNull() || entry.mined_block_hash.IsNull()) { + throw std::ios_base::failure("null evo quorum commitment block hash"); + } + if (entry.commitment.llmqType != data.llmq_type) { + throw std::ios_base::failure("mismatched evo quorum commitment type"); + } + if (entry.commitment.quorumHash != entry.quorum_base_block_hash) { + throw std::ios_base::failure("mismatched evo quorum commitment base hash"); + } + if (indexed != data.rotation_enabled) throw std::ios_base::failure("mismatched evo quorum rotation version"); + if (indexed && (entry.commitment.quorumIndex < 0 || + entry.commitment.quorumIndex >= params.signingActiveQuorumCount)) { + throw std::ios_base::failure("invalid evo quorum index"); + } + if (!quorum_hashes.insert(entry.quorum_base_block_hash).second) { + throw std::ios_base::failure("duplicate evo quorum base hash"); + } + if (indexed && !quorum_indexes.insert(entry.commitment.quorumIndex).second) { + throw std::ios_base::failure("duplicate evo quorum index"); + } + } +} + +void ValidateCanonicalMNInvariants(const CDeterministicMNList& list) +{ + const size_t count{list.GetCounts().total()}; + if (count > EVO_SNAPSHOT_MAX_MNS) throw std::ios_base::failure("oversized canonical MN list"); + uint64_t max_internal_id{0}; + list.ForEachMN(/*onlyValid=*/false, [&](const auto& dmn) { + max_internal_id = std::max(max_internal_id, dmn.GetInternalId()); + if (dmn.pdmnState->payouts.size() > EVO_SNAPSHOT_MAX_PAYOUT_SHARES || + dmn.pdmnState->netInfo->Validate() != NetInfoStatus::Success) { + throw std::ios_base::failure("invalid canonical MN nested collection"); + } + }); + if (count != 0 && max_internal_id >= list.GetTotalRegisteredCount()) { + throw std::ios_base::failure("canonical MN-list internalId exceeds registration counter"); + } +} + +} // namespace + +std::vector EvoSnapshotReconstructionHeights( + int base_height, const std::vector& enabled_llmqs) +{ + if (base_height < 0) throw std::invalid_argument("invalid reconstruction base height"); + std::vector heights; + for (const auto& params : enabled_llmqs) { + if (params.dkgInterval <= 0 || params.signingActiveQuorumCount <= 0) { + throw std::invalid_argument("invalid reconstruction LLMQ parameters"); + } + const int h{base_height - base_height % params.dkgInterval}; + const size_t count{params.useRotation ? EVO_SNAPSHOT_ROTATION_CYCLES + : SnapshotCommitmentCount(params, /*rotation_enabled=*/false)}; + const size_t first{params.useRotation ? 1U : 0U}; + for (size_t i{first}; i < first + count; ++i) { + const int quorum_height{h - static_cast(i) * params.dkgInterval}; + heights.push_back({params.type, params.useRotation, quorum_height, + quorum_height - llmq::WORK_DIFF_DEPTH}); + } + } + return heights; +} + +uint256 CanonicalMNListHash(const CDeterministicMNList& list) +{ + CHashWriter writer{SER_DISK, CLIENT_VERSION}; + SerializeCanonicalMNList(writer, list); + return writer.GetHash(); +} + +bool ReconstructHistoricalMNLists(const CEvoSnapshot& snapshot, + std::map& lists, std::string& error) +{ + lists.clear(); + error.clear(); + CDeterministicMNList current{snapshot.mn_list}; + uint256 previous_hash{snapshot.base_block_hash}; + int previous_height{current.GetHeightForSnapshotCodec()}; + try { + const auto history{Sorted(snapshot.historical_mn_list_diffs)}; + for (const auto& entry : history) { + if (entry.previous_block_hash != previous_hash || entry.block_hash.IsNull() || + entry.height < 0 || entry.height >= previous_height || entry.canonical_list_hash.IsNull()) { + throw std::ios_base::failure("broken historical MN-list diff chain"); + } + current.ApplyDiffForSnapshot(entry.block_hash, entry.height, entry.total_registered_count, entry.diff); + ValidateCanonicalMNInvariants(current); + if (CanonicalMNListHash(current) != entry.canonical_list_hash) { + throw std::ios_base::failure("historical MN-list diff hash mismatch"); + } + if (!lists.emplace(entry.block_hash, current).second) { + throw std::ios_base::failure("duplicate historical MN-list diff target"); + } + previous_hash = entry.block_hash; + previous_height = entry.height; + } + } catch (const std::exception& e) { + error = e.what(); + lists.clear(); + return false; + } + return true; +} + +void CEvoSnapshot::Validate(bool require_canonical_order) const +{ + if (version != EVO_SNAPSHOT_VERSION) throw std::ios_base::failure("unsupported evo snapshot version"); + if (base_block_hash.IsNull() || mn_list.GetBlockHash() != base_block_hash) { + throw std::ios_base::failure("evo snapshot base block mismatch"); + } + ValidateCanonicalMNInvariants(mn_list); + if (quorums.size() > Consensus::available_llmqs.size() || + historical_mn_list_diffs.size() > EvoSnapshotMaxHistoricalMNLists() || + quorum_modifiers.size() > EVO_SNAPSHOT_MAX_MODIFIERS || + mnhf_signals.size() > Consensus::MAX_VERSION_BITS_DEPLOYMENTS) { + throw std::ios_base::failure("oversized evo snapshot collection"); + } + if (require_canonical_order && (!IsStrictlySorted(quorums) || !IsStrictlySorted(historical_mn_list_diffs) || + !IsStrictlySorted(quorum_modifiers))) { + throw std::ios_base::failure("noncanonical evo snapshot top-level order"); + } + + std::map reconstructed; + std::string reconstruction_error; + if (!ReconstructHistoricalMNLists(*this, reconstructed, reconstruction_error)) { + throw std::ios_base::failure(reconstruction_error); + } + std::set historical_hashes; + for (const auto& [hash, _] : reconstructed) historical_hashes.insert(hash); + + std::set> required_modifiers; + std::set required_work_hashes; + + std::set quorum_types; + for (const auto& data : quorums) { + const auto& params{SnapshotLLMQParams(data.llmq_type)}; + if (!quorum_types.insert(data.llmq_type).second || (data.rotation_enabled && !params.useRotation)) { + throw std::ios_base::failure("invalid evo quorum type"); + } + const size_t active_count{static_cast(params.signingActiveQuorumCount)}; + const size_t total_count{SnapshotCommitmentCount(params, data.rotation_enabled)}; + if (data.active_commitments.size() != active_count || + data.safety_commitments.size() != total_count - active_count || + data.rotation_snapshots.size() != (data.rotation_enabled ? EVO_SNAPSHOT_ROTATION_CYCLES : 0)) { + throw std::ios_base::failure("invalid params-derived evo per-type quorum counts"); + } + std::set quorum_hashes; + ValidateCommitments(data, data.active_commitments, quorum_hashes, require_canonical_order); + ValidateCommitments(data, data.safety_commitments, quorum_hashes, require_canonical_order); + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + required_work_hashes.insert(entry.work_block_hash); + required_modifiers.emplace(data.llmq_type, entry.work_block_hash); + } + } + if (require_canonical_order && !IsStrictlySorted(data.rotation_snapshots)) { + throw std::ios_base::failure("noncanonical evo quorum rotation snapshots"); + } + std::set cycle_hashes; + for (const auto& entry : data.rotation_snapshots) { + if (entry.cycle_base_block_hash.IsNull() || entry.work_block_hash.IsNull() || + !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.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 + // deltas that legitimately go negative once the build wraps the + // combined MN list. Semantic validity is established by quorum + // reconstruction against chain state, not here. + (!entry.snapshot.mnSkipList.empty() && entry.snapshot.mnSkipList.front() < 0)) { + throw std::ios_base::failure("invalid evo quorum rotation snapshot"); + } + required_work_hashes.insert(entry.work_block_hash); + required_modifiers.emplace(data.llmq_type, entry.work_block_hash); + } + } + if (historical_hashes != required_work_hashes) { + throw std::ios_base::failure("missing or extra historical MN-list diff target"); + } + std::set> actual_modifiers; + for (const auto& entry : quorum_modifiers) { + SnapshotLLMQParams(entry.llmq_type); + if (entry.work_block_hash.IsNull() || entry.modifier.IsNull() || + !actual_modifiers.emplace(entry.llmq_type, entry.work_block_hash).second) { + throw std::ios_base::failure("invalid or duplicate evo quorum modifier"); + } + } + if (actual_modifiers != required_modifiers) { + throw std::ios_base::failure("missing or extra evo quorum modifier"); + } +} + +uint256 GetEvoSnapshotHash(const CEvoSnapshot& snapshot) +{ + snapshot.Validate(); + CDataStream stream{SER_DISK, CLIENT_VERSION}; + stream << snapshot; + uint256 hash; + CSHA256().Write(UCharCast(stream.data()), stream.size()).Finalize(hash.begin()); + return hash; +} + +bool VerifyEvoSnapshotCbTx(const CEvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error) +{ + error.clear(); + try { + snapshot.Validate(); + } catch (const std::exception& e) { + error = e.what(); + return false; + } + bool mutated{false}; + const uint256 mn_root{snapshot.mn_list.to_sml()->CalcMerkleRoot(&mutated)}; + if (mutated || mn_root != cbtx.merkleRootMNList) { + error = "evo snapshot masternode merkle root mismatch"; + return false; + } + if (cbtx.nVersion >= CCbTx::Version::MERKLE_ROOT_QUORUMS) { + std::vector hashes; + for (const auto& data : snapshot.quorums) { + for (const auto& entry : data.active_commitments) hashes.emplace_back(SerializeHash(entry.commitment)); + } + std::sort(hashes.begin(), hashes.end()); + const uint256 quorum_root{ComputeMerkleRoot(hashes, &mutated)}; + if (mutated || quorum_root != cbtx.merkleRootQuorums) { + error = "evo snapshot quorum merkle root mismatch"; + return false; + } + } + if (cbtx.nVersion >= CCbTx::Version::CLSIG_AND_BALANCE && snapshot.credit_pool.locked != cbtx.creditPoolBalance) { + error = "evo snapshot credit pool balance mismatch"; + return false; + } + return true; +} + +} // namespace evo diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h new file mode 100644 index 000000000000..0a60449cd753 --- /dev/null +++ b/src/evo/snapshot.h @@ -0,0 +1,599 @@ +// 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. + +#ifndef BITCOIN_EVO_SNAPSHOT_H +#define BITCOIN_EVO_SNAPSHOT_H + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class CCbTx; + +namespace evo { + +static constexpr uint16_t EVO_SNAPSHOT_VERSION{3}; +/** Serialized little-endian bytes are "DASHEVO\0". */ +static constexpr uint64_t EVO_SNAPSHOT_MARKER{0x004f564548534144ULL}; +// ComputeQuorumMembersByQuarterRotation consumes H-C, H-2C and H-3C. To +// reconstruct both H and the safety cycle H-C, the union is H-C..H-4C. +static constexpr size_t EVO_SNAPSHOT_ROTATION_CYCLES{4}; +// A hard allocation bound, not a network population target. 100,000 full MN +// records is already far beyond today's list while limiting hostile snapshots +// to a tractable decode. Changes above this require a format-version review. +static constexpr size_t EVO_SNAPSHOT_MAX_MNS{100'000}; +// Asset-unlock indexes are uint64_t and have no consensus upper bound. This is +// a range-count allocation/work bound, chosen far above any plausible live +// state. Raising it requires an evo snapshot format-version review. +static constexpr size_t EVO_SNAPSHOT_MAX_RANGES{100'000}; +// IsPayoutListTriviallyValid() is the protocol admission rule for MultiPayout. +static constexpr size_t EVO_SNAPSHOT_MAX_PAYOUT_SHARES{8}; +// CDeterministicMN contains several consensus/P2P CompactSize collections +// (scripts, payout shares, and ExtNetInfo maps/lists). Snapshot decoding gives +// each MN a cumulative budget so nested counts cannot multiply decode work. +// This comfortably covers protocol-valid scripts and network information. +static constexpr size_t EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS{10'000}; +static constexpr size_t EVO_SNAPSHOT_MAX_MODIFIERS{4'096}; +// A cycle's skip list accumulates across every quorum index and the build can +// wrap the combined MN list more than once, so a single quorum's size does not +// bound its legitimate length. This is a decode ceiling on claimed sizes only, +// far above any state the aggregate rotation build reaches on real chains. +static constexpr size_t EVO_SNAPSHOT_MAX_SKIPLIST_ENTRIES{1'000'000}; +static_assert(std::ranges::all_of(Consensus::available_llmqs, [](const auto& params) { + return !params.useRotation || params.keepOldConnections <= 2 * params.signingActiveQuorumCount; +}), "rotated LLMQ retention exceeds the two serialized cycles"); + +template +size_t ReadBoundedCompactSize(Stream& s, size_t limit, const char* field) +{ + const uint64_t size{ReadCompactSize(s)}; + if (size > limit) throw std::ios_base::failure(std::string{"oversized evo snapshot "} + field); + return static_cast(size); +} + +inline const Consensus::LLMQParams& SnapshotLLMQParams(Consensus::LLMQType type) +{ + const auto it{std::ranges::find_if(Consensus::available_llmqs, + [type](const auto& params) { return params.type == type; })}; + if (it == Consensus::available_llmqs.end()) throw std::ios_base::failure("unknown evo snapshot LLMQ type"); + return *it; +} + +inline size_t SnapshotCommitmentCount(const Consensus::LLMQParams& params, bool rotation_enabled) +{ + if (!rotation_enabled) { + return static_cast(std::max(params.signingActiveQuorumCount + 1, params.keepOldConnections)); + } + const size_t active{static_cast(params.signingActiveQuorumCount)}; + const size_t retained{static_cast(params.keepOldConnections)}; + // Rotation seeding promises the active and previous complete cycles. A + // future parameter set retaining more must extend the serialized cycles. + if (retained > 2 * active) throw std::ios_base::failure("rotated LLMQ retention exceeds two cycles"); + return 2 * active; +} + +/** + * Maximum number of distinct historical work-block lists a snapshot can need. + * The serialized set is deduplicated, so summing every enabled-type horizon is + * conservative: two retained commitment cycles plus H-C..H-4C for rotated + * types, or the retained commitment horizon for non-rotated types. + */ +inline size_t EvoSnapshotMaxHistoricalMNLists() +{ + size_t count{0}; + for (const auto& params : Consensus::available_llmqs) { + count += params.useRotation + ? SnapshotCommitmentCount(params, /*rotation_enabled=*/true) + EVO_SNAPSHOT_ROTATION_CYCLES + : SnapshotCommitmentCount(params, /*rotation_enabled=*/false); + } + return count; +} + +/** + * A historical diff covers one required quorum work-block transition. Allow + * 4,096 net add/update/remove operations per transition (already far above + * plausible per-block MN churn), across the entire params-derived horizon. + * This generous cumulative ceiling prevents individually-valid 100k-entry + * diffs from multiplying decode work across every historical entry. + */ +inline size_t EvoSnapshotMaxHistoricalMNOperations() +{ + return EvoSnapshotMaxHistoricalMNLists() * 4'096; +} + +template +class SnapshotBoundedInput +{ +private: + Stream& m_stream; + uint64_t m_compact_budget; + +public: + SnapshotBoundedInput(Stream& stream, uint64_t compact_budget) : + m_stream{stream}, m_compact_budget{compact_budget} {} + + int GetType() const { return m_stream.GetType(); } + int GetVersion() const { return m_stream.GetVersion(); } + void read(Span dst) { m_stream.read(dst); } + void ignore(size_t size) { m_stream.ignore(size); } + + uint64_t ReadBudgetedCompactSize() + { + const uint64_t size{::ReadCompactSize(m_stream)}; + if (size > m_compact_budget) throw std::ios_base::failure("canonical MN nested CompactSize budget exceeded"); + m_compact_budget -= size; + return size; + } + + template + SnapshotBoundedInput& operator>>(T&& obj) + { + ::Unserialize(*this, obj); + return *this; + } +}; + +template +uint64_t ReadCompactSize(SnapshotBoundedInput& stream) +{ + return stream.ReadBudgetedCompactSize(); +} + +/** + * NetInfoEntry overrides the stream version while decoding its payload. Keep + * the snapshot-local CompactSize budget visible through that transparent + * wrapper so strings are rejected before their deserializer resizes them. + */ +template +uint64_t ReadCompactSize(OverrideStream>& stream) +{ + return stream.GetStream().ReadBudgetedCompactSize(); +} + +/** + * Snapshot-local canonical deterministic-MN encoding. + * + * internalId and nTotalRegisteredCount are intentionally retained. They are + * consensus-deterministic for nodes synced from genesis: registrations assign + * internalId in on-chain order and advance the counter identically. Thus a + * from-genesis background validation re-derives the dumper's exact values. + * Entries are sorted by the full proTxHash, never by immer iteration order. + */ +template +void SerializeCanonicalMNList(Stream& s, const CDeterministicMNList& list) +{ + s << list.GetBlockHash() << list.GetHeightForSnapshotCodec() << list.GetTotalRegisteredCount(); + std::vector mns; + mns.reserve(list.GetCounts().total()); + list.ForEachMNShared(/*onlyValid=*/false, [&](const auto& dmn) { mns.emplace_back(dmn); }); + std::sort(mns.begin(), mns.end(), [](const auto& a, const auto& b) { return a->proTxHash < b->proTxHash; }); + WriteCompactSize(s, mns.size()); + for (const auto& dmn : mns) s << *dmn; +} + +template +CDeterministicMNList UnserializeCanonicalMNList(Stream& s) +{ + uint256 block_hash; + int height; + uint32_t total_registered; + s >> block_hash >> height >> total_registered; + if (height < 0) throw std::ios_base::failure("negative canonical MN-list height"); + CDeterministicMNList list{block_hash, height, total_registered}; + const size_t count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "MN count")}; + uint256 previous; + bool have_previous{false}; + uint64_t max_internal_id{0}; + for (size_t i{0}; i < count; ++i) { + SnapshotBoundedInput bounded{s, EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; + auto dmn{std::make_shared(deserialize, bounded)}; + if (dmn->pdmnState->payouts.size() > EVO_SNAPSHOT_MAX_PAYOUT_SHARES) { + throw std::ios_base::failure("oversized canonical MN payout list"); + } + if (dmn->pdmnState->netInfo->Validate() != NetInfoStatus::Success) { + throw std::ios_base::failure("invalid canonical MN network info"); + } + if (have_previous && !(previous < dmn->proTxHash)) { + throw std::ios_base::failure("noncanonical canonical MN-list order"); + } + previous = dmn->proTxHash; + have_previous = true; + max_internal_id = std::max(max_internal_id, dmn->GetInternalId()); + try { + list.AddMN(dmn, /*fBumpTotalCount=*/false); + } catch (const std::exception& e) { + throw std::ios_base::failure(std::string{"invalid canonical MN list: "} + e.what()); + } + } + if (count != 0 && max_internal_id >= total_registered) { + throw std::ios_base::failure("canonical MN-list internalId exceeds registration counter"); + } + return list; +} + +/** Canonical hash shared by snapshot encoding and M3 completion comparison. */ +uint256 CanonicalMNListHash(const CDeterministicMNList& list); + +/** Canonical snapshot-local encoding of a deterministic-MN list diff. */ +template +void SerializeCanonicalMNListDiff(Stream& s, const CDeterministicMNListDiff& diff) +{ + auto added{diff.addedMNs}; + std::sort(added.begin(), added.end(), [](const auto& a, const auto& b) { + return std::make_tuple(a->GetInternalId(), a->proTxHash) < + std::make_tuple(b->GetInternalId(), b->proTxHash); + }); + WriteCompactSize(s, added.size()); + for (const auto& dmn : added) s << *dmn; + + std::vector updated; + updated.reserve(diff.updatedMNs.size()); + for (const auto& [internal_id, _] : diff.updatedMNs) updated.emplace_back(internal_id); + std::sort(updated.begin(), updated.end()); + WriteCompactSize(s, updated.size()); + for (const uint64_t internal_id : updated) { + WriteVarInt(s, internal_id); + s << diff.updatedMNs.at(internal_id); + } + WriteCompactSize(s, diff.removedMns.size()); + for (const uint64_t internal_id : diff.removedMns) { + WriteVarInt(s, internal_id); + } +} + +template +CDeterministicMNListDiff UnserializeCanonicalMNListDiff(Stream& s, size_t& remaining_operations) +{ + CDeterministicMNListDiff diff; + const size_t added_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "MN-diff additions")}; + if (added_count > remaining_operations) throw std::ios_base::failure("historical MN-diff operation budget exceeded"); + remaining_operations -= added_count; + uint64_t previous_id{0}; + bool have_previous{false}; + diff.addedMNs.reserve(added_count); + for (size_t i{0}; i < added_count; ++i) { + SnapshotBoundedInput bounded{s, EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; + auto dmn{std::make_shared(deserialize, bounded)}; + if ((have_previous && previous_id >= dmn->GetInternalId()) || + dmn->pdmnState->payouts.size() > EVO_SNAPSHOT_MAX_PAYOUT_SHARES || + dmn->pdmnState->netInfo->Validate() != NetInfoStatus::Success) { + throw std::ios_base::failure("noncanonical canonical MN-diff addition"); + } + previous_id = dmn->GetInternalId(); + have_previous = true; + diff.addedMNs.emplace_back(std::move(dmn)); + } + + const size_t updated_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "MN-diff updates")}; + if (updated_count > remaining_operations) throw std::ios_base::failure("historical MN-diff operation budget exceeded"); + remaining_operations -= updated_count; + previous_id = 0; + have_previous = false; + for (size_t i{0}; i < updated_count; ++i) { + const uint64_t internal_id{ReadVarInt(s)}; + if (have_previous && previous_id >= internal_id) { + throw std::ios_base::failure("noncanonical canonical MN-diff update order"); + } + SnapshotBoundedInput bounded{s, EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; + diff.updatedMNs.emplace(internal_id, CDeterministicMNStateDiff(deserialize, bounded)); + previous_id = internal_id; + have_previous = true; + } + + const size_t removed_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "MN-diff removals")}; + if (removed_count > remaining_operations) throw std::ios_base::failure("historical MN-diff operation budget exceeded"); + remaining_operations -= removed_count; + previous_id = 0; + have_previous = false; + for (size_t i{0}; i < removed_count; ++i) { + const uint64_t internal_id{ReadVarInt(s)}; + if (have_previous && previous_id >= internal_id) { + throw std::ios_base::failure("noncanonical canonical MN-diff removal order"); + } + diff.removedMns.emplace(internal_id); + previous_id = internal_id; + have_previous = true; + } + return diff; +} + +template +CDeterministicMNListDiff UnserializeCanonicalMNListDiff(Stream& s) +{ + size_t remaining_operations{EvoSnapshotMaxHistoricalMNOperations()}; + return UnserializeCanonicalMNListDiff(s, remaining_operations); +} + +struct CMinedQuorumCommitment { + uint256 quorum_base_block_hash; + uint256 work_block_hash; + llmq::CFinalCommitment commitment; + uint256 mined_block_hash; + + SERIALIZE_METHODS(CMinedQuorumCommitment, obj) + { + READWRITE(obj.quorum_base_block_hash, obj.work_block_hash, obj.commitment, obj.mined_block_hash); + } +}; + +template +CMinedQuorumCommitment ReadMinedQuorumCommitment(Stream& s, const Consensus::LLMQParams& params) +{ + CMinedQuorumCommitment entry; + auto& commitment{entry.commitment}; + s >> entry.quorum_base_block_hash >> entry.work_block_hash >> commitment.nVersion >> commitment.llmqType >> commitment.quorumHash; + const bool indexed{commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION || + commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION}; + if (indexed) s >> commitment.quorumIndex; + const size_t signers_size{ReadBoundedCompactSize(s, params.size, "commitment signers")}; + if (signers_size != static_cast(params.size)) { + throw std::ios_base::failure("invalid evo snapshot commitment signers size"); + } + ReadFixedBitSet(s, commitment.signers, signers_size); + const size_t valid_members_size{ReadBoundedCompactSize(s, params.size, "commitment valid members")}; + if (valid_members_size != static_cast(params.size)) { + throw std::ios_base::failure("invalid evo snapshot commitment valid-members size"); + } + ReadFixedBitSet(s, commitment.validMembers, valid_members_size); + const bool legacy{commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION || + commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION}; + s >> CBLSPublicKeyVersionWrapper(commitment.quorumPublicKey, legacy) >> commitment.quorumVvecHash >> + CBLSSignatureVersionWrapper(commitment.quorumSig, legacy) >> + CBLSSignatureVersionWrapper(commitment.membersSig, legacy); + // The consensus/P2P serializer remains unchanged; this snapshot-local path + // bounds both bitsets before allocation and verifies the decoded object. + if (!entry.commitment.VerifySizes(params)) { + throw std::ios_base::failure("invalid evo snapshot commitment bitset size"); + } + s >> entry.mined_block_hash; + return entry; +} + +struct CQuorumSnapshotEntry { + uint256 cycle_base_block_hash; + uint256 work_block_hash; + llmq::CQuorumSnapshot snapshot; +}; + +struct CHistoricalMNListDiff { + uint256 previous_block_hash; + uint256 block_hash; + int height{-1}; + uint32_t total_registered_count{0}; + uint256 canonical_list_hash; + CDeterministicMNListDiff diff; +}; + +struct CQuorumModifier { + Consensus::LLMQType llmq_type{Consensus::LLMQType::LLMQ_NONE}; + uint256 work_block_hash; + uint256 modifier; + + SERIALIZE_METHODS(CQuorumModifier, obj) + { + READWRITE(obj.llmq_type, obj.work_block_hash, obj.modifier); + } +}; + +struct CQuorumSnapshotData { + Consensus::LLMQType llmq_type{Consensus::LLMQType::LLMQ_NONE}; + bool rotation_enabled{false}; + std::vector active_commitments; + std::vector safety_commitments; + std::vector rotation_snapshots; + + template void Serialize(Stream& s) const; + template void Unserialize(Stream& s); +}; + +/** Canonical Dash-derived state attached to an assumeutxo snapshot. */ +class CEvoSnapshot +{ +public: + uint16_t version{EVO_SNAPSHOT_VERSION}; + uint256 base_block_hash; + CDeterministicMNList mn_list; + std::vector quorums; + std::vector historical_mn_list_diffs; + std::vector quorum_modifiers; + CCreditPool credit_pool; + AbstractEHFManager::Signals mnhf_signals; + + template void Serialize(Stream& s) const; + template void Unserialize(Stream& s); + + /** Validate invariants not requiring chainstate or block-index lookup. */ + void Validate(bool require_canonical_order = false) const; +}; + +template +void WriteSnapshotVector(Stream& s, const std::vector& values, WriteOne&& write_one) +{ + WriteCompactSize(s, values.size()); + for (const auto& value : values) write_one(value); +} + +template +void WriteRotationSnapshot(Stream& s, const CQuorumSnapshotEntry& entry) +{ + s << entry.cycle_base_block_hash << entry.work_block_hash << entry.snapshot.mnSkipListMode; + WriteCompactSize(s, entry.snapshot.activeQuorumMembers.size()); + WriteFixedBitSet(s, entry.snapshot.activeQuorumMembers, entry.snapshot.activeQuorumMembers.size()); + s << entry.snapshot.mnSkipList; +} + +template +CQuorumSnapshotEntry ReadRotationSnapshot(Stream& s, const Consensus::LLMQParams& params) +{ + CQuorumSnapshotEntry entry; + s >> entry.cycle_base_block_hash >> entry.work_block_hash >> entry.snapshot.mnSkipListMode; + // BuildQuorumSnapshot sizes this bitset to the complete work-block MN list, + // not to the quorum size. The exact historical-list size is chain-aware and + // is checked by the chain-aware validation layered on later in the series. + const size_t bit_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "rotation bitset")}; + ReadFixedBitSet(s, entry.snapshot.activeQuorumMembers, bit_count); + const size_t skip_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_SKIPLIST_ENTRIES, "rotation skip list")}; + // Clamp the upfront allocation: a hostile claimed count must pay with its + // own serialized bytes, not with a proportional reserve. + entry.snapshot.mnSkipList.reserve(std::min(skip_count, params.size)); + for (size_t i{0}; i < skip_count; ++i) { + int value; + s >> value; + entry.snapshot.mnSkipList.emplace_back(value); + } + return entry; +} + +template +void CQuorumSnapshotData::Serialize(Stream& s) const +{ + auto active{active_commitments}; + auto safety{safety_commitments}; + auto snapshots{rotation_snapshots}; + const auto commitment_less = [](const auto& a, const auto& b) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + }; + std::sort(active.begin(), active.end(), commitment_less); + std::sort(safety.begin(), safety.end(), commitment_less); + std::sort(snapshots.begin(), snapshots.end(), + [](const auto& a, const auto& b) { return a.cycle_base_block_hash < b.cycle_base_block_hash; }); + s << llmq_type << rotation_enabled << active << safety; + WriteSnapshotVector(s, snapshots, [&](const auto& entry) { WriteRotationSnapshot(s, entry); }); +} + +template +void CQuorumSnapshotData::Unserialize(Stream& s) +{ + s >> llmq_type >> rotation_enabled; + const auto& params{SnapshotLLMQParams(llmq_type)}; + const size_t total_count{SnapshotCommitmentCount(params, rotation_enabled)}; + const size_t expected_active{static_cast(params.signingActiveQuorumCount)}; + const size_t active_count{ReadBoundedCompactSize(s, expected_active, "active commitments")}; + active_commitments.reserve(active_count); + for (size_t i{0}; i < active_count; ++i) { + active_commitments.emplace_back(ReadMinedQuorumCommitment(s, params)); + } + const size_t safety_count{ReadBoundedCompactSize(s, total_count - expected_active, "safety commitments")}; + safety_commitments.reserve(safety_count); + for (size_t i{0}; i < safety_count; ++i) { + safety_commitments.emplace_back(ReadMinedQuorumCommitment(s, params)); + } + const size_t snapshot_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_ROTATION_CYCLES, "rotation snapshots")}; + rotation_snapshots.reserve(snapshot_count); + for (size_t i{0}; i < snapshot_count; ++i) rotation_snapshots.emplace_back(ReadRotationSnapshot(s, params)); +} + +template +void CEvoSnapshot::Serialize(Stream& s) const +{ + auto sorted_quorums{quorums}; + auto sorted_history{historical_mn_list_diffs}; + auto sorted_modifiers{quorum_modifiers}; + std::sort(sorted_quorums.begin(), sorted_quorums.end(), + [](const auto& a, const auto& b) { return a.llmq_type < b.llmq_type; }); + std::sort(sorted_history.begin(), sorted_history.end(), [](const auto& a, const auto& b) { + return std::tie(a.height, a.block_hash) > std::tie(b.height, b.block_hash); + }); + std::sort(sorted_modifiers.begin(), sorted_modifiers.end(), [](const auto& a, const auto& b) { + return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); + }); + s << version << base_block_hash; + SerializeCanonicalMNList(s, mn_list); + s << sorted_quorums; + WriteCompactSize(s, sorted_history.size()); + for (const auto& entry : sorted_history) { + s << entry.previous_block_hash << entry.block_hash << entry.height << entry.total_registered_count << entry.canonical_list_hash; + SerializeCanonicalMNListDiff(s, entry.diff); + } + s << sorted_modifiers; + s << credit_pool; + WriteCompactSize(s, mnhf_signals.size()); + for (const auto& signal : mnhf_signals) s << signal; +} + +template +void CEvoSnapshot::Unserialize(Stream& s) +{ + s >> version; + if (version != EVO_SNAPSHOT_VERSION) throw std::ios_base::failure("unsupported evo snapshot version"); + s >> base_block_hash; + mn_list = UnserializeCanonicalMNList(s); + const size_t quorum_count{ReadBoundedCompactSize(s, Consensus::available_llmqs.size(), "quorum-type count")}; + quorums.reserve(quorum_count); + for (size_t i{0}; i < quorum_count; ++i) { + CQuorumSnapshotData data; + s >> data; + quorums.emplace_back(std::move(data)); + } + const size_t history_count{ReadBoundedCompactSize(s, EvoSnapshotMaxHistoricalMNLists(), + "historical MN-list count")}; + historical_mn_list_diffs.reserve(history_count); + size_t remaining_history_operations{EvoSnapshotMaxHistoricalMNOperations()}; + for (size_t i{0}; i < history_count; ++i) { + CHistoricalMNListDiff entry; + s >> entry.previous_block_hash >> entry.block_hash >> entry.height >> entry.total_registered_count >> entry.canonical_list_hash; + entry.diff = UnserializeCanonicalMNListDiff(s, remaining_history_operations); + historical_mn_list_diffs.emplace_back(std::move(entry)); + } + const size_t modifier_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MODIFIERS, "quorum modifier count")}; + quorum_modifiers.reserve(modifier_count); + for (size_t i{0}; i < modifier_count; ++i) { + CQuorumModifier modifier; + s >> modifier; + quorum_modifiers.emplace_back(std::move(modifier)); + } + s >> credit_pool.locked >> credit_pool.currentLimit >> credit_pool.latelyUnlocked; + credit_pool.indexes.UnserializeBounded(s, EVO_SNAPSHOT_MAX_RANGES); + const size_t signal_count{ReadBoundedCompactSize(s, Consensus::MAX_VERSION_BITS_DEPLOYMENTS, "MNHF signals")}; + for (size_t i{0}; i < signal_count; ++i) { + std::pair signal; + s >> signal; + if (!mnhf_signals.emplace(signal).second) throw std::ios_base::failure("duplicate MNHF signal bit"); + } + Validate(/*require_canonical_order=*/true); +} + +/** Single SHA256 of the canonical SER_DISK/CLIENT_VERSION encoding. */ +uint256 GetEvoSnapshotHash(const CEvoSnapshot& snapshot); + +struct CQuorumReconstructionHeight { + Consensus::LLMQType llmq_type; + bool rotation; + int quorum_height; + int work_height; +}; + +/** Pure conservative reconstruction horizon for the supplied enabled types. */ +std::vector EvoSnapshotReconstructionHeights( + int base_height, const std::vector& enabled_llmqs); + +/** Apply the complete diff chain and return lists keyed by target block hash. */ +bool ReconstructHistoricalMNLists(const CEvoSnapshot& snapshot, + std::map& lists, std::string& error); + +/** Pure CbTx checks over already-built snapshot content. */ +bool VerifyEvoSnapshotCbTx(const CEvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error); + +} // namespace evo + +#endif // BITCOIN_EVO_SNAPSHOT_H diff --git a/src/streams.h b/src/streams.h index ae2679b97826..bac54515a518 100644 --- a/src/streams.h +++ b/src/streams.h @@ -62,6 +62,7 @@ class OverrideStream int GetVersion() const { return nVersion; } int GetType() const { return nType; } + Stream& GetStream() { return *stream; } size_t size() const { return stream->size(); } void ignore(size_t size) { return stream->ignore(size); } }; diff --git a/src/test/evo_netinfo_tests.cpp b/src/test/evo_netinfo_tests.cpp index 2211720c6aaf..0e33280fa3bc 100644 --- a/src/test/evo_netinfo_tests.cpp +++ b/src/test/evo_netinfo_tests.cpp @@ -689,4 +689,28 @@ BOOST_FIXTURE_TEST_CASE(extnetinfo_validate_deser, RegTestingSetup) } } +BOOST_AUTO_TEST_CASE(domain_port_wire_compatibility) +{ + DomainPort domain; + BOOST_REQUIRE_EQUAL(domain.Set("example.com", 443), DomainPort::Status::Success); + + CDataStream encoded{SER_NETWORK, CLIENT_VERSION}; + encoded << domain; + CDataStream expected{SER_NETWORK, CLIENT_VERSION}; + expected << std::string{"example.com"} << Using>(uint16_t{443}); + BOOST_CHECK_EQUAL_COLLECTIONS(encoded.begin(), encoded.end(), expected.begin(), expected.end()); + + CDataStream oversized{SER_NETWORK, CLIENT_VERSION}; + oversized << NetInfoEntry::NetInfoType::Domain; + constexpr size_t MAX_DOMAIN_LENGTH{253}; + WriteCompactSize(oversized, MAX_DOMAIN_LENGTH + 1); + const std::string oversized_addr(MAX_DOMAIN_LENGTH + 1, 'a'); + oversized.write(MakeByteSpan(oversized_addr)); + oversized << Using>(uint16_t{443}); + + NetInfoEntry entry; + BOOST_CHECK_EXCEPTION(oversized >> entry, std::ios_base::failure, + [](const auto& e) { return std::string{e.what()}.find("String length limit exceeded") != std::string::npos; }); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp new file mode 100644 index 000000000000..6d615b2b3ca9 --- /dev/null +++ b/src/test/evo_snapshot_tests.cpp @@ -0,0 +1,637 @@ +// 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. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace { + +uint256 H(uint8_t value) +{ + uint256 hash; + hash.begin()[0] = value; + return hash; +} + +uint256 CollidingH(uint8_t suffix) +{ + uint256 hash; + std::fill_n(hash.begin(), 8, 0xa5); + hash.begin()[8] = suffix; + return hash; +} + +uint160 H160(uint8_t value) +{ + uint160 hash; + hash.begin()[0] = value; + return hash; +} + +CDeterministicMNCPtr MN(uint64_t internal_id, uint8_t hash_suffix, MnType type, int version, uint8_t address_tag) +{ + auto state{std::make_shared()}; + state->nVersion = version; + state->nRegisteredHeight = 10 + internal_id; + state->nLastPaidHeight = 20 + internal_id; + state->nPoSePenalty = internal_id; + state->keyIDOwner = CKeyID{H160(address_tag)}; + state->keyIDVoting = CKeyID{H160(address_tag + 20)}; + state->scriptPayout = CScript{} << OP_RETURN << std::vector{address_tag, 1}; + state->scriptOperatorPayout = CScript{} << OP_RETURN << std::vector{address_tag, 2}; + state->netInfo = NetInfoInterface::MakeNetInfo(version); + BOOST_REQUIRE_EQUAL(state->netInfo->AddEntry(NetInfoPurpose::CORE_P2P, + strprintf("1.1.1.%d:%d", address_tag, Params().GetDefaultPort())), + NetInfoStatus::Success); + if (type == MnType::Evo) { + state->platformNodeID = H160(address_tag + 40); + BOOST_REQUIRE_EQUAL(state->netInfo->AddEntry(NetInfoPurpose::PLATFORM_P2P, + strprintf("2.2.2.%d:26657", address_tag)), + NetInfoStatus::Success); + BOOST_REQUIRE_EQUAL(state->netInfo->AddEntry(NetInfoPurpose::PLATFORM_HTTPS, + strprintf("evo%d.example.org:443", address_tag)), + NetInfoStatus::Success); + } + + auto dmn{std::make_shared(internal_id, type)}; + dmn->proTxHash = CollidingH(hash_suffix); + dmn->collateralOutpoint = COutPoint(H(address_tag + 80), internal_id); + dmn->nOperatorReward = address_tag * 10; + state->UpdateConfirmedHash(dmn->proTxHash, H(address_tag + 100)); + dmn->pdmnState = std::move(state); + return dmn; +} + +CDeterministicMNList MNList(const uint256& block_hash, int height, bool reverse) +{ + CDeterministicMNList list{block_hash, height, 10}; + std::vector mns{ + MN(2, 3, MnType::Regular, ProTxVersion::LegacyBLS, 3), + MN(5, 1, MnType::Evo, ProTxVersion::ExtAddr, 5), + MN(7, 2, MnType::Regular, ProTxVersion::LegacyBLS, 7), + }; + if (reverse) std::reverse(mns.begin(), mns.end()); + for (const auto& dmn : mns) list.AddMN(dmn, /*fBumpTotalCount=*/false); + return list; +} + +evo::CMinedQuorumCommitment Commitment(Consensus::LLMQType type, uint8_t quorum, uint8_t mined, bool rotated, + int16_t index = 0) +{ + llmq::CFinalCommitment commitment; + commitment.nVersion = rotated ? llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION + : llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION; + commitment.llmqType = type; + commitment.quorumHash = H(quorum); + commitment.quorumIndex = index; + const auto& params{evo::SnapshotLLMQParams(type)}; + commitment.signers.resize(params.size); + commitment.validMembers.resize(params.size); + return {H(quorum), H(quorum + 120), std::move(commitment), H(mined)}; +} + +evo::CEvoSnapshot SyntheticSnapshot(bool reverse_representation = false) +{ + evo::CEvoSnapshot snapshot; + snapshot.base_block_hash = H(42); + snapshot.mn_list = MNList(snapshot.base_block_hash, 500, reverse_representation); + snapshot.credit_pool.locked = 123456; + snapshot.credit_pool.currentLimit = 700; + snapshot.credit_pool.latelyUnlocked = 11; + if (reverse_representation) { + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(15)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(8)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(7)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(9)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Remove(9)); + snapshot.mnhf_signals.emplace(9, 30); + snapshot.mnhf_signals.emplace(2, 12); + } else { + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(7)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(8)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(15)); + snapshot.mnhf_signals.emplace(2, 12); + snapshot.mnhf_signals.emplace(9, 30); + } + + evo::CQuorumSnapshotData plain; + plain.llmq_type = Consensus::LLMQType::LLMQ_TEST; + plain.active_commitments = {Commitment(plain.llmq_type, 11, 51, false), Commitment(plain.llmq_type, 12, 52, false)}; + plain.safety_commitments = {Commitment(plain.llmq_type, 10, 50, false)}; + + evo::CQuorumSnapshotData rotated; + rotated.llmq_type = Consensus::LLMQType::LLMQ_TEST_DIP0024; + rotated.rotation_enabled = true; + rotated.active_commitments = {Commitment(rotated.llmq_type, 31, 71, true, 0), + Commitment(rotated.llmq_type, 32, 72, true, 1)}; + rotated.safety_commitments = {Commitment(rotated.llmq_type, 21, 61, true, 0), + Commitment(rotated.llmq_type, 22, 62, true, 1)}; + for (uint8_t i{1}; i <= evo::EVO_SNAPSHOT_ROTATION_CYCLES; ++i) { + const auto mode{i == 2 ? SnapshotSkipMode::MODE_SKIPPING_ENTRIES : SnapshotSkipMode::MODE_NO_SKIPPING}; + rotated.rotation_snapshots.push_back( + {H(40 + i), H(100 + i), llmq::CQuorumSnapshot{{true, false, true, false}, mode, i == 2 ? std::vector{1} : std::vector{}}}); + } + + snapshot.quorums = {std::move(plain), std::move(rotated)}; + std::set work_hashes; + std::set> modifier_keys; + for (const auto& data : snapshot.quorums) { + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + work_hashes.insert(entry.work_block_hash); + modifier_keys.emplace(data.llmq_type, entry.work_block_hash); + } + } + for (const auto& entry : data.rotation_snapshots) { + work_hashes.insert(entry.work_block_hash); + modifier_keys.emplace(data.llmq_type, entry.work_block_hash); + } + } + CDeterministicMNList previous{snapshot.mn_list}; + uint256 previous_hash{snapshot.base_block_hash}; + int height{499}; + for (const auto& work_hash : work_hashes) { + auto list{MNList(work_hash, height--, reverse_representation)}; + snapshot.historical_mn_list_diffs.push_back({previous_hash, work_hash, list.GetHeightForSnapshotCodec(), + list.GetTotalRegisteredCount(), evo::CanonicalMNListHash(list), + previous.BuildDiff(list)}); + previous_hash = work_hash; + previous = std::move(list); + } + for (const auto& [type, work_hash] : modifier_keys) { + snapshot.quorum_modifiers.push_back({type, work_hash, H(static_cast(150 + snapshot.quorum_modifiers.size()))}); + } + if (reverse_representation) { + std::reverse(snapshot.quorums.begin(), snapshot.quorums.end()); + std::reverse(snapshot.historical_mn_list_diffs.begin(), snapshot.historical_mn_list_diffs.end()); + std::reverse(snapshot.quorum_modifiers.begin(), snapshot.quorum_modifiers.end()); + for (auto& data : snapshot.quorums) { + std::reverse(data.active_commitments.begin(), data.active_commitments.end()); + std::reverse(data.safety_commitments.begin(), data.safety_commitments.end()); + std::reverse(data.rotation_snapshots.begin(), data.rotation_snapshots.end()); + } + } + return snapshot; +} + +CDataStream SerializeSnapshot(const evo::CEvoSnapshot& snapshot) +{ + CDataStream stream{SER_DISK, CLIENT_VERSION}; + stream << snapshot; + return stream; +} + +void CheckInvalid(evo::CEvoSnapshot snapshot) +{ + BOOST_CHECK_THROW(snapshot.Validate(), std::ios_base::failure); +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(evo_snapshot_tests) + +BOOST_FIXTURE_TEST_CASE(populated_roundtrip_and_representation_independence, BasicTestingSetup) +{ + const auto forward{SyntheticSnapshot()}; + const auto reverse{SyntheticSnapshot(/*reverse_representation=*/true)}; + const auto forward_bytes{SerializeSnapshot(forward)}; + const auto reverse_bytes{SerializeSnapshot(reverse)}; + BOOST_CHECK_EQUAL_COLLECTIONS(forward_bytes.begin(), forward_bytes.end(), reverse_bytes.begin(), reverse_bytes.end()); + BOOST_CHECK(evo::CanonicalMNListHash(forward.mn_list) == evo::CanonicalMNListHash(reverse.mn_list)); + BOOST_CHECK(GetEvoSnapshotHash(forward) == GetEvoSnapshotHash(reverse)); + + CDataStream input{forward_bytes}; + evo::CEvoSnapshot decoded; + input >> decoded; + BOOST_CHECK(input.empty()); + const auto decoded_bytes{SerializeSnapshot(decoded)}; + BOOST_CHECK_EQUAL_COLLECTIONS(forward_bytes.begin(), forward_bytes.end(), decoded_bytes.begin(), decoded_bytes.end()); + BOOST_CHECK(evo::CanonicalMNListHash(decoded.mn_list) == evo::CanonicalMNListHash(forward.mn_list)); + BOOST_CHECK_EQUAL(decoded.mn_list.GetCounts().total(), 3U); + BOOST_CHECK_EQUAL(decoded.historical_mn_list_diffs.size(), forward.historical_mn_list_diffs.size()); + BOOST_CHECK(decoded.credit_pool.indexes.Contains(7)); + BOOST_CHECK(decoded.credit_pool.indexes.Contains(8)); + BOOST_CHECK(decoded.credit_pool.indexes.Contains(15)); + BOOST_CHECK(decoded.mnhf_signals == forward.mnhf_signals); + + for (const auto internal_id : {2U, 5U, 7U}) { + const auto original{forward.mn_list.GetMNByInternalId(internal_id)}; + BOOST_REQUIRE(original); + const auto by_hash{decoded.mn_list.GetMN(original->proTxHash)}; + const auto by_id{decoded.mn_list.GetMNByInternalId(internal_id)}; + const auto by_collateral{decoded.mn_list.GetUniquePropertyMN(original->collateralOutpoint)}; + const auto by_owner{decoded.mn_list.GetUniquePropertyMN(original->pdmnState->keyIDOwner)}; + const auto by_service{decoded.mn_list.GetMNByService(original->pdmnState->netInfo->GetPrimary())}; + BOOST_REQUIRE(by_hash); + BOOST_REQUIRE(by_id); + BOOST_REQUIRE(by_collateral); + BOOST_REQUIRE(by_owner); + BOOST_REQUIRE(by_service); + BOOST_CHECK(by_hash->proTxHash == original->proTxHash); + BOOST_CHECK(by_id->proTxHash == original->proTxHash); + BOOST_CHECK(by_collateral->proTxHash == original->proTxHash); + BOOST_CHECK(by_owner->proTxHash == original->proTxHash); + BOOST_CHECK(by_service->proTxHash == original->proTxHash); + } +} + +BOOST_AUTO_TEST_CASE(reconstruction_horizon_height_enumeration) +{ + const auto rotated{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + const auto plain{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + const int base_height{20 * rotated.dkgInterval + 7}; + const auto heights{evo::EvoSnapshotReconstructionHeights(base_height, {rotated, plain})}; + BOOST_REQUIRE_EQUAL(heights.size(), evo::EVO_SNAPSHOT_ROTATION_CYCLES + + evo::SnapshotCommitmentCount(plain, false)); + const int rotated_h{base_height - base_height % rotated.dkgInterval}; + for (size_t i{0}; i < evo::EVO_SNAPSHOT_ROTATION_CYCLES; ++i) { + const int expected_cycle{rotated_h - static_cast(i + 1) * rotated.dkgInterval}; + BOOST_CHECK(heights[i].rotation); + BOOST_CHECK_EQUAL(heights[i].quorum_height, expected_cycle); + BOOST_CHECK_EQUAL(heights[i].work_height, expected_cycle - llmq::WORK_DIFF_DEPTH); + } + const int plain_h{base_height - base_height % plain.dkgInterval}; + for (size_t i{0}; i < evo::SnapshotCommitmentCount(plain, false); ++i) { + const auto& height{heights[evo::EVO_SNAPSHOT_ROTATION_CYCLES + i]}; + BOOST_CHECK(!height.rotation); + BOOST_CHECK_EQUAL(height.quorum_height, plain_h - static_cast(i) * plain.dkgInterval); + BOOST_CHECK_EQUAL(height.work_height, height.quorum_height - llmq::WORK_DIFF_DEPTH); + } +} + +BOOST_AUTO_TEST_CASE(rotation_bitset_larger_than_quorum_roundtrips) +{ + const auto& params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + evo::CQuorumSnapshotEntry entry; + entry.cycle_base_block_hash = H(1); + entry.work_block_hash = H(2); + entry.snapshot.activeQuorumMembers.resize(params.size + 3); + entry.snapshot.activeQuorumMembers[params.size + 1] = true; + entry.snapshot.mnSkipListMode = SnapshotSkipMode::MODE_NO_SKIPPING; + + CDataStream stream{SER_DISK, CLIENT_VERSION}; + evo::WriteRotationSnapshot(stream, entry); + const auto decoded{evo::ReadRotationSnapshot(stream, params)}; + BOOST_CHECK(stream.empty()); + BOOST_CHECK_EQUAL(decoded.snapshot.activeQuorumMembers.size(), params.size + 3U); + BOOST_CHECK(decoded.snapshot.activeQuorumMembers[params.size + 1]); +} + +BOOST_FIXTURE_TEST_CASE(populated_v3_golden_value, BasicTestingSetup) +{ + BOOST_CHECK_EQUAL(GetEvoSnapshotHash(SyntheticSnapshot()).ToString(), + "bb1985a651ed3110218a3c8d65d77c85facdc544d6b9203f0815d5785c1f01ff"); +} + +BOOST_FIXTURE_TEST_CASE(canonical_mn_reader_rejects_order_and_counter, BasicTestingSetup) +{ + BOOST_CHECK(evo::CanonicalMNListHash(CDeterministicMNList{}) == + evo::CanonicalMNListHash(CDeterministicMNList{})); + const auto write_raw = [](uint32_t total, std::vector mns) { + CDataStream stream{SER_DISK, CLIENT_VERSION}; + stream << H(42) << 42 << total; + WriteCompactSize(stream, mns.size()); + for (const auto& dmn : mns) stream << *dmn; + return stream; + }; + auto unsorted{write_raw(10, {MN(2, 2, MnType::Regular, ProTxVersion::LegacyBLS, 2), + MN(1, 1, MnType::Regular, ProTxVersion::LegacyBLS, 1)})}; + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNList(unsorted), std::ios_base::failure); + auto bad_counter{write_raw(2, {MN(2, 1, MnType::Regular, ProTxVersion::LegacyBLS, 1)})}; + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNList(bad_counter), std::ios_base::failure); +} + +BOOST_FIXTURE_TEST_CASE(diff_chain_roundtrip_and_canonical_determinism, BasicTestingSetup) +{ + const auto base{MNList(H(10), 100, false)}; + auto target{base}; + target.RemoveMN(base.GetMNByInternalId(2)->proTxHash); + target.AddMN(MN(8, 8, MnType::Regular, ProTxVersion::LegacyBLS, 8)); + for (const uint64_t id : {5, 7}) { + const auto dmn{target.GetMNByInternalId(id)}; + auto state{std::make_shared(*dmn->pdmnState)}; + state->nLastPaidHeight += static_cast(id); + target.UpdateMN(*dmn, state); + } + const auto diff{base.BuildDiff(target)}; + auto permuted{diff}; + std::reverse(permuted.addedMNs.begin(), permuted.addedMNs.end()); + std::vector> updates(permuted.updatedMNs.begin(), + permuted.updatedMNs.end()); + std::reverse(updates.begin(), updates.end()); + permuted.updatedMNs.clear(); + for (auto& update : updates) permuted.updatedMNs.emplace(std::move(update)); + + CDataStream canonical{SER_DISK, CLIENT_VERSION}; + CDataStream reordered{SER_DISK, CLIENT_VERSION}; + evo::SerializeCanonicalMNListDiff(canonical, diff); + evo::SerializeCanonicalMNListDiff(reordered, permuted); + BOOST_CHECK_EQUAL_COLLECTIONS(canonical.begin(), canonical.end(), reordered.begin(), reordered.end()); + + auto decoded{evo::UnserializeCanonicalMNListDiff(canonical)}; + auto reconstructed{base}; + reconstructed.ApplyDiffForSnapshot(H(11), 99, target.GetTotalRegisteredCount(), decoded); + target.ApplyDiffForSnapshot(H(11), 99, target.GetTotalRegisteredCount(), CDeterministicMNListDiff{}); + BOOST_CHECK(evo::CanonicalMNListHash(reconstructed) == evo::CanonicalMNListHash(target)); + BOOST_CHECK(canonical.empty()); +} + +BOOST_FIXTURE_TEST_CASE(historical_diff_decode_has_cumulative_operation_budget, BasicTestingSetup) +{ + CDeterministicMNListDiff one_removal; + one_removal.removedMns.emplace(1); + CDataStream first{SER_DISK, CLIENT_VERSION}; + CDataStream second{SER_DISK, CLIENT_VERSION}; + evo::SerializeCanonicalMNListDiff(first, one_removal); + evo::SerializeCanonicalMNListDiff(second, one_removal); + + size_t remaining_operations{1}; + const auto decoded{evo::UnserializeCanonicalMNListDiff(first, remaining_operations)}; + BOOST_CHECK_EQUAL(decoded.removedMns.size(), 1U); + BOOST_CHECK_EQUAL(remaining_operations, 0U); + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNListDiff(second, remaining_operations), + std::ios_base::failure); + BOOST_CHECK(evo::EvoSnapshotMaxHistoricalMNLists() < 2'048U); +} + +BOOST_FIXTURE_TEST_CASE(context_free_validation_matrix, BasicTestingSetup) +{ + auto snapshot{SyntheticSnapshot()}; + snapshot.version++; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.base_block_hash = H(1); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + std::reverse(snapshot.quorums.begin(), snapshot.quorums.end()); + BOOST_CHECK_THROW(snapshot.Validate(/*require_canonical_order=*/true), std::ios_base::failure); + snapshot = SyntheticSnapshot(); + std::reverse(snapshot.historical_mn_list_diffs.begin(), snapshot.historical_mn_list_diffs.end()); + BOOST_CHECK_THROW(snapshot.Validate(/*require_canonical_order=*/true), std::ios_base::failure); + snapshot = SyntheticSnapshot(); + std::reverse(snapshot.quorums[0].active_commitments.begin(), snapshot.quorums[0].active_commitments.end()); + BOOST_CHECK_THROW(snapshot.Validate(/*require_canonical_order=*/true), std::ios_base::failure); + snapshot = SyntheticSnapshot(); + std::reverse(snapshot.quorums[1].rotation_snapshots.begin(), snapshot.quorums[1].rotation_snapshots.end()); + BOOST_CHECK_THROW(snapshot.Validate(/*require_canonical_order=*/true), std::ios_base::failure); + snapshot = SyntheticSnapshot(); + snapshot.historical_mn_list_diffs[0].block_hash = H(1); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[1].rotation_snapshots[0].work_block_hash = H(1); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[0].llmq_type = Consensus::LLMQType::LLMQ_NONE; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[0].rotation_enabled = true; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[0].active_commitments.pop_back(); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[0].safety_commitments.clear(); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[1].rotation_snapshots.pop_back(); + CheckInvalid(snapshot); + + const auto mutate_commitment = [](auto mutation) { + auto value{SyntheticSnapshot()}; + mutation(value.quorums[0].active_commitments[0]); + CheckInvalid(std::move(value)); + }; + mutate_commitment([](auto& e) { e.quorum_base_block_hash.SetNull(); }); + mutate_commitment([](auto& e) { e.mined_block_hash.SetNull(); }); + mutate_commitment([](auto& e) { e.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST_PLATFORM; }); + mutate_commitment([](auto& e) { e.commitment.quorumHash = H(99); }); + mutate_commitment([](auto& e) { e.commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION; }); + mutate_commitment([](auto& e) { e.commitment.nVersion = 99; }); + snapshot = SyntheticSnapshot(); + snapshot.quorums[1].active_commitments[1].commitment.quorumIndex = 0; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[1].active_commitments[1].commitment.quorumIndex = 2; + CheckInvalid(snapshot); + + const auto mutate_rotation = [](auto mutation) { + auto value{SyntheticSnapshot()}; + mutation(value.quorums[1].rotation_snapshots[0]); + CheckInvalid(std::move(value)); + }; + mutate_rotation([](auto& e) { e.cycle_base_block_hash.SetNull(); }); + mutate_rotation([](auto& e) { e.work_block_hash.SetNull(); }); + mutate_rotation([](auto& e) { e.snapshot.mnSkipListMode = static_cast(9); }); + mutate_rotation([](auto& e) { e.snapshot.activeQuorumMembers.resize(evo::EVO_SNAPSHOT_MAX_MNS + 1); }); + mutate_rotation([](auto& e) { e.snapshot.mnSkipList = {-1}; }); + + // A cycle's skip list accumulates across every quorum index, so lengths + // beyond a single quorum's size and negative wraparound deltas after the + // first (absolute) entry are legitimate. + auto aggregate_skips{SyntheticSnapshot()}; + auto& rotation_entry{aggregate_skips.quorums[1].rotation_snapshots[0]}; + const auto& rotation_params{evo::SnapshotLLMQParams(aggregate_skips.quorums[1].llmq_type)}; + rotation_entry.snapshot.mnSkipListMode = SnapshotSkipMode::MODE_SKIPPING_ENTRIES; + rotation_entry.snapshot.mnSkipList.assign(static_cast(rotation_params.size) + 2, 1); + rotation_entry.snapshot.mnSkipList.front() = 3; + rotation_entry.snapshot.mnSkipList.back() = -2; + BOOST_CHECK_NO_THROW(aggregate_skips.Validate()); +} + +BOOST_FIXTURE_TEST_CASE(bounded_readers_reject_claimed_sizes_first, BasicTestingSetup) +{ + CDataStream mn_stream{SER_DISK, CLIENT_VERSION}; + mn_stream << H(1) << 1 << uint32_t{0}; + WriteCompactSize(mn_stream, evo::EVO_SNAPSHOT_MAX_MNS + 1); + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNList(mn_stream), std::ios_base::failure); + BOOST_CHECK(mn_stream.empty()); + + const auto decode_quorum = [](CDataStream stream) { + evo::CQuorumSnapshotData data; + stream >> data; + }; + const auto& plain_params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + const size_t commitment_limit{evo::SnapshotCommitmentCount(plain_params, false)}; + CDataStream active{SER_DISK, CLIENT_VERSION}; + active << Consensus::LLMQType::LLMQ_TEST << false; + WriteCompactSize(active, plain_params.signingActiveQuorumCount + 1); + BOOST_CHECK_THROW(decode_quorum(active), std::ios_base::failure); + CDataStream safety{SER_DISK, CLIENT_VERSION}; + safety << Consensus::LLMQType::LLMQ_TEST << false; + WriteCompactSize(safety, 0); + WriteCompactSize(safety, commitment_limit - plain_params.signingActiveQuorumCount + 1); + BOOST_CHECK_THROW(decode_quorum(safety), std::ios_base::failure); + CDataStream rotations{SER_DISK, CLIENT_VERSION}; + rotations << Consensus::LLMQType::LLMQ_TEST_DIP0024 << true; + WriteCompactSize(rotations, 0); + WriteCompactSize(rotations, 0); + WriteCompactSize(rotations, evo::EVO_SNAPSHOT_ROTATION_CYCLES + 1); + BOOST_CHECK_THROW(decode_quorum(rotations), std::ios_base::failure); + + const auto& rotated_params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + CDataStream bitset{SER_DISK, CLIENT_VERSION}; + bitset << H(1) << H(2) << SnapshotSkipMode::MODE_NO_SKIPPING; + WriteCompactSize(bitset, evo::EVO_SNAPSHOT_MAX_MNS + 1); + BOOST_CHECK_THROW(evo::ReadRotationSnapshot(bitset, rotated_params), std::ios_base::failure); + CDataStream skip_list{SER_DISK, CLIENT_VERSION}; + skip_list << H(1) << H(2) << SnapshotSkipMode::MODE_NO_SKIPPING; + WriteCompactSize(skip_list, 0); + WriteCompactSize(skip_list, rotated_params.size + 1); + BOOST_CHECK_THROW(evo::ReadRotationSnapshot(skip_list, rotated_params), std::ios_base::failure); + + CDataStream commitment_bits{SER_DISK, CLIENT_VERSION}; + auto oversized_commitment{Commitment(Consensus::LLMQType::LLMQ_TEST, 1, 2, false)}; + oversized_commitment.commitment.signers.resize(plain_params.size + 1); + commitment_bits << Consensus::LLMQType::LLMQ_TEST << false; + WriteCompactSize(commitment_bits, 1); + commitment_bits << oversized_commitment; + evo::CQuorumSnapshotData oversized_data; + BOOST_CHECK_THROW(commitment_bits >> oversized_data, std::ios_base::failure); + // The bitset payload and mined-block hash remain unread: rejection occurs + // from the claimed count, before allocation or accepting the element. + BOOST_CHECK_GT(commitment_bits.size(), uint256::size()); + + auto oversized_payout_mn{std::const_pointer_cast( + MN(8, 8, MnType::Regular, ProTxVersion::ExtAddr, 8))}; + auto payout_state{std::make_shared(*oversized_payout_mn->pdmnState)}; + payout_state->payouts.resize(evo::EVO_SNAPSHOT_MAX_PAYOUT_SHARES + 1); + oversized_payout_mn->pdmnState = std::move(payout_state); + CDataStream payouts{SER_DISK, CLIENT_VERSION}; + payouts << H(42) << 42 << uint32_t{10}; + WriteCompactSize(payouts, 1); + payouts << *oversized_payout_mn; + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNList(payouts), std::ios_base::failure); + + CDataStream wrapped_string{SER_DISK, CLIENT_VERSION}; + WriteCompactSize(wrapped_string, evo::EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS + 1); + wrapped_string << uint8_t{0x42}; + evo::SnapshotBoundedInput bounded_string{wrapped_string, evo::EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; + OverrideStream bounded_override{&bounded_string, SER_DISK, CLIENT_VERSION}; + std::string decoded_string; + BOOST_CHECK_EXCEPTION(bounded_override >> decoded_string, std::ios_base::failure, + [](const auto& e) { return std::string{e.what()}.find("CompactSize budget exceeded") != std::string::npos; }); + BOOST_CHECK(decoded_string.empty()); + BOOST_REQUIRE_EQUAL(wrapped_string.size(), 1U); + BOOST_CHECK_EQUAL(std::to_integer(wrapped_string.data()[0]), 0x42); + + const auto snapshot_prefix = [](CDataStream& stream) { + stream << evo::EVO_SNAPSHOT_VERSION << H(42); + evo::SerializeCanonicalMNList(stream, CDeterministicMNList{H(42), 1, 0}); + }; + CDataStream quorum_types{SER_DISK, CLIENT_VERSION}; + snapshot_prefix(quorum_types); + WriteCompactSize(quorum_types, Consensus::available_llmqs.size() + 1); + evo::CEvoSnapshot decoded; + BOOST_CHECK_THROW(quorum_types >> decoded, std::ios_base::failure); + + CDataStream history{SER_DISK, CLIENT_VERSION}; + snapshot_prefix(history); + WriteCompactSize(history, 0); + WriteCompactSize(history, evo::EvoSnapshotMaxHistoricalMNLists() + 1); + BOOST_CHECK_THROW(history >> decoded, std::ios_base::failure); + + CDataStream signals{SER_DISK, CLIENT_VERSION}; + snapshot_prefix(signals); + WriteCompactSize(signals, 0); + WriteCompactSize(signals, 0); + WriteCompactSize(signals, 0); + signals << CCreditPool{}; + WriteCompactSize(signals, Consensus::MAX_VERSION_BITS_DEPLOYMENTS + 1); + BOOST_CHECK_THROW(signals >> decoded, std::ios_base::failure); + + CDataStream ranges{SER_DISK, CLIENT_VERSION}; + snapshot_prefix(ranges); + WriteCompactSize(ranges, 0); + WriteCompactSize(ranges, 0); + WriteCompactSize(ranges, 0); + ranges << CAmount{0} << CAmount{0} << CAmount{0}; + WriteCompactSize(ranges, evo::EVO_SNAPSHOT_MAX_RANGES + 1); + BOOST_CHECK_THROW(ranges >> decoded, std::ios_base::failure); + BOOST_CHECK(ranges.empty()); + + const auto snapshot_bytes{SerializeSnapshot(SyntheticSnapshot())}; + DomainPort domain; + BOOST_REQUIRE_EQUAL(domain.Set("evo5.example.org", 443), DomainPort::Status::Success); + CDataStream encoded_domain{SER_DISK, CLIENT_VERSION}; + encoded_domain << domain; + const auto domain_pos{std::search(snapshot_bytes.begin(), snapshot_bytes.end(), + encoded_domain.begin(), encoded_domain.end())}; + BOOST_REQUIRE(domain_pos != snapshot_bytes.end()); + + CDataStream oversized_domain{SER_DISK, CLIENT_VERSION}; + const size_t domain_offset{static_cast(std::distance(snapshot_bytes.begin(), domain_pos))}; + oversized_domain.write(Span{snapshot_bytes}.first(domain_offset)); + constexpr size_t MAX_DOMAIN_LENGTH{253}; + WriteCompactSize(oversized_domain, MAX_DOMAIN_LENGTH + 1); + const std::string oversized_addr(MAX_DOMAIN_LENGTH + 1, 'a'); + oversized_domain.write(MakeByteSpan(oversized_addr)); + const size_t serialized_addr_size{encoded_domain.size() - sizeof(uint16_t)}; + oversized_domain.write(Span{snapshot_bytes}.subspan(domain_offset + serialized_addr_size)); + BOOST_CHECK_EXCEPTION(oversized_domain >> decoded, std::ios_base::failure, + [](const auto& e) { return std::string{e.what()}.find("String length limit exceeded") != std::string::npos; }); +} + +BOOST_FIXTURE_TEST_CASE(cbtx_cross_checks, BasicTestingSetup) +{ + const auto snapshot{SyntheticSnapshot()}; + CCbTx cbtx; + cbtx.nVersion = CCbTx::Version::CLSIG_AND_BALANCE; + cbtx.merkleRootMNList = snapshot.mn_list.to_sml()->CalcMerkleRoot(); + std::vector quorum_hashes; + for (const auto& data : snapshot.quorums) { + for (const auto& entry : data.active_commitments) quorum_hashes.emplace_back(SerializeHash(entry.commitment)); + } + std::sort(quorum_hashes.begin(), quorum_hashes.end()); + cbtx.merkleRootQuorums = ComputeMerkleRoot(quorum_hashes); + cbtx.creditPoolBalance = snapshot.credit_pool.locked; + + std::string error; + BOOST_CHECK(evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); + cbtx.merkleRootMNList = H(1); + BOOST_CHECK(!evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); + cbtx.merkleRootMNList = snapshot.mn_list.to_sml()->CalcMerkleRoot(); + cbtx.merkleRootQuorums = H(2); + BOOST_CHECK(!evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); + cbtx.merkleRootQuorums = ComputeMerkleRoot(quorum_hashes); + cbtx.creditPoolBalance++; + BOOST_CHECK(!evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); +} + +BOOST_FIXTURE_TEST_CASE(rejects_unknown_wire_version, BasicTestingSetup) +{ + auto bytes{SerializeSnapshot(SyntheticSnapshot())}; + bytes.data()[0] = std::byte{4}; + evo::CEvoSnapshot decoded; + BOOST_CHECK_THROW(bytes >> decoded, std::ios_base::failure); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/src/util/ranges_set.h b/src/util/ranges_set.h index d67be4919056..86e1b484976f 100644 --- a/src/util/ranges_set.h +++ b/src/util/ranges_set.h @@ -9,7 +9,10 @@ #include #include +#include +#include #include +#include /** * The CRangesSet is a datastructure that keeps efficiently numbers as set of @@ -47,6 +50,8 @@ class CRangesSet std::set ranges; public: + static constexpr uint64_t DEFAULT_MAX_RANGES{MAX_SIZE}; + /** * this function adds `value` to the datastructure. * it returns true if `add` succeed @@ -75,9 +80,48 @@ class CRangesSet */ [[nodiscard]] bool IsEmpty() const noexcept; - SERIALIZE_METHODS(CRangesSet, obj) + template + void Serialize(Stream& s) const + { + // Preserve the established canonical set encoding. + s << ranges; + } + + template + void UnserializeBounded(Stream& s, uint64_t max_ranges) + { + std::set decoded; + const uint64_t count{ReadCompactSize(s)}; + if (count > max_ranges) throw std::ios_base::failure("oversized CRangesSet range count"); + uint64_t previous_end{0}; + bool have_previous{false}; + for (uint64_t i{0}; i < count; ++i) { + Range range; + s >> range; + const bool wrapped_max{range.end == 0}; + if (!wrapped_max && range.begin >= range.end) { + throw std::ios_base::failure("invalid empty CRangesSet range"); + } + // Equality is adjacent and must have been merged; less-than is + // overlapping or unordered. Both are noncanonical and could make + // Size() underflow. + if (have_previous && (previous_end == 0 || range.begin <= previous_end)) { + throw std::ios_base::failure("noncanonical CRangesSet ranges"); + } + if (wrapped_max && i + 1 != count) { + throw std::ios_base::failure("wrapped CRangesSet range must be last"); + } + previous_end = range.end; + have_previous = true; + decoded.emplace(range); + } + ranges = std::move(decoded); + } + + template + void Unserialize(Stream& s) { - READWRITE(obj.ranges); + UnserializeBounded(s, DEFAULT_MAX_RANGES); } }; diff --git a/test/sanitizer_suppressions/ubsan b/test/sanitizer_suppressions/ubsan index 5560aada773a..b0a3d21901a0 100644 --- a/test/sanitizer_suppressions/ubsan +++ b/test/sanitizer_suppressions/ubsan @@ -32,6 +32,10 @@ implicit-unsigned-integer-truncation:test/fuzz/crypto_diff_fuzz_chacha20.cpp shift-base:*/include/c++/ shift-base:leveldb/ shift-base:minisketch/ +# Vendored immer's HAMT merge computes a bitmap shift past the hash width when +# two keys share a full 64-bit hash. Only reachable through deliberately +# colliding test keys (evo_snapshot_tests MN fixtures); harmless in immer. +shift-base:immer/ shift-base:secp256k1* shift-base:test/fuzz/crypto_diff_fuzz_chacha20.cpp # Unsigned integer overflow occurs when the result of an unsigned integer From 10e30262fe72250a49042f5fc19a9404796ca2b9 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 12 Aug 2026 19:37:44 -0500 Subject: [PATCH 02/14] fix: build the ReadFixedBitSet trailing-bits mask without an implicit sign change The mask for rejecting out-of-range trailing bits promotes through operator~ to a negative int before its implicit conversion back to uint8_t, which clang's implicit-integer-sign-change check reports for every bitset whose size is not a multiple of eight. The evo snapshot unit tests are the first to deserialize such bitsets under the sanitizer job. Same bits, stated explicitly. Co-Authored-By: Claude Fable 5 --- src/serialize.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serialize.h b/src/serialize.h index 6f266311fa87..cffce70bc672 100644 --- a/src/serialize.h +++ b/src/serialize.h @@ -465,7 +465,7 @@ void ReadFixedBitSet(Stream& s, std::vector& vec, size_t size) vec[p] = (vBytes[p / 8] & (1 << (p % 8))) != 0; if (vBytes.size() * 8 != size) { size_t rem = vBytes.size() * 8 - size; - uint8_t m = ~(uint8_t)(0xff >> rem); + const auto m{static_cast(~(0xffU >> rem))}; if (vBytes[vBytes.size() - 1] & m) { throw std::ios_base::failure("Out-of-range bits set"); } From 25f815aed065194c474548dcd40d68a2fefb1951 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 13 Aug 2026 01:01:16 -0500 Subject: [PATCH 03/14] test: cover the bounded CRangesSet unserializer The codec PR shipped UnserializeBounded without its unit coverage; add the malformed/canonical decode matrix and the round-trip checks from the original series. Co-Authored-By: Claude Fable 5 --- src/test/util_tests.cpp | 66 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/test/util_tests.cpp b/src/test/util_tests.cpp index 2939888582ac..8e8bd6a27ffe 100644 --- a/src/test/util_tests.cpp +++ b/src/test/util_tests.cpp @@ -1399,6 +1399,72 @@ BOOST_AUTO_TEST_CASE(test_Capitalize) BOOST_CHECK_EQUAL(Capitalize("\x00\xfe\xff"), "\x00\xfe\xff"); } +BOOST_AUTO_TEST_CASE(test_CRanges_deserialize_validation) +{ + const auto encoded = [](std::initializer_list> ranges) { + CDataStream stream{SER_NETWORK, 0}; + WriteCompactSize(stream, ranges.size()); + for (const auto& [begin, end] : ranges) stream << begin << end; + return stream; + }; + + for (auto malformed : {encoded({{4, 4}}), // empty + encoded({{4, 8}, {7, 10}}), // overlapping + encoded({{4, 8}, {8, 10}}), // adjacent (must be merged) + encoded({{12, 14}, {4, 8}})}) { // unordered + CRangesSet decoded; + BOOST_CHECK_THROW(malformed >> decoded, std::ios_base::failure); + } + + auto canonical{encoded({{4, 8}, {10, 12}})}; + CRangesSet decoded; + BOOST_CHECK_NO_THROW(canonical >> decoded); + BOOST_CHECK_EQUAL(decoded.Size(), 6U); + BOOST_CHECK(decoded.Contains(4)); + BOOST_CHECK(decoded.Contains(11)); + BOOST_CHECK(!decoded.Contains(8)); + + constexpr uint64_t max{std::numeric_limits::max()}; + CRangesSet max_value; + BOOST_CHECK(max_value.Add(max - 2)); + BOOST_CHECK(max_value.Add(max - 1)); + BOOST_CHECK(max_value.Add(max)); + CDataStream max_encoded{SER_NETWORK, 0}; + max_encoded << max_value; + CRangesSet max_decoded; + max_encoded >> max_decoded; + BOOST_CHECK_EQUAL(max_decoded.Size(), 3U); + BOOST_CHECK(max_decoded.Contains(max - 2)); + BOOST_CHECK(max_decoded.Contains(max - 1)); + BOOST_CHECK(max_decoded.Contains(max)); + + BOOST_CHECK(max_decoded.Remove(max)); + CDataStream removed_max_encoded{SER_NETWORK, 0}; + removed_max_encoded << max_decoded; + CRangesSet removed_max_decoded; + removed_max_encoded >> removed_max_decoded; + BOOST_CHECK_EQUAL(removed_max_decoded.Size(), 2U); + BOOST_CHECK(removed_max_decoded.Contains(max - 2)); + BOOST_CHECK(removed_max_decoded.Contains(max - 1)); + BOOST_CHECK(!removed_max_decoded.Contains(max)); + + BOOST_CHECK(max_value.Remove(max - 1)); + CDataStream removed_interior_encoded{SER_NETWORK, 0}; + removed_interior_encoded << max_value; + CRangesSet removed_interior_decoded; + removed_interior_encoded >> removed_interior_decoded; + BOOST_CHECK_EQUAL(removed_interior_decoded.Size(), 2U); + BOOST_CHECK(removed_interior_decoded.Contains(max - 2)); + BOOST_CHECK(!removed_interior_decoded.Contains(max - 1)); + BOOST_CHECK(removed_interior_decoded.Contains(max)); + + auto invalid_wrapped{encoded({{5, 0}, {10, 12}})}; + BOOST_CHECK_THROW(invalid_wrapped >> decoded, std::ios_base::failure); + + auto invalid_reverse{encoded({{5, 4}})}; + BOOST_CHECK_THROW(invalid_reverse >> decoded, std::ios_base::failure); +} + BOOST_AUTO_TEST_CASE(test_CRanges) { std::mt19937 gen; From c6a2189f01b6764c9bf329d3149e54d73c21fa92 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 13 Aug 2026 15:18:33 -0500 Subject: [PATCH 04/14] fix: allow partial evo snapshot quorum history --- src/evo/snapshot.cpp | 10 +++++++--- src/test/evo_snapshot_tests.cpp | 12 ++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp index 26e1987557cd..553a288ef56b 100644 --- a/src/evo/snapshot.cpp +++ b/src/evo/snapshot.cpp @@ -227,9 +227,13 @@ void CEvoSnapshot::Validate(bool require_canonical_order) const } const size_t active_count{static_cast(params.signingActiveQuorumCount)}; const size_t total_count{SnapshotCommitmentCount(params, data.rotation_enabled)}; - if (data.active_commitments.size() != active_count || - data.safety_commitments.size() != total_count - active_count || - data.rotation_snapshots.size() != (data.rotation_enabled ? EVO_SNAPSHOT_ROTATION_CYCLES : 0)) { + // Parameter-derived counts are maxima, not exact requirements: a young + // chain carries however much quorum history exists. The chain-aware + // validation and the completion-time CbTx quorum merkle root establish + // that nothing available was withheld. + if (data.active_commitments.size() > active_count || + data.safety_commitments.size() > total_count - active_count || + data.rotation_snapshots.size() > (data.rotation_enabled ? EVO_SNAPSHOT_ROTATION_CYCLES : size_t{0})) { throw std::ios_base::failure("invalid params-derived evo per-type quorum counts"); } std::set quorum_hashes; diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index 6d615b2b3ca9..c399492db495 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -425,6 +425,18 @@ BOOST_FIXTURE_TEST_CASE(context_free_validation_matrix, BasicTestingSetup) snapshot.quorums[1].rotation_snapshots.pop_back(); CheckInvalid(snapshot); + // Parameter-derived history counts are ceilings. Young chains and newly + // activated quorum types legitimately carry fewer commitments and cycles; + // chain-aware validation and the base CbTx establish completeness later. + snapshot = SyntheticSnapshot(); + snapshot.quorums.clear(); + snapshot.historical_mn_list_diffs.clear(); + snapshot.quorum_modifiers.clear(); + evo::CQuorumSnapshotData partial; + partial.llmq_type = Consensus::LLMQType::LLMQ_TEST; + snapshot.quorums.emplace_back(std::move(partial)); + BOOST_CHECK_NO_THROW(snapshot.Validate()); + const auto mutate_commitment = [](auto mutation) { auto value{SyntheticSnapshot()}; mutation(value.quorums[0].active_commitments[0]); From d826155473e8f87e5e82405b4d12f11d6fff53c9 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 13 Aug 2026 15:18:37 -0500 Subject: [PATCH 05/14] fix: reject full-domain ranges during deserialization --- src/test/util_tests.cpp | 3 ++- src/util/ranges_set.h | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/test/util_tests.cpp b/src/test/util_tests.cpp index 8e8bd6a27ffe..7243181c32cb 100644 --- a/src/test/util_tests.cpp +++ b/src/test/util_tests.cpp @@ -1408,7 +1408,8 @@ BOOST_AUTO_TEST_CASE(test_CRanges_deserialize_validation) return stream; }; - for (auto malformed : {encoded({{4, 4}}), // empty + for (auto malformed : {encoded({{0, 0}}), // full uint64_t domain (unrepresentable size) + encoded({{4, 4}}), // empty encoded({{4, 8}, {7, 10}}), // overlapping encoded({{4, 8}, {8, 10}}), // adjacent (must be merged) encoded({{12, 14}, {4, 8}})}) { // unordered diff --git a/src/util/ranges_set.h b/src/util/ranges_set.h index 86e1b484976f..b9a696989a26 100644 --- a/src/util/ranges_set.h +++ b/src/util/ranges_set.h @@ -99,6 +99,9 @@ class CRangesSet Range range; s >> range; const bool wrapped_max{range.end == 0}; + if (wrapped_max && range.begin == 0) { + throw std::ios_base::failure("unrepresentable full-domain CRangesSet range"); + } if (!wrapped_max && range.begin >= range.end) { throw std::ios_base::failure("invalid empty CRangesSet range"); } From 8fd8fde4dbf4289aca105a1c696992e1ea9cdc91 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 19 Aug 2026 23:56:00 -0500 Subject: [PATCH 06/14] fix: reject noncanonical MNHF signal order during deserialization The signal map normalizes iteration order, so wire order was unobservable after decode: any permutation of the same signal set was accepted by Unserialize() and reserialized sorted, violating the require_canonical_order contract that every other top-level collection enforces. Require the strictly ascending bit order the serializer emits at the only point where wire order is visible, which also subsumes the duplicate-bit rejection. Co-Authored-By: Claude Fable 5 --- src/evo/snapshot.h | 11 ++++++++++- src/test/evo_snapshot_tests.cpp | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index 0a60449cd753..ee60f3255a56 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -565,10 +566,18 @@ void CEvoSnapshot::Unserialize(Stream& s) s >> credit_pool.locked >> credit_pool.currentLimit >> credit_pool.latelyUnlocked; credit_pool.indexes.UnserializeBounded(s, EVO_SNAPSHOT_MAX_RANGES); const size_t signal_count{ReadBoundedCompactSize(s, Consensus::MAX_VERSION_BITS_DEPLOYMENTS, "MNHF signals")}; + // The signal map normalizes iteration order, so wire order is observable + // only here: require the strictly ascending bit order the serializer + // emits, which also rejects duplicate bits. + std::optional previous_bit; for (size_t i{0}; i < signal_count; ++i) { std::pair signal; s >> signal; - if (!mnhf_signals.emplace(signal).second) throw std::ios_base::failure("duplicate MNHF signal bit"); + if (previous_bit && *previous_bit >= signal.first) { + throw std::ios_base::failure("noncanonical MNHF signal order"); + } + previous_bit = signal.first; + mnhf_signals.emplace(signal); } Validate(/*require_canonical_order=*/true); } diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index c399492db495..dd8a15296e25 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include @@ -612,6 +613,34 @@ BOOST_FIXTURE_TEST_CASE(bounded_readers_reject_claimed_sizes_first, BasicTesting [](const auto& e) { return std::string{e.what()}.find("String length limit exceeded") != std::string::npos; }); } +BOOST_FIXTURE_TEST_CASE(mnhf_signal_wire_order_is_canonical, BasicTestingSetup) +{ + const auto bytes{SerializeSnapshot(SyntheticSnapshot())}; + // The MNHF signal section is the encoding's tail: a count followed by + // (bit, height) pairs. SyntheticSnapshot carries (2, 12) and (9, 30). + const size_t tail_size{1 + 2 * (sizeof(uint8_t) + sizeof(int32_t))}; + const auto with_signals = [&](const std::vector>& signals) { + CDataStream stream{SER_DISK, CLIENT_VERSION}; + stream.write(Span{bytes}.first(bytes.size() - tail_size)); + WriteCompactSize(stream, signals.size()); + for (const auto& signal : signals) stream << signal; + return stream; + }; + const auto expect_noncanonical = [](CDataStream stream) { + evo::CEvoSnapshot decoded; + BOOST_CHECK_EXCEPTION(stream >> decoded, std::ios_base::failure, [](const auto& e) { + return std::string{e.what()}.find("noncanonical MNHF signal order") != std::string::npos; + }); + }; + auto canonical{with_signals({{2, 12}, {9, 30}})}; + evo::CEvoSnapshot decoded; + BOOST_CHECK_NO_THROW(canonical >> decoded); + BOOST_CHECK(canonical.empty()); + BOOST_CHECK_EQUAL(decoded.mnhf_signals.size(), 2U); + expect_noncanonical(with_signals({{9, 30}, {2, 12}})); + expect_noncanonical(with_signals({{2, 12}, {2, 30}})); +} + BOOST_FIXTURE_TEST_CASE(cbtx_cross_checks, BasicTestingSetup) { const auto snapshot{SyntheticSnapshot()}; From c0f878b72fd7f45a35d093e089be44a56c3cf738 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 19 Aug 2026 23:57:28 -0500 Subject: [PATCH 07/14] fix: bound commitment bitsets by a format ceiling instead of static LLMQ size -llmqtestparams and -llmqdevnetparams mutate size, minSize, threshold, and dkgBadVotesThreshold on the CChainParams copy of the LLMQ table, so a commitment produced under a supported non-default quorum size was rejected by the static Consensus::available_llmqs sizing: a larger size exceeded the read bound and a smaller one failed the exact comparison. The context-free layer now enforces an allocation ceiling (EVO_SNAPSHOT_MAX_QUORUM_SIZE) plus signers/validMembers internal consistency; exact sizing already happens in the chain-aware validation, which iterates the effective consensus.llmqs table and calls VerifySizes against it. Count, rotation, and interval fields stay on the static table since the overrides cannot change them. Co-Authored-By: Claude Fable 5 --- src/evo/snapshot.cpp | 9 +++++- src/evo/snapshot.h | 40 +++++++++++++++++---------- src/test/evo_snapshot_tests.cpp | 49 +++++++++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp index 553a288ef56b..7a9f26c66c9d 100644 --- a/src/evo/snapshot.cpp +++ b/src/evo/snapshot.cpp @@ -83,7 +83,14 @@ void ValidateCommitments(const CQuorumSnapshotData& data, const std::vector EVO_SNAPSHOT_MAX_QUORUM_SIZE) { + throw std::ios_base::failure("invalid evo quorum commitment sizes"); + } if (!known_version) throw std::ios_base::failure("unknown evo quorum commitment version"); if (entry.quorum_base_block_hash.IsNull() || entry.work_block_hash.IsNull() || entry.mined_block_hash.IsNull()) { throw std::ios_base::failure("null evo quorum commitment block hash"); diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index ee60f3255a56..098768c96763 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -59,6 +59,12 @@ static constexpr size_t EVO_SNAPSHOT_MAX_MODIFIERS{4'096}; // bound its legitimate length. This is a decode ceiling on claimed sizes only, // far above any state the aggregate rotation build reaches on real chains. static constexpr size_t EVO_SNAPSHOT_MAX_SKIPLIST_ENTRIES{1'000'000}; +// Commitment bitsets are sized by the effective chain parameters, which +// -llmqtestparams/-llmqdevnetparams may override at runtime. This context-free +// layer only enforces an allocation ceiling (far above the largest defined +// quorum, size 400) plus internal consistency; exact sizing against the +// effective parameters belongs to the chain-aware validation. +static constexpr size_t EVO_SNAPSHOT_MAX_QUORUM_SIZE{10'000}; static_assert(std::ranges::all_of(Consensus::available_llmqs, [](const auto& params) { return !params.useRotation || params.keepOldConnections <= 2 * params.signingActiveQuorumCount; }), "rotated LLMQ retention exceeds the two serialized cycles"); @@ -71,6 +77,13 @@ size_t ReadBoundedCompactSize(Stream& s, size_t limit, const char* field) return static_cast(size); } +/** + * The static table is intentional for this context-free layer: the runtime + * overrides (-llmqtestparams, -llmqdevnetparams) mutate only size/threshold + * fields on the CChainParams copy, so the count, rotation, and interval fields + * consumed here are reliable. Nothing here may depend on LLMQParams::size; + * size checks are format-level bounds with exact sizing done chain-aware. + */ inline const Consensus::LLMQParams& SnapshotLLMQParams(Consensus::LLMQType type) { const auto it{std::ranges::find_if(Consensus::available_llmqs, @@ -337,7 +350,7 @@ struct CMinedQuorumCommitment { }; template -CMinedQuorumCommitment ReadMinedQuorumCommitment(Stream& s, const Consensus::LLMQParams& params) +CMinedQuorumCommitment ReadMinedQuorumCommitment(Stream& s) { CMinedQuorumCommitment entry; auto& commitment{entry.commitment}; @@ -345,14 +358,18 @@ CMinedQuorumCommitment ReadMinedQuorumCommitment(Stream& s, const Consensus::LLM const bool indexed{commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION || commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION}; if (indexed) s >> commitment.quorumIndex; - const size_t signers_size{ReadBoundedCompactSize(s, params.size, "commitment signers")}; - if (signers_size != static_cast(params.size)) { - throw std::ios_base::failure("invalid evo snapshot commitment signers size"); + // The consensus/P2P serializer remains unchanged; this snapshot-local path + // bounds both claimed bitset sizes before allocation. The effective quorum + // size is runtime-configurable, so only internal consistency is enforced + // here; exact sizing is established by the chain-aware validation. + const size_t signers_size{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_QUORUM_SIZE, "commitment signers")}; + if (signers_size == 0) { + throw std::ios_base::failure("empty evo snapshot commitment signers"); } ReadFixedBitSet(s, commitment.signers, signers_size); - const size_t valid_members_size{ReadBoundedCompactSize(s, params.size, "commitment valid members")}; - if (valid_members_size != static_cast(params.size)) { - throw std::ios_base::failure("invalid evo snapshot commitment valid-members size"); + const size_t valid_members_size{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_QUORUM_SIZE, "commitment valid members")}; + if (valid_members_size != signers_size) { + throw std::ios_base::failure("inconsistent evo snapshot commitment bitset sizes"); } ReadFixedBitSet(s, commitment.validMembers, valid_members_size); const bool legacy{commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION || @@ -360,11 +377,6 @@ CMinedQuorumCommitment ReadMinedQuorumCommitment(Stream& s, const Consensus::LLM s >> CBLSPublicKeyVersionWrapper(commitment.quorumPublicKey, legacy) >> commitment.quorumVvecHash >> CBLSSignatureVersionWrapper(commitment.quorumSig, legacy) >> CBLSSignatureVersionWrapper(commitment.membersSig, legacy); - // The consensus/P2P serializer remains unchanged; this snapshot-local path - // bounds both bitsets before allocation and verifies the decoded object. - if (!entry.commitment.VerifySizes(params)) { - throw std::ios_base::failure("invalid evo snapshot commitment bitset size"); - } s >> entry.mined_block_hash; return entry; } @@ -492,12 +504,12 @@ void CQuorumSnapshotData::Unserialize(Stream& s) const size_t active_count{ReadBoundedCompactSize(s, expected_active, "active commitments")}; active_commitments.reserve(active_count); for (size_t i{0}; i < active_count; ++i) { - active_commitments.emplace_back(ReadMinedQuorumCommitment(s, params)); + active_commitments.emplace_back(ReadMinedQuorumCommitment(s)); } const size_t safety_count{ReadBoundedCompactSize(s, total_count - expected_active, "safety commitments")}; safety_commitments.reserve(safety_count); for (size_t i{0}; i < safety_count; ++i) { - safety_commitments.emplace_back(ReadMinedQuorumCommitment(s, params)); + safety_commitments.emplace_back(ReadMinedQuorumCommitment(s)); } const size_t snapshot_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_ROTATION_CYCLES, "rotation snapshots")}; rotation_snapshots.reserve(snapshot_count); diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index dd8a15296e25..cadcb9fda47e 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -443,6 +443,11 @@ BOOST_FIXTURE_TEST_CASE(context_free_validation_matrix, BasicTestingSetup) mutation(value.quorums[0].active_commitments[0]); CheckInvalid(std::move(value)); }; + mutate_commitment([](auto& e) { e.commitment.validMembers.resize(e.commitment.signers.size() + 1); }); + mutate_commitment([](auto& e) { + e.commitment.signers.clear(); + e.commitment.validMembers.clear(); + }); mutate_commitment([](auto& e) { e.quorum_base_block_hash.SetNull(); }); mutate_commitment([](auto& e) { e.mined_block_hash.SetNull(); }); mutate_commitment([](auto& e) { e.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST_PLATFORM; }); @@ -528,11 +533,28 @@ BOOST_FIXTURE_TEST_CASE(bounded_readers_reject_claimed_sizes_first, BasicTesting WriteCompactSize(commitment_bits, 1); commitment_bits << oversized_commitment; evo::CQuorumSnapshotData oversized_data; - BOOST_CHECK_THROW(commitment_bits >> oversized_data, std::ios_base::failure); - // The bitset payload and mined-block hash remain unread: rejection occurs - // from the claimed count, before allocation or accepting the element. + BOOST_CHECK_EXCEPTION(commitment_bits >> oversized_data, std::ios_base::failure, [](const auto& e) { + return std::string{e.what()}.find("inconsistent evo snapshot commitment bitset sizes") != std::string::npos; + }); + // The valid-members bitset payload, BLS material, and mined-block hash + // remain unread: rejection occurs at the mismatched claimed size. BOOST_CHECK_GT(commitment_bits.size(), uint256::size()); + const auto commitment_prefix = [](CDataStream& s) { + s << Consensus::LLMQType::LLMQ_TEST << false; + WriteCompactSize(s, 1); + s << H(1) << H(2) << uint16_t{llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION} + << Consensus::LLMQType::LLMQ_TEST << H(1); + }; + CDataStream over_ceiling{SER_DISK, CLIENT_VERSION}; + commitment_prefix(over_ceiling); + WriteCompactSize(over_ceiling, evo::EVO_SNAPSHOT_MAX_QUORUM_SIZE + 1); + BOOST_CHECK_THROW(decode_quorum(over_ceiling), std::ios_base::failure); + CDataStream empty_bits{SER_DISK, CLIENT_VERSION}; + commitment_prefix(empty_bits); + WriteCompactSize(empty_bits, 0); + BOOST_CHECK_THROW(decode_quorum(empty_bits), std::ios_base::failure); + auto oversized_payout_mn{std::const_pointer_cast( MN(8, 8, MnType::Regular, ProTxVersion::ExtAddr, 8))}; auto payout_state{std::make_shared(*oversized_payout_mn->pdmnState)}; @@ -613,6 +635,27 @@ BOOST_FIXTURE_TEST_CASE(bounded_readers_reject_claimed_sizes_first, BasicTesting [](const auto& e) { return std::string{e.what()}.find("String length limit exceeded") != std::string::npos; }); } +BOOST_FIXTURE_TEST_CASE(commitment_sizes_are_format_bounded_not_param_exact, BasicTestingSetup) +{ + // -llmqtestparams and -llmqdevnetparams change the effective quorum size at + // runtime, so commitments whose bitsets differ from the static default must + // pass this layer; exact sizing is established by chain-aware validation. + auto snapshot{SyntheticSnapshot()}; + const auto& params{evo::SnapshotLLMQParams(snapshot.quorums[0].llmq_type)}; + for (auto& entry : snapshot.quorums[0].active_commitments) { + entry.commitment.signers.assign(params.size + 5, false); + entry.commitment.validMembers.assign(params.size + 5, true); + } + BOOST_CHECK_NO_THROW(snapshot.Validate(/*require_canonical_order=*/true)); + const auto bytes{SerializeSnapshot(snapshot)}; + CDataStream input{bytes}; + evo::CEvoSnapshot decoded; + BOOST_CHECK_NO_THROW(input >> decoded); + BOOST_CHECK(input.empty()); + const auto reencoded{SerializeSnapshot(decoded)}; + BOOST_CHECK_EQUAL_COLLECTIONS(bytes.begin(), bytes.end(), reencoded.begin(), reencoded.end()); +} + BOOST_FIXTURE_TEST_CASE(mnhf_signal_wire_order_is_canonical, BasicTestingSetup) { const auto bytes{SerializeSnapshot(SyntheticSnapshot())}; From 65c56bfbc3b59ed190e4b8d72d515e2e27077f5a Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 19 Aug 2026 23:58:01 -0500 Subject: [PATCH 08/14] fix: validate credit-pool amounts and MNHF signal semantics context-free Validate() bounded only the MNHF map's cardinality: out-of-money-range credit pool amounts, a currentLimit above locked, signal bits at or above VERSIONBITS_NUM_BITS, and signal heights outside [0, base height] all received a canonical snapshot hash. ConstructCreditPool guarantees 0 <= currentLimit <= locked in every deployment branch and consensus admits MNHF signals only for bits below VERSIONBITS_NUM_BITS at their mined height, so enforce exactly those invariants. Co-Authored-By: Claude Fable 5 --- src/evo/snapshot.cpp | 13 +++++++++++++ src/test/evo_snapshot_tests.cpp | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp index 7a9f26c66c9d..188864495310 100644 --- a/src/evo/snapshot.cpp +++ b/src/evo/snapshot.cpp @@ -210,6 +210,19 @@ void CEvoSnapshot::Validate(bool require_canonical_order) const mnhf_signals.size() > Consensus::MAX_VERSION_BITS_DEPLOYMENTS) { throw std::ios_base::failure("oversized evo snapshot collection"); } + // ConstructCreditPool guarantees 0 <= currentLimit <= locked in every + // deployment branch, and all three amounts are money-range window sums. + if (!MoneyRange(credit_pool.locked) || !MoneyRange(credit_pool.currentLimit) || + !MoneyRange(credit_pool.latelyUnlocked) || credit_pool.currentLimit > credit_pool.locked) { + throw std::ios_base::failure("invalid evo snapshot credit pool amounts"); + } + // Consensus admits MNHF signals only for bits below VERSIONBITS_NUM_BITS + // and records the mined height, which cannot exceed the base height. + for (const auto& [bit, height] : mnhf_signals) { + if (bit >= VERSIONBITS_NUM_BITS || height < 0 || height > mn_list.GetHeightForSnapshotCodec()) { + throw std::ios_base::failure("invalid evo snapshot MNHF signal"); + } + } if (require_canonical_order && (!IsStrictlySorted(quorums) || !IsStrictlySorted(historical_mn_list_diffs) || !IsStrictlySorted(quorum_modifiers))) { throw std::ios_base::failure("noncanonical evo snapshot top-level order"); diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index cadcb9fda47e..f41681da6171 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -24,6 +25,7 @@ #include #include #include +#include #include #include @@ -438,6 +440,25 @@ BOOST_FIXTURE_TEST_CASE(context_free_validation_matrix, BasicTestingSetup) snapshot.quorums.emplace_back(std::move(partial)); BOOST_CHECK_NO_THROW(snapshot.Validate()); + snapshot = SyntheticSnapshot(); + snapshot.credit_pool.locked = -1; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.credit_pool.currentLimit = snapshot.credit_pool.locked + 1; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.credit_pool.latelyUnlocked = MAX_MONEY + 1; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.mnhf_signals.emplace(VERSIONBITS_NUM_BITS, 10); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.mnhf_signals.emplace(11, -1); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.mnhf_signals.emplace(11, snapshot.mn_list.GetHeightForSnapshotCodec() + 1); + CheckInvalid(snapshot); + const auto mutate_commitment = [](auto mutation) { auto value{SyntheticSnapshot()}; mutation(value.quorums[0].active_commitments[0]); From e89e43e9da04255ee17bf8e5a34a9274d67d86c6 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 20 Aug 2026 01:21:05 -0500 Subject: [PATCH 09/14] fix: replace previous contents when deserializing an evo snapshot Unserialize() appended to quorums, historical_mn_list_diffs, and quorum_modifiers and merged into the existing MNHF signal map, so a successful decode into a reused object accumulated state the consumed bytes never contained and could still pass Validate(). Clear the collections up front. Co-Authored-By: Claude Fable 5 --- src/evo/snapshot.h | 7 +++++++ src/test/evo_snapshot_tests.cpp | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index 098768c96763..08ae0e2d5fdf 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -550,6 +550,13 @@ void CEvoSnapshot::Unserialize(Stream& s) s >> version; if (version != EVO_SNAPSHOT_VERSION) throw std::ios_base::failure("unsupported evo snapshot version"); s >> base_block_hash; + // Decoding must replace any previous contents: the collections below are + // appended to (and the signal map merged into), so a reused object would + // otherwise accumulate state that the consumed bytes never contained. + quorums.clear(); + historical_mn_list_diffs.clear(); + quorum_modifiers.clear(); + mnhf_signals.clear(); mn_list = UnserializeCanonicalMNList(s); const size_t quorum_count{ReadBoundedCompactSize(s, Consensus::available_llmqs.size(), "quorum-type count")}; quorums.reserve(quorum_count); diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index f41681da6171..c54072c31ab8 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -705,6 +705,28 @@ BOOST_FIXTURE_TEST_CASE(mnhf_signal_wire_order_is_canonical, BasicTestingSetup) expect_noncanonical(with_signals({{2, 12}, {2, 30}})); } +BOOST_FIXTURE_TEST_CASE(unserialize_replaces_previous_contents, BasicTestingSetup) +{ + const auto populated_bytes{SerializeSnapshot(SyntheticSnapshot())}; + evo::CEvoSnapshot minimal; + minimal.base_block_hash = H(42); + minimal.mn_list = CDeterministicMNList{H(42), 500, 0}; + const auto minimal_bytes{SerializeSnapshot(minimal)}; + + evo::CEvoSnapshot decoded; + CDataStream populated{populated_bytes}; + populated >> decoded; + BOOST_REQUIRE(!decoded.mnhf_signals.empty()); + CDataStream empty{minimal_bytes}; + empty >> decoded; + BOOST_CHECK(decoded.quorums.empty()); + BOOST_CHECK(decoded.historical_mn_list_diffs.empty()); + BOOST_CHECK(decoded.quorum_modifiers.empty()); + BOOST_CHECK(decoded.mnhf_signals.empty()); + const auto reencoded{SerializeSnapshot(decoded)}; + BOOST_CHECK_EQUAL_COLLECTIONS(minimal_bytes.begin(), minimal_bytes.end(), reencoded.begin(), reencoded.end()); +} + BOOST_FIXTURE_TEST_CASE(cbtx_cross_checks, BasicTestingSetup) { const auto snapshot{SyntheticSnapshot()}; From 6e9ffd9605873706dcb9800fccade54a8b232924 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 20 Aug 2026 01:21:30 -0500 Subject: [PATCH 10/14] fix: bound same-prefix proTxHash runs against HAMT collision blowup CDeterministicMNList's HAMT hashes proTxHash by its first 8 bytes, so a snapshot supplying up to 100,000 distinct, canonically ordered hashes sharing one 64-bit prefix made every AddMN copy the whole immer collision node: quadratic work and allocation from a single crafted snapshot. Real proTxHashes are uniform txids, where even one shared prefix among 100,000 has probability ~3e-10, so bound collision runs at 8. Enforced on the sorted base list during decoding (before the inserts), on the merged current-plus-additions prefix set before every historical diff application, and as an object-level invariant; the run detector sorts a plain vector so it cannot itself be driven into hash-collision buckets. Co-Authored-By: Claude Fable 5 --- src/evo/snapshot.cpp | 31 +++++++++++++++++++++++++ src/evo/snapshot.h | 17 ++++++++++++++ src/test/evo_snapshot_tests.cpp | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp index 188864495310..dbff347a1e5d 100644 --- a/src/evo/snapshot.cpp +++ b/src/evo/snapshot.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -115,18 +116,36 @@ void ValidateCommitments(const CQuorumSnapshotData& data, const std::vector& prefixes) +{ + std::sort(prefixes.begin(), prefixes.end()); + size_t run{0}; + for (size_t i{0}; i < prefixes.size(); ++i) { + run = (i != 0 && prefixes[i] == prefixes[i - 1]) ? run + 1 : 1; + if (run > EVO_SNAPSHOT_MAX_HASH_PREFIX_RUN) { + throw std::ios_base::failure("canonical MN-list hash-prefix run exceeds collision bound"); + } + } +} + void ValidateCanonicalMNInvariants(const CDeterministicMNList& list) { const size_t count{list.GetCounts().total()}; if (count > EVO_SNAPSHOT_MAX_MNS) throw std::ios_base::failure("oversized canonical MN list"); uint64_t max_internal_id{0}; + std::vector prefixes; + prefixes.reserve(count); list.ForEachMN(/*onlyValid=*/false, [&](const auto& dmn) { max_internal_id = std::max(max_internal_id, dmn.GetInternalId()); + prefixes.push_back(ReadLE64(dmn.proTxHash.begin())); if (dmn.pdmnState->payouts.size() > EVO_SNAPSHOT_MAX_PAYOUT_SHARES || dmn.pdmnState->netInfo->Validate() != NetInfoStatus::Success) { throw std::ios_base::failure("invalid canonical MN nested collection"); } }); + ValidateHashPrefixRuns(prefixes); if (count != 0 && max_internal_id >= list.GetTotalRegisteredCount()) { throw std::ios_base::failure("canonical MN-list internalId exceeds registration counter"); } @@ -178,6 +197,18 @@ bool ReconstructHistoricalMNLists(const CEvoSnapshot& snapshot, entry.height < 0 || entry.height >= previous_height || entry.canonical_list_hash.IsNull()) { throw std::ios_base::failure("broken historical MN-list diff chain"); } + // Bound the collision groups the additions would create before the + // HAMT performs the inserts; the post-apply invariant check would + // run only after the quadratic work it exists to prevent. + std::vector merged_prefixes; + merged_prefixes.reserve(current.GetCounts().total() + entry.diff.addedMNs.size()); + current.ForEachMN(/*onlyValid=*/false, [&](const auto& dmn) { + merged_prefixes.push_back(ReadLE64(dmn.proTxHash.begin())); + }); + for (const auto& dmn : entry.diff.addedMNs) { + merged_prefixes.push_back(ReadLE64(dmn->proTxHash.begin())); + } + ValidateHashPrefixRuns(merged_prefixes); current.ApplyDiffForSnapshot(entry.block_hash, entry.height, entry.total_registered_count, entry.diff); ValidateCanonicalMNInvariants(current); if (CanonicalMNListHash(current) != entry.canonical_list_hash) { diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index 08ae0e2d5fdf..42609d0212fd 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -6,6 +6,7 @@ #define BITCOIN_EVO_SNAPSHOT_H #include +#include #include #include #include @@ -65,6 +66,13 @@ static constexpr size_t EVO_SNAPSHOT_MAX_SKIPLIST_ENTRIES{1'000'000}; // quorum, size 400) plus internal consistency; exact sizing against the // effective parameters belongs to the chain-aware validation. static constexpr size_t EVO_SNAPSHOT_MAX_QUORUM_SIZE{10'000}; +// CDeterministicMNList's HAMT hashes proTxHash by its first 8 bytes, so a +// snapshot supplying many distinct hashes that share one 64-bit prefix would +// make every insertion copy the whole collision node (quadratic decode work). +// Real proTxHashes are uniform txids: among 100,000 of them even a single +// shared prefix has probability ~3e-10, so a run of 8 is unreachable outside +// crafted input. Enforced on the base list and every reconstructed list. +static constexpr size_t EVO_SNAPSHOT_MAX_HASH_PREFIX_RUN{8}; static_assert(std::ranges::all_of(Consensus::available_llmqs, [](const auto& params) { return !params.useRotation || params.keepOldConnections <= 2 * params.signingActiveQuorumCount; }), "rotated LLMQ retention exceeds the two serialized cycles"); @@ -217,6 +225,7 @@ CDeterministicMNList UnserializeCanonicalMNList(Stream& s) uint256 previous; bool have_previous{false}; uint64_t max_internal_id{0}; + size_t prefix_run{0}; for (size_t i{0}; i < count; ++i) { SnapshotBoundedInput bounded{s, EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; auto dmn{std::make_shared(deserialize, bounded)}; @@ -229,6 +238,14 @@ CDeterministicMNList UnserializeCanonicalMNList(Stream& s) if (have_previous && !(previous < dmn->proTxHash)) { throw std::ios_base::failure("noncanonical canonical MN-list order"); } + // Entries arrive sorted by the full hash, so equal 64-bit prefixes are + // adjacent. Reject collision runs before AddMN performs the inserts. + prefix_run = (have_previous && ReadLE64(previous.begin()) == ReadLE64(dmn->proTxHash.begin())) + ? prefix_run + 1 + : 1; + if (prefix_run > EVO_SNAPSHOT_MAX_HASH_PREFIX_RUN) { + throw std::ios_base::failure("canonical MN-list hash-prefix run exceeds collision bound"); + } previous = dmn->proTxHash; have_previous = true; max_internal_id = std::max(max_internal_id, dmn->GetInternalId()); diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index c54072c31ab8..18a306a24274 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -727,6 +727,46 @@ BOOST_FIXTURE_TEST_CASE(unserialize_replaces_previous_contents, BasicTestingSetu BOOST_CHECK_EQUAL_COLLECTIONS(minimal_bytes.begin(), minimal_bytes.end(), reencoded.begin(), reencoded.end()); } +BOOST_FIXTURE_TEST_CASE(hash_prefix_collision_runs_are_bounded, BasicTestingSetup) +{ + // Every MN() proTxHash shares CollidingH's 64-bit prefix, so run length + // equals list size here. + const auto write_list = [](size_t count) { + CDataStream stream{SER_DISK, CLIENT_VERSION}; + stream << H(42) << 42 << uint32_t{200}; + WriteCompactSize(stream, count); + for (size_t i{0}; i < count; ++i) { + stream << *MN(i + 1, static_cast(i + 1), MnType::Regular, ProTxVersion::LegacyBLS, + static_cast(i + 1)); + } + return stream; + }; + auto at_bound{write_list(evo::EVO_SNAPSHOT_MAX_HASH_PREFIX_RUN)}; + BOOST_CHECK_NO_THROW(evo::UnserializeCanonicalMNList(at_bound)); + auto over_bound{write_list(evo::EVO_SNAPSHOT_MAX_HASH_PREFIX_RUN + 1)}; + BOOST_CHECK_EXCEPTION(evo::UnserializeCanonicalMNList(over_bound), std::ios_base::failure, [](const auto& e) { + return std::string{e.what()}.find("collision bound") != std::string::npos; + }); + + // A diff addition that would grow an at-bound collision group is rejected + // before the HAMT performs the inserts. + evo::CEvoSnapshot snapshot; + snapshot.base_block_hash = H(42); + snapshot.mn_list = CDeterministicMNList{H(42), 500, 200}; + for (size_t i{0}; i < evo::EVO_SNAPSHOT_MAX_HASH_PREFIX_RUN; ++i) { + snapshot.mn_list.AddMN(MN(i + 1, static_cast(i + 1), MnType::Regular, ProTxVersion::LegacyBLS, + static_cast(i + 1)), + /*fBumpTotalCount=*/false); + } + CDeterministicMNListDiff diff; + diff.addedMNs.push_back(MN(30, 30, MnType::Regular, ProTxVersion::LegacyBLS, 30)); + snapshot.historical_mn_list_diffs.push_back({H(42), H(43), 499, 200, H(1), std::move(diff)}); + std::map lists; + std::string reconstruction_error; + BOOST_CHECK(!evo::ReconstructHistoricalMNLists(snapshot, lists, reconstruction_error)); + BOOST_CHECK(reconstruction_error.find("collision bound") != std::string::npos); +} + BOOST_FIXTURE_TEST_CASE(cbtx_cross_checks, BasicTestingSetup) { const auto snapshot{SyntheticSnapshot()}; From 3a2cc0b6832a31cd2b6612110b6a77f3960b8fe0 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 20 Aug 2026 08:33:45 -0500 Subject: [PATCH 11/14] fix: bind the CbTx height in VerifyEvoSnapshotCbTx The context-free CbTx cross-check compared the MN root, active-quorum root, and credit-pool balance but never the height, so a CbTx claiming a different height passed whenever those values were unchanged; the test even verified successfully with nHeight left at zero against a height-500 list. Compare cbtx.nHeight with the snapshot list's height. Co-Authored-By: Claude Fable 5 --- src/evo/snapshot.cpp | 4 ++++ src/test/evo_snapshot_tests.cpp | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp index dbff347a1e5d..ca5f83705151 100644 --- a/src/evo/snapshot.cpp +++ b/src/evo/snapshot.cpp @@ -354,6 +354,10 @@ bool VerifyEvoSnapshotCbTx(const CEvoSnapshot& snapshot, const CCbTx& cbtx, std: error = e.what(); return false; } + if (cbtx.nHeight != snapshot.mn_list.GetHeightForSnapshotCodec()) { + error = "evo snapshot coinbase height mismatch"; + return false; + } bool mutated{false}; const uint256 mn_root{snapshot.mn_list.to_sml()->CalcMerkleRoot(&mutated)}; if (mutated || mn_root != cbtx.merkleRootMNList) { diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index 18a306a24274..3a5c93bff642 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -772,6 +772,7 @@ BOOST_FIXTURE_TEST_CASE(cbtx_cross_checks, BasicTestingSetup) const auto snapshot{SyntheticSnapshot()}; CCbTx cbtx; cbtx.nVersion = CCbTx::Version::CLSIG_AND_BALANCE; + cbtx.nHeight = snapshot.mn_list.GetHeightForSnapshotCodec(); cbtx.merkleRootMNList = snapshot.mn_list.to_sml()->CalcMerkleRoot(); std::vector quorum_hashes; for (const auto& data : snapshot.quorums) { @@ -791,6 +792,10 @@ BOOST_FIXTURE_TEST_CASE(cbtx_cross_checks, BasicTestingSetup) cbtx.merkleRootQuorums = ComputeMerkleRoot(quorum_hashes); cbtx.creditPoolBalance++; BOOST_CHECK(!evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); + cbtx.creditPoolBalance = snapshot.credit_pool.locked; + cbtx.nHeight++; + BOOST_CHECK(!evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); + BOOST_CHECK(error.find("coinbase height") != std::string::npos); } BOOST_FIXTURE_TEST_CASE(rejects_unknown_wire_version, BasicTestingSetup) From 8d32a3f943e12f6df6d8888142cdf81fd11a27ab Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 20 Aug 2026 08:34:15 -0500 Subject: [PATCH 12/14] fix: replace per-quorum contents when deserializing CQuorumSnapshotData The top-level decoder always constructs a fresh local, but direct stream >> data into a reused CQuorumSnapshotData retained prior commitments and rotation entries and could exceed the incoming count bounds. Clear the three vectors up front, matching CEvoSnapshot::Unserialize. Co-Authored-By: Claude Fable 5 --- src/evo/snapshot.h | 6 ++++++ src/test/evo_snapshot_tests.cpp | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index 42609d0212fd..c4aa1b6db2d3 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -515,6 +515,12 @@ template void CQuorumSnapshotData::Unserialize(Stream& s) { s >> llmq_type >> rotation_enabled; + // Same replacement semantics as CEvoSnapshot::Unserialize: decoding into a + // reused object must not retain (or exceed the count bounds through) + // previously held entries. + active_commitments.clear(); + safety_commitments.clear(); + rotation_snapshots.clear(); const auto& params{SnapshotLLMQParams(llmq_type)}; const size_t total_count{SnapshotCommitmentCount(params, rotation_enabled)}; const size_t expected_active{static_cast(params.signingActiveQuorumCount)}; diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index 3a5c93bff642..a4cb7341bbe2 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -767,6 +767,24 @@ BOOST_FIXTURE_TEST_CASE(hash_prefix_collision_runs_are_bounded, BasicTestingSetu BOOST_CHECK(reconstruction_error.find("collision bound") != std::string::npos); } +BOOST_FIXTURE_TEST_CASE(quorum_data_unserialize_replaces_previous_contents, BasicTestingSetup) +{ + evo::CQuorumSnapshotData data; + data.llmq_type = Consensus::LLMQType::LLMQ_TEST; + data.active_commitments = {Commitment(data.llmq_type, 11, 51, false)}; + CDataStream once{SER_DISK, CLIENT_VERSION}; + once << data; + CDataStream twice{SER_DISK, CLIENT_VERSION}; + twice << data; + + evo::CQuorumSnapshotData reused; + once >> reused; + twice >> reused; + BOOST_CHECK_EQUAL(reused.active_commitments.size(), 1U); + BOOST_CHECK_EQUAL(reused.safety_commitments.size(), 0U); + BOOST_CHECK_EQUAL(reused.rotation_snapshots.size(), 0U); +} + BOOST_FIXTURE_TEST_CASE(cbtx_cross_checks, BasicTestingSetup) { const auto snapshot{SyntheticSnapshot()}; From ce6c66adc135558431acd76be3485ffb6afed168 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 20 Aug 2026 08:34:34 -0500 Subject: [PATCH 13/14] fix: bound cumulative historical MN-list reconstruction work The per-diff operation budget charges only additions, updates, and removals, so a few hundred bytes of zero-operation diff entries could drag a maximum-size list across the whole table-wide history horizon: every entry traverses, sorts, and canonically hashes the full reconstructed list (~19M record serializations from a small input). Charge a cumulative record budget up front in ReconstructHistoricalMNLists. The table-wide horizon sums types no single network enables together, so even a ceiling-sized list under a fully loaded real configuration stays well below half the budget. Co-Authored-By: Claude Fable 5 --- src/evo/snapshot.cpp | 12 +++++++++++- src/evo/snapshot.h | 11 ++++++++++- src/test/evo_snapshot_tests.cpp | 14 ++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp index ca5f83705151..4483fde4faca 100644 --- a/src/evo/snapshot.cpp +++ b/src/evo/snapshot.cpp @@ -183,13 +183,15 @@ uint256 CanonicalMNListHash(const CDeterministicMNList& list) } bool ReconstructHistoricalMNLists(const CEvoSnapshot& snapshot, - std::map& lists, std::string& error) + std::map& lists, std::string& error, + size_t max_records) { lists.clear(); error.clear(); CDeterministicMNList current{snapshot.mn_list}; uint256 previous_hash{snapshot.base_block_hash}; int previous_height{current.GetHeightForSnapshotCodec()}; + size_t records_processed{0}; try { const auto history{Sorted(snapshot.historical_mn_list_diffs)}; for (const auto& entry : history) { @@ -197,6 +199,14 @@ bool ReconstructHistoricalMNLists(const CEvoSnapshot& snapshot, entry.height < 0 || entry.height >= previous_height || entry.canonical_list_hash.IsNull()) { throw std::ios_base::failure("broken historical MN-list diff chain"); } + // Each entry traverses, sorts, and canonically hashes the whole + // reconstructed list, so the per-diff operation budget alone lets + // zero-operation entries multiply a maximum-size list across the + // history horizon. Charge the cumulative record count up front. + records_processed += current.GetCounts().total() + entry.diff.addedMNs.size(); + if (records_processed > max_records) { + throw std::ios_base::failure("historical MN-list reconstruction record budget exceeded"); + } // Bound the collision groups the additions would create before the // HAMT performs the inserts; the post-apply invariant check would // run only after the quadratic work it exists to prevent. diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index c4aa1b6db2d3..d4784593b648 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -73,6 +73,14 @@ static constexpr size_t EVO_SNAPSHOT_MAX_QUORUM_SIZE{10'000}; // shared prefix has probability ~3e-10, so a run of 8 is unreachable outside // crafted input. Enforced on the base list and every reconstructed list. static constexpr size_t EVO_SNAPSHOT_MAX_HASH_PREFIX_RUN{8}; +// Every historical entry costs a full traversal, sort, and canonical hash of +// the reconstructed list, so a few hundred bytes of zero-operation diffs could +// otherwise drag a maximum-size list across the whole table-wide history +// horizon (~19M record visits). The horizon that sums every table entry is +// unreachable on a real chain: no network enables more than a fraction of the +// LLMQ table at once, so even a ceiling-sized list on a fully loaded mainnet +// configuration stays well below half of this cumulative record budget. +static constexpr size_t EVO_SNAPSHOT_MAX_RECONSTRUCTION_RECORDS{8'000'000}; static_assert(std::ranges::all_of(Consensus::available_llmqs, [](const auto& params) { return !params.useRotation || params.keepOldConnections <= 2 * params.signingActiveQuorumCount; }), "rotated LLMQ retention exceeds the two serialized cycles"); @@ -640,7 +648,8 @@ std::vector EvoSnapshotReconstructionHeights( /** Apply the complete diff chain and return lists keyed by target block hash. */ bool ReconstructHistoricalMNLists(const CEvoSnapshot& snapshot, - std::map& lists, std::string& error); + std::map& lists, std::string& error, + size_t max_records = EVO_SNAPSHOT_MAX_RECONSTRUCTION_RECORDS); /** Pure CbTx checks over already-built snapshot content. */ bool VerifyEvoSnapshotCbTx(const CEvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error); diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index a4cb7341bbe2..e96230f616f6 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -785,6 +785,20 @@ BOOST_FIXTURE_TEST_CASE(quorum_data_unserialize_replaces_previous_contents, Basi BOOST_CHECK_EQUAL(reused.rotation_snapshots.size(), 0U); } +BOOST_FIXTURE_TEST_CASE(reconstruction_record_budget_is_cumulative, BasicTestingSetup) +{ + const auto snapshot{SyntheticSnapshot()}; + std::map lists; + std::string error; + BOOST_REQUIRE(evo::ReconstructHistoricalMNLists(snapshot, lists, error)); + // Every historical entry here carries the same 3-MN list with no + // additions, so the cumulative charge is exactly 3 records per entry. + const size_t total_records{3 * snapshot.historical_mn_list_diffs.size()}; + BOOST_CHECK(evo::ReconstructHistoricalMNLists(snapshot, lists, error, total_records)); + BOOST_CHECK(!evo::ReconstructHistoricalMNLists(snapshot, lists, error, total_records - 1)); + BOOST_CHECK(error.find("record budget") != std::string::npos); +} + BOOST_FIXTURE_TEST_CASE(cbtx_cross_checks, BasicTestingSetup) { const auto snapshot{SyntheticSnapshot()}; From 604253c24f0aa715fb95bfe0f4425aad12851ff0 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 13 Aug 2026 11:48:02 -0500 Subject: [PATCH 14/14] feat: build, chain-validate, and seed evo snapshot state Second code PR of the assumeutxo M4 series: BuildEvoSnapshot() collects commitments, rotation snapshots, historical MN-list diffs, and exact score modifiers from chain state; ValidateEvoSnapshotAgainstChain() verifies a decoded snapshot against the block index, deployments, and quorum reconstruction; and the EvoDB seeding surface writes exactly the state reconstruction later reads. The chain-aware implementations live in the header-less evo/snapshot_chain.cpp unit so the codec in evo/snapshot.cpp never includes validation.h (no evo/snapshot -> validation cycle); ChainstateManager code is untouched here and stays in validation.cpp later in the series. Parameter-derived quorum counts are carried as maxima rather than exact requirements: a young chain, a freshly activated type, or a rotation type whose horizon predates activation legitimately has less history, and requiring the full horizon made dumptxoutset fail on valid chain state (flagged as blocking by review on the original M4 PR). Completeness is anchored by the completion-time CbTx quorum merkle root, the historical-diff and modifier tallies, and completion-time quorum reconstruction; two regression tests fail against exact-count enforcement. GetHashModifier() prefers a seeded exact modifier when work-block data is unavailable and cross-checks it when present; the mismatch error stays unreachable until the load integration seeds modifiers. GetDeterministicMNListHash() switches to the canonical codec hash (dev-channel-only marker-compat caveat in the PR description). Co-Authored-By: Claude Fable 5 --- src/Makefile.am | 3 + src/evo/chainhelper.cpp | 10 +- src/evo/chainhelper.h | 5 + src/evo/creditpool.cpp | 18 +- src/evo/creditpool.h | 2 + src/evo/deterministicmns.cpp | 33 +- src/evo/deterministicmns.h | 18 +- src/evo/evodb.cpp | 45 ++- src/evo/evodb.h | 8 + src/evo/mnhftx.cpp | 6 + src/evo/mnhftx.h | 2 + src/evo/snapshot.h | 24 +- src/evo/snapshot_chain.cpp | 425 ++++++++++++++++++++++ src/evo/snapshot_types.h | 20 + src/evo/specialtxman.cpp | 6 +- src/llmq/blockprocessor.cpp | 27 ++ src/llmq/blockprocessor.h | 4 + src/llmq/snapshot.cpp | 40 +- src/llmq/snapshot.h | 9 + src/llmq/utils.cpp | 57 ++- src/llmq/utils.h | 5 + src/test/evo_snapshot_tests.cpp | 623 ++++++++++++++++++++++++++++++++ src/test/util/setup_common.cpp | 2 + 23 files changed, 1357 insertions(+), 35 deletions(-) create mode 100644 src/evo/snapshot_chain.cpp create mode 100644 src/evo/snapshot_types.h diff --git a/src/Makefile.am b/src/Makefile.am index 8938dc0eeeed..51f938dd9cb9 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -235,6 +235,7 @@ BITCOIN_CORE_H = \ evo/providertx_service.h \ evo/simplifiedmns.h \ evo/smldiff.h \ + evo/snapshot_types.h \ evo/snapshot.h \ evo/specialtx.h \ evo/specialtx_filter.h \ @@ -544,6 +545,7 @@ libbitcoin_node_a_SOURCES = \ evo/mnauth.cpp \ evo/mnhftx.cpp \ evo/snapshot.cpp \ + evo/snapshot_chain.cpp \ evo/providertx.cpp \ evo/providertx_service.cpp \ evo/simplifiedmns.cpp \ @@ -1283,6 +1285,7 @@ libdashkernel_la_SOURCES = \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ evo/snapshot.cpp \ + evo/snapshot_chain.cpp \ evo/specialtx.cpp \ evo/specialtx_filter.cpp \ evo/specialtxman.cpp \ diff --git a/src/evo/chainhelper.cpp b/src/evo/chainhelper.cpp index 7bcd66d40c6e..ac5927eea041 100644 --- a/src/evo/chainhelper.cpp +++ b/src/evo/chainhelper.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,8 @@ CChainstateHelper::CChainstateHelper(CEvoDB& evodb, CDeterministicMNManager& dmn isman{isman}, mn_sync{mn_sync}, m_dmnman{dmnman}, + m_qblockman{qblockman}, + m_qsnapman{qsnapman}, credit_pool_manager{std::make_unique(evodb, chainman)}, m_chainlocks{chainlocks}, ehf_manager{std::make_unique(evodb, chainman)}, @@ -66,7 +69,12 @@ int32_t CChainstateHelper::GetBestChainLockHeight() const { return m_chainlocks. uint256 CChainstateHelper::GetDeterministicMNListHash(const CBlockIndex* pindex) const { - return SerializeHash(m_dmnman.GetListForBlock(Assert(pindex))); + const CBlockIndex* index{Assert(pindex)}; + CDeterministicMNList list{m_dmnman.GetListForBlock(index)}; + if (list.GetBlockHash().IsNull()) { + list = CDeterministicMNList{index->GetBlockHash(), index->nHeight, 0}; + } + return evo::CanonicalMNListHash(list); } /** Passthrough functions to CCreditPoolManager */ diff --git a/src/evo/chainhelper.h b/src/evo/chainhelper.h index 0036475fd282..00b07600b4f8 100644 --- a/src/evo/chainhelper.h +++ b/src/evo/chainhelper.h @@ -43,6 +43,8 @@ class CChainstateHelper llmq::CInstantSendManager& isman; const CMasternodeSync& mn_sync; CDeterministicMNManager& m_dmnman; + llmq::CQuorumBlockProcessor& m_qblockman; + llmq::CQuorumSnapshotManager& m_qsnapman; public: const std::unique_ptr credit_pool_manager; @@ -72,6 +74,9 @@ class CChainstateHelper /** Return a canonical hash of the deterministic MN list derived at a block. */ uint256 GetDeterministicMNListHash(const CBlockIndex* pindex) const; + CDeterministicMNManager& DeterministicMNManager() { return m_dmnman; } + llmq::CQuorumBlockProcessor& QuorumBlockProcessor() { return m_qblockman; } + llmq::CQuorumSnapshotManager& QuorumSnapshotManager() { return m_qsnapman; } /** Passthrough functions to CCreditPoolManager */ CCreditPool GetCreditPool(const CBlockIndex* const pindex); diff --git a/src/evo/creditpool.cpp b/src/evo/creditpool.cpp index 5839b890305f..a0ed73322f0b 100644 --- a/src/evo/creditpool.cpp +++ b/src/evo/creditpool.cpp @@ -125,12 +125,12 @@ std::optional CCreditPoolManager::GetFromCache(const CBlockIndex& b return pool; } } - if (block_index.nHeight % DISK_SNAPSHOT_PERIOD == 0) { - if (evoDb.Read(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block_hash), pool)) { - LOCK(cache_mutex); - creditPoolCache.insert(block_hash, pool); - return pool; - } + // Snapshot activation may deliberately seed a full state at a height that + // is not one of the normal periodic checkpoints. + if (evoDb.Read(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block_hash), pool)) { + LOCK(cache_mutex); + creditPoolCache.insert(block_hash, pool); + return pool; } return std::nullopt; } @@ -155,6 +155,12 @@ void CCreditPoolManager::AddToCache(const uint256& block_hash, int height, const } } +bool CCreditPoolManager::SeedSnapshot(const CBlockIndex* block, const CCreditPool& pool) +{ + assert(block != nullptr); + return evoDb.WriteDerived(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block->GetBlockHash()), pool); +} + CCreditPool CCreditPoolManager::ConstructCreditPool(const gsl::not_null block_index, CCreditPool prev) { std::optional opt_block_data = GetCreditDataFromBlock(block_index, m_chainman.GetConsensus()); diff --git a/src/evo/creditpool.h b/src/evo/creditpool.h index fec44ab680cb..196632bb7290 100644 --- a/src/evo/creditpool.h +++ b/src/evo/creditpool.h @@ -134,6 +134,8 @@ class CCreditPoolManager * it can happen if there limits of withdrawal (unlock) exceed */ CCreditPool GetCreditPool(const CBlockIndex* block) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); + /** Seed a full pool snapshot in the current EvoDB transaction. */ + bool SeedSnapshot(const CBlockIndex* block, const CCreditPool& pool) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); private: std::optional GetFromCache(const CBlockIndex& block_index) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); diff --git a/src/evo/deterministicmns.cpp b/src/evo/deterministicmns.cpp index a2abc2dda9d0..4c914ce5d728 100644 --- a/src/evo/deterministicmns.cpp +++ b/src/evo/deterministicmns.cpp @@ -376,19 +376,13 @@ void CDeterministicMNList::ApplyDiff(gsl::not_null pindex, c for (const auto& id : diff.removedMns) { auto dmn = GetMNByInternalId(id); - if (!dmn) { - throw std::runtime_error(strprintf("%s: can't find a removed masternode, id=%d", __func__, id)); - } + if (!dmn) throw std::runtime_error(strprintf("%s: can't find a removed masternode, id=%d", __func__, id)); RemoveMN(dmn->proTxHash); } - for (const auto& dmn : diff.addedMNs) { - AddMN(dmn); - } + for (const auto& dmn : diff.addedMNs) AddMN(dmn); for (const auto& p : diff.updatedMNs) { auto dmn = GetMNByInternalId(p.first); - if (!dmn) { - throw std::runtime_error(strprintf("%s: can't find an updated masternode, id=%d", __func__, p.first)); - } + if (!dmn) throw std::runtime_error(strprintf("%s: can't find an updated masternode, id=%d", __func__, p.first)); UpdateMN(*dmn, p.second); } } @@ -403,7 +397,9 @@ void CDeterministicMNList::ApplyDiffForSnapshot(const uint256& block_hash, int h for (const auto& id : diff.removedMns) { auto dmn = GetMNByInternalId(id); - if (!dmn) throw std::runtime_error(strprintf("%s: can't find a removed masternode, id=%d", __func__, id)); + if (!dmn) { + throw std::runtime_error(strprintf("%s: can't find a removed masternode, id=%d", __func__, id)); + } RemoveMN(dmn->proTxHash); } for (const auto& dmn : diff.addedMNs) { @@ -411,7 +407,9 @@ void CDeterministicMNList::ApplyDiffForSnapshot(const uint256& block_hash, int h } for (const auto& p : diff.updatedMNs) { auto dmn = GetMNByInternalId(p.first); - if (!dmn) throw std::runtime_error(strprintf("%s: can't find an updated masternode, id=%d", __func__, p.first)); + if (!dmn) { + throw std::runtime_error(strprintf("%s: can't find an updated masternode, id=%d", __func__, p.first)); + } UpdateMN(*dmn, p.second); } nTotalRegisteredCount = total_registered_count; @@ -646,6 +644,18 @@ CDeterministicMNManager::CDeterministicMNManager(CEvoDB& evoDb, CMasternodeMetaM CDeterministicMNManager::~CDeterministicMNManager() = default; +bool CDeterministicMNManager::SeedListForBlock(const CDeterministicMNList& list) +{ + return m_evoDb.WriteDerived(std::make_pair(DB_LIST_SNAPSHOT, list.GetBlockHash()), list); +} + +void CDeterministicMNManager::InvalidateListCacheForBlock(const uint256& block_hash) +{ + LOCK(cs); + mnListsCache.erase(block_hash); + mnListDiffsCache.erase(block_hash); +} + bool CDeterministicMNManager::ProcessBlock(const CBlock& block, gsl::not_null pindex, BlockValidationState& state, const CDeterministicMNList& newList, std::optional& updatesRet) @@ -813,6 +823,7 @@ CDeterministicMNList CDeterministicMNManager::GetListForBlockInternal(gsl::not_n mnListsCache.emplace(pindex->GetBlockHash(), snapshot); break; } + if (m_list_snapshot_miss_hook) m_list_snapshot_miss_hook(pindex); // no snapshot found yet, check diffs auto itDiffs = mnListDiffsCache.find(pindex->GetBlockHash()); diff --git a/src/evo/deterministicmns.h b/src/evo/deterministicmns.h index b92612ce5116..d30db484945f 100644 --- a/src/evo/deterministicmns.h +++ b/src/evo/deterministicmns.h @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -424,7 +425,6 @@ class CDeterministicMNList */ void ApplyDiff(gsl::not_null pindex, const CDeterministicMNListDiff& diff) EXCLUSIVE_LOCKS_REQUIRED(!m_cached_sml_mutex); - /** Apply a snapshot-local historical diff without dereferencing block data. */ void ApplyDiffForSnapshot(const uint256& block_hash, int height, uint32_t total_registered_count, const CDeterministicMNListDiff& diff) @@ -773,6 +773,7 @@ class CDeterministicMNManager Uint256HashMap mnListsCache GUARDED_BY(cs); Uint256HashMap mnListDiffsCache GUARDED_BY(cs); + std::function m_list_snapshot_miss_hook GUARDED_BY(cs); const CBlockIndex* tipIndex GUARDED_BY(cs) {nullptr}; const CBlockIndex* m_initial_snapshot_index GUARDED_BY(cs) {nullptr}; @@ -796,6 +797,21 @@ class CDeterministicMNManager }; CDeterministicMNList GetListAtChainTip() EXCLUSIVE_LOCKS_REQUIRED(!cs); + /** Seed a canonical full-list snapshot in the current EvoDB transaction. */ + bool SeedListForBlock(const CDeterministicMNList& list) EXCLUSIVE_LOCKS_REQUIRED(!cs); + + /** Invalidate cached list data so the next lookup reloads it from EvoDB. */ + void InvalidateListCacheForBlock(const uint256& block_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); + + /** Test-only guard invoked after a full-list cache/EvoDB miss, before + * ordinary diff-chain reconstruction can access earlier NORMAL state. */ + void SetListSnapshotMissHookForTesting(std::function hook) + EXCLUSIVE_LOCKS_REQUIRED(!cs) + { + LOCK(cs); + m_list_snapshot_miss_hook = std::move(hook); + } + void SetListForBlockForTesting(const CDeterministicMNList& list) EXCLUSIVE_LOCKS_REQUIRED(!cs) { LOCK(cs); diff --git a/src/evo/evodb.cpp b/src/evo/evodb.cpp index 88787fe1e8ea..3085baacdd9b 100644 --- a/src/evo/evodb.cpp +++ b/src/evo/evodb.cpp @@ -42,6 +42,12 @@ CEvoDB::CEvoDB(const util::DbWrapperParams& db_params) : CEvoDB::~CEvoDB() = default; +bool CEvoDB::HasActiveTransaction() +{ + LOCK(cs); + return active_transaction.has_value(); +} + CEvoDB::TransactionContext& CEvoDB::GetContext(EvoDbIdentity identity) { auto it = transaction_contexts.find(identity); @@ -182,6 +188,37 @@ bool CEvoDB::ReadBackgroundMNListHash(uint256& block_hash, uint256& mn_list_hash return true; } +void CEvoDB::WriteRequiredWorkMNListHashes(const std::vector& block_hashes) +{ + Write(EVODB_REQUIRED_WORK_MNLISTS, block_hashes); +} + +bool CEvoDB::ReadRequiredWorkMNListHashes(std::vector& block_hashes) +{ + return Read(EVODB_REQUIRED_WORK_MNLISTS, block_hashes); +} + +void CEvoDB::WriteBackgroundWorkMNListHash(const uint256& block_hash, const uint256& mn_list_hash) +{ + Write(std::make_pair(EVODB_BACKGROUND_WORK_MNLIST_HASH, block_hash), mn_list_hash); +} + +bool CEvoDB::ReadBackgroundWorkMNListHash(const uint256& block_hash, uint256& mn_list_hash) +{ + return Read(std::make_pair(EVODB_BACKGROUND_WORK_MNLIST_HASH, block_hash), mn_list_hash); +} + +static void EraseHistoricalMNListMarkers(CDBWrapper& db, CDBBatch& batch) +{ + std::vector required; + if (db.Read(EVODB_REQUIRED_WORK_MNLISTS, required)) { + for (const auto& block_hash : required) { + batch.Erase(std::make_pair(EVODB_BACKGROUND_WORK_MNLIST_HASH, block_hash)); + } + } + batch.Erase(EVODB_REQUIRED_WORK_MNLISTS); +} + bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) { LOCK(cs); @@ -198,7 +235,9 @@ bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) uint256 normal_tip; const bool already_promoted = db->Read(EVODB_BEST_BLOCK, normal_tip) && normal_tip == expected_snapshot_tip && !db->Exists(EVODB_DUAL_CHAINSTATE) && !db->Exists(EVODB_SNAPSHOT_MNLIST_HASH) && - !db->Exists(EVODB_BACKGROUND_MNLIST_HASH); + !db->Exists(EVODB_BACKGROUND_MNLIST_HASH) && + !db->Exists(EVODB_REQUIRED_WORK_MNLISTS) && + !db->Exists(EVODB_SNAPSHOT_EVO_SECTION); if (already_promoted) m_default_identity = EvoDbIdentity::NORMAL; return already_promoted; } @@ -209,6 +248,8 @@ bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) batch.Erase(snapshot_key); batch.Erase(EVODB_SNAPSHOT_MNLIST_HASH); batch.Erase(EVODB_BACKGROUND_MNLIST_HASH); + EraseHistoricalMNListMarkers(*db, batch); + batch.Erase(EVODB_SNAPSHOT_EVO_SECTION); batch.Erase(EVODB_DUAL_CHAINSTATE); if (!db->WriteBatch(batch, /*fSync=*/true)) return false; // The dual-chainstate run is over: the promoted state is the NORMAL @@ -231,6 +272,8 @@ bool CEvoDB::DiscardSnapshotMarkers() batch.Erase(std::make_pair(EVODB_BEST_BLOCK, uint8_t{1})); batch.Erase(EVODB_SNAPSHOT_MNLIST_HASH); batch.Erase(EVODB_BACKGROUND_MNLIST_HASH); + EraseHistoricalMNListMarkers(*db, batch); + batch.Erase(EVODB_SNAPSHOT_EVO_SECTION); batch.Erase(EVODB_DUAL_CHAINSTATE); if (!db->WriteBatch(batch, /*fSync=*/true)) return false; // The snapshot chainstate is gone; transaction-less access must resolve diff --git a/src/evo/evodb.h b/src/evo/evodb.h index 40d37b48229d..7aff76c9f986 100644 --- a/src/evo/evodb.h +++ b/src/evo/evodb.h @@ -31,6 +31,9 @@ static const std::string EVODB_BEST_BLOCK = "b_b4"; static const std::string EVODB_DUAL_CHAINSTATE = "b_dcs"; static const std::string EVODB_SNAPSHOT_MNLIST_HASH = "b_dcs_mn"; static const std::string EVODB_BACKGROUND_MNLIST_HASH = "b_dcs_bg_mn"; +static const std::string EVODB_REQUIRED_WORK_MNLISTS = "b_dcs_req_mn"; +static const std::string EVODB_BACKGROUND_WORK_MNLIST_HASH = "b_dcs_bg_work_mn"; +static const std::string EVODB_SNAPSHOT_EVO_SECTION = "b_dcs_evo"; enum class EvoDbIdentity { NORMAL, @@ -224,6 +227,7 @@ class CEvoDB bool CommitRootTransaction(EvoDbIdentity identity = EvoDbIdentity::NORMAL, bool sync = false) EXCLUSIVE_LOCKS_REQUIRED(!cs); bool IsEmpty() { return db->IsEmpty(); } + bool HasActiveTransaction() EXCLUSIVE_LOCKS_REQUIRED(!cs); //! Set the identity used by reads/writes outside any transaction. Must //! track the active chainstate: snapshot activation sets SNAPSHOT; @@ -250,6 +254,10 @@ class CEvoDB bool ReadSnapshotBaseMNListHash(uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); void WriteBackgroundMNListHash(const uint256& block_hash, const uint256& mn_list_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); bool ReadBackgroundMNListHash(uint256& block_hash, uint256& mn_list_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); + void WriteRequiredWorkMNListHashes(const std::vector& block_hashes) EXCLUSIVE_LOCKS_REQUIRED(!cs); + bool ReadRequiredWorkMNListHashes(std::vector& block_hashes) EXCLUSIVE_LOCKS_REQUIRED(!cs); + void WriteBackgroundWorkMNListHash(const uint256& block_hash, const uint256& mn_list_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); + bool ReadBackgroundWorkMNListHash(const uint256& block_hash, uint256& mn_list_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); /** * Atomically promote the surviving snapshot marker to the legacy NORMAL key diff --git a/src/evo/mnhftx.cpp b/src/evo/mnhftx.cpp index f36e9b0a80ca..73bb7fd93c37 100644 --- a/src/evo/mnhftx.cpp +++ b/src/evo/mnhftx.cpp @@ -383,6 +383,12 @@ void CMNHFManager::AddToCache(const Signals& signals, const CBlockIndex* const p } } +bool CMNHFManager::SeedSignals(const CBlockIndex* pindex, const Signals& signals) +{ + assert(pindex != nullptr); + return m_evoDb.WriteDerived(std::make_pair(DB_SIGNALS_v2, pindex->GetBlockHash()), signals); +} + void CMNHFManager::AddSignal(const CBlockIndex* const pindex, int bit) { auto signals = GetForBlock(pindex->pprev); diff --git a/src/evo/mnhftx.h b/src/evo/mnhftx.h index b5d721360d49..1ce2abd5a87a 100644 --- a/src/evo/mnhftx.h +++ b/src/evo/mnhftx.h @@ -136,6 +136,8 @@ class CMNHFManager : public AbstractEHFManager void AddSignal(const CBlockIndex* const pindex, int bit) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); bool ForceSignalDBUpdate() EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs_cache); + /** Seed the signals at a block in the current EvoDB transaction. */ + bool SeedSignals(const CBlockIndex* pindex, const Signals& signals) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); private: void AddToCache(const Signals& signals, const CBlockIndex* const pindex) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index d4784593b648..2619490da881 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -29,7 +30,17 @@ #include #include +class CBlockIndex; +class CChainParams; +class ChainstateManager; class CCbTx; +class CCreditPoolManager; +class CMNHFManager; + +namespace llmq { +class CQuorumBlockProcessor; +class CQuorumSnapshotManager; +} // namespace llmq namespace evo { @@ -486,7 +497,7 @@ CQuorumSnapshotEntry ReadRotationSnapshot(Stream& s, const Consensus::LLMQParams s >> entry.cycle_base_block_hash >> entry.work_block_hash >> entry.snapshot.mnSkipListMode; // BuildQuorumSnapshot sizes this bitset to the complete work-block MN list, // not to the quorum size. The exact historical-list size is chain-aware and - // is checked by the chain-aware validation layered on later in the series. + // is checked by ValidateEvoSnapshotAgainstChain. const size_t bit_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "rotation bitset")}; ReadFixedBitSet(s, entry.snapshot.activeQuorumMembers, bit_count); const size_t skip_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_SKIPLIST_ENTRIES, "rotation skip list")}; @@ -635,6 +646,12 @@ void CEvoSnapshot::Unserialize(Stream& s) /** Single SHA256 of the canonical SER_DISK/CLIENT_VERSION encoding. */ uint256 GetEvoSnapshotHash(const CEvoSnapshot& snapshot); +bool BuildEvoSnapshot(const CChainParams& chainparams, const ChainstateManager& chainman, + CDeterministicMNManager& dmnman, + const llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, + CCreditPoolManager& cpoolman, CMNHFManager& mnhfman, const CBlockIndex* base_index, + CEvoSnapshot& snapshot, std::string& error) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + struct CQuorumReconstructionHeight { Consensus::LLMQType llmq_type; bool rotation; @@ -651,6 +668,11 @@ bool ReconstructHistoricalMNLists(const CEvoSnapshot& snapshot, std::map& lists, std::string& error, size_t max_records = EVO_SNAPSHOT_MAX_RECONSTRUCTION_RECORDS); +/** Validate all snapshot invariants requiring the block index or deployments. */ +bool ValidateEvoSnapshotAgainstChain(const CEvoSnapshot& snapshot, const ChainstateManager& chainman, + const CBlockIndex* base_index, std::string& error) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + /** Pure CbTx checks over already-built snapshot content. */ bool VerifyEvoSnapshotCbTx(const CEvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error); diff --git a/src/evo/snapshot_chain.cpp b/src/evo/snapshot_chain.cpp new file mode 100644 index 000000000000..126085e93f83 --- /dev/null +++ b/src/evo/snapshot_chain.cpp @@ -0,0 +1,425 @@ +// 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. + +// Chain-aware companions to evo/snapshot.cpp, deliberately header-less: these +// implementations are declared in evo/snapshot.h but need validation.h and +// llmq internals, so they live in their own compilation unit to keep the +// snapshot codec free of an evo/snapshot -> validation -> evo/snapshot cycle. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace evo { +namespace { + +// Mirrors the canonical ordering used by the codec in evo/snapshot.cpp. +template +std::vector Sorted(std::vector values) +{ + std::sort(values.begin(), values.end(), [](const T& a, const T& b) { + if constexpr (std::is_same_v) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + } else if constexpr (std::is_same_v) { + return a.cycle_base_block_hash < b.cycle_base_block_hash; + } else if constexpr (std::is_same_v) { + return std::tie(a.height, a.block_hash) > std::tie(b.height, b.block_hash); + } else if constexpr (std::is_same_v) { + return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); + } else { + return a.llmq_type < b.llmq_type; + } + }); + return values; +} + +bool ValidateCommitmentAgainstChain(const CMinedQuorumCommitment& entry, const ChainstateManager& chainman, + const CBlockIndex* base_index, const Consensus::LLMQParams& params, + bool rotation_enabled) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + const CBlockIndex* mined_index{chainman.m_blockman.LookupBlockIndex(entry.mined_block_hash)}; + if (quorum_index == nullptr || mined_index == nullptr || + base_index->GetAncestor(quorum_index->nHeight) != quorum_index || + base_index->GetAncestor(mined_index->nHeight) != mined_index) return false; + const int cycle_height{quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval}; + if (rotation_enabled) { + if (entry.commitment.quorumIndex != quorum_index->nHeight % params.dkgInterval || + entry.commitment.quorumIndex < 0 || + entry.commitment.quorumIndex >= params.signingActiveQuorumCount) return false; + } else if (quorum_index->nHeight != cycle_height || entry.commitment.quorumIndex != 0) { + return false; + } + const int mined_cycle{mined_index->nHeight - mined_index->nHeight % params.dkgInterval}; + if (mined_cycle != cycle_height || mined_index->nHeight % params.dkgInterval < params.dkgMiningWindowStart || + mined_index->nHeight % params.dkgInterval > params.dkgMiningWindowEnd) return false; + const uint16_t expected_version{llmq::CFinalCommitment::GetVersion( + rotation_enabled, DeploymentActiveAfter(quorum_index, chainman.GetConsensus(), Consensus::DEPLOYMENT_V19))}; + return entry.commitment.nVersion == expected_version && entry.commitment.VerifySizes(params); +} + +CMinedQuorumCommitment ReadCommitment(const llmq::CQuorumBlockProcessor& qblockman, Consensus::LLMQType type, + const CBlockIndex* quorum_index, const CBlockIndex* work_index, + std::string& error) +{ + auto [commitment, mined_hash] = qblockman.GetMinedCommitment(type, quorum_index->GetBlockHash()); + if (mined_hash.IsNull()) error = "mined quorum commitment not found for " + quorum_index->GetBlockHash().ToString(); + return {quorum_index->GetBlockHash(), work_index->GetBlockHash(), std::move(commitment), mined_hash}; +} + + +} // namespace + +bool BuildEvoSnapshot(const CChainParams& chainparams, const ChainstateManager& chainman, + CDeterministicMNManager& dmnman, + const llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, + CCreditPoolManager& cpoolman, CMNHFManager& mnhfman, const CBlockIndex* base_index, + CEvoSnapshot& snapshot, std::string& error) +{ + AssertLockHeld(::cs_main); + error.clear(); + if (base_index == nullptr) { + error = "evo snapshot base block is null"; + return false; + } + + CEvoSnapshot result; + result.base_block_hash = base_index->GetBlockHash(); + if (!DeploymentActiveAt(*base_index, chainparams.GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + result.mn_list = CDeterministicMNList{base_index->GetBlockHash(), base_index->nHeight, 0}; + result.Validate(); + snapshot = std::move(result); + return true; + } + result.mn_list = dmnman.GetListForBlock(base_index); + std::map> historical; + std::map, uint256> modifiers; + + const auto register_work_block = [&](const Consensus::LLMQParams& params, + bool rotation_enabled, + const CBlockIndex* quorum_index) -> const CBlockIndex* { + const CBlockIndex* modifier_base{rotation_enabled + ? quorum_index->GetAncestor(quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval) + : quorum_index}; + if (modifier_base == nullptr) return nullptr; + const CBlockIndex* work_index{ + (rotation_enabled || + DeploymentActiveAfter(modifier_base, chainparams.GetConsensus(), Consensus::DEPLOYMENT_V20)) + ? modifier_base->GetAncestor(modifier_base->nHeight - llmq::WORK_DIFF_DEPTH) + : modifier_base}; + if (work_index == nullptr) return nullptr; + historical.try_emplace(work_index->GetBlockHash(), work_index, dmnman.GetListForBlock(work_index)); + modifiers.emplace(std::make_pair(params.type, work_index->GetBlockHash()), + llmq::utils::GetQuorumHashModifier(params, chainparams.GetConsensus(), modifier_base)); + return work_index; + }; + + for (const auto& params : chainparams.GetConsensus().llmqs) { + if (!chainman.IsQuorumTypeEnabled(params.type, base_index)) continue; + CQuorumSnapshotData data; + data.llmq_type = params.type; + data.rotation_enabled = llmq::IsQuorumRotationEnabled(params, base_index); + + const size_t active_count{static_cast(params.signingActiveQuorumCount)}; + const size_t total_count{SnapshotCommitmentCount(params, data.rotation_enabled)}; + std::vector indexes; + if (data.rotation_enabled) { + indexes = qblockman.GetLastMinedCommitmentsPerQuorumIndexUntilBlock(params.type, base_index, 0); + } else { + indexes = qblockman.GetMinedCommitmentsUntilBlock(params.type, base_index, total_count); + } + // A young chain (or a freshly activated type) legitimately has fewer + // mined commitments than the parameter-derived horizon. Emit what + // exists: the CbTx quorum merkle root pins the active set at + // completion, so a shortfall cannot be used to hide commitments. + const size_t emit_active{std::min(indexes.size(), active_count)}; + for (size_t i{0}; i < emit_active; ++i) { + const CBlockIndex* work_index{register_work_block(params, data.rotation_enabled, indexes[i])}; + if (work_index == nullptr) { + error = "missing active quorum work block"; + return false; + } + auto entry{ReadCommitment(qblockman, params.type, indexes[i], work_index, error)}; + if (!error.empty()) return false; + data.active_commitments.emplace_back(std::move(entry)); + } + + if (data.rotation_enabled) { + indexes = qblockman.GetLastMinedCommitmentsPerQuorumIndexUntilBlock(params.type, base_index, 1); + } else { + indexes.erase(indexes.begin(), indexes.begin() + emit_active); + } + const size_t safety_count{std::min(indexes.size(), total_count - active_count)}; + for (size_t i{0}; i < safety_count; ++i) { + const CBlockIndex* work_index{register_work_block(params, data.rotation_enabled, indexes[i])}; + if (work_index == nullptr) { + error = "missing safety quorum work block"; + return false; + } + auto entry{ReadCommitment(qblockman, params.type, indexes[i], work_index, error)}; + if (!error.empty()) return false; + data.safety_commitments.emplace_back(std::move(entry)); + } + + if (data.rotation_enabled) { + std::vector one_type{params}; + for (const auto& required : EvoSnapshotReconstructionHeights(base_index->nHeight, one_type)) { + const int cycle_height{required.quorum_height}; + const int work_height{required.work_height}; + const CBlockIndex* cycle_index{cycle_height >= 0 ? base_index->GetAncestor(cycle_height) : nullptr}; + const CBlockIndex* work_index{cycle_index && work_height >= 0 + ? cycle_index->GetAncestor(work_height) + : nullptr}; + // Horizons preceding the chain, and cycles that predate the + // type's first rotation DKG, have no snapshot to carry. A gap + // on a mature chain surfaces at completion, where quorum + // reconstruction from the carried state must match the chain. + if (cycle_index == nullptr || work_index == nullptr) continue; + auto stored{qsnapman.GetSnapshotForBlock(params.type, cycle_index)}; + if (!stored) continue; + data.rotation_snapshots.push_back( + {cycle_index->GetBlockHash(), work_index->GetBlockHash(), *stored}); + historical.try_emplace(work_index->GetBlockHash(), work_index, dmnman.GetListForBlock(work_index)); + modifiers.emplace(std::make_pair(params.type, work_index->GetBlockHash()), + llmq::utils::GetQuorumHashModifier(params, chainparams.GetConsensus(), cycle_index)); + } + } + data.active_commitments = Sorted(std::move(data.active_commitments)); + data.safety_commitments = Sorted(std::move(data.safety_commitments)); + data.rotation_snapshots = Sorted(std::move(data.rotation_snapshots)); + result.quorums.emplace_back(std::move(data)); + } + + std::vector> ordered_history; + ordered_history.reserve(historical.size()); + for (auto& [_, indexed_list] : historical) ordered_history.emplace_back(std::move(indexed_list)); + std::sort(ordered_history.begin(), ordered_history.end(), [](const auto& a, const auto& b) { + return std::make_tuple(a.first->nHeight, a.first->GetBlockHash()) > + std::make_tuple(b.first->nHeight, b.first->GetBlockHash()); + }); + CDeterministicMNList previous_list{result.mn_list}; + uint256 previous_hash{result.base_block_hash}; + for (const auto& [index, list] : ordered_history) { + if (index->GetBlockHash() == result.base_block_hash) continue; + result.historical_mn_list_diffs.push_back({previous_hash, index->GetBlockHash(), index->nHeight, + list.GetTotalRegisteredCount(), CanonicalMNListHash(list), + previous_list.BuildDiff(list)}); + previous_hash = index->GetBlockHash(); + previous_list = list; + } + for (const auto& [key, modifier] : modifiers) { + result.quorum_modifiers.push_back({key.first, key.second, modifier}); + } + result.credit_pool = cpoolman.GetCreditPool(base_index); + result.mnhf_signals = mnhfman.GetSignalsStage(base_index); + result.quorums = Sorted(std::move(result.quorums)); + result.historical_mn_list_diffs = Sorted(std::move(result.historical_mn_list_diffs)); + result.quorum_modifiers = Sorted(std::move(result.quorum_modifiers)); + try { + result.Validate(); + } catch (const std::exception& e) { + error = e.what(); + return false; + } + snapshot = std::move(result); + return true; +} + +bool ValidateEvoSnapshotAgainstChain(const CEvoSnapshot& snapshot, const ChainstateManager& chainman, + const CBlockIndex* base_index, std::string& error) +{ + AssertLockHeld(::cs_main); + error.clear(); + const auto fail = [&](const std::string& message) { + error = message; + return false; + }; + if (base_index == nullptr || snapshot.base_block_hash != base_index->GetBlockHash() || + snapshot.mn_list.GetBlockHash() != base_index->GetBlockHash() || + snapshot.mn_list.GetHeightForSnapshotCodec() != base_index->nHeight) { + return fail("evo snapshot base block/height mismatch"); + } + try { + snapshot.Validate(/*require_canonical_order=*/true); + } catch (const std::exception& e) { + return fail(e.what()); + } + + const auto& consensus{chainman.GetConsensus()}; + if (!DeploymentActiveAt(*base_index, consensus, Consensus::DEPLOYMENT_DIP0003)) { + if (!snapshot.quorums.empty() || !snapshot.historical_mn_list_diffs.empty() || + !snapshot.quorum_modifiers.empty() || + snapshot.credit_pool.locked != 0 || snapshot.credit_pool.currentLimit != 0 || + snapshot.credit_pool.latelyUnlocked != 0 || !snapshot.credit_pool.indexes.IsEmpty() || + !snapshot.mnhf_signals.empty()) { + return fail("nonempty pre-DIP3 evo snapshot"); + } + return true; + } + + std::map historical_lists; + if (!ReconstructHistoricalMNLists(snapshot, historical_lists, error)) return false; + for (const auto& entry : snapshot.historical_mn_list_diffs) { + const CBlockIndex* index{chainman.m_blockman.LookupBlockIndex(entry.block_hash)}; + if (index == nullptr || base_index->GetAncestor(index->nHeight) != index || + entry.height != index->nHeight) { + return fail("invalid historical evo MN list chain data"); + } + } + + std::map actual; + for (const auto& data : snapshot.quorums) actual.emplace(data.llmq_type, &data); + size_t enabled_count{0}; + std::set required_work_hashes; + for (const auto& params : consensus.llmqs) { + if (!chainman.IsQuorumTypeEnabled(params.type, base_index)) continue; + ++enabled_count; + const auto it{actual.find(params.type)}; + if (it == actual.end()) return fail("missing enabled evo quorum type"); + const auto& data{*it->second}; + const bool rotation_enabled{llmq::IsQuorumRotationEnabled(params, base_index)}; + const size_t active_count{static_cast(params.signingActiveQuorumCount)}; + const size_t total_count{SnapshotCommitmentCount(params, rotation_enabled)}; + if (data.rotation_enabled != rotation_enabled || data.active_commitments.size() > active_count || + data.safety_commitments.size() > total_count - active_count) { + return fail("evo quorum params/count mismatch"); + } + + std::set active_indexes; + const auto validate_work_block = [&](const CMinedQuorumCommitment& entry) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + if (quorum_index == nullptr) return false; + const CBlockIndex* modifier_base{rotation_enabled + ? quorum_index->GetAncestor(quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval) + : quorum_index}; + if (modifier_base == nullptr) return false; + const CBlockIndex* expected_work{ + (rotation_enabled || DeploymentActiveAfter(modifier_base, consensus, Consensus::DEPLOYMENT_V20)) + ? modifier_base->GetAncestor(modifier_base->nHeight - llmq::WORK_DIFF_DEPTH) + : modifier_base}; + return expected_work != nullptr && entry.work_block_hash == expected_work->GetBlockHash(); + }; + for (const auto& entry : data.active_commitments) { + if (!ValidateCommitmentAgainstChain(entry, chainman, base_index, params, rotation_enabled) || + !validate_work_block(entry) || + (rotation_enabled && !active_indexes.insert(entry.commitment.quorumIndex).second)) { + return fail("invalid active evo quorum commitment chain data"); + } + required_work_hashes.insert(entry.work_block_hash); + } + for (const auto& entry : data.safety_commitments) { + if (!ValidateCommitmentAgainstChain(entry, chainman, base_index, params, rotation_enabled) || + !validate_work_block(entry)) { + return fail("invalid safety evo quorum commitment chain data"); + } + required_work_hashes.insert(entry.work_block_hash); + } + std::map rotations; + for (const auto& entry : data.rotation_snapshots) rotations.emplace(entry.cycle_base_block_hash, &entry); + const auto heights{EvoSnapshotReconstructionHeights(base_index->nHeight, {params})}; + if (rotations.size() > (rotation_enabled ? heights.size() : size_t{0})) { + return fail("evo rotation snapshot count mismatch"); + } + if (rotation_enabled) { + // Every carried rotation snapshot must sit at a derived horizon + // cycle with the matching work ancestor. Horizons the chain or the + // type's rotation history cannot provide are legitimately absent; + // completion-time quorum reconstruction establishes sufficiency. + size_t matched{0}; + for (const auto& required : heights) { + const int cycle_height{required.quorum_height}; + const int work_height{required.work_height}; + const CBlockIndex* cycle{cycle_height >= 0 ? base_index->GetAncestor(cycle_height) : nullptr}; + const CBlockIndex* work{work_height >= 0 ? base_index->GetAncestor(work_height) : nullptr}; + if (cycle == nullptr || work == nullptr) continue; + const auto rotation{rotations.find(cycle->GetBlockHash())}; + if (rotation == rotations.end()) continue; + if (rotation->second->work_block_hash != work->GetBlockHash()) { + return fail("evo rotation cycle/work ancestor mismatch"); + } + const auto historical{historical_lists.find(work->GetBlockHash())}; + if (historical == historical_lists.end() || + rotation->second->snapshot.activeQuorumMembers.size() != + historical->second.GetCounts().total()) { + return fail("evo rotation bitset/work-block MN count mismatch"); + } + required_work_hashes.insert(work->GetBlockHash()); + ++matched; + } + if (matched != rotations.size()) return fail("unknown evo rotation cycle"); + } + } + if (actual.size() != enabled_count) return fail("unexpected disabled evo quorum type"); + + std::set historical_hashes; + for (const auto& [hash, list] : historical_lists) historical_hashes.insert(hash); + historical_hashes.erase(base_index->GetBlockHash()); + required_work_hashes.erase(base_index->GetBlockHash()); + if (historical_hashes != required_work_hashes) return fail("missing or extra historical evo MN list"); + + std::map, uint256> seeded_modifiers; + for (const auto& entry : snapshot.quorum_modifiers) { + seeded_modifiers.emplace(std::make_pair(entry.llmq_type, entry.work_block_hash), entry.modifier); + } + for (const auto& data : snapshot.quorums) { + const auto params{chainman.GetParams().GetLLMQ(data.llmq_type)}; + if (!params) return fail("unknown chain LLMQ parameters for modifier"); + const auto check_modifier = [&](const uint256& quorum_hash, const uint256& work_hash) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + const auto seeded{seeded_modifiers.find(std::make_pair(data.llmq_type, work_hash))}; + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(quorum_hash)}; + const CBlockIndex* work_index{chainman.m_blockman.LookupBlockIndex(work_hash)}; + if (seeded == seeded_modifiers.end() || quorum_index == nullptr || work_index == nullptr) return false; + if ((work_index->nStatus & BLOCK_HAVE_DATA) != 0 && + seeded->second != llmq::utils::GetQuorumHashModifier(*params, consensus, quorum_index)) return false; + return true; + }; + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + const CBlockIndex* modifier_base{data.rotation_enabled && quorum_index != nullptr + ? quorum_index->GetAncestor(quorum_index->nHeight - quorum_index->nHeight % params->dkgInterval) + : quorum_index}; + if (modifier_base == nullptr || + !check_modifier(modifier_base->GetBlockHash(), entry.work_block_hash)) { + return fail("evo seeded quorum modifier mismatch"); + } + } + } + for (const auto& entry : data.rotation_snapshots) { + if (!check_modifier(entry.cycle_base_block_hash, entry.work_block_hash)) { + return fail("evo seeded rotation modifier mismatch"); + } + } + } + + if (!MoneyRange(snapshot.credit_pool.locked) || !MoneyRange(snapshot.credit_pool.currentLimit) || + !MoneyRange(snapshot.credit_pool.latelyUnlocked)) return fail("invalid evo credit pool monetary value"); + for (const auto& [bit, height] : snapshot.mnhf_signals) { + if (bit >= VERSIONBITS_NUM_BITS || height < 0 || height > base_index->nHeight) { + return fail("invalid evo MNHF signal bit/height"); + } + } + return true; +} + +} // namespace evo diff --git a/src/evo/snapshot_types.h b/src/evo/snapshot_types.h new file mode 100644 index 000000000000..a6b4389ef1e5 --- /dev/null +++ b/src/evo/snapshot_types.h @@ -0,0 +1,20 @@ +// 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. + +#ifndef BITCOIN_EVO_SNAPSHOT_TYPES_H +#define BITCOIN_EVO_SNAPSHOT_TYPES_H + +#include + +namespace evo { + +class SnapshotStateMismatchError : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +} // namespace evo + +#endif // BITCOIN_EVO_SNAPSHOT_TYPES_H diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index d8b89b43a89b..253d1d1c856a 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -787,7 +788,10 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const LogPrint(BCLog::BENCHMARK, " - m_qblockman.ProcessBlock: %.2fms [%.2fs]\n", 0.001 * (nTime5 - nTime4), nTimeQuorum * 0.000001); - CDeterministicMNList mn_list; + // Even before DIP3, bind the canonical empty list to the block so the + // independently derived completion hash has the same identity as an + // empty evo snapshot section. + CDeterministicMNList mn_list{pindex->GetBlockHash(), pindex->nHeight, 0}; if (DeploymentActiveAt(*pindex, m_consensus_params, Consensus::DEPLOYMENT_DIP0003)) { if (!BuildNewListFromBlock(block, pindex->pprev, view, true, state, mn_list)) { // pass the state returned by the function above diff --git a/src/llmq/blockprocessor.cpp b/src/llmq/blockprocessor.cpp index ef5b287192df..81157c5e9e3a 100644 --- a/src/llmq/blockprocessor.cpp +++ b/src/llmq/blockprocessor.cpp @@ -685,6 +685,33 @@ std::pair CQuorumBlockProcessor::GetMinedCommitment(C return ret; } +bool CQuorumBlockProcessor::SeedMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorum_hash, + const CFinalCommitment& commitment, + const uint256& mined_block_hash) +{ + AssertLockHeld(::cs_main); + const auto llmq_params = Params().GetLLMQ(llmqType); + const CBlockIndex* mined_index = m_chainman.m_blockman.LookupBlockIndex(mined_block_hash); + const CBlockIndex* quorum_base_index = m_chainman.m_blockman.LookupBlockIndex(quorum_hash); + if (!llmq_params || mined_index == nullptr || quorum_base_index == nullptr) return false; + if (!m_evoDb.WriteDerived( + std::make_pair(DB_MINED_COMMITMENT, std::make_pair(llmqType, quorum_hash)), + std::make_pair(commitment, mined_block_hash))) { + return false; + } + + // Replay ProcessCommitment's iteration index exactly. These entries drive + // the first post-snapshot CbTx quorum-merkle-root calculation. + if (IsQuorumRotationEnabled(*llmq_params, quorum_base_index)) { + m_evoDb.Write(BuildInversedHeightKeyIndexed(llmqType, mined_index->nHeight, + int(commitment.quorumIndex)), + quorum_base_index->nHeight); + } else { + m_evoDb.Write(BuildInversedHeightKey(llmqType, mined_index->nHeight), quorum_base_index->nHeight); + } + return true; +} + // The returned quorums are in reversed order, so the most recent one is at index 0 std::vector CQuorumBlockProcessor::GetMinedCommitmentsUntilBlock(Consensus::LLMQType llmqType, gsl::not_null pindex, size_t maxCount) const { diff --git a/src/llmq/blockprocessor.h b/src/llmq/blockprocessor.h index bb11cd67ba58..1feb52f91e89 100644 --- a/src/llmq/blockprocessor.h +++ b/src/llmq/blockprocessor.h @@ -126,6 +126,10 @@ class CQuorumBlockProcessor bool HasMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorumHash, const CChain& chain) const EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !minableCommitmentsCs); std::pair GetMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorumHash) const; + /** Seed a mined commitment in the current EvoDB transaction. */ + bool SeedMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorum_hash, + const CFinalCommitment& commitment, const uint256& mined_block_hash) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main); /** * Serialized hashes of the commitments mined for the quorums active as of pindexPrev. diff --git a/src/llmq/snapshot.cpp b/src/llmq/snapshot.cpp index 51d3a44007b8..661777127c5a 100644 --- a/src/llmq/snapshot.cpp +++ b/src/llmq/snapshot.cpp @@ -317,10 +317,42 @@ void CQuorumSnapshotManager::StoreSnapshotForBlock(const Consensus::LLMQType llm { auto snapshotHash = ::SerializeHash(std::make_pair(llmqType, pindex->GetBlockHash())); - // LOCK(::cs_main); - AssertLockNotHeld(m_evoDb.cs); - LOCK2(snapshotCacheCs, m_evoDb.cs); - m_evoDb.GetRawDB().Write(std::make_pair(DB_QUORUM_SNAPSHOT, snapshotHash), snapshot); + if (!m_evoDb.WriteDerived(std::make_pair(DB_QUORUM_SNAPSHOT, snapshotHash), snapshot)) { + throw std::runtime_error("EvoDB quorum snapshot payload mismatch"); + } + LOCK(snapshotCacheCs); quorumSnapshotCache.insert(snapshotHash, snapshot); } + +bool CQuorumSnapshotManager::SeedSnapshotForBlock(const Consensus::LLMQType llmqType, const CBlockIndex* pindex, + const CQuorumSnapshot& snapshot) +{ + const auto snapshot_hash = ::SerializeHash(std::make_pair(llmqType, pindex->GetBlockHash())); + return m_evoDb.WriteDerived(std::make_pair(DB_QUORUM_SNAPSHOT, snapshot_hash), snapshot); +} + +bool CQuorumSnapshotManager::SeedQuorumModifier(Consensus::LLMQType llmq_type, + const uint256& work_block_hash, + const uint256& modifier) +{ + return m_evoDb.WriteDerived(std::make_tuple(std::string_view{"llmq_M3"}, llmq_type, work_block_hash), modifier); +} + +std::optional CQuorumSnapshotManager::GetSeededQuorumModifier( + Consensus::LLMQType llmq_type, const uint256& work_block_hash) const +{ + uint256 modifier; + if (!m_evoDb.Read(std::make_tuple(std::string_view{"llmq_M3"}, llmq_type, work_block_hash), modifier)) { + return std::nullopt; + } + return modifier; +} + +void CQuorumSnapshotManager::InvalidateSnapshotCacheForBlock(Consensus::LLMQType llmq_type, + const uint256& block_hash) +{ + const auto snapshot_hash{::SerializeHash(std::make_pair(llmq_type, block_hash))}; + LOCK(snapshotCacheCs); + quorumSnapshotCache.erase(snapshot_hash); +} } // namespace llmq diff --git a/src/llmq/snapshot.h b/src/llmq/snapshot.h index 7691dbf19286..b66ccbb6ed88 100644 --- a/src/llmq/snapshot.h +++ b/src/llmq/snapshot.h @@ -237,6 +237,15 @@ class CQuorumSnapshotManager std::optional GetSnapshotForBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex); void StoreSnapshotForBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex, const CQuorumSnapshot& snapshot); + /** Seed EvoDB without publishing state to the shared NORMAL-chainstate cache. */ + bool SeedSnapshotForBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex, + const CQuorumSnapshot& snapshot); + /** Seed/read the exact v20 score modifier keyed by type and work block. */ + bool SeedQuorumModifier(Consensus::LLMQType llmq_type, const uint256& work_block_hash, + const uint256& modifier); + std::optional GetSeededQuorumModifier(Consensus::LLMQType llmq_type, + const uint256& work_block_hash) const; + void InvalidateSnapshotCacheForBlock(Consensus::LLMQType llmq_type, const uint256& block_hash); }; } // namespace llmq diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 602f7e7d4d81..24fc7dd38f8f 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -22,6 +23,7 @@ #include #include #include +#include /** * Forward declarations @@ -95,8 +97,8 @@ uint256 GetHashModifierFromWorkBlock(const Consensus::LLMQParams& llmqParams, co return ::SerializeHash(std::make_pair(llmqParams.type, pWorkBlockIndex->GetBlockHash())); } -uint256 GetHashModifier(const Consensus::LLMQParams& llmqParams, const Consensus::Params& consensus_params, - gsl::not_null pCycleQuorumBaseBlockIndex) +uint256 CalculateHashModifier(const Consensus::LLMQParams& llmqParams, const Consensus::Params& consensus_params, + gsl::not_null pCycleQuorumBaseBlockIndex) { ASSERT_IF_DEBUG(pCycleQuorumBaseBlockIndex->nHeight % llmqParams.dkgInterval == 0); const CBlockIndex* pWorkBlockIndex = pCycleQuorumBaseBlockIndex->GetAncestor(pCycleQuorumBaseBlockIndex->nHeight - llmq::WORK_DIFF_DEPTH); @@ -113,6 +115,22 @@ uint256 GetHashModifier(const Consensus::LLMQParams& llmqParams, const Consensus return ::SerializeHash(std::make_pair(llmqParams.type, pCycleQuorumBaseBlockIndex->GetBlockHash())); } +uint256 GetHashModifier(const Consensus::LLMQParams& llmq_params, const Consensus::Params& consensus_params, + gsl::not_null cycle_index, + const llmq::CQuorumSnapshotManager* snapshot_manager) +{ + const CBlockIndex* work_index{cycle_index->GetAncestor(cycle_index->nHeight - llmq::WORK_DIFF_DEPTH)}; + if (snapshot_manager != nullptr && work_index != nullptr) { + if (const auto seeded{snapshot_manager->GetSeededQuorumModifier(llmq_params.type, work_index->GetBlockHash())}) { + if (WITH_LOCK(::cs_main, return (work_index->nStatus & BLOCK_HAVE_DATA) == 0;)) return *seeded; + const uint256 recomputed{CalculateHashModifier(llmq_params, consensus_params, cycle_index)}; + if (recomputed != *seeded) throw evo::SnapshotStateMismatchError("seeded quorum score modifier mismatch"); + return recomputed; + } + } + return CalculateHashModifier(llmq_params, consensus_params, cycle_index); +} + std::vector CalculateScoresForQuorum(QuorumMembers&& dmns, const uint256& modifier, const bool onlyEvoNodes) { std::vector scores; @@ -186,6 +204,7 @@ QuorumMembers CalculateQuorum(List&& mn_list, const uint256& modifier, size_t ma std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LLMQParams& llmqParams, CDeterministicMNManager& dmnman, + const llmq::CQuorumSnapshotManager& qsnapman, const Consensus::Params& consensus_params, const CBlockIndex* pCycleQuorumBaseBlockIndex, const llmq::CQuorumSnapshot& snapshot, int nHeight) @@ -200,7 +219,7 @@ std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LL const CBlockIndex* pWorkBlockIndex = pCycleQuorumBaseBlockIndex->GetAncestor( pCycleQuorumBaseBlockIndex->nHeight - llmq::WORK_DIFF_DEPTH); auto mn_list = dmnman.GetListForBlock(pWorkBlockIndex); - const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex); + const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex, &qsnapman); auto sortedAllMns = CalculateQuorum(mn_list, modifier); std::vector usedMNs; @@ -288,7 +307,8 @@ std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LL } QuorumMembers ComputeQuorumMembers(Consensus::LLMQType llmqType, const CChainParams& chainparams, - const CDeterministicMNList& mn_list, const CBlockIndex* pQuorumBaseBlockIndex) + const CDeterministicMNList& mn_list, const CBlockIndex* pQuorumBaseBlockIndex, + const llmq::CQuorumSnapshotManager* qsnapman) { bool EvoOnly = (chainparams.GetConsensus().llmqTypePlatform == llmqType) && DeploymentActiveAfter(pQuorumBaseBlockIndex, chainparams.GetConsensus(), Consensus::DEPLOYMENT_V19); @@ -299,7 +319,8 @@ QuorumMembers ComputeQuorumMembers(Consensus::LLMQType llmqType, const CChainPar return {}; } - const auto modifier = GetHashModifier(llmq_params_opt.value(), chainparams.GetConsensus(), pQuorumBaseBlockIndex); + const auto modifier = GetHashModifier(llmq_params_opt.value(), chainparams.GetConsensus(), pQuorumBaseBlockIndex, + qsnapman); return CalculateQuorum(mn_list, modifier, llmq_params_opt->size, EvoOnly); } @@ -315,7 +336,7 @@ void BuildQuorumSnapshot(const Consensus::LLMQParams& llmqParams, const Consensu const auto allMnsTotal = allMns.GetCounts().total(); quorumSnapshot.activeQuorumMembers.resize(allMnsTotal); - const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex); + const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex, nullptr); auto sortedAllMns = CalculateQuorum(allMns, modifier); LogPrint(BCLog::LLMQ, "BuildQuorumSnapshot h[%d] numMns[%d]\n", pCycleQuorumBaseBlockIndex->nHeight, @@ -502,6 +523,7 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus break; } prev_cycles[idx]->m_members = GetQuorumQuarterMembersBySnapshot(llmqParams, util_params.m_dmnman, + util_params.m_qsnapman, util_params.m_chainman.GetConsensus(), prev_cycles[idx]->m_cycle_index, prev_cycles[idx]->m_snap, @@ -545,6 +567,13 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus namespace llmq { namespace utils { +uint256 GetQuorumHashModifier(const Consensus::LLMQParams& llmq_params, + const Consensus::Params& consensus_params, + gsl::not_null cycle_quorum_base_index) +{ + return CalculateHashModifier(llmq_params, consensus_params, cycle_quorum_base_index); +} + BlsCheck::BlsCheck() = default; BlsCheck::BlsCheck(CBLSSignature sig, std::vector pubkeys, uint256 msg_hash, std::string id_string) : @@ -630,7 +659,8 @@ std::optional> ComputeQuorumMembersFromWorkBlo return quorumMembers[quorumIndex]; } -QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) +static QuorumMembers GetAllQuorumMembersInternal(Consensus::LLMQType llmqType, const UtilParameters& util_params, + bool reset_cache) { static RecursiveMutex cs_members; static std::map> mapQuorumMembers GUARDED_BY(cs_members); @@ -700,7 +730,7 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame const CBlockIndex* pWorkBlockIndex = pCycleQuorumBaseBlockIndex->GetAncestor(cycleQuorumBaseHeight - WORK_DIFF_DEPTH); const auto modifier = GetHashModifier(llmq_params, util_params.m_chainman.GetConsensus(), - pCycleQuorumBaseBlockIndex); + pCycleQuorumBaseBlockIndex, &util_params.m_qsnapman); auto q = ComputeQuorumMembersByQuarterRotation(llmq_params, util_params.replace_index(pCycleQuorumBaseBlockIndex), pWorkBlockIndex, cycleQuorumBaseHeight, modifier, /*predicting=*/false); @@ -720,7 +750,7 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame : util_params.m_base_index.get(); CDeterministicMNList mn_list = util_params.m_dmnman.GetListForBlock(pWorkBlockIndex); quorumMembers = ComputeQuorumMembers(llmqType, util_params.m_chainman.GetParams(), mn_list, - util_params.m_base_index); + util_params.m_base_index, &util_params.m_qsnapman); } LOCK(cs_members); @@ -728,6 +758,15 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame return quorumMembers; } +QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) +{ + // A SnapshotStateMismatchError from a seeded-modifier disagreement + // propagates to the caller. Production code does not seed modifiers yet; + // the load-time integration later in the series routes this into the + // controlled invalid-snapshot path. + return GetAllQuorumMembersInternal(llmqType, util_params, reset_cache); +} + uint256 DeterministicOutboundConnection(const uint256& proTxHash1, const uint256& proTxHash2) { // We need to deterministically select who is going to initiate the connection. The naive way would be to simply diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 01ab95316117..4082066c7914 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -43,6 +43,11 @@ struct UtilParameters { }; namespace utils { +/** Normal consensus modifier calculation; snapshot overrides are internal to reconstruction. */ +uint256 GetQuorumHashModifier(const Consensus::LLMQParams& llmq_params, + const Consensus::Params& consensus_params, + gsl::not_null cycle_quorum_base_index); + struct BlsCheck { CBLSSignature m_sig; std::vector m_pubkeys; diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index e96230f616f6..1b6cc28b8f13 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -218,8 +218,39 @@ void CheckInvalid(evo::CEvoSnapshot snapshot) BOOST_CHECK_THROW(snapshot.Validate(), std::ios_base::failure); } +//! Restores consensus params mutated through const_cast when the test case +//! leaves scope, including through a failed BOOST_REQUIRE, so mutated state +//! cannot leak into cases running later in the same process. +class [[nodiscard]] ConsensusParamsRestorer +{ + Consensus::Params& m_params; + const Consensus::Params m_saved; + +public: + explicit ConsensusParamsRestorer(const Consensus::Params& params) : + m_params{const_cast(params)}, m_saved{params} + { + } + ~ConsensusParamsRestorer() { m_params = m_saved; } + Consensus::Params& Get() { return m_params; } +}; + } // namespace +//! Chain fixture whose activation heights are already in force while the chain +//! is mined, so every historical coinbase is the CbTx that v20-era code paths +//! (e.g. the quorum hash modifier's chainlock probe) are entitled to assume. +//! Forcing the heights down through const_cast after mining instead would leave +//! pre-DIP3 coinbases on a chain claiming v20 was always active, which trips +//! GetTxPayload's payload-type assertion in debug builds. +struct SnapshotActivationChainSetup : public TestChainSetup { + SnapshotActivationChainSetup() : + TestChainSetup{102, CBaseChainParams::REGTEST, + {"-dip3params=2:2", "-testactivationheight=v20@2", "-testactivationheight=mn_rr@2"}} + { + } +}; + BOOST_AUTO_TEST_SUITE(evo_snapshot_tests) BOOST_FIXTURE_TEST_CASE(populated_roundtrip_and_representation_independence, BasicTestingSetup) @@ -267,6 +298,598 @@ BOOST_FIXTURE_TEST_CASE(populated_roundtrip_and_representation_independence, Bas } } +BOOST_FIXTURE_TEST_CASE(snapshot_identity_seeding_is_retrievable, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + const auto list{MNList(base->GetBlockHash(), base->nHeight, false)}; + const CBlockIndex* historical_index{base->GetAncestor(50)}; + const auto historical_list{MNList(historical_index->GetBlockHash(), historical_index->nHeight, true)}; + const auto indexed_commitment = [&](Consensus::LLMQType type, int quorum_height, int mined_height, + bool rotated, int16_t quorum_index = 0) { + auto entry{Commitment(type, 1, 2, rotated, quorum_index)}; + entry.quorum_base_block_hash = base->GetAncestor(quorum_height)->GetBlockHash(); + entry.commitment.quorumHash = entry.quorum_base_block_hash; + entry.mined_block_hash = base->GetAncestor(mined_height)->GetBlockHash(); + return entry; + }; + const std::vector nonrotated{ + indexed_commitment(Consensus::LLMQType::LLMQ_TEST, 48, 58, false), + indexed_commitment(Consensus::LLMQType::LLMQ_TEST, 72, 82, false), + }; + const std::vector rotated{ + indexed_commitment(Consensus::LLMQType::LLMQ_TEST_DIP0024, 72, 84, true, 0), + indexed_commitment(Consensus::LLMQType::LLMQ_TEST_DIP0024, 73, 85, true, 1), + }; + CCreditPool pool; + pool.locked = 123; + pool.currentLimit = 45; + pool.latelyUnlocked = 6; + AbstractEHFManager::Signals signals{{2, base->nHeight}}; + llmq::CQuorumSnapshot quorum_snapshot{{true, false, true}, SnapshotSkipMode::MODE_NO_SKIPPING, {}}; + + ConsensusParamsRestorer params_restorer{Params().GetConsensus()}; + const int old_dip3_height{params_restorer.Get().DIP0003Height}; + params_restorer.Get().DIP0003Height = 1; + BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(base).GetCounts().total(), 0U); + BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(historical_index).GetCounts().total(), 0U); + + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(list)); + BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(historical_list)); + { + LOCK(::cs_main); + for (const auto& entry : nonrotated) { + BOOST_REQUIRE(m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + entry.commitment.llmqType, entry.quorum_base_block_hash, + entry.commitment, entry.mined_block_hash)); + } + for (const auto& entry : rotated) { + BOOST_REQUIRE(m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + entry.commitment.llmqType, entry.quorum_base_block_hash, + entry.commitment, entry.mined_block_hash)); + } + } + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base, quorum_snapshot)); + BOOST_REQUIRE(m_node.chain_helper->credit_pool_manager->SeedSnapshot(base, pool)); + BOOST_REQUIRE(m_node.chain_helper->ehf_manager->SeedSignals(base, signals)); + tx->Commit(); + } + BOOST_REQUIRE(m_node.evodb->CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true)); + { + LOCK(::cs_main); + m_node.dmnman->InvalidateListCacheForBlock(base->GetBlockHash()); + m_node.dmnman->InvalidateListCacheForBlock(historical_index->GetBlockHash()); + } + + CDeterministicMNList stored_list; + CDeterministicMNList stored_historical_list; + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + stored_list = m_node.dmnman->GetListForBlock(base); + stored_historical_list = m_node.dmnman->GetListForBlock(historical_index); + } + m_node.dmnman->InvalidateListCacheForBlock(base->GetBlockHash()); + m_node.dmnman->InvalidateListCacheForBlock(historical_index->GetBlockHash()); + const auto subsequent_list{m_node.dmnman->GetListForBlock(base)}; + const auto subsequent_historical_list{m_node.dmnman->GetListForBlock(historical_index)}; + params_restorer.Get().DIP0003Height = old_dip3_height; + BOOST_CHECK(evo::CanonicalMNListHash(stored_list) == evo::CanonicalMNListHash(list)); + BOOST_CHECK(evo::CanonicalMNListHash(stored_historical_list) == evo::CanonicalMNListHash(historical_list)); + BOOST_CHECK(evo::CanonicalMNListHash(subsequent_list) == evo::CanonicalMNListHash(list)); + BOOST_CHECK(evo::CanonicalMNListHash(subsequent_historical_list) == evo::CanonicalMNListHash(historical_list)); + const auto [stored_commitment, stored_mined_hash]{ + m_node.llmq_ctx->quorum_block_processor->GetMinedCommitment( + nonrotated.back().commitment.llmqType, nonrotated.back().quorum_base_block_hash)}; + BOOST_CHECK_EQUAL(stored_mined_hash, nonrotated.back().mined_block_hash); + BOOST_CHECK_EQUAL(SerializeHash(stored_commitment), SerializeHash(nonrotated.back().commitment)); + { + LOCK(::cs_main); + const auto plain{m_node.llmq_ctx->quorum_block_processor->GetMinedCommitmentsUntilBlock( + Consensus::LLMQType::LLMQ_TEST, base, 2)}; + BOOST_REQUIRE_EQUAL(plain.size(), 2U); + BOOST_CHECK_EQUAL(plain[0]->nHeight, 72); + BOOST_CHECK_EQUAL(plain[1]->nHeight, 48); + const auto indexed{m_node.llmq_ctx->quorum_block_processor->GetLastMinedCommitmentsPerQuorumIndexUntilBlock( + Consensus::LLMQType::LLMQ_TEST_DIP0024, base, 0)}; + BOOST_REQUIRE_EQUAL(indexed.size(), 2U); + BOOST_CHECK_EQUAL(indexed[0]->nHeight, 72); + BOOST_CHECK_EQUAL(indexed[1]->nHeight, 73); + + CBlock first_post_base_block; + uint256 quorum_root; + BlockValidationState state; + BOOST_CHECK_MESSAGE(CalcCbTxMerkleRootQuorums(first_post_base_block, base, + *m_node.llmq_ctx->quorum_block_processor, quorum_root, state), + state.ToString()); + } + const auto stored_snapshot{m_node.llmq_ctx->qsnapman->GetSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base)}; + BOOST_REQUIRE(stored_snapshot.has_value()); + BOOST_CHECK(stored_snapshot->activeQuorumMembers == quorum_snapshot.activeQuorumMembers); + + CCreditPool stored_pool; + AbstractEHFManager::Signals stored_signals; + BOOST_REQUIRE(m_node.evodb->Read(std::make_pair(std::string{"cpm_S"}, base->GetBlockHash()), stored_pool)); + BOOST_REQUIRE(m_node.evodb->Read(std::make_pair(std::string{"mnhf_s2"}, base->GetBlockHash()), stored_signals)); + BOOST_CHECK_EQUAL(stored_pool.locked, pool.locked); + BOOST_CHECK(stored_signals == signals); +} + +BOOST_FIXTURE_TEST_CASE(snapshot_seed_rollback_does_not_publish_caches, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + const auto seeded_list{MNList(base->GetBlockHash(), base->nHeight, false)}; + CCreditPool seeded_pool; + seeded_pool.locked = 123; + AbstractEHFManager::Signals seeded_signals{{2, base->nHeight}}; + const llmq::CQuorumSnapshot seeded_quorum{{true, false, true}, SnapshotSkipMode::MODE_NO_SKIPPING, {}}; + + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(seeded_list)); + BOOST_REQUIRE(m_node.chain_helper->credit_pool_manager->SeedSnapshot(base, seeded_pool)); + BOOST_REQUIRE(m_node.chain_helper->ehf_manager->SeedSignals(base, seeded_signals)); + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base, seeded_quorum)); + BOOST_CHECK(!WITH_LOCK(::cs_main, return m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + Consensus::LLMQType::LLMQ_TEST, H(200), + Commitment(Consensus::LLMQType::LLMQ_TEST, 1, 2, false).commitment, H(201)))); + // Destruction without Commit() rolls the complete scoped transaction back. + } + + CDeterministicMNList db_list; + CCreditPool db_pool; + AbstractEHFManager::Signals db_signals; + const auto quorum_hash{SerializeHash(std::make_pair(Consensus::LLMQType::LLMQ_TEST, base->GetBlockHash()))}; + llmq::CQuorumSnapshot db_quorum; + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"dmn_S3"}, base->GetBlockHash()), db_list)); + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"cpm_S"}, base->GetBlockHash()), db_pool)); + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"mnhf_s2"}, base->GetBlockHash()), db_signals)); + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string_view{"llmq_S"}, quorum_hash), db_quorum)); + + { + ConsensusParamsRestorer params_restorer{Params().GetConsensus()}; + params_restorer.Get().DIP0003Height = 1; + params_restorer.Get().V20Height = 1; + BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(base).GetCounts().total(), 0U); + BOOST_CHECK_EQUAL(m_node.chain_helper->credit_pool_manager->GetCreditPool(base).locked, 0); + BOOST_CHECK(m_node.chain_helper->ehf_manager->GetSignalsStage(base).empty()); + } + BOOST_CHECK(!m_node.llmq_ctx->qsnapman->GetSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base).has_value()); +} + +BOOST_FIXTURE_TEST_CASE(quorum_members_reconstruct_from_seeded_state_only, SnapshotActivationChainSetup) +{ + const CBlockIndex* tip{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(tip != nullptr); + ConsensusParamsRestorer global_restorer{Params().GetConsensus()}; + ConsensusParamsRestorer chain_restorer{m_node.chainman->GetConsensus()}; + auto& global_consensus{global_restorer.Get()}; + auto& consensus{chain_restorer.Get()}; + auto plain{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + auto rotated{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + plain.dkgInterval = 12; + plain.dkgMiningWindowStart = 1; + plain.dkgMiningWindowEnd = 3; + rotated.dkgInterval = 12; + consensus.llmqs = {plain, rotated}; + global_consensus.llmqs = consensus.llmqs; + + const CBlockIndex* quorum{tip->GetAncestor(96)}; + BOOST_REQUIRE(quorum != nullptr); + std::map lists; + const auto make_list = [&](const CBlockIndex* work) { + CDeterministicMNList list{work->GetBlockHash(), work->nHeight, 100}; + for (uint8_t i{0}; i < 12; ++i) { + list.AddMN(MN(20 + i, 20 + i, MnType::Regular, ProTxVersion::LegacyBLS, 20 + i), false); + } + return list; + }; + const CBlockIndex* plain_work{quorum->GetAncestor(88)}; + lists.emplace(plain_work, make_list(plain_work)); + std::vector rotated_cycles; + for (const int height : {96, 84, 72, 60}) { + const CBlockIndex* cycle{tip->GetAncestor(height)}; + const CBlockIndex* work{tip->GetAncestor(height - llmq::WORK_DIFF_DEPTH)}; + rotated_cycles.emplace_back(cycle); + lists.try_emplace(work, make_list(work)); + } + for (const auto& [work, list] : lists) m_node.dmnman->SetListForBlockForTesting(list); + BOOST_REQUIRE(m_node.chainman->IsQuorumTypeEnabled(plain.type, quorum->pprev)); + BOOST_REQUIRE(m_node.chainman->IsQuorumTypeEnabled(rotated.type, quorum->pprev)); + BOOST_REQUIRE_EQUAL(m_node.dmnman->GetListForBlock(plain_work).GetCounts().enabled(), 12U); + const llmq::CQuorumSnapshot empty_snapshot{std::vector(12, false), + SnapshotSkipMode::MODE_NO_SKIPPING, {}}; + for (size_t i{1}; i < rotated_cycles.size(); ++i) { + m_node.llmq_ctx->qsnapman->StoreSnapshotForBlock(rotated.type, rotated_cycles[i], empty_snapshot); + } + + // Derive the oracle through a separate manager, cache, and EvoDB. The + // manager under test is seeded only after these expected sets exist. + CEvoDB expected_db{util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evo_snapshot_oracle", + .memory = true, .wipe = true}}; + CMasternodeMetaMan expected_meta; + CDeterministicMNManager expected_dmnman{expected_db, expected_meta}; + llmq::CQuorumSnapshotManager expected_qsnapman{expected_db}; + { + auto tx{expected_db.BeginTransaction(EvoDbIdentity::NORMAL)}; + for (const auto& [_, list] : lists) BOOST_REQUIRE(expected_dmnman.SeedListForBlock(list)); + for (size_t i{1}; i < rotated_cycles.size(); ++i) { + expected_qsnapman.StoreSnapshotForBlock(rotated.type, rotated_cycles[i], empty_snapshot); + } + tx->Commit(); + } + const auto plain_expected{llmq::utils::GetAllQuorumMembers( + plain.type, {expected_dmnman, expected_qsnapman, *m_node.chainman, quorum}, true)}; + const auto rotated_expected{llmq::utils::GetAllQuorumMembers( + rotated.type, {expected_dmnman, expected_qsnapman, *m_node.chainman, quorum}, true)}; + BOOST_REQUIRE(!plain_expected.empty()); + BOOST_REQUIRE(!rotated_expected.empty()); + + CBLSSecretKey quorum_key; + quorum_key.MakeNewKey(); + llmq::CFinalCommitment seeded_commitment{plain, quorum->GetBlockHash()}; + seeded_commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION; + seeded_commitment.quorumPublicKey = quorum_key.GetPublicKey(); + seeded_commitment.quorumVvecHash = H(201); + const CBlockIndex* mined_index{tip->GetAncestor(98)}; + + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + for (const auto& [work, list] : lists) BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(list)); + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedQuorumModifier( + plain.type, plain_work->GetBlockHash(), + llmq::utils::GetQuorumHashModifier(plain, consensus, quorum))); + for (const auto* cycle : rotated_cycles) { + const CBlockIndex* work{cycle->GetAncestor(cycle->nHeight - llmq::WORK_DIFF_DEPTH)}; + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedQuorumModifier( + rotated.type, work->GetBlockHash(), + llmq::utils::GetQuorumHashModifier(rotated, consensus, cycle))); + } + for (size_t i{1}; i < rotated_cycles.size(); ++i) { + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedSnapshotForBlock( + rotated.type, rotated_cycles[i], empty_snapshot)); + } + BOOST_REQUIRE(WITH_LOCK(::cs_main, return m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + plain.type, quorum->GetBlockHash(), seeded_commitment, mined_index->GetBlockHash());)); + tx->Commit(); + } + + std::map saved_status; + { + LOCK(::cs_main); + for (const auto& [work, _] : lists) { + auto* mutable_work{const_cast(work)}; + saved_status.emplace(mutable_work, mutable_work->nStatus); + mutable_work->nStatus &= ~BLOCK_HAVE_DATA; + m_node.dmnman->InvalidateListCacheForBlock(work->GetBlockHash()); + } + for (size_t i{1}; i < rotated_cycles.size(); ++i) { + m_node.llmq_ctx->qsnapman->InvalidateSnapshotCacheForBlock(rotated.type, + rotated_cycles[i]->GetBlockHash()); + } + } + std::vector plain_seeded; + std::vector rotated_seeded; + std::vector scanned; + llmq::VerifyRecSigStatus recovered_sig_status{llmq::VerifyRecSigStatus::NoQuorum}; + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + plain_seeded = llmq::utils::GetAllQuorumMembers( + plain.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true); + rotated_seeded = llmq::utils::GetAllQuorumMembers( + rotated.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true); + scanned = m_node.llmq_ctx->qman->ScanQuorums(plain.type, tip, 1); + const uint256 id{H(202)}; + const uint256 msg_hash{H(203)}; + const llmq::SignHash sign_hash{plain.type, quorum->GetBlockHash(), id, msg_hash}; + recovered_sig_status = llmq::VerifyRecoveredSig( + plain.type, *m_node.llmq_ctx->qman, tip, id, msg_hash, + quorum_key.Sign(sign_hash.Get(), /*specificLegacyScheme=*/false)); + } + const auto hashes = [](const auto& members) { + std::vector result; + for (const auto& member : members) result.emplace_back(member->proTxHash); + return result; + }; + BOOST_CHECK(hashes(plain_seeded) == hashes(plain_expected)); + BOOST_CHECK(hashes(rotated_seeded) == hashes(rotated_expected)); + BOOST_REQUIRE_EQUAL(scanned.size(), 1U); + BOOST_CHECK(hashes(scanned[0]->members) == hashes(plain_expected)); + BOOST_CHECK(recovered_sig_status == llmq::VerifyRecSigStatus::Valid); + + // Prove reconstruction fails closed instead of falling through to the + // ordinary diff chain when one required seeded full list is absent. + size_t forbidden_fallbacks{0}; + m_node.dmnman->SetListSnapshotMissHookForTesting([&](const CBlockIndex* index) { + ++forbidden_fallbacks; + throw std::logic_error(strprintf("forbidden NORMAL MN-list fallback at height %d", index->nHeight)); + }); + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + m_node.evodb->Erase(std::make_pair(std::string{"dmn_S3"}, plain_work->GetBlockHash())); + m_node.dmnman->InvalidateListCacheForBlock(plain_work->GetBlockHash()); + BOOST_CHECK_THROW(llmq::utils::GetAllQuorumMembers( + plain.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true), + std::logic_error); + } + m_node.dmnman->SetListSnapshotMissHookForTesting({}); + BOOST_CHECK_EQUAL(forbidden_fallbacks, 1U); + + { + LOCK(::cs_main); + for (const auto& [work, status] : saved_status) work->nStatus = status; + } + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + const auto modifier_key{std::make_tuple(std::string_view{"llmq_M3"}, plain.type, + plain_work->GetBlockHash())}; + m_node.evodb->Erase(modifier_key); + m_node.evodb->Write(modifier_key, H(254)); + BOOST_CHECK_THROW(llmq::utils::GetAllQuorumMembers( + plain.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true), + evo::SnapshotStateMismatchError); + } +} + +BOOST_FIXTURE_TEST_CASE(chain_validation_pre_dip3_matrix, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + evo::CEvoSnapshot snapshot; + snapshot.base_block_hash = base->GetBlockHash(); + snapshot.mn_list = CDeterministicMNList{base->GetBlockHash(), base->nHeight, 0}; + std::string error; + BOOST_CHECK(WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(snapshot, *m_node.chainman, base, error))); + + auto wrong_base{snapshot}; + wrong_base.base_block_hash = H(99); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(wrong_base, *m_node.chainman, base, error))); + + auto nonempty{snapshot}; + nonempty.credit_pool.locked = 1; + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(nonempty, *m_node.chainman, base, error))); + + ConsensusParamsRestorer params_restorer{m_node.chainman->GetConsensus()}; + auto& mutable_consensus{params_restorer.Get()}; + mutable_consensus.DIP0003Height = 1; + mutable_consensus.V19Height = 1; + mutable_consensus.llmqs = {evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + + evo::CEvoSnapshot active{snapshot}; + evo::CQuorumSnapshotData quorum_data; + quorum_data.llmq_type = Consensus::LLMQType::LLMQ_TEST; + const auto& params{mutable_consensus.llmqs.front()}; + const auto make_commitment = [&](int quorum_height, int mined_height) { + evo::CMinedQuorumCommitment entry; + const CBlockIndex* quorum{base->GetAncestor(quorum_height)}; + entry.quorum_base_block_hash = quorum->GetBlockHash(); + entry.work_block_hash = entry.quorum_base_block_hash; + entry.mined_block_hash = base->GetAncestor(mined_height)->GetBlockHash(); + entry.commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION; + entry.commitment.llmqType = params.type; + entry.commitment.quorumHash = entry.quorum_base_block_hash; + entry.commitment.signers.resize(params.size); + entry.commitment.validMembers.resize(params.size); + return entry; + }; + quorum_data.active_commitments = {make_commitment(72, 82), make_commitment(48, 58)}; + quorum_data.safety_commitments = {make_commitment(24, 34)}; + std::sort(quorum_data.active_commitments.begin(), quorum_data.active_commitments.end(), + [](const auto& a, const auto& b) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + }); + active.quorums = {quorum_data}; + CDeterministicMNList previous{active.mn_list}; + uint256 previous_hash{active.base_block_hash}; + for (const int height : {72, 48, 24}) { + const CBlockIndex* work{base->GetAncestor(height)}; + CDeterministicMNList list{work->GetBlockHash(), height, 0}; + active.historical_mn_list_diffs.push_back({previous_hash, work->GetBlockHash(), height, 0, + evo::CanonicalMNListHash(list), previous.BuildDiff(list)}); + active.quorum_modifiers.push_back({params.type, work->GetBlockHash(), + llmq::utils::GetQuorumHashModifier(params, mutable_consensus, work)}); + previous_hash = work->GetBlockHash(); + previous = std::move(list); + } + std::sort(active.quorum_modifiers.begin(), active.quorum_modifiers.end(), [](const auto& a, const auto& b) { + return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); + }); + const bool active_valid{WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(active, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(active_valid, error); + + auto wrong_counts{active}; + wrong_counts.quorums[0].safety_commitments.clear(); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(wrong_counts, *m_node.chainman, base, error))); + + auto non_ancestor{active}; + non_ancestor.quorums[0].active_commitments[0].quorum_base_block_hash = H(99); + non_ancestor.quorums[0].active_commitments[0].commitment.quorumHash = H(99); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(non_ancestor, *m_node.chainman, base, error))); + + // A young chain carries fewer commitments than the parameter horizon. A + // coherent snapshot with a single active commitment, its historical diff, + // and its modifier must pass both validation layers: parameter counts are + // maxima, and completeness is established by the completion-time CbTx + // quorum merkle root, not by per-type count equality. + evo::CEvoSnapshot partial{snapshot}; + evo::CQuorumSnapshotData partial_data; + partial_data.llmq_type = Consensus::LLMQType::LLMQ_TEST; + partial_data.active_commitments = {make_commitment(72, 82)}; + partial.quorums = {partial_data}; + { + const CBlockIndex* work{base->GetAncestor(72)}; + CDeterministicMNList list{work->GetBlockHash(), 72, 0}; + partial.historical_mn_list_diffs.push_back({partial.base_block_hash, work->GetBlockHash(), 72, 0, + evo::CanonicalMNListHash(list), partial.mn_list.BuildDiff(list)}); + partial.quorum_modifiers.push_back({params.type, work->GetBlockHash(), + llmq::utils::GetQuorumHashModifier(params, mutable_consensus, work)}); + } + BOOST_CHECK_NO_THROW(partial.Validate()); + const bool partial_valid{WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(partial, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(partial_valid, error); +} + +BOOST_FIXTURE_TEST_CASE(builder_emits_available_history_on_young_chains, SnapshotActivationChainSetup) +{ + // No masternodes exist and no DKGs have run on this fixture chain, so every + // enabled LLMQ type has zero mined commitments and zero rotation cycles. + // dumptxoutset-grade building must succeed on such a chain and emit the + // history that exists rather than failing the parameter-derived horizon. + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + evo::CEvoSnapshot snapshot; + std::string error; + const bool built{WITH_LOCK(::cs_main, + return evo::BuildEvoSnapshot(Params(), *m_node.chainman, *m_node.dmnman, + *m_node.llmq_ctx->quorum_block_processor, *m_node.llmq_ctx->qsnapman, + *m_node.chain_helper->credit_pool_manager, *m_node.chain_helper->ehf_manager, + base, snapshot, error))}; + BOOST_REQUIRE_MESSAGE(built, error); + for (const auto& data : snapshot.quorums) { + BOOST_CHECK(data.active_commitments.empty()); + BOOST_CHECK(data.safety_commitments.empty()); + BOOST_CHECK(data.rotation_snapshots.empty()); + } + BOOST_CHECK_NO_THROW(snapshot.Validate()); + const bool valid{WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(snapshot, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(valid, error); +} + +BOOST_FIXTURE_TEST_CASE(rotation_bitset_matches_historical_work_list, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + ConsensusParamsRestorer params_restorer{m_node.chainman->GetConsensus()}; + auto& consensus{params_restorer.Get()}; + auto params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + params.dkgInterval = 12; + params.dkgMiningWindowStart = 2; + params.dkgMiningWindowEnd = 6; + consensus.llmqs = {params}; + consensus.DIP0003Height = 1; + consensus.V19Height = 1; + + evo::CEvoSnapshot snapshot; + snapshot.base_block_hash = base->GetBlockHash(); + snapshot.mn_list = CDeterministicMNList{base->GetBlockHash(), base->nHeight, 0}; + evo::CQuorumSnapshotData data; + data.llmq_type = params.type; + data.rotation_enabled = true; + const auto commitment = [&](int quorum_height, int mined_height, int16_t quorum_index) { + evo::CMinedQuorumCommitment entry; + const CBlockIndex* quorum{base->GetAncestor(quorum_height)}; + const CBlockIndex* cycle{quorum->GetAncestor(quorum->nHeight - quorum->nHeight % params.dkgInterval)}; + entry.quorum_base_block_hash = quorum->GetBlockHash(); + entry.work_block_hash = cycle->GetAncestor(cycle->nHeight - llmq::WORK_DIFF_DEPTH)->GetBlockHash(); + entry.mined_block_hash = base->GetAncestor(mined_height)->GetBlockHash(); + entry.commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION; + entry.commitment.llmqType = params.type; + entry.commitment.quorumHash = entry.quorum_base_block_hash; + entry.commitment.quorumIndex = quorum_index; + entry.commitment.signers.resize(params.size); + entry.commitment.validMembers.resize(params.size); + return entry; + }; + data.active_commitments = {commitment(84, 86, 0), commitment(85, 87, 1)}; + data.safety_commitments = {commitment(72, 74, 0), commitment(73, 75, 1)}; + + std::map required_work; + for (const auto& required : evo::EvoSnapshotReconstructionHeights(base->nHeight, {params})) { + const int cycle_height{required.quorum_height}; + const int work_height{required.work_height}; + const CBlockIndex* cycle{base->GetAncestor(cycle_height)}; + const CBlockIndex* work{base->GetAncestor(work_height)}; + BOOST_REQUIRE(cycle != nullptr); + BOOST_REQUIRE(work != nullptr); + const size_t population{static_cast(params.size + 3)}; + data.rotation_snapshots.push_back({cycle->GetBlockHash(), work->GetBlockHash(), + llmq::CQuorumSnapshot{std::vector(population, true), SnapshotSkipMode::MODE_NO_SKIPPING, {}}}); + required_work.emplace(work->GetBlockHash(), work); + } + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* work{WITH_LOCK(::cs_main, + return m_node.chainman->m_blockman.LookupBlockIndex(entry.work_block_hash);)}; + BOOST_REQUIRE(work != nullptr); + required_work.emplace(entry.work_block_hash, work); + } + } + const auto commitment_less = [](const auto& a, const auto& b) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + }; + std::sort(data.active_commitments.begin(), data.active_commitments.end(), commitment_less); + std::sort(data.safety_commitments.begin(), data.safety_commitments.end(), commitment_less); + std::sort(data.rotation_snapshots.begin(), data.rotation_snapshots.end(), [](const auto& a, const auto& b) { + return std::tie(a.cycle_base_block_hash, a.work_block_hash) < + std::tie(b.cycle_base_block_hash, b.work_block_hash); + }); + snapshot.quorums = {std::move(data)}; + + std::vector ordered_work; + for (const auto& [_, work] : required_work) ordered_work.emplace_back(work); + std::sort(ordered_work.begin(), ordered_work.end(), [](const auto* a, const auto* b) { return a->nHeight > b->nHeight; }); + CDeterministicMNList previous{snapshot.mn_list}; + uint256 previous_hash{snapshot.base_block_hash}; + for (const auto* work : ordered_work) { + CDeterministicMNList work_list{work->GetBlockHash(), work->nHeight, 100}; + for (uint8_t i{0}; i < params.size + 3; ++i) { + work_list.AddMN(MN(20 + i, 20 + i, MnType::Regular, ProTxVersion::LegacyBLS, 20 + i), false); + } + snapshot.historical_mn_list_diffs.push_back( + {previous_hash, work->GetBlockHash(), work->nHeight, work_list.GetTotalRegisteredCount(), + evo::CanonicalMNListHash(work_list), previous.BuildDiff(work_list)}); + previous_hash = work->GetBlockHash(); + previous = std::move(work_list); + } + std::map modifier_cycles; + for (const auto* commitments : {&snapshot.quorums[0].active_commitments, &snapshot.quorums[0].safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* quorum_index{WITH_LOCK(::cs_main, + return m_node.chainman->m_blockman.LookupBlockIndex(entry.quorum_base_block_hash);)}; + const CBlockIndex* cycle{quorum_index->GetAncestor( + quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval)}; + modifier_cycles.emplace(entry.work_block_hash, cycle); + } + } + for (const auto& entry : snapshot.quorums[0].rotation_snapshots) { + modifier_cycles.emplace(entry.work_block_hash, WITH_LOCK(::cs_main, + return m_node.chainman->m_blockman.LookupBlockIndex(entry.cycle_base_block_hash);)); + } + for (const auto& [work_hash, cycle] : modifier_cycles) { + snapshot.quorum_modifiers.push_back({params.type, work_hash, + llmq::utils::GetQuorumHashModifier(params, consensus, cycle)}); + } + + std::string error; + const bool valid{WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(snapshot, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(valid, error); + auto short_bitset{snapshot}; + short_bitset.quorums[0].rotation_snapshots[0].snapshot.activeQuorumMembers.pop_back(); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(short_bitset, *m_node.chainman, base, error))); + auto bad_modifier{snapshot}; + bad_modifier.quorum_modifiers[0].modifier.begin()[0] ^= 1; + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(bad_modifier, *m_node.chainman, base, error))); +} + BOOST_AUTO_TEST_CASE(reconstruction_horizon_height_enumeration) { const auto rotated{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; diff --git a/src/test/util/setup_common.cpp b/src/test/util/setup_common.cpp index e1f6a8efcd52..ecd62cb62bd4 100644 --- a/src/test/util/setup_common.cpp +++ b/src/test/util/setup_common.cpp @@ -468,6 +468,8 @@ TestChainSetup::TestChainSetup( { 98, uint256S("0x150e127929d578d8129b77a6cb7e2e343a1379aa3feaaa9cce59e0a645756a81") }, /*TestChain100Setup=*/ { 100, uint256S("0x6ffb83129c19ebdf1ae3771be6a67fe34b35f4c956326b9ba152fac1649f65ae") }, + /*SnapshotActivationChainSetup=*/ + { 102, uint256S("0x37876f3493ac152f9a0bdf0049d85969fe3ba82745733a81c8e1afeefa16ab3b") }, /*TestChainV19BeforeActivationSetup=*/ { 103, uint256S("0x13adad9565d0ca558f5675c50e3828f4354d26b64de044ebc88686056f30faab") }, /*TestChainDIP3BeforeActivationSetup=*/