diff --git a/src/Makefile.am b/src/Makefile.am index c09876eca3fc..8938dc0eeeed 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -235,6 +235,7 @@ BITCOIN_CORE_H = \ evo/providertx_service.h \ evo/simplifiedmns.h \ evo/smldiff.h \ + evo/snapshot.h \ evo/specialtx.h \ evo/specialtx_filter.h \ evo/specialtxman.h \ @@ -542,6 +543,7 @@ libbitcoin_node_a_SOURCES = \ evo/evodb.cpp \ evo/mnauth.cpp \ evo/mnhftx.cpp \ + evo/snapshot.cpp \ evo/providertx.cpp \ evo/providertx_service.cpp \ evo/simplifiedmns.cpp \ @@ -1280,6 +1282,7 @@ libdashkernel_la_SOURCES = \ evo/providertx_util.cpp \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ + evo/snapshot.cpp \ evo/specialtx.cpp \ evo/specialtx_filter.cpp \ evo/specialtxman.cpp \ diff --git a/src/Makefile.test.include b/src/Makefile.test.include index a3c8cb3aead3..2b637b7f75e5 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -117,6 +117,7 @@ BITCOIN_TESTS =\ test/evo_mnauth_tests.cpp \ test/evo_mnhf_tests.cpp \ test/evo_netinfo_tests.cpp \ + test/evo_snapshot_tests.cpp \ test/evo_simplifiedmns_tests.cpp \ test/evo_trivialvalidation.cpp \ test/evo_utils_tests.cpp \ diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 3d391b691e52..565d2486bd97 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -882,11 +882,11 @@ class CRegTestParams : public CChainParams { m_assumeutxo_data = MapAssumeutxo{ { 110, - {AssumeutxoHash{uint256S("0x9b2a277a3e3b979f1a539d57e949495d7f8247312dbc32bce6619128c192b44b")}, 110}, + {AssumeutxoHash{uint256S("0x9b2a277a3e3b979f1a539d57e949495d7f8247312dbc32bce6619128c192b44b")}, EvoSnapshotHash{uint256{}}, 110}, }, { 200, - {AssumeutxoHash{uint256S("0x8a5bdd92252fc6b24663244bbe958c947bb036dc1f94ccd15439f48d8d1cb4e3")}, 200}, + {AssumeutxoHash{uint256S("0x8a5bdd92252fc6b24663244bbe958c947bb036dc1f94ccd15439f48d8d1cb4e3")}, EvoSnapshotHash{uint256{}}, 200}, }, }; diff --git a/src/chainparams.h b/src/chainparams.h index 69b4baaa61fa..fa974ba40cb7 100644 --- a/src/chainparams.h +++ b/src/chainparams.h @@ -34,6 +34,10 @@ struct AssumeutxoHash : public BaseHash { explicit AssumeutxoHash(const uint256& hash) : BaseHash(hash) {} }; +struct EvoSnapshotHash : public BaseHash { + explicit EvoSnapshotHash(const uint256& hash) : BaseHash(hash) {} +}; + /** * Holds configuration for use during UTXO snapshot load and validation. The contents * here are security critical, since they dictate which UTXO snapshots are recognized @@ -43,6 +47,9 @@ struct AssumeutxoData { //! The expected hash of the deserialized UTXO set. const AssumeutxoHash hash_serialized; + //! The expected single-SHA256 hash of the canonical Dash evo section. + const EvoSnapshotHash evo_hash; + //! Used to populate the nChainTx value, which is used during BlockManager::LoadBlockIndex(). //! //! We need to hardcode the value here because this is computed cumulatively using block data, diff --git a/src/evo/deterministicmns.cpp b/src/evo/deterministicmns.cpp index d652efaec732..a2abc2dda9d0 100644 --- a/src/evo/deterministicmns.cpp +++ b/src/evo/deterministicmns.cpp @@ -393,6 +393,30 @@ void CDeterministicMNList::ApplyDiff(gsl::not_null pindex, c } } +void CDeterministicMNList::ApplyDiffForSnapshot(const uint256& block_hash, int height, + uint32_t total_registered_count, + const CDeterministicMNListDiff& diff) +{ + if (height < 0) throw std::runtime_error("negative historical MN-list height"); + blockHash = block_hash; + nHeight = height; + + for (const auto& id : diff.removedMns) { + auto dmn = GetMNByInternalId(id); + if (!dmn) throw std::runtime_error(strprintf("%s: can't find a removed masternode, id=%d", __func__, id)); + RemoveMN(dmn->proTxHash); + } + for (const auto& dmn : diff.addedMNs) { + AddMN(dmn, /*fBumpTotalCount=*/false); + } + for (const auto& p : diff.updatedMNs) { + auto dmn = GetMNByInternalId(p.first); + if (!dmn) throw std::runtime_error(strprintf("%s: can't find an updated masternode, id=%d", __func__, p.first)); + UpdateMN(*dmn, p.second); + } + nTotalRegisteredCount = total_registered_count; +} + void CDeterministicMNList::AddMN(const CDeterministicMNCPtr& dmn, bool fBumpTotalCount) { assert(dmn != nullptr); diff --git a/src/evo/deterministicmns.h b/src/evo/deterministicmns.h index 4fb5dee91aef..b92612ce5116 100644 --- a/src/evo/deterministicmns.h +++ b/src/evo/deterministicmns.h @@ -339,6 +339,8 @@ class CDeterministicMNList assert(nHeight >= 0); return nHeight; } + /** Snapshot hashing also covers the pre-DIP3 default list (height -1). */ + [[nodiscard]] int GetHeightForSnapshotCodec() const noexcept { return nHeight; } void SetHeight(int _height) { assert(_height >= 0); @@ -423,6 +425,11 @@ class CDeterministicMNList void ApplyDiff(gsl::not_null pindex, const CDeterministicMNListDiff& diff) EXCLUSIVE_LOCKS_REQUIRED(!m_cached_sml_mutex); + /** Apply a snapshot-local historical diff without dereferencing block data. */ + void ApplyDiffForSnapshot(const uint256& block_hash, int height, uint32_t total_registered_count, + const CDeterministicMNListDiff& diff) + EXCLUSIVE_LOCKS_REQUIRED(!m_cached_sml_mutex); + void AddMN(const CDeterministicMNCPtr& dmn, bool fBumpTotalCount = true) EXCLUSIVE_LOCKS_REQUIRED(!m_cached_sml_mutex); void UpdateMN(const CDeterministicMN& oldDmn, const std::shared_ptr& pdmnState) EXCLUSIVE_LOCKS_REQUIRED(!m_cached_sml_mutex); diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp new file mode 100644 index 000000000000..4483fde4faca --- /dev/null +++ b/src/evo/snapshot.cpp @@ -0,0 +1,396 @@ +// 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 + +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}; + // The effective quorum size is runtime-configurable, so this layer + // enforces only internal consistency and the format ceiling; the + // chain-aware validation checks exact sizes against effective params. + if (entry.commitment.signers.size() != entry.commitment.validMembers.size() || + entry.commitment.signers.empty() || + entry.commitment.signers.size() > 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"); + } + 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"); + } + } +} + +// Sorting keeps the run detection adversary-proof: a hash-keyed counter could +// itself be driven into collision buckets by the same crafted prefixes. +void ValidateHashPrefixRuns(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"); + } +} + +} // 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, + 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) { + 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"); + } + // 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. + 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) { + 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"); + } + // 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"); + } + + 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)}; + // 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; + 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; + } + 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) { + 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..d4784593b648 --- /dev/null +++ b/src/evo/snapshot.h @@ -0,0 +1,659 @@ +// 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 +#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}; +// 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}; +// 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}; +// 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"); + +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); +} + +/** + * 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, + [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}; + 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)}; + 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"); + } + // 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()); + 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) +{ + 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; + // 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, 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 || + commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION}; + s >> CBLSPublicKeyVersionWrapper(commitment.quorumPublicKey, legacy) >> commitment.quorumVvecHash >> + CBLSSignatureVersionWrapper(commitment.quorumSig, legacy) >> + CBLSSignatureVersionWrapper(commitment.membersSig, legacy); + 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; + // 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)}; + 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)); + } + 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)); + } + 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; + // 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); + 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")}; + // 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 (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); +} + +/** 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, + 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); + +} // namespace evo + +#endif // BITCOIN_EVO_SNAPSHOT_H 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"); } 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..e96230f616f6 --- /dev/null +++ b/src/test/evo_snapshot_tests.cpp @@ -0,0 +1,841 @@ +// 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 +#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); + + // 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()); + + 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]); + 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; }); + 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_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)}; + 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(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())}; + // 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(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(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(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(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()}; + 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) { + 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)); + 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) +{ + 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/test/util_tests.cpp b/src/test/util_tests.cpp index 2939888582ac..7243181c32cb 100644 --- a/src/test/util_tests.cpp +++ b/src/test/util_tests.cpp @@ -1399,6 +1399,73 @@ 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({{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 + 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; diff --git a/src/util/ranges_set.h b/src/util/ranges_set.h index d67be4919056..b9a696989a26 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,51 @@ 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 == 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"); + } + // 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