diff --git a/src/Makefile.am b/src/Makefile.am index fb1ffde2529a..8d09aaa8200d 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -234,6 +234,8 @@ BITCOIN_CORE_H = \ evo/providertx.h \ evo/simplifiedmns.h \ evo/smldiff.h \ + evo/snapshot_types.h \ + evo/snapshot.h \ evo/specialtx.h \ evo/specialtx_filter.h \ evo/specialtxman.h \ @@ -538,6 +540,7 @@ libbitcoin_node_a_SOURCES = \ evo/evodb.cpp \ evo/mnauth.cpp \ evo/mnhftx.cpp \ + evo/snapshot.cpp \ evo/providertx.cpp \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ @@ -1275,6 +1278,7 @@ libdashkernel_la_SOURCES = \ evo/providertx_util.cpp \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ + evo/snapshot.cpp \ evo/specialtx.cpp \ evo/specialtx_filter.cpp \ evo/specialtxman.cpp \ diff --git a/src/Makefile.test.include b/src/Makefile.test.include index 427909b8e12f..92c5c1bcc69b 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -117,6 +117,7 @@ BITCOIN_TESTS =\ test/evo_mnauth_tests.cpp \ test/evo_mnhf_tests.cpp \ test/evo_netinfo_tests.cpp \ + test/evo_snapshot_tests.cpp \ test/evo_simplifiedmns_tests.cpp \ test/evo_trivialvalidation.cpp \ test/evo_utils_tests.cpp \ diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 3d391b691e52..565d2486bd97 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -882,11 +882,11 @@ class CRegTestParams : public CChainParams { m_assumeutxo_data = MapAssumeutxo{ { 110, - {AssumeutxoHash{uint256S("0x9b2a277a3e3b979f1a539d57e949495d7f8247312dbc32bce6619128c192b44b")}, 110}, + {AssumeutxoHash{uint256S("0x9b2a277a3e3b979f1a539d57e949495d7f8247312dbc32bce6619128c192b44b")}, EvoSnapshotHash{uint256{}}, 110}, }, { 200, - {AssumeutxoHash{uint256S("0x8a5bdd92252fc6b24663244bbe958c947bb036dc1f94ccd15439f48d8d1cb4e3")}, 200}, + {AssumeutxoHash{uint256S("0x8a5bdd92252fc6b24663244bbe958c947bb036dc1f94ccd15439f48d8d1cb4e3")}, EvoSnapshotHash{uint256{}}, 200}, }, }; diff --git a/src/chainparams.h b/src/chainparams.h index 69b4baaa61fa..fa974ba40cb7 100644 --- a/src/chainparams.h +++ b/src/chainparams.h @@ -34,6 +34,10 @@ struct AssumeutxoHash : public BaseHash { explicit AssumeutxoHash(const uint256& hash) : BaseHash(hash) {} }; +struct EvoSnapshotHash : public BaseHash { + explicit EvoSnapshotHash(const uint256& hash) : BaseHash(hash) {} +}; + /** * Holds configuration for use during UTXO snapshot load and validation. The contents * here are security critical, since they dictate which UTXO snapshots are recognized @@ -43,6 +47,9 @@ struct AssumeutxoData { //! The expected hash of the deserialized UTXO set. const AssumeutxoHash hash_serialized; + //! The expected single-SHA256 hash of the canonical Dash evo section. + const EvoSnapshotHash evo_hash; + //! Used to populate the nChainTx value, which is used during BlockManager::LoadBlockIndex(). //! //! We need to hardcode the value here because this is computed cumulatively using block data, diff --git a/src/evo/chainhelper.cpp b/src/evo/chainhelper.cpp index 9412607e93a0..4c577b7b2221 100644 --- a/src/evo/chainhelper.cpp +++ b/src/evo/chainhelper.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,8 @@ CChainstateHelper::CChainstateHelper(CEvoDB& evodb, CDeterministicMNManager& dmn isman{isman}, mn_sync{mn_sync}, m_dmnman{dmnman}, + m_qblockman{qblockman}, + m_qsnapman{qsnapman}, credit_pool_manager{std::make_unique(evodb, chainman)}, m_chainlocks{chainlocks}, ehf_manager{std::make_unique(evodb, chainman)}, @@ -66,7 +69,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 eac183777ba1..2c5c6940de1d 100644 --- a/src/evo/chainhelper.h +++ b/src/evo/chainhelper.h @@ -43,6 +43,8 @@ class CChainstateHelper llmq::CInstantSendManager& isman; const CMasternodeSync& mn_sync; CDeterministicMNManager& m_dmnman; + llmq::CQuorumBlockProcessor& m_qblockman; + llmq::CQuorumSnapshotManager& m_qsnapman; public: const std::unique_ptr credit_pool_manager; @@ -72,6 +74,9 @@ class CChainstateHelper /** Return a canonical hash of the deterministic MN list derived at a block. */ uint256 GetDeterministicMNListHash(const CBlockIndex* pindex) const; + CDeterministicMNManager& DeterministicMNManager() { return m_dmnman; } + llmq::CQuorumBlockProcessor& QuorumBlockProcessor() { return m_qblockman; } + llmq::CQuorumSnapshotManager& QuorumSnapshotManager() { return m_qsnapman; } /** Passthrough functions to CCreditPoolManager */ CCreditPool GetCreditPool(const CBlockIndex* const pindex); diff --git a/src/evo/creditpool.cpp b/src/evo/creditpool.cpp index 5d9a1f60d3cf..85dca6008208 100644 --- a/src/evo/creditpool.cpp +++ b/src/evo/creditpool.cpp @@ -125,12 +125,12 @@ std::optional CCreditPoolManager::GetFromCache(const CBlockIndex& b return pool; } } - if (block_index.nHeight % DISK_SNAPSHOT_PERIOD == 0) { - if (evoDb.Read(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block_hash), pool)) { - LOCK(cache_mutex); - creditPoolCache.insert(block_hash, pool); - return pool; - } + // Snapshot activation may deliberately seed a full state at a height that + // is not one of the normal periodic checkpoints. + if (evoDb.Read(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block_hash), pool)) { + LOCK(cache_mutex); + creditPoolCache.insert(block_hash, pool); + return pool; } return std::nullopt; } @@ -155,6 +155,12 @@ void CCreditPoolManager::AddToCache(const uint256& block_hash, int height, const } } +bool CCreditPoolManager::SeedSnapshot(const CBlockIndex* block, const CCreditPool& pool) +{ + assert(block != nullptr); + return evoDb.WriteDerived(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block->GetBlockHash()), pool); +} + CCreditPool CCreditPoolManager::ConstructCreditPool(const gsl::not_null block_index, CCreditPool prev) { std::optional opt_block_data = GetCreditDataFromBlock(block_index, m_chainman.GetConsensus()); diff --git a/src/evo/creditpool.h b/src/evo/creditpool.h index fec44ab680cb..196632bb7290 100644 --- a/src/evo/creditpool.h +++ b/src/evo/creditpool.h @@ -134,6 +134,8 @@ class CCreditPoolManager * it can happen if there limits of withdrawal (unlock) exceed */ CCreditPool GetCreditPool(const CBlockIndex* block) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); + /** Seed a full pool snapshot in the current EvoDB transaction. */ + bool SeedSnapshot(const CBlockIndex* block, const CCreditPool& pool) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); private: std::optional GetFromCache(const CBlockIndex& block_index) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); diff --git a/src/evo/deterministicmns.cpp b/src/evo/deterministicmns.cpp index 357402ae2c16..86e7f8b1677c 100644 --- a/src/evo/deterministicmns.cpp +++ b/src/evo/deterministicmns.cpp @@ -374,6 +374,27 @@ void CDeterministicMNList::ApplyDiff(gsl::not_null pindex, c blockHash = pindex->GetBlockHash(); nHeight = pindex->nHeight; + for (const auto& id : diff.removedMns) { + auto dmn = GetMNByInternalId(id); + if (!dmn) throw std::runtime_error(strprintf("%s: can't find a removed masternode, id=%d", __func__, id)); + RemoveMN(dmn->proTxHash); + } + for (const auto& dmn : diff.addedMNs) AddMN(dmn); + for (const auto& p : diff.updatedMNs) { + auto dmn = GetMNByInternalId(p.first); + if (!dmn) throw std::runtime_error(strprintf("%s: can't find an updated masternode, id=%d", __func__, p.first)); + UpdateMN(*dmn, p.second); + } +} + +void CDeterministicMNList::ApplyDiffForSnapshot(const uint256& block_hash, int height, + uint32_t total_registered_count, + const CDeterministicMNListDiff& diff) +{ + if (height < 0) throw std::runtime_error("negative historical MN-list height"); + blockHash = block_hash; + nHeight = height; + for (const auto& id : diff.removedMns) { auto dmn = GetMNByInternalId(id); if (!dmn) { @@ -382,7 +403,7 @@ void CDeterministicMNList::ApplyDiff(gsl::not_null pindex, c RemoveMN(dmn->proTxHash); } for (const auto& dmn : diff.addedMNs) { - AddMN(dmn); + AddMN(dmn, /*fBumpTotalCount=*/false); } for (const auto& p : diff.updatedMNs) { auto dmn = GetMNByInternalId(p.first); @@ -391,6 +412,7 @@ void CDeterministicMNList::ApplyDiff(gsl::not_null pindex, c } UpdateMN(*dmn, p.second); } + nTotalRegisteredCount = total_registered_count; } void CDeterministicMNList::AddMN(const CDeterministicMNCPtr& dmn, bool fBumpTotalCount) @@ -622,6 +644,18 @@ CDeterministicMNManager::CDeterministicMNManager(CEvoDB& evoDb, CMasternodeMetaM CDeterministicMNManager::~CDeterministicMNManager() = default; +bool CDeterministicMNManager::SeedListForBlock(const CDeterministicMNList& list) +{ + return m_evoDb.WriteDerived(std::make_pair(DB_LIST_SNAPSHOT, list.GetBlockHash()), list); +} + +void CDeterministicMNManager::InvalidateListCacheForBlock(const uint256& block_hash) +{ + LOCK(cs); + mnListsCache.erase(block_hash); + mnListDiffsCache.erase(block_hash); +} + bool CDeterministicMNManager::ProcessBlock(const CBlock& block, gsl::not_null pindex, BlockValidationState& state, const CDeterministicMNList& newList, std::optional& updatesRet) @@ -789,6 +823,7 @@ CDeterministicMNList CDeterministicMNManager::GetListForBlockInternal(gsl::not_n mnListsCache.emplace(pindex->GetBlockHash(), snapshot); break; } + if (m_list_snapshot_miss_hook) m_list_snapshot_miss_hook(pindex); // no snapshot found yet, check diffs auto itDiffs = mnListDiffsCache.find(pindex->GetBlockHash()); diff --git a/src/evo/deterministicmns.h b/src/evo/deterministicmns.h index 4fb5dee91aef..d30db484945f 100644 --- a/src/evo/deterministicmns.h +++ b/src/evo/deterministicmns.h @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -339,6 +340,8 @@ class CDeterministicMNList assert(nHeight >= 0); return nHeight; } + /** Snapshot hashing also covers the pre-DIP3 default list (height -1). */ + [[nodiscard]] int GetHeightForSnapshotCodec() const noexcept { return nHeight; } void SetHeight(int _height) { assert(_height >= 0); @@ -422,6 +425,10 @@ class CDeterministicMNList */ void ApplyDiff(gsl::not_null pindex, const CDeterministicMNListDiff& diff) EXCLUSIVE_LOCKS_REQUIRED(!m_cached_sml_mutex); + /** Apply a snapshot-local historical diff without dereferencing block data. */ + void ApplyDiffForSnapshot(const uint256& block_hash, int height, uint32_t total_registered_count, + const CDeterministicMNListDiff& diff) + EXCLUSIVE_LOCKS_REQUIRED(!m_cached_sml_mutex); void AddMN(const CDeterministicMNCPtr& dmn, bool fBumpTotalCount = true) EXCLUSIVE_LOCKS_REQUIRED(!m_cached_sml_mutex); void UpdateMN(const CDeterministicMN& oldDmn, const std::shared_ptr& pdmnState) @@ -766,6 +773,7 @@ class CDeterministicMNManager Uint256HashMap mnListsCache GUARDED_BY(cs); Uint256HashMap mnListDiffsCache GUARDED_BY(cs); + std::function m_list_snapshot_miss_hook GUARDED_BY(cs); const CBlockIndex* tipIndex GUARDED_BY(cs) {nullptr}; const CBlockIndex* m_initial_snapshot_index GUARDED_BY(cs) {nullptr}; @@ -789,6 +797,21 @@ class CDeterministicMNManager }; CDeterministicMNList GetListAtChainTip() EXCLUSIVE_LOCKS_REQUIRED(!cs); + /** Seed a canonical full-list snapshot in the current EvoDB transaction. */ + bool SeedListForBlock(const CDeterministicMNList& list) EXCLUSIVE_LOCKS_REQUIRED(!cs); + + /** Invalidate cached list data so the next lookup reloads it from EvoDB. */ + void InvalidateListCacheForBlock(const uint256& block_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); + + /** Test-only guard invoked after a full-list cache/EvoDB miss, before + * ordinary diff-chain reconstruction can access earlier NORMAL state. */ + void SetListSnapshotMissHookForTesting(std::function hook) + EXCLUSIVE_LOCKS_REQUIRED(!cs) + { + LOCK(cs); + m_list_snapshot_miss_hook = std::move(hook); + } + void SetListForBlockForTesting(const CDeterministicMNList& list) EXCLUSIVE_LOCKS_REQUIRED(!cs) { LOCK(cs); diff --git a/src/evo/evodb.cpp b/src/evo/evodb.cpp index 88787fe1e8ea..3085baacdd9b 100644 --- a/src/evo/evodb.cpp +++ b/src/evo/evodb.cpp @@ -42,6 +42,12 @@ CEvoDB::CEvoDB(const util::DbWrapperParams& db_params) : CEvoDB::~CEvoDB() = default; +bool CEvoDB::HasActiveTransaction() +{ + LOCK(cs); + return active_transaction.has_value(); +} + CEvoDB::TransactionContext& CEvoDB::GetContext(EvoDbIdentity identity) { auto it = transaction_contexts.find(identity); @@ -182,6 +188,37 @@ bool CEvoDB::ReadBackgroundMNListHash(uint256& block_hash, uint256& mn_list_hash return true; } +void CEvoDB::WriteRequiredWorkMNListHashes(const std::vector& block_hashes) +{ + Write(EVODB_REQUIRED_WORK_MNLISTS, block_hashes); +} + +bool CEvoDB::ReadRequiredWorkMNListHashes(std::vector& block_hashes) +{ + return Read(EVODB_REQUIRED_WORK_MNLISTS, block_hashes); +} + +void CEvoDB::WriteBackgroundWorkMNListHash(const uint256& block_hash, const uint256& mn_list_hash) +{ + Write(std::make_pair(EVODB_BACKGROUND_WORK_MNLIST_HASH, block_hash), mn_list_hash); +} + +bool CEvoDB::ReadBackgroundWorkMNListHash(const uint256& block_hash, uint256& mn_list_hash) +{ + return Read(std::make_pair(EVODB_BACKGROUND_WORK_MNLIST_HASH, block_hash), mn_list_hash); +} + +static void EraseHistoricalMNListMarkers(CDBWrapper& db, CDBBatch& batch) +{ + std::vector required; + if (db.Read(EVODB_REQUIRED_WORK_MNLISTS, required)) { + for (const auto& block_hash : required) { + batch.Erase(std::make_pair(EVODB_BACKGROUND_WORK_MNLIST_HASH, block_hash)); + } + } + batch.Erase(EVODB_REQUIRED_WORK_MNLISTS); +} + bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) { LOCK(cs); @@ -198,7 +235,9 @@ bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) uint256 normal_tip; const bool already_promoted = db->Read(EVODB_BEST_BLOCK, normal_tip) && normal_tip == expected_snapshot_tip && !db->Exists(EVODB_DUAL_CHAINSTATE) && !db->Exists(EVODB_SNAPSHOT_MNLIST_HASH) && - !db->Exists(EVODB_BACKGROUND_MNLIST_HASH); + !db->Exists(EVODB_BACKGROUND_MNLIST_HASH) && + !db->Exists(EVODB_REQUIRED_WORK_MNLISTS) && + !db->Exists(EVODB_SNAPSHOT_EVO_SECTION); if (already_promoted) m_default_identity = EvoDbIdentity::NORMAL; return already_promoted; } @@ -209,6 +248,8 @@ bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) batch.Erase(snapshot_key); batch.Erase(EVODB_SNAPSHOT_MNLIST_HASH); batch.Erase(EVODB_BACKGROUND_MNLIST_HASH); + EraseHistoricalMNListMarkers(*db, batch); + batch.Erase(EVODB_SNAPSHOT_EVO_SECTION); batch.Erase(EVODB_DUAL_CHAINSTATE); if (!db->WriteBatch(batch, /*fSync=*/true)) return false; // The dual-chainstate run is over: the promoted state is the NORMAL @@ -231,6 +272,8 @@ bool CEvoDB::DiscardSnapshotMarkers() batch.Erase(std::make_pair(EVODB_BEST_BLOCK, uint8_t{1})); batch.Erase(EVODB_SNAPSHOT_MNLIST_HASH); batch.Erase(EVODB_BACKGROUND_MNLIST_HASH); + EraseHistoricalMNListMarkers(*db, batch); + batch.Erase(EVODB_SNAPSHOT_EVO_SECTION); batch.Erase(EVODB_DUAL_CHAINSTATE); if (!db->WriteBatch(batch, /*fSync=*/true)) return false; // The snapshot chainstate is gone; transaction-less access must resolve diff --git a/src/evo/evodb.h b/src/evo/evodb.h index 40d37b48229d..7aff76c9f986 100644 --- a/src/evo/evodb.h +++ b/src/evo/evodb.h @@ -31,6 +31,9 @@ static const std::string EVODB_BEST_BLOCK = "b_b4"; static const std::string EVODB_DUAL_CHAINSTATE = "b_dcs"; static const std::string EVODB_SNAPSHOT_MNLIST_HASH = "b_dcs_mn"; static const std::string EVODB_BACKGROUND_MNLIST_HASH = "b_dcs_bg_mn"; +static const std::string EVODB_REQUIRED_WORK_MNLISTS = "b_dcs_req_mn"; +static const std::string EVODB_BACKGROUND_WORK_MNLIST_HASH = "b_dcs_bg_work_mn"; +static const std::string EVODB_SNAPSHOT_EVO_SECTION = "b_dcs_evo"; enum class EvoDbIdentity { NORMAL, @@ -224,6 +227,7 @@ class CEvoDB bool CommitRootTransaction(EvoDbIdentity identity = EvoDbIdentity::NORMAL, bool sync = false) EXCLUSIVE_LOCKS_REQUIRED(!cs); bool IsEmpty() { return db->IsEmpty(); } + bool HasActiveTransaction() EXCLUSIVE_LOCKS_REQUIRED(!cs); //! Set the identity used by reads/writes outside any transaction. Must //! track the active chainstate: snapshot activation sets SNAPSHOT; @@ -250,6 +254,10 @@ class CEvoDB bool ReadSnapshotBaseMNListHash(uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); void WriteBackgroundMNListHash(const uint256& block_hash, const uint256& mn_list_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); bool ReadBackgroundMNListHash(uint256& block_hash, uint256& mn_list_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); + void WriteRequiredWorkMNListHashes(const std::vector& block_hashes) EXCLUSIVE_LOCKS_REQUIRED(!cs); + bool ReadRequiredWorkMNListHashes(std::vector& block_hashes) EXCLUSIVE_LOCKS_REQUIRED(!cs); + void WriteBackgroundWorkMNListHash(const uint256& block_hash, const uint256& mn_list_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); + bool ReadBackgroundWorkMNListHash(const uint256& block_hash, uint256& mn_list_hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); /** * Atomically promote the surviving snapshot marker to the legacy NORMAL key diff --git a/src/evo/mnhftx.cpp b/src/evo/mnhftx.cpp index f36e9b0a80ca..73bb7fd93c37 100644 --- a/src/evo/mnhftx.cpp +++ b/src/evo/mnhftx.cpp @@ -383,6 +383,12 @@ void CMNHFManager::AddToCache(const Signals& signals, const CBlockIndex* const p } } +bool CMNHFManager::SeedSignals(const CBlockIndex* pindex, const Signals& signals) +{ + assert(pindex != nullptr); + return m_evoDb.WriteDerived(std::make_pair(DB_SIGNALS_v2, pindex->GetBlockHash()), signals); +} + void CMNHFManager::AddSignal(const CBlockIndex* const pindex, int bit) { auto signals = GetForBlock(pindex->pprev); diff --git a/src/evo/mnhftx.h b/src/evo/mnhftx.h index b5d721360d49..1ce2abd5a87a 100644 --- a/src/evo/mnhftx.h +++ b/src/evo/mnhftx.h @@ -136,6 +136,8 @@ class CMNHFManager : public AbstractEHFManager void AddSignal(const CBlockIndex* const pindex, int bit) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); bool ForceSignalDBUpdate() EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs_cache); + /** Seed the signals at a block in the current EvoDB transaction. */ + bool SeedSignals(const CBlockIndex* pindex, const Signals& signals) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); private: void AddToCache(const Signals& signals, const CBlockIndex* const pindex) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp new file mode 100644 index 000000000000..30c14d0b1665 --- /dev/null +++ b/src/evo/snapshot.cpp @@ -0,0 +1,703 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace evo { +namespace { + +template +std::vector Sorted(std::vector values) +{ + std::sort(values.begin(), values.end(), [](const T& a, const T& b) { + if constexpr (std::is_same_v) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + } else if constexpr (std::is_same_v) { + return a.cycle_base_block_hash < b.cycle_base_block_hash; + } else if constexpr (std::is_same_v) { + return std::tie(a.height, a.block_hash) > std::tie(b.height, b.block_hash); + } else if constexpr (std::is_same_v) { + return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); + } else { + return a.llmq_type < b.llmq_type; + } + }); + return values; +} + +template +bool IsStrictlySorted(const std::vector& values) +{ + return std::adjacent_find(values.begin(), values.end(), [](const T& a, const T& b) { + if constexpr (std::is_same_v) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) >= + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + } else if constexpr (std::is_same_v) { + return !(a.cycle_base_block_hash < b.cycle_base_block_hash); + } else if constexpr (std::is_same_v) { + return !(std::tie(a.height, a.block_hash) > std::tie(b.height, b.block_hash)); + } else if constexpr (std::is_same_v) { + return !(std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash)); + } else { + return a.llmq_type >= b.llmq_type; + } + }) == values.end(); +} + +void ValidateCommitments(const CQuorumSnapshotData& data, const std::vector& commitments, + std::set& quorum_hashes, bool require_canonical_order) +{ + if (require_canonical_order && !IsStrictlySorted(commitments)) { + throw std::ios_base::failure("noncanonical evo quorum commitments"); + } + std::set quorum_indexes; + const auto& params{SnapshotLLMQParams(data.llmq_type)}; + for (const auto& entry : commitments) { + const bool known_version{ + entry.commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION || + entry.commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION || + entry.commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION || + entry.commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION}; + const bool indexed{entry.commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION || + entry.commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION}; + if (!entry.commitment.VerifySizes(params)) throw std::ios_base::failure("invalid evo quorum commitment sizes"); + if (!known_version) throw std::ios_base::failure("unknown evo quorum commitment version"); + if (entry.quorum_base_block_hash.IsNull() || entry.work_block_hash.IsNull() || entry.mined_block_hash.IsNull()) { + throw std::ios_base::failure("null evo quorum commitment block hash"); + } + if (entry.commitment.llmqType != data.llmq_type) { + throw std::ios_base::failure("mismatched evo quorum commitment type"); + } + if (entry.commitment.quorumHash != entry.quorum_base_block_hash) { + throw std::ios_base::failure("mismatched evo quorum commitment base hash"); + } + if (indexed != data.rotation_enabled) throw std::ios_base::failure("mismatched evo quorum rotation version"); + if (indexed && (entry.commitment.quorumIndex < 0 || + entry.commitment.quorumIndex >= params.signingActiveQuorumCount)) { + throw std::ios_base::failure("invalid evo quorum index"); + } + if (!quorum_hashes.insert(entry.quorum_base_block_hash).second) { + throw std::ios_base::failure("duplicate evo quorum base hash"); + } + if (indexed && !quorum_indexes.insert(entry.commitment.quorumIndex).second) { + throw std::ios_base::failure("duplicate evo quorum index"); + } + } +} + +CMinedQuorumCommitment ReadCommitment(const llmq::CQuorumBlockProcessor& qblockman, Consensus::LLMQType type, + const CBlockIndex* quorum_index, const CBlockIndex* work_index, + std::string& error) +{ + auto [commitment, mined_hash] = qblockman.GetMinedCommitment(type, quorum_index->GetBlockHash()); + if (mined_hash.IsNull()) error = "mined quorum commitment not found for " + quorum_index->GetBlockHash().ToString(); + return {quorum_index->GetBlockHash(), work_index->GetBlockHash(), std::move(commitment), mined_hash}; +} + +void ValidateCanonicalMNInvariants(const CDeterministicMNList& list) +{ + const size_t count{list.GetCounts().total()}; + if (count > EVO_SNAPSHOT_MAX_MNS) throw std::ios_base::failure("oversized canonical MN list"); + uint64_t max_internal_id{0}; + list.ForEachMN(/*onlyValid=*/false, [&](const auto& dmn) { + max_internal_id = std::max(max_internal_id, dmn.GetInternalId()); + if (dmn.pdmnState->payouts.size() > EVO_SNAPSHOT_MAX_PAYOUT_SHARES || + dmn.pdmnState->netInfo->Validate() != NetInfoStatus::Success) { + throw std::ios_base::failure("invalid canonical MN nested collection"); + } + }); + if (count != 0 && max_internal_id >= list.GetTotalRegisteredCount()) { + throw std::ios_base::failure("canonical MN-list internalId exceeds registration counter"); + } +} + +bool ValidateCommitmentAgainstChain(const CMinedQuorumCommitment& entry, const ChainstateManager& chainman, + const CBlockIndex* base_index, const Consensus::LLMQParams& params, + bool rotation_enabled) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + const CBlockIndex* mined_index{chainman.m_blockman.LookupBlockIndex(entry.mined_block_hash)}; + if (quorum_index == nullptr || mined_index == nullptr || + base_index->GetAncestor(quorum_index->nHeight) != quorum_index || + base_index->GetAncestor(mined_index->nHeight) != mined_index) return false; + const int cycle_height{quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval}; + if (rotation_enabled) { + if (entry.commitment.quorumIndex != quorum_index->nHeight % params.dkgInterval || + entry.commitment.quorumIndex < 0 || + entry.commitment.quorumIndex >= params.signingActiveQuorumCount) return false; + } else if (quorum_index->nHeight != cycle_height || entry.commitment.quorumIndex != 0) { + return false; + } + const int mined_cycle{mined_index->nHeight - mined_index->nHeight % params.dkgInterval}; + if (mined_cycle != cycle_height || mined_index->nHeight % params.dkgInterval < params.dkgMiningWindowStart || + mined_index->nHeight % params.dkgInterval > params.dkgMiningWindowEnd) return false; + const uint16_t expected_version{llmq::CFinalCommitment::GetVersion( + rotation_enabled, DeploymentActiveAfter(quorum_index, chainman.GetConsensus(), Consensus::DEPLOYMENT_V19))}; + return entry.commitment.nVersion == expected_version && entry.commitment.VerifySizes(params); +} + +} // namespace + +std::vector EvoSnapshotReconstructionHeights( + int base_height, const std::vector& enabled_llmqs) +{ + if (base_height < 0) throw std::invalid_argument("invalid reconstruction base height"); + std::vector heights; + for (const auto& params : enabled_llmqs) { + if (params.dkgInterval <= 0 || params.signingActiveQuorumCount <= 0) { + throw std::invalid_argument("invalid reconstruction LLMQ parameters"); + } + const int h{base_height - base_height % params.dkgInterval}; + const size_t count{params.useRotation ? EVO_SNAPSHOT_ROTATION_CYCLES + : SnapshotCommitmentCount(params, /*rotation_enabled=*/false)}; + const size_t first{params.useRotation ? 1U : 0U}; + for (size_t i{first}; i < first + count; ++i) { + const int quorum_height{h - static_cast(i) * params.dkgInterval}; + heights.push_back({params.type, params.useRotation, quorum_height, + quorum_height - llmq::WORK_DIFF_DEPTH}); + } + } + return heights; +} + +uint256 CanonicalMNListHash(const CDeterministicMNList& list) +{ + CHashWriter writer{SER_DISK, CLIENT_VERSION}; + SerializeCanonicalMNList(writer, list); + return writer.GetHash(); +} + +bool ReconstructHistoricalMNLists(const CEvoSnapshot& snapshot, + std::map& lists, std::string& error) +{ + lists.clear(); + error.clear(); + CDeterministicMNList current{snapshot.mn_list}; + uint256 previous_hash{snapshot.base_block_hash}; + int previous_height{current.GetHeightForSnapshotCodec()}; + try { + const auto history{Sorted(snapshot.historical_mn_list_diffs)}; + for (const auto& entry : history) { + if (entry.previous_block_hash != previous_hash || entry.block_hash.IsNull() || + entry.height < 0 || entry.height >= previous_height || entry.canonical_list_hash.IsNull()) { + throw std::ios_base::failure("broken historical MN-list diff chain"); + } + current.ApplyDiffForSnapshot(entry.block_hash, entry.height, entry.total_registered_count, entry.diff); + ValidateCanonicalMNInvariants(current); + if (CanonicalMNListHash(current) != entry.canonical_list_hash) { + throw std::ios_base::failure("historical MN-list diff hash mismatch"); + } + if (!lists.emplace(entry.block_hash, current).second) { + throw std::ios_base::failure("duplicate historical MN-list diff target"); + } + previous_hash = entry.block_hash; + previous_height = entry.height; + } + } catch (const std::exception& e) { + error = e.what(); + lists.clear(); + return false; + } + return true; +} + +void CEvoSnapshot::Validate(bool require_canonical_order) const +{ + if (version != EVO_SNAPSHOT_VERSION) throw std::ios_base::failure("unsupported evo snapshot version"); + if (base_block_hash.IsNull() || mn_list.GetBlockHash() != base_block_hash) { + throw std::ios_base::failure("evo snapshot base block mismatch"); + } + ValidateCanonicalMNInvariants(mn_list); + if (quorums.size() > Consensus::available_llmqs.size() || + historical_mn_list_diffs.size() > EvoSnapshotMaxHistoricalMNLists() || + quorum_modifiers.size() > EVO_SNAPSHOT_MAX_MODIFIERS || + mnhf_signals.size() > Consensus::MAX_VERSION_BITS_DEPLOYMENTS) { + throw std::ios_base::failure("oversized evo snapshot collection"); + } + if (require_canonical_order && (!IsStrictlySorted(quorums) || !IsStrictlySorted(historical_mn_list_diffs) || + !IsStrictlySorted(quorum_modifiers))) { + throw std::ios_base::failure("noncanonical evo snapshot top-level order"); + } + + std::map reconstructed; + std::string reconstruction_error; + if (!ReconstructHistoricalMNLists(*this, reconstructed, reconstruction_error)) { + throw std::ios_base::failure(reconstruction_error); + } + std::set historical_hashes; + for (const auto& [hash, _] : reconstructed) historical_hashes.insert(hash); + + std::set> required_modifiers; + std::set required_work_hashes; + + std::set quorum_types; + for (const auto& data : quorums) { + const auto& params{SnapshotLLMQParams(data.llmq_type)}; + if (!quorum_types.insert(data.llmq_type).second || (data.rotation_enabled && !params.useRotation)) { + throw std::ios_base::failure("invalid evo quorum type"); + } + const size_t active_count{static_cast(params.signingActiveQuorumCount)}; + const size_t total_count{SnapshotCommitmentCount(params, data.rotation_enabled)}; + // Parameter-derived counts are maxima, not exact requirements: a young + // chain carries however much quorum history exists. The chain-aware + // validation and the completion-time CbTx quorum merkle root establish + // that nothing available was withheld. + if (data.active_commitments.size() > active_count || + data.safety_commitments.size() > total_count - active_count || + data.rotation_snapshots.size() > (data.rotation_enabled ? EVO_SNAPSHOT_ROTATION_CYCLES : size_t{0})) { + throw std::ios_base::failure("invalid params-derived evo per-type quorum counts"); + } + std::set quorum_hashes; + ValidateCommitments(data, data.active_commitments, quorum_hashes, require_canonical_order); + ValidateCommitments(data, data.safety_commitments, quorum_hashes, require_canonical_order); + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + required_work_hashes.insert(entry.work_block_hash); + required_modifiers.emplace(data.llmq_type, entry.work_block_hash); + } + } + if (require_canonical_order && !IsStrictlySorted(data.rotation_snapshots)) { + throw std::ios_base::failure("noncanonical evo quorum rotation snapshots"); + } + std::set cycle_hashes; + for (const auto& entry : data.rotation_snapshots) { + if (entry.cycle_base_block_hash.IsNull() || entry.work_block_hash.IsNull() || + !cycle_hashes.insert(entry.cycle_base_block_hash).second || + !historical_hashes.contains(entry.work_block_hash) || + entry.snapshot.mnSkipListMode < SnapshotSkipMode::MODE_NO_SKIPPING || + entry.snapshot.mnSkipListMode > SnapshotSkipMode::MODE_ALL_SKIPPED || + entry.snapshot.activeQuorumMembers.size() > EVO_SNAPSHOT_MAX_MNS || + entry.snapshot.mnSkipList.size() > EVO_SNAPSHOT_MAX_SKIPLIST_ENTRIES || + // Only the first entry is an absolute index; later entries are + // deltas that legitimately go negative once the build wraps the + // combined MN list. Semantic validity is established by quorum + // reconstruction against chain state, not here. + (!entry.snapshot.mnSkipList.empty() && entry.snapshot.mnSkipList.front() < 0)) { + throw std::ios_base::failure("invalid evo quorum rotation snapshot"); + } + required_work_hashes.insert(entry.work_block_hash); + required_modifiers.emplace(data.llmq_type, entry.work_block_hash); + } + } + if (historical_hashes != required_work_hashes) { + throw std::ios_base::failure("missing or extra historical MN-list diff target"); + } + std::set> actual_modifiers; + for (const auto& entry : quorum_modifiers) { + SnapshotLLMQParams(entry.llmq_type); + if (entry.work_block_hash.IsNull() || entry.modifier.IsNull() || + !actual_modifiers.emplace(entry.llmq_type, entry.work_block_hash).second) { + throw std::ios_base::failure("invalid or duplicate evo quorum modifier"); + } + } + if (actual_modifiers != required_modifiers) { + throw std::ios_base::failure("missing or extra evo quorum modifier"); + } +} + +uint256 GetEvoSnapshotHash(const CEvoSnapshot& snapshot) +{ + snapshot.Validate(); + CDataStream stream{SER_DISK, CLIENT_VERSION}; + stream << snapshot; + uint256 hash; + CSHA256().Write(UCharCast(stream.data()), stream.size()).Finalize(hash.begin()); + return hash; +} + +bool BuildEvoSnapshot(const CChainParams& chainparams, const ChainstateManager& chainman, + CDeterministicMNManager& dmnman, + const llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, + CCreditPoolManager& cpoolman, CMNHFManager& mnhfman, const CBlockIndex* base_index, + CEvoSnapshot& snapshot, std::string& error) +{ + AssertLockHeld(::cs_main); + error.clear(); + if (base_index == nullptr) { + error = "evo snapshot base block is null"; + return false; + } + + CEvoSnapshot result; + result.base_block_hash = base_index->GetBlockHash(); + if (!DeploymentActiveAt(*base_index, chainparams.GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { + result.mn_list = CDeterministicMNList{base_index->GetBlockHash(), base_index->nHeight, 0}; + result.Validate(); + snapshot = std::move(result); + return true; + } + result.mn_list = dmnman.GetListForBlock(base_index); + std::map> historical; + std::map, uint256> modifiers; + + const auto register_work_block = [&](const Consensus::LLMQParams& params, + bool rotation_enabled, + const CBlockIndex* quorum_index) -> const CBlockIndex* { + const CBlockIndex* modifier_base{rotation_enabled + ? quorum_index->GetAncestor(quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval) + : quorum_index}; + if (modifier_base == nullptr) return nullptr; + const CBlockIndex* work_index{ + (rotation_enabled || + DeploymentActiveAfter(modifier_base, chainparams.GetConsensus(), Consensus::DEPLOYMENT_V20)) + ? modifier_base->GetAncestor(modifier_base->nHeight - llmq::WORK_DIFF_DEPTH) + : modifier_base}; + if (work_index == nullptr) return nullptr; + historical.try_emplace(work_index->GetBlockHash(), work_index, dmnman.GetListForBlock(work_index)); + modifiers.emplace(std::make_pair(params.type, work_index->GetBlockHash()), + llmq::utils::GetQuorumHashModifier(params, chainparams.GetConsensus(), modifier_base)); + return work_index; + }; + + for (const auto& params : chainparams.GetConsensus().llmqs) { + if (!chainman.IsQuorumTypeEnabled(params.type, base_index)) continue; + CQuorumSnapshotData data; + data.llmq_type = params.type; + data.rotation_enabled = llmq::IsQuorumRotationEnabled(params, base_index); + + const size_t active_count{static_cast(params.signingActiveQuorumCount)}; + const size_t total_count{SnapshotCommitmentCount(params, data.rotation_enabled)}; + std::vector indexes; + if (data.rotation_enabled) { + indexes = qblockman.GetLastMinedCommitmentsPerQuorumIndexUntilBlock(params.type, base_index, 0); + } else { + indexes = qblockman.GetMinedCommitmentsUntilBlock(params.type, base_index, total_count); + } + // 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 = "missing active quorum work block"; + return false; + } + auto entry{ReadCommitment(qblockman, params.type, indexes[i], work_index, error)}; + if (!error.empty()) return false; + data.active_commitments.emplace_back(std::move(entry)); + } + + if (data.rotation_enabled) { + indexes = qblockman.GetLastMinedCommitmentsPerQuorumIndexUntilBlock(params.type, base_index, 1); + } else { + indexes.erase(indexes.begin(), indexes.begin() + 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 = "missing safety quorum work block"; + return false; + } + auto entry{ReadCommitment(qblockman, params.type, indexes[i], work_index, error)}; + if (!error.empty()) return false; + data.safety_commitments.emplace_back(std::move(entry)); + } + + if (data.rotation_enabled) { + std::vector one_type{params}; + for (const auto& required : EvoSnapshotReconstructionHeights(base_index->nHeight, one_type)) { + const int cycle_height{required.quorum_height}; + const int work_height{required.work_height}; + const CBlockIndex* cycle_index{cycle_height >= 0 ? base_index->GetAncestor(cycle_height) : nullptr}; + const CBlockIndex* work_index{cycle_index && work_height >= 0 + ? cycle_index->GetAncestor(work_height) + : nullptr}; + // 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; + data.rotation_snapshots.push_back( + {cycle_index->GetBlockHash(), work_index->GetBlockHash(), *stored}); + historical.try_emplace(work_index->GetBlockHash(), work_index, dmnman.GetListForBlock(work_index)); + modifiers.emplace(std::make_pair(params.type, work_index->GetBlockHash()), + llmq::utils::GetQuorumHashModifier(params, chainparams.GetConsensus(), cycle_index)); + } + } + data.active_commitments = Sorted(std::move(data.active_commitments)); + data.safety_commitments = Sorted(std::move(data.safety_commitments)); + data.rotation_snapshots = Sorted(std::move(data.rotation_snapshots)); + result.quorums.emplace_back(std::move(data)); + } + + std::vector> ordered_history; + ordered_history.reserve(historical.size()); + for (auto& [_, indexed_list] : historical) ordered_history.emplace_back(std::move(indexed_list)); + std::sort(ordered_history.begin(), ordered_history.end(), [](const auto& a, const auto& b) { + return std::make_tuple(a.first->nHeight, a.first->GetBlockHash()) > + std::make_tuple(b.first->nHeight, b.first->GetBlockHash()); + }); + CDeterministicMNList previous_list{result.mn_list}; + uint256 previous_hash{result.base_block_hash}; + for (const auto& [index, list] : ordered_history) { + if (index->GetBlockHash() == result.base_block_hash) continue; + result.historical_mn_list_diffs.push_back({previous_hash, index->GetBlockHash(), index->nHeight, + list.GetTotalRegisteredCount(), CanonicalMNListHash(list), + previous_list.BuildDiff(list)}); + previous_hash = index->GetBlockHash(); + previous_list = list; + } + for (const auto& [key, modifier] : modifiers) { + result.quorum_modifiers.push_back({key.first, key.second, modifier}); + } + result.credit_pool = cpoolman.GetCreditPool(base_index); + result.mnhf_signals = mnhfman.GetSignalsStage(base_index); + result.quorums = Sorted(std::move(result.quorums)); + result.historical_mn_list_diffs = Sorted(std::move(result.historical_mn_list_diffs)); + result.quorum_modifiers = Sorted(std::move(result.quorum_modifiers)); + try { + result.Validate(); + } catch (const std::exception& e) { + error = e.what(); + return false; + } + snapshot = std::move(result); + return true; +} + +bool ValidateEvoSnapshotAgainstChain(const CEvoSnapshot& snapshot, const ChainstateManager& chainman, + const CBlockIndex* base_index, std::string& error) +{ + AssertLockHeld(::cs_main); + error.clear(); + const auto fail = [&](const std::string& message) { + error = message; + return false; + }; + if (base_index == nullptr || snapshot.base_block_hash != base_index->GetBlockHash() || + snapshot.mn_list.GetBlockHash() != base_index->GetBlockHash() || + snapshot.mn_list.GetHeightForSnapshotCodec() != base_index->nHeight) { + return fail("evo snapshot base block/height mismatch"); + } + try { + snapshot.Validate(/*require_canonical_order=*/true); + } catch (const std::exception& e) { + return fail(e.what()); + } + + const auto& consensus{chainman.GetConsensus()}; + if (!DeploymentActiveAt(*base_index, consensus, Consensus::DEPLOYMENT_DIP0003)) { + if (!snapshot.quorums.empty() || !snapshot.historical_mn_list_diffs.empty() || + !snapshot.quorum_modifiers.empty() || + snapshot.credit_pool.locked != 0 || snapshot.credit_pool.currentLimit != 0 || + snapshot.credit_pool.latelyUnlocked != 0 || !snapshot.credit_pool.indexes.IsEmpty() || + !snapshot.mnhf_signals.empty()) { + return fail("nonempty pre-DIP3 evo snapshot"); + } + return true; + } + + std::map historical_lists; + if (!ReconstructHistoricalMNLists(snapshot, historical_lists, error)) return false; + for (const auto& entry : snapshot.historical_mn_list_diffs) { + const CBlockIndex* index{chainman.m_blockman.LookupBlockIndex(entry.block_hash)}; + if (index == nullptr || base_index->GetAncestor(index->nHeight) != index || + entry.height != index->nHeight) { + return fail("invalid historical evo MN list chain data"); + } + } + + std::map actual; + for (const auto& data : snapshot.quorums) actual.emplace(data.llmq_type, &data); + size_t enabled_count{0}; + std::set required_work_hashes; + for (const auto& params : consensus.llmqs) { + if (!chainman.IsQuorumTypeEnabled(params.type, base_index)) continue; + ++enabled_count; + const auto it{actual.find(params.type)}; + if (it == actual.end()) return fail("missing enabled evo quorum type"); + const auto& data{*it->second}; + const bool rotation_enabled{llmq::IsQuorumRotationEnabled(params, base_index)}; + const size_t active_count{static_cast(params.signingActiveQuorumCount)}; + const size_t total_count{SnapshotCommitmentCount(params, rotation_enabled)}; + if (data.rotation_enabled != rotation_enabled || data.active_commitments.size() > active_count || + data.safety_commitments.size() > total_count - active_count) { + return fail("evo quorum params/count mismatch"); + } + + std::set active_indexes; + const auto validate_work_block = [&](const CMinedQuorumCommitment& entry) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + if (quorum_index == nullptr) return false; + const CBlockIndex* modifier_base{rotation_enabled + ? quorum_index->GetAncestor(quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval) + : quorum_index}; + if (modifier_base == nullptr) return false; + const CBlockIndex* expected_work{ + (rotation_enabled || DeploymentActiveAfter(modifier_base, consensus, Consensus::DEPLOYMENT_V20)) + ? modifier_base->GetAncestor(modifier_base->nHeight - llmq::WORK_DIFF_DEPTH) + : modifier_base}; + return expected_work != nullptr && entry.work_block_hash == expected_work->GetBlockHash(); + }; + for (const auto& entry : data.active_commitments) { + if (!ValidateCommitmentAgainstChain(entry, chainman, base_index, params, rotation_enabled) || + !validate_work_block(entry) || + (rotation_enabled && !active_indexes.insert(entry.commitment.quorumIndex).second)) { + return fail("invalid active evo quorum commitment chain data"); + } + required_work_hashes.insert(entry.work_block_hash); + } + for (const auto& entry : data.safety_commitments) { + if (!ValidateCommitmentAgainstChain(entry, chainman, base_index, params, rotation_enabled) || + !validate_work_block(entry)) { + return fail("invalid safety evo quorum commitment chain data"); + } + required_work_hashes.insert(entry.work_block_hash); + } + 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() != enabled_count) return fail("unexpected disabled evo quorum type"); + + std::set historical_hashes; + for (const auto& [hash, list] : historical_lists) historical_hashes.insert(hash); + historical_hashes.erase(base_index->GetBlockHash()); + required_work_hashes.erase(base_index->GetBlockHash()); + if (historical_hashes != required_work_hashes) return fail("missing or extra historical evo MN list"); + + std::map, uint256> seeded_modifiers; + for (const auto& entry : snapshot.quorum_modifiers) { + seeded_modifiers.emplace(std::make_pair(entry.llmq_type, entry.work_block_hash), entry.modifier); + } + for (const auto& data : snapshot.quorums) { + const auto params{chainman.GetParams().GetLLMQ(data.llmq_type)}; + if (!params) return fail("unknown chain LLMQ parameters for modifier"); + const auto check_modifier = [&](const uint256& quorum_hash, const uint256& work_hash) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + const auto seeded{seeded_modifiers.find(std::make_pair(data.llmq_type, work_hash))}; + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(quorum_hash)}; + const CBlockIndex* work_index{chainman.m_blockman.LookupBlockIndex(work_hash)}; + if (seeded == seeded_modifiers.end() || quorum_index == nullptr || work_index == nullptr) return false; + if ((work_index->nStatus & BLOCK_HAVE_DATA) != 0 && + seeded->second != llmq::utils::GetQuorumHashModifier(*params, consensus, quorum_index)) return false; + return true; + }; + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + const CBlockIndex* modifier_base{data.rotation_enabled && quorum_index != nullptr + ? quorum_index->GetAncestor(quorum_index->nHeight - quorum_index->nHeight % params->dkgInterval) + : quorum_index}; + if (modifier_base == nullptr || + !check_modifier(modifier_base->GetBlockHash(), entry.work_block_hash)) { + return fail("evo seeded quorum modifier mismatch"); + } + } + } + for (const auto& entry : data.rotation_snapshots) { + if (!check_modifier(entry.cycle_base_block_hash, entry.work_block_hash)) { + return fail("evo seeded rotation modifier mismatch"); + } + } + } + + if (!MoneyRange(snapshot.credit_pool.locked) || !MoneyRange(snapshot.credit_pool.currentLimit) || + !MoneyRange(snapshot.credit_pool.latelyUnlocked)) return fail("invalid evo credit pool monetary value"); + for (const auto& [bit, height] : snapshot.mnhf_signals) { + if (bit >= VERSIONBITS_NUM_BITS || height < 0 || height > base_index->nHeight) { + return fail("invalid evo MNHF signal bit/height"); + } + } + return true; +} + +bool VerifyEvoSnapshotCbTx(const CEvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error) +{ + error.clear(); + try { + snapshot.Validate(); + } catch (const std::exception& e) { + error = e.what(); + return false; + } + bool mutated{false}; + const uint256 mn_root{snapshot.mn_list.to_sml()->CalcMerkleRoot(&mutated)}; + if (mutated || mn_root != cbtx.merkleRootMNList) { + error = "evo snapshot masternode merkle root mismatch"; + return false; + } + if (cbtx.nVersion >= CCbTx::Version::MERKLE_ROOT_QUORUMS) { + std::vector hashes; + for (const auto& data : snapshot.quorums) { + for (const auto& entry : data.active_commitments) hashes.emplace_back(SerializeHash(entry.commitment)); + } + std::sort(hashes.begin(), hashes.end()); + const uint256 quorum_root{ComputeMerkleRoot(hashes, &mutated)}; + if (mutated || quorum_root != cbtx.merkleRootQuorums) { + error = "evo snapshot quorum merkle root mismatch"; + return false; + } + } + if (cbtx.nVersion >= CCbTx::Version::CLSIG_AND_BALANCE && snapshot.credit_pool.locked != cbtx.creditPoolBalance) { + error = "evo snapshot credit pool balance mismatch"; + return false; + } + return true; +} + +} // namespace evo diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h new file mode 100644 index 000000000000..ce01f8395a4d --- /dev/null +++ b/src/evo/snapshot.h @@ -0,0 +1,621 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_EVO_SNAPSHOT_H +#define BITCOIN_EVO_SNAPSHOT_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class CBlockIndex; +class CChainParams; +class ChainstateManager; +class CCbTx; +class CCreditPoolManager; +class CMNHFManager; + +namespace llmq { +class CQuorumBlockProcessor; +class CQuorumSnapshotManager; +} // namespace llmq + +namespace evo { + +static constexpr uint16_t EVO_SNAPSHOT_VERSION{3}; +/** Serialized little-endian bytes are "DASHEVO\0". */ +static constexpr uint64_t EVO_SNAPSHOT_MARKER{0x004f564548534144ULL}; +// ComputeQuorumMembersByQuarterRotation consumes H-C, H-2C and H-3C. To +// reconstruct both H and the safety cycle H-C, the union is H-C..H-4C. +static constexpr size_t EVO_SNAPSHOT_ROTATION_CYCLES{4}; +// A hard allocation bound, not a network population target. 100,000 full MN +// records is already far beyond today's list while limiting hostile snapshots +// to a tractable decode. Changes above this require a format-version review. +static constexpr size_t EVO_SNAPSHOT_MAX_MNS{100'000}; +// Asset-unlock indexes are uint64_t and have no consensus upper bound. This is +// a range-count allocation/work bound, chosen far above any plausible live +// state. Raising it requires an evo snapshot format-version review. +static constexpr size_t EVO_SNAPSHOT_MAX_RANGES{100'000}; +// IsPayoutListTriviallyValid() is the protocol admission rule for MultiPayout. +static constexpr size_t EVO_SNAPSHOT_MAX_PAYOUT_SHARES{8}; +// CDeterministicMN contains several consensus/P2P CompactSize collections +// (scripts, payout shares, and ExtNetInfo maps/lists). Snapshot decoding gives +// each MN a cumulative budget so nested counts cannot multiply decode work. +// This comfortably covers protocol-valid scripts and network information. +static constexpr size_t EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS{10'000}; +static constexpr size_t EVO_SNAPSHOT_MAX_MODIFIERS{4'096}; +// A cycle's skip list accumulates across every quorum index and the build can +// wrap the combined MN list more than once, so a single quorum's size does not +// bound its legitimate length. This is a decode ceiling on claimed sizes only, +// far above any state the aggregate rotation build reaches on real chains. +static constexpr size_t EVO_SNAPSHOT_MAX_SKIPLIST_ENTRIES{1'000'000}; +static_assert(std::ranges::all_of(Consensus::available_llmqs, [](const auto& params) { + return !params.useRotation || params.keepOldConnections <= 2 * params.signingActiveQuorumCount; +}), "rotated LLMQ retention exceeds the two serialized cycles"); + +template +size_t ReadBoundedCompactSize(Stream& s, size_t limit, const char* field) +{ + const uint64_t size{ReadCompactSize(s)}; + if (size > limit) throw std::ios_base::failure(std::string{"oversized evo snapshot "} + field); + return static_cast(size); +} + +inline const Consensus::LLMQParams& SnapshotLLMQParams(Consensus::LLMQType type) +{ + const auto it{std::ranges::find_if(Consensus::available_llmqs, + [type](const auto& params) { return params.type == type; })}; + if (it == Consensus::available_llmqs.end()) throw std::ios_base::failure("unknown evo snapshot LLMQ type"); + return *it; +} + +inline size_t SnapshotCommitmentCount(const Consensus::LLMQParams& params, bool rotation_enabled) +{ + if (!rotation_enabled) { + return static_cast(std::max(params.signingActiveQuorumCount + 1, params.keepOldConnections)); + } + const size_t active{static_cast(params.signingActiveQuorumCount)}; + const size_t retained{static_cast(params.keepOldConnections)}; + // Rotation seeding promises the active and previous complete cycles. A + // future parameter set retaining more must extend the serialized cycles. + if (retained > 2 * active) throw std::ios_base::failure("rotated LLMQ retention exceeds two cycles"); + return 2 * active; +} + +/** + * Maximum number of distinct historical work-block lists a snapshot can need. + * The serialized set is deduplicated, so summing every enabled-type horizon is + * conservative: two retained commitment cycles plus H-C..H-4C for rotated + * types, or the retained commitment horizon for non-rotated types. + */ +inline size_t EvoSnapshotMaxHistoricalMNLists() +{ + size_t count{0}; + for (const auto& params : Consensus::available_llmqs) { + count += params.useRotation + ? SnapshotCommitmentCount(params, /*rotation_enabled=*/true) + EVO_SNAPSHOT_ROTATION_CYCLES + : SnapshotCommitmentCount(params, /*rotation_enabled=*/false); + } + return count; +} + +/** + * A historical diff covers one required quorum work-block transition. Allow + * 4,096 net add/update/remove operations per transition (already far above + * plausible per-block MN churn), across the entire params-derived horizon. + * This generous cumulative ceiling prevents individually-valid 100k-entry + * diffs from multiplying decode work across every historical entry. + */ +inline size_t EvoSnapshotMaxHistoricalMNOperations() +{ + return EvoSnapshotMaxHistoricalMNLists() * 4'096; +} + +template +class SnapshotBoundedInput +{ +private: + Stream& m_stream; + uint64_t m_compact_budget; + +public: + SnapshotBoundedInput(Stream& stream, uint64_t compact_budget) : + m_stream{stream}, m_compact_budget{compact_budget} {} + + int GetType() const { return m_stream.GetType(); } + int GetVersion() const { return m_stream.GetVersion(); } + void read(Span dst) { m_stream.read(dst); } + void ignore(size_t size) { m_stream.ignore(size); } + + uint64_t ReadBudgetedCompactSize() + { + const uint64_t size{::ReadCompactSize(m_stream)}; + if (size > m_compact_budget) throw std::ios_base::failure("canonical MN nested CompactSize budget exceeded"); + m_compact_budget -= size; + return size; + } + + template + SnapshotBoundedInput& operator>>(T&& obj) + { + ::Unserialize(*this, obj); + return *this; + } +}; + +template +uint64_t ReadCompactSize(SnapshotBoundedInput& stream) +{ + return stream.ReadBudgetedCompactSize(); +} + +/** + * NetInfoEntry overrides the stream version while decoding its payload. Keep + * the snapshot-local CompactSize budget visible through that transparent + * wrapper so strings are rejected before their deserializer resizes them. + */ +template +uint64_t ReadCompactSize(OverrideStream>& stream) +{ + return stream.GetStream().ReadBudgetedCompactSize(); +} + +/** + * Snapshot-local canonical deterministic-MN encoding. + * + * internalId and nTotalRegisteredCount are intentionally retained. They are + * consensus-deterministic for nodes synced from genesis: registrations assign + * internalId in on-chain order and advance the counter identically. Thus a + * from-genesis background validation re-derives the dumper's exact values. + * Entries are sorted by the full proTxHash, never by immer iteration order. + */ +template +void SerializeCanonicalMNList(Stream& s, const CDeterministicMNList& list) +{ + s << list.GetBlockHash() << list.GetHeightForSnapshotCodec() << list.GetTotalRegisteredCount(); + std::vector mns; + mns.reserve(list.GetCounts().total()); + list.ForEachMNShared(/*onlyValid=*/false, [&](const auto& dmn) { mns.emplace_back(dmn); }); + std::sort(mns.begin(), mns.end(), [](const auto& a, const auto& b) { return a->proTxHash < b->proTxHash; }); + WriteCompactSize(s, mns.size()); + for (const auto& dmn : mns) s << *dmn; +} + +template +CDeterministicMNList UnserializeCanonicalMNList(Stream& s) +{ + uint256 block_hash; + int height; + uint32_t total_registered; + s >> block_hash >> height >> total_registered; + if (height < 0) throw std::ios_base::failure("negative canonical MN-list height"); + CDeterministicMNList list{block_hash, height, total_registered}; + const size_t count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "MN count")}; + uint256 previous; + bool have_previous{false}; + uint64_t max_internal_id{0}; + for (size_t i{0}; i < count; ++i) { + SnapshotBoundedInput bounded{s, EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; + auto dmn{std::make_shared(deserialize, bounded)}; + if (dmn->pdmnState->payouts.size() > EVO_SNAPSHOT_MAX_PAYOUT_SHARES) { + throw std::ios_base::failure("oversized canonical MN payout list"); + } + if (dmn->pdmnState->netInfo->Validate() != NetInfoStatus::Success) { + throw std::ios_base::failure("invalid canonical MN network info"); + } + if (have_previous && !(previous < dmn->proTxHash)) { + throw std::ios_base::failure("noncanonical canonical MN-list order"); + } + previous = dmn->proTxHash; + have_previous = true; + max_internal_id = std::max(max_internal_id, dmn->GetInternalId()); + try { + list.AddMN(dmn, /*fBumpTotalCount=*/false); + } catch (const std::exception& e) { + throw std::ios_base::failure(std::string{"invalid canonical MN list: "} + e.what()); + } + } + if (count != 0 && max_internal_id >= total_registered) { + throw std::ios_base::failure("canonical MN-list internalId exceeds registration counter"); + } + return list; +} + +/** Canonical hash shared by snapshot encoding and M3 completion comparison. */ +uint256 CanonicalMNListHash(const CDeterministicMNList& list); + +/** Canonical snapshot-local encoding of a deterministic-MN list diff. */ +template +void SerializeCanonicalMNListDiff(Stream& s, const CDeterministicMNListDiff& diff) +{ + auto added{diff.addedMNs}; + std::sort(added.begin(), added.end(), [](const auto& a, const auto& b) { + return std::make_tuple(a->GetInternalId(), a->proTxHash) < + std::make_tuple(b->GetInternalId(), b->proTxHash); + }); + WriteCompactSize(s, added.size()); + for (const auto& dmn : added) s << *dmn; + + std::vector updated; + updated.reserve(diff.updatedMNs.size()); + for (const auto& [internal_id, _] : diff.updatedMNs) updated.emplace_back(internal_id); + std::sort(updated.begin(), updated.end()); + WriteCompactSize(s, updated.size()); + for (const uint64_t internal_id : updated) { + WriteVarInt(s, internal_id); + s << diff.updatedMNs.at(internal_id); + } + WriteCompactSize(s, diff.removedMns.size()); + for (const uint64_t internal_id : diff.removedMns) { + WriteVarInt(s, internal_id); + } +} + +template +CDeterministicMNListDiff UnserializeCanonicalMNListDiff(Stream& s, size_t& remaining_operations) +{ + CDeterministicMNListDiff diff; + const size_t added_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "MN-diff additions")}; + if (added_count > remaining_operations) throw std::ios_base::failure("historical MN-diff operation budget exceeded"); + remaining_operations -= added_count; + uint64_t previous_id{0}; + bool have_previous{false}; + diff.addedMNs.reserve(added_count); + for (size_t i{0}; i < added_count; ++i) { + SnapshotBoundedInput bounded{s, EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; + auto dmn{std::make_shared(deserialize, bounded)}; + if ((have_previous && previous_id >= dmn->GetInternalId()) || + dmn->pdmnState->payouts.size() > EVO_SNAPSHOT_MAX_PAYOUT_SHARES || + dmn->pdmnState->netInfo->Validate() != NetInfoStatus::Success) { + throw std::ios_base::failure("noncanonical canonical MN-diff addition"); + } + previous_id = dmn->GetInternalId(); + have_previous = true; + diff.addedMNs.emplace_back(std::move(dmn)); + } + + const size_t updated_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "MN-diff updates")}; + if (updated_count > remaining_operations) throw std::ios_base::failure("historical MN-diff operation budget exceeded"); + remaining_operations -= updated_count; + previous_id = 0; + have_previous = false; + for (size_t i{0}; i < updated_count; ++i) { + const uint64_t internal_id{ReadVarInt(s)}; + if (have_previous && previous_id >= internal_id) { + throw std::ios_base::failure("noncanonical canonical MN-diff update order"); + } + SnapshotBoundedInput bounded{s, EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; + diff.updatedMNs.emplace(internal_id, CDeterministicMNStateDiff(deserialize, bounded)); + previous_id = internal_id; + have_previous = true; + } + + const size_t removed_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MNS, "MN-diff removals")}; + if (removed_count > remaining_operations) throw std::ios_base::failure("historical MN-diff operation budget exceeded"); + remaining_operations -= removed_count; + previous_id = 0; + have_previous = false; + for (size_t i{0}; i < removed_count; ++i) { + const uint64_t internal_id{ReadVarInt(s)}; + if (have_previous && previous_id >= internal_id) { + throw std::ios_base::failure("noncanonical canonical MN-diff removal order"); + } + diff.removedMns.emplace(internal_id); + previous_id = internal_id; + have_previous = true; + } + return diff; +} + +template +CDeterministicMNListDiff UnserializeCanonicalMNListDiff(Stream& s) +{ + size_t remaining_operations{EvoSnapshotMaxHistoricalMNOperations()}; + return UnserializeCanonicalMNListDiff(s, remaining_operations); +} + +struct CMinedQuorumCommitment { + uint256 quorum_base_block_hash; + uint256 work_block_hash; + llmq::CFinalCommitment commitment; + uint256 mined_block_hash; + + SERIALIZE_METHODS(CMinedQuorumCommitment, obj) + { + READWRITE(obj.quorum_base_block_hash, obj.work_block_hash, obj.commitment, obj.mined_block_hash); + } +}; + +template +CMinedQuorumCommitment ReadMinedQuorumCommitment(Stream& s, const Consensus::LLMQParams& params) +{ + CMinedQuorumCommitment entry; + auto& commitment{entry.commitment}; + s >> entry.quorum_base_block_hash >> entry.work_block_hash >> commitment.nVersion >> commitment.llmqType >> commitment.quorumHash; + const bool indexed{commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION || + commitment.nVersion == llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION}; + if (indexed) s >> commitment.quorumIndex; + const size_t signers_size{ReadBoundedCompactSize(s, params.size, "commitment signers")}; + if (signers_size != static_cast(params.size)) { + throw std::ios_base::failure("invalid evo snapshot commitment signers size"); + } + ReadFixedBitSet(s, commitment.signers, signers_size); + const size_t valid_members_size{ReadBoundedCompactSize(s, params.size, "commitment valid members")}; + if (valid_members_size != static_cast(params.size)) { + throw std::ios_base::failure("invalid evo snapshot commitment valid-members size"); + } + ReadFixedBitSet(s, commitment.validMembers, valid_members_size); + const bool legacy{commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION || + commitment.nVersion == llmq::CFinalCommitment::LEGACY_BLS_INDEXED_QUORUM_VERSION}; + s >> CBLSPublicKeyVersionWrapper(commitment.quorumPublicKey, legacy) >> commitment.quorumVvecHash >> + CBLSSignatureVersionWrapper(commitment.quorumSig, legacy) >> + CBLSSignatureVersionWrapper(commitment.membersSig, legacy); + // The consensus/P2P serializer remains unchanged; this snapshot-local path + // bounds both bitsets before allocation and verifies the decoded object. + if (!entry.commitment.VerifySizes(params)) { + throw std::ios_base::failure("invalid evo snapshot commitment bitset size"); + } + s >> entry.mined_block_hash; + return entry; +} + +struct CQuorumSnapshotEntry { + uint256 cycle_base_block_hash; + uint256 work_block_hash; + llmq::CQuorumSnapshot snapshot; +}; + +struct CHistoricalMNListDiff { + uint256 previous_block_hash; + uint256 block_hash; + int height{-1}; + uint32_t total_registered_count{0}; + uint256 canonical_list_hash; + CDeterministicMNListDiff diff; +}; + +struct CQuorumModifier { + Consensus::LLMQType llmq_type{Consensus::LLMQType::LLMQ_NONE}; + uint256 work_block_hash; + uint256 modifier; + + SERIALIZE_METHODS(CQuorumModifier, obj) + { + READWRITE(obj.llmq_type, obj.work_block_hash, obj.modifier); + } +}; + +struct CQuorumSnapshotData { + Consensus::LLMQType llmq_type{Consensus::LLMQType::LLMQ_NONE}; + bool rotation_enabled{false}; + std::vector active_commitments; + std::vector safety_commitments; + std::vector rotation_snapshots; + + template void Serialize(Stream& s) const; + template void Unserialize(Stream& s); +}; + +/** Canonical Dash-derived state attached to an assumeutxo snapshot. */ +class CEvoSnapshot +{ +public: + uint16_t version{EVO_SNAPSHOT_VERSION}; + uint256 base_block_hash; + CDeterministicMNList mn_list; + std::vector quorums; + std::vector historical_mn_list_diffs; + std::vector quorum_modifiers; + CCreditPool credit_pool; + AbstractEHFManager::Signals mnhf_signals; + + template void Serialize(Stream& s) const; + template void Unserialize(Stream& s); + + /** Validate invariants not requiring chainstate or block-index lookup. */ + void Validate(bool require_canonical_order = false) const; +}; + +template +void WriteSnapshotVector(Stream& s, const std::vector& values, WriteOne&& write_one) +{ + WriteCompactSize(s, values.size()); + for (const auto& value : values) write_one(value); +} + +template +void WriteRotationSnapshot(Stream& s, const CQuorumSnapshotEntry& entry) +{ + s << entry.cycle_base_block_hash << entry.work_block_hash << entry.snapshot.mnSkipListMode; + WriteCompactSize(s, entry.snapshot.activeQuorumMembers.size()); + WriteFixedBitSet(s, entry.snapshot.activeQuorumMembers, entry.snapshot.activeQuorumMembers.size()); + s << entry.snapshot.mnSkipList; +} + +template +CQuorumSnapshotEntry ReadRotationSnapshot(Stream& s, const Consensus::LLMQParams& params) +{ + CQuorumSnapshotEntry entry; + s >> entry.cycle_base_block_hash >> entry.work_block_hash >> entry.snapshot.mnSkipListMode; + // BuildQuorumSnapshot sizes this bitset to the complete work-block MN list, + // not to the quorum size. The exact historical-list size is chain-aware and + // is checked by 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")}; + // Clamp the upfront allocation: a hostile claimed count must pay with its + // own serialized bytes, not with a proportional reserve. + entry.snapshot.mnSkipList.reserve(std::min(skip_count, params.size)); + for (size_t i{0}; i < skip_count; ++i) { + int value; + s >> value; + entry.snapshot.mnSkipList.emplace_back(value); + } + return entry; +} + +template +void CQuorumSnapshotData::Serialize(Stream& s) const +{ + auto active{active_commitments}; + auto safety{safety_commitments}; + auto snapshots{rotation_snapshots}; + const auto commitment_less = [](const auto& a, const auto& b) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + }; + std::sort(active.begin(), active.end(), commitment_less); + std::sort(safety.begin(), safety.end(), commitment_less); + std::sort(snapshots.begin(), snapshots.end(), + [](const auto& a, const auto& b) { return a.cycle_base_block_hash < b.cycle_base_block_hash; }); + s << llmq_type << rotation_enabled << active << safety; + WriteSnapshotVector(s, snapshots, [&](const auto& entry) { WriteRotationSnapshot(s, entry); }); +} + +template +void CQuorumSnapshotData::Unserialize(Stream& s) +{ + s >> llmq_type >> rotation_enabled; + const auto& params{SnapshotLLMQParams(llmq_type)}; + const size_t total_count{SnapshotCommitmentCount(params, rotation_enabled)}; + const size_t expected_active{static_cast(params.signingActiveQuorumCount)}; + const size_t active_count{ReadBoundedCompactSize(s, expected_active, "active commitments")}; + active_commitments.reserve(active_count); + for (size_t i{0}; i < active_count; ++i) { + active_commitments.emplace_back(ReadMinedQuorumCommitment(s, params)); + } + const size_t safety_count{ReadBoundedCompactSize(s, total_count - expected_active, "safety commitments")}; + safety_commitments.reserve(safety_count); + for (size_t i{0}; i < safety_count; ++i) { + safety_commitments.emplace_back(ReadMinedQuorumCommitment(s, params)); + } + const size_t snapshot_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_ROTATION_CYCLES, "rotation snapshots")}; + rotation_snapshots.reserve(snapshot_count); + for (size_t i{0}; i < snapshot_count; ++i) rotation_snapshots.emplace_back(ReadRotationSnapshot(s, params)); +} + +template +void CEvoSnapshot::Serialize(Stream& s) const +{ + auto sorted_quorums{quorums}; + auto sorted_history{historical_mn_list_diffs}; + auto sorted_modifiers{quorum_modifiers}; + std::sort(sorted_quorums.begin(), sorted_quorums.end(), + [](const auto& a, const auto& b) { return a.llmq_type < b.llmq_type; }); + std::sort(sorted_history.begin(), sorted_history.end(), [](const auto& a, const auto& b) { + return std::tie(a.height, a.block_hash) > std::tie(b.height, b.block_hash); + }); + std::sort(sorted_modifiers.begin(), sorted_modifiers.end(), [](const auto& a, const auto& b) { + return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); + }); + s << version << base_block_hash; + SerializeCanonicalMNList(s, mn_list); + s << sorted_quorums; + WriteCompactSize(s, sorted_history.size()); + for (const auto& entry : sorted_history) { + s << entry.previous_block_hash << entry.block_hash << entry.height << entry.total_registered_count << entry.canonical_list_hash; + SerializeCanonicalMNListDiff(s, entry.diff); + } + s << sorted_modifiers; + s << credit_pool; + WriteCompactSize(s, mnhf_signals.size()); + for (const auto& signal : mnhf_signals) s << signal; +} + +template +void CEvoSnapshot::Unserialize(Stream& s) +{ + s >> version; + if (version != EVO_SNAPSHOT_VERSION) throw std::ios_base::failure("unsupported evo snapshot version"); + s >> base_block_hash; + mn_list = UnserializeCanonicalMNList(s); + const size_t quorum_count{ReadBoundedCompactSize(s, Consensus::available_llmqs.size(), "quorum-type count")}; + quorums.reserve(quorum_count); + for (size_t i{0}; i < quorum_count; ++i) { + CQuorumSnapshotData data; + s >> data; + quorums.emplace_back(std::move(data)); + } + const size_t history_count{ReadBoundedCompactSize(s, EvoSnapshotMaxHistoricalMNLists(), + "historical MN-list count")}; + historical_mn_list_diffs.reserve(history_count); + size_t remaining_history_operations{EvoSnapshotMaxHistoricalMNOperations()}; + for (size_t i{0}; i < history_count; ++i) { + CHistoricalMNListDiff entry; + s >> entry.previous_block_hash >> entry.block_hash >> entry.height >> entry.total_registered_count >> entry.canonical_list_hash; + entry.diff = UnserializeCanonicalMNListDiff(s, remaining_history_operations); + historical_mn_list_diffs.emplace_back(std::move(entry)); + } + const size_t modifier_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_MODIFIERS, "quorum modifier count")}; + quorum_modifiers.reserve(modifier_count); + for (size_t i{0}; i < modifier_count; ++i) { + CQuorumModifier modifier; + s >> modifier; + quorum_modifiers.emplace_back(std::move(modifier)); + } + s >> credit_pool.locked >> credit_pool.currentLimit >> credit_pool.latelyUnlocked; + credit_pool.indexes.UnserializeBounded(s, EVO_SNAPSHOT_MAX_RANGES); + const size_t signal_count{ReadBoundedCompactSize(s, Consensus::MAX_VERSION_BITS_DEPLOYMENTS, "MNHF signals")}; + for (size_t i{0}; i < signal_count; ++i) { + std::pair signal; + s >> signal; + if (!mnhf_signals.emplace(signal).second) throw std::ios_base::failure("duplicate MNHF signal bit"); + } + Validate(/*require_canonical_order=*/true); +} + +/** Single SHA256 of the canonical SER_DISK/CLIENT_VERSION encoding. */ +uint256 GetEvoSnapshotHash(const CEvoSnapshot& snapshot); + +bool BuildEvoSnapshot(const CChainParams& chainparams, const ChainstateManager& chainman, + CDeterministicMNManager& dmnman, + const llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, + CCreditPoolManager& cpoolman, CMNHFManager& mnhfman, const CBlockIndex* base_index, + CEvoSnapshot& snapshot, std::string& error) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + +struct CQuorumReconstructionHeight { + Consensus::LLMQType llmq_type; + bool rotation; + int quorum_height; + int work_height; +}; + +/** Pure conservative reconstruction horizon for the supplied enabled types. */ +std::vector EvoSnapshotReconstructionHeights( + int base_height, const std::vector& enabled_llmqs); + +/** Apply the complete diff chain and return lists keyed by target block hash. */ +bool ReconstructHistoricalMNLists(const CEvoSnapshot& snapshot, + std::map& lists, std::string& error); + +/** Validate all snapshot invariants requiring the block index or deployments. */ +bool ValidateEvoSnapshotAgainstChain(const CEvoSnapshot& snapshot, const ChainstateManager& chainman, + const CBlockIndex* base_index, std::string& error) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + +/** Pure CbTx checks over already-built snapshot content. */ +bool VerifyEvoSnapshotCbTx(const CEvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error); + +} // namespace evo + +#endif // BITCOIN_EVO_SNAPSHOT_H diff --git a/src/evo/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 73c08df28492..668691cdc717 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -787,7 +788,10 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const LogPrint(BCLog::BENCHMARK, " - m_qblockman.ProcessBlock: %.2fms [%.2fs]\n", 0.001 * (nTime5 - nTime4), nTimeQuorum * 0.000001); - CDeterministicMNList mn_list; + // Even before DIP3, bind the canonical empty list to the block so the + // independently derived completion hash has the same identity as an + // empty evo snapshot section. + CDeterministicMNList mn_list{pindex->GetBlockHash(), pindex->nHeight, 0}; if (DeploymentActiveAt(*pindex, m_consensus_params, Consensus::DEPLOYMENT_DIP0003)) { if (!BuildNewListFromBlock(block, pindex->pprev, view, true, state, mn_list)) { // pass the state returned by the function above diff --git a/src/llmq/blockprocessor.cpp b/src/llmq/blockprocessor.cpp index 58d7ac546548..9f72006d92cd 100644 --- a/src/llmq/blockprocessor.cpp +++ b/src/llmq/blockprocessor.cpp @@ -685,6 +685,33 @@ std::pair CQuorumBlockProcessor::GetMinedCommitment(C return ret; } +bool CQuorumBlockProcessor::SeedMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorum_hash, + const CFinalCommitment& commitment, + const uint256& mined_block_hash) +{ + AssertLockHeld(::cs_main); + const auto llmq_params = Params().GetLLMQ(llmqType); + const CBlockIndex* mined_index = m_chainman.m_blockman.LookupBlockIndex(mined_block_hash); + const CBlockIndex* quorum_base_index = m_chainman.m_blockman.LookupBlockIndex(quorum_hash); + if (!llmq_params || mined_index == nullptr || quorum_base_index == nullptr) return false; + if (!m_evoDb.WriteDerived( + std::make_pair(DB_MINED_COMMITMENT, std::make_pair(llmqType, quorum_hash)), + std::make_pair(commitment, mined_block_hash))) { + return false; + } + + // Replay ProcessCommitment's iteration index exactly. These entries drive + // the first post-snapshot CbTx quorum-merkle-root calculation. + if (IsQuorumRotationEnabled(*llmq_params, quorum_base_index)) { + m_evoDb.Write(BuildInversedHeightKeyIndexed(llmqType, mined_index->nHeight, + int(commitment.quorumIndex)), + quorum_base_index->nHeight); + } else { + m_evoDb.Write(BuildInversedHeightKey(llmqType, mined_index->nHeight), quorum_base_index->nHeight); + } + return true; +} + // The returned quorums are in reversed order, so the most recent one is at index 0 std::vector CQuorumBlockProcessor::GetMinedCommitmentsUntilBlock(Consensus::LLMQType llmqType, gsl::not_null pindex, size_t maxCount) const { diff --git a/src/llmq/blockprocessor.h b/src/llmq/blockprocessor.h index bb11cd67ba58..1feb52f91e89 100644 --- a/src/llmq/blockprocessor.h +++ b/src/llmq/blockprocessor.h @@ -126,6 +126,10 @@ class CQuorumBlockProcessor bool HasMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorumHash, const CChain& chain) const EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !minableCommitmentsCs); std::pair GetMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorumHash) const; + /** Seed a mined commitment in the current EvoDB transaction. */ + bool SeedMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorum_hash, + const CFinalCommitment& commitment, const uint256& mined_block_hash) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main); /** * Serialized hashes of the commitments mined for the quorums active as of pindexPrev. diff --git a/src/llmq/snapshot.cpp b/src/llmq/snapshot.cpp index 51d3a44007b8..661777127c5a 100644 --- a/src/llmq/snapshot.cpp +++ b/src/llmq/snapshot.cpp @@ -317,10 +317,42 @@ void CQuorumSnapshotManager::StoreSnapshotForBlock(const Consensus::LLMQType llm { auto snapshotHash = ::SerializeHash(std::make_pair(llmqType, pindex->GetBlockHash())); - // LOCK(::cs_main); - AssertLockNotHeld(m_evoDb.cs); - LOCK2(snapshotCacheCs, m_evoDb.cs); - m_evoDb.GetRawDB().Write(std::make_pair(DB_QUORUM_SNAPSHOT, snapshotHash), snapshot); + if (!m_evoDb.WriteDerived(std::make_pair(DB_QUORUM_SNAPSHOT, snapshotHash), snapshot)) { + throw std::runtime_error("EvoDB quorum snapshot payload mismatch"); + } + LOCK(snapshotCacheCs); quorumSnapshotCache.insert(snapshotHash, snapshot); } + +bool CQuorumSnapshotManager::SeedSnapshotForBlock(const Consensus::LLMQType llmqType, const CBlockIndex* pindex, + const CQuorumSnapshot& snapshot) +{ + const auto snapshot_hash = ::SerializeHash(std::make_pair(llmqType, pindex->GetBlockHash())); + return m_evoDb.WriteDerived(std::make_pair(DB_QUORUM_SNAPSHOT, snapshot_hash), snapshot); +} + +bool CQuorumSnapshotManager::SeedQuorumModifier(Consensus::LLMQType llmq_type, + const uint256& work_block_hash, + const uint256& modifier) +{ + return m_evoDb.WriteDerived(std::make_tuple(std::string_view{"llmq_M3"}, llmq_type, work_block_hash), modifier); +} + +std::optional CQuorumSnapshotManager::GetSeededQuorumModifier( + Consensus::LLMQType llmq_type, const uint256& work_block_hash) const +{ + uint256 modifier; + if (!m_evoDb.Read(std::make_tuple(std::string_view{"llmq_M3"}, llmq_type, work_block_hash), modifier)) { + return std::nullopt; + } + return modifier; +} + +void CQuorumSnapshotManager::InvalidateSnapshotCacheForBlock(Consensus::LLMQType llmq_type, + const uint256& block_hash) +{ + const auto snapshot_hash{::SerializeHash(std::make_pair(llmq_type, block_hash))}; + LOCK(snapshotCacheCs); + quorumSnapshotCache.erase(snapshot_hash); +} } // namespace llmq diff --git a/src/llmq/snapshot.h b/src/llmq/snapshot.h index 7691dbf19286..b66ccbb6ed88 100644 --- a/src/llmq/snapshot.h +++ b/src/llmq/snapshot.h @@ -237,6 +237,15 @@ class CQuorumSnapshotManager std::optional GetSnapshotForBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex); void StoreSnapshotForBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex, const CQuorumSnapshot& snapshot); + /** Seed EvoDB without publishing state to the shared NORMAL-chainstate cache. */ + bool SeedSnapshotForBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex, + const CQuorumSnapshot& snapshot); + /** Seed/read the exact v20 score modifier keyed by type and work block. */ + bool SeedQuorumModifier(Consensus::LLMQType llmq_type, const uint256& work_block_hash, + const uint256& modifier); + std::optional GetSeededQuorumModifier(Consensus::LLMQType llmq_type, + const uint256& work_block_hash) const; + void InvalidateSnapshotCacheForBlock(Consensus::LLMQType llmq_type, const uint256& block_hash); }; } // namespace llmq diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index be172e2ee813..02bce1ac56c8 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -22,6 +23,7 @@ #include #include #include +#include /** * Forward declarations @@ -95,8 +97,8 @@ uint256 GetHashModifierFromWorkBlock(const Consensus::LLMQParams& llmqParams, co return ::SerializeHash(std::make_pair(llmqParams.type, pWorkBlockIndex->GetBlockHash())); } -uint256 GetHashModifier(const Consensus::LLMQParams& llmqParams, const Consensus::Params& consensus_params, - gsl::not_null pCycleQuorumBaseBlockIndex) +uint256 CalculateHashModifier(const Consensus::LLMQParams& llmqParams, const Consensus::Params& consensus_params, + gsl::not_null pCycleQuorumBaseBlockIndex) { ASSERT_IF_DEBUG(pCycleQuorumBaseBlockIndex->nHeight % llmqParams.dkgInterval == 0); const CBlockIndex* pWorkBlockIndex = pCycleQuorumBaseBlockIndex->GetAncestor(pCycleQuorumBaseBlockIndex->nHeight - llmq::WORK_DIFF_DEPTH); @@ -113,6 +115,22 @@ uint256 GetHashModifier(const Consensus::LLMQParams& llmqParams, const Consensus return ::SerializeHash(std::make_pair(llmqParams.type, pCycleQuorumBaseBlockIndex->GetBlockHash())); } +uint256 GetHashModifier(const Consensus::LLMQParams& llmq_params, const Consensus::Params& consensus_params, + gsl::not_null cycle_index, + const llmq::CQuorumSnapshotManager* snapshot_manager) +{ + const CBlockIndex* work_index{cycle_index->GetAncestor(cycle_index->nHeight - llmq::WORK_DIFF_DEPTH)}; + if (snapshot_manager != nullptr && work_index != nullptr) { + if (const auto seeded{snapshot_manager->GetSeededQuorumModifier(llmq_params.type, work_index->GetBlockHash())}) { + if (WITH_LOCK(::cs_main, return (work_index->nStatus & BLOCK_HAVE_DATA) == 0;)) return *seeded; + const uint256 recomputed{CalculateHashModifier(llmq_params, consensus_params, cycle_index)}; + if (recomputed != *seeded) throw evo::SnapshotStateMismatchError("seeded quorum score modifier mismatch"); + return recomputed; + } + } + return CalculateHashModifier(llmq_params, consensus_params, cycle_index); +} + std::vector CalculateScoresForQuorum(QuorumMembers&& dmns, const uint256& modifier, const bool onlyEvoNodes) { std::vector scores; @@ -186,6 +204,7 @@ QuorumMembers CalculateQuorum(List&& mn_list, const uint256& modifier, size_t ma std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LLMQParams& llmqParams, CDeterministicMNManager& dmnman, + const llmq::CQuorumSnapshotManager& qsnapman, const Consensus::Params& consensus_params, const CBlockIndex* pCycleQuorumBaseBlockIndex, const llmq::CQuorumSnapshot& snapshot, int nHeight) @@ -200,7 +219,7 @@ std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LL const CBlockIndex* pWorkBlockIndex = pCycleQuorumBaseBlockIndex->GetAncestor( pCycleQuorumBaseBlockIndex->nHeight - llmq::WORK_DIFF_DEPTH); auto mn_list = dmnman.GetListForBlock(pWorkBlockIndex); - const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex); + const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex, &qsnapman); auto sortedAllMns = CalculateQuorum(mn_list, modifier); std::vector usedMNs; @@ -288,7 +307,8 @@ std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LL } QuorumMembers ComputeQuorumMembers(Consensus::LLMQType llmqType, const CChainParams& chainparams, - const CDeterministicMNList& mn_list, const CBlockIndex* pQuorumBaseBlockIndex) + const CDeterministicMNList& mn_list, const CBlockIndex* pQuorumBaseBlockIndex, + const llmq::CQuorumSnapshotManager* qsnapman) { bool EvoOnly = (chainparams.GetConsensus().llmqTypePlatform == llmqType) && DeploymentActiveAfter(pQuorumBaseBlockIndex, chainparams.GetConsensus(), Consensus::DEPLOYMENT_V19); @@ -299,7 +319,8 @@ QuorumMembers ComputeQuorumMembers(Consensus::LLMQType llmqType, const CChainPar return {}; } - const auto modifier = GetHashModifier(llmq_params_opt.value(), chainparams.GetConsensus(), pQuorumBaseBlockIndex); + const auto modifier = GetHashModifier(llmq_params_opt.value(), chainparams.GetConsensus(), pQuorumBaseBlockIndex, + qsnapman); return CalculateQuorum(mn_list, modifier, llmq_params_opt->size, EvoOnly); } @@ -315,7 +336,7 @@ void BuildQuorumSnapshot(const Consensus::LLMQParams& llmqParams, const Consensu const auto allMnsTotal = allMns.GetCounts().total(); quorumSnapshot.activeQuorumMembers.resize(allMnsTotal); - const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex); + const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex, nullptr); auto sortedAllMns = CalculateQuorum(allMns, modifier); LogPrint(BCLog::LLMQ, "BuildQuorumSnapshot h[%d] numMns[%d]\n", pCycleQuorumBaseBlockIndex->nHeight, @@ -502,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, @@ -545,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) : @@ -630,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 std::map> mapQuorumMembers GUARDED_BY(cs_members); @@ -700,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); @@ -720,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); @@ -728,6 +758,15 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame return quorumMembers; } +QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) +{ + // A SnapshotStateMismatchError from a seeded-modifier disagreement + // propagates to the caller. Production code does not seed modifiers yet; + // the load-time integration later in the series routes this into the + // controlled invalid-snapshot path. + return GetAllQuorumMembersInternal(llmqType, util_params, reset_cache); +} + uint256 DeterministicOutboundConnection(const uint256& proTxHash1, const uint256& proTxHash2) { // We need to deterministically select who is going to initiate the connection. The naive way would be to simply diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 01ab95316117..4082066c7914 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -43,6 +43,11 @@ struct UtilParameters { }; namespace utils { +/** Normal consensus modifier calculation; snapshot overrides are internal to reconstruction. */ +uint256 GetQuorumHashModifier(const Consensus::LLMQParams& llmq_params, + const Consensus::Params& consensus_params, + gsl::not_null cycle_quorum_base_index); + struct BlsCheck { CBLSSignature m_sig; std::vector m_pubkeys; diff --git a/src/serialize.h b/src/serialize.h index 6f266311fa87..cffce70bc672 100644 --- a/src/serialize.h +++ b/src/serialize.h @@ -465,7 +465,7 @@ void ReadFixedBitSet(Stream& s, std::vector& vec, size_t size) vec[p] = (vBytes[p / 8] & (1 << (p % 8))) != 0; if (vBytes.size() * 8 != size) { size_t rem = vBytes.size() * 8 - size; - uint8_t m = ~(uint8_t)(0xff >> rem); + const auto m{static_cast(~(0xffU >> rem))}; if (vBytes[vBytes.size() - 1] & m) { throw std::ios_base::failure("Out-of-range bits set"); } diff --git a/src/streams.h b/src/streams.h index ae2679b97826..bac54515a518 100644 --- a/src/streams.h +++ b/src/streams.h @@ -62,6 +62,7 @@ class OverrideStream int GetVersion() const { return nVersion; } int GetType() const { return nType; } + Stream& GetStream() { return *stream; } size_t size() const { return stream->size(); } void ignore(size_t size) { return stream->ignore(size); } }; diff --git a/src/test/evo_netinfo_tests.cpp b/src/test/evo_netinfo_tests.cpp index b9f30eb1081a..adff9e874be8 100644 --- a/src/test/evo_netinfo_tests.cpp +++ b/src/test/evo_netinfo_tests.cpp @@ -646,4 +646,28 @@ BOOST_FIXTURE_TEST_CASE(extnetinfo_validate_deser, RegTestingSetup) } } +BOOST_AUTO_TEST_CASE(domain_port_wire_compatibility) +{ + DomainPort domain; + BOOST_REQUIRE_EQUAL(domain.Set("example.com", 443), DomainPort::Status::Success); + + CDataStream encoded{SER_NETWORK, CLIENT_VERSION}; + encoded << domain; + CDataStream expected{SER_NETWORK, CLIENT_VERSION}; + expected << std::string{"example.com"} << Using>(uint16_t{443}); + BOOST_CHECK_EQUAL_COLLECTIONS(encoded.begin(), encoded.end(), expected.begin(), expected.end()); + + CDataStream oversized{SER_NETWORK, CLIENT_VERSION}; + oversized << NetInfoEntry::NetInfoType::Domain; + constexpr size_t MAX_DOMAIN_LENGTH{253}; + WriteCompactSize(oversized, MAX_DOMAIN_LENGTH + 1); + const std::string oversized_addr(MAX_DOMAIN_LENGTH + 1, 'a'); + oversized.write(MakeByteSpan(oversized_addr)); + oversized << Using>(uint16_t{443}); + + NetInfoEntry entry; + BOOST_CHECK_EXCEPTION(oversized >> entry, std::ios_base::failure, + [](const auto& e) { return std::string{e.what()}.find("String length limit exceeded") != std::string::npos; }); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/evo_snapshot_tests.cpp b/src/test/evo_snapshot_tests.cpp new file mode 100644 index 000000000000..b7ed33a68c72 --- /dev/null +++ b/src/test/evo_snapshot_tests.cpp @@ -0,0 +1,1260 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace { + +uint256 H(uint8_t value) +{ + uint256 hash; + hash.begin()[0] = value; + return hash; +} + +uint256 CollidingH(uint8_t suffix) +{ + uint256 hash; + std::fill_n(hash.begin(), 8, 0xa5); + hash.begin()[8] = suffix; + return hash; +} + +uint160 H160(uint8_t value) +{ + uint160 hash; + hash.begin()[0] = value; + return hash; +} + +CDeterministicMNCPtr MN(uint64_t internal_id, uint8_t hash_suffix, MnType type, int version, uint8_t address_tag) +{ + auto state{std::make_shared()}; + state->nVersion = version; + state->nRegisteredHeight = 10 + internal_id; + state->nLastPaidHeight = 20 + internal_id; + state->nPoSePenalty = internal_id; + state->keyIDOwner = CKeyID{H160(address_tag)}; + state->keyIDVoting = CKeyID{H160(address_tag + 20)}; + state->scriptPayout = CScript{} << OP_RETURN << std::vector{address_tag, 1}; + state->scriptOperatorPayout = CScript{} << OP_RETURN << std::vector{address_tag, 2}; + state->netInfo = NetInfoInterface::MakeNetInfo(version); + BOOST_REQUIRE_EQUAL(state->netInfo->AddEntry(NetInfoPurpose::CORE_P2P, + strprintf("1.1.1.%d:%d", address_tag, Params().GetDefaultPort())), + NetInfoStatus::Success); + if (type == MnType::Evo) { + state->platformNodeID = H160(address_tag + 40); + BOOST_REQUIRE_EQUAL(state->netInfo->AddEntry(NetInfoPurpose::PLATFORM_P2P, + strprintf("2.2.2.%d:26657", address_tag)), + NetInfoStatus::Success); + BOOST_REQUIRE_EQUAL(state->netInfo->AddEntry(NetInfoPurpose::PLATFORM_HTTPS, + strprintf("evo%d.example.org:443", address_tag)), + NetInfoStatus::Success); + } + + auto dmn{std::make_shared(internal_id, type)}; + dmn->proTxHash = CollidingH(hash_suffix); + dmn->collateralOutpoint = COutPoint(H(address_tag + 80), internal_id); + dmn->nOperatorReward = address_tag * 10; + state->UpdateConfirmedHash(dmn->proTxHash, H(address_tag + 100)); + dmn->pdmnState = std::move(state); + return dmn; +} + +CDeterministicMNList MNList(const uint256& block_hash, int height, bool reverse) +{ + CDeterministicMNList list{block_hash, height, 10}; + std::vector mns{ + MN(2, 3, MnType::Regular, ProTxVersion::LegacyBLS, 3), + MN(5, 1, MnType::Evo, ProTxVersion::ExtAddr, 5), + MN(7, 2, MnType::Regular, ProTxVersion::LegacyBLS, 7), + }; + if (reverse) std::reverse(mns.begin(), mns.end()); + for (const auto& dmn : mns) list.AddMN(dmn, /*fBumpTotalCount=*/false); + return list; +} + +evo::CMinedQuorumCommitment Commitment(Consensus::LLMQType type, uint8_t quorum, uint8_t mined, bool rotated, + int16_t index = 0) +{ + llmq::CFinalCommitment commitment; + commitment.nVersion = rotated ? llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION + : llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION; + commitment.llmqType = type; + commitment.quorumHash = H(quorum); + commitment.quorumIndex = index; + const auto& params{evo::SnapshotLLMQParams(type)}; + commitment.signers.resize(params.size); + commitment.validMembers.resize(params.size); + return {H(quorum), H(quorum + 120), std::move(commitment), H(mined)}; +} + +evo::CEvoSnapshot SyntheticSnapshot(bool reverse_representation = false) +{ + evo::CEvoSnapshot snapshot; + snapshot.base_block_hash = H(42); + snapshot.mn_list = MNList(snapshot.base_block_hash, 500, reverse_representation); + snapshot.credit_pool.locked = 123456; + snapshot.credit_pool.currentLimit = 700; + snapshot.credit_pool.latelyUnlocked = 11; + if (reverse_representation) { + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(15)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(8)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(7)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(9)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Remove(9)); + snapshot.mnhf_signals.emplace(9, 30); + snapshot.mnhf_signals.emplace(2, 12); + } else { + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(7)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(8)); + BOOST_REQUIRE(snapshot.credit_pool.indexes.Add(15)); + snapshot.mnhf_signals.emplace(2, 12); + snapshot.mnhf_signals.emplace(9, 30); + } + + evo::CQuorumSnapshotData plain; + plain.llmq_type = Consensus::LLMQType::LLMQ_TEST; + plain.active_commitments = {Commitment(plain.llmq_type, 11, 51, false), Commitment(plain.llmq_type, 12, 52, false)}; + plain.safety_commitments = {Commitment(plain.llmq_type, 10, 50, false)}; + + evo::CQuorumSnapshotData rotated; + rotated.llmq_type = Consensus::LLMQType::LLMQ_TEST_DIP0024; + rotated.rotation_enabled = true; + rotated.active_commitments = {Commitment(rotated.llmq_type, 31, 71, true, 0), + Commitment(rotated.llmq_type, 32, 72, true, 1)}; + rotated.safety_commitments = {Commitment(rotated.llmq_type, 21, 61, true, 0), + Commitment(rotated.llmq_type, 22, 62, true, 1)}; + for (uint8_t i{1}; i <= evo::EVO_SNAPSHOT_ROTATION_CYCLES; ++i) { + const auto mode{i == 2 ? SnapshotSkipMode::MODE_SKIPPING_ENTRIES : SnapshotSkipMode::MODE_NO_SKIPPING}; + rotated.rotation_snapshots.push_back( + {H(40 + i), H(100 + i), llmq::CQuorumSnapshot{{true, false, true, false}, mode, i == 2 ? std::vector{1} : std::vector{}}}); + } + + snapshot.quorums = {std::move(plain), std::move(rotated)}; + std::set work_hashes; + std::set> modifier_keys; + for (const auto& data : snapshot.quorums) { + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + work_hashes.insert(entry.work_block_hash); + modifier_keys.emplace(data.llmq_type, entry.work_block_hash); + } + } + for (const auto& entry : data.rotation_snapshots) { + work_hashes.insert(entry.work_block_hash); + modifier_keys.emplace(data.llmq_type, entry.work_block_hash); + } + } + CDeterministicMNList previous{snapshot.mn_list}; + uint256 previous_hash{snapshot.base_block_hash}; + int height{499}; + for (const auto& work_hash : work_hashes) { + auto list{MNList(work_hash, height--, reverse_representation)}; + snapshot.historical_mn_list_diffs.push_back({previous_hash, work_hash, list.GetHeightForSnapshotCodec(), + list.GetTotalRegisteredCount(), evo::CanonicalMNListHash(list), + previous.BuildDiff(list)}); + previous_hash = work_hash; + previous = std::move(list); + } + for (const auto& [type, work_hash] : modifier_keys) { + snapshot.quorum_modifiers.push_back({type, work_hash, H(static_cast(150 + snapshot.quorum_modifiers.size()))}); + } + if (reverse_representation) { + std::reverse(snapshot.quorums.begin(), snapshot.quorums.end()); + std::reverse(snapshot.historical_mn_list_diffs.begin(), snapshot.historical_mn_list_diffs.end()); + std::reverse(snapshot.quorum_modifiers.begin(), snapshot.quorum_modifiers.end()); + for (auto& data : snapshot.quorums) { + std::reverse(data.active_commitments.begin(), data.active_commitments.end()); + std::reverse(data.safety_commitments.begin(), data.safety_commitments.end()); + std::reverse(data.rotation_snapshots.begin(), data.rotation_snapshots.end()); + } + } + return snapshot; +} + +CDataStream SerializeSnapshot(const evo::CEvoSnapshot& snapshot) +{ + CDataStream stream{SER_DISK, CLIENT_VERSION}; + stream << snapshot; + return stream; +} + +void CheckInvalid(evo::CEvoSnapshot snapshot) +{ + BOOST_CHECK_THROW(snapshot.Validate(), std::ios_base::failure); +} + +//! 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) +{ + const auto forward{SyntheticSnapshot()}; + const auto reverse{SyntheticSnapshot(/*reverse_representation=*/true)}; + const auto forward_bytes{SerializeSnapshot(forward)}; + const auto reverse_bytes{SerializeSnapshot(reverse)}; + BOOST_CHECK_EQUAL_COLLECTIONS(forward_bytes.begin(), forward_bytes.end(), reverse_bytes.begin(), reverse_bytes.end()); + BOOST_CHECK(evo::CanonicalMNListHash(forward.mn_list) == evo::CanonicalMNListHash(reverse.mn_list)); + BOOST_CHECK(GetEvoSnapshotHash(forward) == GetEvoSnapshotHash(reverse)); + + CDataStream input{forward_bytes}; + evo::CEvoSnapshot decoded; + input >> decoded; + BOOST_CHECK(input.empty()); + const auto decoded_bytes{SerializeSnapshot(decoded)}; + BOOST_CHECK_EQUAL_COLLECTIONS(forward_bytes.begin(), forward_bytes.end(), decoded_bytes.begin(), decoded_bytes.end()); + BOOST_CHECK(evo::CanonicalMNListHash(decoded.mn_list) == evo::CanonicalMNListHash(forward.mn_list)); + BOOST_CHECK_EQUAL(decoded.mn_list.GetCounts().total(), 3U); + BOOST_CHECK_EQUAL(decoded.historical_mn_list_diffs.size(), forward.historical_mn_list_diffs.size()); + BOOST_CHECK(decoded.credit_pool.indexes.Contains(7)); + BOOST_CHECK(decoded.credit_pool.indexes.Contains(8)); + BOOST_CHECK(decoded.credit_pool.indexes.Contains(15)); + BOOST_CHECK(decoded.mnhf_signals == forward.mnhf_signals); + + for (const auto internal_id : {2U, 5U, 7U}) { + const auto original{forward.mn_list.GetMNByInternalId(internal_id)}; + BOOST_REQUIRE(original); + const auto by_hash{decoded.mn_list.GetMN(original->proTxHash)}; + const auto by_id{decoded.mn_list.GetMNByInternalId(internal_id)}; + const auto by_collateral{decoded.mn_list.GetUniquePropertyMN(original->collateralOutpoint)}; + const auto by_owner{decoded.mn_list.GetUniquePropertyMN(original->pdmnState->keyIDOwner)}; + const auto by_service{decoded.mn_list.GetMNByService(original->pdmnState->netInfo->GetPrimary())}; + BOOST_REQUIRE(by_hash); + BOOST_REQUIRE(by_id); + BOOST_REQUIRE(by_collateral); + BOOST_REQUIRE(by_owner); + BOOST_REQUIRE(by_service); + BOOST_CHECK(by_hash->proTxHash == original->proTxHash); + BOOST_CHECK(by_id->proTxHash == original->proTxHash); + BOOST_CHECK(by_collateral->proTxHash == original->proTxHash); + BOOST_CHECK(by_owner->proTxHash == original->proTxHash); + BOOST_CHECK(by_service->proTxHash == original->proTxHash); + } +} + +BOOST_FIXTURE_TEST_CASE(snapshot_identity_seeding_is_retrievable, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + const auto list{MNList(base->GetBlockHash(), base->nHeight, false)}; + const CBlockIndex* historical_index{base->GetAncestor(50)}; + const auto historical_list{MNList(historical_index->GetBlockHash(), historical_index->nHeight, true)}; + const auto indexed_commitment = [&](Consensus::LLMQType type, int quorum_height, int mined_height, + bool rotated, int16_t quorum_index = 0) { + auto entry{Commitment(type, 1, 2, rotated, quorum_index)}; + entry.quorum_base_block_hash = base->GetAncestor(quorum_height)->GetBlockHash(); + entry.commitment.quorumHash = entry.quorum_base_block_hash; + entry.mined_block_hash = base->GetAncestor(mined_height)->GetBlockHash(); + return entry; + }; + const std::vector nonrotated{ + indexed_commitment(Consensus::LLMQType::LLMQ_TEST, 48, 58, false), + indexed_commitment(Consensus::LLMQType::LLMQ_TEST, 72, 82, false), + }; + const std::vector rotated{ + indexed_commitment(Consensus::LLMQType::LLMQ_TEST_DIP0024, 72, 84, true, 0), + indexed_commitment(Consensus::LLMQType::LLMQ_TEST_DIP0024, 73, 85, true, 1), + }; + CCreditPool pool; + pool.locked = 123; + pool.currentLimit = 45; + pool.latelyUnlocked = 6; + AbstractEHFManager::Signals signals{{2, base->nHeight}}; + llmq::CQuorumSnapshot quorum_snapshot{{true, false, true}, SnapshotSkipMode::MODE_NO_SKIPPING, {}}; + + 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); + + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(list)); + BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(historical_list)); + { + LOCK(::cs_main); + for (const auto& entry : nonrotated) { + BOOST_REQUIRE(m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + entry.commitment.llmqType, entry.quorum_base_block_hash, + entry.commitment, entry.mined_block_hash)); + } + for (const auto& entry : rotated) { + BOOST_REQUIRE(m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + entry.commitment.llmqType, entry.quorum_base_block_hash, + entry.commitment, entry.mined_block_hash)); + } + } + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base, quorum_snapshot)); + BOOST_REQUIRE(m_node.chain_helper->credit_pool_manager->SeedSnapshot(base, pool)); + BOOST_REQUIRE(m_node.chain_helper->ehf_manager->SeedSignals(base, signals)); + tx->Commit(); + } + BOOST_REQUIRE(m_node.evodb->CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true)); + { + LOCK(::cs_main); + m_node.dmnman->InvalidateListCacheForBlock(base->GetBlockHash()); + m_node.dmnman->InvalidateListCacheForBlock(historical_index->GetBlockHash()); + } + + CDeterministicMNList stored_list; + CDeterministicMNList stored_historical_list; + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + stored_list = m_node.dmnman->GetListForBlock(base); + stored_historical_list = m_node.dmnman->GetListForBlock(historical_index); + } + m_node.dmnman->InvalidateListCacheForBlock(base->GetBlockHash()); + m_node.dmnman->InvalidateListCacheForBlock(historical_index->GetBlockHash()); + const auto subsequent_list{m_node.dmnman->GetListForBlock(base)}; + const auto subsequent_historical_list{m_node.dmnman->GetListForBlock(historical_index)}; + 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, {}}; + + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(seeded_list)); + BOOST_REQUIRE(m_node.chain_helper->credit_pool_manager->SeedSnapshot(base, seeded_pool)); + BOOST_REQUIRE(m_node.chain_helper->ehf_manager->SeedSignals(base, seeded_signals)); + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base, seeded_quorum)); + BOOST_CHECK(!WITH_LOCK(::cs_main, return m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + Consensus::LLMQType::LLMQ_TEST, H(200), + Commitment(Consensus::LLMQType::LLMQ_TEST, 1, 2, false).commitment, H(201)))); + // Destruction without Commit() rolls the complete scoped transaction back. + } + + CDeterministicMNList db_list; + CCreditPool db_pool; + AbstractEHFManager::Signals db_signals; + const auto quorum_hash{SerializeHash(std::make_pair(Consensus::LLMQType::LLMQ_TEST, base->GetBlockHash()))}; + llmq::CQuorumSnapshot db_quorum; + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"dmn_S3"}, base->GetBlockHash()), db_list)); + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"cpm_S"}, base->GetBlockHash()), db_pool)); + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string{"mnhf_s2"}, base->GetBlockHash()), db_signals)); + BOOST_CHECK(!m_node.evodb->Read(std::make_pair(std::string_view{"llmq_S"}, quorum_hash), db_quorum)); + + { + ConsensusParamsRestorer params_restorer{Params().GetConsensus()}; + params_restorer.Get().DIP0003Height = 1; + params_restorer.Get().V20Height = 1; + BOOST_CHECK_EQUAL(m_node.dmnman->GetListForBlock(base).GetCounts().total(), 0U); + BOOST_CHECK_EQUAL(m_node.chain_helper->credit_pool_manager->GetCreditPool(base).locked, 0); + BOOST_CHECK(m_node.chain_helper->ehf_manager->GetSignalsStage(base).empty()); + } + BOOST_CHECK(!m_node.llmq_ctx->qsnapman->GetSnapshotForBlock( + Consensus::LLMQType::LLMQ_TEST, base).has_value()); +} + +BOOST_FIXTURE_TEST_CASE(quorum_members_reconstruct_from_seeded_state_only, 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}; + { + auto tx{expected_db.BeginTransaction(EvoDbIdentity::NORMAL)}; + for (const auto& [_, list] : lists) BOOST_REQUIRE(expected_dmnman.SeedListForBlock(list)); + for (size_t i{1}; i < rotated_cycles.size(); ++i) { + expected_qsnapman.StoreSnapshotForBlock(rotated.type, rotated_cycles[i], empty_snapshot); + } + tx->Commit(); + } + const auto plain_expected{llmq::utils::GetAllQuorumMembers( + plain.type, {expected_dmnman, expected_qsnapman, *m_node.chainman, quorum}, true)}; + const auto rotated_expected{llmq::utils::GetAllQuorumMembers( + rotated.type, {expected_dmnman, expected_qsnapman, *m_node.chainman, quorum}, true)}; + BOOST_REQUIRE(!plain_expected.empty()); + BOOST_REQUIRE(!rotated_expected.empty()); + + CBLSSecretKey quorum_key; + quorum_key.MakeNewKey(); + llmq::CFinalCommitment seeded_commitment{plain, quorum->GetBlockHash()}; + seeded_commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION; + seeded_commitment.quorumPublicKey = quorum_key.GetPublicKey(); + seeded_commitment.quorumVvecHash = H(201); + const CBlockIndex* mined_index{tip->GetAncestor(98)}; + + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + for (const auto& [work, list] : lists) BOOST_REQUIRE(m_node.dmnman->SeedListForBlock(list)); + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedQuorumModifier( + plain.type, plain_work->GetBlockHash(), + llmq::utils::GetQuorumHashModifier(plain, consensus, quorum))); + for (const auto* cycle : rotated_cycles) { + const CBlockIndex* work{cycle->GetAncestor(cycle->nHeight - llmq::WORK_DIFF_DEPTH)}; + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedQuorumModifier( + rotated.type, work->GetBlockHash(), + llmq::utils::GetQuorumHashModifier(rotated, consensus, cycle))); + } + for (size_t i{1}; i < rotated_cycles.size(); ++i) { + BOOST_REQUIRE(m_node.llmq_ctx->qsnapman->SeedSnapshotForBlock( + rotated.type, rotated_cycles[i], empty_snapshot)); + } + BOOST_REQUIRE(WITH_LOCK(::cs_main, return m_node.llmq_ctx->quorum_block_processor->SeedMinedCommitment( + plain.type, quorum->GetBlockHash(), seeded_commitment, mined_index->GetBlockHash());)); + tx->Commit(); + } + + std::map saved_status; + { + LOCK(::cs_main); + for (const auto& [work, _] : lists) { + auto* mutable_work{const_cast(work)}; + saved_status.emplace(mutable_work, mutable_work->nStatus); + mutable_work->nStatus &= ~BLOCK_HAVE_DATA; + m_node.dmnman->InvalidateListCacheForBlock(work->GetBlockHash()); + } + for (size_t i{1}; i < rotated_cycles.size(); ++i) { + m_node.llmq_ctx->qsnapman->InvalidateSnapshotCacheForBlock(rotated.type, + rotated_cycles[i]->GetBlockHash()); + } + } + std::vector plain_seeded; + std::vector rotated_seeded; + std::vector scanned; + llmq::VerifyRecSigStatus recovered_sig_status{llmq::VerifyRecSigStatus::NoQuorum}; + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + plain_seeded = llmq::utils::GetAllQuorumMembers( + plain.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true); + rotated_seeded = llmq::utils::GetAllQuorumMembers( + rotated.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true); + scanned = m_node.llmq_ctx->qman->ScanQuorums(plain.type, tip, 1); + const uint256 id{H(202)}; + const uint256 msg_hash{H(203)}; + const llmq::SignHash sign_hash{plain.type, quorum->GetBlockHash(), id, msg_hash}; + recovered_sig_status = llmq::VerifyRecoveredSig( + plain.type, *m_node.llmq_ctx->qman, tip, id, msg_hash, + quorum_key.Sign(sign_hash.Get(), /*specificLegacyScheme=*/false)); + } + const auto hashes = [](const auto& members) { + std::vector result; + for (const auto& member : members) result.emplace_back(member->proTxHash); + return result; + }; + BOOST_CHECK(hashes(plain_seeded) == hashes(plain_expected)); + BOOST_CHECK(hashes(rotated_seeded) == hashes(rotated_expected)); + BOOST_REQUIRE_EQUAL(scanned.size(), 1U); + BOOST_CHECK(hashes(scanned[0]->members) == hashes(plain_expected)); + BOOST_CHECK(recovered_sig_status == llmq::VerifyRecSigStatus::Valid); + + // Prove reconstruction fails closed instead of falling through to the + // ordinary diff chain when one required seeded full list is absent. + size_t forbidden_fallbacks{0}; + m_node.dmnman->SetListSnapshotMissHookForTesting([&](const CBlockIndex* index) { + ++forbidden_fallbacks; + throw std::logic_error(strprintf("forbidden NORMAL MN-list fallback at height %d", index->nHeight)); + }); + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + m_node.evodb->Erase(std::make_pair(std::string{"dmn_S3"}, plain_work->GetBlockHash())); + m_node.dmnman->InvalidateListCacheForBlock(plain_work->GetBlockHash()); + BOOST_CHECK_THROW(llmq::utils::GetAllQuorumMembers( + plain.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true), + std::logic_error); + } + m_node.dmnman->SetListSnapshotMissHookForTesting({}); + BOOST_CHECK_EQUAL(forbidden_fallbacks, 1U); + + { + LOCK(::cs_main); + for (const auto& [work, status] : saved_status) work->nStatus = status; + } + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + const auto modifier_key{std::make_tuple(std::string_view{"llmq_M3"}, plain.type, + plain_work->GetBlockHash())}; + m_node.evodb->Erase(modifier_key); + m_node.evodb->Write(modifier_key, H(254)); + BOOST_CHECK_THROW(llmq::utils::GetAllQuorumMembers( + plain.type, {*m_node.dmnman, *m_node.llmq_ctx->qsnapman, *m_node.chainman, quorum}, true), + evo::SnapshotStateMismatchError); + } +} + +BOOST_FIXTURE_TEST_CASE(chain_validation_pre_dip3_matrix, TestChain100Setup) +{ + const CBlockIndex* base{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip())}; + BOOST_REQUIRE(base != nullptr); + evo::CEvoSnapshot snapshot; + snapshot.base_block_hash = base->GetBlockHash(); + snapshot.mn_list = CDeterministicMNList{base->GetBlockHash(), base->nHeight, 0}; + std::string error; + BOOST_CHECK(WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(snapshot, *m_node.chainman, base, error))); + + auto wrong_base{snapshot}; + wrong_base.base_block_hash = H(99); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(wrong_base, *m_node.chainman, base, error))); + + auto nonempty{snapshot}; + nonempty.credit_pool.locked = 1; + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(nonempty, *m_node.chainman, base, error))); + + 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::CEvoSnapshot active{snapshot}; + evo::CQuorumSnapshotData quorum_data; + quorum_data.llmq_type = Consensus::LLMQType::LLMQ_TEST; + const auto& params{mutable_consensus.llmqs.front()}; + const auto make_commitment = [&](int quorum_height, int mined_height) { + evo::CMinedQuorumCommitment entry; + const CBlockIndex* quorum{base->GetAncestor(quorum_height)}; + entry.quorum_base_block_hash = quorum->GetBlockHash(); + entry.work_block_hash = entry.quorum_base_block_hash; + entry.mined_block_hash = base->GetAncestor(mined_height)->GetBlockHash(); + entry.commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION; + entry.commitment.llmqType = params.type; + entry.commitment.quorumHash = entry.quorum_base_block_hash; + entry.commitment.signers.resize(params.size); + entry.commitment.validMembers.resize(params.size); + return entry; + }; + quorum_data.active_commitments = {make_commitment(72, 82), make_commitment(48, 58)}; + quorum_data.safety_commitments = {make_commitment(24, 34)}; + std::sort(quorum_data.active_commitments.begin(), quorum_data.active_commitments.end(), + [](const auto& a, const auto& b) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + }); + active.quorums = {quorum_data}; + CDeterministicMNList previous{active.mn_list}; + uint256 previous_hash{active.base_block_hash}; + for (const int height : {72, 48, 24}) { + const CBlockIndex* work{base->GetAncestor(height)}; + CDeterministicMNList list{work->GetBlockHash(), height, 0}; + active.historical_mn_list_diffs.push_back({previous_hash, work->GetBlockHash(), height, 0, + evo::CanonicalMNListHash(list), previous.BuildDiff(list)}); + active.quorum_modifiers.push_back({params.type, work->GetBlockHash(), + llmq::utils::GetQuorumHashModifier(params, mutable_consensus, work)}); + previous_hash = work->GetBlockHash(); + previous = std::move(list); + } + std::sort(active.quorum_modifiers.begin(), active.quorum_modifiers.end(), [](const auto& a, const auto& b) { + return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); + }); + const bool active_valid{WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(active, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(active_valid, error); + + auto wrong_counts{active}; + wrong_counts.quorums[0].safety_commitments.clear(); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(wrong_counts, *m_node.chainman, base, error))); + + auto non_ancestor{active}; + non_ancestor.quorums[0].active_commitments[0].quorum_base_block_hash = H(99); + non_ancestor.quorums[0].active_commitments[0].commitment.quorumHash = H(99); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(non_ancestor, *m_node.chainman, base, error))); + + // 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::CEvoSnapshot partial{snapshot}; + evo::CQuorumSnapshotData 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(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::CEvoSnapshot 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(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::CEvoSnapshot snapshot; + snapshot.base_block_hash = base->GetBlockHash(); + snapshot.mn_list = CDeterministicMNList{base->GetBlockHash(), base->nHeight, 0}; + evo::CQuorumSnapshotData data; + data.llmq_type = params.type; + data.rotation_enabled = true; + const auto commitment = [&](int quorum_height, int mined_height, int16_t quorum_index) { + evo::CMinedQuorumCommitment entry; + const CBlockIndex* quorum{base->GetAncestor(quorum_height)}; + const CBlockIndex* cycle{quorum->GetAncestor(quorum->nHeight - quorum->nHeight % params.dkgInterval)}; + entry.quorum_base_block_hash = quorum->GetBlockHash(); + entry.work_block_hash = cycle->GetAncestor(cycle->nHeight - llmq::WORK_DIFF_DEPTH)->GetBlockHash(); + entry.mined_block_hash = base->GetAncestor(mined_height)->GetBlockHash(); + entry.commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION; + entry.commitment.llmqType = params.type; + entry.commitment.quorumHash = entry.quorum_base_block_hash; + entry.commitment.quorumIndex = quorum_index; + entry.commitment.signers.resize(params.size); + entry.commitment.validMembers.resize(params.size); + return entry; + }; + data.active_commitments = {commitment(84, 86, 0), commitment(85, 87, 1)}; + data.safety_commitments = {commitment(72, 74, 0), commitment(73, 75, 1)}; + + std::map required_work; + for (const auto& required : evo::EvoSnapshotReconstructionHeights(base->nHeight, {params})) { + const int cycle_height{required.quorum_height}; + const int work_height{required.work_height}; + const CBlockIndex* cycle{base->GetAncestor(cycle_height)}; + const CBlockIndex* work{base->GetAncestor(work_height)}; + BOOST_REQUIRE(cycle != nullptr); + BOOST_REQUIRE(work != nullptr); + const size_t population{static_cast(params.size + 3)}; + data.rotation_snapshots.push_back({cycle->GetBlockHash(), work->GetBlockHash(), + llmq::CQuorumSnapshot{std::vector(population, true), SnapshotSkipMode::MODE_NO_SKIPPING, {}}}); + required_work.emplace(work->GetBlockHash(), work); + } + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* work{WITH_LOCK(::cs_main, + return m_node.chainman->m_blockman.LookupBlockIndex(entry.work_block_hash);)}; + BOOST_REQUIRE(work != nullptr); + required_work.emplace(entry.work_block_hash, work); + } + } + const auto commitment_less = [](const auto& a, const auto& b) { + return std::tie(a.quorum_base_block_hash, a.mined_block_hash) < + std::tie(b.quorum_base_block_hash, b.mined_block_hash); + }; + std::sort(data.active_commitments.begin(), data.active_commitments.end(), commitment_less); + std::sort(data.safety_commitments.begin(), data.safety_commitments.end(), commitment_less); + std::sort(data.rotation_snapshots.begin(), data.rotation_snapshots.end(), [](const auto& a, const auto& b) { + return std::tie(a.cycle_base_block_hash, a.work_block_hash) < + std::tie(b.cycle_base_block_hash, b.work_block_hash); + }); + snapshot.quorums = {std::move(data)}; + + std::vector ordered_work; + for (const auto& [_, work] : required_work) ordered_work.emplace_back(work); + std::sort(ordered_work.begin(), ordered_work.end(), [](const auto* a, const auto* b) { return a->nHeight > b->nHeight; }); + CDeterministicMNList previous{snapshot.mn_list}; + uint256 previous_hash{snapshot.base_block_hash}; + for (const auto* work : ordered_work) { + CDeterministicMNList work_list{work->GetBlockHash(), work->nHeight, 100}; + for (uint8_t i{0}; i < params.size + 3; ++i) { + work_list.AddMN(MN(20 + i, 20 + i, MnType::Regular, ProTxVersion::LegacyBLS, 20 + i), false); + } + snapshot.historical_mn_list_diffs.push_back( + {previous_hash, work->GetBlockHash(), work->nHeight, work_list.GetTotalRegisteredCount(), + evo::CanonicalMNListHash(work_list), previous.BuildDiff(work_list)}); + previous_hash = work->GetBlockHash(); + previous = std::move(work_list); + } + std::map modifier_cycles; + for (const auto* commitments : {&snapshot.quorums[0].active_commitments, &snapshot.quorums[0].safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* quorum_index{WITH_LOCK(::cs_main, + return m_node.chainman->m_blockman.LookupBlockIndex(entry.quorum_base_block_hash);)}; + const CBlockIndex* cycle{quorum_index->GetAncestor( + quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval)}; + modifier_cycles.emplace(entry.work_block_hash, cycle); + } + } + for (const auto& entry : snapshot.quorums[0].rotation_snapshots) { + modifier_cycles.emplace(entry.work_block_hash, WITH_LOCK(::cs_main, + return m_node.chainman->m_blockman.LookupBlockIndex(entry.cycle_base_block_hash);)); + } + for (const auto& [work_hash, cycle] : modifier_cycles) { + snapshot.quorum_modifiers.push_back({params.type, work_hash, + llmq::utils::GetQuorumHashModifier(params, consensus, cycle)}); + } + + std::string error; + const bool valid{WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(snapshot, *m_node.chainman, base, error))}; + BOOST_CHECK_MESSAGE(valid, error); + auto short_bitset{snapshot}; + short_bitset.quorums[0].rotation_snapshots[0].snapshot.activeQuorumMembers.pop_back(); + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(short_bitset, *m_node.chainman, base, error))); + auto bad_modifier{snapshot}; + bad_modifier.quorum_modifiers[0].modifier.begin()[0] ^= 1; + BOOST_CHECK(!WITH_LOCK(::cs_main, + return evo::ValidateEvoSnapshotAgainstChain(bad_modifier, *m_node.chainman, base, error))); +} + +BOOST_AUTO_TEST_CASE(reconstruction_horizon_height_enumeration) +{ + const auto rotated{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + const auto plain{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + const int base_height{20 * rotated.dkgInterval + 7}; + const auto heights{evo::EvoSnapshotReconstructionHeights(base_height, {rotated, plain})}; + BOOST_REQUIRE_EQUAL(heights.size(), evo::EVO_SNAPSHOT_ROTATION_CYCLES + + evo::SnapshotCommitmentCount(plain, false)); + const int rotated_h{base_height - base_height % rotated.dkgInterval}; + for (size_t i{0}; i < evo::EVO_SNAPSHOT_ROTATION_CYCLES; ++i) { + const int expected_cycle{rotated_h - static_cast(i + 1) * rotated.dkgInterval}; + BOOST_CHECK(heights[i].rotation); + BOOST_CHECK_EQUAL(heights[i].quorum_height, expected_cycle); + BOOST_CHECK_EQUAL(heights[i].work_height, expected_cycle - llmq::WORK_DIFF_DEPTH); + } + const int plain_h{base_height - base_height % plain.dkgInterval}; + for (size_t i{0}; i < evo::SnapshotCommitmentCount(plain, false); ++i) { + const auto& height{heights[evo::EVO_SNAPSHOT_ROTATION_CYCLES + i]}; + BOOST_CHECK(!height.rotation); + BOOST_CHECK_EQUAL(height.quorum_height, plain_h - static_cast(i) * plain.dkgInterval); + BOOST_CHECK_EQUAL(height.work_height, height.quorum_height - llmq::WORK_DIFF_DEPTH); + } +} + +BOOST_AUTO_TEST_CASE(rotation_bitset_larger_than_quorum_roundtrips) +{ + const auto& params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + evo::CQuorumSnapshotEntry entry; + entry.cycle_base_block_hash = H(1); + entry.work_block_hash = H(2); + entry.snapshot.activeQuorumMembers.resize(params.size + 3); + entry.snapshot.activeQuorumMembers[params.size + 1] = true; + entry.snapshot.mnSkipListMode = SnapshotSkipMode::MODE_NO_SKIPPING; + + CDataStream stream{SER_DISK, CLIENT_VERSION}; + evo::WriteRotationSnapshot(stream, entry); + const auto decoded{evo::ReadRotationSnapshot(stream, params)}; + BOOST_CHECK(stream.empty()); + BOOST_CHECK_EQUAL(decoded.snapshot.activeQuorumMembers.size(), params.size + 3U); + BOOST_CHECK(decoded.snapshot.activeQuorumMembers[params.size + 1]); +} + +BOOST_FIXTURE_TEST_CASE(populated_v3_golden_value, BasicTestingSetup) +{ + BOOST_CHECK_EQUAL(GetEvoSnapshotHash(SyntheticSnapshot()).ToString(), + "bb1985a651ed3110218a3c8d65d77c85facdc544d6b9203f0815d5785c1f01ff"); +} + +BOOST_FIXTURE_TEST_CASE(canonical_mn_reader_rejects_order_and_counter, BasicTestingSetup) +{ + BOOST_CHECK(evo::CanonicalMNListHash(CDeterministicMNList{}) == + evo::CanonicalMNListHash(CDeterministicMNList{})); + const auto write_raw = [](uint32_t total, std::vector mns) { + CDataStream stream{SER_DISK, CLIENT_VERSION}; + stream << H(42) << 42 << total; + WriteCompactSize(stream, mns.size()); + for (const auto& dmn : mns) stream << *dmn; + return stream; + }; + auto unsorted{write_raw(10, {MN(2, 2, MnType::Regular, ProTxVersion::LegacyBLS, 2), + MN(1, 1, MnType::Regular, ProTxVersion::LegacyBLS, 1)})}; + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNList(unsorted), std::ios_base::failure); + auto bad_counter{write_raw(2, {MN(2, 1, MnType::Regular, ProTxVersion::LegacyBLS, 1)})}; + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNList(bad_counter), std::ios_base::failure); +} + +BOOST_FIXTURE_TEST_CASE(diff_chain_roundtrip_and_canonical_determinism, BasicTestingSetup) +{ + const auto base{MNList(H(10), 100, false)}; + auto target{base}; + target.RemoveMN(base.GetMNByInternalId(2)->proTxHash); + target.AddMN(MN(8, 8, MnType::Regular, ProTxVersion::LegacyBLS, 8)); + for (const uint64_t id : {5, 7}) { + const auto dmn{target.GetMNByInternalId(id)}; + auto state{std::make_shared(*dmn->pdmnState)}; + state->nLastPaidHeight += static_cast(id); + target.UpdateMN(*dmn, state); + } + const auto diff{base.BuildDiff(target)}; + auto permuted{diff}; + std::reverse(permuted.addedMNs.begin(), permuted.addedMNs.end()); + std::vector> updates(permuted.updatedMNs.begin(), + permuted.updatedMNs.end()); + std::reverse(updates.begin(), updates.end()); + permuted.updatedMNs.clear(); + for (auto& update : updates) permuted.updatedMNs.emplace(std::move(update)); + + CDataStream canonical{SER_DISK, CLIENT_VERSION}; + CDataStream reordered{SER_DISK, CLIENT_VERSION}; + evo::SerializeCanonicalMNListDiff(canonical, diff); + evo::SerializeCanonicalMNListDiff(reordered, permuted); + BOOST_CHECK_EQUAL_COLLECTIONS(canonical.begin(), canonical.end(), reordered.begin(), reordered.end()); + + auto decoded{evo::UnserializeCanonicalMNListDiff(canonical)}; + auto reconstructed{base}; + reconstructed.ApplyDiffForSnapshot(H(11), 99, target.GetTotalRegisteredCount(), decoded); + target.ApplyDiffForSnapshot(H(11), 99, target.GetTotalRegisteredCount(), CDeterministicMNListDiff{}); + BOOST_CHECK(evo::CanonicalMNListHash(reconstructed) == evo::CanonicalMNListHash(target)); + BOOST_CHECK(canonical.empty()); +} + +BOOST_FIXTURE_TEST_CASE(historical_diff_decode_has_cumulative_operation_budget, BasicTestingSetup) +{ + CDeterministicMNListDiff one_removal; + one_removal.removedMns.emplace(1); + CDataStream first{SER_DISK, CLIENT_VERSION}; + CDataStream second{SER_DISK, CLIENT_VERSION}; + evo::SerializeCanonicalMNListDiff(first, one_removal); + evo::SerializeCanonicalMNListDiff(second, one_removal); + + size_t remaining_operations{1}; + const auto decoded{evo::UnserializeCanonicalMNListDiff(first, remaining_operations)}; + BOOST_CHECK_EQUAL(decoded.removedMns.size(), 1U); + BOOST_CHECK_EQUAL(remaining_operations, 0U); + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNListDiff(second, remaining_operations), + std::ios_base::failure); + BOOST_CHECK(evo::EvoSnapshotMaxHistoricalMNLists() < 2'048U); +} + +BOOST_FIXTURE_TEST_CASE(context_free_validation_matrix, BasicTestingSetup) +{ + auto snapshot{SyntheticSnapshot()}; + snapshot.version++; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.base_block_hash = H(1); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + std::reverse(snapshot.quorums.begin(), snapshot.quorums.end()); + BOOST_CHECK_THROW(snapshot.Validate(/*require_canonical_order=*/true), std::ios_base::failure); + snapshot = SyntheticSnapshot(); + std::reverse(snapshot.historical_mn_list_diffs.begin(), snapshot.historical_mn_list_diffs.end()); + BOOST_CHECK_THROW(snapshot.Validate(/*require_canonical_order=*/true), std::ios_base::failure); + snapshot = SyntheticSnapshot(); + std::reverse(snapshot.quorums[0].active_commitments.begin(), snapshot.quorums[0].active_commitments.end()); + BOOST_CHECK_THROW(snapshot.Validate(/*require_canonical_order=*/true), std::ios_base::failure); + snapshot = SyntheticSnapshot(); + std::reverse(snapshot.quorums[1].rotation_snapshots.begin(), snapshot.quorums[1].rotation_snapshots.end()); + BOOST_CHECK_THROW(snapshot.Validate(/*require_canonical_order=*/true), std::ios_base::failure); + snapshot = SyntheticSnapshot(); + snapshot.historical_mn_list_diffs[0].block_hash = H(1); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[1].rotation_snapshots[0].work_block_hash = H(1); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[0].llmq_type = Consensus::LLMQType::LLMQ_NONE; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[0].rotation_enabled = true; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[0].active_commitments.pop_back(); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[0].safety_commitments.clear(); + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[1].rotation_snapshots.pop_back(); + CheckInvalid(snapshot); + + const auto mutate_commitment = [](auto mutation) { + auto value{SyntheticSnapshot()}; + mutation(value.quorums[0].active_commitments[0]); + CheckInvalid(std::move(value)); + }; + mutate_commitment([](auto& e) { e.quorum_base_block_hash.SetNull(); }); + mutate_commitment([](auto& e) { e.mined_block_hash.SetNull(); }); + mutate_commitment([](auto& e) { e.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST_PLATFORM; }); + mutate_commitment([](auto& e) { e.commitment.quorumHash = H(99); }); + mutate_commitment([](auto& e) { e.commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION; }); + mutate_commitment([](auto& e) { e.commitment.nVersion = 99; }); + snapshot = SyntheticSnapshot(); + snapshot.quorums[1].active_commitments[1].commitment.quorumIndex = 0; + CheckInvalid(snapshot); + snapshot = SyntheticSnapshot(); + snapshot.quorums[1].active_commitments[1].commitment.quorumIndex = 2; + CheckInvalid(snapshot); + + const auto mutate_rotation = [](auto mutation) { + auto value{SyntheticSnapshot()}; + mutation(value.quorums[1].rotation_snapshots[0]); + CheckInvalid(std::move(value)); + }; + mutate_rotation([](auto& e) { e.cycle_base_block_hash.SetNull(); }); + mutate_rotation([](auto& e) { e.work_block_hash.SetNull(); }); + mutate_rotation([](auto& e) { e.snapshot.mnSkipListMode = static_cast(9); }); + mutate_rotation([](auto& e) { e.snapshot.activeQuorumMembers.resize(evo::EVO_SNAPSHOT_MAX_MNS + 1); }); + mutate_rotation([](auto& e) { e.snapshot.mnSkipList = {-1}; }); + + // A cycle's skip list accumulates across every quorum index, so lengths + // beyond a single quorum's size and negative wraparound deltas after the + // first (absolute) entry are legitimate. + auto aggregate_skips{SyntheticSnapshot()}; + auto& rotation_entry{aggregate_skips.quorums[1].rotation_snapshots[0]}; + const auto& rotation_params{evo::SnapshotLLMQParams(aggregate_skips.quorums[1].llmq_type)}; + rotation_entry.snapshot.mnSkipListMode = SnapshotSkipMode::MODE_SKIPPING_ENTRIES; + rotation_entry.snapshot.mnSkipList.assign(static_cast(rotation_params.size) + 2, 1); + rotation_entry.snapshot.mnSkipList.front() = 3; + rotation_entry.snapshot.mnSkipList.back() = -2; + BOOST_CHECK_NO_THROW(aggregate_skips.Validate()); +} + +BOOST_FIXTURE_TEST_CASE(bounded_readers_reject_claimed_sizes_first, BasicTestingSetup) +{ + CDataStream mn_stream{SER_DISK, CLIENT_VERSION}; + mn_stream << H(1) << 1 << uint32_t{0}; + WriteCompactSize(mn_stream, evo::EVO_SNAPSHOT_MAX_MNS + 1); + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNList(mn_stream), std::ios_base::failure); + BOOST_CHECK(mn_stream.empty()); + + const auto decode_quorum = [](CDataStream stream) { + evo::CQuorumSnapshotData data; + stream >> data; + }; + const auto& plain_params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST)}; + const size_t commitment_limit{evo::SnapshotCommitmentCount(plain_params, false)}; + CDataStream active{SER_DISK, CLIENT_VERSION}; + active << Consensus::LLMQType::LLMQ_TEST << false; + WriteCompactSize(active, plain_params.signingActiveQuorumCount + 1); + BOOST_CHECK_THROW(decode_quorum(active), std::ios_base::failure); + CDataStream safety{SER_DISK, CLIENT_VERSION}; + safety << Consensus::LLMQType::LLMQ_TEST << false; + WriteCompactSize(safety, 0); + WriteCompactSize(safety, commitment_limit - plain_params.signingActiveQuorumCount + 1); + BOOST_CHECK_THROW(decode_quorum(safety), std::ios_base::failure); + CDataStream rotations{SER_DISK, CLIENT_VERSION}; + rotations << Consensus::LLMQType::LLMQ_TEST_DIP0024 << true; + WriteCompactSize(rotations, 0); + WriteCompactSize(rotations, 0); + WriteCompactSize(rotations, evo::EVO_SNAPSHOT_ROTATION_CYCLES + 1); + BOOST_CHECK_THROW(decode_quorum(rotations), std::ios_base::failure); + + const auto& rotated_params{evo::SnapshotLLMQParams(Consensus::LLMQType::LLMQ_TEST_DIP0024)}; + CDataStream bitset{SER_DISK, CLIENT_VERSION}; + bitset << H(1) << H(2) << SnapshotSkipMode::MODE_NO_SKIPPING; + WriteCompactSize(bitset, evo::EVO_SNAPSHOT_MAX_MNS + 1); + BOOST_CHECK_THROW(evo::ReadRotationSnapshot(bitset, rotated_params), std::ios_base::failure); + CDataStream skip_list{SER_DISK, CLIENT_VERSION}; + skip_list << H(1) << H(2) << SnapshotSkipMode::MODE_NO_SKIPPING; + WriteCompactSize(skip_list, 0); + WriteCompactSize(skip_list, rotated_params.size + 1); + BOOST_CHECK_THROW(evo::ReadRotationSnapshot(skip_list, rotated_params), std::ios_base::failure); + + CDataStream commitment_bits{SER_DISK, CLIENT_VERSION}; + auto oversized_commitment{Commitment(Consensus::LLMQType::LLMQ_TEST, 1, 2, false)}; + oversized_commitment.commitment.signers.resize(plain_params.size + 1); + commitment_bits << Consensus::LLMQType::LLMQ_TEST << false; + WriteCompactSize(commitment_bits, 1); + commitment_bits << oversized_commitment; + evo::CQuorumSnapshotData oversized_data; + BOOST_CHECK_THROW(commitment_bits >> oversized_data, std::ios_base::failure); + // The bitset payload and mined-block hash remain unread: rejection occurs + // from the claimed count, before allocation or accepting the element. + BOOST_CHECK_GT(commitment_bits.size(), uint256::size()); + + auto oversized_payout_mn{std::const_pointer_cast( + MN(8, 8, MnType::Regular, ProTxVersion::ExtAddr, 8))}; + auto payout_state{std::make_shared(*oversized_payout_mn->pdmnState)}; + payout_state->payouts.resize(evo::EVO_SNAPSHOT_MAX_PAYOUT_SHARES + 1); + oversized_payout_mn->pdmnState = std::move(payout_state); + CDataStream payouts{SER_DISK, CLIENT_VERSION}; + payouts << H(42) << 42 << uint32_t{10}; + WriteCompactSize(payouts, 1); + payouts << *oversized_payout_mn; + BOOST_CHECK_THROW(evo::UnserializeCanonicalMNList(payouts), std::ios_base::failure); + + CDataStream wrapped_string{SER_DISK, CLIENT_VERSION}; + WriteCompactSize(wrapped_string, evo::EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS + 1); + wrapped_string << uint8_t{0x42}; + evo::SnapshotBoundedInput bounded_string{wrapped_string, evo::EVO_SNAPSHOT_MAX_MN_COMPACT_ITEMS}; + OverrideStream bounded_override{&bounded_string, SER_DISK, CLIENT_VERSION}; + std::string decoded_string; + BOOST_CHECK_EXCEPTION(bounded_override >> decoded_string, std::ios_base::failure, + [](const auto& e) { return std::string{e.what()}.find("CompactSize budget exceeded") != std::string::npos; }); + BOOST_CHECK(decoded_string.empty()); + BOOST_REQUIRE_EQUAL(wrapped_string.size(), 1U); + BOOST_CHECK_EQUAL(std::to_integer(wrapped_string.data()[0]), 0x42); + + const auto snapshot_prefix = [](CDataStream& stream) { + stream << evo::EVO_SNAPSHOT_VERSION << H(42); + evo::SerializeCanonicalMNList(stream, CDeterministicMNList{H(42), 1, 0}); + }; + CDataStream quorum_types{SER_DISK, CLIENT_VERSION}; + snapshot_prefix(quorum_types); + WriteCompactSize(quorum_types, Consensus::available_llmqs.size() + 1); + evo::CEvoSnapshot decoded; + BOOST_CHECK_THROW(quorum_types >> decoded, std::ios_base::failure); + + CDataStream history{SER_DISK, CLIENT_VERSION}; + snapshot_prefix(history); + WriteCompactSize(history, 0); + WriteCompactSize(history, evo::EvoSnapshotMaxHistoricalMNLists() + 1); + BOOST_CHECK_THROW(history >> decoded, std::ios_base::failure); + + CDataStream signals{SER_DISK, CLIENT_VERSION}; + snapshot_prefix(signals); + WriteCompactSize(signals, 0); + WriteCompactSize(signals, 0); + WriteCompactSize(signals, 0); + signals << CCreditPool{}; + WriteCompactSize(signals, Consensus::MAX_VERSION_BITS_DEPLOYMENTS + 1); + BOOST_CHECK_THROW(signals >> decoded, std::ios_base::failure); + + CDataStream ranges{SER_DISK, CLIENT_VERSION}; + snapshot_prefix(ranges); + WriteCompactSize(ranges, 0); + WriteCompactSize(ranges, 0); + WriteCompactSize(ranges, 0); + ranges << CAmount{0} << CAmount{0} << CAmount{0}; + WriteCompactSize(ranges, evo::EVO_SNAPSHOT_MAX_RANGES + 1); + BOOST_CHECK_THROW(ranges >> decoded, std::ios_base::failure); + BOOST_CHECK(ranges.empty()); + + const auto snapshot_bytes{SerializeSnapshot(SyntheticSnapshot())}; + DomainPort domain; + BOOST_REQUIRE_EQUAL(domain.Set("evo5.example.org", 443), DomainPort::Status::Success); + CDataStream encoded_domain{SER_DISK, CLIENT_VERSION}; + encoded_domain << domain; + const auto domain_pos{std::search(snapshot_bytes.begin(), snapshot_bytes.end(), + encoded_domain.begin(), encoded_domain.end())}; + BOOST_REQUIRE(domain_pos != snapshot_bytes.end()); + + CDataStream oversized_domain{SER_DISK, CLIENT_VERSION}; + const size_t domain_offset{static_cast(std::distance(snapshot_bytes.begin(), domain_pos))}; + oversized_domain.write(Span{snapshot_bytes}.first(domain_offset)); + constexpr size_t MAX_DOMAIN_LENGTH{253}; + WriteCompactSize(oversized_domain, MAX_DOMAIN_LENGTH + 1); + const std::string oversized_addr(MAX_DOMAIN_LENGTH + 1, 'a'); + oversized_domain.write(MakeByteSpan(oversized_addr)); + const size_t serialized_addr_size{encoded_domain.size() - sizeof(uint16_t)}; + oversized_domain.write(Span{snapshot_bytes}.subspan(domain_offset + serialized_addr_size)); + BOOST_CHECK_EXCEPTION(oversized_domain >> decoded, std::ios_base::failure, + [](const auto& e) { return std::string{e.what()}.find("String length limit exceeded") != std::string::npos; }); +} + +BOOST_FIXTURE_TEST_CASE(cbtx_cross_checks, BasicTestingSetup) +{ + const auto snapshot{SyntheticSnapshot()}; + CCbTx cbtx; + cbtx.nVersion = CCbTx::Version::CLSIG_AND_BALANCE; + cbtx.merkleRootMNList = snapshot.mn_list.to_sml()->CalcMerkleRoot(); + std::vector quorum_hashes; + for (const auto& data : snapshot.quorums) { + for (const auto& entry : data.active_commitments) quorum_hashes.emplace_back(SerializeHash(entry.commitment)); + } + std::sort(quorum_hashes.begin(), quorum_hashes.end()); + cbtx.merkleRootQuorums = ComputeMerkleRoot(quorum_hashes); + cbtx.creditPoolBalance = snapshot.credit_pool.locked; + + std::string error; + BOOST_CHECK(evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); + cbtx.merkleRootMNList = H(1); + BOOST_CHECK(!evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); + cbtx.merkleRootMNList = snapshot.mn_list.to_sml()->CalcMerkleRoot(); + cbtx.merkleRootQuorums = H(2); + BOOST_CHECK(!evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); + cbtx.merkleRootQuorums = ComputeMerkleRoot(quorum_hashes); + cbtx.creditPoolBalance++; + BOOST_CHECK(!evo::VerifyEvoSnapshotCbTx(snapshot, cbtx, error)); +} + +BOOST_FIXTURE_TEST_CASE(rejects_unknown_wire_version, BasicTestingSetup) +{ + auto bytes{SerializeSnapshot(SyntheticSnapshot())}; + bytes.data()[0] = std::byte{4}; + evo::CEvoSnapshot decoded; + BOOST_CHECK_THROW(bytes >> decoded, std::ios_base::failure); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/util/setup_common.cpp b/src/test/util/setup_common.cpp index a370cb1072ea..fb747bb00b3b 100644 --- a/src/test/util/setup_common.cpp +++ b/src/test/util/setup_common.cpp @@ -451,6 +451,8 @@ TestChainSetup::TestChainSetup( { 98, uint256S("0x150e127929d578d8129b77a6cb7e2e343a1379aa3feaaa9cce59e0a645756a81") }, /*TestChain100Setup=*/ { 100, uint256S("0x6ffb83129c19ebdf1ae3771be6a67fe34b35f4c956326b9ba152fac1649f65ae") }, + /*SnapshotActivationChainSetup=*/ + { 102, uint256S("0x37876f3493ac152f9a0bdf0049d85969fe3ba82745733a81c8e1afeefa16ab3b") }, /*TestChainV19BeforeActivationSetup=*/ { 103, uint256S("0x13adad9565d0ca558f5675c50e3828f4354d26b64de044ebc88686056f30faab") }, /*TestChainDIP3BeforeActivationSetup=*/ diff --git a/src/util/ranges_set.h b/src/util/ranges_set.h index d67be4919056..86e1b484976f 100644 --- a/src/util/ranges_set.h +++ b/src/util/ranges_set.h @@ -9,7 +9,10 @@ #include #include +#include +#include #include +#include /** * The CRangesSet is a datastructure that keeps efficiently numbers as set of @@ -47,6 +50,8 @@ class CRangesSet std::set ranges; public: + static constexpr uint64_t DEFAULT_MAX_RANGES{MAX_SIZE}; + /** * this function adds `value` to the datastructure. * it returns true if `add` succeed @@ -75,9 +80,48 @@ class CRangesSet */ [[nodiscard]] bool IsEmpty() const noexcept; - SERIALIZE_METHODS(CRangesSet, obj) + template + void Serialize(Stream& s) const + { + // Preserve the established canonical set encoding. + s << ranges; + } + + template + void UnserializeBounded(Stream& s, uint64_t max_ranges) + { + std::set decoded; + const uint64_t count{ReadCompactSize(s)}; + if (count > max_ranges) throw std::ios_base::failure("oversized CRangesSet range count"); + uint64_t previous_end{0}; + bool have_previous{false}; + for (uint64_t i{0}; i < count; ++i) { + Range range; + s >> range; + const bool wrapped_max{range.end == 0}; + if (!wrapped_max && range.begin >= range.end) { + throw std::ios_base::failure("invalid empty CRangesSet range"); + } + // Equality is adjacent and must have been merged; less-than is + // overlapping or unordered. Both are noncanonical and could make + // Size() underflow. + if (have_previous && (previous_end == 0 || range.begin <= previous_end)) { + throw std::ios_base::failure("noncanonical CRangesSet ranges"); + } + if (wrapped_max && i + 1 != count) { + throw std::ios_base::failure("wrapped CRangesSet range must be last"); + } + previous_end = range.end; + have_previous = true; + decoded.emplace(range); + } + ranges = std::move(decoded); + } + + template + void Unserialize(Stream& s) { - READWRITE(obj.ranges); + UnserializeBounded(s, DEFAULT_MAX_RANGES); } }; diff --git a/test/sanitizer_suppressions/ubsan b/test/sanitizer_suppressions/ubsan index 5560aada773a..b0a3d21901a0 100644 --- a/test/sanitizer_suppressions/ubsan +++ b/test/sanitizer_suppressions/ubsan @@ -32,6 +32,10 @@ implicit-unsigned-integer-truncation:test/fuzz/crypto_diff_fuzz_chacha20.cpp shift-base:*/include/c++/ shift-base:leveldb/ shift-base:minisketch/ +# Vendored immer's HAMT merge computes a bitmap shift past the hash width when +# two keys share a full 64-bit hash. Only reachable through deliberately +# colliding test keys (evo_snapshot_tests MN fixtures); harmless in immer. +shift-base:immer/ shift-base:secp256k1* shift-base:test/fuzz/crypto_diff_fuzz_chacha20.cpp # Unsigned integer overflow occurs when the result of an unsigned integer