From 1d3b001576310665967b85b9709d0eeae4d76d23 Mon Sep 17 00:00:00 2001 From: Pasta Date: Sat, 11 Jul 2026 11:51:11 -0500 Subject: [PATCH 1/8] feat: assumeutxo evo snapshot format (v3, quorum reconstruction history) --- src/Makefile.am | 3 + src/Makefile.test.include | 1 + src/chainparams.cpp | 4 +- src/chainparams.h | 7 + src/evo/chainhelper.cpp | 5 +- src/evo/chainhelper.h | 5 + src/evo/deterministicmns.cpp | 37 +- src/evo/deterministicmns.h | 23 + src/evo/snapshot.cpp | 696 ++++++++++++++++++ src/evo/snapshot.h | 619 ++++++++++++++++ src/evo/specialtxman.cpp | 6 +- src/llmq/snapshot.cpp | 40 +- src/llmq/snapshot.h | 9 + src/llmq/utils.cpp | 45 +- src/llmq/utils.h | 5 + src/streams.h | 1 + src/test/evo_netinfo_tests.cpp | 24 + src/test/evo_snapshot_tests.cpp | 1189 +++++++++++++++++++++++++++++++ src/test/util_tests.cpp | 67 ++ src/util/ranges_set.cpp | 6 +- src/util/ranges_set.h | 48 +- 21 files changed, 2820 insertions(+), 20 deletions(-) create mode 100644 src/evo/snapshot.cpp create mode 100644 src/evo/snapshot.h create mode 100644 src/test/evo_snapshot_tests.cpp diff --git a/src/Makefile.am b/src/Makefile.am index fb1ffde2529a..35da6bfb58a6 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -234,6 +234,7 @@ BITCOIN_CORE_H = \ evo/providertx.h \ evo/simplifiedmns.h \ evo/smldiff.h \ + evo/snapshot.h \ evo/specialtx.h \ evo/specialtx_filter.h \ evo/specialtxman.h \ @@ -538,6 +539,7 @@ libbitcoin_node_a_SOURCES = \ evo/evodb.cpp \ evo/mnauth.cpp \ evo/mnhftx.cpp \ + evo/snapshot.cpp \ evo/providertx.cpp \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ @@ -1275,6 +1277,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 427909b8e12f..55b85b58e10a 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -118,6 +118,7 @@ BITCOIN_TESTS =\ test/evo_mnhf_tests.cpp \ test/evo_netinfo_tests.cpp \ test/evo_simplifiedmns_tests.cpp \ + test/evo_snapshot_tests.cpp \ test/evo_trivialvalidation.cpp \ test/evo_utils_tests.cpp \ test/flatfile_tests.cpp \ 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/chainhelper.cpp b/src/evo/chainhelper.cpp index 9412607e93a0..6af84ff98d9d 100644 --- a/src/evo/chainhelper.cpp +++ b/src/evo/chainhelper.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,8 @@ CChainstateHelper::CChainstateHelper(CEvoDB& evodb, CDeterministicMNManager& dmn isman{isman}, mn_sync{mn_sync}, m_dmnman{dmnman}, + m_qblockman{qblockman}, + m_qsnapman{qsnapman}, credit_pool_manager{std::make_unique(evodb, chainman)}, m_chainlocks{chainlocks}, ehf_manager{std::make_unique(evodb, chainman)}, @@ -66,7 +69,7 @@ int32_t CChainstateHelper::GetBestChainLockHeight() const { return m_chainlocks. uint256 CChainstateHelper::GetDeterministicMNListHash(const CBlockIndex* pindex) const { - return SerializeHash(m_dmnman.GetListForBlock(Assert(pindex))); + return evo::CanonicalMNListHash(m_dmnman.GetListForBlock(Assert(pindex))); } /** Passthrough functions to CCreditPoolManager */ diff --git a/src/evo/chainhelper.h b/src/evo/chainhelper.h index eac183777ba1..2c5c6940de1d 100644 --- a/src/evo/chainhelper.h +++ b/src/evo/chainhelper.h @@ -43,6 +43,8 @@ class CChainstateHelper llmq::CInstantSendManager& isman; const CMasternodeSync& mn_sync; CDeterministicMNManager& m_dmnman; + llmq::CQuorumBlockProcessor& m_qblockman; + llmq::CQuorumSnapshotManager& m_qsnapman; public: const std::unique_ptr credit_pool_manager; @@ -72,6 +74,9 @@ class CChainstateHelper /** Return a canonical hash of the deterministic MN list derived at a block. */ uint256 GetDeterministicMNListHash(const CBlockIndex* pindex) const; + CDeterministicMNManager& DeterministicMNManager() { return m_dmnman; } + llmq::CQuorumBlockProcessor& QuorumBlockProcessor() { return m_qblockman; } + llmq::CQuorumSnapshotManager& QuorumSnapshotManager() { return m_qsnapman; } /** Passthrough functions to CCreditPoolManager */ CCreditPool GetCreditPool(const CBlockIndex* const pindex); diff --git a/src/evo/deterministicmns.cpp b/src/evo/deterministicmns.cpp index 357402ae2c16..86e7f8b1677c 100644 --- a/src/evo/deterministicmns.cpp +++ b/src/evo/deterministicmns.cpp @@ -374,6 +374,27 @@ void CDeterministicMNList::ApplyDiff(gsl::not_null pindex, c blockHash = pindex->GetBlockHash(); nHeight = pindex->nHeight; + 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); + 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); + } +} + +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) { @@ -382,7 +403,7 @@ void CDeterministicMNList::ApplyDiff(gsl::not_null pindex, c RemoveMN(dmn->proTxHash); } for (const auto& dmn : diff.addedMNs) { - AddMN(dmn); + AddMN(dmn, /*fBumpTotalCount=*/false); } for (const auto& p : diff.updatedMNs) { auto dmn = GetMNByInternalId(p.first); @@ -391,6 +412,7 @@ void CDeterministicMNList::ApplyDiff(gsl::not_null pindex, c } UpdateMN(*dmn, p.second); } + nTotalRegisteredCount = total_registered_count; } void CDeterministicMNList::AddMN(const CDeterministicMNCPtr& dmn, bool fBumpTotalCount) @@ -622,6 +644,18 @@ CDeterministicMNManager::CDeterministicMNManager(CEvoDB& evoDb, CMasternodeMetaM CDeterministicMNManager::~CDeterministicMNManager() = default; +bool CDeterministicMNManager::SeedListForBlock(const CDeterministicMNList& list) +{ + return m_evoDb.WriteDerived(std::make_pair(DB_LIST_SNAPSHOT, list.GetBlockHash()), list); +} + +void CDeterministicMNManager::InvalidateListCacheForBlock(const uint256& block_hash) +{ + LOCK(cs); + mnListsCache.erase(block_hash); + mnListDiffsCache.erase(block_hash); +} + bool CDeterministicMNManager::ProcessBlock(const CBlock& block, gsl::not_null pindex, BlockValidationState& state, const CDeterministicMNList& newList, std::optional& updatesRet) @@ -789,6 +823,7 @@ CDeterministicMNList CDeterministicMNManager::GetListForBlockInternal(gsl::not_n mnListsCache.emplace(pindex->GetBlockHash(), snapshot); break; } + if (m_list_snapshot_miss_hook) m_list_snapshot_miss_hook(pindex); // no snapshot found yet, check diffs auto itDiffs = mnListDiffsCache.find(pindex->GetBlockHash()); diff --git a/src/evo/deterministicmns.h b/src/evo/deterministicmns.h index 4fb5dee91aef..d30db484945f 100644 --- a/src/evo/deterministicmns.h +++ b/src/evo/deterministicmns.h @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -339,6 +340,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); @@ -422,6 +425,10 @@ 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) @@ -766,6 +773,7 @@ class CDeterministicMNManager Uint256HashMap mnListsCache GUARDED_BY(cs); Uint256HashMap mnListDiffsCache GUARDED_BY(cs); + std::function m_list_snapshot_miss_hook GUARDED_BY(cs); const CBlockIndex* tipIndex GUARDED_BY(cs) {nullptr}; const CBlockIndex* m_initial_snapshot_index GUARDED_BY(cs) {nullptr}; @@ -789,6 +797,21 @@ class CDeterministicMNManager }; CDeterministicMNList GetListAtChainTip() EXCLUSIVE_LOCKS_REQUIRED(!cs); + /** Seed a canonical full-list snapshot in the current EvoDB transaction. */ + bool SeedListForBlock(const CDeterministicMNList& list) EXCLUSIVE_LOCKS_REQUIRED(!cs); + + /** Invalidate cached list data so the next lookup reloads it from EvoDB. */ + void InvalidateListCacheForBlock(const uint256& block_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); + + /** Test-only guard invoked after a full-list cache/EvoDB miss, before + * ordinary diff-chain reconstruction can access earlier NORMAL state. */ + void SetListSnapshotMissHookForTesting(std::function hook) + EXCLUSIVE_LOCKS_REQUIRED(!cs) + { + LOCK(cs); + m_list_snapshot_miss_hook = std::move(hook); + } + void SetListForBlockForTesting(const CDeterministicMNList& list) EXCLUSIVE_LOCKS_REQUIRED(!cs) { LOCK(cs); diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp new file mode 100644 index 000000000000..375c95e6008e --- /dev/null +++ b/src/evo/snapshot.cpp @@ -0,0 +1,696 @@ +// 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 + +namespace evo { +namespace { + +template +std::vector Sorted(std::vector values) +{ + std::sort(values.begin(), values.end(), [](const T& a, const T& b) { + if constexpr (std::is_same_v) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + } else if constexpr (std::is_same_v) { + return a.cycle_base_block_hash < b.cycle_base_block_hash; + } else if constexpr (std::is_same_v) { + return std::tie(a.height, a.block_hash) > std::tie(b.height, b.block_hash); + } else if constexpr (std::is_same_v) { + return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); + } else { + return a.llmq_type < b.llmq_type; + } + }); + return values; +} + +template +bool IsStrictlySorted(const std::vector& values) +{ + return std::adjacent_find(values.begin(), values.end(), [](const T& a, const T& b) { + if constexpr (std::is_same_v) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) >= + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + } else if constexpr (std::is_same_v) { + return !(a.cycle_base_block_hash < b.cycle_base_block_hash); + } else if constexpr (std::is_same_v) { + return !(std::tie(a.height, a.block_hash) > std::tie(b.height, b.block_hash)); + } else if constexpr (std::is_same_v) { + return !(std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash)); + } else { + return a.llmq_type >= b.llmq_type; + } + }) == values.end(); +} + +void ValidateCommitments(const CQuorumSnapshotData& data, const std::vector& commitments, + std::set& quorum_hashes, bool require_canonical_order) +{ + if (require_canonical_order && !IsStrictlySorted(commitments)) { + throw std::ios_base::failure("noncanonical evo quorum commitments"); + } + std::set quorum_indexes; + const auto& params{SnapshotLLMQParams(data.llmq_type)}; + for (const auto& entry : commitments) { + const bool known_version{ + entry.commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION || + entry.commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION || + entry.commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION || + entry.commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION}; + const bool indexed{entry.commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION || + entry.commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION}; + if (!entry.commitment.VerifySizes(params)) throw std::ios_base::failure("invalid evo quorum commitment sizes"); + if (!known_version) throw std::ios_base::failure("unknown evo quorum commitment version"); + if (entry.quorum_base_block_hash.IsNull() || entry.work_block_hash.IsNull() || entry.mined_block_hash.IsNull()) { + throw std::ios_base::failure("null evo quorum commitment block hash"); + } + if (entry.commitment.llmqType != data.llmq_type) { + throw std::ios_base::failure("mismatched evo quorum commitment type"); + } + if (entry.commitment.quorumHash != entry.quorum_base_block_hash) { + throw std::ios_base::failure("mismatched evo quorum commitment base hash"); + } + if (indexed != data.rotation_enabled) throw std::ios_base::failure("mismatched evo quorum rotation version"); + if (indexed && (entry.commitment.quorumIndex < 0 || + entry.commitment.quorumIndex >= params.signingActiveQuorumCount)) { + throw std::ios_base::failure("invalid evo quorum index"); + } + if (!quorum_hashes.insert(entry.quorum_base_block_hash).second) { + throw std::ios_base::failure("duplicate evo quorum base hash"); + } + if (indexed && !quorum_indexes.insert(entry.commitment.quorumIndex).second) { + throw std::ios_base::failure("duplicate evo quorum index"); + } + } +} + +CMinedQuorumCommitment ReadCommitment(const llmq::CQuorumBlockProcessor& qblockman, Consensus::LLMQType type, + const CBlockIndex* quorum_index, const CBlockIndex* work_index, + std::string& error) +{ + auto [commitment, mined_hash] = qblockman.GetMinedCommitment(type, quorum_index->GetBlockHash()); + if (mined_hash.IsNull()) error = "mined quorum commitment not found for " + quorum_index->GetBlockHash().ToString(); + return {quorum_index->GetBlockHash(), work_index->GetBlockHash(), std::move(commitment), mined_hash}; +} + +void ValidateCanonicalMNInvariants(const CDeterministicMNList& list) +{ + const size_t count{list.GetCounts().total()}; + if (count > EVO_SNAPSHOT_MAX_MNS) throw std::ios_base::failure("oversized canonical MN list"); + uint64_t max_internal_id{0}; + list.ForEachMN(/*onlyValid=*/false, [&](const auto& dmn) { + max_internal_id = std::max(max_internal_id, dmn.GetInternalId()); + if (dmn.pdmnState->payouts.size() > EVO_SNAPSHOT_MAX_PAYOUT_SHARES || + dmn.pdmnState->netInfo->Validate() != NetInfoStatus::Success) { + throw std::ios_base::failure("invalid canonical MN nested collection"); + } + }); + if (count != 0 && max_internal_id >= list.GetTotalRegisteredCount()) { + throw std::ios_base::failure("canonical MN-list internalId exceeds registration counter"); + } +} + +bool ValidateCommitmentAgainstChain(const CMinedQuorumCommitment& entry, const ChainstateManager& chainman, + const CBlockIndex* base_index, const Consensus::LLMQParams& params, + bool rotation_enabled) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + const CBlockIndex* mined_index{chainman.m_blockman.LookupBlockIndex(entry.mined_block_hash)}; + if (quorum_index == nullptr || mined_index == nullptr || + base_index->GetAncestor(quorum_index->nHeight) != quorum_index || + base_index->GetAncestor(mined_index->nHeight) != mined_index) return false; + const int cycle_height{quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval}; + if (rotation_enabled) { + if (entry.commitment.quorumIndex != quorum_index->nHeight % params.dkgInterval || + entry.commitment.quorumIndex < 0 || + entry.commitment.quorumIndex >= params.signingActiveQuorumCount) return false; + } else if (quorum_index->nHeight != cycle_height || entry.commitment.quorumIndex != 0) { + return false; + } + const int mined_cycle{mined_index->nHeight - mined_index->nHeight % params.dkgInterval}; + if (mined_cycle != cycle_height || mined_index->nHeight % params.dkgInterval < params.dkgMiningWindowStart || + mined_index->nHeight % params.dkgInterval > params.dkgMiningWindowEnd) return false; + const uint16_t expected_version{llmq::CFinalCommitment::GetVersion( + rotation_enabled, DeploymentActiveAfter(quorum_index, chainman.GetConsensus(), Consensus::DEPLOYMENT_V19))}; + return entry.commitment.nVersion == expected_version && entry.commitment.VerifySizes(params); +} + +} // namespace + +std::vector EvoSnapshotReconstructionHeights( + int base_height, const std::vector& enabled_llmqs) +{ + if (base_height < 0) throw std::invalid_argument("invalid reconstruction base height"); + std::vector heights; + for (const auto& params : enabled_llmqs) { + if (params.dkgInterval <= 0 || params.signingActiveQuorumCount <= 0) { + throw std::invalid_argument("invalid reconstruction LLMQ parameters"); + } + const int h{base_height - base_height % params.dkgInterval}; + const size_t count{params.useRotation ? EVO_SNAPSHOT_ROTATION_CYCLES + : SnapshotCommitmentCount(params, /*rotation_enabled=*/false)}; + const size_t first{params.useRotation ? 1U : 0U}; + for (size_t i{first}; i < first + count; ++i) { + const int quorum_height{h - static_cast(i) * params.dkgInterval}; + heights.push_back({params.type, params.useRotation, quorum_height, + quorum_height - llmq::WORK_DIFF_DEPTH}); + } + } + return heights; +} + +uint256 CanonicalMNListHash(const CDeterministicMNList& list) +{ + CHashWriter writer{SER_DISK, CLIENT_VERSION}; + SerializeCanonicalMNList(writer, list); + return writer.GetHash(); +} + +bool ReconstructHistoricalMNLists(const CEvoSnapshot& snapshot, + std::map& lists, std::string& error) +{ + lists.clear(); + error.clear(); + CDeterministicMNList current{snapshot.mn_list}; + uint256 previous_hash{snapshot.base_block_hash}; + int previous_height{current.GetHeightForSnapshotCodec()}; + try { + const auto history{Sorted(snapshot.historical_mn_list_diffs)}; + for (const auto& entry : history) { + if (entry.previous_block_hash != previous_hash || entry.block_hash.IsNull() || + entry.height < 0 || entry.height >= previous_height || entry.canonical_list_hash.IsNull()) { + throw std::ios_base::failure("broken historical MN-list diff chain"); + } + current.ApplyDiffForSnapshot(entry.block_hash, entry.height, entry.total_registered_count, entry.diff); + ValidateCanonicalMNInvariants(current); + if (CanonicalMNListHash(current) != entry.canonical_list_hash) { + throw std::ios_base::failure("historical MN-list diff hash mismatch"); + } + if (!lists.emplace(entry.block_hash, current).second) { + throw std::ios_base::failure("duplicate historical MN-list diff target"); + } + previous_hash = entry.block_hash; + previous_height = entry.height; + } + } catch (const std::exception& e) { + error = e.what(); + lists.clear(); + return false; + } + return true; +} + +void CEvoSnapshot::Validate(bool require_canonical_order) const +{ + if (version != EVO_SNAPSHOT_VERSION) throw std::ios_base::failure("unsupported evo snapshot version"); + if (base_block_hash.IsNull() || mn_list.GetBlockHash() != base_block_hash) { + throw std::ios_base::failure("evo snapshot base block mismatch"); + } + ValidateCanonicalMNInvariants(mn_list); + if (quorums.size() > Consensus::available_llmqs.size() || + historical_mn_list_diffs.size() > EvoSnapshotMaxHistoricalMNLists() || + quorum_modifiers.size() > EVO_SNAPSHOT_MAX_MODIFIERS || + mnhf_signals.size() > Consensus::MAX_VERSION_BITS_DEPLOYMENTS) { + throw std::ios_base::failure("oversized evo snapshot collection"); + } + if (require_canonical_order && (!IsStrictlySorted(quorums) || !IsStrictlySorted(historical_mn_list_diffs) || + !IsStrictlySorted(quorum_modifiers))) { + throw std::ios_base::failure("noncanonical evo snapshot top-level order"); + } + + std::map reconstructed; + std::string reconstruction_error; + if (!ReconstructHistoricalMNLists(*this, reconstructed, reconstruction_error)) { + throw std::ios_base::failure(reconstruction_error); + } + std::set historical_hashes; + for (const auto& [hash, _] : reconstructed) historical_hashes.insert(hash); + + std::set> required_modifiers; + std::set required_work_hashes; + + std::set quorum_types; + for (const auto& data : quorums) { + const auto& params{SnapshotLLMQParams(data.llmq_type)}; + if (!quorum_types.insert(data.llmq_type).second || (data.rotation_enabled && !params.useRotation)) { + throw std::ios_base::failure("invalid evo quorum type"); + } + const size_t active_count{static_cast(params.signingActiveQuorumCount)}; + const size_t total_count{SnapshotCommitmentCount(params, data.rotation_enabled)}; + if (data.active_commitments.size() != active_count || + data.safety_commitments.size() != total_count - active_count || + data.rotation_snapshots.size() != (data.rotation_enabled ? EVO_SNAPSHOT_ROTATION_CYCLES : 0)) { + throw std::ios_base::failure("invalid params-derived evo per-type quorum counts"); + } + std::set quorum_hashes; + ValidateCommitments(data, data.active_commitments, quorum_hashes, require_canonical_order); + ValidateCommitments(data, data.safety_commitments, quorum_hashes, require_canonical_order); + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + required_work_hashes.insert(entry.work_block_hash); + required_modifiers.emplace(data.llmq_type, entry.work_block_hash); + } + } + if (require_canonical_order && !IsStrictlySorted(data.rotation_snapshots)) { + throw std::ios_base::failure("noncanonical evo quorum rotation snapshots"); + } + std::set cycle_hashes; + for (const auto& entry : data.rotation_snapshots) { + if (entry.cycle_base_block_hash.IsNull() || entry.work_block_hash.IsNull() || + !cycle_hashes.insert(entry.cycle_base_block_hash).second || + !historical_hashes.contains(entry.work_block_hash) || + entry.snapshot.mnSkipListMode < SnapshotSkipMode::MODE_NO_SKIPPING || + entry.snapshot.mnSkipListMode > SnapshotSkipMode::MODE_ALL_SKIPPED || + entry.snapshot.activeQuorumMembers.size() > EVO_SNAPSHOT_MAX_MNS || + entry.snapshot.mnSkipList.size() > static_cast(params.size) || + std::ranges::any_of(entry.snapshot.mnSkipList, [](int index) { return index < 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 BuildEvoSnapshot(const CChainParams& chainparams, const ChainstateManager& chainman, + CDeterministicMNManager& dmnman, + const llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, + CCreditPoolManager& cpoolman, CMNHFManager& mnhfman, const CBlockIndex* base_index, + CEvoSnapshot& snapshot, std::string& error) +{ + AssertLockHeld(::cs_main); + error.clear(); + if (base_index == nullptr) { + error = "evo snapshot base block is null"; + return false; + } + + CEvoSnapshot result; + result.base_block_hash = base_index->GetBlockHash(); + if (!DeploymentActiveAt(*base_index, chainparams.GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + result.mn_list = CDeterministicMNList{base_index->GetBlockHash(), base_index->nHeight, 0}; + result.Validate(); + snapshot = std::move(result); + return true; + } + result.mn_list = dmnman.GetListForBlock(base_index); + std::map> historical; + std::map, uint256> modifiers; + + const auto register_work_block = [&](const Consensus::LLMQParams& params, + bool rotation_enabled, + const CBlockIndex* quorum_index) -> const CBlockIndex* { + const CBlockIndex* modifier_base{rotation_enabled + ? quorum_index->GetAncestor(quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval) + : quorum_index}; + if (modifier_base == nullptr) return nullptr; + const CBlockIndex* work_index{ + (rotation_enabled || + DeploymentActiveAfter(modifier_base, chainparams.GetConsensus(), Consensus::DEPLOYMENT_V20)) + ? modifier_base->GetAncestor(modifier_base->nHeight - llmq::WORK_DIFF_DEPTH) + : modifier_base}; + if (work_index == nullptr) return nullptr; + historical.try_emplace(work_index->GetBlockHash(), work_index, dmnman.GetListForBlock(work_index)); + modifiers.emplace(std::make_pair(params.type, work_index->GetBlockHash()), + llmq::utils::GetQuorumHashModifier(params, chainparams.GetConsensus(), modifier_base)); + return work_index; + }; + + for (const auto& params : chainparams.GetConsensus().llmqs) { + if (!chainman.IsQuorumTypeEnabled(params.type, base_index)) continue; + CQuorumSnapshotData data; + data.llmq_type = params.type; + data.rotation_enabled = llmq::IsQuorumRotationEnabled(params, base_index); + + const size_t active_count{static_cast(params.signingActiveQuorumCount)}; + const size_t total_count{SnapshotCommitmentCount(params, data.rotation_enabled)}; + std::vector indexes; + if (data.rotation_enabled) { + indexes = qblockman.GetLastMinedCommitmentsPerQuorumIndexUntilBlock(params.type, base_index, 0); + } else { + indexes = qblockman.GetMinedCommitmentsUntilBlock(params.type, base_index, total_count); + } + if (indexes.size() < active_count) { + error = strprintf("not enough active quorum commitments for LLMQ type %d", static_cast(params.type)); + return false; + } + for (size_t i{0}; i < active_count; ++i) { + const CBlockIndex* work_index{register_work_block(params, data.rotation_enabled, indexes[i])}; + if (work_index == nullptr) { + error = "missing active quorum work block"; + return false; + } + auto entry{ReadCommitment(qblockman, params.type, indexes[i], work_index, error)}; + if (!error.empty()) return false; + data.active_commitments.emplace_back(std::move(entry)); + } + + if (data.rotation_enabled) { + indexes = qblockman.GetLastMinedCommitmentsPerQuorumIndexUntilBlock(params.type, base_index, 1); + } else { + indexes.erase(indexes.begin(), indexes.begin() + active_count); + } + const size_t safety_count{total_count - active_count}; + if (indexes.size() < safety_count) { + error = strprintf("not enough safety quorum commitments for LLMQ type %d", static_cast(params.type)); + return false; + } + for (size_t i{0}; i < safety_count; ++i) { + const CBlockIndex* work_index{register_work_block(params, data.rotation_enabled, indexes[i])}; + if (work_index == nullptr) { + error = "missing safety quorum work block"; + return false; + } + auto entry{ReadCommitment(qblockman, params.type, indexes[i], work_index, error)}; + if (!error.empty()) return false; + data.safety_commitments.emplace_back(std::move(entry)); + } + + if (data.rotation_enabled) { + std::vector one_type{params}; + for (const auto& required : EvoSnapshotReconstructionHeights(base_index->nHeight, one_type)) { + const int cycle_height{required.quorum_height}; + const int work_height{required.work_height}; + const CBlockIndex* cycle_index{cycle_height >= 0 ? base_index->GetAncestor(cycle_height) : nullptr}; + const CBlockIndex* work_index{cycle_index && work_height >= 0 + ? cycle_index->GetAncestor(work_height) + : nullptr}; + if (cycle_index == nullptr || work_index == nullptr) { + error = "missing quorum rotation cycle/work block"; + return false; + } + auto stored{qsnapman.GetSnapshotForBlock(params.type, cycle_index)}; + if (!stored) { + error = "missing quorum rotation snapshot at " + cycle_index->GetBlockHash().ToString(); + return false; + } + data.rotation_snapshots.push_back( + {cycle_index->GetBlockHash(), work_index->GetBlockHash(), *stored}); + historical.try_emplace(work_index->GetBlockHash(), work_index, dmnman.GetListForBlock(work_index)); + modifiers.emplace(std::make_pair(params.type, work_index->GetBlockHash()), + llmq::utils::GetQuorumHashModifier(params, chainparams.GetConsensus(), cycle_index)); + } + } + data.active_commitments = Sorted(std::move(data.active_commitments)); + data.safety_commitments = Sorted(std::move(data.safety_commitments)); + data.rotation_snapshots = Sorted(std::move(data.rotation_snapshots)); + result.quorums.emplace_back(std::move(data)); + } + + std::vector> ordered_history; + ordered_history.reserve(historical.size()); + for (auto& [_, indexed_list] : historical) ordered_history.emplace_back(std::move(indexed_list)); + std::sort(ordered_history.begin(), ordered_history.end(), [](const auto& a, const auto& b) { + return std::make_tuple(a.first->nHeight, a.first->GetBlockHash()) > + std::make_tuple(b.first->nHeight, b.first->GetBlockHash()); + }); + CDeterministicMNList previous_list{result.mn_list}; + uint256 previous_hash{result.base_block_hash}; + for (const auto& [index, list] : ordered_history) { + if (index->GetBlockHash() == result.base_block_hash) continue; + result.historical_mn_list_diffs.push_back({previous_hash, index->GetBlockHash(), index->nHeight, + list.GetTotalRegisteredCount(), CanonicalMNListHash(list), + previous_list.BuildDiff(list)}); + previous_hash = index->GetBlockHash(); + previous_list = list; + } + for (const auto& [key, modifier] : modifiers) { + result.quorum_modifiers.push_back({key.first, key.second, modifier}); + } + result.credit_pool = cpoolman.GetCreditPool(base_index); + result.mnhf_signals = mnhfman.GetSignalsStage(base_index); + result.quorums = Sorted(std::move(result.quorums)); + result.historical_mn_list_diffs = Sorted(std::move(result.historical_mn_list_diffs)); + result.quorum_modifiers = Sorted(std::move(result.quorum_modifiers)); + try { + result.Validate(); + } catch (const std::exception& e) { + error = e.what(); + return false; + } + snapshot = std::move(result); + return true; +} + +bool ValidateEvoSnapshotAgainstChain(const CEvoSnapshot& snapshot, const ChainstateManager& chainman, + const CBlockIndex* base_index, std::string& error) +{ + AssertLockHeld(::cs_main); + error.clear(); + const auto fail = [&](const std::string& message) { + error = message; + return false; + }; + if (base_index == nullptr || snapshot.base_block_hash != base_index->GetBlockHash() || + snapshot.mn_list.GetBlockHash() != base_index->GetBlockHash() || + snapshot.mn_list.GetHeightForSnapshotCodec() != base_index->nHeight) { + return fail("evo snapshot base block/height mismatch"); + } + try { + snapshot.Validate(/*require_canonical_order=*/true); + } catch (const std::exception& e) { + return fail(e.what()); + } + + const auto& consensus{chainman.GetConsensus()}; + if (!DeploymentActiveAt(*base_index, consensus, Consensus::DEPLOYMENT_DIP0003)) { + if (!snapshot.quorums.empty() || !snapshot.historical_mn_list_diffs.empty() || + !snapshot.quorum_modifiers.empty() || + snapshot.credit_pool.locked != 0 || snapshot.credit_pool.currentLimit != 0 || + snapshot.credit_pool.latelyUnlocked != 0 || !snapshot.credit_pool.indexes.IsEmpty() || + !snapshot.mnhf_signals.empty()) { + return fail("nonempty pre-DIP3 evo snapshot"); + } + return true; + } + + std::map historical_lists; + if (!ReconstructHistoricalMNLists(snapshot, historical_lists, error)) return false; + for (const auto& entry : snapshot.historical_mn_list_diffs) { + const CBlockIndex* index{chainman.m_blockman.LookupBlockIndex(entry.block_hash)}; + if (index == nullptr || base_index->GetAncestor(index->nHeight) != index || + entry.height != index->nHeight) { + return fail("invalid historical evo MN list chain data"); + } + } + + std::map actual; + for (const auto& data : snapshot.quorums) actual.emplace(data.llmq_type, &data); + size_t enabled_count{0}; + std::set required_work_hashes; + for (const auto& params : consensus.llmqs) { + if (!chainman.IsQuorumTypeEnabled(params.type, base_index)) continue; + ++enabled_count; + const auto it{actual.find(params.type)}; + if (it == actual.end()) return fail("missing enabled evo quorum type"); + const auto& data{*it->second}; + const bool rotation_enabled{llmq::IsQuorumRotationEnabled(params, base_index)}; + const size_t active_count{static_cast(params.signingActiveQuorumCount)}; + const size_t total_count{SnapshotCommitmentCount(params, rotation_enabled)}; + if (data.rotation_enabled != rotation_enabled || data.active_commitments.size() != active_count || + data.safety_commitments.size() != total_count - active_count) { + return fail("evo quorum params/count mismatch"); + } + + std::set active_indexes; + const auto validate_work_block = [&](const CMinedQuorumCommitment& entry) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + if (quorum_index == nullptr) return false; + const CBlockIndex* modifier_base{rotation_enabled + ? quorum_index->GetAncestor(quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval) + : quorum_index}; + if (modifier_base == nullptr) return false; + const CBlockIndex* expected_work{ + (rotation_enabled || DeploymentActiveAfter(modifier_base, consensus, Consensus::DEPLOYMENT_V20)) + ? modifier_base->GetAncestor(modifier_base->nHeight - llmq::WORK_DIFF_DEPTH) + : modifier_base}; + return expected_work != nullptr && entry.work_block_hash == expected_work->GetBlockHash(); + }; + for (const auto& entry : data.active_commitments) { + if (!ValidateCommitmentAgainstChain(entry, chainman, base_index, params, rotation_enabled) || + !validate_work_block(entry) || + (rotation_enabled && !active_indexes.insert(entry.commitment.quorumIndex).second)) { + return fail("invalid active evo quorum commitment chain data"); + } + required_work_hashes.insert(entry.work_block_hash); + } + for (const auto& entry : data.safety_commitments) { + if (!ValidateCommitmentAgainstChain(entry, chainman, base_index, params, rotation_enabled) || + !validate_work_block(entry)) { + return fail("invalid safety evo quorum commitment chain data"); + } + required_work_hashes.insert(entry.work_block_hash); + } + if (rotation_enabled && active_indexes.size() != active_count) { + return fail("incomplete active rotated quorum indexes"); + } + + std::map rotations; + for (const auto& entry : data.rotation_snapshots) rotations.emplace(entry.cycle_base_block_hash, &entry); + const auto heights{EvoSnapshotReconstructionHeights(base_index->nHeight, {params})}; + if (rotations.size() != (rotation_enabled ? heights.size() : 0)) { + return fail("evo rotation snapshot count mismatch"); + } + if (rotation_enabled) { + for (const auto& required : heights) { + const int cycle_height{required.quorum_height}; + const int work_height{required.work_height}; + const CBlockIndex* cycle{base_index->GetAncestor(cycle_height)}; + const CBlockIndex* work{base_index->GetAncestor(work_height)}; + if (cycle == nullptr || work == nullptr) return fail("evo rotation horizon precedes chain"); + const auto rotation{rotations.find(cycle->GetBlockHash())}; + if (rotation == rotations.end() || rotation->second->work_block_hash != work->GetBlockHash()) { + return fail("evo rotation cycle/work ancestor mismatch"); + } + const auto historical{historical_lists.find(work->GetBlockHash())}; + if (historical == historical_lists.end() || + rotation->second->snapshot.activeQuorumMembers.size() != + historical->second.GetCounts().total()) { + return fail("evo rotation bitset/work-block MN count mismatch"); + } + required_work_hashes.insert(work->GetBlockHash()); + } + } + } + if (actual.size() != enabled_count) return fail("unexpected disabled evo quorum type"); + + std::set historical_hashes; + for (const auto& [hash, list] : historical_lists) historical_hashes.insert(hash); + historical_hashes.erase(base_index->GetBlockHash()); + required_work_hashes.erase(base_index->GetBlockHash()); + if (historical_hashes != required_work_hashes) return fail("missing or extra historical evo MN list"); + + std::map, uint256> seeded_modifiers; + for (const auto& entry : snapshot.quorum_modifiers) { + seeded_modifiers.emplace(std::make_pair(entry.llmq_type, entry.work_block_hash), entry.modifier); + } + for (const auto& data : snapshot.quorums) { + const auto params{chainman.GetParams().GetLLMQ(data.llmq_type)}; + if (!params) return fail("unknown chain LLMQ parameters for modifier"); + const auto check_modifier = [&](const uint256& quorum_hash, const uint256& work_hash) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + const auto seeded{seeded_modifiers.find(std::make_pair(data.llmq_type, work_hash))}; + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(quorum_hash)}; + const CBlockIndex* work_index{chainman.m_blockman.LookupBlockIndex(work_hash)}; + if (seeded == seeded_modifiers.end() || quorum_index == nullptr || work_index == nullptr) return false; + if ((work_index->nStatus & BLOCK_HAVE_DATA) != 0 && + seeded->second != llmq::utils::GetQuorumHashModifier(*params, consensus, quorum_index)) return false; + return true; + }; + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + const CBlockIndex* modifier_base{data.rotation_enabled && quorum_index != nullptr + ? quorum_index->GetAncestor(quorum_index->nHeight - quorum_index->nHeight % params->dkgInterval) + : quorum_index}; + if (modifier_base == nullptr || + !check_modifier(modifier_base->GetBlockHash(), entry.work_block_hash)) { + return fail("evo seeded quorum modifier mismatch"); + } + } + } + for (const auto& entry : data.rotation_snapshots) { + if (!check_modifier(entry.cycle_base_block_hash, entry.work_block_hash)) { + return fail("evo seeded rotation modifier mismatch"); + } + } + } + + if (!MoneyRange(snapshot.credit_pool.locked) || !MoneyRange(snapshot.credit_pool.currentLimit) || + !MoneyRange(snapshot.credit_pool.latelyUnlocked)) return fail("invalid evo credit pool monetary value"); + for (const auto& [bit, height] : snapshot.mnhf_signals) { + if (bit >= VERSIONBITS_NUM_BITS || height < 0 || height > base_index->nHeight) { + return fail("invalid evo MNHF signal bit/height"); + } + } + return true; +} + +bool VerifyEvoSnapshotCbTx(const CEvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error) +{ + error.clear(); + try { + snapshot.Validate(); + } catch (const std::exception& e) { + error = e.what(); + return false; + } + bool mutated{false}; + const uint256 mn_root{snapshot.mn_list.to_sml()->CalcMerkleRoot(&mutated)}; + if (mutated || mn_root != cbtx.merkleRootMNList) { + error = "evo snapshot masternode merkle root mismatch"; + return false; + } + if (cbtx.nVersion >= CCbTx::Version::MERKLE_ROOT_QUORUMS) { + std::vector hashes; + for (const auto& data : snapshot.quorums) { + for (const auto& entry : data.active_commitments) hashes.emplace_back(SerializeHash(entry.commitment)); + } + std::sort(hashes.begin(), hashes.end()); + const uint256 quorum_root{ComputeMerkleRoot(hashes, &mutated)}; + if (mutated || quorum_root != cbtx.merkleRootQuorums) { + error = "evo snapshot quorum merkle root mismatch"; + return false; + } + } + if (cbtx.nVersion >= CCbTx::Version::CLSIG_AND_BALANCE && snapshot.credit_pool.locked != cbtx.creditPoolBalance) { + error = "evo snapshot credit pool balance mismatch"; + return false; + } + return true; +} + +} // namespace evo diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h new file mode 100644 index 000000000000..682d02b56156 --- /dev/null +++ b/src/evo/snapshot.h @@ -0,0 +1,619 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_EVO_SNAPSHOT_H +#define BITCOIN_EVO_SNAPSHOT_H + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class CBlockIndex; +class CChainParams; +class ChainstateManager; +class CCbTx; +class CCreditPoolManager; +class CMNHFManager; + +namespace llmq { +class CQuorumBlockProcessor; +class CQuorumSnapshotManager; +} // namespace llmq + +namespace evo { + +class SnapshotStateMismatchError : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +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}; +static_assert(std::ranges::all_of(Consensus::available_llmqs, [](const auto& params) { + return !params.useRotation || params.keepOldConnections <= 2 * params.signingActiveQuorumCount; +}), "rotated LLMQ retention exceeds the two serialized cycles"); + +template +size_t ReadBoundedCompactSize(Stream& s, size_t limit, const char* field) +{ + const uint64_t size{ReadCompactSize(s)}; + if (size > limit) throw std::ios_base::failure(std::string{"oversized evo snapshot "} + field); + return static_cast(size); +} + +inline const Consensus::LLMQParams& SnapshotLLMQParams(Consensus::LLMQType type) +{ + const auto it{std::ranges::find_if(Consensus::available_llmqs, + [type](const auto& params) { return params.type == type; })}; + if (it == Consensus::available_llmqs.end()) throw std::ios_base::failure("unknown evo snapshot LLMQ type"); + return *it; +} + +inline size_t SnapshotCommitmentCount(const Consensus::LLMQParams& params, bool rotation_enabled) +{ + if (!rotation_enabled) { + return static_cast(std::max(params.signingActiveQuorumCount + 1, params.keepOldConnections)); + } + const size_t active{static_cast(params.signingActiveQuorumCount)}; + const size_t retained{static_cast(params.keepOldConnections)}; + // Rotation seeding promises the active and previous complete cycles. A + // future parameter set retaining more must extend the serialized cycles. + if (retained > 2 * active) throw std::ios_base::failure("rotated LLMQ retention exceeds two cycles"); + return 2 * active; +} + +/** + * Maximum number of distinct historical work-block lists a snapshot can need. + * The serialized set is deduplicated, so summing every enabled-type horizon is + * conservative: two retained commitment cycles plus H-C..H-4C for rotated + * types, or the retained commitment horizon for non-rotated types. + */ +inline size_t EvoSnapshotMaxHistoricalMNLists() +{ + size_t count{0}; + for (const auto& params : Consensus::available_llmqs) { + count += params.useRotation + ? SnapshotCommitmentCount(params, /*rotation_enabled=*/true) + EVO_SNAPSHOT_ROTATION_CYCLES + : SnapshotCommitmentCount(params, /*rotation_enabled=*/false); + } + return count; +} + +/** + * A historical diff covers one required quorum work-block transition. Allow + * 4,096 net add/update/remove operations per transition (already far above + * plausible per-block MN churn), across the entire params-derived horizon. + * This generous cumulative ceiling prevents individually-valid 100k-entry + * diffs from multiplying decode work across every historical entry. + */ +inline size_t EvoSnapshotMaxHistoricalMNOperations() +{ + return EvoSnapshotMaxHistoricalMNLists() * 4'096; +} + +template +class SnapshotBoundedInput +{ +private: + Stream& m_stream; + uint64_t m_compact_budget; + +public: + SnapshotBoundedInput(Stream& stream, uint64_t compact_budget) : + m_stream{stream}, m_compact_budget{compact_budget} {} + + int GetType() const { return m_stream.GetType(); } + int GetVersion() const { return m_stream.GetVersion(); } + void read(Span dst) { m_stream.read(dst); } + void ignore(size_t size) { m_stream.ignore(size); } + + uint64_t ReadBudgetedCompactSize() + { + const uint64_t size{::ReadCompactSize(m_stream)}; + if (size > m_compact_budget) throw std::ios_base::failure("canonical MN nested CompactSize budget exceeded"); + m_compact_budget -= size; + return size; + } + + template + SnapshotBoundedInput& operator>>(T&& obj) + { + ::Unserialize(*this, obj); + return *this; + } +}; + +template +uint64_t ReadCompactSize(SnapshotBoundedInput& stream) +{ + return stream.ReadBudgetedCompactSize(); +} + +/** + * NetInfoEntry overrides the stream version while decoding its payload. Keep + * the snapshot-local CompactSize budget visible through that transparent + * wrapper so strings are rejected before their deserializer resizes them. + */ +template +uint64_t ReadCompactSize(OverrideStream>& stream) +{ + return stream.GetStream().ReadBudgetedCompactSize(); +} + +/** + * Snapshot-local canonical deterministic-MN encoding. + * + * internalId and nTotalRegisteredCount are intentionally retained. They are + * consensus-deterministic for nodes synced from genesis: registrations assign + * internalId in on-chain order and advance the counter identically. Thus a + * from-genesis background validation re-derives the dumper's exact values. + * Entries are sorted by the full proTxHash, never by immer iteration order. + */ +template +void SerializeCanonicalMNList(Stream& s, const CDeterministicMNList& list) +{ + s << list.GetBlockHash() << list.GetHeightForSnapshotCodec() << list.GetTotalRegisteredCount(); + std::vector mns; + mns.reserve(list.GetCounts().total()); + list.ForEachMNShared(/*onlyValid=*/false, [&](const auto& dmn) { mns.emplace_back(dmn); }); + std::sort(mns.begin(), mns.end(), [](const auto& a, const auto& b) { return a->proTxHash < b->proTxHash; }); + WriteCompactSize(s, mns.size()); + for (const auto& dmn : mns) s << *dmn; +} + +template +CDeterministicMNList UnserializeCanonicalMNList(Stream& s) +{ + uint256 block_hash; + int height; + uint32_t total_registered; + s >> block_hash >> height >> total_registered; + if (height < 0) throw std::ios_base::failure("negative canonical MN-list height"); + CDeterministicMNList list{block_hash, height, total_registered}; + const size_t count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "MN count")}; + uint256 previous; + bool have_previous{false}; + uint64_t max_internal_id{0}; + for (size_t i{0}; i < count; ++i) { + SnapshotBoundedInput bounded{s, EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; + auto dmn{std::make_shared(deserialize, bounded)}; + if (dmn->pdmnState->payouts.size() > EVO_SNAPSHOT_MAX_PAYOUT_SHARES) { + throw std::ios_base::failure("oversized canonical MN payout list"); + } + if (dmn->pdmnState->netInfo->Validate() != NetInfoStatus::Success) { + throw std::ios_base::failure("invalid canonical MN network info"); + } + if (have_previous && !(previous < dmn->proTxHash)) { + throw std::ios_base::failure("noncanonical canonical MN-list order"); + } + previous = dmn->proTxHash; + have_previous = true; + max_internal_id = std::max(max_internal_id, dmn->GetInternalId()); + try { + list.AddMN(dmn, /*fBumpTotalCount=*/false); + } catch (const std::exception& e) { + throw std::ios_base::failure(std::string{"invalid canonical MN list: "} + e.what()); + } + } + if (count != 0 && max_internal_id >= total_registered) { + throw std::ios_base::failure("canonical MN-list internalId exceeds registration counter"); + } + return list; +} + +/** Canonical hash shared by snapshot encoding and M3 completion comparison. */ +uint256 CanonicalMNListHash(const CDeterministicMNList& list); + +/** Canonical snapshot-local encoding of a deterministic-MN list diff. */ +template +void SerializeCanonicalMNListDiff(Stream& s, const CDeterministicMNListDiff& diff) +{ + auto added{diff.addedMNs}; + std::sort(added.begin(), added.end(), [](const auto& a, const auto& b) { + return std::make_tuple(a->GetInternalId(), a->proTxHash) < + std::make_tuple(b->GetInternalId(), b->proTxHash); + }); + WriteCompactSize(s, added.size()); + for (const auto& dmn : added) s << *dmn; + + std::vector updated; + updated.reserve(diff.updatedMNs.size()); + for (const auto& [internal_id, _] : diff.updatedMNs) updated.emplace_back(internal_id); + std::sort(updated.begin(), updated.end()); + WriteCompactSize(s, updated.size()); + for (const uint64_t internal_id : updated) { + WriteVarInt(s, internal_id); + s << diff.updatedMNs.at(internal_id); + } + WriteCompactSize(s, diff.removedMns.size()); + for (const uint64_t internal_id : diff.removedMns) { + WriteVarInt(s, internal_id); + } +} + +template +CDeterministicMNListDiff UnserializeCanonicalMNListDiff(Stream& s, size_t& remaining_operations) +{ + CDeterministicMNListDiff diff; + const size_t added_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "MN-diff additions")}; + if (added_count > remaining_operations) throw std::ios_base::failure("historical MN-diff operation budget exceeded"); + remaining_operations -= added_count; + uint64_t previous_id{0}; + bool have_previous{false}; + diff.addedMNs.reserve(added_count); + for (size_t i{0}; i < added_count; ++i) { + SnapshotBoundedInput bounded{s, EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; + auto dmn{std::make_shared(deserialize, bounded)}; + if ((have_previous && previous_id >= dmn->GetInternalId()) || + dmn->pdmnState->payouts.size() > EVO_SNAPSHOT_MAX_PAYOUT_SHARES || + dmn->pdmnState->netInfo->Validate() != NetInfoStatus::Success) { + throw std::ios_base::failure("noncanonical canonical MN-diff addition"); + } + previous_id = dmn->GetInternalId(); + have_previous = true; + diff.addedMNs.emplace_back(std::move(dmn)); + } + + const size_t updated_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "MN-diff updates")}; + if (updated_count > remaining_operations) throw std::ios_base::failure("historical MN-diff operation budget exceeded"); + remaining_operations -= updated_count; + previous_id = 0; + have_previous = false; + for (size_t i{0}; i < updated_count; ++i) { + const uint64_t internal_id{ReadVarInt(s)}; + if (have_previous && previous_id >= internal_id) { + throw std::ios_base::failure("noncanonical canonical MN-diff update order"); + } + SnapshotBoundedInput bounded{s, EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; + diff.updatedMNs.emplace(internal_id, CDeterministicMNStateDiff(deserialize, bounded)); + previous_id = internal_id; + have_previous = true; + } + + const size_t removed_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "MN-diff removals")}; + if (removed_count > remaining_operations) throw std::ios_base::failure("historical MN-diff operation budget exceeded"); + remaining_operations -= removed_count; + previous_id = 0; + have_previous = false; + for (size_t i{0}; i < removed_count; ++i) { + const uint64_t internal_id{ReadVarInt(s)}; + if (have_previous && previous_id >= internal_id) { + throw std::ios_base::failure("noncanonical canonical MN-diff removal order"); + } + diff.removedMns.emplace(internal_id); + previous_id = internal_id; + have_previous = true; + } + return diff; +} + +template +CDeterministicMNListDiff UnserializeCanonicalMNListDiff(Stream& s) +{ + size_t remaining_operations{EvoSnapshotMaxHistoricalMNOperations()}; + return UnserializeCanonicalMNListDiff(s, remaining_operations); +} + +struct CMinedQuorumCommitment { + uint256 quorum_base_block_hash; + uint256 work_block_hash; + llmq::CFinalCommitment commitment; + uint256 mined_block_hash; + + SERIALIZE_METHODS(CMinedQuorumCommitment, obj) + { + READWRITE(obj.quorum_base_block_hash, obj.work_block_hash, obj.commitment, obj.mined_block_hash); + } +}; + +template +CMinedQuorumCommitment ReadMinedQuorumCommitment(Stream& s, const Consensus::LLMQParams& params) +{ + CMinedQuorumCommitment entry; + auto& commitment{entry.commitment}; + s >> entry.quorum_base_block_hash >> entry.work_block_hash >> commitment.nVersion >> commitment.llmqType >> commitment.quorumHash; + const bool indexed{commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION || + commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION}; + if (indexed) s >> commitment.quorumIndex; + const size_t signers_size{ReadBoundedCompactSize(s, params.size, "commitment signers")}; + if (signers_size != static_cast(params.size)) { + throw std::ios_base::failure("invalid evo snapshot commitment signers size"); + } + ReadFixedBitSet(s, commitment.signers, signers_size); + const size_t valid_members_size{ReadBoundedCompactSize(s, params.size, "commitment valid members")}; + if (valid_members_size != static_cast(params.size)) { + throw std::ios_base::failure("invalid evo snapshot commitment valid-members size"); + } + ReadFixedBitSet(s, commitment.validMembers, valid_members_size); + const bool legacy{commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION || + commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION}; + s >> CBLSPublicKeyVersionWrapper(commitment.quorumPublicKey, legacy) >> commitment.quorumVvecHash >> + CBLSSignatureVersionWrapper(commitment.quorumSig, legacy) >> + CBLSSignatureVersionWrapper(commitment.membersSig, legacy); + // The consensus/P2P serializer remains unchanged; this snapshot-local path + // bounds both bitsets before allocation and verifies the decoded object. + if (!entry.commitment.VerifySizes(params)) { + throw std::ios_base::failure("invalid evo snapshot commitment bitset size"); + } + s >> entry.mined_block_hash; + return entry; +} + +struct CQuorumSnapshotEntry { + uint256 cycle_base_block_hash; + uint256 work_block_hash; + llmq::CQuorumSnapshot snapshot; +}; + +struct CHistoricalMNListDiff { + uint256 previous_block_hash; + uint256 block_hash; + int height{-1}; + uint32_t total_registered_count{0}; + uint256 canonical_list_hash; + CDeterministicMNListDiff diff; +}; + +struct CQuorumModifier { + Consensus::LLMQType llmq_type{Consensus::LLMQType::LLMQ_NONE}; + uint256 work_block_hash; + uint256 modifier; + + SERIALIZE_METHODS(CQuorumModifier, obj) + { + READWRITE(obj.llmq_type, obj.work_block_hash, obj.modifier); + } +}; + +struct CQuorumSnapshotData { + Consensus::LLMQType llmq_type{Consensus::LLMQType::LLMQ_NONE}; + bool rotation_enabled{false}; + std::vector active_commitments; + std::vector safety_commitments; + std::vector rotation_snapshots; + + template void Serialize(Stream& s) const; + template void Unserialize(Stream& s); +}; + +/** Canonical Dash-derived state attached to an assumeutxo snapshot. */ +class CEvoSnapshot +{ +public: + uint16_t version{EVO_SNAPSHOT_VERSION}; + uint256 base_block_hash; + CDeterministicMNList mn_list; + std::vector quorums; + std::vector historical_mn_list_diffs; + std::vector quorum_modifiers; + CCreditPool credit_pool; + AbstractEHFManager::Signals mnhf_signals; + + template void Serialize(Stream& s) const; + template void Unserialize(Stream& s); + + /** Validate invariants not requiring chainstate or block-index lookup. */ + void Validate(bool require_canonical_order = false) const; +}; + +template +void WriteSnapshotVector(Stream& s, const std::vector& values, WriteOne&& write_one) +{ + WriteCompactSize(s, values.size()); + for (const auto& value : values) write_one(value); +} + +template +void WriteRotationSnapshot(Stream& s, const CQuorumSnapshotEntry& entry) +{ + s << entry.cycle_base_block_hash << entry.work_block_hash << entry.snapshot.mnSkipListMode; + WriteCompactSize(s, entry.snapshot.activeQuorumMembers.size()); + WriteFixedBitSet(s, entry.snapshot.activeQuorumMembers, entry.snapshot.activeQuorumMembers.size()); + s << entry.snapshot.mnSkipList; +} + +template +CQuorumSnapshotEntry ReadRotationSnapshot(Stream& s, const Consensus::LLMQParams& params) +{ + CQuorumSnapshotEntry entry; + s >> entry.cycle_base_block_hash >> entry.work_block_hash >> entry.snapshot.mnSkipListMode; + // BuildQuorumSnapshot sizes this bitset to the complete work-block MN list, + // not to the quorum size. The exact historical-list size is chain-aware and + // is checked by ValidateEvoSnapshotAgainstChain. + const size_t bit_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "rotation bitset")}; + ReadFixedBitSet(s, entry.snapshot.activeQuorumMembers, bit_count); + const size_t skip_count{ReadBoundedCompactSize(s, params.size, "rotation skip list")}; + entry.snapshot.mnSkipList.reserve(skip_count); + for (size_t i{0}; i < skip_count; ++i) { + int value; + s >> value; + entry.snapshot.mnSkipList.emplace_back(value); + } + return entry; +} + +template +void CQuorumSnapshotData::Serialize(Stream& s) const +{ + auto active{active_commitments}; + auto safety{safety_commitments}; + auto snapshots{rotation_snapshots}; + const auto commitment_less = [](const auto& a, const auto& b) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + }; + std::sort(active.begin(), active.end(), commitment_less); + std::sort(safety.begin(), safety.end(), commitment_less); + std::sort(snapshots.begin(), snapshots.end(), + [](const auto& a, const auto& b) { return a.cycle_base_block_hash < b.cycle_base_block_hash; }); + s << llmq_type << rotation_enabled << active << safety; + WriteSnapshotVector(s, snapshots, [&](const auto& entry) { WriteRotationSnapshot(s, entry); }); +} + +template +void CQuorumSnapshotData::Unserialize(Stream& s) +{ + s >> llmq_type >> rotation_enabled; + const auto& params{SnapshotLLMQParams(llmq_type)}; + const size_t total_count{SnapshotCommitmentCount(params, rotation_enabled)}; + const size_t expected_active{static_cast(params.signingActiveQuorumCount)}; + const size_t active_count{ReadBoundedCompactSize(s, expected_active, "active commitments")}; + active_commitments.reserve(active_count); + for (size_t i{0}; i < active_count; ++i) { + active_commitments.emplace_back(ReadMinedQuorumCommitment(s, params)); + } + const size_t safety_count{ReadBoundedCompactSize(s, total_count - expected_active, "safety commitments")}; + safety_commitments.reserve(safety_count); + for (size_t i{0}; i < safety_count; ++i) { + safety_commitments.emplace_back(ReadMinedQuorumCommitment(s, params)); + } + const size_t snapshot_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_ROTATION_CYCLES, "rotation snapshots")}; + rotation_snapshots.reserve(snapshot_count); + for (size_t i{0}; i < snapshot_count; ++i) rotation_snapshots.emplace_back(ReadRotationSnapshot(s, params)); +} + +template +void CEvoSnapshot::Serialize(Stream& s) const +{ + auto sorted_quorums{quorums}; + auto sorted_history{historical_mn_list_diffs}; + auto sorted_modifiers{quorum_modifiers}; + std::sort(sorted_quorums.begin(), sorted_quorums.end(), + [](const auto& a, const auto& b) { return a.llmq_type < b.llmq_type; }); + std::sort(sorted_history.begin(), sorted_history.end(), [](const auto& a, const auto& b) { + return std::tie(a.height, a.block_hash) > std::tie(b.height, b.block_hash); + }); + std::sort(sorted_modifiers.begin(), sorted_modifiers.end(), [](const auto& a, const auto& b) { + return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); + }); + s << version << base_block_hash; + SerializeCanonicalMNList(s, mn_list); + s << sorted_quorums; + WriteCompactSize(s, sorted_history.size()); + for (const auto& entry : sorted_history) { + s << entry.previous_block_hash << entry.block_hash << entry.height << entry.total_registered_count << entry.canonical_list_hash; + SerializeCanonicalMNListDiff(s, entry.diff); + } + s << sorted_modifiers; + s << credit_pool; + WriteCompactSize(s, mnhf_signals.size()); + for (const auto& signal : mnhf_signals) s << signal; +} + +template +void CEvoSnapshot::Unserialize(Stream& s) +{ + s >> version; + if (version != EVO_SNAPSHOT_VERSION) throw std::ios_base::failure("unsupported evo snapshot version"); + s >> base_block_hash; + mn_list = UnserializeCanonicalMNList(s); + const size_t quorum_count{ReadBoundedCompactSize(s, Consensus::available_llmqs.size(), "quorum-type count")}; + quorums.reserve(quorum_count); + for (size_t i{0}; i < quorum_count; ++i) { + CQuorumSnapshotData data; + s >> data; + quorums.emplace_back(std::move(data)); + } + const size_t history_count{ReadBoundedCompactSize(s, EvoSnapshotMaxHistoricalMNLists(), + "historical MN-list count")}; + historical_mn_list_diffs.reserve(history_count); + size_t remaining_history_operations{EvoSnapshotMaxHistoricalMNOperations()}; + for (size_t i{0}; i < history_count; ++i) { + CHistoricalMNListDiff entry; + s >> entry.previous_block_hash >> entry.block_hash >> entry.height >> entry.total_registered_count >> entry.canonical_list_hash; + entry.diff = UnserializeCanonicalMNListDiff(s, remaining_history_operations); + historical_mn_list_diffs.emplace_back(std::move(entry)); + } + const size_t modifier_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MODIFIERS, "quorum modifier count")}; + quorum_modifiers.reserve(modifier_count); + for (size_t i{0}; i < modifier_count; ++i) { + CQuorumModifier modifier; + s >> modifier; + quorum_modifiers.emplace_back(std::move(modifier)); + } + s >> credit_pool.locked >> credit_pool.currentLimit >> credit_pool.latelyUnlocked; + credit_pool.indexes.UnserializeBounded(s, EVO_SNAPSHOT_MAX_RANGES); + const size_t signal_count{ReadBoundedCompactSize(s, Consensus::MAX_VERSION_BITS_DEPLOYMENTS, "MNHF signals")}; + for (size_t i{0}; i < signal_count; ++i) { + std::pair signal; + s >> signal; + if (!mnhf_signals.emplace(signal).second) throw std::ios_base::failure("duplicate MNHF signal bit"); + } + Validate(/*require_canonical_order=*/true); +} + +/** Single SHA256 of the canonical SER_DISK/CLIENT_VERSION encoding. */ +uint256 GetEvoSnapshotHash(const CEvoSnapshot& snapshot); + +bool BuildEvoSnapshot(const CChainParams& chainparams, const ChainstateManager& chainman, + CDeterministicMNManager& dmnman, + const llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, + CCreditPoolManager& cpoolman, CMNHFManager& mnhfman, const CBlockIndex* base_index, + CEvoSnapshot& snapshot, std::string& error) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + +struct CQuorumReconstructionHeight { + Consensus::LLMQType llmq_type; + bool rotation; + 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); + +/** Validate all snapshot invariants requiring the block index or deployments. */ +bool ValidateEvoSnapshotAgainstChain(const CEvoSnapshot& snapshot, const ChainstateManager& chainman, + const CBlockIndex* base_index, std::string& error) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + +/** Pure CbTx checks over already-built snapshot content. */ +bool VerifyEvoSnapshotCbTx(const CEvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error); + +} // namespace evo + +#endif // BITCOIN_EVO_SNAPSHOT_H diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index 73c08df28492..668691cdc717 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -787,7 +788,10 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const LogPrint(BCLog::BENCHMARK, " - m_qblockman.ProcessBlock: %.2fms [%.2fs]\n", 0.001 * (nTime5 - nTime4), nTimeQuorum * 0.000001); - CDeterministicMNList mn_list; + // Even before DIP3, bind the canonical empty list to the block so the + // independently derived completion hash has the same identity as an + // empty evo snapshot section. + CDeterministicMNList mn_list{pindex->GetBlockHash(), pindex->nHeight, 0}; if (DeploymentActiveAt(*pindex, m_consensus_params, Consensus::DEPLOYMENT_DIP0003)) { if (!BuildNewListFromBlock(block, pindex->pprev, view, true, state, mn_list)) { // pass the state returned by the function above diff --git a/src/llmq/snapshot.cpp b/src/llmq/snapshot.cpp index 51d3a44007b8..661777127c5a 100644 --- a/src/llmq/snapshot.cpp +++ b/src/llmq/snapshot.cpp @@ -317,10 +317,42 @@ void CQuorumSnapshotManager::StoreSnapshotForBlock(const Consensus::LLMQType llm { auto snapshotHash = ::SerializeHash(std::make_pair(llmqType, pindex->GetBlockHash())); - // LOCK(::cs_main); - AssertLockNotHeld(m_evoDb.cs); - LOCK2(snapshotCacheCs, m_evoDb.cs); - m_evoDb.GetRawDB().Write(std::make_pair(DB_QUORUM_SNAPSHOT, snapshotHash), snapshot); + if (!m_evoDb.WriteDerived(std::make_pair(DB_QUORUM_SNAPSHOT, snapshotHash), snapshot)) { + throw std::runtime_error("EvoDB quorum snapshot payload mismatch"); + } + LOCK(snapshotCacheCs); quorumSnapshotCache.insert(snapshotHash, snapshot); } + +bool CQuorumSnapshotManager::SeedSnapshotForBlock(const Consensus::LLMQType llmqType, const CBlockIndex* pindex, + const CQuorumSnapshot& snapshot) +{ + const auto snapshot_hash = ::SerializeHash(std::make_pair(llmqType, pindex->GetBlockHash())); + return m_evoDb.WriteDerived(std::make_pair(DB_QUORUM_SNAPSHOT, snapshot_hash), snapshot); +} + +bool CQuorumSnapshotManager::SeedQuorumModifier(Consensus::LLMQType llmq_type, + const uint256& work_block_hash, + const uint256& modifier) +{ + return m_evoDb.WriteDerived(std::make_tuple(std::string_view{"llmq_M3"}, llmq_type, work_block_hash), modifier); +} + +std::optional CQuorumSnapshotManager::GetSeededQuorumModifier( + Consensus::LLMQType llmq_type, const uint256& work_block_hash) const +{ + uint256 modifier; + if (!m_evoDb.Read(std::make_tuple(std::string_view{"llmq_M3"}, llmq_type, work_block_hash), modifier)) { + return std::nullopt; + } + return modifier; +} + +void CQuorumSnapshotManager::InvalidateSnapshotCacheForBlock(Consensus::LLMQType llmq_type, + const uint256& block_hash) +{ + const auto snapshot_hash{::SerializeHash(std::make_pair(llmq_type, block_hash))}; + LOCK(snapshotCacheCs); + quorumSnapshotCache.erase(snapshot_hash); +} } // namespace llmq diff --git a/src/llmq/snapshot.h b/src/llmq/snapshot.h index 7691dbf19286..b66ccbb6ed88 100644 --- a/src/llmq/snapshot.h +++ b/src/llmq/snapshot.h @@ -237,6 +237,15 @@ class CQuorumSnapshotManager std::optional GetSnapshotForBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex); void StoreSnapshotForBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex, const CQuorumSnapshot& snapshot); + /** Seed EvoDB without publishing state to the shared NORMAL-chainstate cache. */ + bool SeedSnapshotForBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex, + const CQuorumSnapshot& snapshot); + /** Seed/read the exact v20 score modifier keyed by type and work block. */ + bool SeedQuorumModifier(Consensus::LLMQType llmq_type, const uint256& work_block_hash, + const uint256& modifier); + std::optional GetSeededQuorumModifier(Consensus::LLMQType llmq_type, + const uint256& work_block_hash) const; + void InvalidateSnapshotCacheForBlock(Consensus::LLMQType llmq_type, const uint256& block_hash); }; } // namespace llmq diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index be172e2ee813..fee8ecefa687 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -22,6 +23,7 @@ #include #include #include +#include /** * Forward declarations @@ -95,8 +97,8 @@ uint256 GetHashModifierFromWorkBlock(const Consensus::LLMQParams& llmqParams, co return ::SerializeHash(std::make_pair(llmqParams.type, pWorkBlockIndex->GetBlockHash())); } -uint256 GetHashModifier(const Consensus::LLMQParams& llmqParams, const Consensus::Params& consensus_params, - gsl::not_null pCycleQuorumBaseBlockIndex) +uint256 CalculateHashModifier(const Consensus::LLMQParams& llmqParams, const Consensus::Params& consensus_params, + gsl::not_null pCycleQuorumBaseBlockIndex) { ASSERT_IF_DEBUG(pCycleQuorumBaseBlockIndex->nHeight % llmqParams.dkgInterval == 0); const CBlockIndex* pWorkBlockIndex = pCycleQuorumBaseBlockIndex->GetAncestor(pCycleQuorumBaseBlockIndex->nHeight - llmq::WORK_DIFF_DEPTH); @@ -113,6 +115,22 @@ uint256 GetHashModifier(const Consensus::LLMQParams& llmqParams, const Consensus return ::SerializeHash(std::make_pair(llmqParams.type, pCycleQuorumBaseBlockIndex->GetBlockHash())); } +uint256 GetHashModifier(const Consensus::LLMQParams& llmq_params, const Consensus::Params& consensus_params, + gsl::not_null cycle_index, + const llmq::CQuorumSnapshotManager* snapshot_manager) +{ + const CBlockIndex* work_index{cycle_index->GetAncestor(cycle_index->nHeight - llmq::WORK_DIFF_DEPTH)}; + if (snapshot_manager != nullptr && work_index != nullptr) { + if (const auto seeded{snapshot_manager->GetSeededQuorumModifier(llmq_params.type, work_index->GetBlockHash())}) { + if (WITH_LOCK(::cs_main, return (work_index->nStatus & BLOCK_HAVE_DATA) == 0;)) return *seeded; + const uint256 recomputed{CalculateHashModifier(llmq_params, consensus_params, cycle_index)}; + if (recomputed != *seeded) throw evo::SnapshotStateMismatchError("seeded quorum score modifier mismatch"); + return recomputed; + } + } + return CalculateHashModifier(llmq_params, consensus_params, cycle_index); +} + std::vector CalculateScoresForQuorum(QuorumMembers&& dmns, const uint256& modifier, const bool onlyEvoNodes) { std::vector scores; @@ -186,6 +204,7 @@ QuorumMembers CalculateQuorum(List&& mn_list, const uint256& modifier, size_t ma std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LLMQParams& llmqParams, CDeterministicMNManager& dmnman, + const llmq::CQuorumSnapshotManager& qsnapman, const Consensus::Params& consensus_params, const CBlockIndex* pCycleQuorumBaseBlockIndex, const llmq::CQuorumSnapshot& snapshot, int nHeight) @@ -200,7 +219,7 @@ std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LL const CBlockIndex* pWorkBlockIndex = pCycleQuorumBaseBlockIndex->GetAncestor( pCycleQuorumBaseBlockIndex->nHeight - llmq::WORK_DIFF_DEPTH); auto mn_list = dmnman.GetListForBlock(pWorkBlockIndex); - const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex); + const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex, &qsnapman); auto sortedAllMns = CalculateQuorum(mn_list, modifier); std::vector usedMNs; @@ -288,7 +307,8 @@ std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LL } QuorumMembers ComputeQuorumMembers(Consensus::LLMQType llmqType, const CChainParams& chainparams, - const CDeterministicMNList& mn_list, const CBlockIndex* pQuorumBaseBlockIndex) + const CDeterministicMNList& mn_list, const CBlockIndex* pQuorumBaseBlockIndex, + const llmq::CQuorumSnapshotManager* qsnapman) { bool EvoOnly = (chainparams.GetConsensus().llmqTypePlatform == llmqType) && DeploymentActiveAfter(pQuorumBaseBlockIndex, chainparams.GetConsensus(), Consensus::DEPLOYMENT_V19); @@ -299,7 +319,8 @@ QuorumMembers ComputeQuorumMembers(Consensus::LLMQType llmqType, const CChainPar return {}; } - const auto modifier = GetHashModifier(llmq_params_opt.value(), chainparams.GetConsensus(), pQuorumBaseBlockIndex); + const auto modifier = GetHashModifier(llmq_params_opt.value(), chainparams.GetConsensus(), pQuorumBaseBlockIndex, + qsnapman); return CalculateQuorum(mn_list, modifier, llmq_params_opt->size, EvoOnly); } @@ -315,7 +336,7 @@ void BuildQuorumSnapshot(const Consensus::LLMQParams& llmqParams, const Consensu const auto allMnsTotal = allMns.GetCounts().total(); quorumSnapshot.activeQuorumMembers.resize(allMnsTotal); - const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex); + const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex, nullptr); auto sortedAllMns = CalculateQuorum(allMns, modifier); LogPrint(BCLog::LLMQ, "BuildQuorumSnapshot h[%d] numMns[%d]\n", pCycleQuorumBaseBlockIndex->nHeight, @@ -357,6 +378,8 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar size_t quorumSize = static_cast(llmqParams.size); auto quarterSize{quorumSize / 4}; + const auto modifier = GetHashModifier(llmqParams, util_params.m_chainman.GetConsensus(), util_params.m_base_index, + &util_params.m_qsnapman); if (allMns.GetCounts().enabled() < quarterSize) { return quarterQuorumMembers; @@ -502,6 +525,7 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus break; } prev_cycles[idx]->m_members = GetQuorumQuarterMembersBySnapshot(llmqParams, util_params.m_dmnman, + util_params.m_qsnapman, util_params.m_chainman.GetConsensus(), prev_cycles[idx]->m_cycle_index, prev_cycles[idx]->m_snap, @@ -545,6 +569,13 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus namespace llmq { namespace utils { +uint256 GetQuorumHashModifier(const Consensus::LLMQParams& llmq_params, + const Consensus::Params& consensus_params, + gsl::not_null cycle_quorum_base_index) +{ + return CalculateHashModifier(llmq_params, consensus_params, cycle_quorum_base_index); +} + BlsCheck::BlsCheck() = default; BlsCheck::BlsCheck(CBLSSignature sig, std::vector pubkeys, uint256 msg_hash, std::string id_string) : @@ -720,7 +751,7 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame : util_params.m_base_index.get(); CDeterministicMNList mn_list = util_params.m_dmnman.GetListForBlock(pWorkBlockIndex); quorumMembers = ComputeQuorumMembers(llmqType, util_params.m_chainman.GetParams(), mn_list, - util_params.m_base_index); + util_params.m_base_index, &util_params.m_qsnapman); } LOCK(cs_members); diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 01ab95316117..4082066c7914 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -43,6 +43,11 @@ struct UtilParameters { }; namespace utils { +/** Normal consensus modifier calculation; snapshot overrides are internal to reconstruction. */ +uint256 GetQuorumHashModifier(const Consensus::LLMQParams& llmq_params, + const Consensus::Params& consensus_params, + gsl::not_null cycle_quorum_base_index); + struct BlsCheck { CBLSSignature m_sig; std::vector m_pubkeys; diff --git a/src/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 b9f30eb1081a..adff9e874be8 100644 --- a/src/test/evo_netinfo_tests.cpp +++ b/src/test/evo_netinfo_tests.cpp @@ -646,4 +646,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..08f787ab971e --- /dev/null +++ b/src/test/evo_snapshot_tests.cpp @@ -0,0 +1,1189 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace { + +uint256 H(uint8_t value) +{ + uint256 hash; + hash.begin()[0] = value; + return hash; +} + +uint256 CollidingH(uint8_t suffix) +{ + uint256 hash; + std::fill_n(hash.begin(), 8, 0xa5); + hash.begin()[8] = suffix; + return hash; +} + +uint160 H160(uint8_t value) +{ + uint160 hash; + hash.begin()[0] = value; + return hash; +} + +CDeterministicMNCPtr MN(uint64_t internal_id, uint8_t hash_suffix, MnType type, int version, uint8_t address_tag) +{ + auto state{std::make_shared()}; + state->nVersion = version; + state->nRegisteredHeight = 10 + internal_id; + state->nLastPaidHeight = 20 + internal_id; + state->nPoSePenalty = internal_id; + state->keyIDOwner = CKeyID{H160(address_tag)}; + state->keyIDVoting = CKeyID{H160(address_tag + 20)}; + state->scriptPayout = CScript{} << OP_RETURN << std::vector{address_tag, 1}; + state->scriptOperatorPayout = CScript{} << OP_RETURN << std::vector{address_tag, 2}; + state->netInfo = NetInfoInterface::MakeNetInfo(version); + BOOST_REQUIRE_EQUAL(state->netInfo->AddEntry(NetInfoPurpose::CORE_P2P, + strprintf("1.1.1.%d:%d", address_tag, Params().GetDefaultPort())), + NetInfoStatus::Success); + if (type == MnType::Evo) { + state->platformNodeID = H160(address_tag + 40); + BOOST_REQUIRE_EQUAL(state->netInfo->AddEntry(NetInfoPurpose::PLATFORM_P2P, + strprintf("2.2.2.%d:26657", address_tag)), + NetInfoStatus::Success); + BOOST_REQUIRE_EQUAL(state->netInfo->AddEntry(NetInfoPurpose::PLATFORM_HTTPS, + strprintf("evo%d.example.org:443", address_tag)), + NetInfoStatus::Success); + } + + auto dmn{std::make_shared(internal_id, type)}; + dmn->proTxHash = CollidingH(hash_suffix); + dmn->collateralOutpoint = COutPoint(H(address_tag + 80), internal_id); + dmn->nOperatorReward = address_tag * 10; + state->UpdateConfirmedHash(dmn->proTxHash, H(address_tag + 100)); + dmn->pdmnState = std::move(state); + return dmn; +} + +CDeterministicMNList MNList(const uint256& block_hash, int height, bool reverse) +{ + CDeterministicMNList list{block_hash, height, 10}; + std::vector mns{ + MN(2, 3, MnType::Regular, ProTxVersion::LegacyBLS, 3), + MN(5, 1, MnType::Evo, ProTxVersion::ExtAddr, 5), + MN(7, 2, MnType::Regular, ProTxVersion::LegacyBLS, 7), + }; + if (reverse) std::reverse(mns.begin(), mns.end()); + for (const auto& dmn : mns) list.AddMN(dmn, /*fBumpTotalCount=*/false); + return list; +} + +evo::CMinedQuorumCommitment Commitment(Consensus::LLMQType type, uint8_t quorum, uint8_t mined, bool rotated, + int16_t index = 0) +{ + llmq::CFinalCommitment commitment; + commitment.nVersion = rotated ? llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION + : llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION; + commitment.llmqType = type; + commitment.quorumHash = H(quorum); + commitment.quorumIndex = index; + const auto& params{evo::SnapshotLLMQParams(type)}; + commitment.signers.resize(params.size); + commitment.validMembers.resize(params.size); + return {H(quorum), H(quorum + 120), std::move(commitment), H(mined)}; +} + +evo::CEvoSnapshot SyntheticSnapshot(bool reverse_representation = false) +{ + evo::CEvoSnapshot snapshot; + snapshot.base_block_hash = H(42); + snapshot.mn_list = MNList(snapshot.base_block_hash, 500, reverse_representation); + snapshot.credit_pool.locked = 123456; + snapshot.credit_pool.currentLimit = 700; + snapshot.credit_pool.latelyUnlocked = 11; + if (reverse_representation) { + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(15)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(8)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(7)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(9)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Remove(9)); + snapshot.mnhf_signals.emplace(9, 30); + snapshot.mnhf_signals.emplace(2, 12); + } else { + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(7)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(8)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(15)); + snapshot.mnhf_signals.emplace(2, 12); + snapshot.mnhf_signals.emplace(9, 30); + } + + evo::CQuorumSnapshotData plain; + plain.llmq_type = Consensus::LLMQType::LLMQ_TEST; + plain.active_commitments = {Commitment(plain.llmq_type, 11, 51, false), Commitment(plain.llmq_type, 12, 52, false)}; + plain.safety_commitments = {Commitment(plain.llmq_type, 10, 50, false)}; + + evo::CQuorumSnapshotData rotated; + rotated.llmq_type = Consensus::LLMQType::LLMQ_TEST_DIP0024; + rotated.rotation_enabled = true; + rotated.active_commitments = {Commitment(rotated.llmq_type, 31, 71, true, 0), + Commitment(rotated.llmq_type, 32, 72, true, 1)}; + rotated.safety_commitments = {Commitment(rotated.llmq_type, 21, 61, true, 0), + Commitment(rotated.llmq_type, 22, 62, true, 1)}; + for (uint8_t i{1}; i <= evo::EVO_SNAPSHOT_ROTATION_CYCLES; ++i) { + const auto mode{i == 2 ? SnapshotSkipMode::MODE_SKIPPING_ENTRIES : SnapshotSkipMode::MODE_NO_SKIPPING}; + rotated.rotation_snapshots.push_back( + {H(40 + i), H(100 + i), llmq::CQuorumSnapshot{{true, false, true, false}, mode, i == 2 ? std::vector{1} : std::vector{}}}); + } + + snapshot.quorums = {std::move(plain), std::move(rotated)}; + std::set work_hashes; + std::set> modifier_keys; + for (const auto& data : snapshot.quorums) { + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + work_hashes.insert(entry.work_block_hash); + modifier_keys.emplace(data.llmq_type, entry.work_block_hash); + } + } + for (const auto& entry : data.rotation_snapshots) { + work_hashes.insert(entry.work_block_hash); + modifier_keys.emplace(data.llmq_type, entry.work_block_hash); + } + } + CDeterministicMNList previous{snapshot.mn_list}; + uint256 previous_hash{snapshot.base_block_hash}; + int height{499}; + for (const auto& work_hash : work_hashes) { + auto list{MNList(work_hash, height--, reverse_representation)}; + snapshot.historical_mn_list_diffs.push_back({previous_hash, work_hash, list.GetHeightForSnapshotCodec(), + list.GetTotalRegisteredCount(), evo::CanonicalMNListHash(list), + previous.BuildDiff(list)}); + previous_hash = work_hash; + previous = std::move(list); + } + for (const auto& [type, work_hash] : modifier_keys) { + snapshot.quorum_modifiers.push_back({type, work_hash, H(static_cast(150 + snapshot.quorum_modifiers.size()))}); + } + if (reverse_representation) { + std::reverse(snapshot.quorums.begin(), snapshot.quorums.end()); + std::reverse(snapshot.historical_mn_list_diffs.begin(), snapshot.historical_mn_list_diffs.end()); + std::reverse(snapshot.quorum_modifiers.begin(), snapshot.quorum_modifiers.end()); + for (auto& data : snapshot.quorums) { + std::reverse(data.active_commitments.begin(), data.active_commitments.end()); + std::reverse(data.safety_commitments.begin(), data.safety_commitments.end()); + std::reverse(data.rotation_snapshots.begin(), data.rotation_snapshots.end()); + } + } + return snapshot; +} + +CDataStream SerializeSnapshot(const evo::CEvoSnapshot& snapshot) +{ + CDataStream stream{SER_DISK, CLIENT_VERSION}; + stream << snapshot; + return stream; +} + +void CheckInvalid(evo::CEvoSnapshot snapshot) +{ + BOOST_CHECK_THROW(snapshot.Validate(), std::ios_base::failure); +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(evo_snapshot_tests) + +BOOST_FIXTURE_TEST_CASE(populated_roundtrip_and_representation_independence, BasicTestingSetup) +{ + const auto forward{SyntheticSnapshot()}; + const auto reverse{SyntheticSnapshot(/*reverse_representation=*/true)}; + const auto forward_bytes{SerializeSnapshot(forward)}; + const auto reverse_bytes{SerializeSnapshot(reverse)}; + BOOST_CHECK_EQUAL_COLLECTIONS(forward_bytes.begin(), forward_bytes.end(), reverse_bytes.begin(), reverse_bytes.end()); + BOOST_CHECK(evo::CanonicalMNListHash(forward.mn_list) == evo::CanonicalMNListHash(reverse.mn_list)); + BOOST_CHECK(GetEvoSnapshotHash(forward) == GetEvoSnapshotHash(reverse)); + + CDataStream input{forward_bytes}; + evo::CEvoSnapshot decoded; + input >> decoded; + BOOST_CHECK(input.empty()); + const auto decoded_bytes{SerializeSnapshot(decoded)}; + BOOST_CHECK_EQUAL_COLLECTIONS(forward_bytes.begin(), forward_bytes.end(), decoded_bytes.begin(), decoded_bytes.end()); + BOOST_CHECK(evo::CanonicalMNListHash(decoded.mn_list) == evo::CanonicalMNListHash(forward.mn_list)); + BOOST_CHECK_EQUAL(decoded.mn_list.GetCounts().total(), 3U); + BOOST_CHECK_EQUAL(decoded.historical_mn_list_diffs.size(), forward.historical_mn_list_diffs.size()); + BOOST_CHECK(decoded.credit_pool.indexes.Contains(7)); + BOOST_CHECK(decoded.credit_pool.indexes.Contains(8)); + BOOST_CHECK(decoded.credit_pool.indexes.Contains(15)); + BOOST_CHECK(decoded.mnhf_signals == forward.mnhf_signals); + + for (const auto internal_id : {2U, 5U, 7U}) { + const auto original{forward.mn_list.GetMNByInternalId(internal_id)}; + BOOST_REQUIRE(original); + const auto by_hash{decoded.mn_list.GetMN(original->proTxHash)}; + const auto by_id{decoded.mn_list.GetMNByInternalId(internal_id)}; + const auto by_collateral{decoded.mn_list.GetUniquePropertyMN(original->collateralOutpoint)}; + const auto by_owner{decoded.mn_list.GetUniquePropertyMN(original->pdmnState->keyIDOwner)}; + const auto by_service{decoded.mn_list.GetMNByService(original->pdmnState->netInfo->GetPrimary())}; + BOOST_REQUIRE(by_hash); + BOOST_REQUIRE(by_id); + BOOST_REQUIRE(by_collateral); + BOOST_REQUIRE(by_owner); + BOOST_REQUIRE(by_service); + BOOST_CHECK(by_hash->proTxHash == original->proTxHash); + BOOST_CHECK(by_id->proTxHash == original->proTxHash); + BOOST_CHECK(by_collateral->proTxHash == original->proTxHash); + BOOST_CHECK(by_owner->proTxHash == original->proTxHash); + BOOST_CHECK(by_service->proTxHash == original->proTxHash); + } +} + +BOOST_FIXTURE_TEST_CASE(snapshot_identity_seeding_is_retrievable, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + const auto list{MNList(base->GetBlockHash(), base->nHeight, false)}; + const CBlockIndex* historical_index{base->GetAncestor(50)}; + const auto historical_list{MNList(historical_index->GetBlockHash(), historical_index->nHeight, true)}; + const auto indexed_commitment = [&](Consensus::LLMQType type, int quorum_height, int mined_height, + bool rotated, int16_t quorum_index = 0) { + auto entry{Commitment(type, 1, 2, rotated, quorum_index)}; + entry.quorum_base_block_hash = base->GetAncestor(quorum_height)->GetBlockHash(); + entry.commitment.quorumHash = entry.quorum_base_block_hash; + entry.mined_block_hash = base->GetAncestor(mined_height)->GetBlockHash(); + return entry; + }; + const std::vector nonrotated{ + indexed_commitment(Consensus::LLMQType::LLMQ_TEST, 48, 58, false), + indexed_commitment(Consensus::LLMQType::LLMQ_TEST, 72, 82, false), + }; + const std::vector rotated{ + indexed_commitment(Consensus::LLMQType::LLMQ_TEST_DIP0024, 72, 84, true, 0), + indexed_commitment(Consensus::LLMQType::LLMQ_TEST_DIP0024, 73, 85, true, 1), + }; + CCreditPool pool; + pool.locked = 123; + pool.currentLimit = 45; + pool.latelyUnlocked = 6; + AbstractEHFManager::Signals signals{{2, base->nHeight}}; + llmq::CQuorumSnapshot quorum_snapshot{{true, false, true}, SnapshotSkipMode::MODE_NO_SKIPPING, {}}; + + auto& mutable_consensus{const_cast(Params().GetConsensus())}; + const int old_dip3_height{mutable_consensus.DIP0003Height}; + mutable_consensus.DIP0003Height = 1; + BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(base).GetCounts().total(), 0U); + BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(historical_index).GetCounts().total(), 0U); + + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(list)); + BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(historical_list)); + { + LOCK(::cs_main); + for (const auto& entry : nonrotated) { + BOOST_REQUIRE(m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + entry.commitment.llmqType, entry.quorum_base_block_hash, + entry.commitment, entry.mined_block_hash)); + } + for (const auto& entry : rotated) { + BOOST_REQUIRE(m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + entry.commitment.llmqType, entry.quorum_base_block_hash, + entry.commitment, entry.mined_block_hash)); + } + } + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base, quorum_snapshot)); + BOOST_REQUIRE(m_node.chain_helper->credit_pool_manager->SeedSnapshot(base, pool)); + BOOST_REQUIRE(m_node.chain_helper->ehf_manager->SeedSignals(base, signals)); + tx->Commit(); + } + BOOST_REQUIRE(m_node.evodb->CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true)); + { + LOCK(::cs_main); + m_node.dmnman->InvalidateListCacheForBlock(base->GetBlockHash()); + m_node.dmnman->InvalidateListCacheForBlock(historical_index->GetBlockHash()); + } + + CDeterministicMNList stored_list; + CDeterministicMNList stored_historical_list; + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + stored_list = m_node.dmnman->GetListForBlock(base); + stored_historical_list = m_node.dmnman->GetListForBlock(historical_index); + } + m_node.dmnman->InvalidateListCacheForBlock(base->GetBlockHash()); + m_node.dmnman->InvalidateListCacheForBlock(historical_index->GetBlockHash()); + const auto subsequent_list{m_node.dmnman->GetListForBlock(base)}; + const auto subsequent_historical_list{m_node.dmnman->GetListForBlock(historical_index)}; + mutable_consensus.DIP0003Height = old_dip3_height; + BOOST_CHECK(evo::CanonicalMNListHash(stored_list) == evo::CanonicalMNListHash(list)); + BOOST_CHECK(evo::CanonicalMNListHash(stored_historical_list) == evo::CanonicalMNListHash(historical_list)); + BOOST_CHECK(evo::CanonicalMNListHash(subsequent_list) == evo::CanonicalMNListHash(list)); + BOOST_CHECK(evo::CanonicalMNListHash(subsequent_historical_list) == evo::CanonicalMNListHash(historical_list)); + const auto [stored_commitment, stored_mined_hash]{ + m_node.llmq_ctx->quorum_block_processor->GetMinedCommitment( + nonrotated.back().commitment.llmqType, nonrotated.back().quorum_base_block_hash)}; + BOOST_CHECK_EQUAL(stored_mined_hash, nonrotated.back().mined_block_hash); + BOOST_CHECK_EQUAL(SerializeHash(stored_commitment), SerializeHash(nonrotated.back().commitment)); + { + LOCK(::cs_main); + const auto plain{m_node.llmq_ctx->quorum_block_processor->GetMinedCommitmentsUntilBlock( + Consensus::LLMQType::LLMQ_TEST, base, 2)}; + BOOST_REQUIRE_EQUAL(plain.size(), 2U); + BOOST_CHECK_EQUAL(plain[0]->nHeight, 72); + BOOST_CHECK_EQUAL(plain[1]->nHeight, 48); + const auto indexed{m_node.llmq_ctx->quorum_block_processor->GetLastMinedCommitmentsPerQuorumIndexUntilBlock( + Consensus::LLMQType::LLMQ_TEST_DIP0024, base, 0)}; + BOOST_REQUIRE_EQUAL(indexed.size(), 2U); + BOOST_CHECK_EQUAL(indexed[0]->nHeight, 72); + BOOST_CHECK_EQUAL(indexed[1]->nHeight, 73); + + CBlock first_post_base_block; + uint256 quorum_root; + BlockValidationState state; + BOOST_CHECK_MESSAGE(CalcCbTxMerkleRootQuorums(first_post_base_block, base, + *m_node.llmq_ctx->quorum_block_processor, quorum_root, state), + state.ToString()); + } + const auto stored_snapshot{m_node.llmq_ctx->qsnapman->GetSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base)}; + BOOST_REQUIRE(stored_snapshot.has_value()); + BOOST_CHECK(stored_snapshot->activeQuorumMembers == quorum_snapshot.activeQuorumMembers); + + CCreditPool stored_pool; + AbstractEHFManager::Signals stored_signals; + BOOST_REQUIRE(m_node.evodb->Read(std::make_pair(std::string{"cpm_S"}, base->GetBlockHash()), stored_pool)); + BOOST_REQUIRE(m_node.evodb->Read(std::make_pair(std::string{"mnhf_s2"}, base->GetBlockHash()), stored_signals)); + BOOST_CHECK_EQUAL(stored_pool.locked, pool.locked); + BOOST_CHECK(stored_signals == signals); +} + +BOOST_FIXTURE_TEST_CASE(snapshot_seed_rollback_does_not_publish_caches, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + const auto seeded_list{MNList(base->GetBlockHash(), base->nHeight, false)}; + CCreditPool seeded_pool; + seeded_pool.locked = 123; + AbstractEHFManager::Signals seeded_signals{{2, base->nHeight}}; + const llmq::CQuorumSnapshot seeded_quorum{{true, false, true}, SnapshotSkipMode::MODE_NO_SKIPPING, {}}; + + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(seeded_list)); + BOOST_REQUIRE(m_node.chain_helper->credit_pool_manager->SeedSnapshot(base, seeded_pool)); + BOOST_REQUIRE(m_node.chain_helper->ehf_manager->SeedSignals(base, seeded_signals)); + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base, seeded_quorum)); + BOOST_CHECK(!WITH_LOCK(::cs_main, return m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + Consensus::LLMQType::LLMQ_TEST, H(200), + Commitment(Consensus::LLMQType::LLMQ_TEST, 1, 2, false).commitment, H(201)))); + // Destruction without Commit() rolls the complete scoped transaction back. + } + + CDeterministicMNList db_list; + CCreditPool db_pool; + AbstractEHFManager::Signals db_signals; + const auto quorum_hash{SerializeHash(std::make_pair(Consensus::LLMQType::LLMQ_TEST, base->GetBlockHash()))}; + llmq::CQuorumSnapshot db_quorum; + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"dmn_S3"}, base->GetBlockHash()), db_list)); + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"cpm_S"}, base->GetBlockHash()), db_pool)); + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"mnhf_s2"}, base->GetBlockHash()), db_signals)); + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string_view{"llmq_S"}, quorum_hash), db_quorum)); + + auto& consensus{const_cast(Params().GetConsensus())}; + const int old_dip3_height{consensus.DIP0003Height}; + const int old_v20_height{consensus.V20Height}; + consensus.DIP0003Height = 1; + consensus.V20Height = 1; + BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(base).GetCounts().total(), 0U); + BOOST_CHECK_EQUAL(m_node.chain_helper->credit_pool_manager->GetCreditPool(base).locked, 0); + BOOST_CHECK(m_node.chain_helper->ehf_manager->GetSignalsStage(base).empty()); + consensus.V20Height = old_v20_height; + consensus.DIP0003Height = old_dip3_height; + BOOST_CHECK(!m_node.llmq_ctx->qsnapman->GetSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base).has_value()); +} + +BOOST_FIXTURE_TEST_CASE(quorum_members_reconstruct_from_seeded_state_only, TestChain100Setup) +{ + const CBlockIndex* tip{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(tip != nullptr); + auto& global_consensus{const_cast(Params().GetConsensus())}; + auto& consensus{const_cast(m_node.chainman->GetConsensus())}; + const auto old_global{global_consensus}; + const auto old_chain{consensus}; + auto plain{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + auto rotated{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + plain.dkgInterval = 12; + plain.dkgMiningWindowStart = 1; + plain.dkgMiningWindowEnd = 3; + rotated.dkgInterval = 12; + consensus.llmqs = {plain, rotated}; + consensus.DIP0003Height = 1; + consensus.V19Height = 1; + consensus.V20Height = 1; + global_consensus.llmqs = consensus.llmqs; + global_consensus.DIP0003Height = 1; + global_consensus.V19Height = 1; + global_consensus.V20Height = 1; + + const CBlockIndex* quorum{tip->GetAncestor(96)}; + BOOST_REQUIRE(quorum != nullptr); + std::map lists; + const auto make_list = [&](const CBlockIndex* work) { + CDeterministicMNList list{work->GetBlockHash(), work->nHeight, 100}; + for (uint8_t i{0}; i < 12; ++i) { + list.AddMN(MN(20 + i, 20 + i, MnType::Regular, ProTxVersion::LegacyBLS, 20 + i), false); + } + return list; + }; + const CBlockIndex* plain_work{quorum->GetAncestor(88)}; + lists.emplace(plain_work, make_list(plain_work)); + std::vector rotated_cycles; + for (const int height : {96, 84, 72, 60}) { + const CBlockIndex* cycle{tip->GetAncestor(height)}; + const CBlockIndex* work{tip->GetAncestor(height - llmq::WORK_DIFF_DEPTH)}; + rotated_cycles.emplace_back(cycle); + lists.try_emplace(work, make_list(work)); + } + for (const auto& [work, list] : lists) m_node.dmnman->SetListForBlockForTesting(list); + BOOST_REQUIRE(m_node.chainman->IsQuorumTypeEnabled(plain.type, quorum->pprev)); + BOOST_REQUIRE(m_node.chainman->IsQuorumTypeEnabled(rotated.type, quorum->pprev)); + BOOST_REQUIRE_EQUAL(m_node.dmnman->GetListForBlock(plain_work).GetCounts().enabled(), 12U); + const llmq::CQuorumSnapshot empty_snapshot{std::vector(12, false), + SnapshotSkipMode::MODE_NO_SKIPPING, {}}; + for (size_t i{1}; i < rotated_cycles.size(); ++i) { + m_node.llmq_ctx->qsnapman->StoreSnapshotForBlock(rotated.type, rotated_cycles[i], empty_snapshot); + } + + // Derive the oracle through a separate manager, cache, and EvoDB. The + // manager under test is seeded only after these expected sets exist. + CEvoDB expected_db{util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evo_snapshot_oracle", + .memory = true, .wipe = true}}; + CMasternodeMetaMan expected_meta; + CDeterministicMNManager expected_dmnman{expected_db, expected_meta}; + llmq::CQuorumSnapshotManager expected_qsnapman{expected_db}; + { + auto tx{expected_db.BeginTransaction(EvoDbIdentity::NORMAL)}; + for (const auto& [_, list] : lists) BOOST_REQUIRE(expected_dmnman.SeedListForBlock(list)); + for (size_t i{1}; i < rotated_cycles.size(); ++i) { + expected_qsnapman.StoreSnapshotForBlock(rotated.type, rotated_cycles[i], empty_snapshot); + } + tx->Commit(); + } + const auto plain_expected{llmq::utils::GetAllQuorumMembers( + plain.type, {expected_dmnman, expected_qsnapman, *m_node.chainman, quorum}, true)}; + const auto rotated_expected{llmq::utils::GetAllQuorumMembers( + rotated.type, {expected_dmnman, expected_qsnapman, *m_node.chainman, quorum}, true)}; + BOOST_REQUIRE(!plain_expected.empty()); + BOOST_REQUIRE(!rotated_expected.empty()); + + CBLSSecretKey quorum_key; + quorum_key.MakeNewKey(); + llmq::CFinalCommitment seeded_commitment{plain, quorum->GetBlockHash()}; + seeded_commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION; + seeded_commitment.quorumPublicKey = quorum_key.GetPublicKey(); + seeded_commitment.quorumVvecHash = H(201); + const CBlockIndex* mined_index{tip->GetAncestor(98)}; + + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + for (const auto& [work, list] : lists) BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(list)); + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedQuorumModifier( + plain.type, plain_work->GetBlockHash(), + llmq::utils::GetQuorumHashModifier(plain, consensus, quorum))); + for (const auto* cycle : rotated_cycles) { + const CBlockIndex* work{cycle->GetAncestor(cycle->nHeight - llmq::WORK_DIFF_DEPTH)}; + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedQuorumModifier( + rotated.type, work->GetBlockHash(), + llmq::utils::GetQuorumHashModifier(rotated, consensus, cycle))); + } + for (size_t i{1}; i < rotated_cycles.size(); ++i) { + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedSnapshotForBlock( + rotated.type, rotated_cycles[i], empty_snapshot)); + } + BOOST_REQUIRE(WITH_LOCK(::cs_main, return m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + plain.type, quorum->GetBlockHash(), seeded_commitment, mined_index->GetBlockHash());)); + tx->Commit(); + } + + std::map saved_status; + { + LOCK(::cs_main); + for (const auto& [work, _] : lists) { + auto* mutable_work{const_cast(work)}; + saved_status.emplace(mutable_work, mutable_work->nStatus); + mutable_work->nStatus &= ~BLOCK_HAVE_DATA; + m_node.dmnman->InvalidateListCacheForBlock(work->GetBlockHash()); + } + for (size_t i{1}; i < rotated_cycles.size(); ++i) { + m_node.llmq_ctx->qsnapman->InvalidateSnapshotCacheForBlock(rotated.type, + rotated_cycles[i]->GetBlockHash()); + } + } + std::vector plain_seeded; + std::vector rotated_seeded; + std::vector scanned; + llmq::VerifyRecSigStatus recovered_sig_status{llmq::VerifyRecSigStatus::NoQuorum}; + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + plain_seeded = llmq::utils::GetAllQuorumMembers( + plain.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true); + rotated_seeded = llmq::utils::GetAllQuorumMembers( + rotated.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true); + scanned = m_node.llmq_ctx->qman->ScanQuorums(plain.type, tip, 1); + const uint256 id{H(202)}; + const uint256 msg_hash{H(203)}; + const llmq::SignHash sign_hash{plain.type, quorum->GetBlockHash(), id, msg_hash}; + recovered_sig_status = llmq::VerifyRecoveredSig( + plain.type, *m_node.llmq_ctx->qman, tip, id, msg_hash, + quorum_key.Sign(sign_hash.Get(), /*specificLegacyScheme=*/false)); + } + const auto hashes = [](const auto& members) { + std::vector result; + for (const auto& member : members) result.emplace_back(member->proTxHash); + return result; + }; + BOOST_CHECK(hashes(plain_seeded) == hashes(plain_expected)); + BOOST_CHECK(hashes(rotated_seeded) == hashes(rotated_expected)); + BOOST_REQUIRE_EQUAL(scanned.size(), 1U); + BOOST_CHECK(hashes(scanned[0]->members) == hashes(plain_expected)); + BOOST_CHECK(recovered_sig_status == llmq::VerifyRecSigStatus::Valid); + + // Prove reconstruction fails closed instead of falling through to the + // ordinary diff chain when one required seeded full list is absent. + size_t forbidden_fallbacks{0}; + m_node.dmnman->SetListSnapshotMissHookForTesting([&](const CBlockIndex* index) { + ++forbidden_fallbacks; + throw std::logic_error(strprintf("forbidden NORMAL MN-list fallback at height %d", index->nHeight)); + }); + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + m_node.evodb->Erase(std::make_pair(std::string{"dmn_S3"}, plain_work->GetBlockHash())); + m_node.dmnman->InvalidateListCacheForBlock(plain_work->GetBlockHash()); + BOOST_CHECK_THROW(llmq::utils::GetAllQuorumMembers( + plain.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true), + std::logic_error); + } + m_node.dmnman->SetListSnapshotMissHookForTesting({}); + BOOST_CHECK_EQUAL(forbidden_fallbacks, 1U); + + { + LOCK(::cs_main); + for (const auto& [work, status] : saved_status) work->nStatus = status; + } + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + const auto modifier_key{std::make_tuple(std::string_view{"llmq_M3"}, plain.type, + plain_work->GetBlockHash())}; + m_node.evodb->Erase(modifier_key); + m_node.evodb->Write(modifier_key, H(254)); + BOOST_CHECK_THROW(llmq::utils::GetAllQuorumMembers( + plain.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true), + evo::SnapshotStateMismatchError); + } + consensus = old_chain; + global_consensus = old_global; +} + +BOOST_FIXTURE_TEST_CASE(chain_validation_pre_dip3_matrix, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + evo::CEvoSnapshot snapshot; + snapshot.base_block_hash = base->GetBlockHash(); + snapshot.mn_list = CDeterministicMNList{base->GetBlockHash(), base->nHeight, 0}; + std::string error; + BOOST_CHECK(WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(snapshot, *m_node.chainman, base, error))); + + auto wrong_base{snapshot}; + wrong_base.base_block_hash = H(99); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(wrong_base, *m_node.chainman, base, error))); + + auto nonempty{snapshot}; + nonempty.credit_pool.locked = 1; + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(nonempty, *m_node.chainman, base, error))); + + auto& mutable_consensus{const_cast(m_node.chainman->GetConsensus())}; + const int old_dip3_height{mutable_consensus.DIP0003Height}; + const int old_v19_height{mutable_consensus.V19Height}; + const auto old_llmqs{mutable_consensus.llmqs}; + mutable_consensus.DIP0003Height = 1; + mutable_consensus.V19Height = 1; + mutable_consensus.llmqs = {evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + + evo::CEvoSnapshot active{snapshot}; + evo::CQuorumSnapshotData quorum_data; + quorum_data.llmq_type = Consensus::LLMQType::LLMQ_TEST; + const auto& params{mutable_consensus.llmqs.front()}; + const auto make_commitment = [&](int quorum_height, int mined_height) { + evo::CMinedQuorumCommitment entry; + const CBlockIndex* quorum{base->GetAncestor(quorum_height)}; + entry.quorum_base_block_hash = quorum->GetBlockHash(); + entry.work_block_hash = entry.quorum_base_block_hash; + entry.mined_block_hash = base->GetAncestor(mined_height)->GetBlockHash(); + entry.commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION; + entry.commitment.llmqType = params.type; + entry.commitment.quorumHash = entry.quorum_base_block_hash; + entry.commitment.signers.resize(params.size); + entry.commitment.validMembers.resize(params.size); + return entry; + }; + quorum_data.active_commitments = {make_commitment(72, 82), make_commitment(48, 58)}; + quorum_data.safety_commitments = {make_commitment(24, 34)}; + std::sort(quorum_data.active_commitments.begin(), quorum_data.active_commitments.end(), + [](const auto& a, const auto& b) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + }); + active.quorums = {quorum_data}; + CDeterministicMNList previous{active.mn_list}; + uint256 previous_hash{active.base_block_hash}; + for (const int height : {72, 48, 24}) { + const CBlockIndex* work{base->GetAncestor(height)}; + CDeterministicMNList list{work->GetBlockHash(), height, 0}; + active.historical_mn_list_diffs.push_back({previous_hash, work->GetBlockHash(), height, 0, + evo::CanonicalMNListHash(list), previous.BuildDiff(list)}); + active.quorum_modifiers.push_back({params.type, work->GetBlockHash(), + llmq::utils::GetQuorumHashModifier(params, mutable_consensus, work)}); + previous_hash = work->GetBlockHash(); + previous = std::move(list); + } + std::sort(active.quorum_modifiers.begin(), active.quorum_modifiers.end(), [](const auto& a, const auto& b) { + return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); + }); + const bool active_valid{WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(active, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(active_valid, error); + + auto wrong_counts{active}; + wrong_counts.quorums[0].safety_commitments.clear(); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(wrong_counts, *m_node.chainman, base, error))); + + auto non_ancestor{active}; + non_ancestor.quorums[0].active_commitments[0].quorum_base_block_hash = H(99); + non_ancestor.quorums[0].active_commitments[0].commitment.quorumHash = H(99); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(non_ancestor, *m_node.chainman, base, error))); + + mutable_consensus.llmqs = old_llmqs; + mutable_consensus.V19Height = old_v19_height; + mutable_consensus.DIP0003Height = old_dip3_height; +} + +BOOST_FIXTURE_TEST_CASE(rotation_bitset_matches_historical_work_list, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + auto& consensus{const_cast(m_node.chainman->GetConsensus())}; + const auto old_llmqs{consensus.llmqs}; + const int old_dip3_height{consensus.DIP0003Height}; + const int old_v19_height{consensus.V19Height}; + auto params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + params.dkgInterval = 12; + params.dkgMiningWindowStart = 2; + params.dkgMiningWindowEnd = 6; + consensus.llmqs = {params}; + consensus.DIP0003Height = 1; + consensus.V19Height = 1; + + evo::CEvoSnapshot snapshot; + snapshot.base_block_hash = base->GetBlockHash(); + snapshot.mn_list = CDeterministicMNList{base->GetBlockHash(), base->nHeight, 0}; + evo::CQuorumSnapshotData data; + data.llmq_type = params.type; + data.rotation_enabled = true; + const auto commitment = [&](int quorum_height, int mined_height, int16_t quorum_index) { + evo::CMinedQuorumCommitment entry; + const CBlockIndex* quorum{base->GetAncestor(quorum_height)}; + const CBlockIndex* cycle{quorum->GetAncestor(quorum->nHeight - quorum->nHeight % params.dkgInterval)}; + entry.quorum_base_block_hash = quorum->GetBlockHash(); + entry.work_block_hash = cycle->GetAncestor(cycle->nHeight - llmq::WORK_DIFF_DEPTH)->GetBlockHash(); + entry.mined_block_hash = base->GetAncestor(mined_height)->GetBlockHash(); + entry.commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION; + entry.commitment.llmqType = params.type; + entry.commitment.quorumHash = entry.quorum_base_block_hash; + entry.commitment.quorumIndex = quorum_index; + entry.commitment.signers.resize(params.size); + entry.commitment.validMembers.resize(params.size); + return entry; + }; + data.active_commitments = {commitment(84, 86, 0), commitment(85, 87, 1)}; + data.safety_commitments = {commitment(72, 74, 0), commitment(73, 75, 1)}; + + std::map required_work; + for (const auto& required : evo::EvoSnapshotReconstructionHeights(base->nHeight, {params})) { + const int cycle_height{required.quorum_height}; + const int work_height{required.work_height}; + const CBlockIndex* cycle{base->GetAncestor(cycle_height)}; + const CBlockIndex* work{base->GetAncestor(work_height)}; + BOOST_REQUIRE(cycle != nullptr); + BOOST_REQUIRE(work != nullptr); + const size_t population{static_cast(params.size + 3)}; + data.rotation_snapshots.push_back({cycle->GetBlockHash(), work->GetBlockHash(), + llmq::CQuorumSnapshot{std::vector(population, true), SnapshotSkipMode::MODE_NO_SKIPPING, {}}}); + required_work.emplace(work->GetBlockHash(), work); + } + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* work{WITH_LOCK(::cs_main, + return m_node.chainman->m_blockman.LookupBlockIndex(entry.work_block_hash);)}; + BOOST_REQUIRE(work != nullptr); + required_work.emplace(entry.work_block_hash, work); + } + } + const auto commitment_less = [](const auto& a, const auto& b) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + }; + std::sort(data.active_commitments.begin(), data.active_commitments.end(), commitment_less); + std::sort(data.safety_commitments.begin(), data.safety_commitments.end(), commitment_less); + std::sort(data.rotation_snapshots.begin(), data.rotation_snapshots.end(), [](const auto& a, const auto& b) { + return std::tie(a.cycle_base_block_hash, a.work_block_hash) < + std::tie(b.cycle_base_block_hash, b.work_block_hash); + }); + snapshot.quorums = {std::move(data)}; + + std::vector ordered_work; + for (const auto& [_, work] : required_work) ordered_work.emplace_back(work); + std::sort(ordered_work.begin(), ordered_work.end(), [](const auto* a, const auto* b) { return a->nHeight > b->nHeight; }); + CDeterministicMNList previous{snapshot.mn_list}; + uint256 previous_hash{snapshot.base_block_hash}; + for (const auto* work : ordered_work) { + CDeterministicMNList work_list{work->GetBlockHash(), work->nHeight, 100}; + for (uint8_t i{0}; i < params.size + 3; ++i) { + work_list.AddMN(MN(20 + i, 20 + i, MnType::Regular, ProTxVersion::LegacyBLS, 20 + i), false); + } + snapshot.historical_mn_list_diffs.push_back( + {previous_hash, work->GetBlockHash(), work->nHeight, work_list.GetTotalRegisteredCount(), + evo::CanonicalMNListHash(work_list), previous.BuildDiff(work_list)}); + previous_hash = work->GetBlockHash(); + previous = std::move(work_list); + } + std::map modifier_cycles; + for (const auto* commitments : {&snapshot.quorums[0].active_commitments, &snapshot.quorums[0].safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* quorum_index{WITH_LOCK(::cs_main, + return m_node.chainman->m_blockman.LookupBlockIndex(entry.quorum_base_block_hash);)}; + const CBlockIndex* cycle{quorum_index->GetAncestor( + quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval)}; + modifier_cycles.emplace(entry.work_block_hash, cycle); + } + } + for (const auto& entry : snapshot.quorums[0].rotation_snapshots) { + modifier_cycles.emplace(entry.work_block_hash, WITH_LOCK(::cs_main, + return m_node.chainman->m_blockman.LookupBlockIndex(entry.cycle_base_block_hash);)); + } + for (const auto& [work_hash, cycle] : modifier_cycles) { + snapshot.quorum_modifiers.push_back({params.type, work_hash, + llmq::utils::GetQuorumHashModifier(params, consensus, cycle)}); + } + + std::string error; + const bool valid{WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(snapshot, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(valid, error); + auto short_bitset{snapshot}; + short_bitset.quorums[0].rotation_snapshots[0].snapshot.activeQuorumMembers.pop_back(); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(short_bitset, *m_node.chainman, base, error))); + auto bad_modifier{snapshot}; + bad_modifier.quorum_modifiers[0].modifier.begin()[0] ^= 1; + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(bad_modifier, *m_node.chainman, base, error))); + + consensus.llmqs = old_llmqs; + consensus.V19Height = old_v19_height; + consensus.DIP0003Height = old_dip3_height; +} + +BOOST_AUTO_TEST_CASE(reconstruction_horizon_height_enumeration) +{ + const auto rotated{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + const auto plain{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + const int base_height{20 * rotated.dkgInterval + 7}; + const auto heights{evo::EvoSnapshotReconstructionHeights(base_height, {rotated, plain})}; + BOOST_REQUIRE_EQUAL(heights.size(), evo::EVO_SNAPSHOT_ROTATION_CYCLES + + evo::SnapshotCommitmentCount(plain, false)); + const int rotated_h{base_height - base_height % rotated.dkgInterval}; + for (size_t i{0}; i < evo::EVO_SNAPSHOT_ROTATION_CYCLES; ++i) { + const int expected_cycle{rotated_h - static_cast(i + 1) * rotated.dkgInterval}; + BOOST_CHECK(heights[i].rotation); + BOOST_CHECK_EQUAL(heights[i].quorum_height, expected_cycle); + BOOST_CHECK_EQUAL(heights[i].work_height, expected_cycle - llmq::WORK_DIFF_DEPTH); + } + const int plain_h{base_height - base_height % plain.dkgInterval}; + for (size_t i{0}; i < evo::SnapshotCommitmentCount(plain, false); ++i) { + const auto& height{heights[evo::EVO_SNAPSHOT_ROTATION_CYCLES + i]}; + BOOST_CHECK(!height.rotation); + BOOST_CHECK_EQUAL(height.quorum_height, plain_h - static_cast(i) * plain.dkgInterval); + BOOST_CHECK_EQUAL(height.work_height, height.quorum_height - llmq::WORK_DIFF_DEPTH); + } +} + +BOOST_AUTO_TEST_CASE(rotation_bitset_larger_than_quorum_roundtrips) +{ + const auto& params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + evo::CQuorumSnapshotEntry entry; + entry.cycle_base_block_hash = H(1); + entry.work_block_hash = H(2); + entry.snapshot.activeQuorumMembers.resize(params.size + 3); + entry.snapshot.activeQuorumMembers[params.size + 1] = true; + entry.snapshot.mnSkipListMode = SnapshotSkipMode::MODE_NO_SKIPPING; + + CDataStream stream{SER_DISK, CLIENT_VERSION}; + evo::WriteRotationSnapshot(stream, entry); + const auto decoded{evo::ReadRotationSnapshot(stream, params)}; + BOOST_CHECK(stream.empty()); + BOOST_CHECK_EQUAL(decoded.snapshot.activeQuorumMembers.size(), params.size + 3U); + BOOST_CHECK(decoded.snapshot.activeQuorumMembers[params.size + 1]); +} + +BOOST_FIXTURE_TEST_CASE(populated_v3_golden_value, BasicTestingSetup) +{ + BOOST_CHECK_EQUAL(GetEvoSnapshotHash(SyntheticSnapshot()).ToString(), + "bb1985a651ed3110218a3c8d65d77c85facdc544d6b9203f0815d5785c1f01ff"); +} + +BOOST_FIXTURE_TEST_CASE(canonical_mn_reader_rejects_order_and_counter, BasicTestingSetup) +{ + BOOST_CHECK(evo::CanonicalMNListHash(CDeterministicMNList{}) == + evo::CanonicalMNListHash(CDeterministicMNList{})); + const auto write_raw = [](uint32_t total, std::vector mns) { + CDataStream stream{SER_DISK, CLIENT_VERSION}; + stream << H(42) << 42 << total; + WriteCompactSize(stream, mns.size()); + for (const auto& dmn : mns) stream << *dmn; + return stream; + }; + auto unsorted{write_raw(10, {MN(2, 2, MnType::Regular, ProTxVersion::LegacyBLS, 2), + MN(1, 1, MnType::Regular, ProTxVersion::LegacyBLS, 1)})}; + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNList(unsorted), std::ios_base::failure); + auto bad_counter{write_raw(2, {MN(2, 1, MnType::Regular, ProTxVersion::LegacyBLS, 1)})}; + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNList(bad_counter), std::ios_base::failure); +} + +BOOST_FIXTURE_TEST_CASE(diff_chain_roundtrip_and_canonical_determinism, BasicTestingSetup) +{ + const auto base{MNList(H(10), 100, false)}; + auto target{base}; + target.RemoveMN(base.GetMNByInternalId(2)->proTxHash); + target.AddMN(MN(8, 8, MnType::Regular, ProTxVersion::LegacyBLS, 8)); + for (const uint64_t id : {5, 7}) { + const auto dmn{target.GetMNByInternalId(id)}; + auto state{std::make_shared(*dmn->pdmnState)}; + state->nLastPaidHeight += static_cast(id); + target.UpdateMN(*dmn, state); + } + const auto diff{base.BuildDiff(target)}; + auto permuted{diff}; + std::reverse(permuted.addedMNs.begin(), permuted.addedMNs.end()); + std::vector> updates(permuted.updatedMNs.begin(), + permuted.updatedMNs.end()); + std::reverse(updates.begin(), updates.end()); + permuted.updatedMNs.clear(); + for (auto& update : updates) permuted.updatedMNs.emplace(std::move(update)); + + CDataStream canonical{SER_DISK, CLIENT_VERSION}; + CDataStream reordered{SER_DISK, CLIENT_VERSION}; + evo::SerializeCanonicalMNListDiff(canonical, diff); + evo::SerializeCanonicalMNListDiff(reordered, permuted); + BOOST_CHECK_EQUAL_COLLECTIONS(canonical.begin(), canonical.end(), reordered.begin(), reordered.end()); + + auto decoded{evo::UnserializeCanonicalMNListDiff(canonical)}; + auto reconstructed{base}; + reconstructed.ApplyDiffForSnapshot(H(11), 99, target.GetTotalRegisteredCount(), decoded); + target.ApplyDiffForSnapshot(H(11), 99, target.GetTotalRegisteredCount(), CDeterministicMNListDiff{}); + BOOST_CHECK(evo::CanonicalMNListHash(reconstructed) == evo::CanonicalMNListHash(target)); + BOOST_CHECK(canonical.empty()); +} + +BOOST_FIXTURE_TEST_CASE(historical_diff_decode_has_cumulative_operation_budget, BasicTestingSetup) +{ + CDeterministicMNListDiff one_removal; + one_removal.removedMns.emplace(1); + CDataStream first{SER_DISK, CLIENT_VERSION}; + CDataStream second{SER_DISK, CLIENT_VERSION}; + evo::SerializeCanonicalMNListDiff(first, one_removal); + evo::SerializeCanonicalMNListDiff(second, one_removal); + + size_t remaining_operations{1}; + const auto decoded{evo::UnserializeCanonicalMNListDiff(first, remaining_operations)}; + BOOST_CHECK_EQUAL(decoded.removedMns.size(), 1U); + BOOST_CHECK_EQUAL(remaining_operations, 0U); + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNListDiff(second, remaining_operations), + std::ios_base::failure); + BOOST_CHECK(evo::EvoSnapshotMaxHistoricalMNLists() < 2'048U); +} + +BOOST_FIXTURE_TEST_CASE(context_free_validation_matrix, BasicTestingSetup) +{ + auto snapshot{SyntheticSnapshot()}; + snapshot.version++; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.base_block_hash = H(1); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + std::reverse(snapshot.quorums.begin(), snapshot.quorums.end()); + BOOST_CHECK_THROW(snapshot.Validate(/*require_canonical_order=*/true), std::ios_base::failure); + snapshot = SyntheticSnapshot(); + std::reverse(snapshot.historical_mn_list_diffs.begin(), snapshot.historical_mn_list_diffs.end()); + BOOST_CHECK_THROW(snapshot.Validate(/*require_canonical_order=*/true), std::ios_base::failure); + snapshot = SyntheticSnapshot(); + std::reverse(snapshot.quorums[0].active_commitments.begin(), snapshot.quorums[0].active_commitments.end()); + BOOST_CHECK_THROW(snapshot.Validate(/*require_canonical_order=*/true), std::ios_base::failure); + snapshot = SyntheticSnapshot(); + std::reverse(snapshot.quorums[1].rotation_snapshots.begin(), snapshot.quorums[1].rotation_snapshots.end()); + BOOST_CHECK_THROW(snapshot.Validate(/*require_canonical_order=*/true), std::ios_base::failure); + snapshot = SyntheticSnapshot(); + snapshot.historical_mn_list_diffs[0].block_hash = H(1); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[1].rotation_snapshots[0].work_block_hash = H(1); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[0].llmq_type = Consensus::LLMQType::LLMQ_NONE; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[0].rotation_enabled = true; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[0].active_commitments.pop_back(); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[0].safety_commitments.clear(); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[1].rotation_snapshots.pop_back(); + CheckInvalid(snapshot); + + const auto mutate_commitment = [](auto mutation) { + auto value{SyntheticSnapshot()}; + mutation(value.quorums[0].active_commitments[0]); + CheckInvalid(std::move(value)); + }; + mutate_commitment([](auto& e) { e.quorum_base_block_hash.SetNull(); }); + mutate_commitment([](auto& e) { e.mined_block_hash.SetNull(); }); + mutate_commitment([](auto& e) { e.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST_PLATFORM; }); + mutate_commitment([](auto& e) { e.commitment.quorumHash = H(99); }); + mutate_commitment([](auto& e) { e.commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION; }); + mutate_commitment([](auto& e) { e.commitment.nVersion = 99; }); + snapshot = SyntheticSnapshot(); + snapshot.quorums[1].active_commitments[1].commitment.quorumIndex = 0; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[1].active_commitments[1].commitment.quorumIndex = 2; + CheckInvalid(snapshot); + + const auto mutate_rotation = [](auto mutation) { + auto value{SyntheticSnapshot()}; + mutation(value.quorums[1].rotation_snapshots[0]); + CheckInvalid(std::move(value)); + }; + mutate_rotation([](auto& e) { e.cycle_base_block_hash.SetNull(); }); + mutate_rotation([](auto& e) { e.work_block_hash.SetNull(); }); + mutate_rotation([](auto& e) { e.snapshot.mnSkipListMode = static_cast(9); }); + mutate_rotation([](auto& e) { e.snapshot.activeQuorumMembers.resize(evo::EVO_SNAPSHOT_MAX_MNS + 1); }); + mutate_rotation([](auto& e) { e.snapshot.mnSkipList = {-1}; }); +} + +BOOST_FIXTURE_TEST_CASE(bounded_readers_reject_claimed_sizes_first, BasicTestingSetup) +{ + CDataStream mn_stream{SER_DISK, CLIENT_VERSION}; + mn_stream << H(1) << 1 << uint32_t{0}; + WriteCompactSize(mn_stream, evo::EVO_SNAPSHOT_MAX_MNS + 1); + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNList(mn_stream), std::ios_base::failure); + BOOST_CHECK(mn_stream.empty()); + + const auto decode_quorum = [](CDataStream stream) { + evo::CQuorumSnapshotData data; + stream >> data; + }; + const auto& plain_params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + const size_t commitment_limit{evo::SnapshotCommitmentCount(plain_params, false)}; + CDataStream active{SER_DISK, CLIENT_VERSION}; + active << Consensus::LLMQType::LLMQ_TEST << false; + WriteCompactSize(active, plain_params.signingActiveQuorumCount + 1); + BOOST_CHECK_THROW(decode_quorum(active), std::ios_base::failure); + CDataStream safety{SER_DISK, CLIENT_VERSION}; + safety << Consensus::LLMQType::LLMQ_TEST << false; + WriteCompactSize(safety, 0); + WriteCompactSize(safety, commitment_limit - plain_params.signingActiveQuorumCount + 1); + BOOST_CHECK_THROW(decode_quorum(safety), std::ios_base::failure); + CDataStream rotations{SER_DISK, CLIENT_VERSION}; + rotations << Consensus::LLMQType::LLMQ_TEST_DIP0024 << true; + WriteCompactSize(rotations, 0); + WriteCompactSize(rotations, 0); + WriteCompactSize(rotations, evo::EVO_SNAPSHOT_ROTATION_CYCLES + 1); + BOOST_CHECK_THROW(decode_quorum(rotations), std::ios_base::failure); + + const auto& rotated_params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + CDataStream bitset{SER_DISK, CLIENT_VERSION}; + bitset << H(1) << H(2) << SnapshotSkipMode::MODE_NO_SKIPPING; + WriteCompactSize(bitset, evo::EVO_SNAPSHOT_MAX_MNS + 1); + BOOST_CHECK_THROW(evo::ReadRotationSnapshot(bitset, rotated_params), std::ios_base::failure); + CDataStream skip_list{SER_DISK, CLIENT_VERSION}; + skip_list << H(1) << H(2) << SnapshotSkipMode::MODE_NO_SKIPPING; + WriteCompactSize(skip_list, 0); + WriteCompactSize(skip_list, rotated_params.size + 1); + BOOST_CHECK_THROW(evo::ReadRotationSnapshot(skip_list, rotated_params), std::ios_base::failure); + + CDataStream commitment_bits{SER_DISK, CLIENT_VERSION}; + auto oversized_commitment{Commitment(Consensus::LLMQType::LLMQ_TEST, 1, 2, false)}; + oversized_commitment.commitment.signers.resize(plain_params.size + 1); + commitment_bits << Consensus::LLMQType::LLMQ_TEST << false; + WriteCompactSize(commitment_bits, 1); + commitment_bits << oversized_commitment; + evo::CQuorumSnapshotData oversized_data; + BOOST_CHECK_THROW(commitment_bits >> oversized_data, std::ios_base::failure); + // The bitset payload and mined-block hash remain unread: rejection occurs + // from the claimed count, before allocation or accepting the element. + BOOST_CHECK_GT(commitment_bits.size(), uint256::size()); + + auto oversized_payout_mn{std::const_pointer_cast( + MN(8, 8, MnType::Regular, ProTxVersion::ExtAddr, 8))}; + auto payout_state{std::make_shared(*oversized_payout_mn->pdmnState)}; + payout_state->payouts.resize(evo::EVO_SNAPSHOT_MAX_PAYOUT_SHARES + 1); + oversized_payout_mn->pdmnState = std::move(payout_state); + CDataStream payouts{SER_DISK, CLIENT_VERSION}; + payouts << H(42) << 42 << uint32_t{10}; + WriteCompactSize(payouts, 1); + payouts << *oversized_payout_mn; + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNList(payouts), std::ios_base::failure); + + CDataStream wrapped_string{SER_DISK, CLIENT_VERSION}; + WriteCompactSize(wrapped_string, evo::EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS + 1); + wrapped_string << uint8_t{0x42}; + evo::SnapshotBoundedInput bounded_string{wrapped_string, evo::EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; + OverrideStream bounded_override{&bounded_string, SER_DISK, CLIENT_VERSION}; + std::string decoded_string; + BOOST_CHECK_EXCEPTION(bounded_override >> decoded_string, std::ios_base::failure, + [](const auto& e) { return std::string{e.what()}.find("CompactSize budget exceeded") != std::string::npos; }); + BOOST_CHECK(decoded_string.empty()); + BOOST_REQUIRE_EQUAL(wrapped_string.size(), 1U); + BOOST_CHECK_EQUAL(std::to_integer(wrapped_string.data()[0]), 0x42); + + const auto snapshot_prefix = [](CDataStream& stream) { + stream << evo::EVO_SNAPSHOT_VERSION << H(42); + evo::SerializeCanonicalMNList(stream, CDeterministicMNList{H(42), 1, 0}); + }; + CDataStream quorum_types{SER_DISK, CLIENT_VERSION}; + snapshot_prefix(quorum_types); + WriteCompactSize(quorum_types, Consensus::available_llmqs.size() + 1); + evo::CEvoSnapshot decoded; + BOOST_CHECK_THROW(quorum_types >> decoded, std::ios_base::failure); + + CDataStream history{SER_DISK, CLIENT_VERSION}; + snapshot_prefix(history); + WriteCompactSize(history, 0); + WriteCompactSize(history, evo::EvoSnapshotMaxHistoricalMNLists() + 1); + BOOST_CHECK_THROW(history >> decoded, std::ios_base::failure); + + CDataStream signals{SER_DISK, CLIENT_VERSION}; + snapshot_prefix(signals); + WriteCompactSize(signals, 0); + WriteCompactSize(signals, 0); + WriteCompactSize(signals, 0); + signals << CCreditPool{}; + WriteCompactSize(signals, Consensus::MAX_VERSION_BITS_DEPLOYMENTS + 1); + BOOST_CHECK_THROW(signals >> decoded, std::ios_base::failure); + + CDataStream ranges{SER_DISK, CLIENT_VERSION}; + snapshot_prefix(ranges); + WriteCompactSize(ranges, 0); + WriteCompactSize(ranges, 0); + WriteCompactSize(ranges, 0); + ranges << CAmount{0} << CAmount{0} << CAmount{0}; + WriteCompactSize(ranges, evo::EVO_SNAPSHOT_MAX_RANGES + 1); + BOOST_CHECK_THROW(ranges >> decoded, std::ios_base::failure); + BOOST_CHECK(ranges.empty()); + + const auto snapshot_bytes{SerializeSnapshot(SyntheticSnapshot())}; + DomainPort domain; + BOOST_REQUIRE_EQUAL(domain.Set("evo5.example.org", 443), DomainPort::Status::Success); + CDataStream encoded_domain{SER_DISK, CLIENT_VERSION}; + encoded_domain << domain; + const auto domain_pos{std::search(snapshot_bytes.begin(), snapshot_bytes.end(), + encoded_domain.begin(), encoded_domain.end())}; + BOOST_REQUIRE(domain_pos != snapshot_bytes.end()); + + CDataStream oversized_domain{SER_DISK, CLIENT_VERSION}; + const size_t domain_offset{static_cast(std::distance(snapshot_bytes.begin(), domain_pos))}; + oversized_domain.write(Span{snapshot_bytes}.first(domain_offset)); + constexpr size_t MAX_DOMAIN_LENGTH{253}; + WriteCompactSize(oversized_domain, MAX_DOMAIN_LENGTH + 1); + const std::string oversized_addr(MAX_DOMAIN_LENGTH + 1, 'a'); + oversized_domain.write(MakeByteSpan(oversized_addr)); + const size_t serialized_addr_size{encoded_domain.size() - sizeof(uint16_t)}; + oversized_domain.write(Span{snapshot_bytes}.subspan(domain_offset + serialized_addr_size)); + BOOST_CHECK_EXCEPTION(oversized_domain >> decoded, std::ios_base::failure, + [](const auto& e) { return std::string{e.what()}.find("String length limit exceeded") != std::string::npos; }); +} + +BOOST_FIXTURE_TEST_CASE(cbtx_cross_checks, BasicTestingSetup) +{ + const auto snapshot{SyntheticSnapshot()}; + CCbTx cbtx; + cbtx.nVersion = CCbTx::Version::CLSIG_AND_BALANCE; + cbtx.merkleRootMNList = snapshot.mn_list.to_sml()->CalcMerkleRoot(); + std::vector quorum_hashes; + for (const auto& data : snapshot.quorums) { + for (const auto& entry : data.active_commitments) quorum_hashes.emplace_back(SerializeHash(entry.commitment)); + } + std::sort(quorum_hashes.begin(), quorum_hashes.end()); + cbtx.merkleRootQuorums = ComputeMerkleRoot(quorum_hashes); + cbtx.creditPoolBalance = snapshot.credit_pool.locked; + + std::string error; + BOOST_CHECK(evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); + cbtx.merkleRootMNList = H(1); + BOOST_CHECK(!evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); + cbtx.merkleRootMNList = snapshot.mn_list.to_sml()->CalcMerkleRoot(); + cbtx.merkleRootQuorums = H(2); + BOOST_CHECK(!evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); + cbtx.merkleRootQuorums = ComputeMerkleRoot(quorum_hashes); + cbtx.creditPoolBalance++; + BOOST_CHECK(!evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); +} + +BOOST_FIXTURE_TEST_CASE(rejects_unknown_wire_version, BasicTestingSetup) +{ + auto bytes{SerializeSnapshot(SyntheticSnapshot())}; + bytes.data()[0] = std::byte{4}; + evo::CEvoSnapshot decoded; + BOOST_CHECK_THROW(bytes >> decoded, std::ios_base::failure); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/util_tests.cpp b/src/test/util_tests.cpp index d941032c97b9..7f5677c7b9aa 100644 --- a/src/test/util_tests.cpp +++ b/src/test/util_tests.cpp @@ -6,6 +6,7 @@ #include // For Hash() #include // For CKey #include +#include #include #include #include @@ -1425,6 +1426,72 @@ BOOST_AUTO_TEST_CASE(test_CRanges) } } +BOOST_AUTO_TEST_CASE(test_CRanges_deserialize_validation) +{ + const auto encoded = [](std::initializer_list> ranges) { + CDataStream stream{SER_NETWORK, 0}; + WriteCompactSize(stream, ranges.size()); + for (const auto& [begin, end] : ranges) stream << begin << end; + return stream; + }; + + for (auto malformed : {encoded({{4, 4}}), // empty + encoded({{4, 8}, {7, 10}}), // overlapping + encoded({{4, 8}, {8, 10}}), // adjacent (must be merged) + encoded({{12, 14}, {4, 8}})}) { // unordered + CRangesSet decoded; + BOOST_CHECK_THROW(malformed >> decoded, std::ios_base::failure); + } + + auto canonical{encoded({{4, 8}, {10, 12}})}; + CRangesSet decoded; + BOOST_CHECK_NO_THROW(canonical >> decoded); + BOOST_CHECK_EQUAL(decoded.Size(), 6U); + BOOST_CHECK(decoded.Contains(4)); + BOOST_CHECK(decoded.Contains(11)); + BOOST_CHECK(!decoded.Contains(8)); + + constexpr uint64_t max{std::numeric_limits::max()}; + CRangesSet max_value; + BOOST_CHECK(max_value.Add(max - 2)); + BOOST_CHECK(max_value.Add(max - 1)); + BOOST_CHECK(max_value.Add(max)); + CDataStream max_encoded{SER_NETWORK, 0}; + max_encoded << max_value; + CRangesSet max_decoded; + max_encoded >> max_decoded; + BOOST_CHECK_EQUAL(max_decoded.Size(), 3U); + BOOST_CHECK(max_decoded.Contains(max - 2)); + BOOST_CHECK(max_decoded.Contains(max - 1)); + BOOST_CHECK(max_decoded.Contains(max)); + + BOOST_CHECK(max_decoded.Remove(max)); + CDataStream removed_max_encoded{SER_NETWORK, 0}; + removed_max_encoded << max_decoded; + CRangesSet removed_max_decoded; + removed_max_encoded >> removed_max_decoded; + BOOST_CHECK_EQUAL(removed_max_decoded.Size(), 2U); + BOOST_CHECK(removed_max_decoded.Contains(max - 2)); + BOOST_CHECK(removed_max_decoded.Contains(max - 1)); + BOOST_CHECK(!removed_max_decoded.Contains(max)); + + BOOST_CHECK(max_value.Remove(max - 1)); + CDataStream removed_interior_encoded{SER_NETWORK, 0}; + removed_interior_encoded << max_value; + CRangesSet removed_interior_decoded; + removed_interior_encoded >> removed_interior_decoded; + BOOST_CHECK_EQUAL(removed_interior_decoded.Size(), 2U); + BOOST_CHECK(removed_interior_decoded.Contains(max - 2)); + BOOST_CHECK(!removed_interior_decoded.Contains(max - 1)); + BOOST_CHECK(removed_interior_decoded.Contains(max)); + + auto invalid_wrapped{encoded({{5, 0}, {10, 12}})}; + BOOST_CHECK_THROW(invalid_wrapped >> decoded, std::ios_base::failure); + + auto invalid_reverse{encoded({{5, 4}})}; + BOOST_CHECK_THROW(invalid_reverse >> decoded, std::ios_base::failure); +} + static std::string SpanToStr(const Span& span) { return std::string(span.begin(), span.end()); diff --git a/src/util/ranges_set.cpp b/src/util/ranges_set.cpp index 11b7863a17f8..b662b3487973 100644 --- a/src/util/ranges_set.cpp +++ b/src/util/ranges_set.cpp @@ -81,7 +81,9 @@ size_t CRangesSet::Size() const noexcept { size_t result{0}; for (auto i : ranges) { - result += i.end - i.begin; + // end == 0 is the half-open representation of a range containing + // UINT64_MAX. Avoid the unsigned subtraction wrap for that range. + result += i.end == 0 ? std::numeric_limits::max() - i.begin + 1 : i.end - i.begin; } return result; } @@ -93,5 +95,5 @@ bool CRangesSet::Contains(uint64_t value) const noexcept if (it == ranges.begin()) return false; auto prev = it; --prev; - return prev->begin <= value && prev->end > value; + return prev->begin <= value && (prev->end == 0 || prev->end > value); } diff --git a/src/util/ranges_set.h b/src/util/ranges_set.h index d67be4919056..86e1b484976f 100644 --- a/src/util/ranges_set.h +++ b/src/util/ranges_set.h @@ -9,7 +9,10 @@ #include #include +#include +#include #include +#include /** * The CRangesSet is a datastructure that keeps efficiently numbers as set of @@ -47,6 +50,8 @@ class CRangesSet std::set ranges; public: + static constexpr uint64_t DEFAULT_MAX_RANGES{MAX_SIZE}; + /** * this function adds `value` to the datastructure. * it returns true if `add` succeed @@ -75,9 +80,48 @@ class CRangesSet */ [[nodiscard]] bool IsEmpty() const noexcept; - SERIALIZE_METHODS(CRangesSet, obj) + template + void Serialize(Stream& s) const + { + // Preserve the established canonical set encoding. + s << ranges; + } + + template + void UnserializeBounded(Stream& s, uint64_t max_ranges) + { + std::set decoded; + const uint64_t count{ReadCompactSize(s)}; + if (count > max_ranges) throw std::ios_base::failure("oversized CRangesSet range count"); + uint64_t previous_end{0}; + bool have_previous{false}; + for (uint64_t i{0}; i < count; ++i) { + Range range; + s >> range; + const bool wrapped_max{range.end == 0}; + if (!wrapped_max && range.begin >= range.end) { + throw std::ios_base::failure("invalid empty CRangesSet range"); + } + // Equality is adjacent and must have been merged; less-than is + // overlapping or unordered. Both are noncanonical and could make + // Size() underflow. + if (have_previous && (previous_end == 0 || range.begin <= previous_end)) { + throw std::ios_base::failure("noncanonical CRangesSet ranges"); + } + if (wrapped_max && i + 1 != count) { + throw std::ios_base::failure("wrapped CRangesSet range must be last"); + } + previous_end = range.end; + have_previous = true; + decoded.emplace(range); + } + ranges = std::move(decoded); + } + + template + void Unserialize(Stream& s) { - READWRITE(obj.ranges); + UnserializeBounded(s, DEFAULT_MAX_RANGES); } }; From 8c3485afe9c8f0a17d9401c735c8c3bdb4316c85 Mon Sep 17 00:00:00 2001 From: Pasta Date: Sat, 11 Jul 2026 11:52:14 -0500 Subject: [PATCH 2/8] feat: emit and seed evo snapshot section in dump/populate (v3) --- src/evo/creditpool.cpp | 18 +- src/evo/creditpool.h | 2 + src/evo/evodb.cpp | 45 +- src/evo/evodb.h | 8 + src/evo/mnhftx.cpp | 6 + src/evo/mnhftx.h | 2 + src/llmq/blockprocessor.cpp | 27 ++ src/llmq/blockprocessor.h | 4 + src/llmq/utils.cpp | 19 +- src/rpc/blockchain.cpp | 25 ++ src/test/evo_db_tests.cpp | 10 + .../validation_chainstatemanager_tests.cpp | 19 + src/validation.cpp | 418 +++++++++++++++--- src/validation.h | 14 +- test/functional/feature_assumeutxo_dash.py | 56 +++ test/functional/rpc_dumptxoutset.py | 5 +- test/functional/test_runner.py | 1 + 17 files changed, 605 insertions(+), 74 deletions(-) create mode 100755 test/functional/feature_assumeutxo_dash.py diff --git a/src/evo/creditpool.cpp b/src/evo/creditpool.cpp index 5d9a1f60d3cf..85dca6008208 100644 --- a/src/evo/creditpool.cpp +++ b/src/evo/creditpool.cpp @@ -125,12 +125,12 @@ std::optional CCreditPoolManager::GetFromCache(const CBlockIndex& b return pool; } } - if (block_index.nHeight % DISK_SNAPSHOT_PERIOD == 0) { - if (evoDb.Read(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block_hash), pool)) { - LOCK(cache_mutex); - creditPoolCache.insert(block_hash, pool); - return pool; - } + // Snapshot activation may deliberately seed a full state at a height that + // is not one of the normal periodic checkpoints. + if (evoDb.Read(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block_hash), pool)) { + LOCK(cache_mutex); + creditPoolCache.insert(block_hash, pool); + return pool; } return std::nullopt; } @@ -155,6 +155,12 @@ void CCreditPoolManager::AddToCache(const uint256& block_hash, int height, const } } +bool CCreditPoolManager::SeedSnapshot(const CBlockIndex* block, const CCreditPool& pool) +{ + assert(block != nullptr); + return evoDb.WriteDerived(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block->GetBlockHash()), pool); +} + CCreditPool CCreditPoolManager::ConstructCreditPool(const gsl::not_null block_index, CCreditPool prev) { std::optional opt_block_data = GetCreditDataFromBlock(block_index, m_chainman.GetConsensus()); diff --git a/src/evo/creditpool.h b/src/evo/creditpool.h index fec44ab680cb..196632bb7290 100644 --- a/src/evo/creditpool.h +++ b/src/evo/creditpool.h @@ -134,6 +134,8 @@ class CCreditPoolManager * it can happen if there limits of withdrawal (unlock) exceed */ CCreditPool GetCreditPool(const CBlockIndex* block) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); + /** Seed a full pool snapshot in the current EvoDB transaction. */ + bool SeedSnapshot(const CBlockIndex* block, const CCreditPool& pool) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); private: std::optional GetFromCache(const CBlockIndex& block_index) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); diff --git a/src/evo/evodb.cpp b/src/evo/evodb.cpp index 88787fe1e8ea..3085baacdd9b 100644 --- a/src/evo/evodb.cpp +++ b/src/evo/evodb.cpp @@ -42,6 +42,12 @@ CEvoDB::CEvoDB(const util::DbWrapperParams& db_params) : CEvoDB::~CEvoDB() = default; +bool CEvoDB::HasActiveTransaction() +{ + LOCK(cs); + return active_transaction.has_value(); +} + CEvoDB::TransactionContext& CEvoDB::GetContext(EvoDbIdentity identity) { auto it = transaction_contexts.find(identity); @@ -182,6 +188,37 @@ bool CEvoDB::ReadBackgroundMNListHash(uint256& block_hash, uint256& mn_list_hash return true; } +void CEvoDB::WriteRequiredWorkMNListHashes(const std::vector& block_hashes) +{ + Write(EVODB_REQUIRED_WORK_MNLISTS, block_hashes); +} + +bool CEvoDB::ReadRequiredWorkMNListHashes(std::vector& block_hashes) +{ + return Read(EVODB_REQUIRED_WORK_MNLISTS, block_hashes); +} + +void CEvoDB::WriteBackgroundWorkMNListHash(const uint256& block_hash, const uint256& mn_list_hash) +{ + Write(std::make_pair(EVODB_BACKGROUND_WORK_MNLIST_HASH, block_hash), mn_list_hash); +} + +bool CEvoDB::ReadBackgroundWorkMNListHash(const uint256& block_hash, uint256& mn_list_hash) +{ + return Read(std::make_pair(EVODB_BACKGROUND_WORK_MNLIST_HASH, block_hash), mn_list_hash); +} + +static void EraseHistoricalMNListMarkers(CDBWrapper& db, CDBBatch& batch) +{ + std::vector required; + if (db.Read(EVODB_REQUIRED_WORK_MNLISTS, required)) { + for (const auto& block_hash : required) { + batch.Erase(std::make_pair(EVODB_BACKGROUND_WORK_MNLIST_HASH, block_hash)); + } + } + batch.Erase(EVODB_REQUIRED_WORK_MNLISTS); +} + bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) { LOCK(cs); @@ -198,7 +235,9 @@ bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) uint256 normal_tip; const bool already_promoted = db->Read(EVODB_BEST_BLOCK, normal_tip) && normal_tip == expected_snapshot_tip && !db->Exists(EVODB_DUAL_CHAINSTATE) && !db->Exists(EVODB_SNAPSHOT_MNLIST_HASH) && - !db->Exists(EVODB_BACKGROUND_MNLIST_HASH); + !db->Exists(EVODB_BACKGROUND_MNLIST_HASH) && + !db->Exists(EVODB_REQUIRED_WORK_MNLISTS) && + !db->Exists(EVODB_SNAPSHOT_EVO_SECTION); if (already_promoted) m_default_identity = EvoDbIdentity::NORMAL; return already_promoted; } @@ -209,6 +248,8 @@ bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) batch.Erase(snapshot_key); batch.Erase(EVODB_SNAPSHOT_MNLIST_HASH); batch.Erase(EVODB_BACKGROUND_MNLIST_HASH); + EraseHistoricalMNListMarkers(*db, batch); + batch.Erase(EVODB_SNAPSHOT_EVO_SECTION); batch.Erase(EVODB_DUAL_CHAINSTATE); if (!db->WriteBatch(batch, /*fSync=*/true)) return false; // The dual-chainstate run is over: the promoted state is the NORMAL @@ -231,6 +272,8 @@ bool CEvoDB::DiscardSnapshotMarkers() batch.Erase(std::make_pair(EVODB_BEST_BLOCK, uint8_t{1})); batch.Erase(EVODB_SNAPSHOT_MNLIST_HASH); batch.Erase(EVODB_BACKGROUND_MNLIST_HASH); + EraseHistoricalMNListMarkers(*db, batch); + batch.Erase(EVODB_SNAPSHOT_EVO_SECTION); batch.Erase(EVODB_DUAL_CHAINSTATE); if (!db->WriteBatch(batch, /*fSync=*/true)) return false; // The snapshot chainstate is gone; transaction-less access must resolve diff --git a/src/evo/evodb.h b/src/evo/evodb.h index 40d37b48229d..7aff76c9f986 100644 --- a/src/evo/evodb.h +++ b/src/evo/evodb.h @@ -31,6 +31,9 @@ static const std::string EVODB_BEST_BLOCK = "b_b4"; static const std::string EVODB_DUAL_CHAINSTATE = "b_dcs"; static const std::string EVODB_SNAPSHOT_MNLIST_HASH = "b_dcs_mn"; static const std::string EVODB_BACKGROUND_MNLIST_HASH = "b_dcs_bg_mn"; +static const std::string EVODB_REQUIRED_WORK_MNLISTS = "b_dcs_req_mn"; +static const std::string EVODB_BACKGROUND_WORK_MNLIST_HASH = "b_dcs_bg_work_mn"; +static const std::string EVODB_SNAPSHOT_EVO_SECTION = "b_dcs_evo"; enum class EvoDbIdentity { NORMAL, @@ -224,6 +227,7 @@ class CEvoDB bool CommitRootTransaction(EvoDbIdentity identity = EvoDbIdentity::NORMAL, bool sync = false) EXCLUSIVE_LOCKS_REQUIRED(!cs); bool IsEmpty() { return db->IsEmpty(); } + bool HasActiveTransaction() EXCLUSIVE_LOCKS_REQUIRED(!cs); //! Set the identity used by reads/writes outside any transaction. Must //! track the active chainstate: snapshot activation sets SNAPSHOT; @@ -250,6 +254,10 @@ class CEvoDB bool ReadSnapshotBaseMNListHash(uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); void WriteBackgroundMNListHash(const uint256& block_hash, const uint256& mn_list_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); bool ReadBackgroundMNListHash(uint256& block_hash, uint256& mn_list_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); + void WriteRequiredWorkMNListHashes(const std::vector& block_hashes) EXCLUSIVE_LOCKS_REQUIRED(!cs); + bool ReadRequiredWorkMNListHashes(std::vector& block_hashes) EXCLUSIVE_LOCKS_REQUIRED(!cs); + void WriteBackgroundWorkMNListHash(const uint256& block_hash, const uint256& mn_list_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); + bool ReadBackgroundWorkMNListHash(const uint256& block_hash, uint256& mn_list_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); /** * Atomically promote the surviving snapshot marker to the legacy NORMAL key diff --git a/src/evo/mnhftx.cpp b/src/evo/mnhftx.cpp index f36e9b0a80ca..73bb7fd93c37 100644 --- a/src/evo/mnhftx.cpp +++ b/src/evo/mnhftx.cpp @@ -383,6 +383,12 @@ void CMNHFManager::AddToCache(const Signals& signals, const CBlockIndex* const p } } +bool CMNHFManager::SeedSignals(const CBlockIndex* pindex, const Signals& signals) +{ + assert(pindex != nullptr); + return m_evoDb.WriteDerived(std::make_pair(DB_SIGNALS_v2, pindex->GetBlockHash()), signals); +} + void CMNHFManager::AddSignal(const CBlockIndex* const pindex, int bit) { auto signals = GetForBlock(pindex->pprev); diff --git a/src/evo/mnhftx.h b/src/evo/mnhftx.h index b5d721360d49..1ce2abd5a87a 100644 --- a/src/evo/mnhftx.h +++ b/src/evo/mnhftx.h @@ -136,6 +136,8 @@ class CMNHFManager : public AbstractEHFManager void AddSignal(const CBlockIndex* const pindex, int bit) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); bool ForceSignalDBUpdate() EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs_cache); + /** Seed the signals at a block in the current EvoDB transaction. */ + bool SeedSignals(const CBlockIndex* pindex, const Signals& signals) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); private: void AddToCache(const Signals& signals, const CBlockIndex* const pindex) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); diff --git a/src/llmq/blockprocessor.cpp b/src/llmq/blockprocessor.cpp index 58d7ac546548..9f72006d92cd 100644 --- a/src/llmq/blockprocessor.cpp +++ b/src/llmq/blockprocessor.cpp @@ -685,6 +685,33 @@ std::pair CQuorumBlockProcessor::GetMinedCommitment(C return ret; } +bool CQuorumBlockProcessor::SeedMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorum_hash, + const CFinalCommitment& commitment, + const uint256& mined_block_hash) +{ + AssertLockHeld(::cs_main); + const auto llmq_params = Params().GetLLMQ(llmqType); + const CBlockIndex* mined_index = m_chainman.m_blockman.LookupBlockIndex(mined_block_hash); + const CBlockIndex* quorum_base_index = m_chainman.m_blockman.LookupBlockIndex(quorum_hash); + if (!llmq_params || mined_index == nullptr || quorum_base_index == nullptr) return false; + if (!m_evoDb.WriteDerived( + std::make_pair(DB_MINED_COMMITMENT, std::make_pair(llmqType, quorum_hash)), + std::make_pair(commitment, mined_block_hash))) { + return false; + } + + // Replay ProcessCommitment's iteration index exactly. These entries drive + // the first post-snapshot CbTx quorum-merkle-root calculation. + if (IsQuorumRotationEnabled(*llmq_params, quorum_base_index)) { + m_evoDb.Write(BuildInversedHeightKeyIndexed(llmqType, mined_index->nHeight, + int(commitment.quorumIndex)), + quorum_base_index->nHeight); + } else { + m_evoDb.Write(BuildInversedHeightKey(llmqType, mined_index->nHeight), quorum_base_index->nHeight); + } + return true; +} + // The returned quorums are in reversed order, so the most recent one is at index 0 std::vector CQuorumBlockProcessor::GetMinedCommitmentsUntilBlock(Consensus::LLMQType llmqType, gsl::not_null pindex, size_t maxCount) const { diff --git a/src/llmq/blockprocessor.h b/src/llmq/blockprocessor.h index bb11cd67ba58..1feb52f91e89 100644 --- a/src/llmq/blockprocessor.h +++ b/src/llmq/blockprocessor.h @@ -126,6 +126,10 @@ class CQuorumBlockProcessor bool HasMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorumHash, const CChain& chain) const EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !minableCommitmentsCs); std::pair GetMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorumHash) const; + /** Seed a mined commitment in the current EvoDB transaction. */ + bool SeedMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorum_hash, + const CFinalCommitment& commitment, const uint256& mined_block_hash) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main); /** * Serialized hashes of the commitments mined for the quorums active as of pindexPrev. diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index fee8ecefa687..a25ed161f1aa 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -661,7 +661,8 @@ std::optional> ComputeQuorumMembersFromWorkBlo return quorumMembers[quorumIndex]; } -QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) +static QuorumMembers GetAllQuorumMembersInternal(Consensus::LLMQType llmqType, const UtilParameters& util_params, + bool reset_cache) { static RecursiveMutex cs_members; static std::map> mapQuorumMembers GUARDED_BY(cs_members); @@ -759,6 +760,22 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame return quorumMembers; } +QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) +{ + try { + return GetAllQuorumMembersInternal(llmqType, util_params, reset_cache); + } catch (const evo::SnapshotStateMismatchError& e) { + // Outside block connection there is no EvoDB transaction to unwind, so + // P2P, RPC, DKG, and quorum-manager callers can immediately enter the + // controlled invalid-snapshot path. During block connect/disconnect, + // defer to the Chainstate boundary after its transaction rolls back. + if (const_cast(util_params.m_chainman).HandleSnapshotStateMismatch(e.what())) { + return {}; + } + throw; + } +} + uint256 DeterministicOutboundConnection(const uint256& proTxHash1, const uint256& proTxHash2) { // We need to deterministically select who is going to initiate the connection. The naive way would be to simply diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index ee644d85e4fe..919f6229aa81 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -55,6 +55,7 @@ #include #include #include +#include #include #include #include @@ -3031,6 +3032,8 @@ static RPCHelpMan dumptxoutset() {RPCResult::Type::NUM, "base_height", "the height of the base of the snapshot"}, {RPCResult::Type::STR, "path", "the absolute path that the snapshot was written to"}, {RPCResult::Type::STR_HEX, "txoutset_hash", "the hash of the UTXO set contents"}, + {RPCResult::Type::STR_HEX, "evo_hash", "the hash of the canonical Dash evo section"}, + {RPCResult::Type::NUM, "evo_mn_count", "the number of deterministic masternodes in the evo section"}, {RPCResult::Type::NUM, "nchaintx", "the number of transactions in the chain up to and including the base block"}, } }, @@ -3081,6 +3084,8 @@ UniValue CreateUTXOSnapshot( std::unique_ptr pcursor; std::optional maybe_stats; const CBlockIndex* tip; + evo::CEvoSnapshot evo_snapshot; + std::string evo_error; { // We need to lock cs_main to ensure that the coinsdb isn't written to @@ -3106,6 +3111,16 @@ UniValue CreateUTXOSnapshot( pcursor = chainstate.CoinsDB().Cursor(); tip = CHECK_NONFATAL(chainstate.m_blockman.LookupBlockIndex(maybe_stats->hashBlock)); + + // Retain evo state from the same cs_main-pinned chain point as the + // LevelDB cursor. The cursor remains a stable snapshot after unlock. + if (!evo::BuildEvoSnapshot(Params(), *node.chainman, *Assert(node.dmnman), + *Assert(node.llmq_ctx)->quorum_block_processor, + *node.llmq_ctx->qsnapman, + *chainstate.ChainHelper().credit_pool_manager, + *chainstate.ChainHelper().ehf_manager, tip, evo_snapshot, evo_error)) { + throw JSONRPCError(RPC_INTERNAL_ERROR, "Unable to build evo snapshot: " + evo_error); + } } LOG_TIME_SECONDS(strprintf("writing UTXO snapshot at height %s (%s) to file %s (via %s)", @@ -3131,6 +3146,14 @@ UniValue CreateUTXOSnapshot( pcursor->Next(); } + // On-disk layout (with no length prefix around the coins) is exactly: + // [SnapshotMetadata][metadata.m_coins_count x (COutPoint,Coin)] + // [uint64 EVO_SNAPSHOT_MARKER][CEvoSnapshot]. CEvoSnapshot carries its + // own format version immediately after the marker. + afile << evo::EVO_SNAPSHOT_MARKER; + OverrideStream evo_file{&afile, SER_DISK, CLIENT_VERSION}; + evo_file << evo_snapshot; + afile.fclose(); UniValue result(UniValue::VOBJ); @@ -3139,6 +3162,8 @@ UniValue CreateUTXOSnapshot( result.pushKV("base_height", tip->nHeight); result.pushKV("path", path.utf8string()); result.pushKV("txoutset_hash", maybe_stats->hashSerialized.ToString()); + result.pushKV("evo_hash", evo::GetEvoSnapshotHash(evo_snapshot).ToString()); + result.pushKV("evo_mn_count", evo_snapshot.mn_list.GetCounts().total()); // Cast required because univalue doesn't have serialization specified for // `unsigned int`, nChainTx's type. result.pushKV("nchaintx", uint64_t{tip->nChainTx}); diff --git a/src/test/evo_db_tests.cpp b/src/test/evo_db_tests.cpp index cf5de0d76edb..555ab41015de 100644 --- a/src/test/evo_db_tests.cpp +++ b/src/test/evo_db_tests.cpp @@ -272,12 +272,15 @@ BOOST_AUTO_TEST_CASE(snapshot_marker_promotion_and_discard) const uint256 normal_tip = BlockHash(30); const uint256 snapshot_tip = BlockHash(300); const uint256 mn_list_hash = BlockHash(3); + const std::vector required_work{BlockHash(10), BlockHash(20)}; WriteMarker(db, EvoDbIdentity::NORMAL, normal_tip); { auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); db.WriteBestBlock(EvoDbIdentity::SNAPSHOT, snapshot_tip); db.WriteSnapshotBaseMNListHash(mn_list_hash); + db.WriteRequiredWorkMNListHashes(required_work); + for (const auto& hash : required_work) db.WriteBackgroundWorkMNListHash(hash, BlockHash(40)); db.WriteDualChainstateMarker(); tx->Commit(); } @@ -291,12 +294,17 @@ BOOST_AUTO_TEST_CASE(snapshot_marker_promotion_and_discard) uint256 value; BOOST_CHECK(!db.ReadBestBlock(EvoDbIdentity::SNAPSHOT, value)); BOOST_CHECK(!db.ReadSnapshotBaseMNListHash(value)); + std::vector required_value; + BOOST_CHECK(!db.ReadRequiredWorkMNListHashes(required_value)); + for (const auto& hash : required_work) BOOST_CHECK(!db.ReadBackgroundWorkMNListHash(hash, value)); BOOST_CHECK(!db.HasDualChainstateMarker()); WriteMarker(db, EvoDbIdentity::SNAPSHOT, BlockHash(301)); { auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); db.WriteSnapshotBaseMNListHash(BlockHash(4)); + db.WriteRequiredWorkMNListHashes(required_work); + for (const auto& hash : required_work) db.WriteBackgroundWorkMNListHash(hash, BlockHash(41)); db.WriteDualChainstateMarker(); tx->Commit(); } @@ -307,6 +315,8 @@ BOOST_AUTO_TEST_CASE(snapshot_marker_promotion_and_discard) BOOST_CHECK(db.VerifyBestBlock(EvoDbIdentity::NORMAL, snapshot_tip)); BOOST_CHECK(!db.ReadBestBlock(EvoDbIdentity::SNAPSHOT, value)); BOOST_CHECK(!db.ReadSnapshotBaseMNListHash(value)); + BOOST_CHECK(!db.ReadRequiredWorkMNListHashes(required_value)); + for (const auto& hash : required_work) BOOST_CHECK(!db.ReadBackgroundWorkMNListHash(hash, value)); BOOST_CHECK(!db.HasDualChainstateMarker()); } diff --git a/src/test/validation_chainstatemanager_tests.cpp b/src/test/validation_chainstatemanager_tests.cpp index 901a3f094f65..4247538000a8 100644 --- a/src/test/validation_chainstatemanager_tests.cpp +++ b/src/test/validation_chainstatemanager_tests.cpp @@ -1068,6 +1068,25 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_completion_incorrect_base_mn_ BOOST_CHECK(!m_node.evodb->ReadBestBlock(EvoDbIdentity::SNAPSHOT, obsolete_marker)); } +BOOST_FIXTURE_TEST_CASE(chainstatemanager_records_only_required_background_work_mn_hashes, SnapshotTestSetup) +{ + auto [validation_chainstate, _] = this->SetupSnapshot(); + const uint256 required_block{GetRandHash()}; + const uint256 unrelated_block{GetRandHash()}; + const uint256 required_hash{GetRandHash()}; + + auto tx = m_node.evodb->BeginTransaction(EvoDbIdentity::NORMAL); + m_node.evodb->WriteRequiredWorkMNListHashes({required_block}); + validation_chainstate->SetRequiredBackgroundMNListHashes({required_block}); + validation_chainstate->RecordBackgroundMNListHash(required_block, required_hash); + validation_chainstate->RecordBackgroundMNListHash(unrelated_block, GetRandHash()); + + uint256 captured_hash; + BOOST_REQUIRE(m_node.evodb->ReadBackgroundWorkMNListHash(required_block, captured_hash)); + BOOST_CHECK_EQUAL(captured_hash, required_hash); + BOOST_CHECK(!m_node.evodb->ReadBackgroundWorkMNListHash(unrelated_block, captured_hash)); +} + BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_cleanup_recovers_first_rename, SnapshotTestSetup) { this->SetupSnapshot(); diff --git a/src/validation.cpp b/src/validation.cpp index 5b1e8924d8c4..c66144856aa8 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -55,11 +55,16 @@ #include #include +#include #include #include +#include +#include #include #include #include +#include +#include #include #include @@ -2780,12 +2785,32 @@ void Chainstate::ForceFlushStateToDisk() void Chainstate::RecordBackgroundMNListHash(const CBlockIndex* pindex, const CDeterministicMNList& mn_list) { if (EvoDbIdentity() != ::EvoDbIdentity::NORMAL) return; - // Only the snapshot base block's list takes part in snapshot completion, - // and it only matters while a snapshot chainstate exists. Hashing the full - // list is too expensive to do on every connect. + const auto base_blockhash = m_chainman.SnapshotBlockhash(); - if (!base_blockhash || *base_blockhash != pindex->GetBlockHash()) return; - m_evoDb.WriteBackgroundMNListHash(pindex->GetBlockHash(), ::SerializeHash(mn_list)); + if (!base_blockhash) return; + + if (!m_required_background_mn_list_hashes) { + std::vector required_work_blocks; + m_evoDb.ReadRequiredWorkMNListHashes(required_work_blocks); + m_required_background_mn_list_hashes.emplace(required_work_blocks.begin(), required_work_blocks.end()); + } + + const uint256 block_hash{pindex->GetBlockHash()}; + const bool is_base_block{*base_blockhash == block_hash}; + const bool is_required_work_block{m_required_background_mn_list_hashes->contains(block_hash)}; + if (!is_base_block && !is_required_work_block) return; + + // Hash only the snapshot base and the bounded set of historical work + // blocks needed for deferred evo validation, not every background block. + const uint256 mn_list_hash{evo::CanonicalMNListHash(mn_list)}; + if (is_base_block) m_evoDb.WriteBackgroundMNListHash(block_hash, mn_list_hash); + if (is_required_work_block) m_evoDb.WriteBackgroundWorkMNListHash(block_hash, mn_list_hash); +} + +void Chainstate::SetRequiredBackgroundMNListHashes(const std::vector& block_hashes) +{ + assert(EvoDbIdentity() == EvoDbIdentity::NORMAL); + m_required_background_mn_list_hashes = std::set{block_hashes.begin(), block_hashes.end()}; } void Chainstate::PruneAndFlush() @@ -2920,7 +2945,7 @@ bool Chainstate::DisconnectTip(BlockValidationState& state, DisconnectedBlockTra !DeploymentActiveAt(*pindexDelete, m_params.GetConsensus(), Consensus::DEPLOYMENT_V19)}; // Apply the block atomically to the chain state. int64_t nStart = GetTimeMicros(); - { + try { auto dbTx = m_evoDb.BeginTransaction(EvoDbIdentity()); CCoinsViewCache view(&CoinsTip()); @@ -2930,6 +2955,11 @@ bool Chainstate::DisconnectTip(BlockValidationState& state, DisconnectedBlockTra bool flushed = view.Flush(); assert(flushed); dbTx->Commit(); + } catch (const evo::SnapshotStateMismatchError& e) { + if (m_chainman.HandleSnapshotStateMismatch(e.what())) { + return state.Error("invalid assumeutxo evo snapshot state"); + } + throw; } LogPrint(BCLog::BENCHMARK, "- Disconnect block: %.2fms\n", (GetTimeMicros() - nStart) * MILLI); @@ -3072,7 +3102,7 @@ bool Chainstate::ConnectTip(BlockValidationState& state, CBlockIndex* pindexNew, // When adding aggregate statistics in the future, keep in mind that // nBlocksTotal may be zero until the ConnectBlock() call below. LogPrint(BCLog::BENCHMARK, " - Load block from disk: %.2fms\n", (nTime2 - nTime1) * MILLI); - { + try { auto dbTx = m_evoDb.BeginTransaction(EvoDbIdentity()); CCoinsViewCache view(&CoinsTip()); @@ -3089,6 +3119,11 @@ bool Chainstate::ConnectTip(BlockValidationState& state, CBlockIndex* pindexNew, bool flushed = view.Flush(); assert(flushed); dbTx->Commit(); + } catch (const evo::SnapshotStateMismatchError& e) { + if (m_chainman.HandleSnapshotStateMismatch(e.what())) { + return state.Error("invalid assumeutxo evo snapshot state"); + } + throw; } int64_t nTime4 = GetTimeMicros(); nTimeFlush += nTime4 - nTime3; LogPrint(BCLog::BENCHMARK, " - Flush: %.2fms [%.2fs (%.2fms/blk)]\n", (nTime4 - nTime3) * MILLI, nTimeFlush * MICRO, nTimeFlush * MILLI / nBlocksTotal); @@ -4534,7 +4569,7 @@ bool TestBlockValidity(BlockValidationState& state, CBlockIndex* pindexPrev, bool fCheckPOW, bool fCheckMerkleRoot) -{ +try { AssertLockHeld(cs_main); assert(pindexPrev && pindexPrev == chainstate.m_chain.Tip()); @@ -4567,6 +4602,11 @@ bool TestBlockValidity(BlockValidationState& state, assert(state.IsValid()); return true; +} catch (const evo::SnapshotStateMismatchError& e) { + if (chainstate.m_chainman.HandleSnapshotStateMismatch(e.what())) { + return state.Error("invalid assumeutxo evo snapshot state"); + } + throw; } /* This function is called from the RPC code for pruneblockchain */ @@ -4629,7 +4669,7 @@ bool CVerifyDB::VerifyDB( CCoinsView& coinsview, CEvoDB& evoDb, int nCheckLevel, int nCheckDepth) -{ +try { AssertLockHeld(cs_main); if (chainstate.m_chain.Tip() == nullptr || chainstate.m_chain.Tip()->pprev == nullptr) { @@ -4752,6 +4792,9 @@ bool CVerifyDB::VerifyDB( LogPrintf("Verification: No coin database inconsistencies in last %i blocks (%i transactions)\n", block_count, nGoodTransactions); return true; +} catch (const evo::SnapshotStateMismatchError& e) { + if (chainstate.m_chainman.HandleSnapshotStateMismatch(e.what())) return false; + throw; } /** Apply the effects of a block on the utxo cache, ignoring that it may already have been applied. */ @@ -4791,7 +4834,7 @@ bool Chainstate::RollforwardBlock(const CBlockIndex* pindex, CCoinsViewCache& in } bool Chainstate::ReplayBlocks() -{ +try { LOCK(cs_main); CCoinsView& db = this->CoinsDB(); @@ -4865,6 +4908,9 @@ bool Chainstate::ReplayBlocks() dbTx->Commit(); uiInterface.ShowProgress("", 100, false); return true; +} catch (const evo::SnapshotStateMismatchError& e) { + if (m_chainman.HandleSnapshotStateMismatch(e.what())) return false; + throw; } void Chainstate::ClearBlockIndexCandidates() @@ -5840,16 +5886,41 @@ bool ChainstateManager::PopulateAndValidateSnapshot( // method. coins_cache.SetBestBlock(base_blockhash); - bool out_of_coins{false}; + std::optional evo_snapshot; + uint64_t evo_marker{0}; try { - coins_file >> outpoint; + coins_file >> evo_marker; } catch (const std::ios_base::failure&) { - // We expect an exception since we should be out of coins. - out_of_coins = true; + if (DeploymentActiveAt(*snapshot_start_block, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + LogPrintf("[snapshot] missing evo section at DIP3-active base\n"); + return false; + } } - if (!out_of_coins) { - LogPrintf("[snapshot] bad snapshot - coins left over after deserializing %d coins\n", - coins_count); + if (evo_marker != 0) { + if (evo_marker != evo::EVO_SNAPSHOT_MARKER) { + LogPrintf("[snapshot] bad evo section marker (or coins left over) after %d coins\n", coins_count); + return false; + } + try { + evo_snapshot.emplace(); + OverrideStream evo_file{&coins_file, SER_DISK, CLIENT_VERSION}; + evo_file >> *evo_snapshot; + } catch (const std::ios_base::failure&) { + LogPrintf("[snapshot] truncated or invalid evo section\n"); + return false; + } + try { + uint8_t trailing; + coins_file >> trailing; + LogPrintf("[snapshot] trailing data after evo section\n"); + return false; + } catch (const std::ios_base::failure&) { + // EOF immediately after a completely decoded CEvoSnapshot is required. + } + } + + if (!evo_snapshot && DeploymentActiveAt(*snapshot_start_block, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + LogPrintf("[snapshot] UTXO-only snapshot refused at DIP3-active base\n"); return false; } @@ -5887,6 +5958,51 @@ bool ChainstateManager::PopulateAndValidateSnapshot( return false; } + if (evo_snapshot) { + std::string evo_error; + { + LOCK(::cs_main); + if (!evo::ValidateEvoSnapshotAgainstChain(*evo_snapshot, *this, snapshot_start_block, evo_error)) { + LogPrintf("[snapshot] bad evo snapshot chain data: %s\n", evo_error); + return false; + } + } + const uint256 actual_evo_hash{evo::GetEvoSnapshotHash(*evo_snapshot)}; + // Regtest entries use a null hash as an intentional M7 parameter slot. + // All structural/chain checks and the available CbTx checks still run. + if (au_data.evo_hash == EvoSnapshotHash{uint256::ZERO} && + GetParams().NetworkIDString() != CBaseChainParams::REGTEST) { + LogPrintf("[snapshot] null evo snapshot hash is only permitted on regtest\n"); + return false; + } + if (au_data.evo_hash != EvoSnapshotHash{uint256::ZERO} && + EvoSnapshotHash{actual_evo_hash} != au_data.evo_hash) { + LogPrintf("[snapshot] bad evo snapshot hash: expected %s, got %s\n", + au_data.evo_hash.ToString(), actual_evo_hash.ToString()); + return false; + } + + // CbTx is part of the full base block, not its header. Check it when the + // block is locally available; otherwise background validation's M3 + // canonical base-state comparison remains the load-time backstop. + const bool base_block_available{WITH_LOCK(::cs_main, return (snapshot_start_block->nStatus & BLOCK_HAVE_DATA) != 0;)}; + if (DeploymentActiveAt(*snapshot_start_block, GetConsensus(), Consensus::DEPLOYMENT_DIP0003) && + base_block_available) { + CBlock base_block; + if (!ReadBlockFromDisk(base_block, snapshot_start_block, GetConsensus()) || base_block.vtx.empty()) { + LogPrintf("[snapshot] failed to read available base block for evo CbTx check\n"); + return false; + } + const auto cbtx{GetTxPayload(*base_block.vtx[0])}; + if (!cbtx || !evo::VerifyEvoSnapshotCbTx(*evo_snapshot, *cbtx, evo_error)) { + LogPrintf("[snapshot] evo CbTx cross-check failed: %s\n", evo_error); + return false; + } + } else if (DeploymentActiveAt(*snapshot_start_block, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + LogPrintf("[snapshot] base block data unavailable; deferring evo CbTx cross-check to background validation\n"); + } + } + snapshot_chainstate.m_chain.SetTip(*snapshot_start_block); // The remainder of this function requires modifying data protected by cs_main. @@ -5964,19 +6080,135 @@ bool ChainstateManager::PopulateAndValidateSnapshot( LogPrintf("[snapshot] failed to sync background EvoDB state\n"); return false; } + std::vector required_work_blocks; + if (evo_snapshot) { + required_work_blocks.reserve(evo_snapshot->historical_mn_list_diffs.size()); + for (const auto& entry : evo_snapshot->historical_mn_list_diffs) { + required_work_blocks.emplace_back(entry.block_hash); + } + std::sort(required_work_blocks.begin(), required_work_blocks.end()); + } + // Usually the background chain has not reached these blocks yet. If it + // has, hash its already-connected ordinary state now, before any snapshot + // seeds enter the shared EvoDB namespace. + std::map existing_background_work_hashes; + auto& background_dmnman{m_ibd_chainstate->ChainHelper().DeterministicMNManager()}; + for (const auto& block_hash : required_work_blocks) { + const CBlockIndex* index{m_blockman.LookupBlockIndex(block_hash)}; + assert(index != nullptr); + if (m_ibd_chainstate->m_chain.Contains(index)) { + existing_background_work_hashes.emplace( + block_hash, evo::CanonicalMNListHash(background_dmnman.GetListForBlock(index))); + } + } { auto db_tx = snapshot_chainstate.m_evoDb.BeginTransaction(EvoDbIdentity::SNAPSHOT); + if (evo_snapshot) { + auto& helper{snapshot_chainstate.ChainHelper()}; + auto& dmnman{helper.DeterministicMNManager()}; + auto& qblockman{helper.QuorumBlockProcessor()}; + auto& qsnapman{helper.QuorumSnapshotManager()}; + if (!dmnman.SeedListForBlock(evo_snapshot->mn_list)) { + LogPrintf("[snapshot] failed to seed base deterministic MN list\n"); + return false; + } + std::map historical_lists; + std::string reconstruction_error; + if (!evo::ReconstructHistoricalMNLists(*evo_snapshot, historical_lists, reconstruction_error)) { + LogPrintf("[snapshot] failed to reconstruct historical deterministic MN lists: %s\n", + reconstruction_error); + return false; + } + for (const auto& [_, historical_list] : historical_lists) { + if (!dmnman.SeedListForBlock(historical_list)) { + LogPrintf("[snapshot] failed to seed historical deterministic MN list\n"); + return false; + } + } + for (const auto& modifier : evo_snapshot->quorum_modifiers) { + if (!qsnapman.SeedQuorumModifier(modifier.llmq_type, modifier.work_block_hash, + modifier.modifier)) { + LogPrintf("[snapshot] failed to seed quorum score modifier\n"); + return false; + } + } + for (const auto& quorum_data : evo_snapshot->quorums) { + const auto seed_commitments = [&](const auto& commitments) { + LOCK(::cs_main); + for (const auto& entry : commitments) { + if (!qblockman.SeedMinedCommitment(quorum_data.llmq_type, entry.quorum_base_block_hash, + entry.commitment, entry.mined_block_hash)) return false; + } + return true; + }; + if (!seed_commitments(quorum_data.active_commitments) || + !seed_commitments(quorum_data.safety_commitments)) { + LogPrintf("[snapshot] failed to seed mined quorum commitment\n"); + return false; + } + for (const auto& rotation : quorum_data.rotation_snapshots) { + const CBlockIndex* cycle_index{m_blockman.LookupBlockIndex(rotation.cycle_base_block_hash)}; + assert(cycle_index != nullptr); + if (!qsnapman.SeedSnapshotForBlock(quorum_data.llmq_type, cycle_index, rotation.snapshot)) { + LogPrintf("[snapshot] failed to seed quorum rotation snapshot\n"); + return false; + } + } + } + if (!helper.credit_pool_manager->SeedSnapshot(snapshot_start_block, evo_snapshot->credit_pool) || + !helper.ehf_manager->SeedSignals(snapshot_start_block, evo_snapshot->mnhf_signals)) { + LogPrintf("[snapshot] failed to seed credit-pool/MNHF state\n"); + return false; + } + if (!snapshot_chainstate.m_evoDb.WriteDerived(EVODB_SNAPSHOT_EVO_SECTION, *evo_snapshot)) { + LogPrintf("[snapshot] failed to retain evo section for deferred CbTx validation\n"); + return false; + } + } snapshot_chainstate.m_evoDb.WriteBestBlock(EvoDbIdentity::SNAPSHOT, base_blockhash); - if (base_mn_list_hash.has_value()) { + if (evo_snapshot) { + snapshot_chainstate.m_evoDb.WriteSnapshotBaseMNListHash( + evo::CanonicalMNListHash(evo_snapshot->mn_list)); + } else if (base_mn_list_hash.has_value()) { snapshot_chainstate.m_evoDb.WriteSnapshotBaseMNListHash(*base_mn_list_hash); } snapshot_chainstate.m_evoDb.WriteDualChainstateMarker(); db_tx->Commit(); } + { + // This bounded required set and its independently computed captures + // belong to the NORMAL identity. Future background connects consult + // the in-memory mirror before recording their canonical hash. + auto db_tx = snapshot_chainstate.m_evoDb.BeginTransaction(EvoDbIdentity::NORMAL); + snapshot_chainstate.m_evoDb.WriteRequiredWorkMNListHashes(required_work_blocks); + for (const auto& [block_hash, mn_list_hash] : existing_background_work_hashes) { + snapshot_chainstate.m_evoDb.WriteBackgroundWorkMNListHash(block_hash, mn_list_hash); + } + db_tx->Commit(); + } + if (!snapshot_chainstate.m_evoDb.CommitRootTransaction(EvoDbIdentity::NORMAL, /*sync=*/true)) { + LogPrintf("[snapshot] failed to commit required historical MN-list marker\n"); + return false; + } + m_ibd_chainstate->SetRequiredBackgroundMNListHashes(required_work_blocks); if (!snapshot_chainstate.m_evoDb.CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true)) { LogPrintf("[snapshot] failed to commit snapshot EvoDB marker\n"); return false; } + if (evo_snapshot) { + auto& helper{snapshot_chainstate.ChainHelper()}; + auto& dmnman{helper.DeterministicMNManager()}; + dmnman.InvalidateListCacheForBlock(base_blockhash); + for (const auto& historical : evo_snapshot->historical_mn_list_diffs) { + dmnman.InvalidateListCacheForBlock(historical.block_hash); + } + for (const auto& quorum_data : evo_snapshot->quorums) { + for (const auto& rotation : quorum_data.rotation_snapshots) { + helper.QuorumSnapshotManager().InvalidateSnapshotCacheForBlock( + quorum_data.llmq_type, rotation.cycle_base_block_hash); + } + } + } LogPrintf("[snapshot] validated snapshot (%.2f MB)\n", coins_cache.DynamicMemoryUsage() / (1000 * 1000)); @@ -5997,6 +6229,63 @@ bool ChainstateManager::PopulateAndValidateSnapshot( // through IsUsable() checks, or // // (ii) giving each chainstate its own lock instead of using cs_main for everything. +bool ChainstateManager::HandleSnapshotStateMismatch( + const std::string& reason, std::function shutdown_fnc) +{ + LOCK(::cs_main); + if (!m_snapshot_chainstate || m_active_chainstate != m_snapshot_chainstate.get() || + !IsUsable(m_snapshot_chainstate.get()) || !IsUsable(m_ibd_chainstate.get()) || + m_snapshot_chainstate->m_evoDb.HasActiveTransaction()) { + return false; + } + + const int snapshot_tip_height{m_snapshot_chainstate->m_chain.Height()}; + const int snapshot_base_height{GetSnapshotBaseHeight().value_or(snapshot_tip_height)}; + bilingual_str user_error = strprintf(_( + "%s failed to validate the -assumeutxo snapshot state. " + "This indicates a hardware problem, or a bug in the software, or a " + "bad software modification that allowed an invalid snapshot to be " + "loaded. As a result of this, the node will shut down and stop using any " + "state that was built on the snapshot, resetting the chain height " + "from %d to %d. On the next " + "restart, the node will resume syncing from %d " + "without using any snapshot data. " + "Please report this incident to %s, including how you obtained the snapshot. " + "The invalid snapshot chainstate will be left on disk in case it is " + "helpful in diagnosing the issue that caused this error."), + PACKAGE_NAME, snapshot_tip_height, snapshot_base_height, snapshot_base_height, PACKAGE_BUGREPORT); + + LogPrintf("[snapshot] evo state mismatch: %s\n", reason); + LogPrintf("[snapshot] !!! %s\n", user_error.original); + LogPrintf("[snapshot] deleting snapshot, reverting to validated chain, and stopping node\n"); + + m_ibd_chainstate->ForceFlushStateToDisk(); + m_snapshot_chainstate->ForceFlushStateToDisk(); + if (!m_ibd_chainstate->m_evoDb.CommitRootTransaction(EvoDbIdentity::NORMAL, /*sync=*/true) || + !m_snapshot_chainstate->m_evoDb.CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true)) { + user_error += Untranslated("\nFailed to sync EvoDB before invalidating the snapshot."); + } + + m_active_chainstate = m_ibd_chainstate.get(); + // The active snapshot owns the mempool. Hand it back before disabling the + // snapshot so the restored background chainstate remains internally + // consistent for the rest of the shutdown path. + m_ibd_chainstate->m_mempool = m_snapshot_chainstate->m_mempool; + m_snapshot_chainstate->m_mempool = nullptr; + m_snapshot_chainstate->m_disabled = true; + assert(!IsUsable(m_snapshot_chainstate.get())); + assert(IsUsable(m_ibd_chainstate.get())); + + auto rename_result = m_snapshot_chainstate->InvalidateCoinsDBOnDisk(); + if (!rename_result) { + user_error += Untranslated("\n") + util::ErrorString(rename_result); + } else if (!m_ibd_chainstate->m_evoDb.DiscardSnapshotMarkers()) { + LogPrintf("[snapshot] failed to remove invalid snapshot EvoDB markers\n"); + } + shutdown_fnc(user_error); + return true; +} + SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( std::function shutdown_fnc) { @@ -6019,7 +6308,6 @@ SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( LogPrintf("[snapshot] on-disk snapshot base block is missing from the block index\n"); return SnapshotCompletionResult::BASE_BLOCKHASH_MISMATCH; } - const int snapshot_tip_height = this->ActiveHeight(); const int snapshot_base_height = *snapshot_base_height_opt; const CBlockIndex& index_new = *Assert(m_ibd_chainstate->m_chain.Tip()); @@ -6046,42 +6334,10 @@ SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( return SnapshotCompletionResult::STATS_FAILED; } - auto handle_invalid_snapshot = [&]() EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { - bilingual_str user_error = strprintf(_( - "%s failed to validate the -assumeutxo snapshot state. " - "This indicates a hardware problem, or a bug in the software, or a " - "bad software modification that allowed an invalid snapshot to be " - "loaded. As a result of this, the node will shut down and stop using any " - "state that was built on the snapshot, resetting the chain height " - "from %d to %d. On the next " - "restart, the node will resume syncing from %d " - "without using any snapshot data. " - "Please report this incident to %s, including how you obtained the snapshot. " - "The invalid snapshot chainstate will be left on disk in case it is " - "helpful in diagnosing the issue that caused this error."), - PACKAGE_NAME, snapshot_tip_height, snapshot_base_height, snapshot_base_height, PACKAGE_BUGREPORT - ); - - LogPrintf("[snapshot] !!! %s\n", user_error.original); - LogPrintf("[snapshot] deleting snapshot, reverting to validated chain, and stopping node\n"); - - m_active_chainstate = m_ibd_chainstate.get(); - // Hand the mempool back so the again-active background chainstate owns - // it for the remainder of this (shutting-down) run. - m_ibd_chainstate->m_mempool = m_snapshot_chainstate->m_mempool; - m_snapshot_chainstate->m_mempool = nullptr; - m_snapshot_chainstate->m_disabled = true; - assert(!this->IsUsable(m_snapshot_chainstate.get())); - assert(this->IsUsable(m_ibd_chainstate.get())); - - auto rename_result = m_snapshot_chainstate->InvalidateCoinsDBOnDisk(); - if (!rename_result) { - user_error += Untranslated("\n") + util::ErrorString(rename_result); - } else if (!m_ibd_chainstate->m_evoDb.DiscardSnapshotMarkers()) { - LogPrintf("[snapshot] failed to remove invalid snapshot EvoDB markers\n"); - } - - shutdown_fnc(user_error); + auto handle_invalid_snapshot = [&](const std::string& reason = "snapshot completion mismatch") + EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + const bool handled{HandleSnapshotStateMismatch(reason, shutdown_fnc)}; + assert(handled); }; if (index_new.GetBlockHash() != snapshot_blockhash) { @@ -6149,19 +6405,55 @@ SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( return SnapshotCompletionResult::HASH_MISMATCH; } + // The base block is necessarily available after background validation + // reaches it. Complete any CbTx checks that could not run at snapshot load. + if (DeploymentActiveAt(index_new, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + evo::CEvoSnapshot retained_snapshot; + CBlock base_block; + std::string evo_error; + if (!m_ibd_chainstate->m_evoDb.Read(EVODB_SNAPSHOT_EVO_SECTION, retained_snapshot) || + !ReadBlockFromDisk(base_block, &index_new, GetConsensus()) || base_block.vtx.empty()) { + LogPrintf("[snapshot] missing retained evo section/base block at completion\n"); + handle_invalid_snapshot(); + return SnapshotCompletionResult::EVO_STATE_MISMATCH; + } + const auto cbtx{GetTxPayload(*base_block.vtx[0])}; + std::map reconstructed_history; + bool history_matches{evo::ReconstructHistoricalMNLists(retained_snapshot, reconstructed_history, evo_error)}; + if (history_matches) { + for (const auto& [block_hash, reconstructed_list] : reconstructed_history) { + uint256 background_hash; + if (!m_ibd_chainstate->m_evoDb.ReadBackgroundWorkMNListHash(block_hash, background_hash) || + background_hash != evo::CanonicalMNListHash(reconstructed_list)) { + evo_error = "missing or mismatched background historical MN-list capture"; + history_matches = false; + break; + } + } + } + if (!history_matches || + !evo::ValidateEvoSnapshotAgainstChain(retained_snapshot, *this, &index_new, evo_error) || + !cbtx || !evo::VerifyEvoSnapshotCbTx(retained_snapshot, *cbtx, evo_error)) { + LogPrintf("[snapshot] deferred evo CbTx cross-check failed: %s\n", evo_error); + handle_invalid_snapshot(); + return SnapshotCompletionResult::EVO_STATE_MISMATCH; + } + } + // The snapshot marker records the derived deterministic-MN state that was // available when the snapshot chainstate began using the base block. Compare // it with the state independently derived by background validation. - // - // TODO(assumeutxo, M4-B4): extend the snapshot format and this comparison to - // the CbTx merkleRootMNList, merkleRootQuorums, and creditPool commitments. uint256 snapshot_mn_list_hash; if (!m_ibd_chainstate->m_evoDb.ReadSnapshotBaseMNListHash(snapshot_mn_list_hash)) { - // Cold-start activation could not capture the base MN list (the - // snapshot format carries no Dash payload yet), so there is nothing to - // compare against. The UTXO-set hash above remains the completion - // criterion, exactly as upstream. - LogPrintf("[snapshot] no base MN-list marker was captured at activation; skipping deterministic MN-list comparison\n"); + if (DeploymentActiveAt(index_new, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + LogPrintf("[snapshot] missing deterministic MN-list marker for evo snapshot\n"); + handle_invalid_snapshot("missing deterministic MN-list marker"); + return SnapshotCompletionResult::EVO_STATE_MISMATCH; + } + // Before DIP3 the snapshot has no evo payload. A cold-start activation + // may therefore have no independently derivable MN-list marker, leaving + // the UTXO-set hash as the completion criterion, exactly as upstream. + LogPrintf("[snapshot] no pre-DIP3 MN-list marker was captured at activation; skipping deterministic MN-list comparison\n"); } else { uint256 background_mn_list_block; uint256 background_mn_list_hash; diff --git a/src/validation.h b/src/validation.h index c325d8b637f2..b123547d12ea 100644 --- a/src/validation.h +++ b/src/validation.h @@ -578,6 +578,7 @@ class Chainstate * std::nullopt if this chainstate was not created from a snapshot. */ const std::optional m_from_snapshot_blockhash; + std::optional> m_required_background_mn_list_hashes; /** * The base of the snapshot this chainstate was created from. @@ -667,9 +668,10 @@ class Chainstate void ForceFlushStateToDisk(); /** Persist the hash of the MN list this chainstate derived when connecting - * the snapshot base block. No-op on any other block or chainstate, so - * ordinary block connection never pays for hashing the full list. */ + * the snapshot base or a required historical work block. No-op on any + * other block or chainstate. */ void RecordBackgroundMNListHash(const CBlockIndex* pindex, const CDeterministicMNList& mn_list); + void SetRequiredBackgroundMNListHashes(const std::vector& block_hashes); //! Prune blockfiles from the disk if necessary and then flush chainstate changes //! if we pruned. @@ -1081,6 +1083,14 @@ class ChainstateManager [](bilingual_str msg) { AbortNode(msg.original, msg); }) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + /** Mark the active assumeutxo chainstate invalid and shut down. Returns + * false when no snapshot is active or an EvoDB transaction must unwind + * before the operation can safely run. */ + bool HandleSnapshotStateMismatch( + const std::string& reason, + std::function shutdown_fnc = + [](bilingual_str msg) { AbortNode(msg.original, msg); }); + //! The most-work chain. Chainstate& ActiveChainstate() const; CChain& ActiveChain() const EXCLUSIVE_LOCKS_REQUIRED(GetMutex()) { return ActiveChainstate().m_chain; } diff --git a/test/functional/feature_assumeutxo_dash.py b/test/functional/feature_assumeutxo_dash.py new file mode 100755 index 000000000000..ed5c43509bba --- /dev/null +++ b/test/functional/feature_assumeutxo_dash.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +"""Exercise Dash evo emission by dumptxoutset (loading is added in M5).""" + +from pathlib import Path + +from test_framework.test_framework import DashTestFramework +from test_framework.util import assert_equal + + +class AssumeutxoDashTest(DashTestFramework): + def set_test_params(self): + # Keep rotation out of this minimal M4 emission test. The three enabled + # non-rotated test types each need two active plus one safety quorum. + args = [[ + "-testactivationheight=dip0024@999999", + "-vbparams=testdummy:999999999999:999999999999", + ] for _ in range(4)] + self.set_dash_test_params(4, 3, extra_args=args, evo_count=3) + self.set_dash_llmq_test_params(3, 2) + + def add_options(self, parser): + self.add_wallet_options(parser) + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + + def run_test(self): + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) + self.wait_for_sporks_same() + for _ in range(self.evo_count): + self.dynamically_add_masternode(evo=True) + + # Each DKG cycle forms all enabled non-rotated test quorum types. Four + # cycles cover llmq_test_platform's larger safety retention horizon. + for _ in range(4): + self.mine_quorum(llmq_type_name="llmq_test", llmq_type=100) + + node = self.nodes[0] + info = node.getblockchaininfo() + assert info["blocks"] >= 100 # DIP3, v19 and v20 are active in DashTestFramework. + result = node.dumptxoutset("assumeutxo-dash.dat") + assert_equal(result["base_height"], node.getblockcount()) + assert len(result["evo_hash"]) == 64 + assert result["evo_mn_count"] >= 3 + + snapshot_path = Path(node.datadir) / self.chain / "assumeutxo-dash.dat" + data = snapshot_path.read_bytes() + assert b"DASHEVO\x00" in data + + +if __name__ == "__main__": + AssumeutxoDashTest().main() diff --git a/test/functional/rpc_dumptxoutset.py b/test/functional/rpc_dumptxoutset.py index 690538997f0f..0dcb60dbed9e 100755 --- a/test/functional/rpc_dumptxoutset.py +++ b/test/functional/rpc_dumptxoutset.py @@ -45,11 +45,14 @@ def run_test(self): # UTXO snapshot hash should be deterministic based on mocked time. assert_equal( sha256sum_file(str(expected_path)).hex(), - '4a34cf865938252bf0bef702989955824d886f595afab596b1edac4dd31cd89f') + '3ee2d4e678f0bcb73e28648434e4b32b5f6ffa600625905ad18fae2a02f42b26') + assert b'DASHEVO\x00' in expected_path.read_bytes() assert_equal( out['txoutset_hash'], 'b2d7429106c96f5ab831843d5c96ba131ca8793111d0a0e30e7d7d8b4841e6cc') assert_equal(out['nchaintx'], 101) + assert_equal(out['evo_mn_count'], 0) + assert_equal(len(out['evo_hash']), 64) # Specifying a path to an existing or invalid file will fail. assert_raises_rpc_error( diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 56acde042f86..88c12d0a225e 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -354,6 +354,7 @@ 'wallet_fallbackfee.py --legacy-wallet', 'wallet_fallbackfee.py --descriptors', 'rpc_dumptxoutset.py', + 'feature_assumeutxo_dash.py', 'feature_minchainwork.py', 'rpc_estimatefee.py', 'p2p_unrequested_blocks.py', # NOTE: needs dash_hash to pass From 52d6a926418ea0abf6939d033bc830706f0e71f3 Mon Sep 17 00:00:00 2001 From: Pasta Date: Sat, 11 Jul 2026 12:31:20 -0500 Subject: [PATCH 3/8] fix: assumeutxo init-order integration (B6) --- src/init.cpp | 4 ++ src/node/blockstorage.cpp | 6 +++ src/node/blockstorage.h | 3 ++ src/node/chainstate.cpp | 31 +++++++++++ src/test/blockmanager_tests.cpp | 13 +++++ .../validation_chainstatemanager_tests.cpp | 51 +++++++++++++++++++ src/validation.cpp | 41 +++++++++++++-- src/validation.h | 4 ++ test/functional/feature_reindex.py | 11 ++++ 9 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/init.cpp b/src/init.cpp index 61b8ed7501ef..7ef07e98177d 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -2075,6 +2075,10 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) const bool quorums_watch = args.GetBoolArg("-watchquorums", llmq::DEFAULT_WATCH_QUORUMS); const llmq::QvvecSyncModeMap sync_map{llmq::GetEnabledQuorumVvecSyncEntries(args)}; const util::DbWrapperParams dash_db_params{.path = args.GetDataDirNet(), .memory = false, .wipe = (fReindex || fReindexChainState)}; + // TODO(assumeutxo M5): runtime loadtxoutset must recreate/rebind the + // Chainstate&-holding ActiveContext signers and NetInstantSend below after + // ChainstateManager switches active chainstates. Startup detection and the + // completion-time ResetChainstates path finish before these are constructed. if (const auto operator_sk_str = args.GetArg("-masternodeblsprivkey", ""); !operator_sk_str.empty()) { const CBLSSecretKey operator_sk{ParseHex(operator_sk_str)}; if (!operator_sk.IsValid()) { diff --git a/src/node/blockstorage.cpp b/src/node/blockstorage.cpp index cc847e32155d..045b665731be 100644 --- a/src/node/blockstorage.cpp +++ b/src/node/blockstorage.cpp @@ -249,6 +249,12 @@ void BlockManager::UpdatePruneLock(const std::string& name, const PruneLockInfo& m_prune_locks[name] = lock_info; } +bool BlockManager::DeletePruneLock(const std::string& name) +{ + AssertLockHeld(::cs_main); + return m_prune_locks.erase(name) > 0; +} + CBlockIndex* BlockManager::InsertBlockIndex(const uint256& hash) { AssertLockHeld(cs_main); diff --git a/src/node/blockstorage.h b/src/node/blockstorage.h index 66eb07f4b4e8..a7c2fa3987d7 100644 --- a/src/node/blockstorage.h +++ b/src/node/blockstorage.h @@ -226,6 +226,9 @@ class BlockManager //! Create or update a prune lock identified by its name void UpdatePruneLock(const std::string& name, const PruneLockInfo& lock_info) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + + //! Delete a prune lock identified by its name. Returns true if the lock existed. + bool DeletePruneLock(const std::string& name) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); }; void CleanupBlockRevFiles(); diff --git a/src/node/chainstate.cpp b/src/node/chainstate.cpp index 87c91b2dcfa6..757e7f6df6c8 100644 --- a/src/node/chainstate.cpp +++ b/src/node/chainstate.cpp @@ -37,6 +37,26 @@ #include namespace node { +static bool RemoveSnapshotChainstateArtifacts(const fs::path& data_dir, bilingual_str& error) +{ + // Explicit reindexing discards both coins databases and EvoDB. Remove every + // snapshot lifecycle directory at the same time so a directory whose b_dcs* + // markers were wiped cannot be detected as a resumable snapshot below. + for (const auto& name : {"chainstate_snapshot", "chainstate_snapshot_INVALID", "chainstate_todelete"}) { + const fs::path path{data_dir / name}; + if (!fs::exists(path)) continue; + try { + fs::remove_all(path); + DirectoryCommit(data_dir); + } catch (const fs::filesystem_error& e) { + error = strprintf(_("Failed to remove snapshot chainstate artifact %s for reindex: %s"), + fs::PathToString(path), e.what()); + return false; + } + } + return true; +} + static bool RecoverSnapshotCleanup(CEvoDB& evodb, const fs::path& data_dir, bilingual_str& error) { const fs::path normal{data_dir / "chainstate"}; @@ -189,6 +209,10 @@ static ChainstateLoadResult CompleteChainstateInitialization(ChainstateManager& return {ChainstateLoadStatus::FAILURE, _("Error loading block database")}; } + // Detection happens before LoadBlockIndex. Once the base is resolvable, + // keep its full block available for Dash's completion-time CbTx check. + chainman.ProtectSnapshotBaseFromPruning(); + if (!chainman.BlockIndex().empty() && !chainman.m_blockman.LookupBlockIndex(chainman.GetConsensus().hashGenesisBlock)) { // If the loaded chain has a wrong genesis, bail out immediately @@ -326,6 +350,13 @@ ChainstateLoadResult LoadChainstate(ChainstateManager& chainman, const CacheSize LOCK(cs_main); + if (options.reindex || options.reindex_chainstate) { + bilingual_str cleanup_error; + if (!RemoveSnapshotChainstateArtifacts(options.data_dir, cleanup_error)) { + return {ChainstateLoadStatus::FAILURE, cleanup_error}; + } + } + evodb.reset(); // TODO: pass DbWrapperParams as options instead multiple params evodb = std::make_unique(util::DbWrapperParams{ diff --git a/src/test/blockmanager_tests.cpp b/src/test/blockmanager_tests.cpp index f70cbc2c2ba3..9d4c0a59bd2d 100644 --- a/src/test/blockmanager_tests.cpp +++ b/src/test/blockmanager_tests.cpp @@ -85,4 +85,17 @@ BOOST_FIXTURE_TEST_CASE(blockmanager_scan_unlink_already_pruned_files, TestChain BOOST_CHECK(!CAutoFile(OpenBlockFile(new_pos, true), SER_DISK, CLIENT_VERSION).IsNull()); } +BOOST_FIXTURE_TEST_CASE(prune_lock_update_and_delete, TestingSetup) +{ + LOCK(::cs_main); + auto& chainman{*Assert(m_node.chainman)}; + auto& blockman{chainman.m_blockman}; + + blockman.UpdatePruneLock("test_lock", node::PruneLockInfo{.height_first = 100}); + blockman.UpdatePruneLock("test_lock", node::PruneLockInfo{.height_first = 200}); + BOOST_CHECK(blockman.DeletePruneLock("test_lock")); + BOOST_CHECK(!blockman.DeletePruneLock("test_lock")); + BOOST_CHECK(!blockman.DeletePruneLock("nonexistent")); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/validation_chainstatemanager_tests.cpp b/src/test/validation_chainstatemanager_tests.cpp index 4247538000a8..2972aed1893f 100644 --- a/src/test/validation_chainstatemanager_tests.cpp +++ b/src/test/validation_chainstatemanager_tests.cpp @@ -181,6 +181,57 @@ BOOST_AUTO_TEST_CASE(chainstatemanager) m_node.dmnman.reset(); } +BOOST_AUTO_TEST_CASE(snapshot_startup_missing_base_header_is_nonfatal) +{ + ChainstateManager& manager = *m_node.chainman; + Chainstate& background = WITH_LOCK(::cs_main, return manager.InitializeChainstate( + m_node.mempool.get(), *m_node.evodb, m_node.chain_helper)); + background.InitCoinsDB(/*cache_size_bytes=*/1 << 23, /*in_memory=*/true, /*should_wipe=*/false); + WITH_LOCK(::cs_main, background.InitCoinsCache(1 << 23)); + m_node.dmnman = std::make_unique(*m_node.evodb, *Assert(m_node.mn_metaman.get())); + DashChainstateSetup(manager, m_node, /*llmq_dbs_in_memory=*/true, /*llmq_dbs_wipe=*/false); + BOOST_REQUIRE(background.LoadGenesisBlock()); + + const uint256 missing_base{GetRandHash()}; + SeedSnapshotMarker(*m_node.evodb, missing_base); + Chainstate* snapshot = WITH_LOCK(::cs_main, return manager.ActivateExistingSnapshot( + m_node.mempool.get(), missing_base)); + BOOST_REQUIRE(snapshot); + + // Startup detection is allowed to precede receipt/loading of the base + // header. Accessors and background candidate setup must fail softly. + WITH_LOCK(::cs_main, { + BOOST_CHECK(snapshot->SnapshotBase() == nullptr); + const size_t candidates_before{background.setBlockIndexCandidates.size()}; + background.TryAddBlockIndexCandidate(manager.m_blockman.LookupBlockIndex( + manager.GetConsensus().hashGenesisBlock)); + BOOST_CHECK_EQUAL(background.setBlockIndexCandidates.size(), candidates_before); + }); + + DashChainstateSetupClose(m_node); + // dmnman holds a reference to m_node.evodb, it mustn't outlive it + m_node.dmnman.reset(); +} + +BOOST_FIXTURE_TEST_CASE(snapshot_prune_lock_release_survives_disconnect, TestChain100Setup) +{ + ChainstateManager& manager{*Assert(m_node.chainman)}; + + WITH_LOCK(::cs_main, { + manager.m_blockman.UpdatePruneLock("assumeutxo", {.height_first = manager.ActiveHeight()}); + manager.ReleaseSnapshotPruneLock(); + BOOST_CHECK(!manager.m_blockman.DeletePruneLock("assumeutxo")); + }); + + BlockValidationState state; + BOOST_REQUIRE(manager.ActiveChainstate().InvalidateBlock( + state, WITH_LOCK(::cs_main, return manager.ActiveTip()))); + + // DisconnectTip rewinds every remaining prune lock. The released snapshot + // lock must not be recreated or start constraining pruning after a reorg. + BOOST_CHECK(WITH_LOCK(::cs_main, return !manager.m_blockman.DeletePruneLock("assumeutxo"))); +} + //! Test rebalancing the caches associated with each chainstate. BOOST_FIXTURE_TEST_CASE(chainstatemanager_rebalance_caches, TestChain100Setup) { diff --git a/src/validation.cpp b/src/validation.cpp index c66144856aa8..c8072c6116cc 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -1668,9 +1668,10 @@ std::string Chainstate::EvoDbInconsistencyMessage() const CBlockIndex* Chainstate::SnapshotBase() { if (!m_from_snapshot_blockhash) return nullptr; - // Unlike upstream, a missing base block is not Assert()ed away: synthetic - // unit fixtures activate a snapshot chainstate before inserting its base - // into the block index, and ChainstateManager::LoadBlockIndex() reports a + // Unlike upstream, a missing base block is not Assert()ed away: snapshot + // detection precedes LoadBlockIndex during startup, synthetic unit + // fixtures activate a snapshot chainstate before inserting its base into + // the block index, and ChainstateManager::LoadBlockIndex() reports a // missing on-disk base as a startup error rather than an abort. Callers // that require existence Assert at the call site. if (!m_cached_snapshot_base) m_cached_snapshot_base = m_chainman.m_blockman.LookupBlockIndex(*m_from_snapshot_blockhash); @@ -3917,7 +3918,8 @@ void Chainstate::TryAddBlockIndexCandidate(CBlockIndex* pindex) // For the background chainstate, we only consider connecting blocks // towards the snapshot base (which can't be nullptr or else we'll // never make progress). - const CBlockIndex* snapshot_base{Assert(m_chainman.GetSnapshotBaseBlock())}; + const CBlockIndex* snapshot_base{m_chainman.GetSnapshotBaseBlock()}; + if (!snapshot_base) return; if (snapshot_base->GetAncestor(pindex->nHeight) == pindex) { setBlockIndexCandidates.insert(pindex); } @@ -5699,6 +5701,7 @@ bool ChainstateManager::ActivateSnapshot( } if (!snapshot_ok) { LOCK(::cs_main); + this->ReleaseSnapshotPruneLock(); this->MaybeRebalanceCaches(); // PopulateAndValidateSnapshot commits the snapshot best-block and @@ -5807,6 +5810,11 @@ bool ChainstateManager::PopulateAndValidateSnapshot( return false; } + // Protect the full base block before the long-running population step. + // Snapshot activation is not visible yet, so use the resolved base directly. + WITH_LOCK(::cs_main, m_blockman.UpdatePruneLock( + "assumeutxo", {.height_first = snapshot_start_block->nHeight})); + int base_height = snapshot_start_block->nHeight; auto maybe_au_data = ExpectedAssumeutxo(base_height, GetParams()); @@ -6273,6 +6281,7 @@ bool ChainstateManager::HandleSnapshotStateMismatch( m_ibd_chainstate->m_mempool = m_snapshot_chainstate->m_mempool; m_snapshot_chainstate->m_mempool = nullptr; m_snapshot_chainstate->m_disabled = true; + ReleaseSnapshotPruneLock(); assert(!IsUsable(m_snapshot_chainstate.get())); assert(IsUsable(m_ibd_chainstate.get())); @@ -6406,7 +6415,10 @@ SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( } // The base block is necessarily available after background validation - // reaches it. Complete any CbTx checks that could not run at snapshot load. + // reaches it. The assumeutxo prune lock is held until this check completes, + // so the shared BlockManager cannot prune the base out from under the + // snapshot chainstate. Complete any CbTx checks deferred at snapshot load. + assert(index_new.nStatus & BLOCK_HAVE_DATA); if (DeploymentActiveAt(index_new, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { evo::CEvoSnapshot retained_snapshot; CBlock base_block; @@ -6481,6 +6493,7 @@ SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( snapshot_blockhash.ToString()); m_ibd_chainstate->m_disabled = true; + ReleaseSnapshotPruneLock(); this->MaybeRebalanceCaches(); return SnapshotCompletionResult::SUCCESS; @@ -6608,6 +6621,24 @@ void ChainstateManager::ResetChainstates() m_active_chainstate = nullptr; } +void ChainstateManager::ProtectSnapshotBaseFromPruning() +{ + AssertLockHeld(::cs_main); + const CBlockIndex* base{GetSnapshotBaseBlock()}; + if (!base) return; + + // The generic prune-lock buffer makes this conservative: automatic and + // manual pruning both stop below the base, keeping its full block available + // for Dash's deferred CbTx/evo check at background-validation completion. + m_blockman.UpdatePruneLock("assumeutxo", {.height_first = base->nHeight}); +} + +void ChainstateManager::ReleaseSnapshotPruneLock() +{ + AssertLockHeld(::cs_main); + m_blockman.DeletePruneLock("assumeutxo"); +} + ChainstateManager::~ChainstateManager() { LOCK(::cs_main); diff --git a/src/validation.h b/src/validation.h index b123547d12ea..3e85ff3b90af 100644 --- a/src/validation.h +++ b/src/validation.h @@ -1246,6 +1246,10 @@ class ChainstateManager void ResetChainstates() EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + //! Keep the snapshot base block available for deferred Dash evo validation. + void ProtectSnapshotBaseFromPruning() EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + void ReleaseSnapshotPruneLock() EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + //! Switch the active chainstate to one based on a UTXO snapshot that was loaded //! previously. Chainstate* ActivateExistingSnapshot(CTxMemPool* mempool, uint256 base_blockhash) diff --git a/test/functional/feature_reindex.py b/test/functional/feature_reindex.py index 0a12c216adbc..6b74a99e3595 100755 --- a/test/functional/feature_reindex.py +++ b/test/functional/feature_reindex.py @@ -11,6 +11,7 @@ """ import os +from pathlib import Path from test_framework.test_framework import BitcoinTestFramework from test_framework.messages import MAGIC_BYTES from test_framework.util import assert_equal @@ -25,9 +26,19 @@ def reindex(self, justchainstate=False, txindex=0): self.generatetoaddress(self.nodes[0], 3, self.nodes[0].get_deterministic_priv_key().address) blockcount = self.nodes[0].getblockcount() self.stop_nodes() + chain_dir = Path(self.nodes[0].datadir) / self.nodes[0].chain + snapshot_artifacts = [ + chain_dir / "chainstate_snapshot", + chain_dir / "chainstate_snapshot_INVALID", + chain_dir / "chainstate_todelete", + ] + for artifact in snapshot_artifacts: + artifact.mkdir() + (artifact / "stale").touch() extra_args = [["-reindex-chainstate", "-txindex=0"]] if justchainstate else [["-reindex", f"-txindex={txindex}"]] self.start_nodes(extra_args) assert_equal(self.nodes[0].getblockcount(), blockcount) # start_node is blocking on reindex + assert all(not artifact.exists() for artifact in snapshot_artifacts) self.log.info("Success") # Check that blocks can be processed out of order From e396b359bd786a372ffa153d6db8b4bcd339e6e1 Mon Sep 17 00:00:00 2001 From: Pasta Date: Sat, 11 Jul 2026 12:43:10 -0500 Subject: [PATCH 4/8] refactor: break new circular dependencies from evo snapshot work --- src/Makefile.am | 3 + src/evo/snapshot.h | 7 +- src/evo/snapshot_load.cpp | 692 ++++++++++++++++++++++++++++++++++++++ src/evo/snapshot_types.h | 20 ++ src/llmq/utils.cpp | 2 +- src/validation.cpp | 662 ------------------------------------ 6 files changed, 717 insertions(+), 669 deletions(-) create mode 100644 src/evo/snapshot_load.cpp create mode 100644 src/evo/snapshot_types.h diff --git a/src/Makefile.am b/src/Makefile.am index 35da6bfb58a6..3ae4f9713b9e 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -231,6 +231,7 @@ BITCOIN_CORE_H = \ evo/mnauth.h \ evo/mnhftx.h \ evo/netinfo.h \ + evo/snapshot_types.h \ evo/providertx.h \ evo/simplifiedmns.h \ evo/smldiff.h \ @@ -540,6 +541,7 @@ libbitcoin_node_a_SOURCES = \ evo/mnauth.cpp \ evo/mnhftx.cpp \ evo/snapshot.cpp \ + evo/snapshot_load.cpp \ evo/providertx.cpp \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ @@ -1382,6 +1384,7 @@ libdashkernel_la_SOURCES = \ util/threadnames.cpp \ util/time.cpp \ util/tokenpipe.cpp \ + evo/snapshot_load.cpp \ validation.cpp \ validationinterface.cpp \ versionbits.cpp \ diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index 682d02b56156..e8a897b71397 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -41,12 +42,6 @@ class CQuorumSnapshotManager; namespace evo { -class SnapshotStateMismatchError : public std::runtime_error -{ -public: - using std::runtime_error::runtime_error; -}; - static constexpr uint16_t EVO_SNAPSHOT_VERSION{3}; /** Serialized little-endian bytes are "DASHEVO\0". */ static constexpr uint64_t EVO_SNAPSHOT_MARKER{0x004f564548534144ULL}; diff --git a/src/evo/snapshot_load.cpp b/src/evo/snapshot_load.cpp new file mode 100644 index 000000000000..ff40a5aa3985 --- /dev/null +++ b/src/evo/snapshot_load.cpp @@ -0,0 +1,692 @@ +// 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 +#include +#include +#include + +using kernel::CCoinsStats; +using kernel::CoinStatsHashType; +using kernel::ComputeUTXOStats; +using node::ReadBlockFromDisk; +using node::SnapshotMetadata; + +static void FlushSnapshotToDisk(CCoinsViewCache& coins_cache, bool snapshot_loaded) +{ + LOG_TIME_MILLIS_WITH_CATEGORY_MSG_ONCE( + strprintf("%s (%.2f MB)", + snapshot_loaded ? "saving snapshot chainstate" : "flushing coins cache", + coins_cache.DynamicMemoryUsage() / (1000 * 1000)), + BCLog::LogFlags::ALL); + + coins_cache.Flush(); +} + +struct StopHashingException : public std::exception +{ + const char* what() const throw() override + { + return "ComputeUTXOStats interrupted by shutdown."; + } +}; + +static void SnapshotUTXOHashBreakpoint() +{ + if (ShutdownRequested()) throw StopHashingException(); +} + +bool ChainstateManager::PopulateAndValidateSnapshot( + Chainstate& snapshot_chainstate, + AutoFile& coins_file, + const SnapshotMetadata& metadata) +{ + // It's okay to release cs_main before we're done using `coins_cache` because we know + // that nothing else will be referencing the newly created snapshot_chainstate yet. + CCoinsViewCache& coins_cache = WITH_LOCK(::cs_main, return snapshot_chainstate.CoinsTip()); + + uint256 base_blockhash = metadata.m_base_blockhash; + + CBlockIndex* snapshot_start_block = WITH_LOCK(::cs_main, return m_blockman.LookupBlockIndex(base_blockhash)); + + if (!snapshot_start_block) { + // Needed for ComputeUTXOStats and ExpectedAssumeutxo to determine the + // height and to avoid a crash when base_blockhash.IsNull() + LogPrintf("[snapshot] Did not find snapshot start blockheader %s\n", + base_blockhash.ToString()); + return false; + } + + // Protect the full base block before the long-running population step. + // Snapshot activation is not visible yet, so use the resolved base directly. + WITH_LOCK(::cs_main, m_blockman.UpdatePruneLock( + "assumeutxo", {.height_first = snapshot_start_block->nHeight})); + + int base_height = snapshot_start_block->nHeight; + auto maybe_au_data = ExpectedAssumeutxo(base_height, GetParams()); + + if (!maybe_au_data) { + LogPrintf("[snapshot] assumeutxo height in snapshot metadata not recognized " /* Continued */ + "(%d) - refusing to load snapshot\n", base_height); + return false; + } + + const AssumeutxoData& au_data = *maybe_au_data; + + COutPoint outpoint; + Coin coin; + const uint64_t coins_count = metadata.m_coins_count; + uint64_t coins_left = metadata.m_coins_count; + + LogPrintf("[snapshot] loading coins from snapshot %s\n", base_blockhash.ToString()); + int64_t coins_processed{0}; + + while (coins_left > 0) { + try { + coins_file >> outpoint; + coins_file >> coin; + } catch (const std::ios_base::failure&) { + LogPrintf("[snapshot] bad snapshot format or truncated snapshot after deserializing %d coins\n", + coins_count - coins_left); + return false; + } + if (coin.nHeight > base_height || + outpoint.n >= std::numeric_limits::max() // Avoid integer wrap-around in coinstats.cpp:ApplyHash + ) { + LogPrintf("[snapshot] bad snapshot data after deserializing %d coins\n", + coins_count - coins_left); + return false; + } + + coins_cache.EmplaceCoinInternalDANGER(std::move(outpoint), std::move(coin)); + + --coins_left; + ++coins_processed; + + if (coins_processed % 1000000 == 0) { + LogPrintf("[snapshot] %d coins loaded (%.2f%%, %.2f MB)\n", + coins_processed, + static_cast(coins_processed) * 100 / static_cast(coins_count), + coins_cache.DynamicMemoryUsage() / (1000 * 1000)); + } + + // Batch write and flush (if we need to) every so often. + // + // If our average Coin size is roughly 41 bytes, checking every 120,000 coins + // means <5MB of memory imprecision. + if (coins_processed % 120000 == 0) { + if (ShutdownRequested()) { + return false; + } + + const auto snapshot_cache_state = WITH_LOCK(::cs_main, + return snapshot_chainstate.GetCoinsCacheSizeState()); + + if (snapshot_cache_state >= CoinsCacheSizeState::CRITICAL) { + // This is a hack - we don't know what the actual best block is, but that + // doesn't matter for the purposes of flushing the cache here. We'll set this + // to its correct value (`base_blockhash`) below after the coins are loaded. + coins_cache.SetBestBlock(GetRandHash()); + + // No need to acquire cs_main since this chainstate isn't being used yet. + FlushSnapshotToDisk(coins_cache, /*snapshot_loaded=*/false); + } + } + } + + // Important that we set this. This and the coins_cache accesses above are + // sort of a layer violation, but either we reach into the innards of + // CCoinsViewCache here or we have to invert some of the Chainstate to + // embed them in a snapshot-activation-specific CCoinsViewCache bulk load + // method. + coins_cache.SetBestBlock(base_blockhash); + + std::optional evo_snapshot; + uint64_t evo_marker{0}; + try { + coins_file >> evo_marker; + } catch (const std::ios_base::failure&) { + if (DeploymentActiveAt(*snapshot_start_block, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + LogPrintf("[snapshot] missing evo section at DIP3-active base\n"); + return false; + } + } + if (evo_marker != 0) { + if (evo_marker != evo::EVO_SNAPSHOT_MARKER) { + LogPrintf("[snapshot] bad evo section marker (or coins left over) after %d coins\n", coins_count); + return false; + } + try { + evo_snapshot.emplace(); + OverrideStream evo_file{&coins_file, SER_DISK, CLIENT_VERSION}; + evo_file >> *evo_snapshot; + } catch (const std::ios_base::failure&) { + LogPrintf("[snapshot] truncated or invalid evo section\n"); + return false; + } + try { + uint8_t trailing; + coins_file >> trailing; + LogPrintf("[snapshot] trailing data after evo section\n"); + return false; + } catch (const std::ios_base::failure&) { + // EOF immediately after a completely decoded CEvoSnapshot is required. + } + } + + if (!evo_snapshot && DeploymentActiveAt(*snapshot_start_block, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + LogPrintf("[snapshot] UTXO-only snapshot refused at DIP3-active base\n"); + return false; + } + + LogPrintf("[snapshot] loaded %d (%.2f MB) coins from snapshot %s\n", + coins_count, + coins_cache.DynamicMemoryUsage() / (1000 * 1000), + base_blockhash.ToString()); + + // No need to acquire cs_main since this chainstate isn't being used yet. + FlushSnapshotToDisk(coins_cache, /*snapshot_loaded=*/true); + + assert(coins_cache.GetBestBlock() == base_blockhash); + + // As above, okay to immediately release cs_main here since no other context knows + // about the snapshot_chainstate. + CCoinsViewDB* snapshot_coinsdb = WITH_LOCK(::cs_main, return &snapshot_chainstate.CoinsDB()); + + std::optional maybe_stats; + + try { + maybe_stats = ComputeUTXOStats( + CoinStatsHashType::HASH_SERIALIZED, snapshot_coinsdb, m_blockman, SnapshotUTXOHashBreakpoint); + } catch (StopHashingException const&) { + return false; + } + if (!maybe_stats.has_value()) { + LogPrintf("[snapshot] failed to generate coins stats\n"); + return false; + } + + // Assert that the deserialized chainstate contents match the expected assumeutxo value. + if (AssumeutxoHash{maybe_stats->hashSerialized} != au_data.hash_serialized) { + LogPrintf("[snapshot] bad snapshot content hash: expected %s, got %s\n", + au_data.hash_serialized.ToString(), maybe_stats->hashSerialized.ToString()); + return false; + } + + if (evo_snapshot) { + std::string evo_error; + { + LOCK(::cs_main); + if (!evo::ValidateEvoSnapshotAgainstChain(*evo_snapshot, *this, snapshot_start_block, evo_error)) { + LogPrintf("[snapshot] bad evo snapshot chain data: %s\n", evo_error); + return false; + } + } + const uint256 actual_evo_hash{evo::GetEvoSnapshotHash(*evo_snapshot)}; + // Regtest entries use a null hash as an intentional M7 parameter slot. + // All structural/chain checks and the available CbTx checks still run. + if (au_data.evo_hash == EvoSnapshotHash{uint256::ZERO} && + GetParams().NetworkIDString() != CBaseChainParams::REGTEST) { + LogPrintf("[snapshot] null evo snapshot hash is only permitted on regtest\n"); + return false; + } + if (au_data.evo_hash != EvoSnapshotHash{uint256::ZERO} && + EvoSnapshotHash{actual_evo_hash} != au_data.evo_hash) { + LogPrintf("[snapshot] bad evo snapshot hash: expected %s, got %s\n", + au_data.evo_hash.ToString(), actual_evo_hash.ToString()); + return false; + } + + // CbTx is part of the full base block, not its header. Check it when the + // block is locally available; otherwise background validation's M3 + // canonical base-state comparison remains the load-time backstop. + const bool base_block_available{WITH_LOCK(::cs_main, return (snapshot_start_block->nStatus & BLOCK_HAVE_DATA) != 0;)}; + if (DeploymentActiveAt(*snapshot_start_block, GetConsensus(), Consensus::DEPLOYMENT_DIP0003) && + base_block_available) { + CBlock base_block; + if (!ReadBlockFromDisk(base_block, snapshot_start_block, GetConsensus()) || base_block.vtx.empty()) { + LogPrintf("[snapshot] failed to read available base block for evo CbTx check\n"); + return false; + } + const auto cbtx{GetTxPayload(*base_block.vtx[0])}; + if (!cbtx || !evo::VerifyEvoSnapshotCbTx(*evo_snapshot, *cbtx, evo_error)) { + LogPrintf("[snapshot] evo CbTx cross-check failed: %s\n", evo_error); + return false; + } + } else if (DeploymentActiveAt(*snapshot_start_block, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + LogPrintf("[snapshot] base block data unavailable; deferring evo CbTx cross-check to background validation\n"); + } + } + + snapshot_chainstate.m_chain.SetTip(*snapshot_start_block); + + // The remainder of this function requires modifying data protected by cs_main. + LOCK(::cs_main); + + // Fake various pieces of CBlockIndex state: + CBlockIndex* index = nullptr; + + // Don't make any modifications to the genesis block. + // This is especially important because we don't want to erroneously + // apply BLOCK_ASSUMED_VALID to genesis, which would happen if we didn't skip + // it here (since it apparently isn't BLOCK_VALID_SCRIPTS). + constexpr int AFTER_GENESIS_START{1}; + + for (int i = AFTER_GENESIS_START; i <= snapshot_chainstate.m_chain.Height(); ++i) { + index = snapshot_chainstate.m_chain[i]; + + // Fake nTx so that LoadBlockIndex() loads assumed-valid CBlockIndex + // entries (among other things) + if (!index->nTx) { + index->nTx = 1; + } + // Fake nChainTx so that GuessVerificationProgress reports accurately + index->nChainTx = index->pprev->nChainTx + index->nTx; + + // Mark unvalidated block index entries beneath the snapshot base block as assumed-valid. + if (!index->IsValid(BLOCK_VALID_SCRIPTS)) { + // This flag will be removed once the block is fully validated by a + // background chainstate. + index->nStatus |= BLOCK_ASSUMED_VALID; + } + + m_blockman.m_dirty_blockindex.insert(index); + // Changes to the block index will be flushed to disk after this call + // returns in `ActivateSnapshot()`, when `MaybeRebalanceCaches()` is + // called, since we've added a snapshot chainstate and therefore will + // have to downsize the IBD chainstate, which will result in a call to + // `FlushStateToDisk(ALWAYS)`. + } + + assert(index); + index->nChainTx = au_data.nChainTx; + snapshot_chainstate.setBlockIndexCandidates.insert(snapshot_start_block); + + // Before DIP3, the snapshot has no evo payload. Capture the base list only + // when the background chain has independently reached it; a cold-start + // lookup would otherwise fabricate and cache an empty initial list. + std::optional base_mn_list_hash; + if (const CBlockIndex* ibd_tip = m_ibd_chainstate->m_chain.Tip(); + ibd_tip != nullptr && ibd_tip->GetBlockHash() == base_blockhash) { + base_mn_list_hash = + snapshot_chainstate.ChainHelper().GetDeterministicMNListHash(snapshot_start_block); + auto db_tx = snapshot_chainstate.m_evoDb.BeginTransaction(::EvoDbIdentity::NORMAL); + snapshot_chainstate.m_evoDb.WriteBackgroundMNListHash(base_blockhash, *base_mn_list_hash); + db_tx->Commit(); + } + + // Snapshot lifecycle recovery depends on the background chainstate's + // independently captured MN-list hash. Make all preceding NORMAL writes + // durable before publishing the snapshot markers. + if (!snapshot_chainstate.m_evoDb.CommitRootTransaction(EvoDbIdentity::NORMAL, /*sync=*/true)) { + LogPrintf("[snapshot] failed to sync background EvoDB state\n"); + return false; + } + std::vector required_work_blocks; + if (evo_snapshot) { + required_work_blocks.reserve(evo_snapshot->historical_mn_list_diffs.size()); + for (const auto& entry : evo_snapshot->historical_mn_list_diffs) { + required_work_blocks.emplace_back(entry.block_hash); + } + std::sort(required_work_blocks.begin(), required_work_blocks.end()); + } + // Usually the background chain has not reached these blocks yet. If it + // has, hash its already-connected ordinary state now, before any snapshot + // seeds enter the shared EvoDB namespace. + std::map existing_background_work_hashes; + auto& background_dmnman{m_ibd_chainstate->ChainHelper().DeterministicMNManager()}; + for (const auto& block_hash : required_work_blocks) { + const CBlockIndex* index{m_blockman.LookupBlockIndex(block_hash)}; + assert(index != nullptr); + if (m_ibd_chainstate->m_chain.Contains(index)) { + existing_background_work_hashes.emplace( + block_hash, evo::CanonicalMNListHash(background_dmnman.GetListForBlock(index))); + } + } + { + auto db_tx = snapshot_chainstate.m_evoDb.BeginTransaction(EvoDbIdentity::SNAPSHOT); + if (evo_snapshot) { + auto& helper{snapshot_chainstate.ChainHelper()}; + auto& dmnman{helper.DeterministicMNManager()}; + auto& qblockman{helper.QuorumBlockProcessor()}; + auto& qsnapman{helper.QuorumSnapshotManager()}; + if (!dmnman.SeedListForBlock(evo_snapshot->mn_list)) { + LogPrintf("[snapshot] failed to seed base deterministic MN list\n"); + return false; + } + std::map historical_lists; + std::string reconstruction_error; + if (!evo::ReconstructHistoricalMNLists(*evo_snapshot, historical_lists, reconstruction_error)) { + LogPrintf("[snapshot] failed to reconstruct historical deterministic MN lists: %s\n", + reconstruction_error); + return false; + } + for (const auto& [_, historical_list] : historical_lists) { + if (!dmnman.SeedListForBlock(historical_list)) { + LogPrintf("[snapshot] failed to seed historical deterministic MN list\n"); + return false; + } + } + for (const auto& modifier : evo_snapshot->quorum_modifiers) { + if (!qsnapman.SeedQuorumModifier(modifier.llmq_type, modifier.work_block_hash, + modifier.modifier)) { + LogPrintf("[snapshot] failed to seed quorum score modifier\n"); + return false; + } + } + for (const auto& quorum_data : evo_snapshot->quorums) { + const auto seed_commitments = [&](const auto& commitments) { + LOCK(::cs_main); + for (const auto& entry : commitments) { + if (!qblockman.SeedMinedCommitment(quorum_data.llmq_type, entry.quorum_base_block_hash, + entry.commitment, entry.mined_block_hash)) return false; + } + return true; + }; + if (!seed_commitments(quorum_data.active_commitments) || + !seed_commitments(quorum_data.safety_commitments)) { + LogPrintf("[snapshot] failed to seed mined quorum commitment\n"); + return false; + } + for (const auto& rotation : quorum_data.rotation_snapshots) { + const CBlockIndex* cycle_index{m_blockman.LookupBlockIndex(rotation.cycle_base_block_hash)}; + assert(cycle_index != nullptr); + if (!qsnapman.SeedSnapshotForBlock(quorum_data.llmq_type, cycle_index, rotation.snapshot)) { + LogPrintf("[snapshot] failed to seed quorum rotation snapshot\n"); + return false; + } + } + } + if (!helper.credit_pool_manager->SeedSnapshot(snapshot_start_block, evo_snapshot->credit_pool) || + !helper.ehf_manager->SeedSignals(snapshot_start_block, evo_snapshot->mnhf_signals)) { + LogPrintf("[snapshot] failed to seed credit-pool/MNHF state\n"); + return false; + } + if (!snapshot_chainstate.m_evoDb.WriteDerived(EVODB_SNAPSHOT_EVO_SECTION, *evo_snapshot)) { + LogPrintf("[snapshot] failed to retain evo section for deferred CbTx validation\n"); + return false; + } + } + snapshot_chainstate.m_evoDb.WriteBestBlock(EvoDbIdentity::SNAPSHOT, base_blockhash); + if (evo_snapshot) { + snapshot_chainstate.m_evoDb.WriteSnapshotBaseMNListHash( + evo::CanonicalMNListHash(evo_snapshot->mn_list)); + } else if (base_mn_list_hash.has_value()) { + snapshot_chainstate.m_evoDb.WriteSnapshotBaseMNListHash(*base_mn_list_hash); + } + snapshot_chainstate.m_evoDb.WriteDualChainstateMarker(); + db_tx->Commit(); + } + { + // This bounded required set and its independently computed captures + // belong to the NORMAL identity. Future background connects consult + // the in-memory mirror before recording their canonical hash. + auto db_tx = snapshot_chainstate.m_evoDb.BeginTransaction(EvoDbIdentity::NORMAL); + snapshot_chainstate.m_evoDb.WriteRequiredWorkMNListHashes(required_work_blocks); + for (const auto& [block_hash, mn_list_hash] : existing_background_work_hashes) { + snapshot_chainstate.m_evoDb.WriteBackgroundWorkMNListHash(block_hash, mn_list_hash); + } + db_tx->Commit(); + } + if (!snapshot_chainstate.m_evoDb.CommitRootTransaction(EvoDbIdentity::NORMAL, /*sync=*/true)) { + LogPrintf("[snapshot] failed to commit required historical MN-list marker\n"); + return false; + } + m_ibd_chainstate->SetRequiredBackgroundMNListHashes(required_work_blocks); + if (!snapshot_chainstate.m_evoDb.CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true)) { + LogPrintf("[snapshot] failed to commit snapshot EvoDB marker\n"); + return false; + } + if (evo_snapshot) { + auto& helper{snapshot_chainstate.ChainHelper()}; + auto& dmnman{helper.DeterministicMNManager()}; + dmnman.InvalidateListCacheForBlock(base_blockhash); + for (const auto& historical : evo_snapshot->historical_mn_list_diffs) { + dmnman.InvalidateListCacheForBlock(historical.block_hash); + } + for (const auto& quorum_data : evo_snapshot->quorums) { + for (const auto& rotation : quorum_data.rotation_snapshots) { + helper.QuorumSnapshotManager().InvalidateSnapshotCacheForBlock( + quorum_data.llmq_type, rotation.cycle_base_block_hash); + } + } + } + + LogPrintf("[snapshot] validated snapshot (%.2f MB)\n", + coins_cache.DynamicMemoryUsage() / (1000 * 1000)); + return true; +} + +SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( + std::function shutdown_fnc) +{ + AssertLockHeld(cs_main); + if (m_ibd_chainstate.get() == &this->ActiveChainstate() || + !this->IsUsable(m_snapshot_chainstate.get()) || + !this->IsUsable(m_ibd_chainstate.get()) || + !m_ibd_chainstate->m_chain.Tip()) { + // Nothing to do - this function only applies to the background + // validation chainstate. + return SnapshotCompletionResult::SKIPPED; + } + const auto snapshot_base_height_opt = this->GetSnapshotBaseHeight(); + if (!snapshot_base_height_opt) { + if (!m_snapshot_chainstate->CoinsDB().StoragePath()) { + // Some Dash unit fixtures construct a synthetic in-memory snapshot + // chainstate before inserting its base block into the block index. + return SnapshotCompletionResult::SKIPPED; + } + LogPrintf("[snapshot] on-disk snapshot base block is missing from the block index\n"); + return SnapshotCompletionResult::BASE_BLOCKHASH_MISMATCH; + } + const int snapshot_base_height = *snapshot_base_height_opt; + const CBlockIndex& index_new = *Assert(m_ibd_chainstate->m_chain.Tip()); + + if (index_new.nHeight < snapshot_base_height) { + // Background IBD not complete yet. + return SnapshotCompletionResult::SKIPPED; + } + + assert(SnapshotBlockhash()); + uint256 snapshot_blockhash = *Assert(SnapshotBlockhash()); + + // Completion is serialized by cs_main. Flush each identity in sequence so + // CEvoDB's single-open-transaction invariant is preserved and marker + // promotion can atomically operate on fully committed transaction trees. + m_ibd_chainstate->ForceFlushStateToDisk(); + m_snapshot_chainstate->ForceFlushStateToDisk(); + if (!m_ibd_chainstate->m_evoDb.CommitRootTransaction(EvoDbIdentity::NORMAL, /*sync=*/true) || + !m_snapshot_chainstate->m_evoDb.CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true)) { + // ConnectTip discards this result and would not retry after the + // background tip reached the base, so treat the write failure as the + // unrecoverable database error it is. + AbortNode("Failed to sync EvoDB state for snapshot completion"); + return SnapshotCompletionResult::STATS_FAILED; + } + + auto handle_invalid_snapshot = [&](const std::string& reason = "snapshot completion mismatch") + EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + const bool handled{HandleSnapshotStateMismatch(reason, shutdown_fnc)}; + assert(handled); + }; + + if (index_new.GetBlockHash() != snapshot_blockhash) { + LogPrintf("[snapshot] supposed base block %s does not match the " /* Continued */ + "snapshot base block %s (height %d). Snapshot is not valid.", + index_new.ToString(), snapshot_blockhash.ToString(), snapshot_base_height); + handle_invalid_snapshot(); + return SnapshotCompletionResult::BASE_BLOCKHASH_MISMATCH; + } + + assert(index_new.nHeight == snapshot_base_height); + + int curr_height = m_ibd_chainstate->m_chain.Height(); + + assert(snapshot_base_height == curr_height); + assert(this->IsUsable(m_snapshot_chainstate.get())); + assert(this->GetAll().size() == 2); + + CCoinsViewDB& ibd_coins_db = m_ibd_chainstate->CoinsDB(); + + auto maybe_au_data = ExpectedAssumeutxo(curr_height, ::Params()); + if (!maybe_au_data) { + LogPrintf("[snapshot] assumeutxo data not found for height " /* Continued */ + "(%d) - refusing to validate snapshot\n", curr_height); + handle_invalid_snapshot(); + return SnapshotCompletionResult::MISSING_CHAINPARAMS; + } + + const AssumeutxoData& au_data = *maybe_au_data; + std::optional maybe_ibd_stats; + LogPrintf("[snapshot] computing UTXO stats for background chainstate to validate " /* Continued */ + "snapshot - this could take a few minutes\n"); + try { + maybe_ibd_stats = ComputeUTXOStats( + CoinStatsHashType::HASH_SERIALIZED, + &ibd_coins_db, + m_blockman, + SnapshotUTXOHashBreakpoint); + } catch (StopHashingException const&) { + return SnapshotCompletionResult::STATS_FAILED; + } + + // XXX note that this function is slow and will hold cs_main for potentially minutes. + if (!maybe_ibd_stats) { + LogPrintf("[snapshot] failed to generate stats for validation coins db\n"); + // While this isn't a problem with the snapshot per se, this condition + // prevents us from validating the snapshot, so we should shut down and let the + // user handle the issue manually. + handle_invalid_snapshot(); + return SnapshotCompletionResult::STATS_FAILED; + } + const auto& ibd_stats = *maybe_ibd_stats; + + // Compare the background validation chainstate's UTXO set hash against the hard-coded + // assumeutxo hash we expect. + // + // TODO: For belt-and-suspenders, we could cache the UTXO set + // hash for the snapshot when it's loaded in its chainstate's leveldb. We could then + // reference that here for an additional check. + if (AssumeutxoHash{ibd_stats.hashSerialized} != au_data.hash_serialized) { + LogPrintf("[snapshot] hash mismatch: actual=%s, expected=%s\n", + ibd_stats.hashSerialized.ToString(), + au_data.hash_serialized.ToString()); + handle_invalid_snapshot(); + return SnapshotCompletionResult::HASH_MISMATCH; + } + + // The base block is necessarily available after background validation + // reaches it. The assumeutxo prune lock is held until this check completes, + // so the shared BlockManager cannot prune the base out from under the + // snapshot chainstate. Complete any CbTx checks deferred at snapshot load. + assert(index_new.nStatus & BLOCK_HAVE_DATA); + if (DeploymentActiveAt(index_new, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + evo::CEvoSnapshot retained_snapshot; + CBlock base_block; + std::string evo_error; + if (!m_ibd_chainstate->m_evoDb.Read(EVODB_SNAPSHOT_EVO_SECTION, retained_snapshot) || + !ReadBlockFromDisk(base_block, &index_new, GetConsensus()) || base_block.vtx.empty()) { + LogPrintf("[snapshot] missing retained evo section/base block at completion\n"); + handle_invalid_snapshot(); + return SnapshotCompletionResult::EVO_STATE_MISMATCH; + } + const auto cbtx{GetTxPayload(*base_block.vtx[0])}; + std::map reconstructed_history; + bool history_matches{evo::ReconstructHistoricalMNLists(retained_snapshot, reconstructed_history, evo_error)}; + if (history_matches) { + for (const auto& [block_hash, reconstructed_list] : reconstructed_history) { + uint256 background_hash; + if (!m_ibd_chainstate->m_evoDb.ReadBackgroundWorkMNListHash(block_hash, background_hash) || + background_hash != evo::CanonicalMNListHash(reconstructed_list)) { + evo_error = "missing or mismatched background historical MN-list capture"; + history_matches = false; + break; + } + } + } + if (!history_matches || + !evo::ValidateEvoSnapshotAgainstChain(retained_snapshot, *this, &index_new, evo_error) || + !cbtx || !evo::VerifyEvoSnapshotCbTx(retained_snapshot, *cbtx, evo_error)) { + LogPrintf("[snapshot] deferred evo CbTx cross-check failed: %s\n", evo_error); + handle_invalid_snapshot(); + return SnapshotCompletionResult::EVO_STATE_MISMATCH; + } + } + + // The snapshot marker records the derived deterministic-MN state that was + // available when the snapshot chainstate began using the base block. Compare + // it with the state independently derived by background validation. + uint256 snapshot_mn_list_hash; + if (!m_ibd_chainstate->m_evoDb.ReadSnapshotBaseMNListHash(snapshot_mn_list_hash)) { + if (DeploymentActiveAt(index_new, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + LogPrintf("[snapshot] missing deterministic MN-list marker for evo snapshot\n"); + handle_invalid_snapshot("missing deterministic MN-list marker"); + return SnapshotCompletionResult::EVO_STATE_MISMATCH; + } + // A pre-DIP3 cold-start snapshot has no evo payload from which to + // derive this marker. Its UTXO-set hash remains the completion check. + LogPrintf("[snapshot] no pre-DIP3 MN-list marker was captured at activation; skipping deterministic MN-list comparison\n"); + } else { + uint256 background_mn_list_block; + uint256 background_mn_list_hash; + if (!m_ibd_chainstate->m_evoDb.ReadBackgroundMNListHash( + background_mn_list_block, background_mn_list_hash) || + background_mn_list_block != snapshot_blockhash || + snapshot_mn_list_hash != background_mn_list_hash) { + LogPrintf("[snapshot] deterministic MN list mismatch at base block: captured_block=%s, actual=%s, expected=%s\n", + background_mn_list_block.ToString(), background_mn_list_hash.ToString(), + snapshot_mn_list_hash.ToString()); + handle_invalid_snapshot(); + return SnapshotCompletionResult::EVO_STATE_MISMATCH; + } + } + + const uint256 snapshot_tip = m_snapshot_chainstate->CoinsTip().GetBestBlock(); + if (!m_ibd_chainstate->m_evoDb.VerifyBestBlock(EvoDbIdentity::NORMAL, snapshot_blockhash) || + !m_snapshot_chainstate->m_evoDb.VerifyBestBlock(EvoDbIdentity::SNAPSHOT, snapshot_tip)) { + LogPrintf("[snapshot] EvoDB best-block markers do not match their chainstate tips\n"); + handle_invalid_snapshot(); + return SnapshotCompletionResult::EVO_STATE_MISMATCH; + } + + LogPrintf("[snapshot] snapshot beginning at %s has been fully validated\n", + snapshot_blockhash.ToString()); + + m_ibd_chainstate->m_disabled = true; + ReleaseSnapshotPruneLock(); + this->MaybeRebalanceCaches(); + + return SnapshotCompletionResult::SUCCESS; +} diff --git a/src/evo/snapshot_types.h b/src/evo/snapshot_types.h new file mode 100644 index 000000000000..a6b4389ef1e5 --- /dev/null +++ b/src/evo/snapshot_types.h @@ -0,0 +1,20 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_EVO_SNAPSHOT_TYPES_H +#define BITCOIN_EVO_SNAPSHOT_TYPES_H + +#include + +namespace evo { + +class SnapshotStateMismatchError : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +} // namespace evo + +#endif // BITCOIN_EVO_SNAPSHOT_TYPES_H diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index a25ed161f1aa..faa4343731ea 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -6,7 +6,7 @@ #include #include -#include +#include #include #include #include diff --git a/src/validation.cpp b/src/validation.cpp index c8072c6116cc..6da5922b4321 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -5765,464 +5765,6 @@ bool ChainstateManager::ActivateSnapshot( return true; } -static void FlushSnapshotToDisk(CCoinsViewCache& coins_cache, bool snapshot_loaded) -{ - LOG_TIME_MILLIS_WITH_CATEGORY_MSG_ONCE( - strprintf("%s (%.2f MB)", - snapshot_loaded ? "saving snapshot chainstate" : "flushing coins cache", - coins_cache.DynamicMemoryUsage() / (1000 * 1000)), - BCLog::LogFlags::ALL); - - coins_cache.Flush(); -} - -struct StopHashingException : public std::exception -{ - const char* what() const throw() override - { - return "ComputeUTXOStats interrupted by shutdown."; - } -}; - -static void SnapshotUTXOHashBreakpoint() -{ - if (ShutdownRequested()) throw StopHashingException(); -} - -bool ChainstateManager::PopulateAndValidateSnapshot( - Chainstate& snapshot_chainstate, - AutoFile& coins_file, - const SnapshotMetadata& metadata) -{ - // It's okay to release cs_main before we're done using `coins_cache` because we know - // that nothing else will be referencing the newly created snapshot_chainstate yet. - CCoinsViewCache& coins_cache = WITH_LOCK(::cs_main, return snapshot_chainstate.CoinsTip()); - - uint256 base_blockhash = metadata.m_base_blockhash; - - CBlockIndex* snapshot_start_block = WITH_LOCK(::cs_main, return m_blockman.LookupBlockIndex(base_blockhash)); - - if (!snapshot_start_block) { - // Needed for ComputeUTXOStats and ExpectedAssumeutxo to determine the - // height and to avoid a crash when base_blockhash.IsNull() - LogPrintf("[snapshot] Did not find snapshot start blockheader %s\n", - base_blockhash.ToString()); - return false; - } - - // Protect the full base block before the long-running population step. - // Snapshot activation is not visible yet, so use the resolved base directly. - WITH_LOCK(::cs_main, m_blockman.UpdatePruneLock( - "assumeutxo", {.height_first = snapshot_start_block->nHeight})); - - int base_height = snapshot_start_block->nHeight; - auto maybe_au_data = ExpectedAssumeutxo(base_height, GetParams()); - - if (!maybe_au_data) { - LogPrintf("[snapshot] assumeutxo height in snapshot metadata not recognized " /* Continued */ - "(%d) - refusing to load snapshot\n", base_height); - return false; - } - - const AssumeutxoData& au_data = *maybe_au_data; - - COutPoint outpoint; - Coin coin; - const uint64_t coins_count = metadata.m_coins_count; - uint64_t coins_left = metadata.m_coins_count; - - LogPrintf("[snapshot] loading coins from snapshot %s\n", base_blockhash.ToString()); - int64_t coins_processed{0}; - - while (coins_left > 0) { - try { - coins_file >> outpoint; - coins_file >> coin; - } catch (const std::ios_base::failure&) { - LogPrintf("[snapshot] bad snapshot format or truncated snapshot after deserializing %d coins\n", - coins_count - coins_left); - return false; - } - if (coin.nHeight > base_height || - outpoint.n >= std::numeric_limits::max() // Avoid integer wrap-around in coinstats.cpp:ApplyHash - ) { - LogPrintf("[snapshot] bad snapshot data after deserializing %d coins\n", - coins_count - coins_left); - return false; - } - - coins_cache.EmplaceCoinInternalDANGER(std::move(outpoint), std::move(coin)); - - --coins_left; - ++coins_processed; - - if (coins_processed % 1000000 == 0) { - LogPrintf("[snapshot] %d coins loaded (%.2f%%, %.2f MB)\n", - coins_processed, - static_cast(coins_processed) * 100 / static_cast(coins_count), - coins_cache.DynamicMemoryUsage() / (1000 * 1000)); - } - - // Batch write and flush (if we need to) every so often. - // - // If our average Coin size is roughly 41 bytes, checking every 120,000 coins - // means <5MB of memory imprecision. - if (coins_processed % 120000 == 0) { - if (ShutdownRequested()) { - return false; - } - - const auto snapshot_cache_state = WITH_LOCK(::cs_main, - return snapshot_chainstate.GetCoinsCacheSizeState()); - - if (snapshot_cache_state >= CoinsCacheSizeState::CRITICAL) { - // This is a hack - we don't know what the actual best block is, but that - // doesn't matter for the purposes of flushing the cache here. We'll set this - // to its correct value (`base_blockhash`) below after the coins are loaded. - coins_cache.SetBestBlock(GetRandHash()); - - // No need to acquire cs_main since this chainstate isn't being used yet. - FlushSnapshotToDisk(coins_cache, /*snapshot_loaded=*/false); - } - } - } - - // Important that we set this. This and the coins_cache accesses above are - // sort of a layer violation, but either we reach into the innards of - // CCoinsViewCache here or we have to invert some of the Chainstate to - // embed them in a snapshot-activation-specific CCoinsViewCache bulk load - // method. - coins_cache.SetBestBlock(base_blockhash); - - std::optional evo_snapshot; - uint64_t evo_marker{0}; - try { - coins_file >> evo_marker; - } catch (const std::ios_base::failure&) { - if (DeploymentActiveAt(*snapshot_start_block, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { - LogPrintf("[snapshot] missing evo section at DIP3-active base\n"); - return false; - } - } - if (evo_marker != 0) { - if (evo_marker != evo::EVO_SNAPSHOT_MARKER) { - LogPrintf("[snapshot] bad evo section marker (or coins left over) after %d coins\n", coins_count); - return false; - } - try { - evo_snapshot.emplace(); - OverrideStream evo_file{&coins_file, SER_DISK, CLIENT_VERSION}; - evo_file >> *evo_snapshot; - } catch (const std::ios_base::failure&) { - LogPrintf("[snapshot] truncated or invalid evo section\n"); - return false; - } - try { - uint8_t trailing; - coins_file >> trailing; - LogPrintf("[snapshot] trailing data after evo section\n"); - return false; - } catch (const std::ios_base::failure&) { - // EOF immediately after a completely decoded CEvoSnapshot is required. - } - } - - if (!evo_snapshot && DeploymentActiveAt(*snapshot_start_block, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { - LogPrintf("[snapshot] UTXO-only snapshot refused at DIP3-active base\n"); - return false; - } - - LogPrintf("[snapshot] loaded %d (%.2f MB) coins from snapshot %s\n", - coins_count, - coins_cache.DynamicMemoryUsage() / (1000 * 1000), - base_blockhash.ToString()); - - // No need to acquire cs_main since this chainstate isn't being used yet. - FlushSnapshotToDisk(coins_cache, /*snapshot_loaded=*/true); - - assert(coins_cache.GetBestBlock() == base_blockhash); - - // As above, okay to immediately release cs_main here since no other context knows - // about the snapshot_chainstate. - CCoinsViewDB* snapshot_coinsdb = WITH_LOCK(::cs_main, return &snapshot_chainstate.CoinsDB()); - - std::optional maybe_stats; - - try { - maybe_stats = ComputeUTXOStats( - CoinStatsHashType::HASH_SERIALIZED, snapshot_coinsdb, m_blockman, SnapshotUTXOHashBreakpoint); - } catch (StopHashingException const&) { - return false; - } - if (!maybe_stats.has_value()) { - LogPrintf("[snapshot] failed to generate coins stats\n"); - return false; - } - - // Assert that the deserialized chainstate contents match the expected assumeutxo value. - if (AssumeutxoHash{maybe_stats->hashSerialized} != au_data.hash_serialized) { - LogPrintf("[snapshot] bad snapshot content hash: expected %s, got %s\n", - au_data.hash_serialized.ToString(), maybe_stats->hashSerialized.ToString()); - return false; - } - - if (evo_snapshot) { - std::string evo_error; - { - LOCK(::cs_main); - if (!evo::ValidateEvoSnapshotAgainstChain(*evo_snapshot, *this, snapshot_start_block, evo_error)) { - LogPrintf("[snapshot] bad evo snapshot chain data: %s\n", evo_error); - return false; - } - } - const uint256 actual_evo_hash{evo::GetEvoSnapshotHash(*evo_snapshot)}; - // Regtest entries use a null hash as an intentional M7 parameter slot. - // All structural/chain checks and the available CbTx checks still run. - if (au_data.evo_hash == EvoSnapshotHash{uint256::ZERO} && - GetParams().NetworkIDString() != CBaseChainParams::REGTEST) { - LogPrintf("[snapshot] null evo snapshot hash is only permitted on regtest\n"); - return false; - } - if (au_data.evo_hash != EvoSnapshotHash{uint256::ZERO} && - EvoSnapshotHash{actual_evo_hash} != au_data.evo_hash) { - LogPrintf("[snapshot] bad evo snapshot hash: expected %s, got %s\n", - au_data.evo_hash.ToString(), actual_evo_hash.ToString()); - return false; - } - - // CbTx is part of the full base block, not its header. Check it when the - // block is locally available; otherwise background validation's M3 - // canonical base-state comparison remains the load-time backstop. - const bool base_block_available{WITH_LOCK(::cs_main, return (snapshot_start_block->nStatus & BLOCK_HAVE_DATA) != 0;)}; - if (DeploymentActiveAt(*snapshot_start_block, GetConsensus(), Consensus::DEPLOYMENT_DIP0003) && - base_block_available) { - CBlock base_block; - if (!ReadBlockFromDisk(base_block, snapshot_start_block, GetConsensus()) || base_block.vtx.empty()) { - LogPrintf("[snapshot] failed to read available base block for evo CbTx check\n"); - return false; - } - const auto cbtx{GetTxPayload(*base_block.vtx[0])}; - if (!cbtx || !evo::VerifyEvoSnapshotCbTx(*evo_snapshot, *cbtx, evo_error)) { - LogPrintf("[snapshot] evo CbTx cross-check failed: %s\n", evo_error); - return false; - } - } else if (DeploymentActiveAt(*snapshot_start_block, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { - LogPrintf("[snapshot] base block data unavailable; deferring evo CbTx cross-check to background validation\n"); - } - } - - snapshot_chainstate.m_chain.SetTip(*snapshot_start_block); - - // The remainder of this function requires modifying data protected by cs_main. - LOCK(::cs_main); - - // Fake various pieces of CBlockIndex state: - CBlockIndex* index = nullptr; - - // Don't make any modifications to the genesis block. - // This is especially important because we don't want to erroneously - // apply BLOCK_ASSUMED_VALID to genesis, which would happen if we didn't skip - // it here (since it apparently isn't BLOCK_VALID_SCRIPTS). - constexpr int AFTER_GENESIS_START{1}; - - for (int i = AFTER_GENESIS_START; i <= snapshot_chainstate.m_chain.Height(); ++i) { - index = snapshot_chainstate.m_chain[i]; - - // Fake nTx so that LoadBlockIndex() loads assumed-valid CBlockIndex - // entries (among other things) - if (!index->nTx) { - index->nTx = 1; - } - // Fake nChainTx so that GuessVerificationProgress reports accurately - index->nChainTx = index->pprev->nChainTx + index->nTx; - - // Mark unvalidated block index entries beneath the snapshot base block as assumed-valid. - if (!index->IsValid(BLOCK_VALID_SCRIPTS)) { - // This flag will be removed once the block is fully validated by a - // background chainstate. - index->nStatus |= BLOCK_ASSUMED_VALID; - } - - m_blockman.m_dirty_blockindex.insert(index); - // Changes to the block index will be flushed to disk after this call - // returns in `ActivateSnapshot()`, when `MaybeRebalanceCaches()` is - // called, since we've added a snapshot chainstate and therefore will - // have to downsize the IBD chainstate, which will result in a call to - // `FlushStateToDisk(ALWAYS)`. - } - - assert(index); - index->nChainTx = au_data.nChainTx; - snapshot_chainstate.setBlockIndexCandidates.insert(snapshot_start_block); - - // Until the loadtxoutset milestone the snapshot carries no Dash payload, - // so the base MN list is only derivable when this node's own background - // chainstate has already validated the base block. On a cold start - // (background tip below the base) it is not derivable at all: attempting - // the lookup would take GetListForBlockInternal's legacy bootstrap branch - // (the dual-chainstate marker is not durable yet at this point), fabricate - // an empty "initial snapshot" list for the base block, and poison the - // shared list cache that the background chainstate later derives base+1 - // from. Capture the lifecycle hashes only when the base state genuinely - // exists; completion skips the comparison when the markers are absent. - // The background chainstate never re-connects a base block it has already - // validated, so RecordBackgroundMNListHash cannot fire for it either -- - // this capture stands in for it. - // TODO(assumeutxo, loadtxoutset): once the snapshot payload carries the - // base MN list, derive the SNAPSHOT-side marker from the payload so it is - // always present and independent of local state. - std::optional base_mn_list_hash; - if (const CBlockIndex* ibd_tip = m_ibd_chainstate->m_chain.Tip(); - ibd_tip != nullptr && ibd_tip->GetBlockHash() == base_blockhash) { - base_mn_list_hash = - snapshot_chainstate.ChainHelper().GetDeterministicMNListHash(snapshot_start_block); - auto db_tx = snapshot_chainstate.m_evoDb.BeginTransaction(::EvoDbIdentity::NORMAL); - snapshot_chainstate.m_evoDb.WriteBackgroundMNListHash(base_blockhash, *base_mn_list_hash); - db_tx->Commit(); - } - - // Snapshot lifecycle recovery depends on the background chainstate's - // independently captured MN-list hash. Make all preceding NORMAL writes - // durable before publishing the snapshot markers. - if (!snapshot_chainstate.m_evoDb.CommitRootTransaction(EvoDbIdentity::NORMAL, /*sync=*/true)) { - LogPrintf("[snapshot] failed to sync background EvoDB state\n"); - return false; - } - std::vector required_work_blocks; - if (evo_snapshot) { - required_work_blocks.reserve(evo_snapshot->historical_mn_list_diffs.size()); - for (const auto& entry : evo_snapshot->historical_mn_list_diffs) { - required_work_blocks.emplace_back(entry.block_hash); - } - std::sort(required_work_blocks.begin(), required_work_blocks.end()); - } - // Usually the background chain has not reached these blocks yet. If it - // has, hash its already-connected ordinary state now, before any snapshot - // seeds enter the shared EvoDB namespace. - std::map existing_background_work_hashes; - auto& background_dmnman{m_ibd_chainstate->ChainHelper().DeterministicMNManager()}; - for (const auto& block_hash : required_work_blocks) { - const CBlockIndex* index{m_blockman.LookupBlockIndex(block_hash)}; - assert(index != nullptr); - if (m_ibd_chainstate->m_chain.Contains(index)) { - existing_background_work_hashes.emplace( - block_hash, evo::CanonicalMNListHash(background_dmnman.GetListForBlock(index))); - } - } - { - auto db_tx = snapshot_chainstate.m_evoDb.BeginTransaction(EvoDbIdentity::SNAPSHOT); - if (evo_snapshot) { - auto& helper{snapshot_chainstate.ChainHelper()}; - auto& dmnman{helper.DeterministicMNManager()}; - auto& qblockman{helper.QuorumBlockProcessor()}; - auto& qsnapman{helper.QuorumSnapshotManager()}; - if (!dmnman.SeedListForBlock(evo_snapshot->mn_list)) { - LogPrintf("[snapshot] failed to seed base deterministic MN list\n"); - return false; - } - std::map historical_lists; - std::string reconstruction_error; - if (!evo::ReconstructHistoricalMNLists(*evo_snapshot, historical_lists, reconstruction_error)) { - LogPrintf("[snapshot] failed to reconstruct historical deterministic MN lists: %s\n", - reconstruction_error); - return false; - } - for (const auto& [_, historical_list] : historical_lists) { - if (!dmnman.SeedListForBlock(historical_list)) { - LogPrintf("[snapshot] failed to seed historical deterministic MN list\n"); - return false; - } - } - for (const auto& modifier : evo_snapshot->quorum_modifiers) { - if (!qsnapman.SeedQuorumModifier(modifier.llmq_type, modifier.work_block_hash, - modifier.modifier)) { - LogPrintf("[snapshot] failed to seed quorum score modifier\n"); - return false; - } - } - for (const auto& quorum_data : evo_snapshot->quorums) { - const auto seed_commitments = [&](const auto& commitments) { - LOCK(::cs_main); - for (const auto& entry : commitments) { - if (!qblockman.SeedMinedCommitment(quorum_data.llmq_type, entry.quorum_base_block_hash, - entry.commitment, entry.mined_block_hash)) return false; - } - return true; - }; - if (!seed_commitments(quorum_data.active_commitments) || - !seed_commitments(quorum_data.safety_commitments)) { - LogPrintf("[snapshot] failed to seed mined quorum commitment\n"); - return false; - } - for (const auto& rotation : quorum_data.rotation_snapshots) { - const CBlockIndex* cycle_index{m_blockman.LookupBlockIndex(rotation.cycle_base_block_hash)}; - assert(cycle_index != nullptr); - if (!qsnapman.SeedSnapshotForBlock(quorum_data.llmq_type, cycle_index, rotation.snapshot)) { - LogPrintf("[snapshot] failed to seed quorum rotation snapshot\n"); - return false; - } - } - } - if (!helper.credit_pool_manager->SeedSnapshot(snapshot_start_block, evo_snapshot->credit_pool) || - !helper.ehf_manager->SeedSignals(snapshot_start_block, evo_snapshot->mnhf_signals)) { - LogPrintf("[snapshot] failed to seed credit-pool/MNHF state\n"); - return false; - } - if (!snapshot_chainstate.m_evoDb.WriteDerived(EVODB_SNAPSHOT_EVO_SECTION, *evo_snapshot)) { - LogPrintf("[snapshot] failed to retain evo section for deferred CbTx validation\n"); - return false; - } - } - snapshot_chainstate.m_evoDb.WriteBestBlock(EvoDbIdentity::SNAPSHOT, base_blockhash); - if (evo_snapshot) { - snapshot_chainstate.m_evoDb.WriteSnapshotBaseMNListHash( - evo::CanonicalMNListHash(evo_snapshot->mn_list)); - } else if (base_mn_list_hash.has_value()) { - snapshot_chainstate.m_evoDb.WriteSnapshotBaseMNListHash(*base_mn_list_hash); - } - snapshot_chainstate.m_evoDb.WriteDualChainstateMarker(); - db_tx->Commit(); - } - { - // This bounded required set and its independently computed captures - // belong to the NORMAL identity. Future background connects consult - // the in-memory mirror before recording their canonical hash. - auto db_tx = snapshot_chainstate.m_evoDb.BeginTransaction(EvoDbIdentity::NORMAL); - snapshot_chainstate.m_evoDb.WriteRequiredWorkMNListHashes(required_work_blocks); - for (const auto& [block_hash, mn_list_hash] : existing_background_work_hashes) { - snapshot_chainstate.m_evoDb.WriteBackgroundWorkMNListHash(block_hash, mn_list_hash); - } - db_tx->Commit(); - } - if (!snapshot_chainstate.m_evoDb.CommitRootTransaction(EvoDbIdentity::NORMAL, /*sync=*/true)) { - LogPrintf("[snapshot] failed to commit required historical MN-list marker\n"); - return false; - } - m_ibd_chainstate->SetRequiredBackgroundMNListHashes(required_work_blocks); - if (!snapshot_chainstate.m_evoDb.CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true)) { - LogPrintf("[snapshot] failed to commit snapshot EvoDB marker\n"); - return false; - } - if (evo_snapshot) { - auto& helper{snapshot_chainstate.ChainHelper()}; - auto& dmnman{helper.DeterministicMNManager()}; - dmnman.InvalidateListCacheForBlock(base_blockhash); - for (const auto& historical : evo_snapshot->historical_mn_list_diffs) { - dmnman.InvalidateListCacheForBlock(historical.block_hash); - } - for (const auto& quorum_data : evo_snapshot->quorums) { - for (const auto& rotation : quorum_data.rotation_snapshots) { - helper.QuorumSnapshotManager().InvalidateSnapshotCacheForBlock( - quorum_data.llmq_type, rotation.cycle_base_block_hash); - } - } - } - - LogPrintf("[snapshot] validated snapshot (%.2f MB)\n", - coins_cache.DynamicMemoryUsage() / (1000 * 1000)); - return true; -} - // Currently, this function holds cs_main for its duration, which could be for // multiple minutes due to the ComputeUTXOStats call. This hold is necessary // because we need to avoid advancing the background validation chainstate @@ -6295,210 +5837,6 @@ bool ChainstateManager::HandleSnapshotStateMismatch( return true; } -SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( - std::function shutdown_fnc) -{ - AssertLockHeld(cs_main); - if (m_ibd_chainstate.get() == &this->ActiveChainstate() || - !this->IsUsable(m_snapshot_chainstate.get()) || - !this->IsUsable(m_ibd_chainstate.get()) || - !m_ibd_chainstate->m_chain.Tip()) { - // Nothing to do - this function only applies to the background - // validation chainstate. - return SnapshotCompletionResult::SKIPPED; - } - const auto snapshot_base_height_opt = this->GetSnapshotBaseHeight(); - if (!snapshot_base_height_opt) { - if (!m_snapshot_chainstate->CoinsDB().StoragePath()) { - // Some Dash unit fixtures construct a synthetic in-memory snapshot - // chainstate before inserting its base block into the block index. - return SnapshotCompletionResult::SKIPPED; - } - LogPrintf("[snapshot] on-disk snapshot base block is missing from the block index\n"); - return SnapshotCompletionResult::BASE_BLOCKHASH_MISMATCH; - } - const int snapshot_base_height = *snapshot_base_height_opt; - const CBlockIndex& index_new = *Assert(m_ibd_chainstate->m_chain.Tip()); - - if (index_new.nHeight < snapshot_base_height) { - // Background IBD not complete yet. - return SnapshotCompletionResult::SKIPPED; - } - - assert(SnapshotBlockhash()); - uint256 snapshot_blockhash = *Assert(SnapshotBlockhash()); - - // Completion is serialized by cs_main. Flush each identity in sequence so - // CEvoDB's single-open-transaction invariant is preserved and marker - // promotion can atomically operate on fully committed transaction trees. - m_ibd_chainstate->ForceFlushStateToDisk(); - m_snapshot_chainstate->ForceFlushStateToDisk(); - if (!m_ibd_chainstate->m_evoDb.CommitRootTransaction(EvoDbIdentity::NORMAL, /*sync=*/true) || - !m_snapshot_chainstate->m_evoDb.CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true)) { - // A failed sync here is an unrecoverable database write error, and the - // caller (ConnectTip) discards the result: with the background tip - // already at the base, nothing would retry completion until restart. - // Abort like the other unrecoverable EvoDB paths. - AbortNode("Failed to sync EvoDB state for snapshot completion"); - return SnapshotCompletionResult::STATS_FAILED; - } - - auto handle_invalid_snapshot = [&](const std::string& reason = "snapshot completion mismatch") - EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { - const bool handled{HandleSnapshotStateMismatch(reason, shutdown_fnc)}; - assert(handled); - }; - - if (index_new.GetBlockHash() != snapshot_blockhash) { - LogPrintf("[snapshot] supposed base block %s does not match the " /* Continued */ - "snapshot base block %s (height %d). Snapshot is not valid.", - index_new.ToString(), snapshot_blockhash.ToString(), snapshot_base_height); - handle_invalid_snapshot(); - return SnapshotCompletionResult::BASE_BLOCKHASH_MISMATCH; - } - - assert(index_new.nHeight == snapshot_base_height); - - int curr_height = m_ibd_chainstate->m_chain.Height(); - - assert(snapshot_base_height == curr_height); - assert(this->IsUsable(m_snapshot_chainstate.get())); - assert(this->GetAll().size() == 2); - - CCoinsViewDB& ibd_coins_db = m_ibd_chainstate->CoinsDB(); - - auto maybe_au_data = ExpectedAssumeutxo(curr_height, ::Params()); - if (!maybe_au_data) { - LogPrintf("[snapshot] assumeutxo data not found for height " /* Continued */ - "(%d) - refusing to validate snapshot\n", curr_height); - handle_invalid_snapshot(); - return SnapshotCompletionResult::MISSING_CHAINPARAMS; - } - - const AssumeutxoData& au_data = *maybe_au_data; - std::optional maybe_ibd_stats; - LogPrintf("[snapshot] computing UTXO stats for background chainstate to validate " /* Continued */ - "snapshot - this could take a few minutes\n"); - try { - maybe_ibd_stats = ComputeUTXOStats( - CoinStatsHashType::HASH_SERIALIZED, - &ibd_coins_db, - m_blockman, - SnapshotUTXOHashBreakpoint); - } catch (StopHashingException const&) { - return SnapshotCompletionResult::STATS_FAILED; - } - - // XXX note that this function is slow and will hold cs_main for potentially minutes. - if (!maybe_ibd_stats) { - LogPrintf("[snapshot] failed to generate stats for validation coins db\n"); - // While this isn't a problem with the snapshot per se, this condition - // prevents us from validating the snapshot, so we should shut down and let the - // user handle the issue manually. - handle_invalid_snapshot(); - return SnapshotCompletionResult::STATS_FAILED; - } - const auto& ibd_stats = *maybe_ibd_stats; - - // Compare the background validation chainstate's UTXO set hash against the hard-coded - // assumeutxo hash we expect. - // - // TODO: For belt-and-suspenders, we could cache the UTXO set - // hash for the snapshot when it's loaded in its chainstate's leveldb. We could then - // reference that here for an additional check. - if (AssumeutxoHash{ibd_stats.hashSerialized} != au_data.hash_serialized) { - LogPrintf("[snapshot] hash mismatch: actual=%s, expected=%s\n", - ibd_stats.hashSerialized.ToString(), - au_data.hash_serialized.ToString()); - handle_invalid_snapshot(); - return SnapshotCompletionResult::HASH_MISMATCH; - } - - // The base block is necessarily available after background validation - // reaches it. The assumeutxo prune lock is held until this check completes, - // so the shared BlockManager cannot prune the base out from under the - // snapshot chainstate. Complete any CbTx checks deferred at snapshot load. - assert(index_new.nStatus & BLOCK_HAVE_DATA); - if (DeploymentActiveAt(index_new, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { - evo::CEvoSnapshot retained_snapshot; - CBlock base_block; - std::string evo_error; - if (!m_ibd_chainstate->m_evoDb.Read(EVODB_SNAPSHOT_EVO_SECTION, retained_snapshot) || - !ReadBlockFromDisk(base_block, &index_new, GetConsensus()) || base_block.vtx.empty()) { - LogPrintf("[snapshot] missing retained evo section/base block at completion\n"); - handle_invalid_snapshot(); - return SnapshotCompletionResult::EVO_STATE_MISMATCH; - } - const auto cbtx{GetTxPayload(*base_block.vtx[0])}; - std::map reconstructed_history; - bool history_matches{evo::ReconstructHistoricalMNLists(retained_snapshot, reconstructed_history, evo_error)}; - if (history_matches) { - for (const auto& [block_hash, reconstructed_list] : reconstructed_history) { - uint256 background_hash; - if (!m_ibd_chainstate->m_evoDb.ReadBackgroundWorkMNListHash(block_hash, background_hash) || - background_hash != evo::CanonicalMNListHash(reconstructed_list)) { - evo_error = "missing or mismatched background historical MN-list capture"; - history_matches = false; - break; - } - } - } - if (!history_matches || - !evo::ValidateEvoSnapshotAgainstChain(retained_snapshot, *this, &index_new, evo_error) || - !cbtx || !evo::VerifyEvoSnapshotCbTx(retained_snapshot, *cbtx, evo_error)) { - LogPrintf("[snapshot] deferred evo CbTx cross-check failed: %s\n", evo_error); - handle_invalid_snapshot(); - return SnapshotCompletionResult::EVO_STATE_MISMATCH; - } - } - - // The snapshot marker records the derived deterministic-MN state that was - // available when the snapshot chainstate began using the base block. Compare - // it with the state independently derived by background validation. - uint256 snapshot_mn_list_hash; - if (!m_ibd_chainstate->m_evoDb.ReadSnapshotBaseMNListHash(snapshot_mn_list_hash)) { - if (DeploymentActiveAt(index_new, GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { - LogPrintf("[snapshot] missing deterministic MN-list marker for evo snapshot\n"); - handle_invalid_snapshot("missing deterministic MN-list marker"); - return SnapshotCompletionResult::EVO_STATE_MISMATCH; - } - // Before DIP3 the snapshot has no evo payload. A cold-start activation - // may therefore have no independently derivable MN-list marker, leaving - // the UTXO-set hash as the completion criterion, exactly as upstream. - LogPrintf("[snapshot] no pre-DIP3 MN-list marker was captured at activation; skipping deterministic MN-list comparison\n"); - } else { - uint256 background_mn_list_block; - uint256 background_mn_list_hash; - if (!m_ibd_chainstate->m_evoDb.ReadBackgroundMNListHash( - background_mn_list_block, background_mn_list_hash) || - background_mn_list_block != snapshot_blockhash || - snapshot_mn_list_hash != background_mn_list_hash) { - LogPrintf("[snapshot] deterministic MN list mismatch at base block: captured_block=%s, actual=%s, expected=%s\n", - background_mn_list_block.ToString(), background_mn_list_hash.ToString(), - snapshot_mn_list_hash.ToString()); - handle_invalid_snapshot(); - return SnapshotCompletionResult::EVO_STATE_MISMATCH; - } - } - - const uint256 snapshot_tip = m_snapshot_chainstate->CoinsTip().GetBestBlock(); - if (!m_ibd_chainstate->m_evoDb.VerifyBestBlock(EvoDbIdentity::NORMAL, snapshot_blockhash) || - !m_snapshot_chainstate->m_evoDb.VerifyBestBlock(EvoDbIdentity::SNAPSHOT, snapshot_tip)) { - LogPrintf("[snapshot] EvoDB best-block markers do not match their chainstate tips\n"); - handle_invalid_snapshot(); - return SnapshotCompletionResult::EVO_STATE_MISMATCH; - } - - LogPrintf("[snapshot] snapshot beginning at %s has been fully validated\n", - snapshot_blockhash.ToString()); - - m_ibd_chainstate->m_disabled = true; - ReleaseSnapshotPruneLock(); - this->MaybeRebalanceCaches(); - - return SnapshotCompletionResult::SUCCESS; -} - Chainstate& ChainstateManager::ActiveChainstate() const { LOCK(::cs_main); From 8baac21925cdf999ef6942be84d2ae2423457ce7 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 11:48:15 -0500 Subject: [PATCH 5/8] llmq: fix snapshot-aware quorum modifier use --- src/llmq/utils.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index faa4343731ea..cb6b0fcfa8af 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -378,8 +378,6 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar size_t quorumSize = static_cast(llmqParams.size); auto quarterSize{quorumSize / 4}; - const auto modifier = GetHashModifier(llmqParams, util_params.m_chainman.GetConsensus(), util_params.m_base_index, - &util_params.m_qsnapman); if (allMns.GetCounts().enabled() < quarterSize) { return quarterQuorumMembers; @@ -732,7 +730,7 @@ static QuorumMembers GetAllQuorumMembersInternal(Consensus::LLMQType llmqType, c const CBlockIndex* pWorkBlockIndex = pCycleQuorumBaseBlockIndex->GetAncestor(cycleQuorumBaseHeight - WORK_DIFF_DEPTH); const auto modifier = GetHashModifier(llmq_params, util_params.m_chainman.GetConsensus(), - pCycleQuorumBaseBlockIndex); + pCycleQuorumBaseBlockIndex, &util_params.m_qsnapman); auto q = ComputeQuorumMembersByQuarterRotation(llmq_params, util_params.replace_index(pCycleQuorumBaseBlockIndex), pWorkBlockIndex, cycleQuorumBaseHeight, modifier, /*predicting=*/false); From d3880a527d988677adec0026b0cb8ab0d1ab7792 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 1 Aug 2026 14:49:48 -0500 Subject: [PATCH 6/8] test: make evo_snapshot_tests order-independent and debug-assert clean quorum_members_reconstruct_from_seeded_state_only forced DIP0003/v19/v20 active through const_cast on a TestChain100Setup chain that was mined with those forks inactive. The v20 quorum hash modifier then probed a pre-DIP3 coinbase for a chainlock and tripped GetTxPayload's payload-type assertion in --enable-debug builds. The abort longjmps past fixture teardown, so mocktime, gArgs, and worker threads leaked and poisoned every later case in the same test_dash process. Mine the fixture chain with the required activation heights already in force instead (new SnapshotActivationChainSetup with its own deterministic checkpoint), and restore const_cast-mutated consensus params through a scope guard so a failed assertion in one case can no longer leak mutated params into cases that run after it. --- src/test/evo_snapshot_tests.cpp | 95 ++++++++++++++++++--------------- src/test/util/setup_common.cpp | 2 + 2 files changed, 54 insertions(+), 43 deletions(-) diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index 08f787ab971e..12fe386e6010 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -215,8 +215,39 @@ void CheckInvalid(evo::CEvoSnapshot snapshot) BOOST_CHECK_THROW(snapshot.Validate(), std::ios_base::failure); } +//! Restores consensus params mutated through const_cast when the test case +//! leaves scope, including through a failed BOOST_REQUIRE, so mutated state +//! cannot leak into cases running later in the same process. +class [[nodiscard]] ConsensusParamsRestorer +{ + Consensus::Params& m_params; + const Consensus::Params m_saved; + +public: + explicit ConsensusParamsRestorer(const Consensus::Params& params) : + m_params{const_cast(params)}, m_saved{params} + { + } + ~ConsensusParamsRestorer() { m_params = m_saved; } + Consensus::Params& Get() { return m_params; } +}; + } // namespace +//! Chain fixture whose activation heights are already in force while the chain +//! is mined, so every historical coinbase is the CbTx that v20-era code paths +//! (e.g. the quorum hash modifier's chainlock probe) are entitled to assume. +//! Forcing the heights down through const_cast after mining instead would leave +//! pre-DIP3 coinbases on a chain claiming v20 was always active, which trips +//! GetTxPayload's payload-type assertion in debug builds. +struct SnapshotActivationChainSetup : public TestChainSetup { + SnapshotActivationChainSetup() : + TestChainSetup{102, CBaseChainParams::REGTEST, + {"-dip3params=2:2", "-testactivationheight=v20@2", "-testactivationheight=mn_rr@2"}} + { + } +}; + BOOST_AUTO_TEST_SUITE(evo_snapshot_tests) BOOST_FIXTURE_TEST_CASE(populated_roundtrip_and_representation_independence, BasicTestingSetup) @@ -294,9 +325,9 @@ BOOST_FIXTURE_TEST_CASE(snapshot_identity_seeding_is_retrievable, TestChain100Se AbstractEHFManager::Signals signals{{2, base->nHeight}}; llmq::CQuorumSnapshot quorum_snapshot{{true, false, true}, SnapshotSkipMode::MODE_NO_SKIPPING, {}}; - auto& mutable_consensus{const_cast(Params().GetConsensus())}; - const int old_dip3_height{mutable_consensus.DIP0003Height}; - mutable_consensus.DIP0003Height = 1; + ConsensusParamsRestorer params_restorer{Params().GetConsensus()}; + const int old_dip3_height{params_restorer.Get().DIP0003Height}; + params_restorer.Get().DIP0003Height = 1; BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(base).GetCounts().total(), 0U); BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(historical_index).GetCounts().total(), 0U); @@ -341,7 +372,7 @@ BOOST_FIXTURE_TEST_CASE(snapshot_identity_seeding_is_retrievable, TestChain100Se m_node.dmnman->InvalidateListCacheForBlock(historical_index->GetBlockHash()); const auto subsequent_list{m_node.dmnman->GetListForBlock(base)}; const auto subsequent_historical_list{m_node.dmnman->GetListForBlock(historical_index)}; - mutable_consensus.DIP0003Height = old_dip3_height; + params_restorer.Get().DIP0003Height = old_dip3_height; BOOST_CHECK(evo::CanonicalMNListHash(stored_list) == evo::CanonicalMNListHash(list)); BOOST_CHECK(evo::CanonicalMNListHash(stored_historical_list) == evo::CanonicalMNListHash(historical_list)); BOOST_CHECK(evo::CanonicalMNListHash(subsequent_list) == evo::CanonicalMNListHash(list)); @@ -417,28 +448,26 @@ BOOST_FIXTURE_TEST_CASE(snapshot_seed_rollback_does_not_publish_caches, TestChai BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"mnhf_s2"}, base->GetBlockHash()), db_signals)); BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string_view{"llmq_S"}, quorum_hash), db_quorum)); - auto& consensus{const_cast(Params().GetConsensus())}; - const int old_dip3_height{consensus.DIP0003Height}; - const int old_v20_height{consensus.V20Height}; - consensus.DIP0003Height = 1; - consensus.V20Height = 1; - BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(base).GetCounts().total(), 0U); - BOOST_CHECK_EQUAL(m_node.chain_helper->credit_pool_manager->GetCreditPool(base).locked, 0); - BOOST_CHECK(m_node.chain_helper->ehf_manager->GetSignalsStage(base).empty()); - consensus.V20Height = old_v20_height; - consensus.DIP0003Height = old_dip3_height; + { + ConsensusParamsRestorer params_restorer{Params().GetConsensus()}; + params_restorer.Get().DIP0003Height = 1; + params_restorer.Get().V20Height = 1; + BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(base).GetCounts().total(), 0U); + BOOST_CHECK_EQUAL(m_node.chain_helper->credit_pool_manager->GetCreditPool(base).locked, 0); + BOOST_CHECK(m_node.chain_helper->ehf_manager->GetSignalsStage(base).empty()); + } BOOST_CHECK(!m_node.llmq_ctx->qsnapman->GetSnapshotForBlock( Consensus::LLMQType::LLMQ_TEST, base).has_value()); } -BOOST_FIXTURE_TEST_CASE(quorum_members_reconstruct_from_seeded_state_only, TestChain100Setup) +BOOST_FIXTURE_TEST_CASE(quorum_members_reconstruct_from_seeded_state_only, SnapshotActivationChainSetup) { const CBlockIndex* tip{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; BOOST_REQUIRE(tip != nullptr); - auto& global_consensus{const_cast(Params().GetConsensus())}; - auto& consensus{const_cast(m_node.chainman->GetConsensus())}; - const auto old_global{global_consensus}; - const auto old_chain{consensus}; + ConsensusParamsRestorer global_restorer{Params().GetConsensus()}; + ConsensusParamsRestorer chain_restorer{m_node.chainman->GetConsensus()}; + auto& global_consensus{global_restorer.Get()}; + auto& consensus{chain_restorer.Get()}; auto plain{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; auto rotated{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; plain.dkgInterval = 12; @@ -446,13 +475,7 @@ BOOST_FIXTURE_TEST_CASE(quorum_members_reconstruct_from_seeded_state_only, TestC plain.dkgMiningWindowEnd = 3; rotated.dkgInterval = 12; consensus.llmqs = {plain, rotated}; - consensus.DIP0003Height = 1; - consensus.V19Height = 1; - consensus.V20Height = 1; global_consensus.llmqs = consensus.llmqs; - global_consensus.DIP0003Height = 1; - global_consensus.V19Height = 1; - global_consensus.V20Height = 1; const CBlockIndex* quorum{tip->GetAncestor(96)}; BOOST_REQUIRE(quorum != nullptr); @@ -609,8 +632,6 @@ BOOST_FIXTURE_TEST_CASE(quorum_members_reconstruct_from_seeded_state_only, TestC plain.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true), evo::SnapshotStateMismatchError); } - consensus = old_chain; - global_consensus = old_global; } BOOST_FIXTURE_TEST_CASE(chain_validation_pre_dip3_matrix, TestChain100Setup) @@ -634,10 +655,8 @@ BOOST_FIXTURE_TEST_CASE(chain_validation_pre_dip3_matrix, TestChain100Setup) BOOST_CHECK(!WITH_LOCK(::cs_main, return evo::ValidateEvoSnapshotAgainstChain(nonempty, *m_node.chainman, base, error))); - auto& mutable_consensus{const_cast(m_node.chainman->GetConsensus())}; - const int old_dip3_height{mutable_consensus.DIP0003Height}; - const int old_v19_height{mutable_consensus.V19Height}; - const auto old_llmqs{mutable_consensus.llmqs}; + ConsensusParamsRestorer params_restorer{m_node.chainman->GetConsensus()}; + auto& mutable_consensus{params_restorer.Get()}; mutable_consensus.DIP0003Height = 1; mutable_consensus.V19Height = 1; mutable_consensus.llmqs = {evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; @@ -696,20 +715,14 @@ BOOST_FIXTURE_TEST_CASE(chain_validation_pre_dip3_matrix, TestChain100Setup) non_ancestor.quorums[0].active_commitments[0].commitment.quorumHash = H(99); BOOST_CHECK(!WITH_LOCK(::cs_main, return evo::ValidateEvoSnapshotAgainstChain(non_ancestor, *m_node.chainman, base, error))); - - mutable_consensus.llmqs = old_llmqs; - mutable_consensus.V19Height = old_v19_height; - mutable_consensus.DIP0003Height = old_dip3_height; } BOOST_FIXTURE_TEST_CASE(rotation_bitset_matches_historical_work_list, TestChain100Setup) { const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; BOOST_REQUIRE(base != nullptr); - auto& consensus{const_cast(m_node.chainman->GetConsensus())}; - const auto old_llmqs{consensus.llmqs}; - const int old_dip3_height{consensus.DIP0003Height}; - const int old_v19_height{consensus.V19Height}; + ConsensusParamsRestorer params_restorer{m_node.chainman->GetConsensus()}; + auto& consensus{params_restorer.Get()}; auto params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; params.dkgInterval = 12; params.dkgMiningWindowStart = 2; @@ -822,10 +835,6 @@ BOOST_FIXTURE_TEST_CASE(rotation_bitset_matches_historical_work_list, TestChain1 bad_modifier.quorum_modifiers[0].modifier.begin()[0] ^= 1; BOOST_CHECK(!WITH_LOCK(::cs_main, return evo::ValidateEvoSnapshotAgainstChain(bad_modifier, *m_node.chainman, base, error))); - - consensus.llmqs = old_llmqs; - consensus.V19Height = old_v19_height; - consensus.DIP0003Height = old_dip3_height; } BOOST_AUTO_TEST_CASE(reconstruction_horizon_height_enumeration) diff --git a/src/test/util/setup_common.cpp b/src/test/util/setup_common.cpp index a370cb1072ea..fb747bb00b3b 100644 --- a/src/test/util/setup_common.cpp +++ b/src/test/util/setup_common.cpp @@ -451,6 +451,8 @@ TestChainSetup::TestChainSetup( { 98, uint256S("0x150e127929d578d8129b77a6cb7e2e343a1379aa3feaaa9cce59e0a645756a81") }, /*TestChain100Setup=*/ { 100, uint256S("0x6ffb83129c19ebdf1ae3771be6a67fe34b35f4c956326b9ba152fac1649f65ae") }, + /*SnapshotActivationChainSetup=*/ + { 102, uint256S("0x37876f3493ac152f9a0bdf0049d85969fe3ba82745733a81c8e1afeefa16ab3b") }, /*TestChainV19BeforeActivationSetup=*/ { 103, uint256S("0x13adad9565d0ca558f5675c50e3828f4354d26b64de044ebc88686056f30faab") }, /*TestChainDIP3BeforeActivationSetup=*/ From 3cfd05da6dbf05a85457be7c779daca11e5d6c07 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 11 Aug 2026 11:00:54 -0500 Subject: [PATCH 7/8] fix: adapt assumeutxo M4 to current M3 --- src/evo/chainhelper.cpp | 7 ++++- src/evo/snapshot_load.cpp | 25 +++++++++++++++ src/streams.h | 25 +++++++++++++++ src/test/flatfile_tests.cpp | 3 ++ src/test/validation_chainstate_tests.cpp | 18 ++++++----- .../validation_chainstatemanager_tests.cpp | 30 ++++++++++++------ src/validation.cpp | 31 +------------------ 7 files changed, 92 insertions(+), 47 deletions(-) diff --git a/src/evo/chainhelper.cpp b/src/evo/chainhelper.cpp index 6af84ff98d9d..4c577b7b2221 100644 --- a/src/evo/chainhelper.cpp +++ b/src/evo/chainhelper.cpp @@ -69,7 +69,12 @@ int32_t CChainstateHelper::GetBestChainLockHeight() const { return m_chainlocks. uint256 CChainstateHelper::GetDeterministicMNListHash(const CBlockIndex* pindex) const { - return evo::CanonicalMNListHash(m_dmnman.GetListForBlock(Assert(pindex))); + const CBlockIndex* index{Assert(pindex)}; + CDeterministicMNList list{m_dmnman.GetListForBlock(index)}; + if (list.GetBlockHash().IsNull()) { + list = CDeterministicMNList{index->GetBlockHash(), index->nHeight, 0}; + } + return evo::CanonicalMNListHash(list); } /** Passthrough functions to CCreditPoolManager */ diff --git a/src/evo/snapshot_load.cpp b/src/evo/snapshot_load.cpp index ff40a5aa3985..1bcf8a40f59f 100644 --- a/src/evo/snapshot_load.cpp +++ b/src/evo/snapshot_load.cpp @@ -489,6 +489,31 @@ bool ChainstateManager::PopulateAndValidateSnapshot( return true; } +void Chainstate::RecordBackgroundMNListHash(const CBlockIndex* pindex, const CDeterministicMNList& mn_list) +{ + if (EvoDbIdentity() != ::EvoDbIdentity::NORMAL) return; + + const auto base_blockhash = m_chainman.SnapshotBlockhash(); + if (!base_blockhash) return; + + if (!m_required_background_mn_list_hashes) { + std::vector required_work_blocks; + m_evoDb.ReadRequiredWorkMNListHashes(required_work_blocks); + m_required_background_mn_list_hashes.emplace(required_work_blocks.begin(), required_work_blocks.end()); + } + + const uint256 block_hash{pindex->GetBlockHash()}; + const bool is_base_block{*base_blockhash == block_hash}; + const bool is_required_work_block{m_required_background_mn_list_hashes->contains(block_hash)}; + if (!is_base_block && !is_required_work_block) return; + + // Hash only the snapshot base and the bounded set of historical work + // blocks needed for deferred evo validation, not every background block. + const uint256 mn_list_hash{evo::CanonicalMNListHash(mn_list)}; + if (is_base_block) m_evoDb.WriteBackgroundMNListHash(block_hash, mn_list_hash); + if (is_required_work_block) m_evoDb.WriteBackgroundWorkMNListHash(block_hash, mn_list_hash); +} + SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( std::function shutdown_fnc) { diff --git a/src/streams.h b/src/streams.h index bac54515a518..d5f55dd69263 100644 --- a/src/streams.h +++ b/src/streams.h @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -537,6 +538,30 @@ class AutoFile // // Stream subset // + size_t size() const + { + if (!file) throw std::ios_base::failure("AutoFile::size: file handle is nullptr"); +#ifdef WIN32 + const auto position{_ftelli64(file)}; + struct _stat64 file_stat; + if (position < 0 || _fstat64(_fileno(file), &file_stat) != 0) { +#else + const auto position{ftello(file)}; + struct stat file_stat; + if (position < 0 || fstat(fileno(file), &file_stat) != 0) { +#endif + throw std::ios_base::failure("AutoFile::size: failed to inspect file"); + } + if (file_stat.st_size < position) { + throw std::ios_base::failure("AutoFile::size: position exceeds file size"); + } + const uint64_t remaining{static_cast(file_stat.st_size - position)}; + if (remaining > std::numeric_limits::max()) { + throw std::ios_base::failure("AutoFile::size: remaining size does not fit size_t"); + } + return static_cast(remaining); + } + void read(Span dst) { if (!file) throw std::ios_base::failure("AutoFile::read: file handle is nullptr"); diff --git a/src/test/flatfile_tests.cpp b/src/test/flatfile_tests.cpp index 9d8e9286f341..647c9b03a4a1 100644 --- a/src/test/flatfile_tests.cpp +++ b/src/test/flatfile_tests.cpp @@ -65,11 +65,14 @@ BOOST_AUTO_TEST_CASE(flatfile_open) std::string text; AutoFile file{seq.Open(FlatFilePos(0, pos1), true)}; + BOOST_CHECK_EQUAL(file.size(), pos2 + GetSerializeSize(line2, CLIENT_VERSION)); file >> LIMITED_STRING(text, 256); BOOST_CHECK_EQUAL(text, line1); + BOOST_CHECK_EQUAL(file.size(), GetSerializeSize(line2, CLIENT_VERSION)); file >> LIMITED_STRING(text, 256); BOOST_CHECK_EQUAL(text, line2); + BOOST_CHECK_EQUAL(file.size(), 0U); } // Read text from file with position offset. diff --git a/src/test/validation_chainstate_tests.cpp b/src/test/validation_chainstate_tests.cpp index fcbdd14b7ab3..8c73bcc412bd 100644 --- a/src/test/validation_chainstate_tests.cpp +++ b/src/test/validation_chainstate_tests.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -215,13 +216,16 @@ BOOST_FIXTURE_TEST_CASE(chainstate_connectblock_bls_scheme, V19AboveSnapshotSetu BOOST_REQUIRE(CreateAndActivateUTXOSnapshot(this, NoMalleation, /*reset_chainstate=*/true)); BOOST_REQUIRE(WITH_LOCK(::cs_main, return chainman.IsSnapshotActive())); - // The background chainstate was reset to genesis before activation, so - // the base MN list was not derivable and no lifecycle marker may have - // been captured: deriving one would fabricate an empty list and poison - // the shared list cache for the background chainstate's later - // re-validation of the base region. - uint256 stale_hash; - BOOST_CHECK(!m_node.evodb->ReadSnapshotBaseMNListHash(stale_hash)); + // M4 snapshots carry a canonical evo section even before DIP3. Its + // block-bound empty list supplies the lifecycle marker without consulting + // or poisoning the background chainstate's shared list cache. + const CBlockIndex* snapshot_base{WITH_LOCK(::cs_main, return chainman.ActiveChain()[110])}; + BOOST_REQUIRE(snapshot_base); + const CDeterministicMNList expected_list{ + snapshot_base->GetBlockHash(), snapshot_base->nHeight, 0}; + uint256 snapshot_hash; + BOOST_REQUIRE(m_node.evodb->ReadSnapshotBaseMNListHash(snapshot_hash)); + BOOST_CHECK_EQUAL(snapshot_hash, evo::CanonicalMNListHash(expected_list)); mineBlocks(V19_HEIGHT - WITH_LOCK(::cs_main, return chainman.ActiveHeight())); BOOST_REQUIRE(!bls::bls_legacy_scheme.load()); diff --git a/src/test/validation_chainstatemanager_tests.cpp b/src/test/validation_chainstatemanager_tests.cpp index 2972aed1893f..52da08c0e539 100644 --- a/src/test/validation_chainstatemanager_tests.cpp +++ b/src/test/validation_chainstatemanager_tests.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -1122,20 +1123,31 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_completion_incorrect_base_mn_ BOOST_FIXTURE_TEST_CASE(chainstatemanager_records_only_required_background_work_mn_hashes, SnapshotTestSetup) { auto [validation_chainstate, _] = this->SetupSnapshot(); - const uint256 required_block{GetRandHash()}; - const uint256 unrelated_block{GetRandHash()}; - const uint256 required_hash{GetRandHash()}; + const CBlockIndex* required_block; + const CBlockIndex* unrelated_block; + { + LOCK(::cs_main); + required_block = validation_chainstate->m_chain[1]; + unrelated_block = validation_chainstate->m_chain[2]; + } + BOOST_REQUIRE(required_block); + BOOST_REQUIRE(unrelated_block); + const CDeterministicMNList required_list{ + required_block->GetBlockHash(), required_block->nHeight, 0}; + const CDeterministicMNList unrelated_list{ + unrelated_block->GetBlockHash(), unrelated_block->nHeight, 0}; + const uint256 required_hash{evo::CanonicalMNListHash(required_list)}; auto tx = m_node.evodb->BeginTransaction(EvoDbIdentity::NORMAL); - m_node.evodb->WriteRequiredWorkMNListHashes({required_block}); - validation_chainstate->SetRequiredBackgroundMNListHashes({required_block}); - validation_chainstate->RecordBackgroundMNListHash(required_block, required_hash); - validation_chainstate->RecordBackgroundMNListHash(unrelated_block, GetRandHash()); + m_node.evodb->WriteRequiredWorkMNListHashes({required_block->GetBlockHash()}); + validation_chainstate->SetRequiredBackgroundMNListHashes({required_block->GetBlockHash()}); + validation_chainstate->RecordBackgroundMNListHash(required_block, required_list); + validation_chainstate->RecordBackgroundMNListHash(unrelated_block, unrelated_list); uint256 captured_hash; - BOOST_REQUIRE(m_node.evodb->ReadBackgroundWorkMNListHash(required_block, captured_hash)); + BOOST_REQUIRE(m_node.evodb->ReadBackgroundWorkMNListHash(required_block->GetBlockHash(), captured_hash)); BOOST_CHECK_EQUAL(captured_hash, required_hash); - BOOST_CHECK(!m_node.evodb->ReadBackgroundWorkMNListHash(unrelated_block, captured_hash)); + BOOST_CHECK(!m_node.evodb->ReadBackgroundWorkMNListHash(unrelated_block->GetBlockHash(), captured_hash)); } BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_cleanup_recovers_first_rename, SnapshotTestSetup) diff --git a/src/validation.cpp b/src/validation.cpp index 6da5922b4321..89faabca068b 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -55,16 +55,12 @@ #include #include -#include #include #include -#include -#include +#include #include #include #include -#include -#include #include #include @@ -2783,31 +2779,6 @@ void Chainstate::ForceFlushStateToDisk() } } -void Chainstate::RecordBackgroundMNListHash(const CBlockIndex* pindex, const CDeterministicMNList& mn_list) -{ - if (EvoDbIdentity() != ::EvoDbIdentity::NORMAL) return; - - const auto base_blockhash = m_chainman.SnapshotBlockhash(); - if (!base_blockhash) return; - - if (!m_required_background_mn_list_hashes) { - std::vector required_work_blocks; - m_evoDb.ReadRequiredWorkMNListHashes(required_work_blocks); - m_required_background_mn_list_hashes.emplace(required_work_blocks.begin(), required_work_blocks.end()); - } - - const uint256 block_hash{pindex->GetBlockHash()}; - const bool is_base_block{*base_blockhash == block_hash}; - const bool is_required_work_block{m_required_background_mn_list_hashes->contains(block_hash)}; - if (!is_base_block && !is_required_work_block) return; - - // Hash only the snapshot base and the bounded set of historical work - // blocks needed for deferred evo validation, not every background block. - const uint256 mn_list_hash{evo::CanonicalMNListHash(mn_list)}; - if (is_base_block) m_evoDb.WriteBackgroundMNListHash(block_hash, mn_list_hash); - if (is_required_work_block) m_evoDb.WriteBackgroundWorkMNListHash(block_hash, mn_list_hash); -} - void Chainstate::SetRequiredBackgroundMNListHashes(const std::vector& block_hashes) { assert(EvoDbIdentity() == EvoDbIdentity::NORMAL); From 34fc56af2a26fca58ebd1ccd59bf5eb702e0502f Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 12 Aug 2026 13:02:37 -0500 Subject: [PATCH 8/8] fix: bound rotation skip lists by the aggregate build, not one quorum BuildNewQuorumQuarterMembers accumulates one skip list across every quorum index of a rotated cycle and can wrap the combined MN list more than once, so params.size does not bound a legitimate list: ReadRotationSnapshot and Validate() would reject a snapshot produced from valid chain state, making dumptxoutset/assumeutxo unavailable at that base. Bound claimed sizes by a dedicated decode ceiling with the upfront reserve clamped to quorum size, and only reject a negative first (absolute) skip entry since later delta entries legitimately go negative after wraparound. Reported by Codex review. Co-Authored-By: Claude Fable 5 --- src/evo/snapshot.cpp | 8 ++++++-- src/evo/snapshot.h | 11 +++++++++-- src/test/evo_snapshot_tests.cpp | 12 ++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp index 375c95e6008e..4e4e08a08815 100644 --- a/src/evo/snapshot.cpp +++ b/src/evo/snapshot.cpp @@ -292,8 +292,12 @@ void CEvoSnapshot::Validate(bool require_canonical_order) const 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() > static_cast(params.size) || - std::ranges::any_of(entry.snapshot.mnSkipList, [](int index) { return index < 0; })) { + 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); diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index e8a897b71397..ce01f8395a4d 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -64,6 +64,11 @@ static constexpr size_t EVO_SNAPSHOT_MAX_PAYOUT_SHARES{8}; // This comfortably covers protocol-valid scripts and network information. static constexpr size_t EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS{10'000}; static constexpr size_t EVO_SNAPSHOT_MAX_MODIFIERS{4'096}; +// A cycle's skip list accumulates across every quorum index and the build can +// wrap the combined MN list more than once, so a single quorum's size does not +// bound its legitimate length. This is a decode ceiling on claimed sizes only, +// far above any state the aggregate rotation build reaches on real chains. +static constexpr size_t EVO_SNAPSHOT_MAX_SKIPLIST_ENTRIES{1'000'000}; static_assert(std::ranges::all_of(Consensus::available_llmqs, [](const auto& params) { return !params.useRotation || params.keepOldConnections <= 2 * params.signingActiveQuorumCount; }), "rotated LLMQ retention exceeds the two serialized cycles"); @@ -457,8 +462,10 @@ CQuorumSnapshotEntry ReadRotationSnapshot(Stream& s, const Consensus::LLMQParams // is checked by ValidateEvoSnapshotAgainstChain. const size_t bit_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "rotation bitset")}; ReadFixedBitSet(s, entry.snapshot.activeQuorumMembers, bit_count); - const size_t skip_count{ReadBoundedCompactSize(s, params.size, "rotation skip list")}; - entry.snapshot.mnSkipList.reserve(skip_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; diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index 12fe386e6010..7791e9a7ca40 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -1026,6 +1026,18 @@ BOOST_FIXTURE_TEST_CASE(context_free_validation_matrix, BasicTestingSetup) 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)