diff --git a/src/Makefile.am b/src/Makefile.am index 57d000012b00..94ce2d02da73 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,8 @@ libdashkernel_la_SOURCES = \ evo/providertx_util.cpp \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ + evo/snapshot.cpp \ + evo/snapshot_chain.cpp \ evo/specialtx.cpp \ evo/specialtx_filter.cpp \ evo/specialtxman.cpp \ diff --git a/src/evo/chainhelper.cpp b/src/evo/chainhelper.cpp index 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..deefefb2efd0 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,22 @@ #include #include +class CBlockIndex; +class CChainParams; +class ChainstateManager; +class CBlock; class CCbTx; +class CCreditPoolManager; +class CMNHFManager; + +namespace llmq { +class CQuorumBlockProcessor; +class CQuorumSnapshotManager; +} // namespace llmq + +namespace node { +class BlockManager; +} // namespace node namespace evo { @@ -586,7 +602,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 +738,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,9 +759,23 @@ 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); +/** 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 new file mode 100644 index 000000000000..42847788cb68 --- /dev/null +++ b/src/evo/snapshot_chain.cpp @@ -0,0 +1,520 @@ +// 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 +#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; +} + +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/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..06c6511f9b7a 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -266,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"); @@ -904,7 +912,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 @@ -992,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"); @@ -1060,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/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..e871df776f3f 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,22 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame return quorumMembers; } +QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) +{ + try { + return GetAllQuorumMembersInternal(llmqType, util_params, reset_cache); + } catch (const evo::SnapshotStateMismatchError& e) { + // Outside block connection there is no EvoDB transaction to unwind, so + // P2P, RPC, DKG, and quorum-manager callers can immediately enter the + // controlled invalid-snapshot path. During block connect/disconnect, + // defer to the Chainstate boundary after its transaction rolls back. + if (const_cast(util_params.m_chainman).HandleSnapshotStateMismatch(e.what())) { + return {}; + } + throw; + } +} + uint256 DeterministicOutboundConnection(const uint256& proTxHash1, const uint256& proTxHash2) { // We need to deterministically select who is going to initiate the connection. The naive way would be to simply diff --git a/src/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/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 87cf6a3b4db0..f5ebdb8ebf94 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) @@ -281,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(); } @@ -300,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(); } @@ -316,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 2d3f97647c16..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 @@ -31,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -223,8 +231,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 +311,898 @@ 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(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 + // 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/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/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=*/ 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",