From 49e831e1e88380bbcb0d36fdddc87e4b7dec89d3 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 13 Aug 2026 11:48:02 -0500 Subject: [PATCH 1/2] feat: build, chain-validate, and seed evo snapshot state Second code PR of the assumeutxo M4 series: BuildEvoSnapshot() collects commitments, rotation snapshots, historical MN-list diffs, and exact score modifiers from chain state; ValidateEvoSnapshotAgainstChain() verifies a decoded snapshot against the block index, deployments, and quorum reconstruction; and the EvoDB seeding surface writes exactly the state reconstruction later reads. The chain-aware implementations live in the header-less evo/snapshot_chain.cpp unit so the codec in evo/snapshot.cpp never includes validation.h (no evo/snapshot -> validation cycle); ChainstateManager code is untouched here and stays in validation.cpp later in the series. Parameter-derived quorum counts are carried as maxima rather than exact requirements: a young chain, a freshly activated type, or a rotation type whose horizon predates activation legitimately has less history, and requiring the full horizon made dumptxoutset fail on valid chain state (flagged as blocking by review on the original M4 PR). Completeness is anchored by the completion-time CbTx quorum merkle root, the historical-diff and modifier tallies, and completion-time quorum reconstruction; two regression tests fail against exact-count enforcement. GetHashModifier() prefers a seeded exact modifier when work-block data is unavailable and cross-checks it when present; the mismatch error stays unreachable until the load integration seeds modifiers. GetDeterministicMNListHash() switches to the canonical codec hash (dev-channel-only marker-compat caveat in the PR description). Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Makefile.am | 3 + src/evo/chainhelper.cpp | 10 +- src/evo/chainhelper.h | 5 + src/evo/creditpool.cpp | 19 +- src/evo/creditpool.h | 2 + src/evo/deterministicmns.cpp | 13 + src/evo/deterministicmns.h | 17 + src/evo/evodb.cpp | 43 +- src/evo/evodb.h | 7 + src/evo/mnhftx.cpp | 7 + src/evo/mnhftx.h | 2 + src/evo/snapshot.h | 24 +- src/evo/snapshot_chain.cpp | 449 +++++++++++++++++ src/evo/snapshot_types.h | 20 + src/evo/specialtxman.cpp | 6 +- src/llmq/blockprocessor.cpp | 29 ++ src/llmq/blockprocessor.h | 4 + src/llmq/snapshot.cpp | 61 ++- src/llmq/snapshot.h | 9 + src/llmq/utils.cpp | 68 ++- src/llmq/utils.h | 5 + src/test/evo_db_tests.cpp | 12 + src/test/evo_snapshot_tests.cpp | 845 ++++++++++++++++++++++++++++++++ src/test/util/setup_common.cpp | 2 + 24 files changed, 1634 insertions(+), 28 deletions(-) create mode 100644 src/evo/snapshot_chain.cpp create mode 100644 src/evo/snapshot_types.h diff --git a/src/Makefile.am b/src/Makefile.am index 57d000012b00..a1c2582fba36 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -237,6 +237,7 @@ BITCOIN_CORE_H = \ evo/simplifiedmns.h \ evo/smldiff.h \ evo/snapshot.h \ + evo/snapshot_types.h \ evo/specialtx.h \ evo/specialtx_filter.h \ evo/specialtxman.h \ @@ -560,6 +561,7 @@ libbitcoin_node_a_SOURCES = \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ evo/snapshot.cpp \ + evo/snapshot_chain.cpp \ evo/specialtx.cpp \ evo/specialtx_filter.cpp \ evo/specialtxman.cpp \ @@ -1293,6 +1295,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/evo/chainhelper.cpp b/src/evo/chainhelper.cpp index 8e92000851e1..dc2d5504d6a5 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, consensus_params)}, @@ -66,7 +69,12 @@ int32_t CChainstateHelper::GetBestChainLockHeight() const { return m_chainlocks. uint256 CChainstateHelper::GetDeterministicMNListHash(const CBlockIndex* pindex) const { - return SerializeHash(m_dmnman.GetListForBlock(Assert(pindex))); + const CBlockIndex* index{Assert(pindex)}; + CDeterministicMNList list{m_dmnman.GetListForBlock(index)}; + if (list.GetBlockHash().IsNull()) { + list = CDeterministicMNList{index->GetBlockHash(), index->nHeight, 0}; + } + return evo::CanonicalMNListHash(list); } /** Passthrough functions to CCreditPoolManager */ diff --git a/src/evo/chainhelper.h b/src/evo/chainhelper.h index a501dd82ceaf..e6f2be69abf0 100644 --- a/src/evo/chainhelper.h +++ b/src/evo/chainhelper.h @@ -46,6 +46,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; @@ -75,6 +77,9 @@ class CChainstateHelper /** Return a canonical hash of the deterministic MN list derived at a block. */ uint256 GetDeterministicMNListHash(const CBlockIndex* pindex) const; + CDeterministicMNManager& DeterministicMNManager() { return m_dmnman; } + llmq::CQuorumBlockProcessor& QuorumBlockProcessor() { return m_qblockman; } + llmq::CQuorumSnapshotManager& QuorumSnapshotManager() { return m_qsnapman; } /** Passthrough functions to CCreditPoolManager */ CCreditPool GetCreditPool(const CBlockIndex* const pindex); diff --git a/src/evo/creditpool.cpp b/src/evo/creditpool.cpp index df40c6378756..45d6e40ba003 100644 --- a/src/evo/creditpool.cpp +++ b/src/evo/creditpool.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -127,12 +128,12 @@ std::optional CCreditPoolManager::GetFromCache(const CBlockIndex& b MaybeWriteSnapshot(block_hash, block_index.nHeight, pool); 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; } @@ -164,6 +165,12 @@ void CCreditPoolManager::AddToCache(const uint256& block_hash, int height, const creditPoolCache.insert(block_hash, pool); } +bool CCreditPoolManager::SeedSnapshot(const CBlockIndex* block, const CCreditPool& pool) +{ + if (!Assume(block != nullptr)) return false; + 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 bb3891a37403..c3091722bb96 100644 --- a/src/evo/creditpool.h +++ b/src/evo/creditpool.h @@ -175,6 +175,8 @@ class CCreditPoolManager * it can happen if there limits of withdrawal (unlock) exceed */ CCreditPool GetCreditPool(const CBlockIndex* block) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); + /** Seed a full pool snapshot in the current EvoDB transaction. */ + bool SeedSnapshot(const CBlockIndex* block, const CCreditPool& pool) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); private: std::optional GetFromCache(const CBlockIndex& block_index) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); diff --git a/src/evo/deterministicmns.cpp b/src/evo/deterministicmns.cpp index 89997142a884..a5bd20064ba9 100644 --- a/src/evo/deterministicmns.cpp +++ b/src/evo/deterministicmns.cpp @@ -686,6 +686,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, MNListUpdates& updatesRet) @@ -851,6 +863,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 efda69005f6c..5bbf767dfe60 100644 --- a/src/evo/deterministicmns.h +++ b/src/evo/deterministicmns.h @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -776,6 +777,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}; @@ -799,6 +801,21 @@ class CDeterministicMNManager }; CDeterministicMNList GetListAtChainTip() EXCLUSIVE_LOCKS_REQUIRED(!cs); + /** Seed a canonical full-list snapshot in the current EvoDB transaction. */ + bool SeedListForBlock(const CDeterministicMNList& list) EXCLUSIVE_LOCKS_REQUIRED(!cs); + + /** Invalidate cached list data so the next lookup reloads it from EvoDB. */ + void InvalidateListCacheForBlock(const uint256& block_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); + + /** Test-only guard invoked after a full-list cache/EvoDB miss, before + * ordinary diff-chain reconstruction can access earlier NORMAL state. */ + void SetListSnapshotMissHookForTesting(std::function hook) + EXCLUSIVE_LOCKS_REQUIRED(!cs) + { + LOCK(cs); + m_list_snapshot_miss_hook = std::move(hook); + } + void SetListForBlockForTesting(const CDeterministicMNList& list) EXCLUSIVE_LOCKS_REQUIRED(!cs) { LOCK(cs); diff --git a/src/evo/evodb.cpp b/src/evo/evodb.cpp index 99dbff46c5e6..4ac6f3f361b2 100644 --- a/src/evo/evodb.cpp +++ b/src/evo/evodb.cpp @@ -147,6 +147,19 @@ bool CEvoDB::HasDualChainstateMarker() return db->Exists(EVODB_DUAL_CHAINSTATE); } +// Reader is the raw DB or a transaction; Eraser a batch or the same transaction. +template +static void EraseHistoricalMNListMarkers(Reader& reader, Eraser& eraser) +{ + std::vector required; + if (reader.Read(EVODB_REQUIRED_WORK_MNLISTS, required)) { + for (const auto& block_hash : required) { + eraser.Erase(std::make_pair(EVODB_BACKGROUND_WORK_MNLIST_HASH, block_hash)); + } + } + eraser.Erase(EVODB_REQUIRED_WORK_MNLISTS); +} + void CEvoDB::EraseSnapshotMarkers() { LOCK(cs); @@ -154,6 +167,8 @@ void CEvoDB::EraseSnapshotMarkers() transaction.Erase(std::make_pair(EVODB_BEST_BLOCK, uint8_t{1})); transaction.Erase(EVODB_SNAPSHOT_MNLIST_HASH); transaction.Erase(EVODB_BACKGROUND_MNLIST_HASH); + EraseHistoricalMNListMarkers(transaction, transaction); + transaction.Erase(EVODB_SNAPSHOT_EVO_SECTION); transaction.Erase(EVODB_DUAL_CHAINSTATE); } @@ -186,6 +201,26 @@ 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); +} + bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) { LOCK(cs); @@ -202,7 +237,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; } @@ -213,6 +250,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 @@ -235,6 +274,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 f9485eb09b89..89e0d367e845 100644 --- a/src/evo/evodb.h +++ b/src/evo/evodb.h @@ -32,6 +32,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, @@ -263,6 +266,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 7014a6924d0e..98351446298a 100644 --- a/src/evo/mnhftx.cpp +++ b/src/evo/mnhftx.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -382,6 +383,12 @@ void CMNHFManager::AddToCache(const Signals& signals, const CBlockIndex* const p } } +bool CMNHFManager::SeedSignals(const CBlockIndex* pindex, const Signals& signals) +{ + if (!Assume(pindex != nullptr)) return false; + 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 ead66e3fa876..03f62dac4399 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(const CBlockIndex* tip) EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs_cache); + /** Seed the signals at a block in the current EvoDB transaction. */ + bool SeedSignals(const CBlockIndex* pindex, const Signals& signals) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); private: void AddToCache(const Signals& signals, const CBlockIndex* const pindex) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index b0944838f90a..3d4ad4d19ef7 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -30,7 +31,17 @@ #include #include +class CBlockIndex; +class CChainParams; +class ChainstateManager; class CCbTx; +class CCreditPoolManager; +class CMNHFManager; + +namespace llmq { +class CQuorumBlockProcessor; +class CQuorumSnapshotManager; +} // namespace llmq namespace evo { @@ -586,7 +597,7 @@ QuorumSnapshotEntry ReadRotationSnapshot(Stream& s, const Consensus::LLMQParams& s >> entry.cycle_base_block_hash >> entry.work_block_hash >> entry.snapshot.mnSkipListMode; // BuildQuorumSnapshot sizes this bitset to the complete work-block MN list, // not to the quorum size. The exact historical-list size is chain-aware and - // is checked by the chain-aware validation layered on later in the series. + // is checked by ValidateEvoSnapshotAgainstChain. const size_t bit_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "rotation bitset")}; ReadFixedBitSet(s, entry.snapshot.activeQuorumMembers, bit_count); const size_t skip_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_SKIPLIST_ENTRIES, "rotation skip list")}; @@ -722,6 +733,12 @@ void EvoSnapshot::Unserialize(Stream& s) /** Single SHA256 of the canonical SER_DISK/CLIENT_VERSION encoding. */ uint256 GetEvoSnapshotHash(const EvoSnapshot& 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, + EvoSnapshot& snapshot, std::string& error) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + struct QuorumReconstructionHeight { Consensus::LLMQType llmq_type; bool rotation; @@ -737,6 +754,11 @@ std::vector EvoSnapshotReconstructionHeights( bool ReconstructHistoricalMNLists(const EvoSnapshot& snapshot, std::map& lists, std::string& error, size_t max_records = EVO_SNAPSHOT_MAX_RECONSTRUCTION_RECORDS); +/** Validate all snapshot invariants requiring the block index or deployments. */ +bool ValidateEvoSnapshotAgainstChain(const EvoSnapshot& 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 EvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error); diff --git a/src/evo/snapshot_chain.cpp b/src/evo/snapshot_chain.cpp new file mode 100644 index 000000000000..cdfd344500ea --- /dev/null +++ b/src/evo/snapshot_chain.cpp @@ -0,0 +1,449 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +// Chain-aware companions to evo/snapshot.cpp, deliberately header-less: these +// implementations are declared in evo/snapshot.h but need validation.h and +// llmq internals, so they live in their own compilation unit to keep the +// snapshot codec free of an evo/snapshot -> validation -> evo/snapshot cycle. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace evo { +namespace { + +bool ValidateCommitmentAgainstChain(const MinedQuorumCommitment& 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); +} + +MinedQuorumCommitment 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}; +} + +//! A v20 modifier commits to the work block's coinbase ChainLock, and the +//! ordinary calculation silently falls back to the block hash when that block +//! cannot be read (e.g. pruned). Never export that fallback: use the seeded +//! exact modifier instead, or fail when there is none. +std::optional ExactQuorumModifier(const Consensus::LLMQParams& params, const Consensus::Params& consensus, + const llmq::CQuorumSnapshotManager& qsnapman, const CBlockIndex* cycle_index) +{ + const CBlockIndex* work_index{cycle_index->GetAncestor(cycle_index->nHeight - llmq::WORK_DIFF_DEPTH)}; + if (work_index != nullptr && DeploymentActiveAt(*work_index, consensus, Consensus::DEPLOYMENT_V20)) { + CBlock block; + if (!node::ReadBlockFromDisk(block, work_index, consensus)) { + return qsnapman.GetSeededQuorumModifier(params.type, work_index->GetBlockHash()); + } + } + return llmq::utils::GetQuorumHashModifier(params, consensus, cycle_index); +} + + +} // namespace + +bool BuildEvoSnapshot(const CChainParams& chainparams, const ChainstateManager& chainman, + CDeterministicMNManager& dmnman, + const llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, + CCreditPoolManager& cpoolman, CMNHFManager& mnhfman, const CBlockIndex* base_index, + EvoSnapshot& snapshot, std::string& error) +{ + AssertLockHeld(::cs_main); + error.clear(); + if (base_index == nullptr) { + error = "evo snapshot base block is null"; + return false; + } + + EvoSnapshot 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}; + try { + result.Validate(); + } catch (const std::exception& e) { + error = e.what(); + return false; + } + 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; + const auto modifier{ExactQuorumModifier(params, chainparams.GetConsensus(), qsnapman, modifier_base)}; + if (!modifier) return nullptr; + historical.try_emplace(work_index->GetBlockHash(), work_index, dmnman.GetListForBlock(work_index)); + modifiers.emplace(std::make_pair(params.type, work_index->GetBlockHash()), *modifier); + return work_index; + }; + + for (const auto& params : chainparams.GetConsensus().llmqs) { + QuorumSnapshotData data; + data.llmq_type = params.type; + data.rotation_enabled = llmq::IsQuorumRotationEnabled(params, base_index); + + const size_t active_count{static_cast(params.signingActiveQuorumCount)}; + const size_t total_count{SnapshotCommitmentCount(params, data.rotation_enabled)}; + std::vector indexes; + if (data.rotation_enabled) { + indexes = qblockman.GetLastMinedCommitmentsPerQuorumIndexUntilBlock(params.type, base_index, 0); + } else { + indexes = qblockman.GetMinedCommitmentsUntilBlock(params.type, base_index, total_count); + } + // A type that can no longer form quorums (e.g. LLMQ_50_60 after DIP24 + // on mainnet) still contributes its retained commitments to the CbTx + // quorum merkle root, so it is carried whenever it has any. + if (indexes.empty() && !chainman.IsQuorumTypeEnabled(params.type, base_index)) continue; + // A young chain (or a freshly activated type) legitimately has fewer + // mined commitments than the parameter-derived horizon. Emit what + // exists: the CbTx quorum merkle root pins the active set at + // completion, so a shortfall cannot be used to hide commitments. + const size_t emit_active{std::min(indexes.size(), active_count)}; + for (size_t i{0}; i < emit_active; ++i) { + const CBlockIndex* work_index{register_work_block(params, data.rotation_enabled, indexes[i])}; + if (work_index == nullptr) { + error = "active quorum work block or its exact score modifier is unavailable"; + return false; + } + auto entry{ReadCommitment(qblockman, params.type, indexes[i], work_index, error)}; + if (!error.empty()) return false; + data.active_commitments.emplace_back(std::move(entry)); + } + + if (data.rotation_enabled) { + indexes = qblockman.GetLastMinedCommitmentsPerQuorumIndexUntilBlock(params.type, base_index, 1); + } else { + indexes.erase(indexes.begin(), indexes.begin() + emit_active); + } + const size_t safety_count{std::min(indexes.size(), total_count - active_count)}; + for (size_t i{0}; i < safety_count; ++i) { + const CBlockIndex* work_index{register_work_block(params, data.rotation_enabled, indexes[i])}; + if (work_index == nullptr) { + error = "safety quorum work block or its exact score modifier is unavailable"; + return false; + } + auto entry{ReadCommitment(qblockman, params.type, indexes[i], work_index, error)}; + if (!error.empty()) return false; + data.safety_commitments.emplace_back(std::move(entry)); + } + + if (data.rotation_enabled) { + std::vector one_type{params}; + for (const auto& required : EvoSnapshotReconstructionHeights(base_index->nHeight, one_type)) { + const int cycle_height{required.quorum_height}; + const int work_height{required.work_height}; + const CBlockIndex* cycle_index{cycle_height >= 0 ? base_index->GetAncestor(cycle_height) : nullptr}; + const CBlockIndex* work_index{cycle_index && work_height >= 0 + ? cycle_index->GetAncestor(work_height) + : nullptr}; + // Horizons preceding the chain, and cycles that predate the + // type's first rotation DKG, have no snapshot to carry. A gap + // on a mature chain surfaces at completion, where quorum + // reconstruction from the carried state must match the chain. + if (cycle_index == nullptr || work_index == nullptr) continue; + auto stored{qsnapman.GetSnapshotForBlock(params.type, cycle_index)}; + if (!stored) continue; + const auto modifier{ExactQuorumModifier(params, chainparams.GetConsensus(), qsnapman, cycle_index)}; + if (!modifier) { + error = "rotation cycle work block data unavailable for its quorum score modifier"; + 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()), *modifier); + } + } + data.active_commitments = CanonicallySortedCopy(std::move(data.active_commitments)); + data.safety_commitments = CanonicallySortedCopy(std::move(data.safety_commitments)); + data.rotation_snapshots = CanonicallySortedCopy(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 = CanonicallySortedCopy(std::move(result.quorums)); + result.historical_mn_list_diffs = CanonicallySortedCopy(std::move(result.historical_mn_list_diffs)); + result.quorum_modifiers = CanonicallySortedCopy(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 EvoSnapshot& 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.mn_list.GetCounts().total() != 0 || + snapshot.mn_list.GetTotalRegisteredCount() != 0 || 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 known_count{0}; + std::set required_work_hashes; + for (const auto& params : consensus.llmqs) { + const bool enabled{chainman.IsQuorumTypeEnabled(params.type, base_index)}; + const auto it{actual.find(params.type)}; + if (it == actual.end()) { + if (enabled) return fail("missing enabled evo quorum type"); + continue; + } + ++known_count; + const auto& data{*it->second}; + // A disabled type is carried only for the retained commitments it + // still contributes to the CbTx quorum merkle root. + if (!enabled && data.active_commitments.empty()) return fail("empty disabled evo quorum type"); + 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 MinedQuorumCommitment& entry) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + if (quorum_index == nullptr) return false; + const CBlockIndex* modifier_base{rotation_enabled + ? quorum_index->GetAncestor(quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval) + : quorum_index}; + if (modifier_base == nullptr) return false; + const CBlockIndex* expected_work{ + (rotation_enabled || DeploymentActiveAfter(modifier_base, consensus, Consensus::DEPLOYMENT_V20)) + ? modifier_base->GetAncestor(modifier_base->nHeight - llmq::WORK_DIFF_DEPTH) + : modifier_base}; + return expected_work != nullptr && entry.work_block_hash == expected_work->GetBlockHash(); + }; + for (const auto& entry : data.active_commitments) { + if (!ValidateCommitmentAgainstChain(entry, chainman, base_index, params, rotation_enabled) || + !validate_work_block(entry) || + (rotation_enabled && !active_indexes.insert(entry.commitment.quorumIndex).second)) { + return fail("invalid active evo quorum commitment chain data"); + } + required_work_hashes.insert(entry.work_block_hash); + } + for (const auto& entry : data.safety_commitments) { + if (!ValidateCommitmentAgainstChain(entry, chainman, base_index, params, rotation_enabled) || + !validate_work_block(entry)) { + return fail("invalid safety evo quorum commitment chain data"); + } + required_work_hashes.insert(entry.work_block_hash); + } + std::map rotations; + for (const auto& entry : data.rotation_snapshots) rotations.emplace(entry.cycle_base_block_hash, &entry); + const auto heights{EvoSnapshotReconstructionHeights(base_index->nHeight, {params})}; + if (rotations.size() > (rotation_enabled ? heights.size() : size_t{0})) { + return fail("evo rotation snapshot count mismatch"); + } + if (rotation_enabled) { + // Every carried rotation snapshot must sit at a derived horizon + // cycle with the matching work ancestor. Horizons the chain or the + // type's rotation history cannot provide are legitimately absent; + // completion-time quorum reconstruction establishes sufficiency. + size_t matched{0}; + for (const auto& required : heights) { + const int cycle_height{required.quorum_height}; + const int work_height{required.work_height}; + const CBlockIndex* cycle{cycle_height >= 0 ? base_index->GetAncestor(cycle_height) : nullptr}; + const CBlockIndex* work{work_height >= 0 ? base_index->GetAncestor(work_height) : nullptr}; + if (cycle == nullptr || work == nullptr) continue; + const auto rotation{rotations.find(cycle->GetBlockHash())}; + if (rotation == rotations.end()) continue; + if (rotation->second->work_block_hash != work->GetBlockHash()) { + return fail("evo rotation cycle/work ancestor mismatch"); + } + const auto historical{historical_lists.find(work->GetBlockHash())}; + if (historical == historical_lists.end() || + rotation->second->snapshot.activeQuorumMembers.size() != + historical->second.GetCounts().total()) { + return fail("evo rotation bitset/work-block MN count mismatch"); + } + required_work_hashes.insert(work->GetBlockHash()); + ++matched; + } + if (matched != rotations.size()) return fail("unknown evo rotation cycle"); + } + } + if (actual.size() != known_count) return fail("unknown evo quorum type"); + + std::set historical_hashes; + for (const auto& [hash, list] : historical_lists) historical_hashes.insert(hash); + historical_hashes.erase(base_index->GetBlockHash()); + required_work_hashes.erase(base_index->GetBlockHash()); + if (historical_hashes != required_work_hashes) return fail("missing or extra historical evo MN list"); + + std::map, uint256> seeded_modifiers; + for (const auto& entry : snapshot.quorum_modifiers) { + seeded_modifiers.emplace(std::make_pair(entry.llmq_type, entry.work_block_hash), entry.modifier); + } + for (const auto& data : snapshot.quorums) { + const auto params{chainman.GetParams().GetLLMQ(data.llmq_type)}; + if (!params) return fail("unknown chain LLMQ parameters for modifier"); + const auto check_modifier = [&](const uint256& quorum_hash, const uint256& work_hash) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + const auto seeded{seeded_modifiers.find(std::make_pair(data.llmq_type, work_hash))}; + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(quorum_hash)}; + const CBlockIndex* work_index{chainman.m_blockman.LookupBlockIndex(work_hash)}; + if (seeded == seeded_modifiers.end() || quorum_index == nullptr || work_index == nullptr) return false; + if ((work_index->nStatus & BLOCK_HAVE_DATA) != 0 && + seeded->second != llmq::utils::GetQuorumHashModifier(*params, consensus, quorum_index)) return false; + return true; + }; + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + const CBlockIndex* modifier_base{data.rotation_enabled && quorum_index != nullptr + ? quorum_index->GetAncestor(quorum_index->nHeight - quorum_index->nHeight % params->dkgInterval) + : quorum_index}; + if (modifier_base == nullptr || + !check_modifier(modifier_base->GetBlockHash(), entry.work_block_hash)) { + return fail("evo seeded quorum modifier mismatch"); + } + } + } + for (const auto& entry : data.rotation_snapshots) { + if (!check_modifier(entry.cycle_base_block_hash, entry.work_block_hash)) { + return fail("evo seeded rotation modifier mismatch"); + } + } + } + + if (!MoneyRange(snapshot.credit_pool.locked) || !MoneyRange(snapshot.credit_pool.currentLimit) || + !MoneyRange(snapshot.credit_pool.latelyUnlocked)) return fail("invalid evo credit pool monetary value"); + for (const auto& [bit, height] : snapshot.mnhf_signals) { + if (bit >= VERSIONBITS_NUM_BITS || height < 0 || height > base_index->nHeight) { + return fail("invalid evo MNHF signal bit/height"); + } + } + return true; +} + +} // namespace evo diff --git a/src/evo/snapshot_types.h b/src/evo/snapshot_types.h new file mode 100644 index 000000000000..a6b4389ef1e5 --- /dev/null +++ b/src/evo/snapshot_types.h @@ -0,0 +1,20 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_EVO_SNAPSHOT_TYPES_H +#define BITCOIN_EVO_SNAPSHOT_TYPES_H + +#include + +namespace evo { + +class SnapshotStateMismatchError : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +} // namespace evo + +#endif // BITCOIN_EVO_SNAPSHOT_TYPES_H diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index be1f0f18175c..083124c31dd0 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -904,7 +905,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, is_v24_active, view, true, state, mn_list)) { // pass the state returned by the function above diff --git a/src/llmq/blockprocessor.cpp b/src/llmq/blockprocessor.cpp index 82b0a87a81a1..6e3dbc45d52e 100644 --- a/src/llmq/blockprocessor.cpp +++ b/src/llmq/blockprocessor.cpp @@ -683,6 +683,35 @@ 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); + } + DropQcHashesCache(); + WITH_LOCK(minableCommitmentsCs, mapMinedCommitmentBlockCache.erase(llmqType, quorum_hash)); + 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 c248a13fd29f..1e8119fbd9e2 100644 --- a/src/llmq/blockprocessor.h +++ b/src/llmq/blockprocessor.h @@ -124,6 +124,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, !minableCommitmentsCs, !m_qc_hashes_cache_mutex); /** * Serialized hashes of the commitments mined for the quorums active as of pindexPrev. diff --git a/src/llmq/snapshot.cpp b/src/llmq/snapshot.cpp index bde0d32fb783..2478f9c9dd1e 100644 --- a/src/llmq/snapshot.cpp +++ b/src/llmq/snapshot.cpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include #include @@ -320,11 +322,64 @@ std::optional CQuorumSnapshotManager::GetSnapshotForBlock(const void CQuorumSnapshotManager::StoreSnapshotForBlock(const Consensus::LLMQType llmqType, const CBlockIndex* pindex, const CQuorumSnapshot& snapshot) { auto snapshotHash = ::SerializeHash(std::make_pair(llmqType, pindex->GetBlockHash())); + const auto key{std::make_pair(DB_QUORUM_SNAPSHOT, snapshotHash)}; - // LOCK(::cs_main); + // The snapshot is derived from the cycle base block alone, so persist it + // durably rather than through the current EvoDB transaction: members are + // also computed outside block connection (DKG, RPC) and inside rolled-back + // block tests, while this cache and the quorum member caches keep claiming + // the snapshot exists either way. AssertLockNotHeld(m_evoDb.cs); - LOCK2(snapshotCacheCs, m_evoDb.cs); - m_evoDb.GetRawDB().Write(std::make_pair(DB_QUORUM_SNAPSHOT, snapshotHash), snapshot); + LOCK(snapshotCacheCs); + CQuorumSnapshot existing; + const bool stored{m_evoDb.Read(key, existing)}; + if (stored && ::SerializeHash(existing) != ::SerializeHash(snapshot)) { + // A mismatch is local EvoDB corruption, not a statement about the + // block. Abort here like the credit pool does: quorum members are also + // computed outside the block-connect catches (DKG, RPC), and those + // catches must not translate this into a consensus rejection. + const std::string msg = strprintf("CQuorumSnapshotManager::%s -- EvoDB quorum snapshot mismatch for block %s", + __func__, pindex->GetBlockHash().ToString()); + AbortNode(msg); + throw EvoDbInconsistencyError(msg); + } + if (!stored) { + LOCK(m_evoDb.cs); + m_evoDb.GetRawDB().Write(key, snapshot); + } quorumSnapshotCache.insert(snapshotHash, snapshot); } + +bool CQuorumSnapshotManager::SeedSnapshotForBlock(const Consensus::LLMQType llmqType, const CBlockIndex* pindex, + const CQuorumSnapshot& snapshot) +{ + if (!Assume(pindex != nullptr)) return false; + 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 43370849c461..108fc0cdffe3 100644 --- a/src/llmq/snapshot.h +++ b/src/llmq/snapshot.h @@ -248,6 +248,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 195a050375a1..667a1123c5a1 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -23,6 +24,7 @@ #include #include #include +#include /** * Forward declarations @@ -96,8 +98,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); @@ -114,6 +116,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; @@ -187,6 +205,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) @@ -201,7 +220,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; @@ -289,7 +308,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); @@ -300,14 +320,14 @@ 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); } -void BuildQuorumSnapshot(const Consensus::LLMQParams& llmqParams, const Consensus::Params& consensus_params, - const CDeterministicMNList& allMns, const CDeterministicMNList& mnUsedAtH, - llmq::CQuorumSnapshot& quorumSnapshot, std::vector& skipList, - const CBlockIndex* pCycleQuorumBaseBlockIndex) +void BuildQuorumSnapshot(const Consensus::LLMQParams& llmqParams, const CDeterministicMNList& allMns, + const CDeterministicMNList& mnUsedAtH, llmq::CQuorumSnapshot& quorumSnapshot, + std::vector& skipList, const CBlockIndex* pCycleQuorumBaseBlockIndex, const uint256& modifier) { if (!llmqParams.useRotation || pCycleQuorumBaseBlockIndex->nHeight % llmqParams.dkgInterval != 0) { ASSERT_IF_DEBUG(false); @@ -316,7 +336,6 @@ 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); auto sortedAllMns = CalculateQuorum(allMns, modifier); LogPrint(BCLog::LLMQ, "BuildQuorumSnapshot h[%d] numMns[%d]\n", pCycleQuorumBaseBlockIndex->nHeight, @@ -457,8 +476,9 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar if (storeSnapshot) { llmq::CQuorumSnapshot quorumSnapshot{}; - BuildQuorumSnapshot(llmqParams, util_params.m_chainman.GetConsensus(), allMns, MnsUsedAtH, quorumSnapshot, - skipList, util_params.m_base_index); + // Reuse the member-selection modifier so the bitset is indexed in the + // same order, including when it came from a seeded snapshot modifier. + BuildQuorumSnapshot(llmqParams, allMns, MnsUsedAtH, quorumSnapshot, skipList, util_params.m_base_index, modifier); util_params.m_qsnapman.StoreSnapshotForBlock(llmqParams.type, util_params.m_base_index, quorumSnapshot); } @@ -503,6 +523,7 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus break; } prev_cycles[idx]->m_members = GetQuorumQuarterMembersBySnapshot(llmqParams, util_params.m_dmnman, + util_params.m_qsnapman, util_params.m_chainman.GetConsensus(), prev_cycles[idx]->m_cycle_index, prev_cycles[idx]->m_snap, @@ -546,6 +567,13 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus namespace llmq { namespace utils { +uint256 GetQuorumHashModifier(const Consensus::LLMQParams& llmq_params, + const Consensus::Params& consensus_params, + gsl::not_null cycle_quorum_base_index) +{ + return CalculateHashModifier(llmq_params, consensus_params, cycle_quorum_base_index); +} + BlsCheck::BlsCheck() = default; BlsCheck::BlsCheck(CBLSSignature sig, std::vector pubkeys, uint256 msg_hash, std::string id_string) : @@ -631,7 +659,8 @@ std::optional> ComputeQuorumMembersFromWorkBlo return quorumMembers[quorumIndex]; } -QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) +static QuorumMembers GetAllQuorumMembersInternal(Consensus::LLMQType llmqType, const UtilParameters& util_params, + bool reset_cache) { static RecursiveMutex cs_members; static PerLlmqTypeCache mapQuorumMembers GUARDED_BY(cs_members); @@ -701,7 +730,7 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame const CBlockIndex* pWorkBlockIndex = pCycleQuorumBaseBlockIndex->GetAncestor(cycleQuorumBaseHeight - WORK_DIFF_DEPTH); const auto modifier = GetHashModifier(llmq_params, util_params.m_chainman.GetConsensus(), - pCycleQuorumBaseBlockIndex); + pCycleQuorumBaseBlockIndex, &util_params.m_qsnapman); auto q = ComputeQuorumMembersByQuarterRotation(llmq_params, util_params.replace_index(pCycleQuorumBaseBlockIndex), pWorkBlockIndex, cycleQuorumBaseHeight, modifier, /*predicting=*/false); @@ -721,7 +750,7 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame : util_params.m_base_index.get(); CDeterministicMNList mn_list = util_params.m_dmnman.GetListForBlock(pWorkBlockIndex); quorumMembers = ComputeQuorumMembers(llmqType, util_params.m_chainman.GetParams(), mn_list, - util_params.m_base_index); + util_params.m_base_index, &util_params.m_qsnapman); } LOCK(cs_members); @@ -729,6 +758,15 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame return quorumMembers; } +QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) +{ + // A SnapshotStateMismatchError from a seeded-modifier disagreement + // propagates to the caller. Production code does not seed modifiers yet; + // the load-time integration later in the series routes this into the + // controlled invalid-snapshot path. + return GetAllQuorumMembersInternal(llmqType, util_params, reset_cache); +} + uint256 DeterministicOutboundConnection(const uint256& proTxHash1, const uint256& proTxHash2) { // We need to deterministically select who is going to initiate the connection. The naive way would be to simply diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 65c4c2682808..bfb6e8141b01 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -43,6 +43,11 @@ struct UtilParameters { }; namespace utils { +/** Normal consensus modifier calculation; snapshot overrides are internal to reconstruction. */ +uint256 GetQuorumHashModifier(const Consensus::LLMQParams& llmq_params, + const Consensus::Params& consensus_params, + gsl::not_null cycle_quorum_base_index); + struct BlsCheck { CBLSSignature m_sig; std::vector m_pubkeys; diff --git a/src/test/evo_db_tests.cpp b/src/test/evo_db_tests.cpp index 87cf6a3b4db0..cb7b421f26bc 100644 --- a/src/test/evo_db_tests.cpp +++ b/src/test/evo_db_tests.cpp @@ -248,10 +248,18 @@ BOOST_AUTO_TEST_CASE(snapshot_markers_can_be_discarded) auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); db.WriteSnapshotBaseMNListHash(BlockHash(4)); db.WriteBackgroundMNListHash(BlockHash(40), BlockHash(4)); + db.Write(EVODB_SNAPSHOT_EVO_SECTION, BlockHash(44)); db.WriteDualChainstateMarker(); tx->Commit(); } BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); + { + auto tx = db.BeginTransaction(EvoDbIdentity::NORMAL); + db.WriteRequiredWorkMNListHashes({BlockHash(30), BlockHash(35)}); + db.WriteBackgroundWorkMNListHash(BlockHash(30), BlockHash(3)); + tx->Commit(); + } + BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::NORMAL)); BOOST_REQUIRE(db.HasDualChainstateMarker()); // Abandoning snapshot activation after the markers were committed must @@ -272,6 +280,10 @@ BOOST_AUTO_TEST_CASE(snapshot_markers_can_be_discarded) BOOST_CHECK(!reopened.ReadSnapshotBaseMNListHash(hash)); BOOST_CHECK(!reopened.ReadBackgroundMNListHash(hash, hash2)); BOOST_CHECK(!reopened.HasDualChainstateMarker()); + std::vector required; + BOOST_CHECK(!reopened.ReadRequiredWorkMNListHashes(required)); + BOOST_CHECK(!reopened.ReadBackgroundWorkMNListHash(BlockHash(30), hash)); + BOOST_CHECK(!reopened.Exists(EVODB_SNAPSHOT_EVO_SECTION)); } BOOST_AUTO_TEST_CASE(snapshot_marker_promotion_and_discard) diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index 2d3f97647c16..4bb2dd214d44 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -223,8 +224,39 @@ void CheckInvalid(evo::EvoSnapshot snapshot) { BOOST_CHECK_THROW(snapshot.Valida // bucket. static_assert(std::is_same_v); +//! 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) @@ -272,6 +304,819 @@ BOOST_FIXTURE_TEST_CASE(populated_roundtrip_and_representation_independence, Bas } } +BOOST_FIXTURE_TEST_CASE(snapshot_identity_seeding_is_retrievable, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + const auto list{MNList(base->GetBlockHash(), base->nHeight, false)}; + const CBlockIndex* historical_index{base->GetAncestor(50)}; + const auto historical_list{MNList(historical_index->GetBlockHash(), historical_index->nHeight, true)}; + const auto indexed_commitment = [&](Consensus::LLMQType type, int quorum_height, int mined_height, + bool rotated, int16_t quorum_index = 0) { + auto entry{Commitment(type, 1, 2, rotated, quorum_index)}; + entry.quorum_base_block_hash = base->GetAncestor(quorum_height)->GetBlockHash(); + entry.commitment.quorumHash = entry.quorum_base_block_hash; + entry.mined_block_hash = base->GetAncestor(mined_height)->GetBlockHash(); + return entry; + }; + const std::vector nonrotated{ + indexed_commitment(Consensus::LLMQType::LLMQ_TEST, 48, 58, false), + indexed_commitment(Consensus::LLMQType::LLMQ_TEST, 72, 82, false), + }; + const std::vector rotated{ + indexed_commitment(Consensus::LLMQType::LLMQ_TEST_DIP0024, 72, 84, true, 0), + indexed_commitment(Consensus::LLMQType::LLMQ_TEST_DIP0024, 73, 85, true, 1), + }; + CCreditPool pool; + pool.locked = 123; + pool.currentLimit = 45; + pool.latelyUnlocked = 6; + AbstractEHFManager::Signals signals{{2, base->nHeight}}; + llmq::CQuorumSnapshot quorum_snapshot{{true, false, true}, SnapshotSkipMode::MODE_NO_SKIPPING, {}}; + + ConsensusParamsRestorer params_restorer{Params().GetConsensus()}; + const int old_dip3_height{params_restorer.Get().DIP0003Height}; + params_restorer.Get().DIP0003Height = 1; + BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(base).GetCounts().total(), 0U); + BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(historical_index).GetCounts().total(), 0U); + + { + LOCK(::cs_main); + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(list)); + BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(historical_list)); + 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(WITH_LOCK(::cs_main, return 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; + { + LOCK(::cs_main); + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + stored_list = m_node.dmnman->GetListForBlock(base); + stored_historical_list = m_node.dmnman->GetListForBlock(historical_index); + } + m_node.dmnman->InvalidateListCacheForBlock(base->GetBlockHash()); + m_node.dmnman->InvalidateListCacheForBlock(historical_index->GetBlockHash()); + const auto subsequent_list{m_node.dmnman->GetListForBlock(base)}; + const auto subsequent_historical_list{m_node.dmnman->GetListForBlock(historical_index)}; + params_restorer.Get().DIP0003Height = old_dip3_height; + BOOST_CHECK(evo::CanonicalMNListHash(stored_list) == evo::CanonicalMNListHash(list)); + BOOST_CHECK(evo::CanonicalMNListHash(stored_historical_list) == evo::CanonicalMNListHash(historical_list)); + BOOST_CHECK(evo::CanonicalMNListHash(subsequent_list) == evo::CanonicalMNListHash(list)); + BOOST_CHECK(evo::CanonicalMNListHash(subsequent_historical_list) == evo::CanonicalMNListHash(historical_list)); + const auto [stored_commitment, stored_mined_hash]{ + m_node.llmq_ctx->quorum_block_processor->GetMinedCommitment( + nonrotated.back().commitment.llmqType, nonrotated.back().quorum_base_block_hash)}; + BOOST_CHECK_EQUAL(stored_mined_hash, nonrotated.back().mined_block_hash); + BOOST_CHECK_EQUAL(SerializeHash(stored_commitment), SerializeHash(nonrotated.back().commitment)); + { + LOCK(::cs_main); + const auto plain{m_node.llmq_ctx->quorum_block_processor->GetMinedCommitmentsUntilBlock( + Consensus::LLMQType::LLMQ_TEST, base, 2)}; + BOOST_REQUIRE_EQUAL(plain.size(), 2U); + BOOST_CHECK_EQUAL(plain[0]->nHeight, 72); + BOOST_CHECK_EQUAL(plain[1]->nHeight, 48); + const auto indexed{m_node.llmq_ctx->quorum_block_processor->GetLastMinedCommitmentsPerQuorumIndexUntilBlock( + Consensus::LLMQType::LLMQ_TEST_DIP0024, base, 0)}; + BOOST_REQUIRE_EQUAL(indexed.size(), 2U); + BOOST_CHECK_EQUAL(indexed[0]->nHeight, 72); + BOOST_CHECK_EQUAL(indexed[1]->nHeight, 73); + + CBlock first_post_base_block; + uint256 quorum_root; + BlockValidationState state; + BOOST_CHECK_MESSAGE(CalcCbTxMerkleRootQuorums(first_post_base_block, base, + *m_node.llmq_ctx->quorum_block_processor, quorum_root, state), + state.ToString()); + } + const auto stored_snapshot{m_node.llmq_ctx->qsnapman->GetSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base)}; + BOOST_REQUIRE(stored_snapshot.has_value()); + BOOST_CHECK(stored_snapshot->activeQuorumMembers == quorum_snapshot.activeQuorumMembers); + + CCreditPool stored_pool; + AbstractEHFManager::Signals stored_signals; + BOOST_REQUIRE(m_node.evodb->Read(std::make_pair(std::string{"cpm_S"}, base->GetBlockHash()), stored_pool)); + BOOST_REQUIRE(m_node.evodb->Read(std::make_pair(std::string{"mnhf_s2"}, base->GetBlockHash()), stored_signals)); + BOOST_CHECK_EQUAL(stored_pool.locked, pool.locked); + BOOST_CHECK(stored_signals == signals); +} + +BOOST_FIXTURE_TEST_CASE(snapshot_seed_rollback_does_not_publish_caches, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + const auto seeded_list{MNList(base->GetBlockHash(), base->nHeight, false)}; + CCreditPool seeded_pool; + seeded_pool.locked = 123; + AbstractEHFManager::Signals seeded_signals{{2, base->nHeight}}; + const llmq::CQuorumSnapshot seeded_quorum{{true, false, true}, SnapshotSkipMode::MODE_NO_SKIPPING, {}}; + + { + LOCK(::cs_main); + 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(!m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + Consensus::LLMQType::LLMQ_TEST, H(200), Commitment(Consensus::LLMQType::LLMQ_TEST, 1, 2, false).commitment, + H(201))); + // Destruction without Commit() rolls the complete scoped transaction back. + } + + CDeterministicMNList db_list; + CCreditPool db_pool; + AbstractEHFManager::Signals db_signals; + const auto quorum_hash{SerializeHash(std::make_pair(Consensus::LLMQType::LLMQ_TEST, base->GetBlockHash()))}; + llmq::CQuorumSnapshot db_quorum; + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"dmn_S3"}, base->GetBlockHash()), db_list)); + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"cpm_S"}, base->GetBlockHash()), db_pool)); + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"mnhf_s2"}, base->GetBlockHash()), db_signals)); + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string_view{"llmq_S"}, quorum_hash), db_quorum)); + + { + ConsensusParamsRestorer params_restorer{Params().GetConsensus()}; + params_restorer.Get().DIP0003Height = 1; + params_restorer.Get().V20Height = 1; + BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(base).GetCounts().total(), 0U); + BOOST_CHECK_EQUAL(m_node.chain_helper->credit_pool_manager->GetCreditPool(base).locked, 0); + BOOST_CHECK(m_node.chain_helper->ehf_manager->GetSignalsStage(base).empty()); + } + BOOST_CHECK(!m_node.llmq_ctx->qsnapman->GetSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base).has_value()); +} + +BOOST_FIXTURE_TEST_CASE(stored_rotation_snapshot_survives_transaction_rollback, TestChain100Setup) +{ + // TestBlockValidity computes rotation members for a cycle base block and + // then rolls its EvoDB transaction back, while the member cache keeps the + // result. The derived snapshot must stay readable afterwards, or connecting + // the same block hits the member cache and never stores it again. + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + const auto type{Consensus::LLMQType::LLMQ_TEST_DIP0024}; + const llmq::CQuorumSnapshot stored{{true, false, true}, SnapshotSkipMode::MODE_NO_SKIPPING, {}}; + { + LOCK(::cs_main); + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::NORMAL)}; + m_node.llmq_ctx->qsnapman->StoreSnapshotForBlock(type, base, stored); + tx->Rollback(); + } + m_node.llmq_ctx->qsnapman->InvalidateSnapshotCacheForBlock(type, base->GetBlockHash()); + const auto reloaded{m_node.llmq_ctx->qsnapman->GetSnapshotForBlock(type, base)}; + BOOST_REQUIRE(reloaded.has_value()); + BOOST_CHECK(reloaded->activeQuorumMembers == stored.activeQuorumMembers); + BOOST_CHECK(reloaded->mnSkipListMode == stored.mnSkipListMode); +} + +BOOST_FIXTURE_TEST_CASE(quorum_members_reconstruct_from_seeded_state_only, SnapshotActivationChainSetup) +{ + const CBlockIndex* tip{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(tip != nullptr); + ConsensusParamsRestorer global_restorer{Params().GetConsensus()}; + ConsensusParamsRestorer chain_restorer{m_node.chainman->GetConsensus()}; + auto& global_consensus{global_restorer.Get()}; + auto& consensus{chain_restorer.Get()}; + auto plain{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + auto rotated{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + plain.dkgInterval = 12; + plain.dkgMiningWindowStart = 1; + plain.dkgMiningWindowEnd = 3; + rotated.dkgInterval = 12; + consensus.llmqs = {plain, rotated}; + global_consensus.llmqs = consensus.llmqs; + + const CBlockIndex* quorum{tip->GetAncestor(96)}; + BOOST_REQUIRE(quorum != nullptr); + std::map lists; + const auto make_list = [&](const CBlockIndex* work) { + CDeterministicMNList list{work->GetBlockHash(), work->nHeight, 100}; + for (uint8_t i{0}; i < 12; ++i) { + list.AddMN(MN(20 + i, 20 + i, MnType::Regular, ProTxVersion::LegacyBLS, 20 + i), false); + } + return list; + }; + const CBlockIndex* plain_work{quorum->GetAncestor(88)}; + lists.emplace(plain_work, make_list(plain_work)); + std::vector rotated_cycles; + for (const int height : {96, 84, 72, 60}) { + const CBlockIndex* cycle{tip->GetAncestor(height)}; + const CBlockIndex* work{tip->GetAncestor(height - llmq::WORK_DIFF_DEPTH)}; + rotated_cycles.emplace_back(cycle); + lists.try_emplace(work, make_list(work)); + } + for (const auto& [work, list] : lists) m_node.dmnman->SetListForBlockForTesting(list); + BOOST_REQUIRE(m_node.chainman->IsQuorumTypeEnabled(plain.type, quorum->pprev)); + BOOST_REQUIRE(m_node.chainman->IsQuorumTypeEnabled(rotated.type, quorum->pprev)); + BOOST_REQUIRE_EQUAL(m_node.dmnman->GetListForBlock(plain_work).GetCounts().enabled(), 12U); + const llmq::CQuorumSnapshot empty_snapshot{std::vector(12, false), + SnapshotSkipMode::MODE_NO_SKIPPING, {}}; + for (size_t i{1}; i < rotated_cycles.size(); ++i) { + m_node.llmq_ctx->qsnapman->StoreSnapshotForBlock(rotated.type, rotated_cycles[i], empty_snapshot); + } + + // Derive the oracle through a separate manager, cache, and EvoDB. The + // manager under test is seeded only after these expected sets exist. + CEvoDB expected_db{util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evo_snapshot_oracle", + .memory = true, .wipe = true}}; + CMasternodeMetaMan expected_meta; + CDeterministicMNManager expected_dmnman{expected_db, expected_meta}; + llmq::CQuorumSnapshotManager expected_qsnapman{expected_db}; + { + LOCK(::cs_main); + 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)}; + + { + LOCK(::cs_main); + 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(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}; + { + LOCK(::cs_main); + 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)); + }); + { + LOCK(::cs_main); + 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; + } + { + LOCK(::cs_main); + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + const auto modifier_key{std::make_tuple(std::string_view{"llmq_M3"}, plain.type, + plain_work->GetBlockHash())}; + m_node.evodb->Erase(modifier_key); + m_node.evodb->Write(modifier_key, H(254)); + BOOST_CHECK_THROW(llmq::utils::GetAllQuorumMembers( + plain.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true), + evo::SnapshotStateMismatchError); + } +} + +BOOST_FIXTURE_TEST_CASE(rotation_snapshot_bitset_uses_seeded_modifier, SnapshotActivationChainSetup) +{ + // A cycle whose work block is unavailable selects members with the seeded + // modifier. The snapshot stored for that cycle must index its bitset in the + // same order, so a later cycle reconstructs exactly the quarter selected. + const CBlockIndex* tip{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(tip != nullptr); + ConsensusParamsRestorer global_restorer{Params().GetConsensus()}; + ConsensusParamsRestorer chain_restorer{m_node.chainman->GetConsensus()}; + auto rotated{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + rotated.dkgInterval = 12; + chain_restorer.Get().llmqs = {rotated}; + global_restorer.Get().llmqs = {rotated}; + + for (const int cycle_height : {96, 84, 72, 60, 48}) { + const CBlockIndex* work{tip->GetAncestor(cycle_height - llmq::WORK_DIFF_DEPTH)}; + 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); + } + m_node.dmnman->SetListForBlockForTesting(list); + } + const llmq::CQuorumSnapshot empty_snapshot{std::vector(12, false), SnapshotSkipMode::MODE_NO_SKIPPING, {}}; + for (const int cycle_height : {72, 60, 48}) { + m_node.llmq_ctx->qsnapman->StoreSnapshotForBlock(rotated.type, tip->GetAncestor(cycle_height), empty_snapshot); + } + + const CBlockIndex* seeded_cycle{tip->GetAncestor(84)}; + auto* seeded_work{const_cast(seeded_cycle->GetAncestor(84 - llmq::WORK_DIFF_DEPTH))}; + const uint256 seeded_modifier{H(77)}; + BOOST_REQUIRE(seeded_modifier != llmq::utils::GetQuorumHashModifier(rotated, chain_restorer.Get(), seeded_cycle)); + { + LOCK(::cs_main); + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::NORMAL)}; + BOOST_REQUIRE( + m_node.llmq_ctx->qsnapman->SeedQuorumModifier(rotated.type, seeded_work->GetBlockHash(), seeded_modifier)); + tx->Commit(); + } + const uint32_t saved_status{WITH_LOCK(::cs_main, return seeded_work->nStatus)}; + WITH_LOCK(::cs_main, seeded_work->nStatus &= ~BLOCK_HAVE_DATA); + + const auto seeded_members{llmq::utils::GetAllQuorumMembers( + rotated.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, seeded_cycle}, true)}; + const auto next_members{llmq::utils::GetAllQuorumMembers( + rotated.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, tip->GetAncestor(96)}, true)}; + WITH_LOCK(::cs_main, seeded_work->nStatus = saved_status); + + // Quorum index 0 is [H-3C, H-2C, H-C, new] quarters of one member each. + BOOST_REQUIRE_EQUAL(seeded_members.size(), 4U); + BOOST_REQUIRE_EQUAL(next_members.size(), 4U); + BOOST_CHECK_EQUAL(next_members[2]->proTxHash, seeded_members[3]->proTxHash); +} + +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::EvoSnapshot 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))); + + // Well-formed for the context-free codec, but nothing can be registered before DIP3. + auto populated{snapshot}; + populated.mn_list = CDeterministicMNList{base->GetBlockHash(), base->nHeight, 1}; + populated.mn_list.AddMN(MN(0, 1, MnType::Regular, ProTxVersion::LegacyBLS, 1), /*fBumpTotalCount=*/false); + BOOST_CHECK( + !WITH_LOCK(::cs_main, return evo::ValidateEvoSnapshotAgainstChain(populated, *m_node.chainman, base, error))); + BOOST_CHECK_EQUAL(error, "nonempty pre-DIP3 evo snapshot"); + auto counted{snapshot}; + counted.mn_list = CDeterministicMNList{base->GetBlockHash(), base->nHeight, 1}; + BOOST_CHECK(!WITH_LOCK(::cs_main, return evo::ValidateEvoSnapshotAgainstChain(counted, *m_node.chainman, base, error))); + BOOST_CHECK_EQUAL(error, "nonempty pre-DIP3 evo snapshot"); + + ConsensusParamsRestorer params_restorer{m_node.chainman->GetConsensus()}; + auto& mutable_consensus{params_restorer.Get()}; + mutable_consensus.DIP0003Height = 1; + mutable_consensus.V19Height = 1; + mutable_consensus.llmqs = {evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + + evo::EvoSnapshot active{snapshot}; + evo::QuorumSnapshotData 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::MinedQuorumCommitment entry; + const CBlockIndex* quorum{base->GetAncestor(quorum_height)}; + entry.quorum_base_block_hash = quorum->GetBlockHash(); + entry.work_block_hash = entry.quorum_base_block_hash; + entry.mined_block_hash = base->GetAncestor(mined_height)->GetBlockHash(); + entry.commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION; + entry.commitment.llmqType = params.type; + entry.commitment.quorumHash = entry.quorum_base_block_hash; + entry.commitment.signers.resize(params.size); + entry.commitment.validMembers.resize(params.size); + return entry; + }; + quorum_data.active_commitments = {make_commitment(72, 82), make_commitment(48, 58)}; + quorum_data.safety_commitments = {make_commitment(24, 34)}; + std::sort(quorum_data.active_commitments.begin(), quorum_data.active_commitments.end(), + [](const auto& a, const auto& b) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + }); + active.quorums = {quorum_data}; + CDeterministicMNList previous{active.mn_list}; + uint256 previous_hash{active.base_block_hash}; + for (const int height : {72, 48, 24}) { + const CBlockIndex* work{base->GetAncestor(height)}; + CDeterministicMNList list{work->GetBlockHash(), height, 0}; + active.historical_mn_list_diffs.push_back({previous_hash, work->GetBlockHash(), height, 0, + evo::CanonicalMNListHash(list), previous.BuildDiff(list)}); + active.quorum_modifiers.push_back({params.type, work->GetBlockHash(), + llmq::utils::GetQuorumHashModifier(params, mutable_consensus, work)}); + previous_hash = work->GetBlockHash(); + previous = std::move(list); + } + std::sort(active.quorum_modifiers.begin(), active.quorum_modifiers.end(), [](const auto& a, const auto& b) { + return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); + }); + const bool active_valid{WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(active, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(active_valid, error); + + auto wrong_counts{active}; + wrong_counts.quorums[0].safety_commitments.clear(); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(wrong_counts, *m_node.chainman, base, error))); + + auto non_ancestor{active}; + non_ancestor.quorums[0].active_commitments[0].quorum_base_block_hash = H(99); + non_ancestor.quorums[0].active_commitments[0].commitment.quorumHash = H(99); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(non_ancestor, *m_node.chainman, base, error))); + + // A young chain carries fewer commitments than the parameter horizon. A + // coherent snapshot with a single active commitment, its historical diff, + // and its modifier must pass both validation layers: parameter counts are + // maxima, and completeness is established by the completion-time CbTx + // quorum merkle root, not by per-type count equality. + evo::EvoSnapshot partial{snapshot}; + evo::QuorumSnapshotData partial_data; + partial_data.llmq_type = Consensus::LLMQType::LLMQ_TEST; + partial_data.active_commitments = {make_commitment(72, 82)}; + partial.quorums = {partial_data}; + { + const CBlockIndex* work{base->GetAncestor(72)}; + CDeterministicMNList list{work->GetBlockHash(), 72, 0}; + partial.historical_mn_list_diffs.push_back({partial.base_block_hash, work->GetBlockHash(), 72, 0, + evo::CanonicalMNListHash(list), partial.mn_list.BuildDiff(list)}); + partial.quorum_modifiers.push_back({params.type, work->GetBlockHash(), + llmq::utils::GetQuorumHashModifier(params, mutable_consensus, work)}); + } + BOOST_CHECK_NO_THROW(partial.Validate()); + const bool partial_valid{WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(partial, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(partial_valid, error); +} + +BOOST_FIXTURE_TEST_CASE(chain_validation_carries_retired_quorum_types, TestChain100Setup) +{ + // A type that can no longer form quorums keeps contributing its retained + // commitments to the CbTx quorum merkle root (LLMQ_50_60 after DIP24 on + // mainnet), so the snapshot must be able to carry it. + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + ConsensusParamsRestorer params_restorer{m_node.chainman->GetConsensus()}; + auto& consensus{params_restorer.Get()}; + consensus.DIP0003Height = 1; + consensus.V19Height = 1; + const auto enabled{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + const auto retired{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_V17)}; + consensus.llmqs = {enabled, retired}; + BOOST_REQUIRE(m_node.chainman->IsQuorumTypeEnabled(enabled.type, base)); + BOOST_REQUIRE(!m_node.chainman->IsQuorumTypeEnabled(retired.type, base)); + + const CBlockIndex* quorum{base->GetAncestor(72)}; + evo::MinedQuorumCommitment entry; + entry.quorum_base_block_hash = quorum->GetBlockHash(); + entry.work_block_hash = quorum->GetBlockHash(); + entry.mined_block_hash = base->GetAncestor(82)->GetBlockHash(); + entry.commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION; + entry.commitment.llmqType = retired.type; + entry.commitment.quorumHash = quorum->GetBlockHash(); + entry.commitment.signers.resize(retired.size); + entry.commitment.validMembers.resize(retired.size); + + evo::EvoSnapshot snapshot; + snapshot.base_block_hash = base->GetBlockHash(); + snapshot.mn_list = CDeterministicMNList{base->GetBlockHash(), base->nHeight, 0}; + evo::QuorumSnapshotData enabled_data; + enabled_data.llmq_type = enabled.type; + evo::QuorumSnapshotData retired_data; + retired_data.llmq_type = retired.type; + retired_data.active_commitments = {entry}; + snapshot.quorums = {enabled_data, retired_data}; + const CDeterministicMNList work_list{quorum->GetBlockHash(), quorum->nHeight, 0}; + snapshot.historical_mn_list_diffs.push_back({snapshot.base_block_hash, quorum->GetBlockHash(), quorum->nHeight, 0, + evo::CanonicalMNListHash(work_list), + snapshot.mn_list.BuildDiff(work_list)}); + snapshot.quorum_modifiers.push_back( + {retired.type, quorum->GetBlockHash(), llmq::utils::GetQuorumHashModifier(retired, consensus, quorum)}); + + std::string error; + const bool valid{ + WITH_LOCK(::cs_main, return evo::ValidateEvoSnapshotAgainstChain(snapshot, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(valid, error); + + // Carrying a retired type is only meaningful for its retained commitments. + evo::EvoSnapshot empty_retired; + empty_retired.base_block_hash = snapshot.base_block_hash; + empty_retired.mn_list = snapshot.mn_list; + empty_retired.quorums = {enabled_data, evo::QuorumSnapshotData{}}; + empty_retired.quorums[1].llmq_type = retired.type; + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(empty_retired, *m_node.chainman, base, error))); + BOOST_CHECK_EQUAL(error, "empty disabled evo quorum type"); + + // Types outside the chain's LLMQ table are still rejected. + consensus.llmqs = {enabled}; + BOOST_CHECK( + !WITH_LOCK(::cs_main, return evo::ValidateEvoSnapshotAgainstChain(snapshot, *m_node.chainman, base, error))); + BOOST_CHECK_EQUAL(error, "unknown evo quorum type"); +} + +BOOST_FIXTURE_TEST_CASE(builder_emits_available_history_on_young_chains, SnapshotActivationChainSetup) +{ + // No masternodes exist and no DKGs have run on this fixture chain, so every + // enabled LLMQ type has zero mined commitments and zero rotation cycles. + // dumptxoutset-grade building must succeed on such a chain and emit the + // history that exists rather than failing the parameter-derived horizon. + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + evo::EvoSnapshot snapshot; + std::string error; + const bool built{WITH_LOCK(::cs_main, + return evo::BuildEvoSnapshot(Params(), *m_node.chainman, *m_node.dmnman, + *m_node.llmq_ctx->quorum_block_processor, *m_node.llmq_ctx->qsnapman, + *m_node.chain_helper->credit_pool_manager, *m_node.chain_helper->ehf_manager, + base, snapshot, error))}; + BOOST_REQUIRE_MESSAGE(built, error); + for (const auto& data : snapshot.quorums) { + BOOST_CHECK(data.active_commitments.empty()); + BOOST_CHECK(data.safety_commitments.empty()); + BOOST_CHECK(data.rotation_snapshots.empty()); + } + BOOST_CHECK_NO_THROW(snapshot.Validate()); + const bool valid{WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(snapshot, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(valid, error); +} + +BOOST_FIXTURE_TEST_CASE(builder_refuses_fallback_modifier_without_work_block, SnapshotActivationChainSetup) +{ + // A v20 modifier is derived from the work block's coinbase. When that block + // is unavailable the builder must export the seeded exact modifier, or fail, + // instead of the block-hash fallback the ordinary calculation returns. + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + ConsensusParamsRestorer global_restorer{Params().GetConsensus()}; + ConsensusParamsRestorer chain_restorer{m_node.chainman->GetConsensus()}; + auto rotated{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + rotated.dkgInterval = 12; + chain_restorer.Get().llmqs = {rotated}; + global_restorer.Get().llmqs = {rotated}; + + const CBlockIndex* cycle{base->GetAncestor(72)}; + auto* work{const_cast(cycle->GetAncestor(72 - llmq::WORK_DIFF_DEPTH))}; + for (const CBlockIndex* index : {base, static_cast(work)}) { + CDeterministicMNList list{index->GetBlockHash(), index->nHeight, 100}; + for (uint8_t i{0}; i < 4; ++i) { + list.AddMN(MN(20 + i, 20 + i, MnType::Regular, ProTxVersion::LegacyBLS, 20 + i), false); + } + m_node.dmnman->SetListForBlockForTesting(list); + } + m_node.llmq_ctx->qsnapman->StoreSnapshotForBlock(rotated.type, cycle, + {std::vector(4, false), SnapshotSkipMode::MODE_NO_SKIPPING, {}}); + + const auto build = [&](evo::EvoSnapshot& snapshot, std::string& error) { + return WITH_LOCK(::cs_main, + return evo::BuildEvoSnapshot(Params(), *m_node.chainman, *m_node.dmnman, + *m_node.llmq_ctx->quorum_block_processor, *m_node.llmq_ctx->qsnapman, + *m_node.chain_helper->credit_pool_manager, + *m_node.chain_helper->ehf_manager, base, snapshot, error)); + }; + const auto exported_modifier = [&](const evo::EvoSnapshot& snapshot) -> std::optional { + for (const auto& entry : snapshot.quorum_modifiers) { + if (entry.llmq_type == rotated.type && entry.work_block_hash == work->GetBlockHash()) return entry.modifier; + } + return std::nullopt; + }; + + evo::EvoSnapshot snapshot; + std::string error; + BOOST_REQUIRE_MESSAGE(build(snapshot, error), error); + BOOST_REQUIRE(exported_modifier(snapshot) == llmq::utils::GetQuorumHashModifier(rotated, chain_restorer.Get(), cycle)); + + const uint32_t saved_status{WITH_LOCK(::cs_main, return work->nStatus)}; + WITH_LOCK(::cs_main, work->nStatus &= ~BLOCK_HAVE_DATA); + BOOST_CHECK(!build(snapshot, error)); + BOOST_CHECK_EQUAL(error, "rotation cycle work block data unavailable for its quorum score modifier"); + + const uint256 seeded_modifier{H(77)}; + { + LOCK(::cs_main); + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::NORMAL)}; + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedQuorumModifier(rotated.type, work->GetBlockHash(), seeded_modifier)); + tx->Commit(); + } + const bool built_with_seed{build(snapshot, error)}; + WITH_LOCK(::cs_main, work->nStatus = saved_status); + BOOST_REQUIRE_MESSAGE(built_with_seed, error); + BOOST_CHECK(exported_modifier(snapshot) == seeded_modifier); +} + +BOOST_FIXTURE_TEST_CASE(rotation_bitset_matches_historical_work_list, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + ConsensusParamsRestorer params_restorer{m_node.chainman->GetConsensus()}; + auto& consensus{params_restorer.Get()}; + auto params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + params.dkgInterval = 12; + params.dkgMiningWindowStart = 2; + params.dkgMiningWindowEnd = 6; + consensus.llmqs = {params}; + consensus.DIP0003Height = 1; + consensus.V19Height = 1; + + evo::EvoSnapshot snapshot; + snapshot.base_block_hash = base->GetBlockHash(); + snapshot.mn_list = CDeterministicMNList{base->GetBlockHash(), base->nHeight, 100}; + evo::QuorumSnapshotData data; + data.llmq_type = params.type; + data.rotation_enabled = true; + const auto commitment = [&](int quorum_height, int mined_height, int16_t quorum_index) { + evo::MinedQuorumCommitment entry; + const CBlockIndex* quorum{base->GetAncestor(quorum_height)}; + const CBlockIndex* cycle{quorum->GetAncestor(quorum->nHeight - quorum->nHeight % params.dkgInterval)}; + entry.quorum_base_block_hash = quorum->GetBlockHash(); + entry.work_block_hash = cycle->GetAncestor(cycle->nHeight - llmq::WORK_DIFF_DEPTH)->GetBlockHash(); + entry.mined_block_hash = base->GetAncestor(mined_height)->GetBlockHash(); + entry.commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION; + entry.commitment.llmqType = params.type; + entry.commitment.quorumHash = entry.quorum_base_block_hash; + entry.commitment.quorumIndex = quorum_index; + entry.commitment.signers.resize(params.size); + entry.commitment.validMembers.resize(params.size); + return entry; + }; + data.active_commitments = {commitment(84, 86, 0), commitment(85, 87, 1)}; + data.safety_commitments = {commitment(72, 74, 0), commitment(73, 75, 1)}; + + std::map required_work; + for (const auto& required : evo::EvoSnapshotReconstructionHeights(base->nHeight, {params})) { + const int cycle_height{required.quorum_height}; + const int work_height{required.work_height}; + const CBlockIndex* cycle{base->GetAncestor(cycle_height)}; + const CBlockIndex* work{base->GetAncestor(work_height)}; + BOOST_REQUIRE(cycle != nullptr); + BOOST_REQUIRE(work != nullptr); + const size_t population{static_cast(params.size + 3)}; + data.rotation_snapshots.push_back({cycle->GetBlockHash(), work->GetBlockHash(), + llmq::CQuorumSnapshot{std::vector(population, true), SnapshotSkipMode::MODE_NO_SKIPPING, {}}}); + required_work.emplace(work->GetBlockHash(), work); + } + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* work{WITH_LOCK(::cs_main, + return m_node.chainman->m_blockman.LookupBlockIndex(entry.work_block_hash);)}; + BOOST_REQUIRE(work != nullptr); + required_work.emplace(entry.work_block_hash, work); + } + } + const auto commitment_less = [](const auto& a, const auto& b) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + }; + std::sort(data.active_commitments.begin(), data.active_commitments.end(), commitment_less); + std::sort(data.safety_commitments.begin(), data.safety_commitments.end(), commitment_less); + std::sort(data.rotation_snapshots.begin(), data.rotation_snapshots.end(), [](const auto& a, const auto& b) { + return std::tie(a.cycle_base_block_hash, a.work_block_hash) < + std::tie(b.cycle_base_block_hash, b.work_block_hash); + }); + snapshot.quorums = {std::move(data)}; + + std::vector ordered_work; + for (const auto& [_, work] : required_work) ordered_work.emplace_back(work); + std::sort(ordered_work.begin(), ordered_work.end(), [](const auto* a, const auto* b) { return a->nHeight > b->nHeight; }); + CDeterministicMNList previous{snapshot.mn_list}; + uint256 previous_hash{snapshot.base_block_hash}; + for (const auto* work : ordered_work) { + CDeterministicMNList work_list{work->GetBlockHash(), work->nHeight, 100}; + for (uint8_t i{0}; i < params.size + 3; ++i) { + work_list.AddMN(MN(20 + i, 20 + i, MnType::Regular, ProTxVersion::LegacyBLS, 20 + i), false); + } + snapshot.historical_mn_list_diffs.push_back( + {previous_hash, work->GetBlockHash(), work->nHeight, work_list.GetTotalRegisteredCount(), + evo::CanonicalMNListHash(work_list), previous.BuildDiff(work_list)}); + previous_hash = work->GetBlockHash(); + previous = std::move(work_list); + } + std::map modifier_cycles; + for (const auto* commitments : {&snapshot.quorums[0].active_commitments, &snapshot.quorums[0].safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* quorum_index{WITH_LOCK(::cs_main, + return m_node.chainman->m_blockman.LookupBlockIndex(entry.quorum_base_block_hash);)}; + const CBlockIndex* cycle{quorum_index->GetAncestor( + quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval)}; + modifier_cycles.emplace(entry.work_block_hash, cycle); + } + } + for (const auto& entry : snapshot.quorums[0].rotation_snapshots) { + modifier_cycles.emplace(entry.work_block_hash, WITH_LOCK(::cs_main, + return m_node.chainman->m_blockman.LookupBlockIndex(entry.cycle_base_block_hash);)); + } + for (const auto& [work_hash, cycle] : modifier_cycles) { + snapshot.quorum_modifiers.push_back({params.type, work_hash, + llmq::utils::GetQuorumHashModifier(params, consensus, cycle)}); + } + + std::string error; + const bool valid{WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(snapshot, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(valid, error); + auto short_bitset{snapshot}; + short_bitset.quorums[0].rotation_snapshots[0].snapshot.activeQuorumMembers.pop_back(); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(short_bitset, *m_node.chainman, base, error))); + auto bad_modifier{snapshot}; + bad_modifier.quorum_modifiers[0].modifier.begin()[0] ^= 1; + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(bad_modifier, *m_node.chainman, base, error))); +} + BOOST_AUTO_TEST_CASE(reconstruction_horizon_height_enumeration) { const auto rotated{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; diff --git a/src/test/util/setup_common.cpp b/src/test/util/setup_common.cpp index 5fa799d2291e..96b2e45d15d6 100644 --- a/src/test/util/setup_common.cpp +++ b/src/test/util/setup_common.cpp @@ -476,6 +476,8 @@ TestChainSetup::TestChainSetup( { 18, uint256S("0x79ffbee99c3f8448a5484564cba47830415dc96d1aed025576d8246dd24f1b0e") }, /*TestChain100Setup=*/ { 100, uint256S("0x6ffb83129c19ebdf1ae3771be6a67fe34b35f4c956326b9ba152fac1649f65ae") }, + /*SnapshotActivationChainSetup=*/ + { 102, uint256S("0x37876f3493ac152f9a0bdf0049d85969fe3ba82745733a81c8e1afeefa16ab3b") }, /*TestChainV19BeforeActivationSetup=*/ { 103, uint256S("0x13adad9565d0ca558f5675c50e3828f4354d26b64de044ebc88686056f30faab") }, /*TestChainDIP3BeforeActivationSetup=*/ From 1fc72c175e555a9ff34791c0ba648803d3cb2f9f Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 13 Aug 2026 01:59:47 -0500 Subject: [PATCH 2/2] feat: emit and seed the evo snapshot section in dumptxoutset and snapshot load Final PR of the assumeutxo M4 series: dumptxoutset appends the canonical evo section (marker, version, payload, hash) after the UTXO records; snapshot activation decodes it, validates it context-free and against the chain, seeds EvoDB through the seeding surface, retains the section for the deferred completion-time CbTx cross-check, and records background MN-list hashes only for the snapshot base and the bounded set of historical work blocks. The completion path cross-checks reconstructed historical MN lists, verifies the retained section against the base CbTx, and routes runtime seeded-state mismatches (GetAllQuorumMembers) into the controlled invalid-snapshot shutdown. The assumeutxo prune lock keeps the base block's data available until that check completes. Per review on the original M4 PR, every ChainstateManager body stays in validation.cpp: the previously proposed evo/snapshot_load.cpp move is gone, with the chain-facing helpers declared in evo/snapshot.h and the load/build/completion logic living where upstream keeps it. feature_assumeutxo_dash.py only checks that dumptxoutset emits the evo section and its metadata on a quorum-bearing chain; loading a dumped file over RPC arrives with loadtxoutset in M5. The load, seeding, and completion paths run in the existing SnapshotTestSetup unit flows, which dump through CreateUTXOSnapshot and activate through ActivateSnapshot on small regtest chains. Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Makefile.am | 1 + src/evo/snapshot.h | 14 + src/evo/snapshot_chain.cpp | 71 +++ src/evo/specialtxman.cpp | 11 + src/llmq/utils.cpp | 17 +- src/node/blockstorage.cpp | 6 + src/node/blockstorage.h | 3 + src/node/chainstate.cpp | 4 + src/rpc/blockchain.cpp | 26 ++ src/streams.h | 24 + src/test/blockmanager_tests.cpp | 13 + src/test/evo_db_tests.cpp | 10 + src/test/evo_snapshot_tests.cpp | 86 ++++ src/test/flatfile_tests.cpp | 3 + src/test/validation_chainstate_tests.cpp | 18 +- .../validation_chainstatemanager_tests.cpp | 83 ++++ src/validation.cpp | 423 +++++++++++++++--- src/validation.h | 18 +- test/functional/feature_assumeutxo_dash.py | 56 +++ test/functional/rpc_dumptxoutset.py | 5 +- test/functional/test_runner.py | 1 + test/lint/lint-circular-dependencies.py | 2 + 22 files changed, 808 insertions(+), 87 deletions(-) create mode 100755 test/functional/feature_assumeutxo_dash.py diff --git a/src/Makefile.am b/src/Makefile.am index a1c2582fba36..94ce2d02da73 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -1296,6 +1296,7 @@ libdashkernel_la_SOURCES = \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ evo/snapshot.cpp \ + evo/snapshot_chain.cpp \ evo/specialtx.cpp \ evo/specialtx_filter.cpp \ evo/specialtxman.cpp \ diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index 3d4ad4d19ef7..deefefb2efd0 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -34,6 +34,7 @@ class CBlockIndex; class CChainParams; class ChainstateManager; +class CBlock; class CCbTx; class CCreditPoolManager; class CMNHFManager; @@ -43,6 +44,10 @@ class CQuorumBlockProcessor; class CQuorumSnapshotManager; } // namespace llmq +namespace node { +class BlockManager; +} // namespace node + namespace evo { static constexpr uint16_t EVO_SNAPSHOT_VERSION{3}; @@ -762,6 +767,15 @@ bool ValidateEvoSnapshotAgainstChain(const EvoSnapshot& snapshot, const Chainsta /** Pure CbTx checks over already-built snapshot content. */ bool VerifyEvoSnapshotCbTx(const EvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error); +/** VerifyEvoSnapshotCbTx over the base block's coinbase payload. */ +bool VerifyEvoSnapshotBaseBlock(const EvoSnapshot& snapshot, const CBlock& base_block, std::string& error); + +/** Seed EvoDB with the decoded snapshot's reconstructed state. */ +bool SeedEvoSnapshotState(const EvoSnapshot& snapshot, CDeterministicMNManager& dmnman, + llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, + CCreditPoolManager& cpoolman, CMNHFManager& mnhfman, + node::BlockManager& blockman, const CBlockIndex* snapshot_start_block); + } // namespace evo #endif // BITCOIN_EVO_SNAPSHOT_H diff --git a/src/evo/snapshot_chain.cpp b/src/evo/snapshot_chain.cpp index cdfd344500ea..42847788cb68 100644 --- a/src/evo/snapshot_chain.cpp +++ b/src/evo/snapshot_chain.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -446,4 +447,74 @@ bool ValidateEvoSnapshotAgainstChain(const EvoSnapshot& snapshot, const Chainsta return true; } +bool VerifyEvoSnapshotBaseBlock(const EvoSnapshot& snapshot, const CBlock& base_block, std::string& error) +{ + if (base_block.vtx.empty()) { + error = "empty base block"; + return false; + } + const auto cbtx{GetTxPayload(*base_block.vtx[0])}; + if (!cbtx) { + error = "missing base block CbTx payload"; + return false; + } + return VerifyEvoSnapshotCbTx(snapshot, *cbtx, error); +} + +bool SeedEvoSnapshotState(const EvoSnapshot& snapshot, CDeterministicMNManager& dmnman, + llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, + CCreditPoolManager& cpoolman, CMNHFManager& mnhfman, + node::BlockManager& blockman, const CBlockIndex* snapshot_start_block) +{ + std::map historical_lists; + std::string reconstruction_error; + if (!ReconstructHistoricalMNLists(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 : 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 : 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{WITH_LOCK(::cs_main, return 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 (!cpoolman.SeedSnapshot(snapshot_start_block, snapshot.credit_pool) || + !mnhfman.SeedSignals(snapshot_start_block, snapshot.mnhf_signals)) { + LogPrintf("[snapshot] failed to seed credit-pool/MNHF state\n"); + return false; + } + return true; +} + } // namespace evo diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index 083124c31dd0..06c6511f9b7a 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -267,6 +267,13 @@ bool CSpecialTxProcessor::CheckSpecialTxInner(const CChain* chain, const CTransa return chain ? CheckAssetUnlockTx(m_blockman, m_qman, *chain, tx, pindexPrev, indexes, is_v24_active, state) : CheckAssetUnlockTx(m_blockman, m_qman, tx, pindexPrev, indexes, is_v24_active, state); } + } catch (const evo::SnapshotStateMismatchError&) { + // During block connection the local snapshot state is wrong, not the + // block: let the chainstate boundary reject the snapshot after the + // EvoDB transaction unwinds. Mempool and mining callers have no such + // boundary and keep rejecting the transaction. + if (chain != nullptr) throw; + return state.Invalid(TxValidationResult::TX_CONSENSUS, "failed-check-special-tx"); } catch (const std::exception& e) { LogPrintf("%s -- failed: %s\n", __func__, e.what()); return state.Invalid(TxValidationResult::TX_CONSENSUS, "failed-check-special-tx"); @@ -996,6 +1003,8 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const // Local EvoDB corruption detected below (the node is already // aborting): fail with M_ERROR so the block is not marked invalid. return state.Error(e.what()); + } catch (const evo::SnapshotStateMismatchError&) { + throw; } catch (const std::exception& e) { LogPrintf("CSpecialTxProcessor::%s -- FAILURE! %s\n", __func__, e.what()); return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "failed-procspectxsinblock"); @@ -1064,6 +1073,8 @@ bool CSpecialTxProcessor::CheckCreditPoolDiffForBlock(const CBlock& block, const // Local EvoDB corruption detected below (the node is already // aborting): fail with M_ERROR so the block is not marked invalid. return state.Error(e.what()); + } catch (const evo::SnapshotStateMismatchError&) { + throw; } catch (const std::exception& e) { LogPrintf("CSpecialTxProcessor::%s -- FAILURE! %s\n", __func__, e.what()); return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "failed-checkcreditpooldiff"); diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 667a1123c5a1..e871df776f3f 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -760,11 +760,18 @@ static QuorumMembers GetAllQuorumMembersInternal(Consensus::LLMQType llmqType, c QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) { - // A SnapshotStateMismatchError from a seeded-modifier disagreement - // propagates to the caller. Production code does not seed modifiers yet; - // the load-time integration later in the series routes this into the - // controlled invalid-snapshot path. - return GetAllQuorumMembersInternal(llmqType, util_params, reset_cache); + 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) diff --git a/src/node/blockstorage.cpp b/src/node/blockstorage.cpp index 99c701420e00..6b1978411663 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 02d259d3a868..e9191c6e16c9 100644 --- a/src/node/blockstorage.h +++ b/src/node/blockstorage.h @@ -232,6 +232,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 35c676617b1c..10abbe318cd6 100644 --- a/src/node/chainstate.cpp +++ b/src/node/chainstate.cpp @@ -185,6 +185,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 diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index 074e29cec3ef..f589900632c7 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -57,6 +58,7 @@ #include #include #include +#include #include #include #include @@ -3152,6 +3154,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"}, } }, @@ -3202,6 +3206,8 @@ UniValue CreateUTXOSnapshot( std::unique_ptr pcursor; std::optional maybe_stats; const CBlockIndex* tip; + evo::EvoSnapshot evo_snapshot; + std::string evo_error; { // We need to lock cs_main to ensure that the coinsdb isn't written to @@ -3227,6 +3233,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. + const auto& llmq_ctx{*CHECK_NONFATAL(node.llmq_ctx)}; + if (!evo::BuildEvoSnapshot(Params(), *node.chainman, *CHECK_NONFATAL(node.dmnman), + *CHECK_NONFATAL(llmq_ctx.quorum_block_processor), *CHECK_NONFATAL(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)", @@ -3252,6 +3268,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][EvoSnapshot]. EvoSnapshot 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); @@ -3260,6 +3284,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/streams.h b/src/streams.h index b1d261b29935..41a37f22dea1 100644 --- a/src/streams.h +++ b/src/streams.h @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -522,6 +523,29 @@ class AutoFile return 0; } + size_t size() const + { + if (!m_file) throw std::ios_base::failure("AutoFile::size: file handle is nullptr"); +#ifdef WIN32 + const auto position{_ftelli64(m_file)}; + struct _stat64 file_stat; + if (position < 0 || _fstat64(_fileno(m_file), &file_stat) != 0) { +#else + const auto position{ftello(m_file)}; + struct stat file_stat; + if (position < 0 || fstat(fileno(m_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); + } /** Get wrapped FILE* with transfer of ownership. * @note This will invalidate the AutoFile object, and makes it the responsibility of the caller * of this function to clean up the returned FILE*. 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/evo_db_tests.cpp b/src/test/evo_db_tests.cpp index cb7b421f26bc..f5ebdb8ebf94 100644 --- a/src/test/evo_db_tests.cpp +++ b/src/test/evo_db_tests.cpp @@ -293,12 +293,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(); } @@ -312,12 +315,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(); } @@ -328,6 +336,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/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp index 4bb2dd214d44..9ccbeb4a75dd 100644 --- a/src/test/evo_snapshot_tests.cpp +++ b/src/test/evo_snapshot_tests.cpp @@ -5,8 +5,11 @@ #include #include +#include #include #include +#include +#include #include #include #include @@ -14,8 +17,11 @@ #include #include #include +#include +#include #include #include +#include #include #include #include @@ -23,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -670,6 +677,85 @@ BOOST_FIXTURE_TEST_CASE(quorum_members_reconstruct_from_seeded_state_only, Snaps } } +BOOST_FIXTURE_TEST_CASE(special_tx_checks_pass_snapshot_mismatch_through, SnapshotActivationChainSetup) +{ + // A seeded-modifier mismatch is local snapshot state, not a fault in the + // commitment being checked. It must reach the chainstate boundary that + // rejects the snapshot instead of being turned into a consensus-invalid tx. + const CBlockIndex* tip{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(tip != nullptr); + ConsensusParamsRestorer global_restorer{Params().GetConsensus()}; + ConsensusParamsRestorer chain_restorer{m_node.chainman->GetConsensus()}; + auto plain{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + plain.dkgInterval = 12; + chain_restorer.Get().llmqs = {plain}; + global_restorer.Get().llmqs = {plain}; + + const CBlockIndex* quorum{tip->GetAncestor(96)}; + const CBlockIndex* work{quorum->GetAncestor(96 - llmq::WORK_DIFF_DEPTH)}; + { + LOCK(::cs_main); + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::NORMAL)}; + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedQuorumModifier(plain.type, work->GetBlockHash(), H(254))); + tx->Commit(); + } + BOOST_CHECK_THROW(llmq::utils::GetAllQuorumMembers(plain.type, + {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, + /*reset_cache=*/true), + evo::SnapshotStateMismatchError); + + CBLSSecretKey key; + key.MakeNewKey(); + llmq::CFinalCommitmentTxPayload payload; + payload.nVersion = llmq::CFinalCommitmentTxPayload::CURRENT_VERSION; + payload.nHeight = tip->nHeight + 1; + payload.commitment = llmq::CFinalCommitment{plain, quorum->GetBlockHash()}; + auto& qc{payload.commitment}; + qc.nVersion = llmq::CFinalCommitment::GetVersion(llmq::IsQuorumRotationEnabled(plain, quorum), + DeploymentActiveAfter(quorum, chain_restorer.Get(), + Consensus::DEPLOYMENT_V19)); + qc.signers.assign(plain.size, true); + qc.validMembers.assign(plain.size, true); + qc.quorumPublicKey = key.GetPublicKey(); + qc.quorumVvecHash = H(201); + qc.quorumSig = key.Sign(H(202), /*specificLegacyScheme=*/false); + qc.membersSig = key.Sign(H(203), /*specificLegacyScheme=*/false); + CMutableTransaction mtx; + mtx.nVersion = 3; + mtx.nType = TRANSACTION_QUORUM_COMMITMENT; + SetTxPayload(mtx, payload); + const CTransaction tx{mtx}; + + LOCK(::cs_main); + Chainstate& chainstate{m_node.chainman->ActiveChainstate()}; + CCoinsViewCache view{&chainstate.CoinsTip()}; + CMutableTransaction coinbase; + coinbase.vin.resize(1); + coinbase.vin[0].prevout.SetNull(); + coinbase.vout.resize(1); + CBlock block; + block.vtx = {MakeTransactionRef(coinbase), MakeTransactionRef(tx)}; + CBlockIndex index{block}; + index.pprev = const_cast(tip); + index.nHeight = tip->nHeight + 1; + BlockValidationState block_state; + MNListUpdates updates; + BOOST_CHECK_THROW(m_node.chain_helper->special_tx->ProcessSpecialTxsInBlock(chainstate, chainstate.m_chain, block, + &index, /*is_v24_active=*/false, view, + /*blockSubsidy=*/0, /*fJustCheck=*/true, + /*fCheckCbTxMerkleRoots=*/false, + block_state, updates), + evo::SnapshotStateMismatchError); + BOOST_CHECK(block_state.IsValid()); + + // Outside block connection there is no snapshot boundary to reach, so the + // mempool path still rejects the transaction rather than throwing. + TxValidationState tx_state; + BOOST_CHECK(!m_node.chain_helper->special_tx->CheckSpecialTx(tx, tip, /*is_v24_active=*/false, view, + /*check_sigs=*/true, tx_state)); + BOOST_CHECK_EQUAL(tx_state.GetRejectReason(), "failed-check-special-tx"); +} + BOOST_FIXTURE_TEST_CASE(rotation_snapshot_bitset_uses_seeded_modifier, SnapshotActivationChainSetup) { // A cycle whose work block is unavailable selects members with the seeded diff --git a/src/test/flatfile_tests.cpp b/src/test/flatfile_tests.cpp index 54c30ed314d6..e9ad19455b4a 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 a3e2fed8239e..62f322ad988e 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 @@ -216,13 +217,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 e8c97241fd57..58809a94afe2 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 @@ -176,6 +177,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) { @@ -1069,6 +1121,37 @@ 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 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)}; + + LOCK(::cs_main); + auto tx = m_node.evodb->BeginTransaction(EvoDbIdentity::NORMAL); + 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->GetBlockHash(), captured_hash)); + BOOST_CHECK_EQUAL(captured_hash, required_hash); + BOOST_CHECK(!m_node.evodb->ReadBackgroundWorkMNListHash(unrelated_block->GetBlockHash(), 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 397626256a13..218aac120d54 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -67,6 +67,8 @@ #include #include #include +#include +#include #include #include #include @@ -1932,9 +1934,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); @@ -2671,8 +2674,7 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, // the snapshot base block (no-op for every other block). Snapshot // activation may populate the shared MN-list cache with seeded // state, so completion must not reconstruct this value through that - // cache. Before DIP3 activates, new_list is the independently - // computed empty list. + // cache. RecordBackgroundMNListHash(pindex, mnlist_updates.new_list); } @@ -3125,15 +3127,10 @@ void Chainstate::ForceFlushStateToDisk() } } -void Chainstate::RecordBackgroundMNListHash(const CBlockIndex* pindex, const CDeterministicMNList& mn_list) +void Chainstate::SetRequiredBackgroundMNListHashes(const std::vector& block_hashes) { - 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)); + assert(EvoDbIdentity() == EvoDbIdentity::NORMAL); + m_required_background_mn_list_hashes = std::set{block_hashes.begin(), block_hashes.end()}; } void Chainstate::PruneAndFlush() @@ -3271,7 +3268,7 @@ bool Chainstate::DisconnectTip(BlockValidationState& state, DisconnectedBlockTra !DeploymentActiveAt(*pindexDelete, m_chainman.GetConsensus(), Consensus::DEPLOYMENT_V19)}; // Apply the block atomically to the chain state. const auto time_start{SteadyClock::now()}; - { + try { auto dbTx = m_evoDb.BeginTransaction(EvoDbIdentity()); CCoinsViewCache view(&CoinsTip()); @@ -3283,6 +3280,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", Ticks(SteadyClock::now() - time_start)); @@ -3422,7 +3424,7 @@ bool Chainstate::ConnectTip(BlockValidationState& state, CBlockIndex* pindexNew, // num_blocks_total may be zero until the ConnectBlock() call below. LogPrint(BCLog::BENCHMARK, " - Load block from disk: %.2fms\n", Ticks(time_2 - time_1)); - { + try { auto dbTx = m_evoDb.BeginTransaction(EvoDbIdentity()); CCoinsViewCache view(&CoinsTip()); @@ -3444,6 +3446,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; } const auto time_4{SteadyClock::now()}; time_flush += time_4 - time_3; @@ -4253,7 +4260,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); } @@ -4927,7 +4935,7 @@ bool TestBlockValidity(BlockValidationState& state, CBlockIndex* pindexPrev, bool fCheckPOW, bool fCheckMerkleRoot) -{ +try { AssertLockHeld(cs_main); assert(pindexPrev && pindexPrev == chainstate.m_chain.Tip()); @@ -4969,6 +4977,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 */ @@ -5031,7 +5044,7 @@ VerifyDBResult 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) { @@ -5172,6 +5185,9 @@ VerifyDBResult CVerifyDB::VerifyDB( return VerifyDBResult::SKIPPED_MISSING_BLOCKS; } return VerifyDBResult::SUCCESS; +} catch (const evo::SnapshotStateMismatchError& e) { + if (chainstate.m_chainman.HandleSnapshotStateMismatch(e.what())) return VerifyDBResult::CORRUPTED_BLOCK_DB; + throw; } /** Apply the effects of a block on the utxo cache, ignoring that it may already have been applied. */ @@ -5216,7 +5232,7 @@ bool Chainstate::RollforwardBlock(const CBlockIndex* pindex, CCoinsViewCache& in } bool Chainstate::ReplayBlocks() -{ +try { LOCK(cs_main); CCoinsView& db = this->CoinsDB(); @@ -5298,6 +5314,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() @@ -6091,6 +6110,7 @@ bool ChainstateManager::ActivateSnapshot( } if (!snapshot_ok) { LOCK(::cs_main); + this->ReleaseSnapshotPruneLock(); this->MaybeRebalanceCaches(); // PopulateAndValidateSnapshot commits the snapshot best-block and @@ -6199,6 +6219,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()); @@ -6278,16 +6303,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 EvoSnapshot 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; } @@ -6325,6 +6375,50 @@ 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; + } + if (!evo::VerifyEvoSnapshotBaseBlock(*evo_snapshot, base_block, 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. @@ -6402,19 +6496,92 @@ 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; + } + if (!evo::SeedEvoSnapshotState(*evo_snapshot, dmnman, qblockman, qsnapman, + *helper.credit_pool_manager, *helper.ehf_manager, + m_blockman, snapshot_start_block)) { + 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)); @@ -6435,6 +6602,94 @@ 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; + ReleaseSnapshotPruneLock(); + 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; +} + +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. + // Before DIP3 the special-tx processor leaves the update record default + // constructed, so bind the canonical empty list to the block here, the + // identity GetDeterministicMNListHash and the evo snapshot payload use. + const uint256 mn_list_hash{mn_list.GetBlockHash().IsNull() + ? evo::CanonicalMNListHash(CDeterministicMNList{block_hash, pindex->nHeight, 0}) + : 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) { @@ -6457,7 +6712,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()); @@ -6484,42 +6738,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) { @@ -6587,19 +6809,57 @@ SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( 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::EvoSnapshot 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; + } + 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) || + !evo::VerifyEvoSnapshotBaseBlock(retained_snapshot, base_block, 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; @@ -6627,6 +6887,7 @@ SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( snapshot_blockhash.ToString()); m_ibd_chainstate->m_disabled = true; + ReleaseSnapshotPruneLock(); this->MaybeRebalanceCaches(); return SnapshotCompletionResult::SUCCESS; @@ -6771,6 +7032,24 @@ ChainstateManager::ChainstateManager(Options options) : m_options{Flatten(std::move(options))}, m_blockman{{m_options.chainparams}} {} +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 e0c4087df5f0..6d0d03df5a51 100644 --- a/src/validation.h +++ b/src/validation.h @@ -576,6 +576,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. @@ -665,9 +666,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. @@ -1080,6 +1082,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; } @@ -1235,6 +1245,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_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 9c7da6d32c25..ea83d0dd7b1a 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 919d68679b47..e9204402a14c 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -359,6 +359,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 diff --git a/test/lint/lint-circular-dependencies.py b/test/lint/lint-circular-dependencies.py index 0a8df7b6e662..e4c29d201783 100755 --- a/test/lint/lint-circular-dependencies.py +++ b/test/lint/lint-circular-dependencies.py @@ -42,7 +42,9 @@ "evo/cbtx -> llmq/blockprocessor -> validation -> evo/cbtx", "evo/chainhelper -> evo/creditpool -> validation -> evo/chainhelper", "evo/creditpool -> validation -> evo/creditpool", + "evo/creditpool -> validation -> evo/snapshot -> evo/creditpool", "evo/creditpool -> validation -> evo/specialtxman -> evo/creditpool", + "evo/snapshot -> llmq/commitment -> validation -> evo/snapshot", "evo/deterministicmns -> node/blockstorage -> validation -> evo/deterministicmns", "evo/deterministicmns -> node/blockstorage -> validation -> masternode/payments -> evo/deterministicmns", "evo/deterministicmns -> node/blockstorage -> validation -> txmempool -> evo/deterministicmns",