From 59bc2ea94298fbd53be1760ee928a8b8ee1b80d6 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 12 Aug 2026 19:03:49 -0500 Subject: [PATCH 01/19] 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 22f28dbbd102..4206a5c7178a 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 \ @@ -546,6 +547,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 \ @@ -1286,6 +1288,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 a34ea4b120a3..fe4db4193177 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 00228ce6311b2aa2929f8a1837e9ded892b7d6df Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 12 Aug 2026 19:37:44 -0500 Subject: [PATCH 02/19] 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 4488b643b4bc12071e6d0b1196b9fff24a21940d Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 13 Aug 2026 01:01:16 -0500 Subject: [PATCH 03/19] 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 0765965f7680950d55efb3756237f24c6364de8a Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 13 Aug 2026 15:18:33 -0500 Subject: [PATCH 04/19] 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 5aea44756dde97f61a547a2c70bc19a1e4f4f393 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 13 Aug 2026 15:18:37 -0500 Subject: [PATCH 05/19] 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 aea2e8c6217346adfd03538dc5e8bcc714cf64e5 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 19 Aug 2026 23:56:00 -0500 Subject: [PATCH 06/19] 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 59675661069aace2c3feeb543858e6506cebc3a0 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 19 Aug 2026 23:57:28 -0500 Subject: [PATCH 07/19] 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 fdaff57c57beaee13b4ad2059def61ed78d55cec Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 19 Aug 2026 23:58:01 -0500 Subject: [PATCH 08/19] 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 8d4ac9ba5b889431b5f8e6bc5f2cd60275cdf3b6 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 20 Aug 2026 01:21:05 -0500 Subject: [PATCH 09/19] 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 8512a6569056ad9e552527e994147c81941bdb87 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 20 Aug 2026 01:21:30 -0500 Subject: [PATCH 10/19] 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 776188b4273746d7e998ec0bc6042285ae47fe78 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 20 Aug 2026 08:33:45 -0500 Subject: [PATCH 11/19] 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 1ea1e01d3d0c5cea16c0d12ea561efc6d24bc61d Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 20 Aug 2026 08:34:15 -0500 Subject: [PATCH 12/19] 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 ec1ef2d5e24a9779c3e2a00bb168427468992872 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 20 Aug 2026 08:34:34 -0500 Subject: [PATCH 13/19] 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 1a90e7eb6b075066e42c02fa4bbeee5f4aab5127 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 28 Aug 2026 16:35:51 +0200 Subject: [PATCH 14/19] refactor: drop the C prefix from the new evo snapshot types doc/developer-notes.md asks for UpperCamelCase class names; the C prefix is a legacy Bitcoin Core convention that new code should not adopt. These seven types are introduced by this series and have no external users yet, so renaming them now costs nothing. The enclosing namespace already reads evo::, which made the prefix redundant anyway. --- src/evo/snapshot.cpp | 33 ++++++++-------- src/evo/snapshot.h | 70 ++++++++++++++++----------------- src/test/evo_snapshot_tests.cpp | 49 +++++++++++------------ 3 files changed, 72 insertions(+), 80 deletions(-) diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp index 4483fde4faca..4097af501581 100644 --- a/src/evo/snapshot.cpp +++ b/src/evo/snapshot.cpp @@ -33,14 +33,14 @@ 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) { + 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) { + } 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) { + } 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) { + } 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; @@ -53,14 +53,14 @@ 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) { + 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) { + } 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) { + } 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) { + } 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; @@ -68,7 +68,7 @@ bool IsStrictlySorted(const std::vector& values) }) == values.end(); } -void ValidateCommitments(const CQuorumSnapshotData& data, const std::vector& commitments, +void ValidateCommitments(const QuorumSnapshotData& data, const std::vector& commitments, std::set& quorum_hashes, bool require_canonical_order) { if (require_canonical_order && !IsStrictlySorted(commitments)) { @@ -153,11 +153,11 @@ void ValidateCanonicalMNInvariants(const CDeterministicMNList& list) } // namespace -std::vector EvoSnapshotReconstructionHeights( +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; + std::vector heights; for (const auto& params : enabled_llmqs) { if (params.dkgInterval <= 0 || params.signingActiveQuorumCount <= 0) { throw std::invalid_argument("invalid reconstruction LLMQ parameters"); @@ -182,9 +182,8 @@ uint256 CanonicalMNListHash(const CDeterministicMNList& list) return writer.GetHash(); } -bool ReconstructHistoricalMNLists(const CEvoSnapshot& snapshot, - std::map& lists, std::string& error, - size_t max_records) +bool ReconstructHistoricalMNLists(const EvoSnapshot& snapshot, std::map& lists, + std::string& error, size_t max_records) { lists.clear(); error.clear(); @@ -238,7 +237,7 @@ bool ReconstructHistoricalMNLists(const CEvoSnapshot& snapshot, return true; } -void CEvoSnapshot::Validate(bool require_canonical_order) const +void EvoSnapshot::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) { @@ -345,7 +344,7 @@ void CEvoSnapshot::Validate(bool require_canonical_order) const } } -uint256 GetEvoSnapshotHash(const CEvoSnapshot& snapshot) +uint256 GetEvoSnapshotHash(const EvoSnapshot& snapshot) { snapshot.Validate(); CDataStream stream{SER_DISK, CLIENT_VERSION}; @@ -355,7 +354,7 @@ uint256 GetEvoSnapshotHash(const CEvoSnapshot& snapshot) return hash; } -bool VerifyEvoSnapshotCbTx(const CEvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error) +bool VerifyEvoSnapshotCbTx(const EvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error) { error.clear(); try { diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index d4784593b648..5579455c9c4b 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -362,22 +362,22 @@ CDeterministicMNListDiff UnserializeCanonicalMNListDiff(Stream& s) return UnserializeCanonicalMNListDiff(s, remaining_operations); } -struct CMinedQuorumCommitment { +struct MinedQuorumCommitment { uint256 quorum_base_block_hash; uint256 work_block_hash; llmq::CFinalCommitment commitment; uint256 mined_block_hash; - SERIALIZE_METHODS(CMinedQuorumCommitment, obj) + SERIALIZE_METHODS(MinedQuorumCommitment, obj) { READWRITE(obj.quorum_base_block_hash, obj.work_block_hash, obj.commitment, obj.mined_block_hash); } }; template -CMinedQuorumCommitment ReadMinedQuorumCommitment(Stream& s) +MinedQuorumCommitment ReadMinedQuorumCommitment(Stream& s) { - CMinedQuorumCommitment entry; + MinedQuorumCommitment 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 || @@ -406,13 +406,13 @@ CMinedQuorumCommitment ReadMinedQuorumCommitment(Stream& s) return entry; } -struct CQuorumSnapshotEntry { +struct QuorumSnapshotEntry { uint256 cycle_base_block_hash; uint256 work_block_hash; llmq::CQuorumSnapshot snapshot; }; -struct CHistoricalMNListDiff { +struct HistoricalMNListDiff { uint256 previous_block_hash; uint256 block_hash; int height{-1}; @@ -421,38 +421,35 @@ struct CHistoricalMNListDiff { CDeterministicMNListDiff diff; }; -struct CQuorumModifier { +struct QuorumModifier { 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); - } + SERIALIZE_METHODS(QuorumModifier, obj) { READWRITE(obj.llmq_type, obj.work_block_hash, obj.modifier); } }; -struct CQuorumSnapshotData { +struct QuorumSnapshotData { Consensus::LLMQType llmq_type{Consensus::LLMQType::LLMQ_NONE}; bool rotation_enabled{false}; - std::vector active_commitments; - std::vector safety_commitments; - std::vector rotation_snapshots; + 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 +class EvoSnapshot { 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; + std::vector quorums; + std::vector historical_mn_list_diffs; + std::vector quorum_modifiers; CCreditPool credit_pool; AbstractEHFManager::Signals mnhf_signals; @@ -471,7 +468,7 @@ void WriteSnapshotVector(Stream& s, const std::vector& values, WriteOne&& wri } template -void WriteRotationSnapshot(Stream& s, const CQuorumSnapshotEntry& entry) +void WriteRotationSnapshot(Stream& s, const QuorumSnapshotEntry& entry) { s << entry.cycle_base_block_hash << entry.work_block_hash << entry.snapshot.mnSkipListMode; WriteCompactSize(s, entry.snapshot.activeQuorumMembers.size()); @@ -480,9 +477,9 @@ void WriteRotationSnapshot(Stream& s, const CQuorumSnapshotEntry& entry) } template -CQuorumSnapshotEntry ReadRotationSnapshot(Stream& s, const Consensus::LLMQParams& params) +QuorumSnapshotEntry ReadRotationSnapshot(Stream& s, const Consensus::LLMQParams& params) { - CQuorumSnapshotEntry entry; + QuorumSnapshotEntry 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 @@ -502,7 +499,7 @@ CQuorumSnapshotEntry ReadRotationSnapshot(Stream& s, const Consensus::LLMQParams } template -void CQuorumSnapshotData::Serialize(Stream& s) const +void QuorumSnapshotData::Serialize(Stream& s) const { auto active{active_commitments}; auto safety{safety_commitments}; @@ -520,10 +517,10 @@ void CQuorumSnapshotData::Serialize(Stream& s) const } template -void CQuorumSnapshotData::Unserialize(Stream& s) +void QuorumSnapshotData::Unserialize(Stream& s) { s >> llmq_type >> rotation_enabled; - // Same replacement semantics as CEvoSnapshot::Unserialize: decoding into a + // Same replacement semantics as EvoSnapshot::Unserialize: decoding into a // reused object must not retain (or exceed the count bounds through) // previously held entries. active_commitments.clear(); @@ -548,7 +545,7 @@ void CQuorumSnapshotData::Unserialize(Stream& s) } template -void CEvoSnapshot::Serialize(Stream& s) const +void EvoSnapshot::Serialize(Stream& s) const { auto sorted_quorums{quorums}; auto sorted_history{historical_mn_list_diffs}; @@ -576,7 +573,7 @@ void CEvoSnapshot::Serialize(Stream& s) const } template -void CEvoSnapshot::Unserialize(Stream& s) +void EvoSnapshot::Unserialize(Stream& s) { s >> version; if (version != EVO_SNAPSHOT_VERSION) throw std::ios_base::failure("unsupported evo snapshot version"); @@ -592,7 +589,7 @@ void CEvoSnapshot::Unserialize(Stream& 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; + QuorumSnapshotData data; s >> data; quorums.emplace_back(std::move(data)); } @@ -601,7 +598,7 @@ void CEvoSnapshot::Unserialize(Stream& s) historical_mn_list_diffs.reserve(history_count); size_t remaining_history_operations{EvoSnapshotMaxHistoricalMNOperations()}; for (size_t i{0}; i < history_count; ++i) { - CHistoricalMNListDiff entry; + HistoricalMNListDiff 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)); @@ -609,7 +606,7 @@ void CEvoSnapshot::Unserialize(Stream& s) 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; + QuorumModifier modifier; s >> modifier; quorum_modifiers.emplace_back(std::move(modifier)); } @@ -633,9 +630,9 @@ void CEvoSnapshot::Unserialize(Stream& s) } /** Single SHA256 of the canonical SER_DISK/CLIENT_VERSION encoding. */ -uint256 GetEvoSnapshotHash(const CEvoSnapshot& snapshot); +uint256 GetEvoSnapshotHash(const EvoSnapshot& snapshot); -struct CQuorumReconstructionHeight { +struct QuorumReconstructionHeight { Consensus::LLMQType llmq_type; bool rotation; int quorum_height; @@ -643,16 +640,15 @@ struct CQuorumReconstructionHeight { }; /** Pure conservative reconstruction horizon for the supplied enabled types. */ -std::vector EvoSnapshotReconstructionHeights( +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, - size_t max_records = EVO_SNAPSHOT_MAX_RECONSTRUCTION_RECORDS); +bool ReconstructHistoricalMNLists(const EvoSnapshot& snapshot, 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); +bool VerifyEvoSnapshotCbTx(const EvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error); } // namespace evo diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index e96230f616f6..16706fd9a1bd 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -107,8 +107,8 @@ CDeterministicMNList MNList(const uint256& block_hash, int height, bool reverse) return list; } -evo::CMinedQuorumCommitment Commitment(Consensus::LLMQType type, uint8_t quorum, uint8_t mined, bool rotated, - int16_t index = 0) +evo::MinedQuorumCommitment 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 @@ -122,9 +122,9 @@ evo::CMinedQuorumCommitment Commitment(Consensus::LLMQType type, uint8_t quorum, return {H(quorum), H(quorum + 120), std::move(commitment), H(mined)}; } -evo::CEvoSnapshot SyntheticSnapshot(bool reverse_representation = false) +evo::EvoSnapshot SyntheticSnapshot(bool reverse_representation = false) { - evo::CEvoSnapshot snapshot; + evo::EvoSnapshot snapshot; snapshot.base_block_hash = H(42); snapshot.mn_list = MNList(snapshot.base_block_hash, 500, reverse_representation); snapshot.credit_pool.locked = 123456; @@ -146,12 +146,12 @@ evo::CEvoSnapshot SyntheticSnapshot(bool reverse_representation = false) snapshot.mnhf_signals.emplace(9, 30); } - evo::CQuorumSnapshotData plain; + evo::QuorumSnapshotData 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; + evo::QuorumSnapshotData rotated; rotated.llmq_type = Consensus::LLMQType::LLMQ_TEST_DIP0024; rotated.rotation_enabled = true; rotated.active_commitments = {Commitment(rotated.llmq_type, 31, 71, true, 0), @@ -206,17 +206,14 @@ evo::CEvoSnapshot SyntheticSnapshot(bool reverse_representation = false) return snapshot; } -CDataStream SerializeSnapshot(const evo::CEvoSnapshot& snapshot) +CDataStream SerializeSnapshot(const evo::EvoSnapshot& 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); -} +void CheckInvalid(evo::EvoSnapshot snapshot) { BOOST_CHECK_THROW(snapshot.Validate(), std::ios_base::failure); } } // namespace @@ -233,7 +230,7 @@ BOOST_FIXTURE_TEST_CASE(populated_roundtrip_and_representation_independence, Bas BOOST_CHECK(GetEvoSnapshotHash(forward) == GetEvoSnapshotHash(reverse)); CDataStream input{forward_bytes}; - evo::CEvoSnapshot decoded; + evo::EvoSnapshot decoded; input >> decoded; BOOST_CHECK(input.empty()); const auto decoded_bytes{SerializeSnapshot(decoded)}; @@ -294,7 +291,7 @@ BOOST_AUTO_TEST_CASE(reconstruction_horizon_height_enumeration) BOOST_AUTO_TEST_CASE(rotation_bitset_larger_than_quorum_roundtrips) { const auto& params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; - evo::CQuorumSnapshotEntry entry; + evo::QuorumSnapshotEntry entry; entry.cycle_base_block_hash = H(1); entry.work_block_hash = H(2); entry.snapshot.activeQuorumMembers.resize(params.size + 3); @@ -435,7 +432,7 @@ BOOST_FIXTURE_TEST_CASE(context_free_validation_matrix, BasicTestingSetup) snapshot.quorums.clear(); snapshot.historical_mn_list_diffs.clear(); snapshot.quorum_modifiers.clear(); - evo::CQuorumSnapshotData partial; + evo::QuorumSnapshotData partial; partial.llmq_type = Consensus::LLMQType::LLMQ_TEST; snapshot.quorums.emplace_back(std::move(partial)); BOOST_CHECK_NO_THROW(snapshot.Validate()); @@ -515,7 +512,7 @@ BOOST_FIXTURE_TEST_CASE(bounded_readers_reject_claimed_sizes_first, BasicTesting BOOST_CHECK(mn_stream.empty()); const auto decode_quorum = [](CDataStream stream) { - evo::CQuorumSnapshotData data; + evo::QuorumSnapshotData data; stream >> data; }; const auto& plain_params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; @@ -553,7 +550,7 @@ BOOST_FIXTURE_TEST_CASE(bounded_readers_reject_claimed_sizes_first, BasicTesting commitment_bits << Consensus::LLMQType::LLMQ_TEST << false; WriteCompactSize(commitment_bits, 1); commitment_bits << oversized_commitment; - evo::CQuorumSnapshotData oversized_data; + evo::QuorumSnapshotData oversized_data; 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; }); @@ -606,7 +603,7 @@ BOOST_FIXTURE_TEST_CASE(bounded_readers_reject_claimed_sizes_first, BasicTesting CDataStream quorum_types{SER_DISK, CLIENT_VERSION}; snapshot_prefix(quorum_types); WriteCompactSize(quorum_types, Consensus::available_llmqs.size() + 1); - evo::CEvoSnapshot decoded; + evo::EvoSnapshot decoded; BOOST_CHECK_THROW(quorum_types >> decoded, std::ios_base::failure); CDataStream history{SER_DISK, CLIENT_VERSION}; @@ -670,7 +667,7 @@ BOOST_FIXTURE_TEST_CASE(commitment_sizes_are_format_bounded_not_param_exact, Bas BOOST_CHECK_NO_THROW(snapshot.Validate(/*require_canonical_order=*/true)); const auto bytes{SerializeSnapshot(snapshot)}; CDataStream input{bytes}; - evo::CEvoSnapshot decoded; + evo::EvoSnapshot decoded; BOOST_CHECK_NO_THROW(input >> decoded); BOOST_CHECK(input.empty()); const auto reencoded{SerializeSnapshot(decoded)}; @@ -691,13 +688,13 @@ BOOST_FIXTURE_TEST_CASE(mnhf_signal_wire_order_is_canonical, BasicTestingSetup) return stream; }; const auto expect_noncanonical = [](CDataStream stream) { - evo::CEvoSnapshot decoded; + evo::EvoSnapshot 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; + evo::EvoSnapshot decoded; BOOST_CHECK_NO_THROW(canonical >> decoded); BOOST_CHECK(canonical.empty()); BOOST_CHECK_EQUAL(decoded.mnhf_signals.size(), 2U); @@ -708,12 +705,12 @@ BOOST_FIXTURE_TEST_CASE(mnhf_signal_wire_order_is_canonical, BasicTestingSetup) BOOST_FIXTURE_TEST_CASE(unserialize_replaces_previous_contents, BasicTestingSetup) { const auto populated_bytes{SerializeSnapshot(SyntheticSnapshot())}; - evo::CEvoSnapshot minimal; + evo::EvoSnapshot minimal; minimal.base_block_hash = H(42); minimal.mn_list = CDeterministicMNList{H(42), 500, 0}; const auto minimal_bytes{SerializeSnapshot(minimal)}; - evo::CEvoSnapshot decoded; + evo::EvoSnapshot decoded; CDataStream populated{populated_bytes}; populated >> decoded; BOOST_REQUIRE(!decoded.mnhf_signals.empty()); @@ -750,7 +747,7 @@ BOOST_FIXTURE_TEST_CASE(hash_prefix_collision_runs_are_bounded, BasicTestingSetu // A diff addition that would grow an at-bound collision group is rejected // before the HAMT performs the inserts. - evo::CEvoSnapshot snapshot; + evo::EvoSnapshot 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) { @@ -769,7 +766,7 @@ BOOST_FIXTURE_TEST_CASE(hash_prefix_collision_runs_are_bounded, BasicTestingSetu BOOST_FIXTURE_TEST_CASE(quorum_data_unserialize_replaces_previous_contents, BasicTestingSetup) { - evo::CQuorumSnapshotData data; + evo::QuorumSnapshotData data; data.llmq_type = Consensus::LLMQType::LLMQ_TEST; data.active_commitments = {Commitment(data.llmq_type, 11, 51, false)}; CDataStream once{SER_DISK, CLIENT_VERSION}; @@ -777,7 +774,7 @@ BOOST_FIXTURE_TEST_CASE(quorum_data_unserialize_replaces_previous_contents, Basi CDataStream twice{SER_DISK, CLIENT_VERSION}; twice << data; - evo::CQuorumSnapshotData reused; + evo::QuorumSnapshotData reused; once >> reused; twice >> reused; BOOST_CHECK_EQUAL(reused.active_commitments.size(), 1U); @@ -834,7 +831,7 @@ BOOST_FIXTURE_TEST_CASE(rejects_unknown_wire_version, BasicTestingSetup) { auto bytes{SerializeSnapshot(SyntheticSnapshot())}; bytes.data()[0] = std::byte{4}; - evo::CEvoSnapshot decoded; + evo::EvoSnapshot decoded; BOOST_CHECK_THROW(bytes >> decoded, std::ios_base::failure); } From babf47ecec7aea2d9bc776c57af2f38400337bc9 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 28 Aug 2026 16:36:48 +0200 Subject: [PATCH 15/19] refactor: give every evo snapshot collection one explicit canonical comparator Sorted() and IsStrictlySorted() carried the same if-constexpr chain with inverted predicates, and a catch-all else silently applied llmq_type ordering to any type without a branch. The two serializers then re-spelled the same orders a third and fourth time as local lambdas. Replace all of it with one IsCanonicallyBefore() overload per element type: the serializer, the decode-time check, and the object-level check now derive from a single definition, and a type with no overload fails to compile instead of being ordered by accident. --- src/evo/snapshot.cpp | 51 +++--------------------------- src/evo/snapshot.h | 74 +++++++++++++++++++++++++++++++------------- 2 files changed, 57 insertions(+), 68 deletions(-) diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp index 4097af501581..ad07f7a6e90c 100644 --- a/src/evo/snapshot.cpp +++ b/src/evo/snapshot.cpp @@ -23,55 +23,14 @@ #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 QuorumSnapshotData& data, const std::vector& commitments, std::set& quorum_hashes, bool require_canonical_order) { - if (require_canonical_order && !IsStrictlySorted(commitments)) { + if (require_canonical_order && !IsCanonicallySorted(commitments)) { throw std::ios_base::failure("noncanonical evo quorum commitments"); } std::set quorum_indexes; @@ -192,7 +151,7 @@ bool ReconstructHistoricalMNLists(const EvoSnapshot& snapshot, std::map= previous_height || entry.canonical_list_hash.IsNull()) { @@ -263,8 +222,8 @@ void EvoSnapshot::Validate(bool require_canonical_order) const throw std::ios_base::failure("invalid evo snapshot MNHF signal"); } } - if (require_canonical_order && (!IsStrictlySorted(quorums) || !IsStrictlySorted(historical_mn_list_diffs) || - !IsStrictlySorted(quorum_modifiers))) { + if (require_canonical_order && (!IsCanonicallySorted(quorums) || !IsCanonicallySorted(historical_mn_list_diffs) || + !IsCanonicallySorted(quorum_modifiers))) { throw std::ios_base::failure("noncanonical evo snapshot top-level order"); } @@ -305,7 +264,7 @@ void EvoSnapshot::Validate(bool require_canonical_order) const required_modifiers.emplace(data.llmq_type, entry.work_block_hash); } } - if (require_canonical_order && !IsStrictlySorted(data.rotation_snapshots)) { + if (require_canonical_order && !IsCanonicallySorted(data.rotation_snapshots)) { throw std::ios_base::failure("noncanonical evo quorum rotation snapshots"); } std::set cycle_hashes; diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index 5579455c9c4b..8d00419c9c7a 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -460,6 +460,52 @@ class EvoSnapshot void Validate(bool require_canonical_order = false) const; }; +/** + * Canonical wire order, one overload per snapshot collection element. The + * serializer, the decode-time order check, and the object-level order check + * all derive from these, so a type without an overload is a compile error + * rather than a silently accepted order. + */ +inline bool IsCanonicallyBefore(const MinedQuorumCommitment& a, const MinedQuorumCommitment& b) +{ + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < std::tie(b.quorum_base_block_hash, b.mined_block_hash); +} + +inline bool IsCanonicallyBefore(const QuorumSnapshotEntry& a, const QuorumSnapshotEntry& b) +{ + return a.cycle_base_block_hash < b.cycle_base_block_hash; +} + +/** Historical diffs descend by height: the chain is replayed newest first. */ +inline bool IsCanonicallyBefore(const HistoricalMNListDiff& a, const HistoricalMNListDiff& b) +{ + return std::tie(a.height, a.block_hash) > std::tie(b.height, b.block_hash); +} + +inline bool IsCanonicallyBefore(const QuorumModifier& a, const QuorumModifier& b) +{ + return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); +} + +inline bool IsCanonicallyBefore(const QuorumSnapshotData& a, const QuorumSnapshotData& b) +{ + return a.llmq_type < b.llmq_type; +} + +template +std::vector SortedCanonically(std::vector values) +{ + std::sort(values.begin(), values.end(), [](const T& a, const T& b) { return IsCanonicallyBefore(a, b); }); + return values; +} + +template +bool IsCanonicallySorted(const std::vector& values) +{ + return std::adjacent_find(values.begin(), values.end(), + [](const T& a, const T& b) { return !IsCanonicallyBefore(a, b); }) == values.end(); +} + template void WriteSnapshotVector(Stream& s, const std::vector& values, WriteOne&& write_one) { @@ -501,17 +547,9 @@ QuorumSnapshotEntry ReadRotationSnapshot(Stream& s, const Consensus::LLMQParams& template void QuorumSnapshotData::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; }); + const auto active{SortedCanonically(active_commitments)}; + const auto safety{SortedCanonically(safety_commitments)}; + const auto snapshots{SortedCanonically(rotation_snapshots)}; s << llmq_type << rotation_enabled << active << safety; WriteSnapshotVector(s, snapshots, [&](const auto& entry) { WriteRotationSnapshot(s, entry); }); } @@ -547,17 +585,9 @@ void QuorumSnapshotData::Unserialize(Stream& s) template void EvoSnapshot::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); - }); + const auto sorted_quorums{SortedCanonically(quorums)}; + const auto sorted_history{SortedCanonically(historical_mn_list_diffs)}; + const auto sorted_modifiers{SortedCanonically(quorum_modifiers)}; s << version << base_block_hash; SerializeCanonicalMNList(s, mn_list); s << sorted_quorums; From da25ab6fcc6caca295709e21952300cbe2e2dbfc Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 28 Aug 2026 16:37:03 +0200 Subject: [PATCH 16/19] build: list evo/snapshot.cpp where it is actually used evo/snapshot.cpp was added to libdashkernel_la_SOURCES, but nothing reachable from the kernel library references evo/snapshot.h; the only consumer so far is the unit test, which links libbitcoin_node. Drop the kernel entry until the milestone that wires snapshot loading into chainstate code needs it there, and move the node and test entries into the alphabetical slots the surrounding lists keep. --- src/Makefile.am | 3 +-- src/Makefile.test.include | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Makefile.am b/src/Makefile.am index 4206a5c7178a..b2faeaab44dd 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -547,11 +547,11 @@ 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 \ evo/smldiff.cpp \ + evo/snapshot.cpp \ evo/specialtx.cpp \ evo/specialtx_filter.cpp \ evo/specialtxman.cpp \ @@ -1288,7 +1288,6 @@ 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 fe4db4193177..f602a792aaa0 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -117,8 +117,8 @@ 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_snapshot_tests.cpp \ test/evo_trivialvalidation.cpp \ test/evo_utils_tests.cpp \ test/flatfile_tests.cpp \ From 7183a4dbc1a24ad6b79ac1a2fdc59f09d28841d3 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 28 Aug 2026 16:37:58 +0200 Subject: [PATCH 17/19] fix: enforce the decoder's historical operation budget during validation Unserialize() charges every diff's additions, updates, and removals against a cumulative EvoSnapshotMaxHistoricalMNOperations() ceiling while streaming, but Validate() only bounded the history entry count and the reconstruction record total. A programmatically built snapshot could therefore pass Validate(), receive a canonical GetEvoSnapshotHash(), and still be undecodable from its own bytes - eight transitions alternating a full 100,000-entry list cost 800,000 operations against a 192 * 4,096 = 786,432 ceiling while staying under every limit Validate() did check. Both paths now derive the charge from EvoSnapshotHistoricalMNOperations(); the decoder keeps consuming it incrementally because it must bound work before the whole chain is in memory. The check runs before reconstruction so the budget is reported rather than a downstream chain error, which is what the new test pins. --- src/evo/snapshot.cpp | 8 +++++++- src/evo/snapshot.h | 15 +++++++++++++++ src/test/evo_snapshot_tests.cpp | 26 ++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp index ad07f7a6e90c..a07362c6c828 100644 --- a/src/evo/snapshot.cpp +++ b/src/evo/snapshot.cpp @@ -149,8 +149,8 @@ bool ReconstructHistoricalMNLists(const EvoSnapshot& snapshot, std::map Consensus::MAX_VERSION_BITS_DEPLOYMENTS) { throw std::ios_base::failure("oversized evo snapshot collection"); } + // Unserialize() charges the same amounts against the cumulative decode + // budget while streaming, so without this a snapshot could validate - and + // therefore be hashed and dumped - yet fail to decode from its own bytes. + if (EvoSnapshotHistoricalMNOperations(historical_mn_list_diffs) > EvoSnapshotMaxHistoricalMNOperations()) { + throw std::ios_base::failure("historical MN-diff operation budget exceeded"); + } // 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) || diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index 8d00419c9c7a..f81b456db30c 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -421,6 +421,21 @@ struct HistoricalMNListDiff { CDeterministicMNListDiff diff; }; +/** + * Cumulative add/update/remove operations a diff chain costs the decoder. + * UnserializeCanonicalMNListDiff() charges these same amounts incrementally + * against EvoSnapshotMaxHistoricalMNOperations() while streaming, so a chain + * above that ceiling cannot be decoded back from its own canonical bytes. + */ +inline size_t EvoSnapshotHistoricalMNOperations(const std::vector& history) +{ + size_t operations{0}; + for (const auto& entry : history) { + operations += entry.diff.addedMNs.size() + entry.diff.updatedMNs.size() + entry.diff.removedMns.size(); + } + return operations; +} + struct QuorumModifier { Consensus::LLMQType llmq_type{Consensus::LLMQType::LLMQ_NONE}; uint256 work_block_hash; diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index 16706fd9a1bd..77c62550938b 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -782,6 +782,32 @@ BOOST_FIXTURE_TEST_CASE(quorum_data_unserialize_replaces_previous_contents, Basi BOOST_CHECK_EQUAL(reused.rotation_snapshots.size(), 0U); } +BOOST_FIXTURE_TEST_CASE(validation_enforces_the_decode_operation_budget, BasicTestingSetup) +{ + auto snapshot{SyntheticSnapshot()}; + const size_t budget{evo::EvoSnapshotMaxHistoricalMNOperations()}; + size_t operations{evo::EvoSnapshotHistoricalMNOperations(snapshot.historical_mn_list_diffs)}; + BOOST_REQUIRE(operations < budget); + BOOST_CHECK_NO_THROW(snapshot.Validate()); + + // Removals are the cheapest chargeable operation and are bounded per diff + // by EVO_SNAPSHOT_MAX_MNS, so spread them over entries until the + // cumulative ceiling is passed by exactly one. Ids start above anything + // BuildDiff() produced so every insertion counts. + uint64_t next_id{1'000'000}; + for (auto& entry : snapshot.historical_mn_list_diffs) { + while (operations <= budget && entry.diff.removedMns.size() < evo::EVO_SNAPSHOT_MAX_MNS) { + entry.diff.removedMns.emplace(next_id++); + ++operations; + } + if (operations > budget) break; + } + BOOST_REQUIRE_EQUAL(operations, budget + 1); + BOOST_CHECK_EXCEPTION(snapshot.Validate(), std::ios_base::failure, [](const auto& e) { + return std::string{e.what()}.find("operation budget") != std::string::npos; + }); +} + BOOST_FIXTURE_TEST_CASE(reconstruction_record_budget_is_cumulative, BasicTestingSetup) { const auto snapshot{SyntheticSnapshot()}; From c94bb8f104d40e8f87fe4beb95781e39a737596c Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 28 Aug 2026 16:38:37 +0200 Subject: [PATCH 18/19] test: exercise evo per-quorum replacement for every cleared vector The decoder clears active_commitments, safety_commitments, and rotation_snapshots, but the regression test only proved it for the first: both payloads left the other two empty and the reused object started with them empty, so removing either clear() would still have passed. Seed all three, decode a payload that populates only active_commitments, and assert the other two come back empty. --- src/test/evo_snapshot_tests.cpp | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index 77c62550938b..6d10a1ea7135 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -769,15 +769,22 @@ BOOST_FIXTURE_TEST_CASE(quorum_data_unserialize_replaces_previous_contents, Basi evo::QuorumSnapshotData 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; + CDataStream encoded{SER_DISK, CLIENT_VERSION}; + encoded << data; + // Every vector the decoder clears starts non-empty and the payload leaves + // two of them empty, so dropping any single clear() leaves stale entries. evo::QuorumSnapshotData reused; - once >> reused; - twice >> reused; + reused.llmq_type = Consensus::LLMQType::LLMQ_TEST; + reused.active_commitments = {Commitment(reused.llmq_type, 21, 61, false)}; + reused.safety_commitments = {Commitment(reused.llmq_type, 22, 62, false)}; + reused.rotation_snapshots = { + {H(23), H(63), llmq::CQuorumSnapshot{{true, false}, SnapshotSkipMode::MODE_NO_SKIPPING, {}}}}; + + encoded >> reused; BOOST_CHECK_EQUAL(reused.active_commitments.size(), 1U); + BOOST_CHECK(reused.active_commitments.at(0).quorum_base_block_hash == + data.active_commitments.at(0).quorum_base_block_hash); BOOST_CHECK_EQUAL(reused.safety_commitments.size(), 0U); BOOST_CHECK_EQUAL(reused.rotation_snapshots.size(), 0U); } From ccf37fe90e2d7da9729e02e3a4a9a7e29d361f66 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 28 Aug 2026 16:38:45 +0200 Subject: [PATCH 19/19] test: name the noncanonical CRangesSet case for its actual defect invalid_wrapped read as though a range whose end is 0 were itself invalid, which the max_value round-trips just above contradict: end == 0 encodes "runs through UINT64_MAX" and is valid, but only as the final range. Rename to wrapped_not_last and state the rule. --- src/test/util_tests.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/util_tests.cpp b/src/test/util_tests.cpp index 7243181c32cb..3a265858a3ff 100644 --- a/src/test/util_tests.cpp +++ b/src/test/util_tests.cpp @@ -1459,8 +1459,10 @@ BOOST_AUTO_TEST_CASE(test_CRanges_deserialize_validation) 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); + // end == 0 means "runs through UINT64_MAX", which is representable and + // valid (see the max_value round-trips above) but only as the final range. + auto wrapped_not_last{encoded({{5, 0}, {10, 12}})}; + BOOST_CHECK_THROW(wrapped_not_last >> decoded, std::ios_base::failure); auto invalid_reverse{encoded({{5, 4}})}; BOOST_CHECK_THROW(invalid_reverse >> decoded, std::ios_base::failure);