diff --git a/src/Makefile.am b/src/Makefile.am index ad095c971a9f..698e64c91314 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -244,10 +244,13 @@ BITCOIN_CORE_H = \ evo/simplifiedmns.h \ evo/smldiff.h \ evo/snapshot.h \ + evo/snapshot_config.h \ + evo/snapshot_types.h \ evo/specialtx.h \ evo/specialtx_filter.h \ evo/specialtxman.h \ evo/types.h \ + evo/viewcache.h \ external_signer.h \ flat-database.h \ flatfile.h \ @@ -570,6 +573,8 @@ libbitcoin_node_a_SOURCES = \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ evo/snapshot.cpp \ + evo/snapshot_chain.cpp \ + evo/snapshot_config.cpp \ evo/specialtx.cpp \ evo/specialtx_filter.cpp \ evo/specialtxman.cpp \ @@ -1299,6 +1304,9 @@ libdashkernel_la_SOURCES = \ evo/providertx_util.cpp \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ + evo/snapshot.cpp \ + evo/snapshot_chain.cpp \ + evo/snapshot_config.cpp \ evo/specialtx.cpp \ evo/specialtx_filter.cpp \ evo/specialtxman.cpp \ diff --git a/src/Makefile.test_util.include b/src/Makefile.test_util.include index 91a9ca2788dc..fa9d1013f13c 100644 --- a/src/Makefile.test_util.include +++ b/src/Makefile.test_util.include @@ -11,6 +11,7 @@ TEST_UTIL_H = \ test/util/blockfilter.h \ test/util/chainstate.h \ test/util/coins.h \ + test/util/evo_snapshot.h \ test/util/json.h \ test/util/index.h \ test/util/llmq_tests.h \ diff --git a/src/chainlock/handler.cpp b/src/chainlock/handler.cpp index 7dacdd7b0fa5..0f45a5a636df 100644 --- a/src/chainlock/handler.cpp +++ b/src/chainlock/handler.cpp @@ -52,6 +52,9 @@ ChainlockHandler::ChainlockHandler(chainlock::Chainlocks& chainlocks, Chainstate std::make_unique(std::thread(util::TraceThread, "cl-schdlr", [&] { scheduler->serviceQueue(); }))}, seenChainLocks{SEEN_CHAINLOCKS_CUTOFF_SIZE, SEEN_CHAINLOCKS_MAX_SIZE} { + // Tasks are scheduled only from Start(), after this is set. + scheduler->SetTaskWrapper( + [this](const CScheduler::Function& task) { RunActiveEvoRound(m_chainman.EvoDb(), "cl-schdlr", task); }); } ChainlockHandler::~ChainlockHandler() @@ -168,12 +171,11 @@ MessageProcessingResult ChainlockHandler::ProcessNewChainLock(const NodeId from, if (!m_chainlocks.UpdateBestChainlock(hash, clsig, pindex)) return {}; if (pindex) { - scheduler->scheduleFromNow( - [&]() { - CheckActiveState(); - EnforceBestChainLock(); - }, - std::chrono::seconds{0}); + scheduler->scheduleFromNow(WithCapturedEvoView([&]() { + CheckActiveState(); + EnforceBestChainLock(); + }), + std::chrono::seconds{0}); LogPrint(BCLog::CHAINLOCKS, "ChainlockHandler::%s -- processed new CLSIG (%s), peer=%d\n", __func__, clsig.ToString(), from); @@ -195,14 +197,19 @@ void ChainlockHandler::UpdatedBlockTip(const CBlockIndex* pindexNew, const CBloc // atomic[If tryLockChainTipScheduled is false, do (set it to true] and schedule signing). if (bool expected = false; tryLockChainTipScheduled.compare_exchange_strong(expected, true)) { - scheduler->scheduleFromNow( - [&]() { - CheckActiveState(); - EnforceBestChainLock(); - Cleanup(); - tryLockChainTipScheduled = false; - }, - std::chrono::seconds{0}); + const auto unschedule{[this] { tryLockChainTipScheduled = false; }}; + scheduler->scheduleFromNow(WithCapturedEvoView( + [&, unschedule]() { + const struct Unschedule { + decltype(unschedule) f; + ~Unschedule() { f(); } + } done{unschedule}; + CheckActiveState(); + EnforceBestChainLock(); + Cleanup(); + }, + unschedule), + std::chrono::seconds{0}); } } diff --git a/src/chainlock/signing.cpp b/src/chainlock/signing.cpp index 7cf84a184520..6b717021bd53 100644 --- a/src/chainlock/signing.cpp +++ b/src/chainlock/signing.cpp @@ -37,6 +37,9 @@ ChainLockSigner::ChainLockSigner(const ChainstateManager& chainman, const chainl m_scheduler_thread{ std::make_unique(std::thread(util::TraceThread, "cls-schdlr", [&] { m_scheduler->serviceQueue(); }))} { + // Tasks are scheduled only from Start(), after this is set. + m_scheduler->SetTaskWrapper( + [this](const CScheduler::Function& task) { RunActiveEvoRound(m_chainman.EvoDb(), "cls-schdlr", task); }); } ChainLockSigner::~ChainLockSigner() { Stop(); } diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 8d5a1195363d..76e5700ba4ef 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -195,6 +195,31 @@ static void ReadRegTestLLMQInstantSendDIP0024Args(const ArgsManager& args, CChai options.llmq_dip0024_instantsend = args.GetArg("-llmqtestinstantsenddip0024", ""); } +static void ReadRegTestAssumeutxoArgs(const ArgsManager& args, CChainParams::RegTestOptions& options) +{ + for (const std::string& arg : args.GetArgs("-assumeutxodata")) { + const std::vector fields{SplitString(arg, ':')}; + const auto valid_hash = [](const std::string& value) { + return value.size() == uint256::size() * 2 && IsHex(value) && !uint256S(value).IsNull(); + }; + int32_t height; + uint32_t n_chain_tx; + if (fields.size() != 5 || !ParseInt32(fields[0], &height) || height <= 0 || !valid_hash(fields[1]) || + !valid_hash(fields[2]) || !ParseUInt32(fields[3], &n_chain_tx) || n_chain_tx == 0 || + !valid_hash(fields[4])) { + throw std::runtime_error(strprintf( + "Invalid value (%s) for -assumeutxodata=::::", + arg)); + } + options.assumeutxo_data.emplace_back(height, AssumeutxoData{ + .hash_serialized = AssumeutxoHash{uint256S(fields[1])}, + .evo_hash = EvoSnapshotHash{uint256S(fields[2])}, + .nChainTx = n_chain_tx, + .blockhash = uint256S(fields[4]), + }); + } +} + void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& options) { if (auto value = args.GetBoolArg("-fastprune")) options.fastprune = *value; @@ -206,6 +231,7 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti ReadRegTestLLMQTestArgs(args, options, Consensus::LLMQType::LLMQ_TEST_INSTANTSEND); ReadRegTestLLMQTestArgs(args, options, Consensus::LLMQType::LLMQ_TEST_PLATFORM); ReadRegTestLLMQInstantSendDIP0024Args(args, options); + ReadRegTestAssumeutxoArgs(args, options); } static void ReadDevNetSubsidyAndDiffArgs(const ArgsManager& args, CChainParams::DevNetOptions& options) @@ -323,6 +349,7 @@ void SetupChainParamsOptions(ArgsManager& argsman) { SetupChainParamsBaseOptions(argsman); + argsman.AddArg("-assumeutxodata=::::", "Add an exact AssumeUTXO snapshot authorization (regtest-only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); argsman.AddArg("-budgetparams=::", "Override masternode, budget and superblock start heights (regtest-only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); argsman.AddArg("-dip3params=:", "Override DIP3 activation and enforcement heights (regtest-only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); argsman.AddArg("-highsubsidyblocks=", "The number of blocks with a higher than normal subsidy to mine at the start of a chain. Block after that height will have fixed subsidy base. (default: 0, devnet-only)", ArgsManager::ALLOW_ANY, OptionsCategory::CHAINPARAMS); diff --git a/src/consensus/validation.h b/src/consensus/validation.h index 80d5a88b7524..66c3f4f6eee7 100644 --- a/src/consensus/validation.h +++ b/src/consensus/validation.h @@ -116,7 +116,28 @@ class ValidationState } }; +/** Why a block could not be processed against this node's own Evo state. This + * never describes the block itself, so it accompanies an error, not a + * BlockValidationResult. */ +enum class LocalEvoStateResult { + NONE = 0, //!< not a local Evo state error + UNAVAILABLE, //!< state the block needs is not available to this chainstate + BINDING, //!< the operation was not bound to this chainstate's Evo view + MISMATCH, //!< state disagrees with the snapshot, which could not be rejected +}; + class TxValidationState : public ValidationState {}; -class BlockValidationState : public ValidationState {}; +class BlockValidationState : public ValidationState +{ + LocalEvoStateResult m_local_evo_result{LocalEvoStateResult::NONE}; + +public: + bool LocalEvoStateError(LocalEvoStateResult result, const std::string& reject_reason) + { + if (IsValid()) m_local_evo_result = result; + return Error(reject_reason); + } + LocalEvoStateResult GetLocalEvoStateResult() const { return m_local_evo_result; } +}; #endif // BITCOIN_CONSENSUS_VALIDATION_H diff --git a/src/evo/cbtx.cpp b/src/evo/cbtx.cpp index 10f23bf6deb1..b8c44f080686 100644 --- a/src/evo/cbtx.cpp +++ b/src/evo/cbtx.cpp @@ -4,6 +4,8 @@ #include +#include +#include #include #include #include @@ -177,6 +179,29 @@ std::string CCbTx::ToString() const creditPoolBalance / COIN, creditPoolBalance % COIN, merkleRootAssetUnlocks.ToString()); } +std::optional DecodeCbTxPayload(const CTransaction& tx, bool legacy_bls_scheme) +{ + if (!tx.IsCoinBase() || tx.nType != TRANSACTION_COINBASE) return std::nullopt; + CDataStream ds(tx.vExtraPayload, SER_NETWORK, PROTOCOL_VERSION); + CCbTx cbtx; + try { + ds >> cbtx.nVersion >> cbtx.nHeight >> cbtx.merkleRootMNList; + if (cbtx.nVersion >= CCbTx::Version::MERKLE_ROOT_QUORUMS) { + ds >> cbtx.merkleRootQuorums; + if (cbtx.nVersion >= CCbTx::Version::CLSIG_AND_BALANCE) { + ds >> COMPACTSIZE(cbtx.bestCLHeightDiff); + ds >> CBLSSignatureVersionWrapper(cbtx.bestCLSignature, legacy_bls_scheme); + ds >> cbtx.creditPoolBalance; + if (cbtx.nVersion >= CCbTx::Version::MERKLE_ROOT_ASSETUNLOCKS) ds >> cbtx.merkleRootAssetUnlocks; + } + } + } catch (const std::exception&) { + return std::nullopt; + } + if (!ds.empty()) return std::nullopt; + return cbtx; +} + std::optional> GetNonNullCoinbaseChainlock(const CBlockIndex* pindex) { if (pindex == nullptr) { @@ -190,20 +215,31 @@ std::optional> GetNonNullCoinbaseChainlock(co CBlock block; if (!ReadBlockFromDisk(block, pindex, Params().GetConsensus())) { + // A snapshot view lacks the bodies at or below its base, where a + // coinbase ChainLock that cannot be read cannot be told from none. + if (const auto view{EvoViewScope::InnermostAny()}; + view && view->identity == EvoDbIdentity::SNAPSHOT && pindex->nHeight <= view->base_height && + !WITH_LOCK(::cs_main, return pindex->nStatus & BLOCK_HAVE_DATA)) { + throw BlockDataUnavailableError( + strprintf("coinbase of block %s at or below the snapshot base is not available", + pindex->GetBlockHash().ToString())); + } return std::nullopt; } - return GetNonNullCoinbaseChainlock(block, pindex->nHeight); + return GetNonNullCoinbaseChainlock(block, *pindex, Params().GetConsensus()); } -std::optional> GetNonNullCoinbaseChainlock(const CBlock& block, int32_t height) +std::optional> GetNonNullCoinbaseChainlock(const CBlock& block, const CBlockIndex& index, + const Consensus::Params& consensus) { - if (block.vtx.empty() || !block.vtx[0]->IsCoinBase() || block.vtx[0]->nType != TRANSACTION_COINBASE) { - return std::nullopt; - } - const auto payload = GetTxPayload(*block.vtx[0]); - if (!payload || payload->nVersion < CCbTx::Version::CLSIG_AND_BALANCE || payload->nHeight != height || - !payload->bestCLSignature.IsValid() || payload->bestCLHeightDiff >= uint32_t(height)) { + if (block.vtx.empty()) return std::nullopt; + // The signature encoding is the block's own scheme, not the one this node + // happens to be in (a background chainstate connects pre-v19 blocks in + // the legacy scheme while other threads read v19 blocks). + const auto payload{DecodeCbTxPayload(*block.vtx[0], !DeploymentActiveAt(index, consensus, Consensus::DEPLOYMENT_V19))}; + if (!payload || payload->nVersion < CCbTx::Version::CLSIG_AND_BALANCE || payload->nHeight != index.nHeight || + !payload->bestCLSignature.IsValid() || payload->bestCLHeightDiff >= static_cast(index.nHeight)) { return std::nullopt; } return std::make_pair(payload->bestCLSignature, payload->bestCLHeightDiff); diff --git a/src/evo/cbtx.h b/src/evo/cbtx.h index 2a4eca48658e..070a3f4d141a 100644 --- a/src/evo/cbtx.h +++ b/src/evo/cbtx.h @@ -17,6 +17,9 @@ class BlockValidationState; class CBlock; class CBlockIndex; +namespace Consensus { +struct Params; +} // namespace Consensus class CDeterministicMNList; class TxValidationState; struct RPCResult; @@ -83,7 +86,13 @@ bool CalcCbTxMerkleRootQuorums(const CBlock& block, const CBlockIndex* pindexPre BlockValidationState& state); uint256 CalcCbTxMerkleRootAssetUnlocks(const CBlock& block); +/** Decode a coinbase's CbTx payload with an explicit BLS scheme for its + * ChainLock signature, independent of the scheme the node is in. */ +std::optional DecodeCbTxPayload(const CTransaction& tx, bool legacy_bls_scheme); std::optional> GetNonNullCoinbaseChainlock(const CBlockIndex* pindex); -std::optional> GetNonNullCoinbaseChainlock(const CBlock& block, int32_t height); +/** The non-null ChainLock in `block`'s coinbase, decoded with the BLS scheme in + * force at `index`, the block's own index. */ +std::optional> GetNonNullCoinbaseChainlock(const CBlock& block, const CBlockIndex& index, + const Consensus::Params& consensus); #endif // BITCOIN_EVO_CBTX_H diff --git a/src/evo/creditpool.cpp b/src/evo/creditpool.cpp index cafe65b023f4..93e37c20da01 100644 --- a/src/evo/creditpool.cpp +++ b/src/evo/creditpool.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -17,6 +18,7 @@ #include #include #include +#include #include #include @@ -29,6 +31,9 @@ using node::ReadBlockFromDisk; static const std::string DB_CREDITPOOL_SNAPSHOT = "cpm_S"; +// Window entries carried by an assumeutxo snapshot, for blocks at or below its +// base. Written only into a snapshot generation. +static const std::string DB_CREDITPOOL_HISTORY = "cpm_H"; static bool GetDataFromUnlockTx(const CTransaction& tx, CAmount& toUnlock, uint64_t& index, TxValidationState& state) { @@ -58,8 +63,30 @@ struct CreditPoolDataPerBlock { CAmount unlocked{0}; std::unordered_set indexes; }; + +//! What each block body contributes, as parsed from the body. +struct BlockDataCache { + Mutex mutex; + Uint256LruHashMap blocks GUARDED_BY(mutex){ + static_cast(Params().CreditPoolPeriodBlocks()) * 2}; +}; + +BlockDataCache& GetBlockDataCache() +{ + static BlockDataCache cache; + return cache; +} } // anonymous namespace +void CCreditPoolManager::ClearBlockDataCacheForTesting() +{ + auto& cache{GetBlockDataCache()}; + LOCK(cache.mutex); + cache.blocks.clear(); +} + +static std::optional ParseCreditData(const CBlock& block, int height); + // it throws exception if anything went wrong static std::optional GetCreditDataFromBlock(const gsl::not_null block_index, const Consensus::Params& consensusParams) @@ -71,28 +98,41 @@ static std::optional GetCreditDataFromBlock(const gsl::n CreditPoolDataPerBlock blockData; - static Mutex cache_mutex; - static Uint256LruHashMap block_data_cache GUARDED_BY(cache_mutex){ - static_cast(Params().CreditPoolPeriodBlocks()) * 2}; - if (LOCK(cache_mutex); block_data_cache.get(block_index->GetBlockHash(), blockData)) { + auto& cache{GetBlockDataCache()}; + if (LOCK(cache.mutex); cache.blocks.get(block_index->GetBlockHash(), blockData)) { return blockData; } CBlock block; if (!ReadBlockFromDisk(block, block_index, consensusParams)) { + // A body this node does not have (pruned, or below an unvalidated + // snapshot base) is a local state error, never the block's fault. + if (!WITH_LOCK(::cs_main, return block_index->nStatus & BLOCK_HAVE_DATA)) { + throw BlockDataUnavailableError("failed-getcbforblock-read"); + } throw std::runtime_error("failed-getcbforblock-read"); } + auto parsed{ParseCreditData(block, block_index->nHeight)}; + if (!parsed) return std::nullopt; + blockData = std::move(*parsed); + + LOCK(cache.mutex); + cache.blocks.insert(block_index->GetBlockHash(), blockData); + return blockData; +} +static std::optional ParseCreditData(const CBlock& block, int height) +{ + CreditPoolDataPerBlock blockData; if (block.vtx.empty() || block.vtx[0]->vExtraPayload.empty() || !block.vtx[0]->IsSpecialTxVersion()) { - LogPrintf("%s: ERROR: empty CbTx for CreditPool at height=%d\n", __func__, block_index->nHeight); + LogPrintf("%s: ERROR: empty CbTx for CreditPool at height=%d\n", __func__, height); return std::nullopt; } - if (const auto opt_cbTx = GetTxPayload(block.vtx[0]->vExtraPayload); opt_cbTx) { blockData.credit_pool = opt_cbTx->creditPoolBalance; } else { - LogPrintf("%s: WARNING: No valid CbTx at height=%d\n", __func__, block_index->nHeight); + LogPrintf("%s: WARNING: No valid CbTx at height=%d\n", __func__, height); return std::nullopt; } for (const CTransactionRef& tx : block.vtx) { @@ -107,9 +147,6 @@ static std::optional GetCreditDataFromBlock(const gsl::n blockData.unlocked += unlocked; blockData.indexes.insert(index); } - - LOCK(cache_mutex); - block_data_cache.insert(block_index->GetBlockHash(), blockData); return blockData; } @@ -124,8 +161,9 @@ std::optional CCreditPoolManager::GetFromCache(const CBlockIndex& b if (!DeploymentActiveAt(block_index, m_chainman.GetConsensus(), Consensus::DEPLOYMENT_V20)) return CCreditPool{}; const uint256 block_hash = block_index.GetBlockHash(); + const EvoViewToken view{evoDb.CurrentView()}; CCreditPool pool; - if (WITH_LOCK(cache_mutex, return creditPoolCache.get(block_hash, pool))) { + if (WITH_LOCK(cache_mutex, return creditPoolCache.For(view).get(block_hash, pool))) { // The pool may have been constructed, and cached, outside a block-scoped transaction // (mempool acceptance, template creation, RPC), where its snapshot cannot be written. // Persist it from the first transaction-scoped lookup instead, or the snapshot would be @@ -133,10 +171,12 @@ std::optional CCreditPoolManager::GetFromCache(const CBlockIndex& b MaybeWriteSnapshot(block_hash, block_index.nHeight, pool); return pool; } - if (block_index.nHeight % DISK_SNAPSHOT_PERIOD == 0) { + // A snapshot base carries its full pool at whatever height the base has. + if (block_index.nHeight % DISK_SNAPSHOT_PERIOD == 0 || + (view.identity == EvoDbIdentity::SNAPSHOT && block_index.nHeight == view.base_height)) { if (evoDb.Read(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block_hash), pool)) { LOCK(cache_mutex); - creditPoolCache.insert(block_hash, pool); + creditPoolCache.For(view).insert(block_hash, pool); return pool; } } @@ -166,8 +206,87 @@ void CCreditPoolManager::MaybeWriteSnapshot(const uint256& block_hash, int heigh void CCreditPoolManager::AddToCache(const uint256& block_hash, int height, const CCreditPool& pool) { MaybeWriteSnapshot(block_hash, height, pool); + const EvoViewToken view{evoDb.CurrentView()}; LOCK(cache_mutex); - creditPoolCache.insert(block_hash, pool); + creditPoolCache.For(view).insert(block_hash, pool); +} + +bool CCreditPoolManager::SeedSnapshot(const EvoViewToken& view, const CBlockIndex* block, const CCreditPool& pool) +{ + if (!Assume(block != nullptr) || !Assume(evoDb.HasActiveTransaction())) return false; + const EvoViewScope scope{view}; + if (!evoDb.WriteDerived(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block->GetBlockHash()), pool)) return false; + LOCK(cache_mutex); + creditPoolCache.For(view).erase(block->GetBlockHash()); + return true; +} + +bool CCreditPoolManager::SeedHistory(const EvoViewToken& view, const CBlockIndex* base, + const std::vector& entries) +{ + if (!Assume(base != nullptr) || !Assume(evoDb.HasActiveTransaction()) || + !Assume(entries.size() <= static_cast(base->nHeight) + 1)) { + return false; + } + const EvoViewScope scope{view}; + const int first_height{base->nHeight - static_cast(entries.size()) + 1}; + for (size_t i{0}; i < entries.size(); ++i) { + const CBlockIndex* index{base->GetAncestor(first_height + static_cast(i))}; + if (!evoDb.WriteDerived(std::make_pair(DB_CREDITPOOL_HISTORY, index->GetBlockHash()), entries[i])) return false; + } + return true; +} + +std::optional CCreditPoolManager::ReadHistoryEntry(gsl::not_null block_index, + const Consensus::Params& consensus_params, + std::string& error) +{ + if (!DeploymentActiveAt(*block_index, consensus_params, Consensus::DEPLOYMENT_DIP0003)) return CreditHistoryEntry{}; + CBlock block; + if (!ReadBlockFromDisk(block, block_index, consensus_params)) { + error = strprintf("credit-pool window block %s is unreadable", block_index->GetBlockHash().ToString()); + return std::nullopt; + } + try { + if (const auto data{ParseCreditData(block, block_index->nHeight)}) { + return CreditHistoryEntry{data->credit_pool, data->unlocked}; + } + } catch (const std::exception& e) { + error = e.what(); + return std::nullopt; + } + error = strprintf("credit-pool window block %s has no valid CbTx", block_index->GetBlockHash().ToString()); + return std::nullopt; +} + +std::optional CCreditPoolManager::GetDistantEntry(gsl::not_null block_index) +{ + const auto& consensus{m_chainman.GetConsensus()}; + const EvoViewToken view{evoDb.CurrentView()}; + if (view.identity != EvoDbIdentity::SNAPSHOT || block_index->nHeight > view.base_height) { + const auto data{GetCreditDataFromBlock(block_index, consensus)}; + if (!data) return std::nullopt; + return CreditHistoryEntry{data->credit_pool, data->unlocked}; + } + // At or below the base the snapshot view carries the window entries. A + // body this node also has must agree with them; the carried record never + // enters the cache of entries parsed from real blocks. + if (!DeploymentActiveAt(*block_index, consensus, Consensus::DEPLOYMENT_DIP0003)) return std::nullopt; + CreditHistoryEntry carried; + if (!evoDb.Read(std::make_pair(DB_CREDITPOOL_HISTORY, block_index->GetBlockHash()), carried)) { + throw BlockDataUnavailableError( + strprintf("credit-pool window entry for block %s is not carried", block_index->GetBlockHash().ToString())); + } + if (WITH_LOCK(::cs_main, return (block_index->nStatus & BLOCK_HAVE_DATA) != 0)) { + std::string error; + const auto real{ReadHistoryEntry(block_index, consensus, error)}; + if (real && *real != carried) { + throw evo::SnapshotStateMismatchError( + strprintf("carried credit-pool window entry for block %s differs from the block", + block_index->GetBlockHash().ToString())); + } + } + return carried; } CCreditPool CCreditPoolManager::ConstructCreditPool(const gsl::not_null block_index, CCreditPool prev) @@ -203,11 +322,9 @@ CCreditPool CCreditPoolManager::ConstructCreditPool(const gsl::not_null distant_block{ - GetCreditDataFromBlock(distant_block_index, m_chainman.GetConsensus())}; - distant_block) { - distantUnlocked = distant_block->unlocked; - distantBalance = distant_block->credit_pool; + if (const auto distant{GetDistantEntry(distant_block_index)}) { + distantUnlocked = distant->unlocked; + distantBalance = distant->credit_pool; } } @@ -418,6 +535,8 @@ std::optional GetCreditPoolDiffForBlock(CCreditPoolManager& cpo // M_ERROR so the block is not marked invalid. state.Error(e.what()); return std::nullopt; + } catch (const evo::LocalStateError&) { + throw; } catch (const std::exception& e) { LogPrintf("%s -- failed: %s\n", __func__, e.what()); state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "failed-getcreditpooldiff"); diff --git a/src/evo/creditpool.h b/src/evo/creditpool.h index 060a570e17ca..53f7021bfacf 100644 --- a/src/evo/creditpool.h +++ b/src/evo/creditpool.h @@ -6,6 +6,7 @@ #define BITCOIN_EVO_CREDITPOOL_H #include +#include #include #include #include @@ -36,6 +37,16 @@ namespace llmq { class CQuorumManager; } // namespace llmq +/** One block's entry in the credit-pool sliding window: the pool balance its + * CbTx records and the amount its asset unlocks withdraw. */ +struct CreditHistoryEntry { + CAmount credit_pool{0}; + CAmount unlocked{0}; + + bool operator==(const CreditHistoryEntry&) const = default; + SERIALIZE_METHODS(CreditHistoryEntry, obj) { READWRITE(obj.credit_pool, obj.unlocked); } +}; + struct CCreditPool { CAmount locked{0}; @@ -118,7 +129,8 @@ class CCreditPoolManager private: static constexpr size_t CreditPoolCacheSize = 1000; Mutex cache_mutex; - Uint256LruHashMap creditPoolCache GUARDED_BY(cache_mutex){CreditPoolCacheSize}; + EvoViewCache> creditPoolCache GUARDED_BY(cache_mutex){ + [] { return Uint256LruHashMap{CreditPoolCacheSize}; }}; CEvoDB& evoDb; const ChainstateManager& m_chainman; @@ -173,6 +185,22 @@ class CCreditPoolManager * it can happen if there limits of withdrawal (unlock) exceed */ CCreditPool GetCreditPool(const CBlockIndex* block) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); + /** Seed the full pool at a snapshot base into `view`, inside the caller's + * open EvoDB transaction for that view. */ + bool SeedSnapshot(const EvoViewToken& view, const CBlockIndex* block, const CCreditPool& pool) + EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); + /** Seed the window entries of the blocks ending at `base`, oldest first, + * into `view`, inside the caller's open EvoDB transaction for that view. */ + bool SeedHistory(const EvoViewToken& view, const CBlockIndex* base, const std::vector& entries); + + /** A block's window entry from its body: {0, 0} before DIP3. Without a + * readable body, or with a malformed CbTx, nullopt and `error`. */ + static std::optional ReadHistoryEntry(gsl::not_null block_index, + const Consensus::Params& consensus_params, + std::string& error); + + /** Forget the block bodies parsed so far, as a restart would. */ + static void ClearBlockDataCacheForTesting(); private: std::optional GetFromCache(const CBlockIndex& block_index) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); @@ -183,6 +211,9 @@ class CCreditPoolManager CCreditPool ConstructCreditPool(const gsl::not_null block_index, CCreditPool prev) EXCLUSIVE_LOCKS_REQUIRED(!cache_mutex); + /** The entry of the block leaving the window: the carried record at or + * below a snapshot base in the snapshot view, the block body otherwise. */ + std::optional GetDistantEntry(gsl::not_null block_index); }; std::optional GetCreditPoolDiffForBlock(CCreditPoolManager& cpoolman, diff --git a/src/evo/deterministicmns.cpp b/src/evo/deterministicmns.cpp index 8241cdcab704..f72ce394a378 100644 --- a/src/evo/deterministicmns.cpp +++ b/src/evo/deterministicmns.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -686,6 +687,17 @@ CDeterministicMNManager::CDeterministicMNManager(CEvoDB& evoDb, CMasternodeMetaM CDeterministicMNManager::~CDeterministicMNManager() = default; +bool CDeterministicMNManager::SeedListForBlock(const EvoViewToken& view, const CDeterministicMNList& list) +{ + if (!Assume(m_evoDb.HasActiveTransaction())) return false; + const EvoViewScope scope{view}; + if (!m_evoDb.WriteDerived(std::make_pair(DB_LIST_SNAPSHOT, list.GetBlockHash()), list)) return false; + LOCK(cs); + View().mnListsCache.erase(list.GetBlockHash()); + View().mnListDiffsCache.erase(list.GetBlockHash()); + return true; +} + bool CDeterministicMNManager::ProcessBlock(const CBlock& block, gsl::not_null pindex, BlockValidationState& state, const CDeterministicMNList& newList, MNListUpdates& updatesRet) @@ -706,8 +718,9 @@ bool CDeterministicMNManager::ProcessBlock(const CBlock& block, gsl::not_nullpprev); + oldList = GetListForBlockInternal(pindex->pprev, view); diff = oldList.BuildDiff(newList); // A WriteDerived mismatch is local EvoDB corruption, not a statement @@ -719,14 +732,14 @@ bool CDeterministicMNManager::ProcessBlock(const CBlock& block, gsl::not_nullpprev == m_initial_snapshot_index) { + if ((nHeight % DISK_SNAPSHOT_PERIOD) == 0 || pindex->pprev == view.m_initial_snapshot_index) { if (!m_evoDb.WriteDerived(std::make_pair(DB_LIST_SNAPSHOT, newList.GetBlockHash()), newList)) { const std::string msg = strprintf("CDeterministicMNManager::%s -- EvoDB list snapshot mismatch for block %s", __func__, newList.GetBlockHash().ToString()); AbortNode(msg); return state.Error(msg); } - mnListsCache.insert_or_assign(newList.GetBlockHash(), newList); + view.mnListsCache.insert_or_assign(newList.GetBlockHash(), newList); LogPrintf("CDeterministicMNManager::%s -- Wrote snapshot. nHeight=%d, mapCurMNs.allMNsCount=%d\n", __func__, nHeight, newList.GetCounts().total()); } @@ -749,8 +762,11 @@ bool CDeterministicMNManager::ProcessBlock(const CBlock& block, gsl::not_nullnHeight; - mnListDiffsCache.emplace(pindex->GetBlockHash(), diff); - mnListsCache.insert_or_assign(newList.GetBlockHash(), newList); + view.mnListDiffsCache.emplace(pindex->GetBlockHash(), diff); + view.processed_height = std::max(view.processed_height, nHeight); + view.mnListsCache.insert_or_assign(newList.GetBlockHash(), newList); + } catch (const evo::LocalStateError&) { + throw; } catch (const std::exception& e) { LogPrintf("CDeterministicMNManager::%s -- internal error: %s\n", __func__, e.what()); return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "failed-dmn-block"); @@ -790,15 +806,16 @@ bool CDeterministicMNManager::UndoBlock(gsl::not_null pindex CDeterministicMNListDiff diff; { LOCK(cs); + auto& view{View()}; m_evoDb.Read(std::make_pair(DB_LIST_DIFF, blockHash), diff); if (diff.HasChanges()) { // need to call this before erasing - prevList = GetListForBlockInternal(pindex->pprev); + prevList = GetListForBlockInternal(pindex->pprev, view); } - mnListsCache.erase(blockHash); - mnListDiffsCache.erase(blockHash); + view.mnListsCache.erase(blockHash); + view.mnListDiffsCache.erase(blockHash); } if (diff.HasChanges()) { CDeterministicMNList curList{prevList}; @@ -820,10 +837,11 @@ void CDeterministicMNManager::UpdatedBlockTip(gsl::not_null { LOCK(cs); - tipIndex = pindex; + View().tipIndex = pindex; } -CDeterministicMNList CDeterministicMNManager::GetListForBlockInternal(gsl::not_null pindex) +CDeterministicMNList CDeterministicMNManager::GetListForBlockInternal(gsl::not_null pindex, + ViewState& view) { CDeterministicMNList snapshot; @@ -833,28 +851,41 @@ CDeterministicMNList CDeterministicMNManager::GetListForBlockInternal(gsl::not_n AssertLockHeld(cs); - if (tipIndex != nullptr && mnListsCache.size() > MAX_CACHED_LISTS) { - CleanupCache(tipIndex->nHeight); + // A view stops receiving tip notifications when its chainstate stops being + // the active one, so its tip can lag far behind the blocks it processes. + if (const int cleanup_height{std::max(view.tipIndex != nullptr ? view.tipIndex->nHeight : -1, view.processed_height)}; + cleanup_height >= 0 && view.mnListsCache.size() > MAX_CACHED_LISTS) { + CleanupCache(view, cleanup_height); } std::list listDiffIndexes; while (true) { // try using cache before reading from disk - auto itLists = mnListsCache.find(pindex->GetBlockHash()); - if (itLists != mnListsCache.end()) { + auto itLists = view.mnListsCache.find(pindex->GetBlockHash()); + if (itLists != view.mnListsCache.end()) { snapshot = itLists->second; break; } if (m_evoDb.Read(std::make_pair(DB_LIST_SNAPSHOT, pindex->GetBlockHash()), snapshot)) { - mnListsCache.emplace(pindex->GetBlockHash(), snapshot); + view.mnListsCache.emplace(pindex->GetBlockHash(), snapshot); break; } + if (m_list_snapshot_miss_hook) m_list_snapshot_miss_hook(pindex); + // At or below a snapshot base the snapshot view carries full lists + // only. A missing one is outside the carried set: never reconstruct it + // from diffs that do not exist there, and never guess an empty list. + if (const EvoViewToken current{m_evoDb.CurrentView()}; + current.identity == EvoDbIdentity::SNAPSHOT && pindex->nHeight <= current.base_height) { + throw BlockDataUnavailableError(strprintf("CDeterministicMNManager::%s -- masternode list for block %s %s", + __func__, pindex->GetBlockHash().ToString(), + BLOCK_DATA_UNAVAILABLE_SUFFIX)); + } // no snapshot found yet, check diffs - auto itDiffs = mnListDiffsCache.find(pindex->GetBlockHash()); - if (itDiffs != mnListDiffsCache.end()) { + auto itDiffs = view.mnListDiffsCache.find(pindex->GetBlockHash()); + if (itDiffs != view.mnListDiffsCache.end()) { listDiffIndexes.emplace_front(pindex); pindex = pindex->pprev; continue; @@ -880,22 +911,22 @@ CDeterministicMNList CDeterministicMNManager::GetListForBlockInternal(gsl::not_n __func__, pindex->GetBlockHash().ToString(), BLOCK_DATA_UNAVAILABLE_SUFFIX)); } // no snapshot and no diff on disk means that it's the initial snapshot - m_initial_snapshot_index = pindex; + view.m_initial_snapshot_index = pindex; snapshot = CDeterministicMNList(pindex->GetBlockHash(), pindex->nHeight, 0); - mnListsCache.emplace(pindex->GetBlockHash(), snapshot); + view.mnListsCache.emplace(pindex->GetBlockHash(), snapshot); LogPrintf("CDeterministicMNManager::%s -- initial snapshot. blockHash=%s nHeight=%d\n", __func__, snapshot.GetBlockHash().ToString(), snapshot.GetHeight()); break; } diff.nHeight = pindex->nHeight; - mnListDiffsCache.emplace(pindex->GetBlockHash(), std::move(diff)); + view.mnListDiffsCache.emplace(pindex->GetBlockHash(), std::move(diff)); listDiffIndexes.emplace_front(pindex); pindex = pindex->pprev; } for (const auto& diffIndex : listDiffIndexes) { - const auto& diff = mnListDiffsCache.at(diffIndex->GetBlockHash()); + const auto& diff = view.mnListDiffsCache.at(diffIndex->GetBlockHash()); snapshot.ApplyDiff(diffIndex, diff); static constexpr int MINI_SNAPSHOT_INTERVAL = 32; @@ -912,24 +943,22 @@ CDeterministicMNList CDeterministicMNManager::GetListForBlockInternal(gsl::not_n // There is also separate in-memory caching for the current tip and active quorums, // but this mini-snapshot cache specifically speeds up repeated requests // for nearby historical blocks. - mnListsCache.emplace(snapshot.GetBlockHash(), snapshot); + view.mnListsCache.emplace(snapshot.GetBlockHash(), snapshot); } } - if (tipIndex) { + if (view.tipIndex) { // always keep a snapshot for the tip - if (const auto snapshot_hash = snapshot.GetBlockHash(); snapshot_hash == tipIndex->GetBlockHash()) { - mnListsCache.emplace(snapshot_hash, snapshot); + if (const auto snapshot_hash = snapshot.GetBlockHash(); snapshot_hash == view.tipIndex->GetBlockHash()) { + view.mnListsCache.emplace(snapshot_hash, snapshot); } else { // keep snapshots for yet alive quorums - if (std::ranges::any_of(Params().GetConsensus().llmqs, [&snapshot, this]( - const auto& params) EXCLUSIVE_LOCKS_REQUIRED(cs) { - AssertLockHeld(cs); + if (std::ranges::any_of(Params().GetConsensus().llmqs, [&snapshot, &view](const auto& params) { return (snapshot.GetHeight() % params.dkgInterval == 0) && (snapshot.GetHeight() + params.dkgInterval * (params.keepOldConnections + 1) >= - tipIndex->nHeight); + view.tipIndex->nHeight); })) { - mnListsCache.emplace(snapshot_hash, snapshot); + view.mnListsCache.emplace(snapshot_hash, snapshot); } } } @@ -941,10 +970,11 @@ CDeterministicMNList CDeterministicMNManager::GetListForBlockInternal(gsl::not_n CDeterministicMNList CDeterministicMNManager::GetListAtChainTip() { LOCK(cs); - if (!tipIndex) { + auto& view{View()}; + if (!view.tipIndex) { return {}; } - return GetListForBlockInternal(tipIndex); + return GetListForBlockInternal(view.tipIndex, view); } bool CDeterministicMNManager::IsProTxWithCollateral(const CTransactionRef& tx, uint32_t n) @@ -972,19 +1002,18 @@ bool CDeterministicMNManager::IsProTxWithCollateral(const CTransactionRef& tx, u return true; } -void CDeterministicMNManager::CleanupCache(int nHeight) +void CDeterministicMNManager::CleanupCache(ViewState& view, int nHeight) { - AssertLockHeld(cs); std::vector toDeleteLists; std::vector toDeleteDiffs; - for (const auto& p : mnListsCache) { + for (const auto& p : view.mnListsCache) { if (p.second.GetHeight() + LIST_DIFFS_CACHE_SIZE < nHeight) { // too old, drop it toDeleteLists.emplace_back(p.first); continue; } - if (tipIndex != nullptr && p.first == tipIndex->GetBlockHash()) { + if (view.tipIndex != nullptr && p.first == view.tipIndex->GetBlockHash()) { // it's a snapshot for the tip, keep it continue; } @@ -1000,15 +1029,15 @@ void CDeterministicMNManager::CleanupCache(int nHeight) toDeleteLists.emplace_back(p.first); } for (const auto& h : toDeleteLists) { - mnListsCache.erase(h); + view.mnListsCache.erase(h); } - for (const auto& p : mnListDiffsCache) { + for (const auto& p : view.mnListDiffsCache) { if (p.second.nHeight + LIST_DIFFS_CACHE_SIZE < nHeight) { toDeleteDiffs.emplace_back(p.first); } } for (const auto& h : toDeleteDiffs) { - mnListDiffsCache.erase(h); + view.mnListDiffsCache.erase(h); } } @@ -1022,6 +1051,8 @@ bool CDeterministicMNManager::IsMigrationRequired() const AssertLockHeld(::cs_main); + // NORMAL-only lifecycle operation: it uses the raw legacy keyspace and + // never sees snapshot-generation records. std::unique_ptr pcursor{m_evoDb.GetRawDB().NewIterator()}; auto start{std::make_tuple(DB_LIST_DIFF_LEGACY, uint256{})}; pcursor->Seek(start); @@ -1043,6 +1074,8 @@ bool CDeterministicMNManager::IsMigrationRequired() const bool CDeterministicMNManager::MigrateLegacyDiffs(const CBlockIndex* const tip_index) { + // NORMAL-only lifecycle operation: it uses the raw legacy keyspace and + // never sees snapshot-generation records. // CRITICAL: This migration converts ALL stored CDeterministicMNListDiff entries // from legacy database key (DB_LIST_DIFF_LEGACY) to new key (DB_LIST_DIFF) format @@ -1178,8 +1211,7 @@ bool CDeterministicMNManager::MigrateLegacyDiffs(const CBlockIndex* const tip_in // Clear caches to force reload with new format LOCK(cs); - mnListsCache.clear(); - mnListDiffsCache.clear(); + m_views.Clear(); LogPrintf("CDeterministicMNManager::%s -- Successfully migrated %d diffs to nVersion-first format\n", __func__, keys_to_erase.size()); @@ -1190,6 +1222,8 @@ bool CDeterministicMNManager::MigrateLegacyDiffs(const CBlockIndex* const tip_in CDeterministicMNManager::RecalcDiffsResult CDeterministicMNManager::RecalculateAndRepairDiffs( const CBlockIndex* start_index, const CBlockIndex* stop_index, BuildListFromBlockFunc build_list_func, bool repair) { + // Verification and repair are NORMAL-only lifecycle operations. + const EvoViewScope evo_view{m_evoDb.ViewFor(EvoDbIdentity::NORMAL)}; RecalcDiffsResult result; result.start_height = start_index->nHeight; result.stop_height = stop_index->nHeight; @@ -1298,11 +1332,18 @@ CDeterministicMNManager::RecalcDiffsResult CDeterministicMNManager::RecalculateA return result; } -bool CDeterministicMNManager::IsRepaired() const { return m_evoDb.Exists(DB_LIST_REPAIRED); } +bool CDeterministicMNManager::IsRepaired() const +{ + // Repair is a NORMAL-only lifecycle operation. + const EvoViewScope evo_view{m_evoDb.ViewFor(EvoDbIdentity::NORMAL)}; + return m_evoDb.Exists(DB_LIST_REPAIRED); +} void CDeterministicMNManager::CompleteRepair() { AssertLockHeld(::cs_main); + // Repair is a NORMAL-only lifecycle operation. + const EvoViewScope evo_view{m_evoDb.ViewFor(EvoDbIdentity::NORMAL)}; auto dbTx = m_evoDb.BeginTransaction(); m_evoDb.Write(DB_LIST_REPAIRED, 1); dbTx->Commit(); @@ -1490,6 +1531,8 @@ void CDeterministicMNManager::WriteRepairedDiffs( return; } + // NORMAL-only lifecycle operation: it uses the raw legacy keyspace and + // never sees snapshot-generation records. CDBBatch batch(m_evoDb.GetRawDB()); const size_t BATCH_SIZE_THRESHOLD = 1 << 24; // 16MB size_t diffs_written = 0; @@ -1520,10 +1563,12 @@ void CDeterministicMNManager::WriteRepairedDiffs( // Clear caches for repaired diffs so next read gets fresh data from disk // Must clear both diff cache and list cache since lists were built from old diffs LOCK(cs); - for (const auto& [block_hash, diff] : recalculated_diffs) { - mnListDiffsCache.erase(block_hash); - mnListsCache.erase(block_hash); - } + m_views.ForEach([&](ViewState& view) { + for (const auto& [block_hash, diff] : recalculated_diffs) { + view.mnListDiffsCache.erase(block_hash); + view.mnListsCache.erase(block_hash); + } + }); LogPrintf("CDeterministicMNManager::%s -- Successfully repaired %d diffs (caches cleared)\n", __func__, recalculated_diffs.size()); diff --git a/src/evo/deterministicmns.h b/src/evo/deterministicmns.h index f8ab114c6312..c3fef7123d08 100644 --- a/src/evo/deterministicmns.h +++ b/src/evo/deterministicmns.h @@ -7,7 +7,9 @@ #include #include +#include #include +#include #include #include @@ -21,6 +23,7 @@ #include #include +#include #include #include #include @@ -738,19 +741,6 @@ struct MNListUpdates inline constexpr std::string_view BLOCK_DATA_UNAVAILABLE_SUFFIX{ "is not available (pruned or below an unvalidated snapshot base)"}; -/** Thrown when the masternode list for a block cannot be reconstructed because - * the data is not on this node yet (pruned, or below an unvalidated snapshot - * base, or pending in another chainstate's unflushed EvoDB overlay). Distinct - * from the plain std::runtime_error that CDeterministicMNList::ApplyDiff - * raises for genuine local corruption, which must never be swallowed. - * The message carries BLOCK_DATA_UNAVAILABLE_SUFFIX, matched by - * IsBlockDataUnavailableError(). */ -class BlockDataUnavailableError : public std::runtime_error -{ -public: - using std::runtime_error::runtime_error; -}; - class CDeterministicMNManager { static constexpr int DISK_SNAPSHOT_PERIOD = 576; // once per day @@ -770,10 +760,22 @@ class CDeterministicMNManager CEvoDB& m_evoDb; CMasternodeMetaMan& m_mn_metaman; - Uint256HashMap mnListsCache GUARDED_BY(cs); - Uint256HashMap mnListDiffsCache GUARDED_BY(cs); - const CBlockIndex* tipIndex GUARDED_BY(cs) {nullptr}; - const CBlockIndex* m_initial_snapshot_index GUARDED_BY(cs) {nullptr}; + //! Lists and diffs derived under one Evo view, with the view's own tip and + //! initial-snapshot anchor. + struct ViewState { + Uint256HashMap mnListsCache; + Uint256HashMap mnListDiffsCache; + const CBlockIndex* tipIndex{nullptr}; + const CBlockIndex* m_initial_snapshot_index{nullptr}; + //! Highest block this view connected. Bounds the cache of a view that + //! is not active, and so never learns a tip from UpdatedBlockTip. + int processed_height{-1}; + }; + EvoViewCache m_views GUARDED_BY(cs); + std::function m_list_snapshot_miss_hook GUARDED_BY(cs); + + //! The partition of the calling operation's Evo view. + ViewState& View() EXCLUSIVE_LOCKS_REQUIRED(cs) { return m_views.For(m_evoDb.CurrentView()); } public: CDeterministicMNManager() = delete; @@ -791,14 +793,39 @@ class CDeterministicMNManager CDeterministicMNList GetListForBlock(gsl::not_null pindex) EXCLUSIVE_LOCKS_REQUIRED(!cs) { LOCK(cs); - return GetListForBlockInternal(pindex); + return GetListForBlockInternal(pindex, View()); }; CDeterministicMNList GetListAtChainTip() EXCLUSIVE_LOCKS_REQUIRED(!cs); + /** Seed a canonical full list into `view`, inside the caller's open EvoDB + * transaction for that view. */ + bool SeedListForBlock(const EvoViewToken& view, const CDeterministicMNList& list) EXCLUSIVE_LOCKS_REQUIRED(!cs); + //! The EvoDB this manager reads, for callers that bind an Evo view. + const CEvoDB& EvoDb() const { return m_evoDb; } + void SetListForBlockForTesting(const CDeterministicMNList& list) EXCLUSIVE_LOCKS_REQUIRED(!cs) { LOCK(cs); - mnListsCache.insert_or_assign(list.GetBlockHash(), list); + View().mnListsCache.insert_or_assign(list.GetBlockHash(), list); + } + + /** Test-only hook invoked whenever a list lookup misses both the cache and + * the stored full lists, before diff-chain reconstruction. */ + void SetListSnapshotMissHookForTesting(std::function hook) EXCLUSIVE_LOCKS_REQUIRED(!cs) + { + LOCK(cs); + m_list_snapshot_miss_hook = std::move(hook); + } + + //! Heights of the lists cached for the current view. + std::vector CachedListHeightsForTesting() EXCLUSIVE_LOCKS_REQUIRED(!cs) + { + LOCK(cs); + std::vector heights; + for (const auto& [_, list] : View().mnListsCache) { + heights.push_back(list.GetHeight()); + } + return heights; } // Test if given TX is a ProRegTx which also contains the collateral at index n @@ -836,8 +863,9 @@ class CDeterministicMNManager [[nodiscard]] bool MigrateLegacyDiffs(const CBlockIndex* const tip_index) EXCLUSIVE_LOCKS_REQUIRED(!cs, ::cs_main); private: - void CleanupCache(int nHeight) EXCLUSIVE_LOCKS_REQUIRED(cs); - CDeterministicMNList GetListForBlockInternal(gsl::not_null pindex) EXCLUSIVE_LOCKS_REQUIRED(cs); + static void CleanupCache(ViewState& view, int nHeight); + CDeterministicMNList GetListForBlockInternal(gsl::not_null pindex, ViewState& view) + EXCLUSIVE_LOCKS_REQUIRED(cs); // Helper methods for RecalculateAndRepairDiffs static std::vector CollectSnapshotBlocks(const CBlockIndex* start_index, diff --git a/src/evo/evodb.cpp b/src/evo/evodb.cpp index 84fc87902d02..1abf58410278 100644 --- a/src/evo/evodb.cpp +++ b/src/evo/evodb.cpp @@ -4,8 +4,14 @@ #include +#include +#include #include +#include +#include +#include +#include #include CEvoDBScopedCommitter::CEvoDBScopedCommitter(CEvoDB& _evoDB, EvoDbIdentity identity) : @@ -34,10 +40,55 @@ void CEvoDBScopedCommitter::Rollback() evoDB.RollbackCurTransaction(identity); } +namespace { +DataStream NamespaceTag() +{ + DataStream tag{}; + tag << EVODB_GENERATION_NAMESPACE; + return tag; +} + +bool StartsWith(const DataStream& key, const DataStream& prefix) +{ + return key.size() >= prefix.size() && std::equal(prefix.begin(), prefix.end(), key.begin()); +} + +bool SameBytes(const DataStream& a, const DataStream& b) { return std::equal(a.begin(), a.end(), b.begin(), b.end()); } + +auto GenerationRecordKey(uint32_t generation) { return std::make_pair(EVODB_GENERATION_RECORD, generation); } +} // namespace + +DataStream CEvoDBStore::GenerationPrefix(uint32_t generation) +{ + DataStream prefix{}; + if (generation != 0) prefix << EVODB_GENERATION_NAMESPACE << generation; + return prefix; +} + +bool CEvoDBStore::Owns(const DataStream& raw_key) const +{ + if (m_prefix.empty()) return !StartsWith(raw_key, NamespaceTag()); + return StartsWith(raw_key, m_prefix); +} + +void CEvoDBStore::Iterator::SkipForeign() +{ + // Generation namespaces form one contiguous key range. The NORMAL view + // steps over it as a whole; a generation view ends where its prefix ends. + if (!m_store.m_prefix.empty() || !m_it->Valid()) return; + const DataStream tag{NamespaceTag()}; + if (!StartsWith(m_it->GetKey(), tag)) return; + DataStream after_tag{tag}; + std::byte& last{after_tag[after_tag.size() - 1]}; + last = std::byte{static_cast(std::to_integer(last) + 1)}; + m_it->Seek(after_tag); +} + CEvoDB::CEvoDB(const util::DbWrapperParams& db_params) : db{util::MakeDbWrapper({db_params.path / "evodb", db_params.memory, db_params.wipe, /*cache_size=*/64 << 20, db_params.options})} { - transaction_contexts.emplace(EvoDbIdentity::NORMAL, std::make_unique(*db)); + transaction_contexts.emplace(EvoDbIdentity::NORMAL, std::make_unique(*db, DataStream{})); + ++g_view_cache_epoch; } CEvoDB::~CEvoDB() = default; @@ -46,9 +97,14 @@ CEvoDB::TransactionContext& CEvoDB::GetContext(EvoDbIdentity identity) { auto it = transaction_contexts.find(identity); if (it == transaction_contexts.end()) { + if (identity == EvoDbIdentity::SNAPSHOT && !m_snapshot_generation) { + throw EvoViewBindingError("EvoDB: the snapshot identity has no generation bound"); + } + const uint32_t generation{identity == EvoDbIdentity::SNAPSHOT ? m_snapshot_generation->generation : 0}; // Construct the context before inserting so a throwing constructor // cannot leave a null entry behind for later dereference. - it = transaction_contexts.emplace(identity, std::make_unique(*db)).first; + auto context{std::make_unique(*db, CEvoDBStore::GenerationPrefix(generation))}; + it = transaction_contexts.emplace(identity, std::move(context)).first; } return *it->second; } @@ -58,12 +114,123 @@ const CEvoDB::TransactionContext& CEvoDB::GetContext(EvoDbIdentity identity) con return *transaction_contexts.at(identity); } -EvoDbIdentity CEvoDB::GetCurrentIdentity() const +namespace { +thread_local const EvoViewScope* g_innermost_scope{nullptr}; +} // namespace + +EvoViewScope::EvoViewScope(const EvoViewToken& token) : + m_token{token}, + m_outer{g_innermost_scope} +{ + if (m_token.db == nullptr) throw EvoViewBindingError("EvoViewScope: token names no EvoDB"); + m_token.db->CheckScopeAgainstTransaction(m_token); + g_innermost_scope = this; +} + +EvoViewScope::~EvoViewScope() +{ + Assume(g_innermost_scope == this); + g_innermost_scope = m_outer; +} + +const EvoViewToken* EvoViewScope::Innermost(const CEvoDB& db) { - if (active_transaction.has_value() && active_transaction_thread == std::this_thread::get_id()) { - return *active_transaction; + for (const EvoViewScope* scope{g_innermost_scope}; scope != nullptr; scope = scope->m_outer) { + if (scope->m_token.db == &db) return &scope->m_token; + } + return nullptr; +} + +std::optional EvoViewScope::InnermostAny() +{ + if (g_innermost_scope == nullptr) return std::nullopt; + return g_innermost_scope->m_token; +} + +bool CEvoDB::IsBound() const +{ + LOCK(cs); + return HoldsTransaction() || EvoViewScope::Innermost(*this) != nullptr; +} + +void CEvoDB::UpdateLiveGeneration() +{ + m_has_live_generation = m_snapshot_generation && (m_snapshot_generation->state == EvoGenerationState::PUBLISHED || + m_snapshot_generation->state == EvoGenerationState::PROMOTING); +} + +EvoViewToken CEvoDB::ActiveView() const +{ + LOCK(cs); + return MakeView(m_default_identity); +} + +bool CEvoDB::IsLive(const EvoViewToken& view) const +{ + LOCK(cs); + return view.identity == EvoDbIdentity::NORMAL || MakeView(EvoDbIdentity::SNAPSHOT) == view; +} + +EvoViewToken CEvoDB::MakeView(EvoDbIdentity identity) const +{ + if (identity == EvoDbIdentity::SNAPSHOT && m_snapshot_generation) { + return EvoViewToken{.db = this, + .identity = identity, + .generation = m_snapshot_generation->generation, + .base = m_snapshot_generation->base, + .base_height = m_snapshot_generation->base_height}; + } + return EvoViewToken{.db = this, .identity = identity}; +} + +EvoViewToken CEvoDB::ResolveView() const +{ + EvoViewToken view; + if (HoldsTransaction()) { + view = MakeView(*active_transaction); + } else if (const EvoViewToken * scoped{EvoViewScope::Innermost(*this)}) { + view = *scoped; + } else if (m_has_live_generation) { + // With two chainstates, an absent binding says nothing about which one + // the access serves; never guess. + throw EvoViewBindingError("unbound Evo access during dual-chainstate operation"); + } else { + return MakeView(m_default_identity); + } + if (view.identity == EvoDbIdentity::SNAPSHOT && view != MakeView(EvoDbIdentity::SNAPSHOT)) { + throw EvoViewBindingError(strprintf("Evo access bound to retired snapshot generation %u", view.generation)); + } + return view; +} + +bool CEvoDB::IsCurrentView(const EvoViewToken& view) const +{ + LOCK(cs); + try { + return ResolveView() == view; + } catch (const EvoViewBindingError&) { + return false; + } +} + +EvoViewToken CEvoDB::CurrentView() const +{ + LOCK(cs); + return ResolveView(); +} + +EvoViewToken CEvoDB::ViewFor(EvoDbIdentity identity) const +{ + LOCK(cs); + return MakeView(identity); +} + +void CEvoDB::CheckScopeAgainstTransaction(const EvoViewToken& token) const +{ + LOCK(cs); + if (HoldsTransaction() && MakeView(*active_transaction) != token) { + throw EvoViewBindingError("EvoViewScope conflicts with this thread's open EvoDB transaction"); } - return m_default_identity; } std::unique_ptr CEvoDB::BeginTransaction(EvoDbIdentity identity) @@ -71,9 +238,12 @@ std::unique_ptr CEvoDB::BeginTransaction(EvoDbIdentity id AssertLockHeld(::cs_main); LOCK(cs); assert(!active_transaction.has_value()); + if (const EvoViewToken * scoped{EvoViewScope::Innermost(*this)}; scoped && *scoped != MakeView(identity)) { + throw EvoViewBindingError("EvoDB transaction conflicts with this thread's EvoViewScope"); + } + GetContext(identity); active_transaction = identity; active_transaction_thread = std::this_thread::get_id(); - GetContext(identity); return std::make_unique(*this, identity); } @@ -91,7 +261,11 @@ void CEvoDB::RollbackCurTransaction(EvoDbIdentity identity) AssertLockHeld(::cs_main); LOCK(cs); assert(active_transaction == identity); - GetContext(identity).cur_transaction.Clear(); + // The open transaction's context exists. Never create one here: that can + // throw, and this runs from CEvoDBScopedCommitter's destructor. + if (const auto it{transaction_contexts.find(identity)}; it != transaction_contexts.end()) { + it->second->cur_transaction.Clear(); + } active_transaction.reset(); } @@ -110,6 +284,7 @@ bool CEvoDB::CommitRootTransaction(EvoDbIdentity identity, bool sync) bool CEvoDB::ReadBestBlock(EvoDbIdentity identity, uint256& hash) { LOCK(cs); + if (identity == EvoDbIdentity::SNAPSHOT && !m_snapshot_generation) return false; auto& transaction = GetContext(identity).cur_transaction; if (identity == EvoDbIdentity::NORMAL) { return transaction.Read(EVODB_BEST_BLOCK, hash); @@ -138,7 +313,8 @@ void CEvoDB::WriteBestBlock(EvoDbIdentity identity, const uint256& hash) void CEvoDB::WriteDualChainstateMarker() { - Write(EVODB_DUAL_CHAINSTATE, uint8_t{1}); + LOCK(cs); + db->Write(EVODB_DUAL_CHAINSTATE, uint8_t{1}); } bool CEvoDB::HasDualChainstateMarker() @@ -147,19 +323,10 @@ bool CEvoDB::HasDualChainstateMarker() return db->Exists(EVODB_DUAL_CHAINSTATE); } -void CEvoDB::EraseSnapshotMarkers() -{ - LOCK(cs); - auto& transaction = GetContext(GetCurrentIdentity()).cur_transaction; - transaction.Erase(std::make_pair(EVODB_BEST_BLOCK, uint8_t{1})); - transaction.Erase(EVODB_SNAPSHOT_MNLIST_HASH); - transaction.Erase(EVODB_BACKGROUND_MNLIST_HASH); - transaction.Erase(EVODB_DUAL_CHAINSTATE); -} - void CEvoDB::WriteSnapshotBaseMNListHash(const uint256& hash) { - Write(EVODB_SNAPSHOT_MNLIST_HASH, hash); + LOCK(cs); + db->Write(EVODB_SNAPSHOT_MNLIST_HASH, hash); } bool CEvoDB::ReadSnapshotBaseMNListHash(uint256& hash) @@ -186,59 +353,391 @@ bool CEvoDB::ReadBackgroundMNListHash(uint256& block_hash, uint256& mn_list_hash return true; } -bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) +std::optional CEvoDB::GetSnapshotGeneration() const { LOCK(cs); - assert(!active_transaction.has_value()); - for (const auto& [_, context] : transaction_contexts) { - if (!context) continue; - assert(context->cur_transaction.IsClean()); - assert(context->root_transaction.IsClean()); + return m_snapshot_generation; +} + +void CEvoDB::BindSnapshotGeneration(std::optional record) +{ + // A context's store is keyed by the generation it was built for. + if (auto it{transaction_contexts.find(EvoDbIdentity::SNAPSHOT)}; it != transaction_contexts.end()) { + Assume(it->second->cur_transaction.IsClean() && it->second->root_transaction.IsClean()); + transaction_contexts.erase(it); } + m_snapshot_generation = std::move(record); + UpdateLiveGeneration(); + if (!m_snapshot_generation) m_default_identity = EvoDbIdentity::NORMAL; +} - const auto snapshot_key = std::make_pair(EVODB_BEST_BLOCK, uint8_t{1}); - uint256 snapshot_tip; - if (!db->Read(snapshot_key, 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); - if (already_promoted) m_default_identity = EvoDbIdentity::NORMAL; - return already_promoted; +bool CEvoDB::TransactionsClean() const +{ + if (active_transaction.has_value()) return false; + return std::ranges::all_of(transaction_contexts, [](const auto& entry) { + return !entry.second || (entry.second->cur_transaction.IsClean() && entry.second->root_transaction.IsClean()); + }); +} + +namespace { +bool IsKnown(EvoGenerationState state) { return state <= EvoGenerationState::PROMOTING; } + +bool IsValid(const EvoPromotionJournal& journal) +{ + return journal.schema_version == EvoGenerationRecord::SCHEMA_VERSION && journal.phase <= EvoPromotionPhase::MERGED; +} +} // namespace + +bool CEvoDB::CreateSnapshotGeneration(const uint256& base, int base_height) +{ + LOCK(cs); + if (m_snapshot_generation) return false; + uint32_t generation{1}; + db->Read(EVODB_GENERATION_NEXT, generation); + // Generation 0 names the NORMAL keyspace; never wrap into it. + if (generation == 0 || generation == std::numeric_limits::max()) { + LogPrintf("ERROR: %s: snapshot EvoDB generation ids are exhausted\n", __func__); + return false; + } + EvoGenerationRecord record; + record.generation = generation; + record.state = EvoGenerationState::UNPUBLISHED; + record.base = base; + record.base_height = base_height; + record.tip = base; + CDBBatch batch{*db}; + batch.Write(EVODB_GENERATION_NEXT, generation + 1); + batch.Write(GenerationRecordKey(generation), record); + if (!db->WriteBatch(batch, /*fSync=*/true)) return false; + BindSnapshotGeneration(record); + return true; +} + +bool CEvoDB::PublishSnapshotGeneration(const uint256& config_fingerprint, const std::optional& base_mn_list_hash, + const std::vector& evo_section) +{ + LOCK(cs); + if (!m_snapshot_generation || m_snapshot_generation->state != EvoGenerationState::UNPUBLISHED) return false; + EvoGenerationRecord record{*m_snapshot_generation}; + record.state = EvoGenerationState::PUBLISHED; + record.config_fingerprint = config_fingerprint; + CDBBatch batch{*db}; + batch.Write(GenerationRecordKey(record.generation), record); + batch.Write(EVODB_DUAL_CHAINSTATE, uint8_t{1}); + if (base_mn_list_hash) batch.Write(EVODB_SNAPSHOT_MNLIST_HASH, *base_mn_list_hash); + if (!evo_section.empty()) batch.Write(EVODB_SNAPSHOT_EVO_SECTION, evo_section); + if (!db->WriteBatch(batch, /*fSync=*/true)) return false; + m_snapshot_generation = record; + UpdateLiveGeneration(); + return true; +} + +std::optional> CEvoDB::ReadRetainedEvoSection() const +{ + LOCK(cs); + std::vector evo_section; + if (!db->Read(EVODB_SNAPSHOT_EVO_SECTION, evo_section)) return std::nullopt; + return evo_section; +} + +util::Result CEvoDB::LoadSnapshotGenerations(const uint256& config_fingerprint) +{ + LOCK(cs); + if (!Assume(TransactionsClean())) return util::Error{_("Evo database transactions are pending at startup.")}; + BindSnapshotGeneration(std::nullopt); + std::vector records; + { + std::unique_ptr it{db->NewIterator()}; + it->Seek(GenerationRecordKey(0)); + for (; it->Valid(); it->Next()) { + std::pair key; + if (!it->GetKey(key) || key.first != EVODB_GENERATION_RECORD) break; + EvoGenerationRecord record; + if (!it->GetValue(record) || record.schema_version != EvoGenerationRecord::SCHEMA_VERSION || + !IsKnown(record.state) || record.generation == 0 || record.generation != key.second) { + return util::Error{_("The Evo database holds assumeutxo snapshot state in an unsupported format. " + "Restart with -reindex-chainstate to discard it.")}; + } + records.push_back(record); + } + } + + std::optional live; + for (const EvoGenerationRecord& record : records) { + switch (record.state) { + case EvoGenerationState::UNPUBLISHED: + if (!CollectGeneration(record.generation, /*erase_record=*/true)) { + return util::Error{_("Failed to remove discarded assumeutxo snapshot state from the Evo database.")}; + } + break; + case EvoGenerationState::REJECTED: + // Kept until startup recovery has invalidated its coins directory. + break; + case EvoGenerationState::PUBLISHED: + case EvoGenerationState::PROMOTING: + if (live) { + return util::Error{_("The Evo database holds more than one live assumeutxo snapshot state. " + "Restart with -reindex-chainstate to discard them.")}; + } + live = record; + break; + } } - if (snapshot_tip != expected_snapshot_tip) return false; + if (live && live->config_fingerprint != config_fingerprint) { + return util::Error{_("The consensus configuration changed since the assumeutxo snapshot was loaded, so its " + "Evo state can no longer be trusted. Restart with the previous configuration, or with " + "-reindex-chainstate to discard the snapshot.")}; + } + // Once a promotion has merged and collected its generation, only the + // journal remains to finish it. + EvoPromotionJournal journal; + const bool finishing_promotion{db->Read(EVODB_PROMOTION_JOURNAL, journal)}; + if (db->Exists(EVODB_PROMOTION_JOURNAL) && (!finishing_promotion || !IsValid(journal))) { + return util::Error{_("The Evo database holds an assumeutxo promotion journal in an unsupported format. " + "Restart with -reindex-chainstate to discard it.")}; + } + if (!live && !finishing_promotion && + (db->Exists(EVODB_DUAL_CHAINSTATE) || db->Exists(std::make_pair(EVODB_BEST_BLOCK, uint8_t{1})))) { + return util::Error{_("An assumeutxo snapshot in progress was loaded by an older version that did not " + "isolate its Evo state. Restart with -reindex-chainstate to discard it.")}; + } + BindSnapshotGeneration(live); + return {}; +} + +bool CEvoDB::HasRejectedGeneration() const +{ + LOCK(cs); + return !RejectedGenerations().empty(); +} + +std::vector CEvoDB::RejectedGenerations() const +{ + std::vector rejected; + std::unique_ptr it{db->NewIterator()}; + for (it->Seek(GenerationRecordKey(0)); it->Valid(); it->Next()) { + std::pair key; + if (!it->GetKey(key) || key.first != EVODB_GENERATION_RECORD) break; + EvoGenerationRecord record; + if (it->GetValue(record) && record.state == EvoGenerationState::REJECTED) rejected.push_back(key.second); + } + return rejected; +} + +bool CEvoDB::CollectRejectedGenerations() +{ + LOCK(cs); + for (const uint32_t generation : RejectedGenerations()) { + if (!CollectGeneration(generation, /*erase_record=*/true)) return false; + } + return true; +} + +bool CEvoDB::CollectGeneration(uint32_t generation, bool erase_record) +{ + if (!Assume(generation != 0)) return false; + const DataStream prefix{CEvoDBStore::GenerationPrefix(generation)}; + std::unique_ptr it{db->NewIterator()}; + CDBBatch batch{*db}; + size_t pending{0}; + for (it->Seek(prefix); it->Valid() && StartsWith(it->GetKey(), prefix); it->Next()) { + batch.Erase(it->GetKey()); + if (++pending >= m_merge_batch_size) { + if (!db->WriteBatch(batch)) return false; + batch.Clear(); + pending = 0; + } + } + if (erase_record) batch.Erase(GenerationRecordKey(generation)); + return db->WriteBatch(batch, /*fSync=*/true); +} + +bool CEvoDB::DiscardBoundGeneration() +{ CDBBatch batch{*db}; - batch.Write(EVODB_BEST_BLOCK, snapshot_tip); - batch.Erase(snapshot_key); batch.Erase(EVODB_SNAPSHOT_MNLIST_HASH); batch.Erase(EVODB_BACKGROUND_MNLIST_HASH); + batch.Erase(EVODB_SNAPSHOT_EVO_SECTION); batch.Erase(EVODB_DUAL_CHAINSTATE); + batch.Erase(EVODB_PROMOTION_JOURNAL); + if (m_snapshot_generation) { + EvoGenerationRecord record{*m_snapshot_generation}; + record.state = EvoGenerationState::REJECTED; + batch.Write(GenerationRecordKey(record.generation), record); + } + // A failure to persist the rejection leaves the generation live, and the + // dual-chainstate markers with it; startup recovery then retries. The + // REJECTED record stays until the snapshot's coins directory is + // invalidated (CollectRejectedGenerations), so a crash in between can + // still finish the invalidation. if (!db->WriteBatch(batch, /*fSync=*/true)) return false; - // The dual-chainstate run is over: the promoted state is the NORMAL - // identity, so transaction-less access must resolve there again. - m_default_identity = EvoDbIdentity::NORMAL; + BindSnapshotGeneration(std::nullopt); + ++g_view_cache_epoch; return true; } +bool CEvoDB::AbandonSnapshotGeneration() +{ + AssertLockHeld(::cs_main); + LOCK(cs); + if (!Assume(!active_transaction.has_value())) return false; + if (auto it{transaction_contexts.find(EvoDbIdentity::SNAPSHOT)}; it != transaction_contexts.end()) { + it->second->cur_transaction.Clear(); + it->second->root_transaction.Clear(); + it->second->root_batch.Clear(); + } + return DiscardBoundGeneration(); +} + bool CEvoDB::DiscardSnapshotMarkers() { LOCK(cs); - assert(!active_transaction.has_value()); - for (const auto& [_, context] : transaction_contexts) { - if (!context) continue; - assert(context->cur_transaction.IsClean()); - assert(context->root_transaction.IsClean()); + if (!Assume(TransactionsClean())) return false; + return DiscardBoundGeneration(); +} + +bool CEvoDB::PrepareSnapshotPromotion(const uint256& snapshot_tip) +{ + LOCK(cs); + if (!m_snapshot_generation) return false; + // The coins tip being promoted must be the tip the generation's Evo state + // was flushed at. + uint256 evo_tip; + if (!GetContext(EvoDbIdentity::SNAPSHOT).cur_transaction.Read(std::make_pair(EVODB_BEST_BLOCK, uint8_t{1}), evo_tip) || + evo_tip != snapshot_tip) { + return false; + } + EvoPromotionJournal journal; + if (db->Read(EVODB_PROMOTION_JOURNAL, journal)) { + // Only a promotion that has not renamed anything yet may be redone. + if (!IsValid(journal) || journal.generation != m_snapshot_generation->generation || + journal.phase != EvoPromotionPhase::PREPARED) { + return false; + } + } + journal = EvoPromotionJournal{}; + journal.generation = m_snapshot_generation->generation; + journal.base = m_snapshot_generation->base; + journal.tip = snapshot_tip; + uint256 base_mn_list_hash; + if (db->Read(EVODB_SNAPSHOT_MNLIST_HASH, base_mn_list_hash)) journal.base_mn_list_hash = base_mn_list_hash; + EvoGenerationRecord record{*m_snapshot_generation}; + record.state = EvoGenerationState::PROMOTING; + record.tip = snapshot_tip; + CDBBatch batch{*db}; + batch.Write(EVODB_PROMOTION_JOURNAL, journal); + batch.Write(GenerationRecordKey(record.generation), record); + if (!db->WriteBatch(batch, /*fSync=*/true)) return false; + m_snapshot_generation = record; + UpdateLiveGeneration(); + return true; +} + +std::optional CEvoDB::ReadPromotionJournal() const +{ + LOCK(cs); + EvoPromotionJournal journal; + if (!db->Read(EVODB_PROMOTION_JOURNAL, journal)) return std::nullopt; + return journal; +} + +bool CEvoDB::MergeGeneration(EvoPromotionJournal& journal) +{ + const DataStream prefix{CEvoDBStore::GenerationPrefix(journal.generation)}; + DataStream best_block_key{}; + best_block_key << std::make_pair(EVODB_BEST_BLOCK, uint8_t{1}); + DataStream llmq_best_block_key{}; + llmq_best_block_key << EVODB_LLMQ_BEST_BLOCK; + + std::unique_ptr it{db->NewIterator()}; + DataStream start{prefix}; + start.write(MakeByteSpan(journal.cursor)); + it->Seek(start); + // The cursor names the last merged key; resume after it. + if (!journal.cursor.empty() && it->Valid() && SameBytes(it->GetKey(), start)) it->Next(); + + CDBBatch batch{*db}; + size_t pending{0}; + for (; it->Valid(); it->Next()) { + const DataStream raw_key{it->GetKey()}; + if (!StartsWith(raw_key, prefix)) break; + DataStream key{raw_key}; + key.ignore(prefix.size()); + // The promoted best block is published with the final markers. + if (!SameBytes(key, best_block_key)) { + CDataStream value{SER_DISK, CLIENT_VERSION}; + if (!db->ReadDataStream(raw_key, value)) return false; + CDataStream normal_value{SER_DISK, CLIENT_VERSION}; + if (db->ReadDataStream(key, normal_value) && !SameBytes(key, llmq_best_block_key) && + !std::equal(normal_value.begin(), normal_value.end(), value.begin(), value.end())) { + // Both chainstates derived this immutable record from the same + // block; differing bytes mean one of them is corrupt. + LogPrintf("ERROR: %s: snapshot generation %u conflicts with NORMAL state for key %s\n", __func__, + journal.generation, HexStr(key)); + return false; + } + batch.Write(key, Span{value}); + } + journal.cursor.assign(UCharCast(key.data()), UCharCast(key.data() + key.size())); + if (++pending >= m_merge_batch_size) { + batch.Write(EVODB_PROMOTION_JOURNAL, journal); + if (!db->WriteBatch(batch, /*fSync=*/true)) return false; + batch.Clear(); + pending = 0; + if (m_promotion_failpoint == PromotionFailpoint::MID_MERGE) return false; + } + } + journal.phase = EvoPromotionPhase::MERGED; + journal.cursor.clear(); + batch.Write(EVODB_PROMOTION_JOURNAL, journal); + return db->WriteBatch(batch, /*fSync=*/true); +} + +bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) +{ + LOCK(cs); + if (!Assume(TransactionsClean())) return false; + + EvoPromotionJournal journal; + if (!db->Read(EVODB_PROMOTION_JOURNAL, journal)) { + uint256 normal_tip; + const bool already_promoted = db->Read(EVODB_BEST_BLOCK, normal_tip) && normal_tip == expected_snapshot_tip && + !m_snapshot_generation && !db->Exists(EVODB_DUAL_CHAINSTATE) && + !db->Exists(EVODB_SNAPSHOT_MNLIST_HASH) && + !db->Exists(EVODB_BACKGROUND_MNLIST_HASH); + if (already_promoted) m_default_identity = EvoDbIdentity::NORMAL; + return already_promoted; + } + if (!IsValid(journal) || journal.tip != expected_snapshot_tip) return false; + + if (journal.phase == EvoPromotionPhase::PREPARED) { + journal.phase = EvoPromotionPhase::MERGING; + if (!db->Write(EVODB_PROMOTION_JOURNAL, journal, /*fSync=*/true)) return false; } + if (journal.phase == EvoPromotionPhase::MERGING && !MergeGeneration(journal)) return false; + if (m_promotion_failpoint == PromotionFailpoint::AFTER_MERGE) return false; + + // Everything is merged: collecting the namespace (idempotent) cannot lose + // data, and the journal still carries the promoted tip. The PROMOTING + // record, with the configuration fingerprint startup checks, stays until + // the final batch. + BindSnapshotGeneration(std::nullopt); + if (!CollectGeneration(journal.generation, /*erase_record=*/false)) return false; + if (m_promotion_failpoint == PromotionFailpoint::AFTER_COLLECT) return false; CDBBatch batch{*db}; - batch.Erase(std::make_pair(EVODB_BEST_BLOCK, uint8_t{1})); + batch.Write(EVODB_BEST_BLOCK, journal.tip); batch.Erase(EVODB_SNAPSHOT_MNLIST_HASH); batch.Erase(EVODB_BACKGROUND_MNLIST_HASH); + batch.Erase(EVODB_SNAPSHOT_EVO_SECTION); batch.Erase(EVODB_DUAL_CHAINSTATE); + batch.Erase(EVODB_PROMOTION_JOURNAL); + batch.Erase(GenerationRecordKey(journal.generation)); if (!db->WriteBatch(batch, /*fSync=*/true)) return false; - // The snapshot chainstate is gone; transaction-less access must resolve - // against the NORMAL identity again. + // The dual-chainstate run is over: the promoted state is the NORMAL + // identity, so transaction-less access must resolve there again. m_default_identity = EvoDbIdentity::NORMAL; + ++g_view_cache_epoch; return true; } diff --git a/src/evo/evodb.h b/src/evo/evodb.h index f9485eb09b89..76d5dcbab079 100644 --- a/src/evo/evodb.h +++ b/src/evo/evodb.h @@ -6,16 +6,23 @@ #define BITCOIN_EVO_EVODB_H #include +#include #include +#include +#include +#include #include +#include +#include #include +#include +#include #include #include #include #include -class uint256; namespace util { struct DbWrapperParams; } // namespace util @@ -32,6 +39,19 @@ 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"; +//! The evo section the live snapshot generation was seeded from. +static const std::string EVODB_SNAPSHOT_EVO_SECTION = "b_dcs_evo"; +// Snapshot-identity lifecycle metadata. It lives in the legacy keyspace and is +// never part of a generation namespace. +static const std::string EVODB_GENERATION_RECORD = "b_evo_gen"; +static const std::string EVODB_GENERATION_NEXT = "b_evo_gen_next"; +static const std::string EVODB_PROMOTION_JOURNAL = "b_evo_promote"; +// Every key the snapshot identity writes is stored as +// (EVODB_GENERATION_NAMESPACE, generation, key). +static const std::string EVODB_GENERATION_NAMESPACE = "S"; +// Mutable singleton records whose snapshot-identity value supersedes the +// NORMAL one when a generation is promoted. +static const std::string EVODB_LLMQ_BEST_BLOCK = "q_bbu2"; enum class EvoDbIdentity { NORMAL, @@ -50,6 +70,199 @@ class EvoDbInconsistencyError : public std::runtime_error class CEvoDB; +/** Names the Evo state one operation reads and writes: the NORMAL view, or + * one generation of the SNAPSHOT view, whose snapshot starts at `base`. */ +struct EvoViewToken { + const CEvoDB* db{nullptr}; + EvoDbIdentity identity{EvoDbIdentity::NORMAL}; + uint32_t generation{0}; + uint256 base{}; + int base_height{-1}; + + bool operator==(const EvoViewToken&) const = default; +}; + +enum class EvoGenerationState : uint8_t { + UNPUBLISHED = 0, + PUBLISHED = 1, + REJECTED = 2, + PROMOTING = 3, +}; + +/** Lifecycle record of one snapshot-identity generation. */ +struct EvoGenerationRecord { + //! Local on-disk schema of generations and the promotion journal. It is + //! independent of the snapshot wire format. + static constexpr uint16_t SCHEMA_VERSION{1}; + + uint16_t schema_version{SCHEMA_VERSION}; + uint32_t generation{0}; + EvoGenerationState state{EvoGenerationState::UNPUBLISHED}; + uint256 base; + int32_t base_height{-1}; + uint256 tip; + //! Fingerprint of the consensus configuration the generation was + //! published under; see EvoSnapshotConsensusConfig. + uint256 config_fingerprint; + + SERIALIZE_METHODS(EvoGenerationRecord, obj) + { + uint8_t state{static_cast(obj.state)}; + READWRITE(obj.schema_version, obj.generation, state, obj.base, obj.base_height, obj.tip, obj.config_fingerprint); + SER_READ(obj, obj.state = static_cast(state)); + } +}; + +enum class EvoPromotionPhase : uint8_t { + //! Decided, before the first coins-directory rename. + PREPARED = 0, + //! Both coins-directory renames are durable; merging into NORMAL keys. + MERGING = 1, + //! Every record is merged; the generation namespace may be collected. + MERGED = 2, +}; + +/** Journal of a snapshot-generation promotion, written before the first + * coins-directory rename and removed together with the final markers. */ +struct EvoPromotionJournal { + uint16_t schema_version{EvoGenerationRecord::SCHEMA_VERSION}; + uint32_t generation{0}; + uint256 base; + uint256 tip; + uint256 base_mn_list_hash; + EvoPromotionPhase phase{EvoPromotionPhase::PREPARED}; + //! Generation-relative key the merge resumes after; empty to start over. + std::vector cursor; + + SERIALIZE_METHODS(EvoPromotionJournal, obj) + { + uint8_t phase{static_cast(obj.phase)}; + READWRITE(obj.schema_version, obj.generation, obj.base, obj.tip, obj.base_mn_list_hash, phase, obj.cursor); + SER_READ(obj, obj.phase = static_cast(phase)); + } +}; + +/** Read side of one view of the EvoDB keyspace: every key is stored behind + * `prefix`. The NORMAL view has an empty prefix and skips all generation + * namespaces, so neither view ever sees the other's records. */ +class CEvoDBStore +{ +public: + class Iterator + { + public: + Iterator(const CEvoDBStore& store, CDBIterator* it) : + m_store{store}, + m_it{it} + { + } + void SeekToFirst() + { + m_it->Seek(m_store.m_prefix); + SkipForeign(); + } + void Seek(const DataStream& key) + { + m_it->Seek(m_store.Key(key)); + SkipForeign(); + } + void Next() + { + m_it->Next(); + SkipForeign(); + } + bool Valid() { return m_it->Valid() && m_store.Owns(m_it->GetKey()); } + DataStream GetKey() + { + DataStream key{m_it->GetKey()}; + key.ignore(m_store.m_prefix.size()); + return key; + } + unsigned int GetKeySize() { return m_it->GetKeySize() - m_store.m_prefix.size(); } + + private: + void SkipForeign(); + + const CEvoDBStore& m_store; + std::unique_ptr m_it; + }; + + CEvoDBStore(CDBWrapper& db, DataStream prefix) : + m_db{db}, + m_prefix{std::move(prefix)} + { + } + + //! Prefix of generation `generation`'s namespace; generation 0 names the + //! NORMAL view, whose prefix is empty. + static DataStream GenerationPrefix(uint32_t generation); + + DataStream Key(const DataStream& key) const + { + DataStream out{m_prefix}; + out.write(Span{key.data(), key.size()}); + return out; + } + bool Owns(const DataStream& raw_key) const; + + template + bool Read(const DataStream& key, V& value) const + { + return m_db.Read(Key(key), value); + } + bool Exists(const DataStream& key) const { return m_db.Exists(Key(key)); } + Iterator* NewIterator() const { return new Iterator(*this, m_db.NewIterator()); } + +private: + CDBWrapper& m_db; + const DataStream m_prefix; +}; + +/** Write side of one view: a commit target that stores keys behind the view's + * prefix. */ +class CEvoDBStoreBatch +{ +public: + CEvoDBStoreBatch(CDBBatch& batch, const CEvoDBStore& store) : + m_batch{batch}, + m_store{store} + { + } + template + void Write(const DataStream& key, const V& value) + { + m_batch.Write(m_store.Key(key), value); + } + void Erase(const DataStream& key) { m_batch.Erase(m_store.Key(key)); } + +private: + CDBBatch& m_batch; + const CEvoDBStore& m_store; +}; + +/** Binds the calling thread's Evo accesses on `token.db` to `token` until + * destruction, which restores the enclosing binding. Scopes nest; the + * innermost one for a CEvoDB wins. A scope must agree with a transaction the + * same thread holds open on that CEvoDB, or construction raises + * EvoViewBindingError. */ +class EvoViewScope +{ +public: + explicit EvoViewScope(const EvoViewToken& token); + ~EvoViewScope(); + EvoViewScope(const EvoViewScope&) = delete; + EvoViewScope& operator=(const EvoViewScope&) = delete; + + //! The innermost token this thread bound for `db`, if any. + static const EvoViewToken* Innermost(const CEvoDB& db); + //! The innermost token this thread bound for any CEvoDB, if any. + static std::optional InnermostAny(); + +private: + const EvoViewToken m_token; + const EvoViewScope* const m_outer; +}; + class CEvoDBScopedCommitter { private: @@ -72,17 +285,21 @@ class CEvoDB private: std::unique_ptr db; - using RootTransaction = CDBTransaction; + using RootTransaction = CDBTransaction; using CurTransaction = CDBTransaction; struct TransactionContext { + CEvoDBStore store; CDBBatch root_batch; + CEvoDBStoreBatch commit_target; RootTransaction root_transaction; CurTransaction cur_transaction; - explicit TransactionContext(CDBWrapper& db) : + TransactionContext(CDBWrapper& db, DataStream prefix) : + store{db, std::move(prefix)}, root_batch{db}, - root_transaction{db, root_batch}, + commit_target{root_batch, store}, + root_transaction{store, commit_target}, cur_transaction{root_transaction, root_transaction} { } @@ -105,7 +322,13 @@ class CEvoDB TransactionContext& GetContext(EvoDbIdentity identity) EXCLUSIVE_LOCKS_REQUIRED(cs); const TransactionContext& GetContext(EvoDbIdentity identity) const EXCLUSIVE_LOCKS_REQUIRED(cs); - EvoDbIdentity GetCurrentIdentity() const EXCLUSIVE_LOCKS_REQUIRED(cs); + EvoDbIdentity GetCurrentIdentity() const EXCLUSIVE_LOCKS_REQUIRED(cs) { return ResolveView().identity; } + EvoViewToken MakeView(EvoDbIdentity identity) const EXCLUSIVE_LOCKS_REQUIRED(cs); + EvoViewToken ResolveView() const EXCLUSIVE_LOCKS_REQUIRED(cs); + bool HoldsTransaction() const EXCLUSIVE_LOCKS_REQUIRED(cs) + { + return active_transaction.has_value() && active_transaction_thread == std::this_thread::get_id(); + } public: CEvoDB() = delete; @@ -124,9 +347,39 @@ class CEvoDB bool HasActiveTransaction() const EXCLUSIVE_LOCKS_REQUIRED(!cs) { LOCK(cs); - return active_transaction.has_value() && active_transaction_thread == std::this_thread::get_id(); + return HoldsTransaction(); } + //! Changes whenever cached Evo-derived state of any view may have gone + //! stale: a CEvoDB was opened, or a snapshot generation was rejected or + //! promoted. EvoViewCache drops every partition when it changes. + static uint64_t ViewCacheEpoch() { return g_view_cache_epoch.load(); } + + //! The view this thread's Evo accesses resolve to: its open transaction, + //! else its innermost EvoViewScope, else the default identity. While a + //! published snapshot generation is live there is no default: an unbound access, or + //! one bound to a retired generation, raises EvoViewBindingError before it + //! touches any state. So does every read, write, iterator and cache lookup + //! that resolves its view through here. + EvoViewToken CurrentView() const EXCLUSIVE_LOCKS_REQUIRED(!cs); + //! Whether this thread's accesses currently resolve to exactly `view`. + bool IsCurrentView(const EvoViewToken& view) const EXCLUSIVE_LOCKS_REQUIRED(!cs); + //! The current view of a chainstate with the given identity. + EvoViewToken ViewFor(EvoDbIdentity identity) const EXCLUSIVE_LOCKS_REQUIRED(!cs); + //! Whether this thread already binds its accesses to this CEvoDB, through + //! an open transaction or an EvoViewScope. + bool IsBound() const EXCLUSIVE_LOCKS_REQUIRED(!cs); + //! Whether a published snapshot generation is live, so that two views exist. + bool HasLiveGeneration() const { return m_has_live_generation.load(); } + //! The view of the active chainstate. + EvoViewToken ActiveView() const EXCLUSIVE_LOCKS_REQUIRED(!cs); + //! Whether `view` still names a view of this CEvoDB: NORMAL, or the live + //! snapshot generation. + bool IsLive(const EvoViewToken& view) const EXCLUSIVE_LOCKS_REQUIRED(!cs); + //! Raise EvoViewBindingError if this thread holds a transaction on this + //! CEvoDB that disagrees with `token`. + void CheckScopeAgainstTransaction(const EvoViewToken& token) const EXCLUSIVE_LOCKS_REQUIRED(!cs); + CurTransaction& GetCurTransaction() EXCLUSIVE_LOCKS_REQUIRED(cs) { AssertLockHeld(cs); // lock must be held from outside as long as the DB transaction is used @@ -148,62 +401,43 @@ class CEvoDB } /** - * Write immutable block-derived data, accepting an identical existing value. - * V must have canonical serialization: its bytes must be a pure function of - * its logical content. Audited call-site types are CDeterministicMNListDiff, - * CDeterministicMNList, AbstractEHFManager::Signals, the mined-commitment - * pair, and CCreditPool. - * TODO(assumeutxo): WriteDerived spot-checks are not the holistic base-state - * comparison required at snapshot completion. + * Write immutable block-derived data, accepting an identical existing value + * in the current view. V must have canonical serialization: its bytes must + * be a pure function of its logical content. Audited call-site types are + * CDeterministicMNListDiff, CDeterministicMNList, + * AbstractEHFManager::Signals, the mined-commitment pair, and CCreditPool. */ template bool WriteDerived(const K& key, const V& value) EXCLUSIVE_LOCKS_REQUIRED(!cs) { LOCK(cs); - const EvoDbIdentity identity = GetCurrentIdentity(); - auto& transaction = GetContext(identity).cur_transaction; + auto& transaction = GetContext(GetCurrentIdentity()).cur_transaction; V existing; - bool write{true}; if (transaction.Read(key, existing)) { - CDataStream existing_stream{SER_DISK, CLIENT_VERSION}; - CDataStream value_stream{SER_DISK, CLIENT_VERSION}; - existing_stream << existing; - value_stream << value; - const bool matches = existing_stream.size() == value_stream.size() && - std::equal(existing_stream.begin(), existing_stream.end(), value_stream.begin()); - if (!matches) { + if (!SameSerialization(existing, value)) { LogPrintf("ERROR: CEvoDB::WriteDerived: block-derived payload mismatch in EvoDB\n"); return false; } - write = false; - } - - // Cross-identity writes of the same key are disjoint by construction - // (background validates blocks <= snapshot base; the snapshot chainstate - // validates blocks > base; seeded data is flushed at activation). This check is - // verification-only insurance for that invariant and must never suppress the - // caller's own write. - for (const auto& [other_identity, context] : transaction_contexts) { - if (other_identity == identity || !context) continue; - V pending; - if (!context->root_transaction.ReadPending(key, pending)) continue; - - CDataStream pending_stream{SER_DISK, CLIENT_VERSION}; - CDataStream value_stream{SER_DISK, CLIENT_VERSION}; - pending_stream << pending; - value_stream << value; - const bool matches = pending_stream.size() == value_stream.size() && - std::equal(pending_stream.begin(), pending_stream.end(), value_stream.begin()); - if (!matches) { - LogPrintf("ERROR: CEvoDB::WriteDerived: cross-identity block-derived payload mismatch in EvoDB\n"); - return false; - } + return true; } - - if (write) transaction.Write(key, value); + transaction.Write(key, value); return true; } + /** Write `value` under `key` in the current view immediately and durably, + * outside any transaction. */ + template + bool WriteDurable(const K& key, const V& value) EXCLUSIVE_LOCKS_REQUIRED(!cs) + { + LOCK(cs); + const auto& store{GetContext(GetCurrentIdentity()).store}; + CDBBatch batch{*db}; + DataStream ssKey{}; + ssKey << key; + batch.Write(store.Key(ssKey), value); + return db->WriteBatch(batch); + } + template bool Exists(const K& key) EXCLUSIVE_LOCKS_REQUIRED(!cs) { @@ -218,6 +452,9 @@ class CEvoDB GetContext(GetCurrentIdentity()).cur_transaction.Erase(key); } + /** The raw database, keyed in the NORMAL view's legacy keyspace. Only + * NORMAL-only lifecycle operations (DMN migration and repair, quorum + * cleanup) may use it: it bypasses view isolation. */ CDBWrapper& GetRawDB() { return *db; @@ -250,29 +487,82 @@ class CEvoDB bool ReadBestBlock(EvoDbIdentity identity, uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); bool VerifyBestBlock(EvoDbIdentity identity, const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); void WriteBestBlock(EvoDbIdentity identity, const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); - void WriteDualChainstateMarker() EXCLUSIVE_LOCKS_REQUIRED(!cs); bool HasDualChainstateMarker() EXCLUSIVE_LOCKS_REQUIRED(!cs); - //! Undo every snapshot lifecycle marker (SNAPSHOT best block, base and - //! background MN-list hashes, dual-chainstate marker). Needed when snapshot - //! activation is abandoned after those markers were already committed: a - //! stale dual-chainstate marker turns supported legacy bootstrapping into - //! "unavailable history", and a stale SNAPSHOT marker would let a later - //! snapshot directory pass ActivateExistingSnapshot(). - void EraseSnapshotMarkers() EXCLUSIVE_LOCKS_REQUIRED(!cs); + //! Lifecycle markers are written to the legacy keyspace immediately, never + //! into a transaction or a generation namespace. + void WriteDualChainstateMarker() EXCLUSIVE_LOCKS_REQUIRED(!cs); void WriteSnapshotBaseMNListHash(const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs); 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); + //! The live (published or promoting) snapshot generation, if any. + std::optional GetSnapshotGeneration() const EXCLUSIVE_LOCKS_REQUIRED(!cs); + + /** Start a new, unpublished snapshot generation for a snapshot whose base + * block is `base`, and bind the SNAPSHOT identity to it. Fails if a + * generation is already live. */ + bool CreateSnapshotGeneration(const uint256& base, int base_height) EXCLUSIVE_LOCKS_REQUIRED(!cs); + + /** Publish the bound generation together with the dual-chainstate markers, + * the consensus-configuration fingerprint it is valid for and the encoded + * evo section it was seeded from (empty for a UTXO-only snapshot). */ + bool PublishSnapshotGeneration(const uint256& config_fingerprint, const std::optional& base_mn_list_hash, + const std::vector& evo_section = {}) EXCLUSIVE_LOCKS_REQUIRED(!cs); + //! The evo section the live generation was published with, if any. + std::optional> ReadRetainedEvoSection() const EXCLUSIVE_LOCKS_REQUIRED(!cs); + + /** Startup: collect unpublished and rejected generations, then bind the + * live one. Fails closed if the live generation was published under a + * different consensus configuration, or if dual-chainstate markers + * predate generation isolation. */ + util::Result LoadSnapshotGenerations(const uint256& config_fingerprint) EXCLUSIVE_LOCKS_REQUIRED(!cs); + + /** Abandon the bound generation of a snapshot whose activation failed. */ + bool AbandonSnapshotGeneration() EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs); + + /** Record the decision to promote the bound generation at `snapshot_tip`. + * Must precede the first coins-directory rename. */ + bool PrepareSnapshotPromotion(const uint256& snapshot_tip) EXCLUSIVE_LOCKS_REQUIRED(!cs); + std::optional ReadPromotionJournal() const EXCLUSIVE_LOCKS_REQUIRED(!cs); + /** - * Atomically promote the surviving snapshot marker to the legacy NORMAL key - * and remove all dual-chainstate metadata. Both identity transaction trees - * must already be fully committed by the caller. + * Merge the promoted generation into the NORMAL keyspace, then atomically + * write the promoted best block and remove the journal and every + * dual-chainstate marker. Resumable from any crash after + * PrepareSnapshotPromotion. Both identity transaction trees must already be + * fully committed by the caller. */ bool PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) EXCLUSIVE_LOCKS_REQUIRED(!cs); - /** Remove snapshot metadata after rejecting a snapshot, preserving NORMAL. */ + /** Reject the bound generation and remove the dual-chainstate markers in + * one synced batch, preserving NORMAL. The REJECTED record remains until + * CollectRejectedGenerations(), after the coins directory is invalidated. */ bool DiscardSnapshotMarkers() EXCLUSIVE_LOCKS_REQUIRED(!cs); + //! Whether a rejected generation still awaits its coins invalidation. + bool HasRejectedGeneration() const EXCLUSIVE_LOCKS_REQUIRED(!cs); + //! Remove every rejected generation's records and namespace (idempotent). + bool CollectRejectedGenerations() EXCLUSIVE_LOCKS_REQUIRED(!cs); + + enum class PromotionFailpoint { + NONE, + MID_MERGE, + AFTER_MERGE, + AFTER_COLLECT, + }; + //! Make PromoteSnapshotMarkers stop with failure at `failpoint`, as if the + //! node crashed there. + void SetPromotionFailpointForTesting(PromotionFailpoint failpoint) EXCLUSIVE_LOCKS_REQUIRED(!cs) + { + LOCK(cs); + m_promotion_failpoint = failpoint; + } + //! Records merged per batch, so tests can stop a merge part-way. + void SetMergeBatchSizeForTesting(size_t size) EXCLUSIVE_LOCKS_REQUIRED(!cs) + { + LOCK(cs); + m_merge_batch_size = size; + } bool VerifyBestBlock(const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs) { return VerifyBestBlock(EvoDbIdentity::NORMAL, hash); } void WriteBestBlock(const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs) { WriteBestBlock(EvoDbIdentity::NORMAL, hash); } @@ -282,6 +572,127 @@ class CEvoDB friend class CEvoDBScopedCommitter; void CommitCurTransaction(EvoDbIdentity identity) EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs); void RollbackCurTransaction(EvoDbIdentity identity) EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs); + + template + static bool SameSerialization(const V& a, const V& b) + { + CDataStream a_stream{SER_DISK, CLIENT_VERSION}; + CDataStream b_stream{SER_DISK, CLIENT_VERSION}; + a_stream << a; + b_stream << b; + return a_stream.size() == b_stream.size() && std::equal(a_stream.begin(), a_stream.end(), b_stream.begin()); + } + + void BindSnapshotGeneration(std::optional record) EXCLUSIVE_LOCKS_REQUIRED(cs); + void UpdateLiveGeneration() EXCLUSIVE_LOCKS_REQUIRED(cs); + //! Whether no transaction is open and every transaction tree is committed. + bool TransactionsClean() const EXCLUSIVE_LOCKS_REQUIRED(cs); + bool CollectGeneration(uint32_t generation, bool erase_record) EXCLUSIVE_LOCKS_REQUIRED(cs); + std::vector RejectedGenerations() const EXCLUSIVE_LOCKS_REQUIRED(cs); + bool MergeGeneration(EvoPromotionJournal& journal) EXCLUSIVE_LOCKS_REQUIRED(cs); + bool DiscardBoundGeneration() EXCLUSIVE_LOCKS_REQUIRED(cs); + + static inline std::atomic g_view_cache_epoch{0}; + std::atomic m_has_live_generation{false}; + + //! The live generation the SNAPSHOT identity reads and writes. + std::optional m_snapshot_generation GUARDED_BY(cs); + PromotionFailpoint m_promotion_failpoint GUARDED_BY(cs){PromotionFailpoint::NONE}; + size_t m_merge_batch_size GUARDED_BY(cs){10000}; }; +/** Binds an active-chain dispatch (an RPC call, a P2P message, an interfaces + * call, a service work round) to the active chainstate's Evo view for its + * duration. Binds nothing while only one view exists, or when the thread is + * already bound (a captured background token, an open transaction). */ +class ActiveEvoViewScope +{ +public: + explicit ActiveEvoViewScope(const CEvoDB* evodb) + { + if (evodb == nullptr || !evodb->HasLiveGeneration() || evodb->IsBound()) return; + m_scope.emplace(evodb->ActiveView()); + } + +private: + std::optional m_scope; +}; + +//! Run one round of active-chain service work bound to the active view. A +//! local Evo state error ends the round, never the thread. +template +void RunActiveEvoRound(const CEvoDB* evodb, const char* what, F&& round) +{ + try { + const ActiveEvoViewScope evo_view{evodb}; + round(); + } catch (const evo::LocalStateError& e) { + LogPrintf("ERROR: %s: Evo state unavailable: %s\n", what, e.what()); + } +} + +/** The Evo view binding of a task handed to another thread: captured from the + * enqueuing thread's innermost scope and reinstalled when the task runs, so + * the executing thread's own binding never replaces it. A task whose + * snapshot generation was retired in between is dropped. */ +class EvoViewCapture +{ +public: + EvoViewCapture() : + m_view{EvoViewScope::InnermostAny()} + { + } + + enum class Outcome { + RAN, //!< the task ran to completion + FAILED, //!< the task started and ended in a local Evo state error + DROPPED, //!< the task never started + }; + + //! Run `task` under the captured binding. A task whose generation was + //! retired, or whose binding cannot be installed, is dropped. + template + Outcome Run(F&& task) const + { + std::optional scope; + if (m_view) { + if (!m_view->db->IsLive(*m_view)) { + LogPrint(BCLog::VALIDATION, "Dropping a task bound to retired snapshot generation %u\n", + m_view->generation); + return Outcome::DROPPED; + } + try { + scope.emplace(*m_view); + } catch (const evo::LocalStateError& e) { + LogPrintf("ERROR: dropping a queued task: %s\n", e.what()); + return Outcome::DROPPED; + } + } + try { + task(); + } catch (const evo::LocalStateError& e) { + LogPrintf("ERROR: Evo state unavailable to a queued task: %s\n", e.what()); + return Outcome::FAILED; + } + return Outcome::RAN; + } + +private: + std::optional m_view; +}; + +//! Wrap `task` so that it runs under the calling thread's current Evo view +//! binding wherever it is eventually executed. `on_drop` releases whatever +//! the enqueuer claimed for the task when it is dropped without starting; a +//! task that started releases its claims itself. +template +auto WithCapturedEvoView(F task, D on_drop = [] {}) +{ + return [task = std::move(task), on_drop = std::move(on_drop), evo_view = EvoViewCapture{}](auto&&... args) mutable { + if (evo_view.Run([&] { task(std::forward(args)...); }) == EvoViewCapture::Outcome::DROPPED) { + on_drop(); + } + }; +} + #endif // BITCOIN_EVO_EVODB_H diff --git a/src/evo/mnhftx.cpp b/src/evo/mnhftx.cpp index 806add0fd78f..a359cb82f2d2 100644 --- a/src/evo/mnhftx.cpp +++ b/src/evo/mnhftx.cpp @@ -6,12 +6,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -254,6 +256,8 @@ std::optional CMNHFManager::ProcessBlock(const CBlock& bl // M_ERROR so the block is not marked invalid. state.Error(e.what()); return std::nullopt; + } catch (const evo::LocalStateError&) { + throw; } catch (const std::exception& e) { LogPrintf("CMNHFManager::ProcessBlock -- failed: %s\n", e.what()); state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "failed-proc-mnhf-inblock"); @@ -301,6 +305,11 @@ CMNHFManager::Signals CMNHFManager::GetForBlock(const CBlockIndex* pindex) } CBlock block; if (!ReadBlockFromDisk(block, pindex_top, m_consensus_params)) { + // A body this node does not have (pruned, or below an unvalidated + // snapshot base) is a local state error, never the block's fault. + if (!WITH_LOCK(::cs_main, return pindex_top->nStatus & BLOCK_HAVE_DATA)) { + throw BlockDataUnavailableError("failed-getehfforblock-read"); + } throw std::runtime_error("failed-getehfforblock-read"); } BlockValidationState state; @@ -332,69 +341,100 @@ std::optional CMNHFManager::GetFromCache(const CBlockInde const uint256& blockHash = pindex->GetBlockHash(); - { - LOCK(cs_cache); - if (mnhfCache.get(blockHash, signals)) { - return signals; - } + const EvoViewToken view{m_evoDb.CurrentView()}; + if (WITH_LOCK(cs_cache, return mnhfCache.For(view).get(blockHash, signals))) { + MaybePersist(signals, pindex); + return signals; } { LOCK(cs_cache); if (!DeploymentActiveAt(*pindex, m_consensus_params, Consensus::DEPLOYMENT_V20)) { - mnhfCache.insert(blockHash, signals); + mnhfCache.For(view).insert(blockHash, signals); return signals; } } if (m_evoDb.Read(std::make_pair(DB_SIGNALS_v2, blockHash), signals)) { LOCK(cs_cache); - mnhfCache.insert(blockHash, signals); + mnhfCache.For(view).insert(blockHash, signals); return signals; } if (!DeploymentActiveAt(*pindex, m_consensus_params, Consensus::DEPLOYMENT_MN_RR)) { // before mn_rr activation we are safe if (m_evoDb.Read(std::make_pair(DB_SIGNALS, blockHash), signals)) { LOCK(cs_cache); - mnhfCache.insert(blockHash, signals); + mnhfCache.For(view).insert(blockHash, signals); return signals; } } return std::nullopt; } -void CMNHFManager::AddToCache(const Signals& signals, const CBlockIndex* const pindex) +void CMNHFManager::MaybePersist(const Signals& signals, const CBlockIndex* const pindex) { - assert(pindex != nullptr); - const uint256& blockHash = pindex->GetBlockHash(); - if (DeploymentActiveAt(*pindex, m_consensus_params, Consensus::DEPLOYMENT_V20) && - !m_evoDb.WriteDerived(std::make_pair(DB_SIGNALS_v2, blockHash), signals)) { + // Outside a block-scoped transaction (mempool acceptance, mining, RPC) a + // write would never be committed and trip the clean-transaction assertion + // at the next root commit. The signals stay cached; GetFromCache persists + // them from the next transaction-scoped lookup. + if (!DeploymentActiveAt(*pindex, m_consensus_params, Consensus::DEPLOYMENT_V20) || !m_evoDb.HasActiveTransaction()) { + return; + } + if (!m_evoDb.WriteDerived(std::make_pair(DB_SIGNALS_v2, pindex->GetBlockHash()), signals)) { // A mismatch is local EvoDB corruption, not a statement about the // block. Abort here: some callers (miner, RPC) never pass through a // validation-state catch, and the block-connect catches must not // translate this into a consensus rejection. - const std::string msg = strprintf("CMNHFManager::%s -- EvoDB MNHF state mismatch for block %s", - __func__, blockHash.ToString()); + const std::string msg = strprintf("CMNHFManager::%s -- EvoDB MNHF state mismatch for block %s", __func__, + pindex->GetBlockHash().ToString()); AbortNode(msg); throw EvoDbInconsistencyError(msg); } - { - LOCK(cs_cache); - mnhfCache.insert(blockHash, signals); - } } -bool CMNHFManager::ForceSignalDBUpdate(const CBlockIndex* tip) +void CMNHFManager::AddToCache(const Signals& signals, const CBlockIndex* const pindex) +{ + assert(pindex != nullptr); + MaybePersist(signals, pindex); + const EvoViewToken view{m_evoDb.CurrentView()}; + LOCK(cs_cache); + mnhfCache.For(view).insert(pindex->GetBlockHash(), signals); +} + +bool CMNHFManager::SeedSignals(const EvoViewToken& view, const CBlockIndex* pindex, const Signals& signals) +{ + if (!Assume(pindex != nullptr) || !Assume(m_evoDb.HasActiveTransaction())) return false; + const EvoViewScope scope{view}; + if (!m_evoDb.WriteDerived(std::make_pair(DB_SIGNALS_v2, pindex->GetBlockHash()), signals)) return false; + LOCK(cs_cache); + mnhfCache.For(view).erase(pindex->GetBlockHash()); + return true; +} + +bool CMNHFManager::ForceSignalDBUpdate(const EvoViewToken& view, const CBlockIndex* tip) { + const EvoViewScope evo_view{view}; // force ehf signals db update - auto dbTx = m_evoDb.BeginTransaction(); + auto dbTx = m_evoDb.BeginTransaction(view.identity); const bool last_legacy = bls::bls_legacy_scheme.load(); bls::bls_legacy_scheme.store(false); - GetSignalsStage(tip); + try { + GetSignalsStage(tip); + } catch (const evo::LocalStateError& e) { + // The signals are rebuilt on demand anyway. History this view lacks + // must not keep startup from, say, promoting a validated snapshot, + // which is what makes it readable. + bls::bls_legacy_scheme.store(last_legacy); + LogPrintf("CMNHFManager::%s -- skipping the EHF signal update: %s\n", __func__, e.what()); + return true; + } catch (...) { + bls::bls_legacy_scheme.store(last_legacy); + throw; + } bls::bls_legacy_scheme.store(last_legacy); dbTx->Commit(); // flush it to disk - if (!m_evoDb.CommitRootTransaction()) { + if (!m_evoDb.CommitRootTransaction(view.identity)) { LogPrintf("CMNHFManager::%s -- failed to commit to evoDB\n", __func__); return false; } diff --git a/src/evo/mnhftx.h b/src/evo/mnhftx.h index 8a0bc91455a7..0ebdd859f3e7 100644 --- a/src/evo/mnhftx.h +++ b/src/evo/mnhftx.h @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -104,7 +105,8 @@ class CMNHFManager : public AbstractEHFManager static constexpr size_t MNHFCacheSize = 1000; Mutex cs_cache; // versionBit <-> height - Uint256LruHashMap mnhfCache GUARDED_BY(cs_cache){MNHFCacheSize}; + EvoViewCache> mnhfCache GUARDED_BY(cs_cache){ + [] { return Uint256LruHashMap{MNHFCacheSize}; }}; public: CMNHFManager() = delete; @@ -130,19 +132,31 @@ class CMNHFManager : public AbstractEHFManager // Implements interface Signals GetSignalsStage(const CBlockIndex* const pindexPrev) override EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); - bool ForceSignalDBUpdate(const CBlockIndex* tip) EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs_cache); - -private: - void AddToCache(const Signals& signals, const CBlockIndex* const pindex) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); + //! Recompute and persist the signals at `tip` under `view`, the view of + //! the chainstate whose tip it is. Signals this node's Evo state cannot + //! rebuild are left to be rebuilt on demand. + bool ForceSignalDBUpdate(const EvoViewToken& view, const CBlockIndex* tip) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs_cache); + /** Seed the signal record of a snapshot base into `view`, inside the + * caller's open EvoDB transaction for that view. */ + bool SeedSignals(const EvoViewToken& view, const CBlockIndex* pindex, const Signals& signals) + EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); /** - * This function returns list of signals available on previous block. - * if the signals for previous block is not available in cache it would read blocks from disk - * until state won't be recovered. + * The raw signal record of a block, before GetSignalsStage() filters it. + * If neither the cache nor EvoDB has it, it is rebuilt from block data, + * persisting the rebuilt records only inside a block-scoped transaction. + * A body the rebuild needs and this node lacks raises + * BlockDataUnavailableError. * NOTE: that some signals could expired between blocks. */ Signals GetForBlock(const CBlockIndex* const pindex) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); +private: + void AddToCache(const Signals& signals, const CBlockIndex* const pindex) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); + //! Persist the signals at `pindex` when a block-scoped transaction can commit them. + void MaybePersist(const Signals& signals, const CBlockIndex* const pindex); + /** * This function access to in-memory cache or to evo db but does not calculate anything * NOTE: that some signals could expired between blocks. diff --git a/src/evo/smldiff.cpp b/src/evo/smldiff.cpp index 255e5a688251..611d73a3b361 100644 --- a/src/evo/smldiff.cpp +++ b/src/evo/smldiff.cpp @@ -21,8 +21,10 @@ using node::ReadBlockFromDisk; -// Forward declaration +// Forward declarations std::optional> GetNonNullCoinbaseChainlock(const CBlockIndex* pindex); +std::optional> GetNonNullCoinbaseChainlock(const CBlock& block, const CBlockIndex& index, + const Consensus::Params& consensus); CSimplifiedMNListDiff::CSimplifiedMNListDiff() = default; @@ -66,7 +68,8 @@ bool CSimplifiedMNListDiff::BuildQuorumsDiff(const CBlockIndex* baseBlockIndex, return true; } -bool CSimplifiedMNListDiff::BuildQuorumChainlockInfo(const llmq::CQuorumManager& qman, const CBlockIndex* blockIndex) +bool CSimplifiedMNListDiff::BuildQuorumChainlockInfo(const llmq::CQuorumManager& qman, const CBlockIndex* blockIndex, + std::string& error) { // Group quorums (indexes corresponding to entries of newQuorums) per CBlockIndex containing the expected CL // signature in CbTx. We want to avoid to load CbTx now, as more than one quorum will target the same block: hence @@ -95,7 +98,22 @@ bool CSimplifiedMNListDiff::BuildQuorumChainlockInfo(const llmq::CQuorumManager& for (auto it = workBaseBlockIndexMap.begin(); it != workBaseBlockIndexMap.end();) { // Process each key (CBlockIndex containing the expected CL signature in CbTx) of the std::multimap once const CBlockIndex* pWorkBaseBlockIndex = it->first; - const auto cbcl = GetNonNullCoinbaseChainlock(pWorkBaseBlockIndex); + std::optional> cbcl; + if (qman.CurrentView().identity == EvoDbIdentity::SNAPSHOT) { + // Without the work block's body the snapshot view cannot tell a + // certificate from none; never serve a guessed null signature. + const auto& consensus{Params().GetConsensus()}; + CBlock block; + if (WITH_LOCK(::cs_main, return (pWorkBaseBlockIndex->nStatus & BLOCK_HAVE_DATA) == 0) || + !ReadBlockFromDisk(block, pWorkBaseBlockIndex, consensus)) { + error = strprintf("coinbase ChainLock of block %s %s", pWorkBaseBlockIndex->GetBlockHash().ToString(), + BLOCK_DATA_UNAVAILABLE_SUFFIX); + return false; + } + cbcl = GetNonNullCoinbaseChainlock(block, *pWorkBaseBlockIndex, consensus); + } else { + cbcl = GetNonNullCoinbaseChainlock(pWorkBaseBlockIndex); + } CBLSSignature sig; if (cbcl.has_value()) { sig = cbcl.value().first; @@ -219,8 +237,16 @@ bool BuildSimplifiedMNListDiff(CDeterministicMNManager& dmnman, const Chainstate } if (DeploymentActiveAfter(blockIndex, chainman.GetConsensus(), Consensus::DEPLOYMENT_V20)) { - if (!mnListDiffRet.BuildQuorumChainlockInfo(qman, blockIndex)) { - errorRet = strprintf("failed to build quorum chainlock info"); + std::string chainlock_error; + try { + if (!mnListDiffRet.BuildQuorumChainlockInfo(qman, blockIndex, chainlock_error)) { + errorRet = chainlock_error.empty() ? "failed to build quorum chainlock info" : chainlock_error; + return false; + } + } catch (const BlockDataUnavailableError& e) { + // Unavailable snapshot-view state; the sentinel keeps the requesting + // peer from being penalized. + errorRet = strprintf("%s %s", e.what(), BLOCK_DATA_UNAVAILABLE_SUFFIX); return false; } } diff --git a/src/evo/smldiff.h b/src/evo/smldiff.h index 7c3d6b67fd2b..d6d4b417779b 100644 --- a/src/evo/smldiff.h +++ b/src/evo/smldiff.h @@ -83,7 +83,10 @@ class CSimplifiedMNListDiff bool BuildQuorumsDiff(const CBlockIndex* baseBlockIndex, const CBlockIndex* blockIndex, const llmq::CQuorumBlockProcessor& quorum_block_processor); - bool BuildQuorumChainlockInfo(const llmq::CQuorumManager& qman, const CBlockIndex* blockIndex); + /** Collect the coinbase ChainLock each new quorum's work block certifies. + * A certificate this node cannot read is an error carrying + * BLOCK_DATA_UNAVAILABLE_SUFFIX, never a null signature. */ + bool BuildQuorumChainlockInfo(const llmq::CQuorumManager& qman, const CBlockIndex* blockIndex, std::string& error); [[nodiscard]] static RPCResult GetJsonHelp(const std::string& key, bool optional); [[nodiscard]] UniValue ToJson(bool extended = false) const; diff --git a/src/evo/snapshot.cpp b/src/evo/snapshot.cpp index 04cffdba85e8..e82938d19574 100644 --- a/src/evo/snapshot.cpp +++ b/src/evo/snapshot.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,7 @@ #include #include #include +#include #include #include @@ -125,28 +127,6 @@ void ValidateCanonicalMNInvariants(const CDeterministicMNList& list) } // 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}; @@ -227,6 +207,30 @@ void EvoSnapshot::Validate(bool require_canonical_order) const } ValidateCanonicalMNInvariants(mn_list); mn_list.ForEachMN(/*onlyValid=*/false, [](const auto& dmn) { ValidateMNEncoding(dmn); }); + if (base_coinbase) { + const CTransaction& coinbase{*base_coinbase}; + // A 64-byte transaction could pass for an inner merkle node. + if (!coinbase.IsCoinBase() || coinbase.nType != TRANSACTION_COINBASE || + coinbase.vExtraPayload.size() > MAX_TX_EXTRA_PAYLOAD || ::GetSerializeSize(coinbase, PROTOCOL_VERSION) <= 64 || + ::GetSerializeSize(coinbase, PROTOCOL_VERSION) > EVO_SNAPSHOT_MAX_COINBASE_BYTES) { + throw std::ios_base::failure("invalid evo snapshot base coinbase"); + } + // The CbTx header precedes its scheme-dependent fields, so the height + // is readable without knowing the base's BLS scheme. + CDataStream payload{coinbase.vExtraPayload, SER_NETWORK, PROTOCOL_VERSION}; + uint16_t cbtx_version; + int32_t cbtx_height; + payload >> cbtx_version >> cbtx_height; + if (cbtx_height != mn_list.GetHeightForSnapshotCodec()) { + throw std::ios_base::failure("evo snapshot base coinbase height mismatch"); + } + if (base_coinbase_branch.size() > EVO_SNAPSHOT_MAX_COINBASE_BRANCH) { + throw std::ios_base::failure("oversized evo snapshot base coinbase branch"); + } + } else if (!base_coinbase_branch.empty() || mn_list.GetCounts().total() != 0 || !quorums.empty()) { + // Without a CbTx at the base there can be no MN or quorum state. + throw std::ios_base::failure("evo snapshot state without a base coinbase"); + } if (quorums.size() > Consensus::available_llmqs.size() || historical_mn_list_diffs.size() > EvoSnapshotMaxHistoricalMNLists() || quorum_modifiers.size() > EVO_SNAPSHOT_MAX_MODIFIERS || @@ -248,6 +252,23 @@ void EvoSnapshot::Validate(bool require_canonical_order) const if (credit_pool.indexes.RangeCount() > EVO_SNAPSHOT_MAX_RANGES) { throw std::ios_base::failure("oversized CRangesSet range count"); } + // The window entries sum to the pool's lately unlocked amount and end at + // the base, whose recorded balance is the pool's. + if (credit_history.size() > EVO_SNAPSHOT_MAX_CREDIT_HISTORY) { + throw std::ios_base::failure("oversized evo snapshot credit history"); + } + CAmount window_unlocked{0}; + for (const auto& entry : credit_history) { + const auto sum{CheckedAdd(window_unlocked, entry.unlocked)}; + if (!MoneyRange(entry.credit_pool) || !MoneyRange(entry.unlocked) || !sum || !MoneyRange(*sum)) { + throw std::ios_base::failure("invalid evo snapshot credit history amount"); + } + window_unlocked = *sum; + } + if (window_unlocked != credit_pool.latelyUnlocked || + (!credit_history.empty() && credit_history.back().credit_pool != credit_pool.locked)) { + throw std::ios_base::failure("evo snapshot credit history disagrees with the credit pool"); + } // Consensus admits MNHF signals only for bits below VERSIONBITS_NUM_BITS // and records the mined height, which cannot exceed the base height. for (const auto& [bit, height] : mnhf_signals) { @@ -279,13 +300,11 @@ void EvoSnapshot::Validate(bool require_canonical_order) const } 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})) { + // Parameter-derived counts are maxima here. The chain-aware validation + // requires the exact sets the dependency planner derives. + if (data.active_commitments.size() > active_count || data.safety_commitments.size() > total_count - active_count || + data.rotation_snapshots.size() > (data.rotation_enabled ? EvoSnapshotMaxRotationSnapshots(params) : size_t{0}) || + data.bootstrap_work.size() > EVO_SNAPSHOT_MAX_BOOTSTRAP_WORK) { throw std::ios_base::failure("invalid params-derived evo per-type quorum counts"); } std::set quorum_hashes; @@ -304,7 +323,7 @@ void EvoSnapshot::Validate(bool require_canonical_order) const 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) || + (!historical_hashes.contains(entry.work_block_hash) && entry.work_block_hash != base_block_hash) || entry.snapshot.mnSkipListMode < SnapshotSkipMode::MODE_NO_SKIPPING || entry.snapshot.mnSkipListMode > SnapshotSkipMode::MODE_ALL_SKIPPED || entry.snapshot.activeQuorumMembers.size() > EVO_SNAPSHOT_MAX_MNS || @@ -319,7 +338,22 @@ void EvoSnapshot::Validate(bool require_canonical_order) const required_work_hashes.insert(entry.work_block_hash); required_modifiers.emplace(data.llmq_type, entry.work_block_hash); } + if (require_canonical_order && !IsCanonicallySorted(data.bootstrap_work)) { + throw std::ios_base::failure("noncanonical evo bootstrap work order"); + } + std::set bootstrap_heights; + for (const auto& entry : data.bootstrap_work) { + if (entry.cycle_base_height < 0 || entry.work_block_hash.IsNull() || + !bootstrap_heights.insert(entry.cycle_base_height).second) { + throw std::ios_base::failure("invalid evo bootstrap work entry"); + } + required_work_hashes.insert(entry.work_block_hash); + required_modifiers.emplace(data.llmq_type, entry.work_block_hash); + } } + // The base list is the work list for a work block equal to the base, so + // it has no historical diff of its own. + required_work_hashes.erase(base_block_hash); if (historical_hashes != required_work_hashes) { throw std::ios_base::failure("missing or extra historical MN-list diff target"); } @@ -346,6 +380,37 @@ uint256 GetEvoSnapshotHash(const EvoSnapshot& snapshot) return hash; } +uint256 ComputeMerkleRootFromBranch(const uint256& leaf, const std::vector& branch, uint32_t index) +{ + uint256 hash{leaf}; + for (const uint256& node : branch) { + hash = (index & 1) ? Hash(node, hash) : Hash(hash, node); + index >>= 1; + } + return hash; +} + +std::vector BlockMerkleBranch(const CBlock& block, uint32_t index) +{ + std::vector level; + level.reserve(block.vtx.size()); + for (const auto& tx : block.vtx) + level.push_back(tx->GetHash()); + std::vector branch; + while (level.size() > 1) { + // ComputeMerkleRoot pairs an odd last node with itself. + const size_t sibling{(index ^ 1) < level.size() ? (index ^ 1) : index}; + branch.push_back(level[sibling]); + std::vector next; + for (size_t i{0}; i < level.size(); i += 2) { + next.push_back(Hash(level[i], level[std::min(i + 1, level.size() - 1)])); + } + level = std::move(next); + index >>= 1; + } + return branch; +} + bool VerifyEvoSnapshotCbTx(const EvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error) { error.clear(); diff --git a/src/evo/snapshot.h b/src/evo/snapshot.h index 00d97aff56d0..c3760922cc40 100644 --- a/src/evo/snapshot.h +++ b/src/evo/snapshot.h @@ -9,13 +9,16 @@ #include #include #include +#include #include #include #include +#include #include #include #include +#include #include #include @@ -25,21 +28,37 @@ #include #include #include +#include #include #include #include #include +class CBlockIndex; +class CChainParams; +class ChainstateManager; class CCbTx; +class CBlock; +class CCreditPoolManager; +class CMNHFManager; +class CEvoDB; +class CSpecialTxProcessor; +struct EvoViewToken; + +namespace llmq { +class CQuorumBlockProcessor; +class CQuorumSnapshotManager; +struct ScanCoverageRecord; +} // namespace llmq +namespace node { +class BlockManager; +} // namespace node namespace evo { -static constexpr uint16_t EVO_SNAPSHOT_VERSION{3}; +static constexpr uint16_t EVO_SNAPSHOT_VERSION{4}; /** 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. @@ -55,7 +74,6 @@ static constexpr size_t EVO_SNAPSHOT_MAX_PAYOUT_SHARES{8}; // 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, @@ -76,15 +94,73 @@ static constexpr size_t EVO_SNAPSHOT_MAX_QUORUM_SIZE{10'000}; static constexpr size_t EVO_SNAPSHOT_MAX_HASH_PREFIX_RUN{8}; // Every historical entry costs a full traversal, sort, and canonical hash of // the reconstructed list, so a few hundred bytes of zero-operation diffs could -// otherwise drag a maximum-size list across the whole table-wide history -// horizon (~19M record visits). The horizon that sums every table entry is -// unreachable on a real chain: no network enables more than a fraction of the -// LLMQ table at once, so even a ceiling-sized list on a fully loaded mainnet -// configuration stays well below half of this cumulative record budget. +// otherwise drag a maximum-size list across every planned work block: the +// table-wide bound is 436 lists (330 for the mainnet table), ~44M record visits +// at the 100,000-MN ceiling, so this budget refuses such inputs. An honest base +// plans only the work blocks its commitments and h/N name: tens of lists at +// today's populations of a few thousand MNs, and even the worst case of every +// LLMQ_60_75 index failing its DKG in a different cycle (262 lists) stays below +// a million visits at 3,000 MNs. static constexpr size_t EVO_SNAPSHOT_MAX_RECONSTRUCTION_RECORDS{8'000'000}; +// The base coinbase is length-delimited and bounded before allocation; its +// merkle branch proves it against the base header, so it is at most as deep as +// a tree over 2^32 transactions. +static constexpr size_t EVO_SNAPSHOT_MAX_COINBASE_BYTES{1'000'000}; +static constexpr size_t EVO_SNAPSHOT_MAX_COINBASE_BRANCH{32}; +// The credit-pool window is CreditPoolPeriodBlocks() long: 576 on main, test +// and devnet, 100 on regtest. A test pins every network to this bound. +static constexpr size_t EVO_SNAPSHOT_MAX_CREDIT_HISTORY{576}; +// At most two bootstrap work entries per type: the cycle containing the base +// and the next one. +static constexpr size_t EVO_SNAPSHOT_MAX_BOOTSTRAP_WORK{2}; 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"); +// Bootstrap work entries are ordered by cycle height, and a cycle's work block +// precedes it. Both need every cycle to be longer than the work depth. +static_assert(std::ranges::all_of(Consensus::available_llmqs, + [](const auto& params) { return params.dkgInterval > llmq::WORK_DIFF_DEPTH; }), + "an LLMQ cycle must be longer than the quorum work depth"); + +/** Commitments carried for one type: max(a + 1, keepOld) without rotation, 2a with it. */ +constexpr size_t SnapshotCommitmentCountFor(const Consensus::LLMQParams& params, bool rotation_enabled) +{ + return rotation_enabled + ? 2 * static_cast(params.signingActiveQuorumCount) + : static_cast(std::max(params.signingActiveQuorumCount + 1, params.keepOldConnections)); +} + +/** Rotation quarters a rotating type can need: three per commitment cycle, plus h..h-3C. */ +constexpr size_t EvoSnapshotMaxRotationSnapshots(const Consensus::LLMQParams& params) +{ + return 3 * SnapshotCommitmentCountFor(params, /*rotation_enabled=*/true) + 4; +} + +/** Work blocks one type can need, over every mode the type can run in. */ +constexpr size_t EvoSnapshotMaxWorkBlocks(const Consensus::LLMQParams& params) +{ + const size_t plain{SnapshotCommitmentCountFor(params, /*rotation_enabled=*/false) + 2}; + if (!params.useRotation) return plain; + return std::max(plain, 4 * SnapshotCommitmentCountFor(params, /*rotation_enabled=*/true) + 6); +} + +/** + * Maximum number of distinct historical work-block lists a snapshot can need. + * The serialized set is deduplicated, so summing every type's bound over the + * static table is conservative. + */ +constexpr size_t EvoSnapshotMaxHistoricalMNLists() +{ + size_t count{0}; + for (const auto& params : Consensus::available_llmqs) + count += EvoSnapshotMaxWorkBlocks(params); + return count; +} +static constexpr size_t EVO_SNAPSHOT_MAX_MODIFIERS{4'096}; +// Modifiers are one per (type, work block), so they number at most the sum of +// the per-type work-block bounds. +static_assert(EvoSnapshotMaxHistoricalMNLists() <= EVO_SNAPSHOT_MAX_MODIFIERS, + "one modifier per (type, work block) must fit the modifier bound"); template size_t ReadBoundedCompactSize(Stream& s, size_t limit, const char* field) @@ -111,32 +187,12 @@ inline const Consensus::LLMQParams& SnapshotLLMQParams(Consensus::LLMQType type) 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); + if (rotation_enabled && params.keepOldConnections > 2 * params.signingActiveQuorumCount) { + throw std::ios_base::failure("rotated LLMQ retention exceeds two cycles"); } - return count; + return SnapshotCommitmentCountFor(params, rotation_enabled); } /** @@ -476,12 +532,27 @@ struct QuorumModifier { SERIALIZE_METHODS(QuorumModifier, obj) { READWRITE(obj.llmq_type, obj.work_block_hash, obj.modifier); } }; +/** + * A work block the snapshot carries for the cycle containing the base (h) or + * the next one (N) that no carried commitment or rotation quarter references. + * The cycle need not be mined: only its height and the work block are named. + */ +struct BootstrapWork { + int32_t cycle_base_height{-1}; + uint256 work_block_hash; + + bool operator==(const BootstrapWork&) const = default; + SERIALIZE_METHODS(BootstrapWork, obj) { READWRITE(obj.cycle_base_height, obj.work_block_hash); } +}; + struct QuorumSnapshotData { Consensus::LLMQType llmq_type{Consensus::LLMQType::LLMQ_NONE}; bool rotation_enabled{false}; std::vector active_commitments; std::vector safety_commitments; std::vector rotation_snapshots; + //! Strictly ascending by cycle height. + std::vector bootstrap_work; template void Serialize(Stream& s) const; template void Unserialize(Stream& s); @@ -494,10 +565,16 @@ class EvoSnapshot uint16_t version{EVO_SNAPSHOT_VERSION}; uint256 base_block_hash; CDeterministicMNList mn_list; + //! The base block's coinbase, null exactly when DIP3 is inactive at the base. + CTransactionRef base_coinbase; + //! Merkle branch of the coinbase (index 0) to the base header, leaf to root. + std::vector base_coinbase_branch; std::vector quorums; std::vector historical_mn_list_diffs; std::vector quorum_modifiers; CCreditPool credit_pool; + //! The window entries of the blocks ending at the base, oldest first. + std::vector credit_history; AbstractEHFManager::Signals mnhf_signals; template void Serialize(Stream& s) const; @@ -534,6 +611,11 @@ inline bool IsCanonicallyBefore(const QuorumModifier& a, const QuorumModifier& b return std::tie(a.llmq_type, a.work_block_hash) < std::tie(b.llmq_type, b.work_block_hash); } +inline bool IsCanonicallyBefore(const BootstrapWork& a, const BootstrapWork& b) +{ + return a.cycle_base_height < b.cycle_base_height; +} + inline bool IsCanonicallyBefore(const QuorumSnapshotData& a, const QuorumSnapshotData& b) { return a.llmq_type < b.llmq_type; @@ -599,6 +681,7 @@ void QuorumSnapshotData::Serialize(Stream& s) const const auto snapshots{CanonicallySortedCopy(rotation_snapshots)}; s << llmq_type << rotation_enabled << active << safety; WriteSnapshotVector(s, snapshots, [&](const auto& entry) { WriteRotationSnapshot(s, entry); }); + s << CanonicallySortedCopy(bootstrap_work); } template @@ -614,6 +697,7 @@ void QuorumSnapshotData::Unserialize(Stream& s) active_commitments.clear(); safety_commitments.clear(); rotation_snapshots.clear(); + bootstrap_work.clear(); const auto& params{SnapshotLLMQParams(llmq_type)}; const size_t total_count{SnapshotCommitmentCount(params, rotation_enabled)}; const size_t expected_active{static_cast(params.signingActiveQuorumCount)}; @@ -627,9 +711,20 @@ void QuorumSnapshotData::Unserialize(Stream& s) for (size_t i{0}; i < safety_count; ++i) { safety_commitments.emplace_back(ReadMinedQuorumCommitment(s)); } - const size_t snapshot_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_ROTATION_CYCLES, "rotation snapshots")}; + const size_t snapshot_count{ + ReadBoundedCompactSize(s, rotation_enabled ? EvoSnapshotMaxRotationSnapshots(params) : size_t{0}, + "rotation snapshots")}; rotation_snapshots.reserve(snapshot_count); for (size_t i{0}; i < snapshot_count; ++i) rotation_snapshots.emplace_back(ReadRotationSnapshot(s, params)); + const size_t bootstrap_count{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_BOOTSTRAP_WORK, "bootstrap work")}; + for (size_t i{0}; i < bootstrap_count; ++i) { + BootstrapWork entry; + s >> entry; + if (!bootstrap_work.empty() && bootstrap_work.back().cycle_base_height >= entry.cycle_base_height) { + throw std::ios_base::failure("noncanonical evo bootstrap work order"); + } + bootstrap_work.emplace_back(entry); + } } template @@ -640,6 +735,15 @@ void EvoSnapshot::Serialize(Stream& s) const const auto sorted_modifiers{CanonicallySortedCopy(quorum_modifiers)}; s << version << base_block_hash; SerializeCanonicalMNList(s, mn_list); + if (base_coinbase) { + CDataStream coinbase{SER_NETWORK, PROTOCOL_VERSION}; + coinbase << *base_coinbase; + WriteCompactSize(s, coinbase.size()); + s.write(MakeByteSpan(coinbase)); + } else { + WriteCompactSize(s, 0); + } + s << base_coinbase_branch; s << sorted_quorums; WriteCompactSize(s, sorted_history.size()); for (const auto& entry : sorted_history) { @@ -648,6 +752,7 @@ void EvoSnapshot::Serialize(Stream& s) const } s << sorted_modifiers; s << credit_pool.locked << credit_pool.currentLimit << credit_pool.latelyUnlocked << credit_pool.indexes; + s << credit_history; WriteCompactSize(s, mnhf_signals.size()); for (const auto& signal : mnhf_signals) s << signal; } @@ -664,8 +769,28 @@ void EvoSnapshot::Unserialize(Stream& s) quorums.clear(); historical_mn_list_diffs.clear(); quorum_modifiers.clear(); + credit_history.clear(); mnhf_signals.clear(); mn_list = UnserializeCanonicalMNList(s); + base_coinbase.reset(); + if (const size_t coinbase_size{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_COINBASE_BYTES, "base coinbase")}) { + std::vector bytes(coinbase_size); + s.read(bytes); + CDataStream coinbase{bytes, SER_NETWORK, PROTOCOL_VERSION}; + CMutableTransaction tx; + coinbase >> tx; + if (!coinbase.empty()) throw std::ios_base::failure("trailing bytes after the evo snapshot base coinbase"); + base_coinbase = MakeTransactionRef(std::move(tx)); + CDataStream reencoded{SER_NETWORK, PROTOCOL_VERSION}; + reencoded << *base_coinbase; + if (reencoded.size() != bytes.size() || !std::equal(bytes.begin(), bytes.end(), reencoded.begin())) { + throw std::ios_base::failure("noncanonical evo snapshot base coinbase"); + } + } + const size_t branch_size{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_COINBASE_BRANCH, "coinbase branch")}; + base_coinbase_branch.resize(branch_size); + for (auto& node : base_coinbase_branch) + s >> node; 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) { @@ -692,6 +817,10 @@ void EvoSnapshot::Unserialize(Stream& s) } s >> credit_pool.locked >> credit_pool.currentLimit >> credit_pool.latelyUnlocked; credit_pool.indexes.UnserializeBounded(s, EVO_SNAPSHOT_MAX_RANGES); + const size_t history_entries{ReadBoundedCompactSize(s, EVO_SNAPSHOT_MAX_CREDIT_HISTORY, "credit history")}; + credit_history.resize(history_entries); + for (auto& entry : credit_history) + s >> entry; const size_t signal_count{ReadBoundedCompactSize(s, VERSIONBITS_NUM_BITS, "MNHF signals")}; // The signal map normalizes iteration order, so wire order is observable // only here: require the strictly ascending bit order the serializer @@ -712,24 +841,150 @@ void EvoSnapshot::Unserialize(Stream& s) /** Single SHA256 of the canonical SER_DISK/CLIENT_VERSION encoding. */ uint256 GetEvoSnapshotHash(const EvoSnapshot& snapshot); -struct QuorumReconstructionHeight { - Consensus::LLMQType llmq_type; - bool rotation; - int quorum_height; - int work_height; +/** A work block the snapshot carries for one type, and the cycle (or, without + * rotation, the quorum) whose score modifier it carries. */ +struct EvoSnapshotWorkBlock { + const CBlockIndex* work_index{nullptr}; + int cycle_height{-1}; }; -/** Pure conservative reconstruction horizon for the supplied enabled types. */ -std::vector EvoSnapshotReconstructionHeights( - int base_height, const std::vector& enabled_llmqs); +/** The Evo state one LLMQ type needs at a snapshot base: every quorum member + * computation the snapshot chainstate makes at or below the base reads only + * these, plus the carried commitments. */ +struct EvoSnapshotDependencyPlan { + Consensus::LLMQType llmq_type{Consensus::LLMQType::LLMQ_NONE}; + bool rotation_enabled{false}; + //! S_T: cycles whose rotation quarters are carried, ascending. + std::vector rotation_cycles; + //! W_T: work blocks whose lists and modifiers are carried, ascending. + std::vector work_blocks; + //! The entries of W_T for h and N that nothing else references. + std::vector bootstrap_work; +}; + +/** The work block a quorum's members are computed from: the cycle's work block + * with rotation, otherwise the quorum's own block before v20 and its work + * block after. */ +int EvoSnapshotWorkHeight(const Consensus::Params& consensus, const Consensus::LLMQParams& params, + bool rotation_enabled, int quorum_height); + +/** Derive the exact dependency sets of `llmq_type` at `base_index` from the + * carried commitments' quorum blocks. The builder, the chain validator and + * the completion rebuild all use it. */ +bool PlanEvoSnapshotDependencies(const ChainstateManager& chainman, const CBlockIndex* base_index, + Consensus::LLMQType llmq_type, const std::vector& commitments, + EvoSnapshotDependencyPlan& plan, std::string& error) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + +/** The inputs of a section that only block bodies provide, as the background + * chainstate captured them while connecting those blocks. */ +struct EvoSnapshotCaptures { + std::map, uint256> modifiers; + std::map credit_history; + CTransactionRef base_coinbase; + std::vector base_coinbase_branch; + std::optional base_credit_pool; +}; + +/** The blocks at or below the base whose bodies a section's inputs come from: + * the work blocks of the v20 modifiers, the credit-pool window and the base, + * whose pool folds in every block back to the last pool on disk. */ +struct EvoSnapshotCapturePlan { + uint256 base; + int base_height{-1}; + int window_first{-1}; + std::map> modifier_blocks; + //! Heights of every block whose body is an input. + std::set heights; +}; + +std::optional PlanEvoSnapshotCaptures(const EvoSnapshot& snapshot, const ChainstateManager& chainman) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + +/** Record, in the calling thread's open transaction, what `block` contributes + * to the planned inputs. At the base that includes its credit pool, read + * through `cpoolman` in the calling thread's Evo view; a LocalStateError + * from that read propagates. */ +bool WriteEvoSnapshotCaptures(CEvoDB& evodb, CCreditPoolManager& cpoolman, const EvoSnapshotCapturePlan& plan, + const CBlock& block, const CBlockIndex& index, const Consensus::Params& consensus, + std::string& error); + +/** The planned inputs captured so far. */ +EvoSnapshotCaptures ReadEvoSnapshotCaptures(CEvoDB& evodb, const EvoSnapshotCapturePlan& plan, + const ChainstateManager& chainman) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + +/** Remove every capture on disk, whatever snapshot it was for. Only for when + * no snapshot generation is live, e.g. after a crash before publication. */ +bool EraseAllEvoSnapshotCaptures(CEvoDB& evodb); + +/** Remove every capture, in the calling thread's open transaction. */ +void EraseEvoSnapshotCaptures(CEvoDB& evodb, const EvoSnapshotCapturePlan& plan, const ChainstateManager& chainman) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + +/** Build the Evo section at `base_index` from the calling thread's Evo view. + * Body-derived inputs come from `captures` where present, otherwise from the + * blocks. */ +bool BuildEvoSnapshot(const CChainParams& chainparams, const ChainstateManager& chainman, + CDeterministicMNManager& dmnman, const llmq::CQuorumBlockProcessor& qblockman, + llmq::CQuorumSnapshotManager& qsnapman, CCreditPoolManager& cpoolman, CMNHFManager& mnhfman, + const CBlockIndex* base_index, EvoSnapshot& snapshot, std::string& error, + const EvoSnapshotCaptures* captures = nullptr) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); /** Apply the complete diff chain and return lists keyed by target block hash. */ bool ReconstructHistoricalMNLists(const EvoSnapshot& snapshot, std::map& lists, std::string& error, size_t max_records = EVO_SNAPSHOT_MAX_RECONSTRUCTION_RECORDS); +/** Validate all snapshot invariants requiring the block index or deployments, + * including exact equality with the planned dependency sets. */ +bool ValidateEvoSnapshotAgainstChain(const EvoSnapshot& snapshot, const ChainstateManager& chainman, + const CBlockIndex* base_index, std::string& error) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + /** Pure CbTx checks over already-built snapshot content. */ bool VerifyEvoSnapshotCbTx(const EvoSnapshot& snapshot, const CCbTx& cbtx, std::string& error); +/** The merkle root a leaf at `index` reaches through `branch` (leaf to root). */ +uint256 ComputeMerkleRootFromBranch(const uint256& leaf, const std::vector& branch, uint32_t index); +/** The merkle branch of the transaction at `index` in `block`, leaf to root. */ +std::vector BlockMerkleBranch(const CBlock& block, uint32_t index); + +/** Load-time anchor of the section to the base header: the carried coinbase + * must hash through its branch to the header's merkle root, and its CbTx, + * decoded with the BLS scheme in force at the base, must commit to the + * carried MN list, active quorums and credit pool. */ +bool VerifyEvoSnapshotBaseBlock(const EvoSnapshot& snapshot, const CBlockIndex& base_index, + const Consensus::Params& consensus, std::string& error); + +/** The scan-coverage gate of ValidateEvoSnapshotAgainstChain on its own: + * every ChainLock verification scan B + 1 may make and the asset-unlock scan + * of B + 1 must be proven from the section. */ +bool CheckEvoSnapshotScanCoverage(const EvoSnapshot& snapshot, const ChainstateManager& chainman, + const CBlockIndex* base_index, std::string& error) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + +/** How the scan sequences of `params`' type end in the section: END where the + * canonical commitment selection carries fewer records than it takes, so no + * older one exists; UNKNOWN otherwise. Local-only, recorded at seeding. + * + * END rests on the carried counts alone. The CbTx quorum root binds only the + * active set and headers say nothing about how many older commitments exist, + * so validation bounds the safety commitments from above only: a section + * omitting one would turn UNKNOWN into END and a short scan into a wrong + * answer. Only the mandatory evo-hash pin binds the counts, so no section may + * be seeded without it. */ +llmq::ScanCoverageRecord EvoSnapshotScanCoverage(const EvoSnapshot& snapshot, const Consensus::LLMQParams& params); + +/** Seed the decoded snapshot's state into `view`, inside the caller's open + * EvoDB transaction for that view. */ +bool SeedEvoSnapshotState(const EvoViewToken& view, const EvoSnapshot& snapshot, CSpecialTxProcessor& special_tx, + const node::BlockManager& blockman, const CBlockIndex* base_index, std::string& error) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + +/** Compute, in the calling thread's (seeded) view, the members of every + * carried quorum, of the base's cycle, and of the next cycle when its work + * block is at or below the base. Seeded state that cannot support them is an + * error. */ +bool DryRunEvoSnapshot(const EvoSnapshot& snapshot, const ChainstateManager& chainman, CSpecialTxProcessor& special_tx, + const CBlockIndex* base_index, std::string& error) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + } // namespace evo #endif // BITCOIN_EVO_SNAPSHOT_H diff --git a/src/evo/snapshot_chain.cpp b/src/evo/snapshot_chain.cpp new file mode 100644 index 000000000000..45e97801f3e8 --- /dev/null +++ b/src/evo/snapshot_chain.cpp @@ -0,0 +1,1105 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +// Chain-aware companions to evo/snapshot.cpp, deliberately header-less: these +// implementations are declared in evo/snapshot.h but need validation.h and +// llmq internals, so they live in their own compilation unit to keep the +// snapshot codec free of an evo/snapshot -> validation -> evo/snapshot cycle. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace evo { +namespace { + +//! V20 is buried, so its state after a height is known even for a cycle that +//! is not mined yet. +bool V20ActiveAfterHeight(const Consensus::Params& consensus, int height) +{ + return height + 1 >= consensus.DeploymentHeight(Consensus::DEPLOYMENT_V20); +} + +bool ValidateCommitmentAgainstChain(const MinedQuorumCommitment& entry, const ChainstateManager& chainman, + const CBlockIndex* base_index, const Consensus::LLMQParams& params, + bool rotation_enabled) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + const CBlockIndex* quorum_index{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + const CBlockIndex* mined_index{chainman.m_blockman.LookupBlockIndex(entry.mined_block_hash)}; + if (quorum_index == nullptr || mined_index == nullptr || + base_index->GetAncestor(quorum_index->nHeight) != quorum_index || + base_index->GetAncestor(mined_index->nHeight) != mined_index) + return false; + const int cycle_height{quorum_index->nHeight - quorum_index->nHeight % params.dkgInterval}; + if (rotation_enabled) { + if (entry.commitment.quorumIndex != quorum_index->nHeight % params.dkgInterval || + entry.commitment.quorumIndex < 0 || entry.commitment.quorumIndex >= params.signingActiveQuorumCount) + return false; + } else if (quorum_index->nHeight != cycle_height || entry.commitment.quorumIndex != 0) { + return false; + } + const int mined_cycle{mined_index->nHeight - mined_index->nHeight % params.dkgInterval}; + if (mined_cycle != cycle_height || mined_index->nHeight % params.dkgInterval < params.dkgMiningWindowStart || + mined_index->nHeight % params.dkgInterval > params.dkgMiningWindowEnd) + return false; + const uint16_t expected_version{ + llmq::CFinalCommitment::GetVersion(rotation_enabled, DeploymentActiveAfter(quorum_index, chainman.GetConsensus(), + Consensus::DEPLOYMENT_V19))}; + return entry.commitment.nVersion == expected_version && entry.commitment.VerifySizes(params); +} + +//! Applicable(Y): a rotation cycle at or below the base whose quarter the +//! ordinary computation builds and later reads. +bool IsApplicableRotationCycle(const ChainstateManager& chainman, const Consensus::LLMQParams& params, + const CBlockIndex* base_index, int cycle_height) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + if (cycle_height < 0 || cycle_height > base_index->nHeight || cycle_height % params.dkgInterval != 0) return false; + const CBlockIndex* cycle{base_index->GetAncestor(cycle_height)}; + return cycle != nullptr && llmq::IsQuorumRotationEnabled(params, cycle) && + chainman.IsQuorumTypeEnabled(params.type, cycle->pprev); +} + +//! The modifier of the cycle at `work.cycle_height`, from the work block's +//! data or, when that is unreadable, from `seeded`. Mirrors the ordinary +//! calculation (llmq/utils.cpp) without its block-hash fallback for an +//! unreadable v20 work block, and without needing an unmined cycle's block. +std::optional ExactWorkModifier(const Consensus::LLMQParams& params, const Consensus::Params& consensus, + const CBlockIndex* base_index, const EvoSnapshotWorkBlock& work, + const std::optional& seeded, std::string& error) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + const CBlockIndex* modifier_work{base_index->GetAncestor(work.cycle_height - llmq::WORK_DIFF_DEPTH)}; + if (modifier_work == nullptr) { + error = "evo quorum modifier work block is not an ancestor of the base"; + return std::nullopt; + } + if (DeploymentActiveAfter(modifier_work, consensus, Consensus::DEPLOYMENT_V20)) { + CBlock block; + if ((modifier_work->nStatus & BLOCK_HAVE_DATA) == 0 || !node::ReadBlockFromDisk(block, modifier_work, consensus)) { + if (!seeded) error = "quorum work block data unavailable for its exact score modifier"; + return seeded; + } + return llmq::utils::GetWorkBlockModifier(params, consensus, block, *modifier_work); + } + if (params.useRotation) return ::SerializeHash(std::make_pair(params.type, modifier_work->GetBlockHash())); + if (work.cycle_height > base_index->nHeight) { + error = "pre-v20 modifier of a cycle above the base is undecidable"; + return std::nullopt; + } + return ::SerializeHash(std::make_pair(params.type, base_index->GetAncestor(work.cycle_height)->GetBlockHash())); +} + +//! A carried list for the snapshot: an unbound pre-DIP3 list is the empty list +//! bound to its block, which is what the snapshot view reads there. +CDeterministicMNList BoundList(const CDeterministicMNList& list, const CBlockIndex* index) +{ + if (list.GetBlockHash().IsNull()) return CDeterministicMNList{index->GetBlockHash(), index->nHeight, 0}; + return list; +} + +//! Whether every activation input of a quorum type is the same for the block +//! after `target_height` as for the block after the base: no buried deployment +//! or DIP0024 quorum height switches in between, and no versionbits deployment +//! that is not yet active crosses a period boundary. +bool ContextUnchangedUntil(const ChainstateManager& chainman, const CBlockIndex* base_index, int64_t target_height) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + const auto& consensus{chainman.GetConsensus()}; + const int64_t base_height{base_index->nHeight}; + for (int16_t dep{Consensus::DEPLOYMENT_HEIGHTINCB}; + Consensus::ValidDeployment(static_cast(dep)); ++dep) { + const int64_t height{consensus.DeploymentHeight(static_cast(dep))}; + if ((base_height + 1 >= height) != (target_height + 1 >= height)) return false; + } + if ((base_height >= consensus.DIP0024QuorumsHeight) != (target_height >= consensus.DIP0024QuorumsHeight)) { + return false; + } + for (int pos{0}; pos < Consensus::MAX_VERSION_BITS_DEPLOYMENTS; ++pos) { + const auto dep{static_cast(pos)}; + if (DeploymentActiveAfter(base_index, chainman, dep)) continue; + const auto& deployment{consensus.vDeployments[dep]}; + const int64_t period{deployment.nWindowSize ? deployment.nWindowSize : int64_t{consensus.nMinerConfirmationWindow}}; + // A state is constant within a period: blocks base+1 .. target+1 share + // one unless one of them starts a new period. + if (period > 0 && (target_height + 1) / period != (base_height + 1) / period) return false; + } + return true; +} + +//! I6 for the next cycle N: its members read work data at or below the base +//! once N - D <= B, and the planner derives h and N from the context at B. +//! A type enabled, or switching rotation, between B and N would need inputs +//! nothing carries, so such a base is refused at export and load alike. +bool CheckNextCycleContext(const ChainstateManager& chainman, const CBlockIndex* base_index, std::string& error) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + for (const auto& params : chainman.GetConsensus().llmqs) { + const int64_t base_height{base_index->nHeight}; + const int64_t next{base_height - base_height % params.dkgInterval + params.dkgInterval}; + // N - 1 == B makes N's context the base's own. + if (next - llmq::WORK_DIFF_DEPTH > base_height || next - 1 == base_height) continue; + if (!ContextUnchangedUntil(chainman, base_index, next - 1)) { + error = strprintf("evo snapshot base precedes an undecidable activation context at the next %s cycle %d", + params.name, next); + return false; + } + } + return true; +} + +//! The carried records of `params`' type in the family its scans read, newest +//! mined first: per quorum index with rotation, one sequence otherwise. +std::vector>> CarriedScanRecords(const EvoSnapshot& snapshot, + const ChainstateManager& chainman, + const Consensus::LLMQParams& params) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + std::vector>> records( + params.useRotation ? static_cast(params.signingActiveQuorumCount) : 1); + const auto data{std::ranges::find_if(snapshot.quorums, [&](const auto& q) { return q.llmq_type == params.type; })}; + // Scans of a rotating type read the indexed family only. Without rotation + // at the base there is no indexed record at or below it. + if (data == snapshot.quorums.end() || data->rotation_enabled != params.useRotation) return records; + for (const auto* commitments : {&data->active_commitments, &data->safety_commitments}) { + for (const auto& entry : *commitments) { + const size_t sequence{params.useRotation ? static_cast(entry.commitment.quorumIndex) : 0}; + const CBlockIndex* mined{chainman.m_blockman.LookupBlockIndex(entry.mined_block_hash)}; + const CBlockIndex* quorum{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + if (sequence < records.size() && mined != nullptr && quorum != nullptr) { + records[sequence].emplace_back(mined->nHeight, quorum); + } + } + } + for (auto& sequence : records) { + std::sort(sequence.begin(), sequence.end(), [](const auto& a, const auto& b) { return a.first > b.first; }); + } + return records; +} + +//! Prove the scans the snapshot chainstate must answer from carried history +//! alone: ChainLock verification for every height B + 1 may certify, and the +//! asset-unlock quorum check of B + 1. With `full_index`, the exporter's +//! complete commitment index, the proven vectors must also equal it. +bool CheckScanCoverage(const EvoSnapshot& snapshot, const ChainstateManager& chainman, const CBlockIndex* base_index, + const llmq::CQuorumBlockProcessor* full_index, std::string& error) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + const auto& consensus{chainman.GetConsensus()}; + struct Request { + Consensus::LLMQType type{Consensus::LLMQType::LLMQ_NONE}; + const CBlockIndex* start{nullptr}; + size_t count{0}; + }; + std::vector requests; + // Without v20 at B + 1 its CbTx carries no ChainLock to verify. + const auto chainlocks{chainman.GetParams().GetLLMQ(consensus.llmqTypeChainLocks)}; + if (chainlocks && DeploymentActiveAfter(base_index, consensus, Consensus::DEPLOYMENT_V20)) { + // B + 1 may certify any height from the base's certified one on; with + // no certificate at the base, any height at all. + int first_height{0}; + const bool legacy{!DeploymentActiveAt(*base_index, consensus, Consensus::DEPLOYMENT_V19)}; + if (const auto cbtx{snapshot.base_coinbase ? DecodeCbTxPayload(*snapshot.base_coinbase, legacy) : std::nullopt}; + cbtx && cbtx->nVersion >= CCbTx::Version::CLSIG_AND_BALANCE && cbtx->bestCLSignature.IsValid() && + cbtx->bestCLHeightDiff < static_cast(base_index->nHeight)) { + first_height = base_index->nHeight - static_cast(cbtx->bestCLHeightDiff) - 1; + } + // With a fixed type and count, coverage only grows with the cutoff, + // and the cutoff with the start. So only the first start of each + // context (enablement and whether the scan is empty by genesis) needs + // proving. + std::optional> context; + for (int height{std::max(first_height, llmq::SIGN_HEIGHT_OFFSET)}; height <= base_index->nHeight; ++height) { + const CBlockIndex* start{base_index->GetAncestor(height - llmq::SIGN_HEIGHT_OFFSET)}; + const bool enabled{chainman.IsQuorumTypeEnabled(chainlocks->type, start)}; + const std::pair current{enabled, enabled && llmq::NormalizeScanStart(*chainlocks, start)}; + if (context == current) continue; + context = current; + if (current.second) { + requests.push_back({chainlocks->type, start, static_cast(chainlocks->signingActiveQuorumCount)}); + } + } + } + if (const auto platform{chainman.GetParams().GetLLMQ(consensus.llmqTypePlatform)}) { + requests.push_back({platform->type, base_index, static_cast(platform->signingActiveQuorumCount) + 1}); + } + + std::map>>> carried; + std::set> checked; + for (const auto& request : requests) { + const auto params{*Assert(chainman.GetParams().GetLLMQ(request.type))}; + if (!chainman.IsQuorumTypeEnabled(request.type, request.start)) continue; + const auto cutoff{llmq::NormalizeScanStart(params, request.start)}; + if (!cutoff || !checked.emplace(request.type, (*cutoff)->nHeight, request.count).second) continue; + auto [it, inserted]{carried.try_emplace(request.type)}; + if (inserted) it->second = CarriedScanRecords(snapshot, chainman, params); + const auto coverage{EvoSnapshotScanCoverage(snapshot, params)}; + std::vector sequences; + for (size_t i{0}; i < it->second.size(); ++i) { + llmq::ScanSequence sequence{{}, coverage.End(i)}; + for (const auto& [mined_height, quorum] : it->second[i]) { + if (mined_height <= (*cutoff)->nHeight) sequence.records.push_back(quorum); + } + sequences.push_back(std::move(sequence)); + } + const auto proven{llmq::TraverseScanSequences(params.useRotation, sequences, request.count)}; + if (!proven) { + error = strprintf("evo snapshot cannot prove the llmqType=%d scan of %d from height %d", + static_cast(request.type), request.count, (*cutoff)->nHeight); + return false; + } + if (full_index == nullptr) continue; + std::vector complete; + if (params.useRotation) { + for (int index{0}; index < params.signingActiveQuorumCount; ++index) { + complete.push_back( + {full_index->GetMinedCommitmentsByQuorumIndexUntilBlock(request.type, *cutoff, index, request.count), + llmq::ScanSequenceEnd::END}); + } + } else { + complete.push_back({full_index->GetMinedCommitmentsUntilBlock(request.type, *cutoff, request.count), + llmq::ScanSequenceEnd::END}); + } + if (llmq::TraverseScanSequences(params.useRotation, complete, request.count) != proven) { + error = strprintf("evo snapshot llmqType=%d scan from height %d differs from the full index", + static_cast(request.type), (*cutoff)->nHeight); + return false; + } + } + return true; +} + +} // namespace + +bool CheckEvoSnapshotScanCoverage(const EvoSnapshot& snapshot, const ChainstateManager& chainman, + const CBlockIndex* base_index, std::string& error) +{ + AssertLockHeld(::cs_main); + return CheckScanCoverage(snapshot, chainman, base_index, /*full_index=*/nullptr, error); +} + +llmq::ScanCoverageRecord EvoSnapshotScanCoverage(const EvoSnapshot& snapshot, const Consensus::LLMQParams& params) +{ + llmq::ScanCoverageRecord coverage; + const auto data{std::ranges::find_if(snapshot.quorums, [&](const auto& q) { return q.llmq_type == params.type; })}; + const auto end_of = [](size_t carried, size_t selected) { + return static_cast(carried < selected ? llmq::ScanSequenceEnd::END : llmq::ScanSequenceEnd::UNKNOWN); + }; + if (params.useRotation) { + // The canonical selection carries the last two of every quorum index; + // fewer means none older exists. Without rotation at the base the + // indexed family is empty there. + std::vector counts(static_cast(params.signingActiveQuorumCount)); + if (data != snapshot.quorums.end() && data->rotation_enabled) { + for (const auto* commitments : {&data->active_commitments, &data->safety_commitments}) { + for (const auto& entry : *commitments) { + if (entry.commitment.quorumIndex >= 0 && + static_cast(entry.commitment.quorumIndex) < counts.size()) { + ++counts[entry.commitment.quorumIndex]; + } + } + } + } + for (const size_t count : counts) + coverage.ends.push_back(end_of(count, 2)); + } else { + const size_t carried{data == snapshot.quorums.end() + ? size_t{0} + : data->active_commitments.size() + data->safety_commitments.size()}; + coverage.ends.push_back(end_of(carried, SnapshotCommitmentCount(params, /*rotation_enabled=*/false))); + } + return coverage; +} + +int EvoSnapshotWorkHeight(const Consensus::Params& consensus, const Consensus::LLMQParams& params, + bool rotation_enabled, int quorum_height) +{ + if (rotation_enabled) return quorum_height - quorum_height % params.dkgInterval - llmq::WORK_DIFF_DEPTH; + return V20ActiveAfterHeight(consensus, quorum_height) ? quorum_height - llmq::WORK_DIFF_DEPTH : quorum_height; +} + +bool PlanEvoSnapshotDependencies(const ChainstateManager& chainman, const CBlockIndex* base_index, + Consensus::LLMQType llmq_type, const std::vector& commitments, + EvoSnapshotDependencyPlan& plan, std::string& error) +{ + AssertLockHeld(::cs_main); + plan = EvoSnapshotDependencyPlan{}; + plan.llmq_type = llmq_type; + const auto params_opt{chainman.GetParams().GetLLMQ(llmq_type)}; + if (base_index == nullptr || !params_opt) { + error = "evo snapshot dependency plan without a base or LLMQ parameters"; + return false; + } + const auto& params{*params_opt}; + const auto& consensus{chainman.GetConsensus()}; + const int base_height{base_index->nHeight}; + const int interval{params.dkgInterval}; + const int h{base_height - base_height % interval}; + const int next{h + interval}; + plan.rotation_enabled = llmq::IsQuorumRotationEnabled(params, base_index); + + // A_T for a rotating type (cycle heights), or the quorum heights K_T ∪ {h, N}. + std::set cycles; + for (const CBlockIndex* quorum : commitments) { + if (quorum == nullptr || base_index->GetAncestor(quorum->nHeight) != quorum) { + error = "evo snapshot commitment is not an ancestor of the base"; + return false; + } + cycles.insert(plan.rotation_enabled ? quorum->nHeight - quorum->nHeight % interval : quorum->nHeight); + } + // A type that forms no quorum at the base is carried only for its retained + // commitments, so it needs no members for h or N. + const bool enabled{chainman.IsQuorumTypeEnabled(llmq_type, base_index)}; + if (enabled) { + cycles.insert(h); + cycles.insert(next); + } + + std::map work_cycles; // work height -> the cycle whose modifier it carries + const auto add_work = [&](int work_height, int cycle_height) { + if (work_height < 0 || work_height > base_height) return; + work_cycles.emplace(work_height, cycle_height); + }; + std::set referenced_work; + for (const CBlockIndex* quorum : commitments) { + referenced_work.insert(EvoSnapshotWorkHeight(consensus, params, plan.rotation_enabled, quorum->nHeight)); + } + + if (plan.rotation_enabled) { + std::set quarters; + for (const int cycle : cycles) { + for (int k{1}; k <= 3; ++k) { + if (IsApplicableRotationCycle(chainman, params, base_index, cycle - k * interval)) { + quarters.insert(cycle - k * interval); + } + } + } + for (const int quarter : quarters) { + plan.rotation_cycles.push_back(base_index->GetAncestor(quarter)); + add_work(quarter - llmq::WORK_DIFF_DEPTH, quarter); + referenced_work.insert(quarter - llmq::WORK_DIFF_DEPTH); + } + for (const int cycle : cycles) + add_work(cycle - llmq::WORK_DIFF_DEPTH, cycle); + } else { + for (const int quorum : cycles) { + add_work(EvoSnapshotWorkHeight(consensus, params, /*rotation_enabled=*/false, quorum), quorum); + } + } + + for (const auto& [work_height, cycle_height] : work_cycles) { + plan.work_blocks.push_back({base_index->GetAncestor(work_height), cycle_height}); + } + if (enabled) { + for (const int cycle : {h, next}) { + const int work_height{EvoSnapshotWorkHeight(consensus, params, plan.rotation_enabled, cycle)}; + if (!work_cycles.contains(work_height) || referenced_work.contains(work_height)) continue; + plan.bootstrap_work.push_back({cycle, base_index->GetAncestor(work_height)->GetBlockHash()}); + } + } + return true; +} + +bool BuildEvoSnapshot(const CChainParams& chainparams, const ChainstateManager& chainman, CDeterministicMNManager& dmnman, + const llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, + CCreditPoolManager& cpoolman, CMNHFManager& mnhfman, const CBlockIndex* base_index, + EvoSnapshot& snapshot, std::string& error, const EvoSnapshotCaptures* captures) +{ + AssertLockHeld(::cs_main); + error.clear(); + if (base_index == nullptr) { + error = "evo snapshot base block is null"; + return false; + } + const auto& consensus{chainparams.GetConsensus()}; + + EvoSnapshot result; + result.base_block_hash = base_index->GetBlockHash(); + if (!DeploymentActiveAt(*base_index, consensus, Consensus::DEPLOYMENT_DIP0003)) { + result.mn_list = CDeterministicMNList{base_index->GetBlockHash(), base_index->nHeight, 0}; + try { + result.Validate(); + } catch (const std::exception& e) { + error = e.what(); + return false; + } + snapshot = std::move(result); + return true; + } + if (!CheckNextCycleContext(chainman, base_index, error)) return false; + result.mn_list = dmnman.GetListForBlock(base_index); + if (captures && captures->base_coinbase) { + result.base_coinbase = captures->base_coinbase; + result.base_coinbase_branch = captures->base_coinbase_branch; + } else { + CBlock base_block; + if (!node::ReadBlockFromDisk(base_block, base_index, consensus) || base_block.vtx.empty()) { + error = "evo snapshot base block is unreadable"; + return false; + } + result.base_coinbase = base_block.vtx[0]; + result.base_coinbase_branch = BlockMerkleBranch(base_block, 0); + } + std::map historical; + + for (const auto& params : consensus.llmqs) { + QuorumSnapshotData data; + data.llmq_type = params.type; + data.rotation_enabled = llmq::IsQuorumRotationEnabled(params, base_index); + + const size_t active_count{static_cast(params.signingActiveQuorumCount)}; + const size_t total_count{SnapshotCommitmentCount(params, data.rotation_enabled)}; + std::vector active; + std::vector safety; + if (data.rotation_enabled) { + active = qblockman.GetLastMinedCommitmentsPerQuorumIndexUntilBlock(params.type, base_index, 0); + safety = qblockman.GetLastMinedCommitmentsPerQuorumIndexUntilBlock(params.type, base_index, 1); + } else { + active = qblockman.GetMinedCommitmentsUntilBlock(params.type, base_index, total_count); + const size_t emit_active{std::min(active.size(), active_count)}; + safety.assign(active.begin() + emit_active, active.end()); + active.resize(emit_active); + } + if (active.size() > active_count) active.resize(active_count); + if (safety.size() > total_count - active_count) safety.resize(total_count - active_count); + // A type that can no longer form quorums (e.g. LLMQ_50_60 after DIP24 + // on mainnet) still contributes its retained commitments to the CbTx + // quorum merkle root, so it is carried whenever it has any. + if (active.empty() && safety.empty() && !chainman.IsQuorumTypeEnabled(params.type, base_index)) continue; + + std::vector carried{active}; + carried.insert(carried.end(), safety.begin(), safety.end()); + EvoSnapshotDependencyPlan plan; + if (!PlanEvoSnapshotDependencies(chainman, base_index, params.type, carried, plan, error)) return false; + + const auto read_commitment = [&](const CBlockIndex* quorum) -> std::optional { + auto [commitment, mined_hash] = qblockman.GetMinedCommitment(params.type, quorum->GetBlockHash()); + if (mined_hash.IsNull()) { + error = "mined quorum commitment not found for " + quorum->GetBlockHash().ToString(); + return std::nullopt; + } + const int work_height{EvoSnapshotWorkHeight(consensus, params, data.rotation_enabled, quorum->nHeight)}; + if (work_height < 0) { + error = "mined quorum commitment below its work depth"; + return std::nullopt; + } + return MinedQuorumCommitment{quorum->GetBlockHash(), base_index->GetAncestor(work_height)->GetBlockHash(), + std::move(commitment), mined_hash}; + }; + for (const CBlockIndex* quorum : active) { + auto entry{read_commitment(quorum)}; + if (!entry) return false; + data.active_commitments.emplace_back(std::move(*entry)); + } + for (const CBlockIndex* quorum : safety) { + auto entry{read_commitment(quorum)}; + if (!entry) return false; + data.safety_commitments.emplace_back(std::move(*entry)); + } + + for (const CBlockIndex* cycle : plan.rotation_cycles) { + auto stored{qsnapman.GetSnapshotForBlock(params.type, cycle)}; + const auto inputs_complete = [&]() EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + for (int k{1}; k <= 3; ++k) { + const int previous{cycle->nHeight - k * params.dkgInterval}; + if (IsApplicableRotationCycle(chainman, params, base_index, previous) && + !qsnapman.GetSnapshotForBlock(params.type, base_index->GetAncestor(previous))) { + return false; + } + } + return true; + }; + if (!stored && inputs_complete()) { + // Block connection stores every applicable quarter when its + // cycle connects (PreComputeQuorumMembers), so this only + // restores a lost one, from complete inputs, the same way. + (void)llmq::utils::GetAllQuorumMembers(params.type, {dmnman, qsnapman, chainman, cycle}); + stored = qsnapman.GetSnapshotForBlock(params.type, cycle); + } + if (!stored) { + error = strprintf("rotation quarter for applicable cycle %d is unavailable", cycle->nHeight); + return false; + } + const CBlockIndex* work{cycle->GetAncestor(cycle->nHeight - llmq::WORK_DIFF_DEPTH)}; + data.rotation_snapshots.push_back({cycle->GetBlockHash(), work->GetBlockHash(), *stored}); + } + for (const auto& work : plan.work_blocks) { + std::optional modifier; + if (captures) { + const auto captured{captures->modifiers.find({params.type, work.work_index->GetBlockHash()})}; + if (captured != captures->modifiers.end()) modifier = captured->second; + } + if (!modifier) { + modifier = ExactWorkModifier(params, consensus, base_index, work, + qsnapman.GetSeededQuorumModifier(params.type, work.work_index->GetBlockHash()), + error); + } + if (!modifier) return false; + result.quorum_modifiers.push_back({params.type, work.work_index->GetBlockHash(), *modifier}); + if (work.work_index != base_index) historical.emplace(work.work_index->GetBlockHash(), work.work_index); + } + data.bootstrap_work = plan.bootstrap_work; + data.active_commitments = CanonicallySortedCopy(std::move(data.active_commitments)); + data.safety_commitments = CanonicallySortedCopy(std::move(data.safety_commitments)); + data.rotation_snapshots = CanonicallySortedCopy(std::move(data.rotation_snapshots)); + result.quorums.emplace_back(std::move(data)); + } + + std::vector ordered_history; + ordered_history.reserve(historical.size()); + for (const auto& [_, index] : historical) + ordered_history.push_back(index); + std::sort(ordered_history.begin(), ordered_history.end(), [](const auto* a, const auto* b) { + return std::make_tuple(a->nHeight, a->GetBlockHash()) > std::make_tuple(b->nHeight, b->GetBlockHash()); + }); + CDeterministicMNList previous_list{result.mn_list}; + uint256 previous_hash{result.base_block_hash}; + for (const CBlockIndex* index : ordered_history) { + const CDeterministicMNList list{BoundList(dmnman.GetListForBlock(index), index)}; + 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; + } + result.credit_pool = captures && captures->base_credit_pool ? *captures->base_credit_pool + : cpoolman.GetCreditPool(base_index); + const int window{chainparams.CreditPoolPeriodBlocks()}; + for (int height{std::max(0, base_index->nHeight - window + 1)}; height <= base_index->nHeight; ++height) { + const CBlockIndex* index{base_index->GetAncestor(height)}; + if (captures) { + if (const auto captured{captures->credit_history.find(index->GetBlockHash())}; + captured != captures->credit_history.end()) { + result.credit_history.push_back(captured->second); + continue; + } + } + const auto entry{CCreditPoolManager::ReadHistoryEntry(index, consensus, error)}; + if (!entry) return false; + result.credit_history.push_back(*entry); + } + // The raw record: GetSignalsStage() would filter it for the next block, + // which the snapshot chainstate does itself. + result.mnhf_signals = mnhfman.GetForBlock(base_index); + result.quorums = CanonicallySortedCopy(std::move(result.quorums)); + result.historical_mn_list_diffs = CanonicallySortedCopy(std::move(result.historical_mn_list_diffs)); + result.quorum_modifiers = CanonicallySortedCopy(std::move(result.quorum_modifiers)); + try { + result.Validate(); + } catch (const std::exception& e) { + error = e.what(); + return false; + } + // Export and load apply the same coverage gate; the exporter also checks + // the proven scans against its complete index. + if (!CheckScanCoverage(result, chainman, base_index, &qblockman, error)) return false; + snapshot = std::move(result); + return true; +} + +bool ValidateEvoSnapshotAgainstChain(const EvoSnapshot& snapshot, const ChainstateManager& chainman, + const CBlockIndex* base_index, std::string& error) +{ + AssertLockHeld(::cs_main); + error.clear(); + const auto fail = [&](const std::string& message) { + error = message; + return false; + }; + if (base_index == nullptr || snapshot.base_block_hash != base_index->GetBlockHash() || + snapshot.mn_list.GetBlockHash() != base_index->GetBlockHash() || + snapshot.mn_list.GetHeightForSnapshotCodec() != base_index->nHeight) { + return fail("evo snapshot base block/height mismatch"); + } + try { + snapshot.Validate(/*require_canonical_order=*/true); + } catch (const std::exception& e) { + return fail(e.what()); + } + + const auto& consensus{chainman.GetConsensus()}; + if (!DeploymentActiveAt(*base_index, consensus, Consensus::DEPLOYMENT_DIP0003)) { + if (!snapshot.quorums.empty() || !snapshot.historical_mn_list_diffs.empty() || + !snapshot.quorum_modifiers.empty() || snapshot.mn_list.GetCounts().total() != 0 || + snapshot.mn_list.GetTotalRegisteredCount() != 0 || snapshot.credit_pool.locked != 0 || + snapshot.credit_pool.currentLimit != 0 || snapshot.credit_pool.latelyUnlocked != 0 || + !snapshot.credit_pool.indexes.IsEmpty() || !snapshot.credit_history.empty() || + !snapshot.mnhf_signals.empty() || snapshot.base_coinbase || !snapshot.base_coinbase_branch.empty()) { + return fail("nonempty pre-DIP3 evo snapshot"); + } + return true; + } + if (!CheckNextCycleContext(chainman, base_index, error)) return false; + // Anchor the section to the base header. + if (!snapshot.base_coinbase || + ComputeMerkleRootFromBranch(snapshot.base_coinbase->GetHash(), snapshot.base_coinbase_branch, 0) != + base_index->hashMerkleRoot) { + return fail("evo snapshot base coinbase is not proven by the base header"); + } + + // The credit-pool window: one entry per block of the window ending at the + // base, zero wherever the pool did not exist yet. + const int window{chainman.GetParams().CreditPoolPeriodBlocks()}; + if (window <= 0 || static_cast(window) > EVO_SNAPSHOT_MAX_CREDIT_HISTORY || + snapshot.credit_history.size() != static_cast(std::min(window, base_index->nHeight + 1))) { + return fail("evo credit history does not cover the credit-pool window"); + } + const int first_height{base_index->nHeight - static_cast(snapshot.credit_history.size()) + 1}; + for (size_t i{0}; i < snapshot.credit_history.size(); ++i) { + if (first_height + static_cast(i) < consensus.DeploymentHeight(Consensus::DEPLOYMENT_V20) && + snapshot.credit_history[i] != CreditHistoryEntry{}) { + return fail("evo credit history before v20 is not empty"); + } + } + if (!DeploymentActiveAt(*base_index, consensus, Consensus::DEPLOYMENT_V20) && + (snapshot.credit_pool.locked != 0 || snapshot.credit_pool.currentLimit != 0 || + snapshot.credit_pool.latelyUnlocked != 0 || !snapshot.credit_pool.indexes.IsEmpty())) { + return fail("evo credit pool before v20 is not empty"); + } + + std::map historical_lists; + if (!ReconstructHistoricalMNLists(snapshot, historical_lists, error)) return false; + historical_lists.emplace(snapshot.base_block_hash, snapshot.mn_list); + 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, uint256> carried_modifiers; + for (const auto& entry : snapshot.quorum_modifiers) { + carried_modifiers.emplace(std::make_pair(entry.llmq_type, entry.work_block_hash), entry.modifier); + } + std::map actual; + for (const auto& data : snapshot.quorums) + actual.emplace(data.llmq_type, &data); + size_t known_count{0}; + std::set required_work_hashes; + std::set> required_modifiers; + for (const auto& params : consensus.llmqs) { + const bool enabled{chainman.IsQuorumTypeEnabled(params.type, base_index)}; + const auto it{actual.find(params.type)}; + if (it == actual.end()) { + if (enabled) return fail("missing enabled evo quorum type"); + continue; + } + ++known_count; + const auto& data{*it->second}; + // A disabled type is carried only for the retained commitments it + // still contributes to the CbTx quorum merkle root. + if (!enabled && data.active_commitments.empty()) return fail("empty disabled evo quorum type"); + const bool rotation_enabled{llmq::IsQuorumRotationEnabled(params, base_index)}; + const size_t active_count{static_cast(params.signingActiveQuorumCount)}; + const size_t total_count{SnapshotCommitmentCount(params, rotation_enabled)}; + if (data.rotation_enabled != rotation_enabled || data.active_commitments.size() > active_count || + data.safety_commitments.size() > total_count - active_count) { + return fail("evo quorum params/count mismatch"); + } + + std::set active_indexes; + std::vector carried; + const auto check_commitment = [&](const MinedQuorumCommitment& entry) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + if (!ValidateCommitmentAgainstChain(entry, chainman, base_index, params, rotation_enabled)) return false; + const CBlockIndex* quorum{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + const CBlockIndex* work{ + base_index->GetAncestor(EvoSnapshotWorkHeight(consensus, params, rotation_enabled, quorum->nHeight))}; + if (work == nullptr || entry.work_block_hash != work->GetBlockHash()) return false; + carried.push_back(quorum); + return true; + }; + for (const auto& entry : data.active_commitments) { + if (!check_commitment(entry) || + (rotation_enabled && !active_indexes.insert(entry.commitment.quorumIndex).second)) { + return fail("invalid active evo quorum commitment chain data"); + } + } + for (const auto& entry : data.safety_commitments) { + if (!check_commitment(entry)) return fail("invalid safety evo quorum commitment chain data"); + } + + EvoSnapshotDependencyPlan plan; + if (!PlanEvoSnapshotDependencies(chainman, base_index, params.type, carried, plan, error)) return false; + + std::set expected_cycles; + for (const CBlockIndex* cycle : plan.rotation_cycles) + expected_cycles.insert(cycle->GetBlockHash()); + std::set actual_cycles; + for (const auto& entry : data.rotation_snapshots) + actual_cycles.insert(entry.cycle_base_block_hash); + if (actual_cycles != expected_cycles) return fail("evo rotation cycles differ from the planned set"); + for (const auto& entry : data.rotation_snapshots) { + // A planned cycle: a known ancestor of the base, above the work depth. + const CBlockIndex* cycle{Assert(chainman.m_blockman.LookupBlockIndex(entry.cycle_base_block_hash))}; + const CBlockIndex* work{Assert(cycle->GetAncestor(cycle->nHeight - llmq::WORK_DIFF_DEPTH))}; + if (entry.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() || + entry.snapshot.activeQuorumMembers.size() != historical->second.GetCounts().total()) { + return fail("evo rotation bitset/work-block MN count mismatch"); + } + // The ordinary computation stores no quarter for a cycle with too + // few masternodes, so later cycles read without one, and a base + // that needs such a cycle is unsupported. This catches the first + // no-store path only; the pin covers the other (no member added). + if (historical->second.GetCounts().enabled() < static_cast(params.size / 4)) { + return fail("evo rotation quarter carried for a cycle the ordinary computation leaves without one"); + } + } + if (data.bootstrap_work != plan.bootstrap_work) return fail("evo bootstrap work differs from the planned set"); + + for (const auto& work : plan.work_blocks) { + const uint256& work_hash{work.work_index->GetBlockHash()}; + required_work_hashes.insert(work_hash); + required_modifiers.emplace(params.type, work_hash); + const auto carried_modifier{carried_modifiers.find(std::make_pair(params.type, work_hash))}; + if (carried_modifier == carried_modifiers.end()) return fail("missing planned evo quorum modifier"); + // Recompute wherever the chain allows; only an unreadable v20 work + // block leaves the carried value to the pin. + std::string modifier_error; + const auto expected{ + ExactWorkModifier(params, consensus, base_index, work, carried_modifier->second, modifier_error)}; + if (!expected) return fail(modifier_error); + if (*expected != carried_modifier->second) return fail("evo carried quorum modifier mismatch"); + } + } + if (actual.size() != known_count) return fail("unknown evo quorum type"); + + std::set historical_hashes; + for (const auto& entry : snapshot.historical_mn_list_diffs) + historical_hashes.insert(entry.block_hash); + required_work_hashes.erase(base_index->GetBlockHash()); + if (historical_hashes != required_work_hashes) return fail("historical evo MN lists differ from the planned set"); + std::set> actual_modifiers; + for (const auto& [key, _] : carried_modifiers) + actual_modifiers.insert(key); + if (actual_modifiers != required_modifiers) return fail("evo quorum modifiers differ from the planned set"); + + 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 CheckScanCoverage(snapshot, chainman, base_index, /*full_index=*/nullptr, error); +} + +bool VerifyEvoSnapshotBaseBlock(const EvoSnapshot& snapshot, const CBlockIndex& base_index, + const Consensus::Params& consensus, std::string& error) +{ + if (!snapshot.base_coinbase) { + error = "evo snapshot without a base coinbase"; + return false; + } + if (ComputeMerkleRootFromBranch(snapshot.base_coinbase->GetHash(), snapshot.base_coinbase_branch, 0) != + base_index.hashMerkleRoot) { + error = "evo snapshot base coinbase is not proven by the base header"; + return false; + } + const bool legacy{!DeploymentActiveAt(base_index, consensus, Consensus::DEPLOYMENT_V19)}; + const auto cbtx{DecodeCbTxPayload(*snapshot.base_coinbase, legacy)}; + if (!cbtx) { + error = "evo snapshot base coinbase has no valid CbTx payload"; + return false; + } + return VerifyEvoSnapshotCbTx(snapshot, *cbtx, error); +} + +bool SeedEvoSnapshotState(const EvoViewToken& view, const EvoSnapshot& snapshot, CSpecialTxProcessor& special_tx, + const node::BlockManager& blockman, const CBlockIndex* base_index, std::string& error) +{ + auto& dmnman{special_tx.DeterministicMNManager()}; + auto& qblockman{special_tx.QuorumBlockProcessor()}; + auto& qsnapman{special_tx.QuorumSnapshotManager()}; + auto& cpoolman{*special_tx.m_cpoolman}; + auto& mnhfman{*special_tx.m_mnhfman}; + AssertLockHeld(::cs_main); + const auto fail = [&](const std::string& message) { + error = message; + return false; + }; + if (base_index == nullptr || base_index->GetBlockHash() != snapshot.base_block_hash) { + return fail("evo snapshot seeding base mismatch"); + } + std::map historical_lists; + if (!ReconstructHistoricalMNLists(snapshot, historical_lists, error)) return false; + if (!dmnman.SeedListForBlock(view, snapshot.mn_list)) return fail("failed to seed the base MN list"); + for (const auto& [_, list] : historical_lists) { + if (!dmnman.SeedListForBlock(view, list)) return fail("failed to seed a historical MN list"); + } + for (const auto& modifier : snapshot.quorum_modifiers) { + if (!qsnapman.SeedQuorumModifier(view, modifier.llmq_type, modifier.work_block_hash, modifier.modifier)) { + return fail("failed to seed a quorum score modifier"); + } + } + for (const auto& data : snapshot.quorums) { + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + if (!qblockman.SeedMinedCommitment(view, data.llmq_type, entry.quorum_base_block_hash, entry.commitment, + entry.mined_block_hash)) { + return fail("failed to seed a mined quorum commitment"); + } + } + } + for (const auto& rotation : data.rotation_snapshots) { + const CBlockIndex* cycle{blockman.LookupBlockIndex(rotation.cycle_base_block_hash)}; + if (cycle == nullptr || !qsnapman.SeedSnapshotForBlock(view, data.llmq_type, cycle, rotation.snapshot)) { + return fail("failed to seed a rotation quarter"); + } + } + } + if (!cpoolman.SeedSnapshot(view, base_index, snapshot.credit_pool) || + !cpoolman.SeedHistory(view, base_index, snapshot.credit_history)) { + return fail("failed to seed the credit pool"); + } + if (!mnhfman.SeedSignals(view, base_index, snapshot.mnhf_signals)) return fail("failed to seed the MNHF signals"); + if (snapshot.base_coinbase && !special_tx.SeedBaseCoinbase(view, base_index, *snapshot.base_coinbase)) { + return fail("failed to seed the base coinbase"); + } + for (const auto& params : special_tx.ConsensusParams().llmqs) { + if (!qblockman.SeedScanCoverage(view, params.type, EvoSnapshotScanCoverage(snapshot, params))) { + return fail("failed to seed quorum scan coverage"); + } + } + return true; +} + +bool DryRunEvoSnapshot(const EvoSnapshot& snapshot, const ChainstateManager& chainman, CSpecialTxProcessor& special_tx, + const CBlockIndex* base_index, std::string& error) +{ + AssertLockHeld(::cs_main); + const auto& consensus{chainman.GetConsensus()}; + const llmq::UtilParameters util{special_tx.DeterministicMNManager(), special_tx.QuorumSnapshotManager(), chainman, + Assert(base_index)}; + try { + for (const auto& data : snapshot.quorums) { + const auto& params{SnapshotLLMQParams(data.llmq_type)}; + for (const auto* commitments : {&data.active_commitments, &data.safety_commitments}) { + for (const auto& entry : *commitments) { + const CBlockIndex* quorum{chainman.m_blockman.LookupBlockIndex(entry.quorum_base_block_hash)}; + if (quorum == nullptr) { + error = "evo snapshot dry run: unknown carried quorum"; + return false; + } + (void)llmq::utils::GetAllQuorumMembers(data.llmq_type, util.replace_index(quorum), + /*reset_cache=*/true); + } + } + if (!chainman.IsQuorumTypeEnabled(data.llmq_type, base_index)) continue; + const int h{base_index->nHeight - base_index->nHeight % params.dkgInterval}; + if (h > 0) { + (void)llmq::utils::GetAllQuorumMembers(data.llmq_type, util.replace_index(base_index->GetAncestor(h)), + /*reset_cache=*/true); + } + // The next cycle has no header yet; its members are computable + // from the section only when its work block is at or below the base. + const int next{h + params.dkgInterval}; + const int work_height{EvoSnapshotWorkHeight(consensus, params, data.rotation_enabled, next)}; + if (work_height >= 0 && work_height <= base_index->nHeight) { + const CBlockIndex* work{base_index->GetAncestor(work_height)}; + // Before v20 the modifier needs the next cycle's own block hash. + if (!llmq::utils::ComputeQuorumMembersFromWorkBlock(data.llmq_type, util.replace_index(work), work, next) && + DeploymentActiveAfter(work, consensus, Consensus::DEPLOYMENT_V20)) { + error = "evo snapshot dry run: the members of the next cycle are not computable"; + return false; + } + } + } + } catch (const LocalStateError& e) { + error = strprintf("evo snapshot dry run: %s", e.what()); + return false; + } + return true; +} + +namespace { +// Captures are NORMAL records that exist only while a snapshot is being +// validated: (prefix, kind, block hash, LLMQ type). +const std::string EVODB_SNAPSHOT_CAPTURE{"b_evo_cap"}; +enum class CaptureKind : uint8_t { + MODIFIER, + CREDIT_HISTORY, + BASE_COINBASE, + BASE_CREDIT_POOL +}; +using CaptureKey = std::tuple; + +CaptureKey MakeCaptureKey(CaptureKind kind, const uint256& block_hash, + Consensus::LLMQType llmq_type = Consensus::LLMQType::LLMQ_NONE) +{ + return {EVODB_SNAPSHOT_CAPTURE, static_cast(kind), block_hash, static_cast(llmq_type)}; +} +} // namespace + +std::optional PlanEvoSnapshotCaptures(const EvoSnapshot& snapshot, const ChainstateManager& chainman) +{ + AssertLockHeld(::cs_main); + const CBlockIndex* base{chainman.m_blockman.LookupBlockIndex(snapshot.base_block_hash)}; + if (base == nullptr) return std::nullopt; + EvoSnapshotCapturePlan plan; + plan.base = base->GetBlockHash(); + plan.base_height = base->nHeight; + if (!snapshot.base_coinbase) return plan; + plan.heights.insert(base->nHeight); + plan.window_first = base->nHeight - static_cast(snapshot.credit_history.size()) + 1; + for (int height{plan.window_first}; height <= base->nHeight; ++height) + plan.heights.insert(height); + for (const auto& entry : snapshot.quorum_modifiers) { + const CBlockIndex* work{chainman.m_blockman.LookupBlockIndex(entry.work_block_hash)}; + if (work == nullptr || base->GetAncestor(work->nHeight) != work) return std::nullopt; + // Only a v20 work block's modifier comes from its body. + if (!DeploymentActiveAfter(work, chainman.GetConsensus(), Consensus::DEPLOYMENT_V20)) continue; + plan.modifier_blocks[work->GetBlockHash()].push_back(entry.llmq_type); + plan.heights.insert(work->nHeight); + } + return plan; +} + +bool WriteEvoSnapshotCaptures(CEvoDB& evodb, CCreditPoolManager& cpoolman, const EvoSnapshotCapturePlan& plan, + const CBlock& block, const CBlockIndex& index, const Consensus::Params& consensus, + std::string& error) +{ + if (!plan.heights.contains(index.nHeight) || index.nHeight > plan.base_height) return true; + const uint256 hash{index.GetBlockHash()}; + if (const auto it{plan.modifier_blocks.find(hash)}; it != plan.modifier_blocks.end()) { + for (const auto llmq_type : it->second) { + const uint256 modifier{ + llmq::utils::GetWorkBlockModifier(SnapshotLLMQParams(llmq_type), consensus, block, index)}; + if (!evodb.WriteDerived(MakeCaptureKey(CaptureKind::MODIFIER, hash, llmq_type), modifier)) { + error = "conflicting quorum modifier capture"; + return false; + } + } + } + if (index.nHeight >= plan.window_first) { + const auto entry{CCreditPoolManager::ReadHistoryEntry(&index, consensus, error)}; + if (!entry || !evodb.WriteDerived(MakeCaptureKey(CaptureKind::CREDIT_HISTORY, hash), *entry)) { + if (error.empty()) error = "conflicting credit-pool window capture"; + return false; + } + } + if (hash == plan.base) { + if (block.vtx.empty() || !evodb.WriteDerived(MakeCaptureKey(CaptureKind::BASE_COINBASE, hash), + std::make_pair(block.vtx[0], BlockMerkleBranch(block, 0)))) { + error = "conflicting base coinbase capture"; + return false; + } + // The pool at the base folds in every block back to the last pool on + // disk, which may lie below the window. + std::optional pool; + try { + pool = cpoolman.GetCreditPool(&index); + } catch (const LocalStateError&) { + // This node's own data, e.g. a pruned body: the caller stalls. + throw; + } catch (const std::exception& e) { + error = strprintf("the credit pool at the base is not computable: %s", e.what()); + return false; + } + if (!evodb.WriteDerived(MakeCaptureKey(CaptureKind::BASE_CREDIT_POOL, hash), *pool)) { + error = "conflicting base credit pool capture"; + return false; + } + } + return true; +} + +EvoSnapshotCaptures ReadEvoSnapshotCaptures(CEvoDB& evodb, const EvoSnapshotCapturePlan& plan, + const ChainstateManager& chainman) +{ + AssertLockHeld(::cs_main); + EvoSnapshotCaptures captures; + for (const auto& [hash, types] : plan.modifier_blocks) { + for (const auto llmq_type : types) { + uint256 modifier; + if (evodb.Read(MakeCaptureKey(CaptureKind::MODIFIER, hash, llmq_type), modifier)) { + captures.modifiers.emplace(std::make_pair(llmq_type, hash), modifier); + } + } + } + const CBlockIndex* base{chainman.m_blockman.LookupBlockIndex(plan.base)}; + if (base == nullptr || plan.window_first < 0) return captures; + for (int height{plan.window_first}; height <= plan.base_height; ++height) { + const uint256 hash{base->GetAncestor(height)->GetBlockHash()}; + CreditHistoryEntry entry; + if (evodb.Read(MakeCaptureKey(CaptureKind::CREDIT_HISTORY, hash), entry)) { + captures.credit_history.emplace(hash, entry); + } + } + std::pair> coinbase; + if (evodb.Read(MakeCaptureKey(CaptureKind::BASE_COINBASE, plan.base), coinbase)) { + std::tie(captures.base_coinbase, captures.base_coinbase_branch) = std::move(coinbase); + } + if (CCreditPool pool; evodb.Read(MakeCaptureKey(CaptureKind::BASE_CREDIT_POOL, plan.base), pool)) { + captures.base_credit_pool = std::move(pool); + } + return captures; +} + +bool EraseAllEvoSnapshotCaptures(CEvoDB& evodb) +{ + CDBWrapper& db{evodb.GetRawDB()}; + DataStream prefix; + prefix << EVODB_SNAPSHOT_CAPTURE; + std::unique_ptr it{db.NewIterator()}; + CDBBatch batch{db}; + for (it->Seek(prefix); it->Valid(); it->Next()) { + const auto key{it->GetKey()}; + if (key.size() < prefix.size() || !std::equal(prefix.begin(), prefix.end(), key.begin())) break; + batch.Erase(key); + } + // Most startups have nothing to erase; spare them the synced write. + return batch.SizeEstimate() == 0 || db.WriteBatch(batch, /*fSync=*/true); +} + +void EraseEvoSnapshotCaptures(CEvoDB& evodb, const EvoSnapshotCapturePlan& plan, const ChainstateManager& chainman) +{ + AssertLockHeld(::cs_main); + for (const auto& [hash, types] : plan.modifier_blocks) { + for (const auto llmq_type : types) + evodb.Erase(MakeCaptureKey(CaptureKind::MODIFIER, hash, llmq_type)); + } + const CBlockIndex* base{chainman.m_blockman.LookupBlockIndex(plan.base)}; + if (base == nullptr || plan.window_first < 0) return; + for (int height{plan.window_first}; height <= plan.base_height; ++height) { + evodb.Erase(MakeCaptureKey(CaptureKind::CREDIT_HISTORY, base->GetAncestor(height)->GetBlockHash())); + } + evodb.Erase(MakeCaptureKey(CaptureKind::BASE_COINBASE, plan.base)); + evodb.Erase(MakeCaptureKey(CaptureKind::BASE_CREDIT_POOL, plan.base)); +} + +} // namespace evo diff --git a/src/evo/snapshot_config.cpp b/src/evo/snapshot_config.cpp new file mode 100644 index 000000000000..7bff4f266eb2 --- /dev/null +++ b/src/evo/snapshot_config.cpp @@ -0,0 +1,40 @@ +// 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 + +EvoSnapshotConsensusConfig EvoSnapshotConsensusConfig::FromParams(const CChainParams& params) +{ + const Consensus::Params& consensus{params.GetConsensus()}; + EvoSnapshotConsensusConfig config; + config.deployments.assign(std::begin(consensus.vDeployments), std::end(consensus.vDeployments)); + config.miner_confirmation_window = consensus.nMinerConfirmationWindow; + config.rule_change_activation_threshold = consensus.nRuleChangeActivationThreshold; + config.min_bip9_warning_height = consensus.MinBIP9WarningHeight; + for (int dep{std::numeric_limits::min()}; + Consensus::ValidDeployment(static_cast(dep)); ++dep) { + config.buried_heights.push_back(consensus.DeploymentHeight(static_cast(dep))); + } + config.dip3_enforcement_height = consensus.DIP0003EnforcementHeight; + config.dip24_quorums_height = consensus.DIP0024QuorumsHeight; + config.llmqs = consensus.llmqs; + for (const Consensus::LLMQType role : {consensus.llmqTypeChainLocks, consensus.llmqTypeDIP0024InstantSend, + consensus.llmqTypePlatform, consensus.llmqTypeMnhf}) { + config.llmq_roles.push_back(static_cast(role)); + } + config.credit_pool_period_blocks = params.CreditPoolPeriodBlocks(); + return config; +} + +uint256 EvoSnapshotConsensusConfig::Fingerprint() const +{ + HashWriter writer{}; + writer << *this; + return writer.GetSHA256(); +} diff --git a/src/evo/snapshot_config.h b/src/evo/snapshot_config.h new file mode 100644 index 000000000000..8897e0ea6f38 --- /dev/null +++ b/src/evo/snapshot_config.h @@ -0,0 +1,86 @@ +// 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_CONFIG_H +#define BITCOIN_EVO_SNAPSHOT_CONFIG_H + +#include +#include +#include + +#include +#include + +class CChainParams; + +/** The effective consensus and deployment configuration that stored Evo state + * depends on. Deployment and LLMQ parameters decide which raw records (MNHF + * signals, commitments, credit-pool windows) were stored, so a snapshot + * generation is valid only for the configuration it was published under. + * + * Fingerprint() is SHA256 over this canonical serialization, led by + * fingerprint_version. Bump FINGERPRINT_VERSION whenever the covered set + * changes. */ +struct EvoSnapshotConsensusConfig { + static constexpr uint16_t FINGERPRINT_VERSION{1}; + + uint16_t fingerprint_version{FINGERPRINT_VERSION}; + std::vector deployments; + uint32_t miner_confirmation_window{0}; + uint32_t rule_change_activation_threshold{0}; + int32_t min_bip9_warning_height{0}; + //! DeploymentHeight() of every BuriedDeployment, in enum order. + std::vector buried_heights; + int32_t dip3_enforcement_height{0}; + int32_t dip24_quorums_height{0}; + std::vector llmqs; + std::vector llmq_roles; + int32_t credit_pool_period_blocks{0}; + + static EvoSnapshotConsensusConfig FromParams(const CChainParams& params); + uint256 Fingerprint() const; + + SERIALIZE_METHODS(EvoSnapshotConsensusConfig, obj) + { + READWRITE(obj.fingerprint_version); + READWRITE(Using>(obj.deployments)); + READWRITE(obj.miner_confirmation_window, obj.rule_change_activation_threshold, obj.min_bip9_warning_height); + READWRITE(obj.buried_heights, obj.dip3_enforcement_height, obj.dip24_quorums_height); + READWRITE(Using>(obj.llmqs)); + READWRITE(obj.llmq_roles, obj.credit_pool_period_blocks); + } + +private: + struct DeploymentFormatter { + template + static void Ser(Stream& s, const Consensus::BIP9Deployment& d) + { + s << int32_t{d.bit} << d.nStartTime << d.nTimeout << int32_t{d.min_activation_height} << d.nWindowSize + << d.nThresholdStart << d.nThresholdMin << d.nFalloffCoeff << d.useEHF; + } + template + static void Unser(Stream&, Consensus::BIP9Deployment&) + { + static_assert(!sizeof(Stream*), "fingerprint input is serialize-only"); + } + }; + struct LLMQParamsFormatter { + template + static void Ser(Stream& s, const Consensus::LLMQParams& p) + { + s << static_cast(p.type) << std::string{p.name} << p.useRotation << int32_t{p.size} + << int32_t{p.minSize} << int32_t{p.threshold} << int32_t{p.dkgInterval} << int32_t{p.dkgPhaseBlocks} + << int32_t{p.dkgMiningWindowStart} << int32_t{p.dkgMiningWindowEnd} << int32_t{p.dkgBadVotesThreshold} + << int32_t{p.signingActiveQuorumCount} << int32_t{p.keepOldConnections} << int32_t{p.keepOldKeys} + << int32_t{p.recoveryMembers}; + } + template + static void Unser(Stream&, Consensus::LLMQParams&) + { + static_assert(!sizeof(Stream*), "fingerprint input is serialize-only"); + } + }; +}; + +#endif // BITCOIN_EVO_SNAPSHOT_CONFIG_H diff --git a/src/evo/snapshot_types.h b/src/evo/snapshot_types.h new file mode 100644 index 000000000000..7ee71843b77b --- /dev/null +++ b/src/evo/snapshot_types.h @@ -0,0 +1,53 @@ +// 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 { + +/** Base of the errors that describe this node's own Evo state rather than the + * block, transaction or peer being processed. Generic catch sites rethrow it + * unchanged so that the chainstate boundary classifies it: it never becomes + * consensus invalidity or a peer penalty. */ +class LocalStateError : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +/** Evo state carried by an assumeutxo snapshot disagrees with state derived + * independently. The snapshot is rejected; the block is not at fault. */ +class SnapshotStateMismatchError : public LocalStateError +{ +public: + using LocalStateError::LocalStateError; +}; + +} // namespace evo + +/** Thrown when Evo state for a block cannot be reconstructed because the data + * is not on this node yet (pruned, or below an unvalidated snapshot base, or + * pending in another chainstate's unflushed EvoDB overlay). Distinct from the + * plain std::runtime_error that CDeterministicMNList::ApplyDiff raises for + * genuine local corruption, which must never be swallowed. Messages about a + * masternode list carry BLOCK_DATA_UNAVAILABLE_SUFFIX, matched by + * IsBlockDataUnavailableError(). */ +class BlockDataUnavailableError : public evo::LocalStateError +{ +public: + using evo::LocalStateError::LocalStateError; +}; + +/** An Evo read, write or cache lookup was not bound to the view of the + * chainstate it serves, or was bound to a conflicting or stale view. */ +class EvoViewBindingError : public evo::LocalStateError +{ +public: + using evo::LocalStateError::LocalStateError; +}; + +#endif // BITCOIN_EVO_SNAPSHOT_TYPES_H diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index 4085b40d454e..cb7af5aa3258 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -15,6 +15,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -27,6 +30,7 @@ #include #include #include +#include #include #include @@ -37,6 +41,7 @@ CSpecialTxProcessor::CSpecialTxProcessor(CEvoDB& evodb, CDeterministicMNManager& const chainlock::Chainlocks& chainlocks, const llmq::CQuorumManager& qman) : m_cpoolman{std::make_unique(evodb, chainman)}, m_mnhfman{std::make_unique(evodb, consensus_params)}, + m_evodb{evodb}, m_dmnman{dmnman}, m_qblockman{qblockman}, m_qsnapman{qsnapman}, @@ -50,6 +55,48 @@ CSpecialTxProcessor::CSpecialTxProcessor(CEvoDB& evodb, CDeterministicMNManager& CSpecialTxProcessor::~CSpecialTxProcessor() = default; +// The coinbase of an assumeutxo snapshot base, carried by the snapshot and +// proven against the base header. Written only into a snapshot generation. +static const std::string DB_BASE_COINBASE{"cb_S"}; + +CoinbaseChainlock CSpecialTxProcessor::GetCoinbaseChainlock(const CBlockIndex* pindex) const +{ + CoinbaseChainlock result; + // There's no CL in CbTx before v20 activation + if (pindex == nullptr || !DeploymentActiveAt(*pindex, m_consensus_params, Consensus::DEPLOYMENT_V20)) return result; + const auto from_coinbase = [&](const CTransaction& coinbase) { + const bool legacy{!DeploymentActiveAt(*pindex, m_consensus_params, Consensus::DEPLOYMENT_V19)}; + const auto cbtx{DecodeCbTxPayload(coinbase, legacy)}; + if (cbtx && cbtx->nVersion >= CCbTx::Version::CLSIG_AND_BALANCE && cbtx->nHeight == pindex->nHeight && + cbtx->bestCLSignature.IsValid() && cbtx->bestCLHeightDiff < static_cast(pindex->nHeight)) { + result = {CoinbaseChainlock::Status::CERTIFIED, cbtx->bestCLSignature, cbtx->bestCLHeightDiff}; + } + return result; + }; + const EvoViewToken view{m_evodb.CurrentView()}; + const bool snapshot_view{view.identity == EvoDbIdentity::SNAPSHOT}; + if (snapshot_view && pindex->nHeight == view.base_height) { + CMutableTransaction carried; + if (m_evodb.Read(std::make_pair(DB_BASE_COINBASE, pindex->GetBlockHash()), carried)) { + return from_coinbase(CTransaction{carried}); + } + } + CBlock block; + if (!node::ReadBlockFromDisk(block, pindex, m_consensus_params)) { + if (snapshot_view) result.status = CoinbaseChainlock::Status::UNAVAILABLE; + return result; + } + if (block.vtx.empty()) return result; + return from_coinbase(*block.vtx[0]); +} + +bool CSpecialTxProcessor::SeedBaseCoinbase(const EvoViewToken& view, const CBlockIndex* base, const CTransaction& coinbase) +{ + if (!Assume(base != nullptr) || !Assume(m_evodb.HasActiveTransaction())) return false; + const EvoViewScope scope{view}; + return m_evodb.WriteDerived(std::make_pair(DB_BASE_COINBASE, base->GetBlockHash()), CMutableTransaction{coinbase}); +} + static bool AddNetInfoEntries(const std::shared_ptr& net_info, NetInfoPurpose purpose, const NetInfoList& entries, BlockValidationState& state) { @@ -157,33 +204,44 @@ static bool ApplyProUpServTx(CDeterministicMNState& state_mn, const CProUpServTx } bool CheckCbTxBestChainlock(const CCbTx& cbTx, const CBlockIndex* pindex, const Consensus::Params& consensus_params, - const CChain& chain, const llmq::CQuorumManager& qman, - const chainlock::Chainlocks& chainlocks, BlockValidationState& state) + const CChain& chain, const llmq::CQuorumManager& qman, const chainlock::Chainlocks& chainlocks, + const CSpecialTxProcessor& special_tx, BlockValidationState& state) { if (cbTx.nVersion < CCbTx::Version::CLSIG_AND_BALANCE) { return true; } + // The previous block's coinbase ChainLock, remembered per Evo view. + struct CachedChainlock { + const CBlockIndex* pindex{nullptr}; + std::optional> chainlock; + }; static Mutex cached_mutex; - static const CBlockIndex* cached_pindex GUARDED_BY(cached_mutex){nullptr}; - static std::optional> cached_chainlock GUARDED_BY(cached_mutex){std::nullopt}; + static EvoViewCache cached_by_view GUARDED_BY(cached_mutex); + const EvoViewToken view{qman.CurrentView()}; auto best_clsig = chainlocks.GetBestChainLock(); if (best_clsig.getHeight() == pindex->nHeight - 1 && cbTx.bestCLHeightDiff == 0 && cbTx.bestCLSignature == best_clsig.getSig()) { // matches our best clsig which still hold values for the previous block LOCK(cached_mutex); - cached_chainlock = std::make_pair(cbTx.bestCLSignature, cbTx.bestCLHeightDiff); - cached_pindex = pindex; + cached_by_view.For(view) = {pindex, std::make_pair(cbTx.bestCLSignature, cbTx.bestCLHeightDiff)}; return true; } std::optional> prevBlockCoinbaseChainlock{std::nullopt}; - if (LOCK(cached_mutex); cached_pindex == pindex->pprev) { - prevBlockCoinbaseChainlock = cached_chainlock; + if (LOCK(cached_mutex); cached_by_view.For(view).pindex == pindex->pprev) { + prevBlockCoinbaseChainlock = cached_by_view.For(view).chainlock; } if (!prevBlockCoinbaseChainlock.has_value()) { - prevBlockCoinbaseChainlock = GetNonNullCoinbaseChainlock(pindex->pprev); + const CoinbaseChainlock previous{special_tx.GetCoinbaseChainlock(pindex->pprev)}; + if (previous.status == CoinbaseChainlock::Status::UNAVAILABLE) { + throw BlockDataUnavailableError( + strprintf("coinbase ChainLock of block %s is not available", pindex->pprev->GetBlockHash().ToString())); + } + if (previous.status == CoinbaseChainlock::Status::CERTIFIED) { + prevBlockCoinbaseChainlock = std::make_pair(previous.signature, previous.height_diff); + } } // If std::optional prevBlockCoinbaseChainlock is empty, then up to the previous block, coinbase Chainlock is null. if (prevBlockCoinbaseChainlock.has_value()) { @@ -207,8 +265,7 @@ bool CheckCbTxBestChainlock(const CCbTx& cbTx, const CBlockIndex* pindex, const if (best_clsig.getHeight() == curBlockCoinbaseCLHeight && best_clsig.getSig() == cbTx.bestCLSignature) { // matches our best (but outdated) clsig, no need to verify it again LOCK(cached_mutex); - cached_chainlock = std::make_pair(cbTx.bestCLSignature, cbTx.bestCLHeightDiff); - cached_pindex = pindex; + cached_by_view.For(view) = {pindex, std::make_pair(cbTx.bestCLSignature, cbTx.bestCLHeightDiff)}; return true; } const CBlockIndex* pAncestor = pindex->GetAncestor(curBlockCoinbaseCLHeight); @@ -224,8 +281,7 @@ bool CheckCbTxBestChainlock(const CCbTx& cbTx, const CBlockIndex* pindex, const return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-cbtx-invalid-clsig"); } LOCK(cached_mutex); - cached_chainlock = std::make_pair(cbTx.bestCLSignature, cbTx.bestCLHeightDiff); - cached_pindex = pindex; + cached_by_view.For(view) = {pindex, std::make_pair(cbTx.bestCLSignature, cbTx.bestCLHeightDiff)}; } else if (cbTx.bestCLHeightDiff != 0) { // Null bestCLSignature is allowed only with bestCLHeightDiff = 0 return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-cbtx-cldiff"); @@ -285,6 +341,13 @@ bool CSpecialTxProcessor::CheckSpecialTxInner(const CChain* chain, const CTransa return chain ? CheckAssetUnlockTx(m_blockman, m_qman, *chain, tx, pindexPrev, indexes, rules.v24, state) : CheckAssetUnlockTx(m_blockman, m_qman, tx, pindexPrev, indexes, rules.v24, state); } + } catch (const evo::LocalStateError& e) { + // This node's Evo state is missing or wrong, not the transaction. + // During block connection the chainstate boundary classifies it. + // Mempool and mining callers have no such boundary: fail the + // operation without blaming the transaction or its relayer. + if (chain != nullptr) throw; + return state.Error(e.what()); } catch (const std::exception& e) { LogPrintf("%s -- failed: %s\n", __func__, e.what()); return state.Invalid(TxValidationResult::TX_CONSENSUS, "failed-check-special-tx"); @@ -872,9 +935,17 @@ bool CMNListBuilder::Finish(const CBlock& block, BlockValidationState& state, CD return m_processor->FinishListForBlock(block, m_pindex_prev, m_prev_list, m_debug_logs, m_list, state, mnListRet); } +uint256 CanonicalDeterministicMNListHash(gsl::not_null pindex, const CDeterministicMNList& list) +{ + if (list.GetBlockHash().IsNull()) { + return evo::CanonicalMNListHash(CDeterministicMNList{pindex->GetBlockHash(), pindex->nHeight, 0}); + } + return evo::CanonicalMNListHash(list); +} + uint256 CSpecialTxProcessor::GetDeterministicMNListHash(gsl::not_null pindex) const { - return SerializeHash(m_dmnman.GetListForBlock(pindex)); + return CanonicalDeterministicMNListHash(pindex, m_dmnman.GetListForBlock(pindex)); } void CSpecialTxProcessor::UpdatedMNListTip(gsl::not_null pindex) { m_dmnman.UpdatedBlockTip(pindex); } @@ -887,6 +958,10 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const { AssertLockHeld(::cs_main); + if (!IsEvoViewBound(chainstate)) { + throw EvoViewBindingError(strprintf("%s: not bound to the chainstate's Evo view", __func__)); + } + try { static int64_t nTimeLoop = 0; static int64_t nTimeQuorum = 0; @@ -1045,7 +1120,7 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const LogPrint(BCLog::BENCHMARK, " - CalcCbTxMerkleRootQuorums: %.2fms [%.2fs]\n", 0.001 * (nTime6_2 - nTime6_1), nTimeMerkleQuorums * 0.000001); - if (!CheckCbTxBestChainlock(*opt_cbTx, pindex, m_consensus_params, chain, m_qman, m_chainlocks, state)) { + if (!CheckCbTxBestChainlock(*opt_cbTx, pindex, m_consensus_params, chain, m_qman, m_chainlocks, *this, state)) { // pass the state returned by the function above return false; } @@ -1078,6 +1153,8 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const // Local EvoDB corruption detected below (the node is already // aborting): fail with M_ERROR so the block is not marked invalid. return state.Error(e.what()); + } catch (const evo::LocalStateError&) { + throw; } catch (const std::exception& e) { LogPrintf("CSpecialTxProcessor::%s -- FAILURE! %s\n", __func__, e.what()); return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "failed-procspectxsinblock"); @@ -1086,9 +1163,13 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(Chainstate& chainstate, const return true; } -bool CSpecialTxProcessor::UndoSpecialTxsInBlock(const Chainstate& chainstate, const CBlock& block, const CBlockIndex* pindex, MNListUpdates& updatesRet) +bool CSpecialTxProcessor::UndoSpecialTxsInBlock(const Chainstate& chainstate, const CBlock& block, + const CBlockIndex* pindex, MNListUpdates& updatesRet) { AssertLockHeld(::cs_main); + if (!IsEvoViewBound(chainstate)) { + throw EvoViewBindingError(strprintf("%s: not bound to the chainstate's Evo view", __func__)); + } auto bls_legacy_scheme = bls::bls_legacy_scheme.load(); @@ -1111,6 +1192,9 @@ bool CSpecialTxProcessor::UndoSpecialTxsInBlock(const Chainstate& chainstate, co if (!m_qblockman.UndoBlock(chainstate, block, pindex)) { return false; } + } catch (const evo::LocalStateError&) { + bls::bls_legacy_scheme.store(bls_legacy_scheme); + throw; } catch (const std::exception& e) { bls::bls_legacy_scheme.store(bls_legacy_scheme); LogPrintf("CSpecialTxProcessor::%s -- bls_legacy_scheme=%d\n", __func__, bls::bls_legacy_scheme.load()); @@ -1146,6 +1230,8 @@ bool CSpecialTxProcessor::CheckCreditPoolDiffForBlock(const CBlock& block, const // Local EvoDB corruption detected below (the node is already // aborting): fail with M_ERROR so the block is not marked invalid. return state.Error(e.what()); + } catch (const evo::LocalStateError&) { + throw; } catch (const std::exception& e) { LogPrintf("CSpecialTxProcessor::%s -- FAILURE! %s\n", __func__, e.what()); return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "failed-checkcreditpooldiff"); diff --git a/src/evo/specialtxman.h b/src/evo/specialtxman.h index f9e3fcaf15e8..481eff2f1f06 100644 --- a/src/evo/specialtxman.h +++ b/src/evo/specialtxman.h @@ -5,6 +5,7 @@ #ifndef BITCOIN_EVO_SPECIALTXMAN_H #define BITCOIN_EVO_SPECIALTXMAN_H +#include #include #include #include @@ -32,8 +33,13 @@ class Chainstate; class CMNHFManager; class TxValidationState; class uint256; +struct EvoViewToken; struct MNListUpdates; +//! Whether the calling thread is bound to `chainstate`'s Evo view (defined in +//! validation.cpp, so callers need only this declaration). +bool IsEvoViewBound(const Chainstate& chainstate); + namespace chainlock { class Chainlocks; } @@ -55,6 +61,21 @@ struct SpecialTxRules { bool evo_shares{false}; }; +/** The ChainLock a block's coinbase certifies, as far as this node can tell. */ +struct CoinbaseChainlock { + enum class Status { + //! The coinbase carries a non-null ChainLock. + CERTIFIED, + //! The coinbase is known to carry none (or there is no CbTx ChainLock yet). + NONE, + //! This node cannot tell: the block's data is not available to its view. + UNAVAILABLE, + }; + Status status{Status::NONE}; + CBLSSignature signature{}; + uint32_t height_diff{0}; +}; + class CSpecialTxProcessor { public: @@ -64,6 +85,7 @@ class CSpecialTxProcessor const std::unique_ptr m_mnhfman; private: + CEvoDB& m_evodb; CDeterministicMNManager& m_dmnman; llmq::CQuorumBlockProcessor& m_qblockman; llmq::CQuorumSnapshotManager& m_qsnapman; @@ -89,8 +111,8 @@ class CSpecialTxProcessor CAmount blockSubsidy, bool fJustCheck, bool fCheckCbTxMerkleRoots, BlockValidationState& state, MNListUpdates& updatesRet) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); - bool UndoSpecialTxsInBlock(const Chainstate& chainstate, const CBlock& block, const CBlockIndex* pindex, MNListUpdates& updatesRet) - EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + bool UndoSpecialTxsInBlock(const Chainstate& chainstate, const CBlock& block, const CBlockIndex* pindex, + MNListUpdates& updatesRet) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); // the returned list will not contain the correct block hash (we can't know it yet as the coinbase TX is not updated yet) @@ -106,6 +128,19 @@ class CSpecialTxProcessor /** Return a canonical hash of the deterministic MN list derived at a block. */ uint256 GetDeterministicMNListHash(gsl::not_null pindex) const; + /** The ChainLock certified by `pindex`'s coinbase. At a snapshot base, in + * the snapshot view, it comes from the carried coinbase. Without the + * block's data the snapshot view answers UNAVAILABLE; NORMAL keeps the + * legacy answer NONE. */ + CoinbaseChainlock GetCoinbaseChainlock(const CBlockIndex* pindex) const; + /** Seed the header-proven coinbase of a snapshot base into `view`, inside + * the caller's open EvoDB transaction for that view. */ + bool SeedBaseCoinbase(const EvoViewToken& view, const CBlockIndex* base, const CTransaction& coinbase); + + const Consensus::Params& ConsensusParams() const { return m_consensus_params; } + CDeterministicMNManager& DeterministicMNManager() { return m_dmnman; } + llmq::CQuorumBlockProcessor& QuorumBlockProcessor() { return m_qblockman; } + llmq::CQuorumSnapshotManager& QuorumSnapshotManager() { return m_qsnapman; } /** Passthrough to CDeterministicMNManager::UpdatedBlockTip */ void UpdatedMNListTip(gsl::not_null pindex); @@ -131,6 +166,10 @@ class CSpecialTxProcessor CAmount blockSubsidy, BlockValidationState& state) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); }; +/** Canonical hash of a block's deterministic MN list. An unbound list (none is + * built before DIP3) hashes as the empty list bound to the block. */ +uint256 CanonicalDeterministicMNListHash(gsl::not_null pindex, const CDeterministicMNList& list); + /** * Builds the masternode list for a block one transaction at a time, with the same result as * CSpecialTxProcessor::RebuildListFromBlock() over the same transactions. Block assembly uses it to @@ -175,8 +214,8 @@ std::optional GetValidatedPayload(const CTransaction& tx, gsl::not_null pindexPrev, CDeterministicMNManager& dmnman, const CCoinsViewCache& view, const Consensus::Params& consensus_params, SpecialTxRules rules, diff --git a/src/evo/viewcache.h b/src/evo/viewcache.h new file mode 100644 index 000000000000..e6ae016b9e65 --- /dev/null +++ b/src/evo/viewcache.h @@ -0,0 +1,64 @@ +// 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_VIEWCACHE_H +#define BITCOIN_EVO_VIEWCACHE_H + +#include + +#include +#include +#include +#include +#include + +/** A cache of Evo-derived state, partitioned by Evo view so that state derived + * under one view is never served to another. Every partition is dropped when + * any snapshot generation is rejected or promoted. Callers resolve the view + * once per operation and use the returned partition throughout it. Not + * thread-safe: guard it with the owner's lock. + * + * For() may drop every partition, so a reference it returned is valid only + * until the next For() on the same cache: never hold one across another + * For() call, and hand out shared_ptr partitions when a reference must + * outlive the owner's lock. */ +template +class EvoViewCache +{ +public: + explicit EvoViewCache(std::function make = [] { return T{}; }) : + m_make{std::move(make)} + { + } + + T& For(const EvoViewToken& view) + { + if (const uint64_t epoch{CEvoDB::ViewCacheEpoch()}; epoch != m_epoch) { + m_parts.clear(); + m_epoch = epoch; + } + const Key key{view.db, view.identity, view.generation}; + auto it{m_parts.find(key)}; + if (it == m_parts.end()) it = m_parts.emplace(key, m_make()).first; + return it->second; + } + + template + void ForEach(F&& f) + { + for (auto& [_, part] : m_parts) + f(part); + } + + void Clear() { m_parts.clear(); } + +private: + using Key = std::tuple; + + std::function m_make; + std::map m_parts; + uint64_t m_epoch{CEvoDB::ViewCacheEpoch()}; +}; + +#endif // BITCOIN_EVO_VIEWCACHE_H diff --git a/src/init.cpp b/src/init.cpp index 008ae13fdb33..4b305608dbec 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -464,6 +464,7 @@ void PrepareShutdown(NodeContext& node) node.llmq_ctx.reset(); node.isman.reset(); node.dmnman.reset(); + node.evodb_for_dispatch = nullptr; node.evodb.reset(); } for (const auto& client : node.chain_clients) { @@ -1503,6 +1504,134 @@ bool AppInitInterfaces(NodeContext& node) return true; } +void ReleaseChainstateLoadAttempt(NodeContext& node) +{ + // Validation events a failed attempt queued carry Evo view bindings to the + // databases released below, so they run first. + SyncWithValidationInterfaceQueue(); + node.mempool.reset(); + node.special_tx.reset(); + node.llmq_ctx.reset(); + node.isman.reset(); + node.dmnman.reset(); + node.evodb.reset(); +} + +void RunSchedulerTask(const NodeContext& node, const std::function& task) +{ + RunActiveEvoRound(node.evodb_for_dispatch.load(), "scheduler", task); +} + +void FinishImportedStartup(NodeContext& node, const ArgsManager& args, bool skip_evodb_repair_on_reindex) +{ + // The startup tail serves the active chain, like any other dispatch. + const ActiveEvoViewScope evo_view{node.evodb.get()}; + + { + const CBlockIndex* tip = WITH_LOCK(::cs_main, return node.chainman->ActiveTip()); + const bool ibd = node.chainman->ActiveChainstate().IsInitialBlockDownload(); + if (node.active_ctx) { + // On masternodes, defer the full broadcast until after + // nodeman->Init() so that GetProTxHash() is available + // for quorum connection setup and skShare derivation. + // Only kick CDSNotificationInterface here (cached block + // height for DS/MN payments/budgets). + node.ds_notification_interface->InitializeCurrentBlockTip(tip, ibd); + } else { + // Non-masternode nodes (including observer-only): broadcast + // to all subscribers now; no proTxHash dependency. + GetMainSignals().InitializeCurrentBlockTip(tip, ibd); + } + } + + // Seed InstantSend tip-height cache; NetInstantSend receives future + // updates via CValidationInterface but misses InitializeCurrentBlockTip. + // TODO: move cache updates from NetInstantSend to g_ds_notification due to specific of Tip's processing + node.isman->CacheTipHeight(WITH_LOCK(::cs_main, return node.chainman->ActiveChain().Tip())); + + { + // Get all UTXOs for each MN collateral in one go so that we can fill coin cache early + // and reduce further locking overhead for cs_main in other parts of code including GUI + LogPrintf("Filling coin cache with masternode UTXOs...\n"); + LOCK(cs_main); + const auto start{SteadyClock::now()}; + const auto mnList{node.dmnman->GetListAtChainTip()}; + mnList.ForEachMN(/*onlyValid=*/false, [&](const auto& dmn) { + Coin coin; + GetUTXOCoin(node.chainman->ActiveChainstate(), dmn.collateralOutpoint, coin); + }); + LogPrintf("Filling coin cache with masternode UTXOs: done in %dms\n", + Ticks(SteadyClock::now() - start)); + } + + if (skip_evodb_repair_on_reindex) { + LogPrintf("Skipping evodb repair during reindex\n"); + WITH_LOCK(::cs_main, node.dmnman->CompleteRepair()); // Mark as repaired since we're rebuilding fresh + } else if (node.dmnman->IsRepaired() && !args.GetBoolArg("-forceevodbrepair", false)) { + LogPrintf("Masternode list diffs are already repaired\n"); + } else { + const CBlockIndex* start_index; + const CBlockIndex* stop_index; + { + LOCK(cs_main); + const auto& consensus_params = Params().GetConsensus(); + // Repair works on NORMAL state, so walk the chain of the chainstate + // that owns it: the background one while a snapshot is in use. + const CChain* chain{&node.chainman->ActiveChain()}; + for (const Chainstate* chainstate : node.chainman->GetAll()) { + if (chainstate->EvoDbIdentity() == EvoDbIdentity::NORMAL) chain = &chainstate->m_chain; + } + start_index = (*chain)[consensus_params.DIP0003Height]; + stop_index = chain->Tip(); + } + + if (start_index && stop_index && start_index->nHeight < stop_index->nHeight) { + LogPrintf("Verifying and repairing masternode list diffs...\n"); + const auto start{SteadyClock::now()}; + // Create a callback that wraps CSpecialTxProcessor::BuildNewListFromBlock + auto build_list_func = [&node](const CBlock& block, const CBlockIndex* const pindexPrev, + const CDeterministicMNList& prevList, const CCoinsViewCache& view, + bool debugLogs, BlockValidationState& state, + CDeterministicMNList& mnListRet) -> bool { + const bool is_v24_active{DeploymentActiveAfter(pindexPrev, *node.chainman, Consensus::DEPLOYMENT_V24)}; + return node.special_tx->RebuildListFromBlock(block, pindexPrev, is_v24_active, prevList, view, + debugLogs, state, mnListRet); + }; + auto result = node.dmnman->RecalculateAndRepairDiffs(start_index, stop_index, build_list_func, true); + + if (!result.verification_errors.empty()) { + LogPrintf("WARNING: Verification errors:\n%s\n", Join(result.verification_errors, "\n")); + } + + if (!result.repair_errors.empty()) { + // Critical errors occurred - reindex required + LogPrintf("Failed to repair masternode list diffs. Database corruption detected. " /* Continued */ + "Please restart with -reindex to rebuild the database.\n" + "Errors:\n%s\n", + Join(result.repair_errors, "\n")); + StartShutdown(); + return; + } + WITH_LOCK(::cs_main, node.dmnman->CompleteRepair()); + LogPrintf("Successfully repaired %d masternode list diffs, verified %d snapshots in %ds\n", + result.diffs_recalculated, result.snapshots_verified, + Ticks(SteadyClock::now() - start)); + } + } + + if (node.active_ctx) { + node.active_ctx->nodeman->Init(WITH_LOCK(::cs_main, return node.chainman->ActiveTip())); + // Now that nodeman->Init has set proTxHash, fan out the + // startup tip to all CValidationInterface subscribers. + // The earlier call only kicked CDSNotificationInterface + // directly because the full broadcast (NetQuorum, + // ActiveContext) depends on a valid proTxHash. + const CBlockIndex* tip = WITH_LOCK(::cs_main, return node.chainman->ActiveTip()); + const bool ibd = node.chainman->ActiveChainstate().IsInitialBlockDownload(); + GetMainSignals().InitializeCurrentBlockTip(tip, ibd); + } +} + bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) { const ArgsManager& args = *Assert(node.args); @@ -1562,6 +1691,9 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) assert(!node.scheduler); node.scheduler = std::make_unique(); + // Each scheduled round serves the active chain unless the task carries + // its own Evo view binding. + node.scheduler->SetTaskWrapper([&node](const CScheduler::Function& task) { RunSchedulerTask(node, task); }); // Start the lightweight task scheduler thread node.scheduler->m_service_thread = std::thread(util::TraceThread, "scheduler", [&] { node.scheduler->serviceQueue(); }); @@ -2032,12 +2164,7 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) for (bool fLoaded = false; !fLoaded && !ShutdownRequested();) { // On a retry iteration the previous instances still hold the on-disk // database locks, so release them before opening the databases again. - node.mempool.reset(); - node.special_tx.reset(); - node.llmq_ctx.reset(); - node.isman.reset(); - node.dmnman.reset(); - node.evodb.reset(); + ReleaseChainstateLoadAttempt(node); auto catch_exceptions = [](auto&& f) { try { return f(); @@ -2143,6 +2270,9 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) } } + // No load retry replaces the Evo database from here on. + node.evodb_for_dispatch = node.evodb.get(); + // As LoadBlockIndex can take several minutes, it's possible the user // requested to kill the GUI during the last operation. If so, exit. // As the program has not fully started yet, Shutdown() is possibly overkill. @@ -2471,103 +2601,15 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) chainman.m_load_block = std::thread(&util::TraceThread, "loadblk", [=, &args, &chainman, &node] { // ThreadImport can switch fReindex from true to false, fetch its original state here to use later bool skip_evodb_repair_on_reindex = fReindex || fReindexChainState; - ThreadImport(chainman, vImportFiles, args, ShouldPersistMempool(args) ? MempoolPath(args) : fs::path{}); - - { - const CBlockIndex* tip = WITH_LOCK(::cs_main, return chainman.ActiveTip()); - const bool ibd = chainman.ActiveChainstate().IsInitialBlockDownload(); - if (node.active_ctx) { - // On masternodes, defer the full broadcast until after - // nodeman->Init() so that GetProTxHash() is available - // for quorum connection setup and skShare derivation. - // Only kick CDSNotificationInterface here (cached block - // height for DS/MN payments/budgets). - node.ds_notification_interface->InitializeCurrentBlockTip(tip, ibd); - } else { - // Non-masternode nodes (including observer-only): broadcast - // to all subscribers now; no proTxHash dependency. - GetMainSignals().InitializeCurrentBlockTip(tip, ibd); - } - } - - // Seed InstantSend tip-height cache; NetInstantSend receives future - // updates via CValidationInterface but misses InitializeCurrentBlockTip. - // TODO: move cache updates from NetInstantSend to g_ds_notification due to specific of Tip's processing - node.isman->CacheTipHeight(WITH_LOCK(::cs_main, return chainman.ActiveChain().Tip())); - - { - // Get all UTXOs for each MN collateral in one go so that we can fill coin cache early - // and reduce further locking overhead for cs_main in other parts of code including GUI - LogPrintf("Filling coin cache with masternode UTXOs...\n"); - LOCK(cs_main); - const auto start{SteadyClock::now()}; - const auto mnList{node.dmnman->GetListAtChainTip()}; - mnList.ForEachMN(/*onlyValid=*/false, [&](const auto& dmn) { - Coin coin; - GetUTXOCoin(chainman.ActiveChainstate(), dmn.collateralOutpoint, coin); - }); - LogPrintf("Filling coin cache with masternode UTXOs: done in %dms\n", Ticks(SteadyClock::now() - start)); - } - - if (skip_evodb_repair_on_reindex) { - LogPrintf("Skipping evodb repair during reindex\n"); - WITH_LOCK(::cs_main, node.dmnman->CompleteRepair()); // Mark as repaired since we're rebuilding fresh - } else if (node.dmnman->IsRepaired() && !args.GetBoolArg("-forceevodbrepair", false)) { - LogPrintf("Masternode list diffs are already repaired\n"); - } else { - const CBlockIndex* start_index; - const CBlockIndex* stop_index; + try { { - LOCK(cs_main); - const auto& consensus_params = Params().GetConsensus(); - start_index = chainman.ActiveChain()[consensus_params.DIP0003Height]; - stop_index = chainman.ActiveChain().Tip(); + const ActiveEvoViewScope evo_view{node.evodb.get()}; + ThreadImport(chainman, vImportFiles, args, ShouldPersistMempool(args) ? MempoolPath(args) : fs::path{}); } - - if (start_index && stop_index && start_index->nHeight < stop_index->nHeight) { - LogPrintf("Verifying and repairing masternode list diffs...\n"); - const auto start{SteadyClock::now()}; - // Create a callback that wraps CSpecialTxProcessor::BuildNewListFromBlock - auto build_list_func = - [&node, &chainman](const CBlock& block, const CBlockIndex* const pindexPrev, - const CDeterministicMNList& prevList, const CCoinsViewCache& view, bool debugLogs, - BlockValidationState& state, CDeterministicMNList& mnListRet) -> bool { - const bool is_v24_active{DeploymentActiveAfter(pindexPrev, chainman, Consensus::DEPLOYMENT_V24)}; - return node.special_tx->RebuildListFromBlock(block, pindexPrev, is_v24_active, prevList, - view, debugLogs, state, mnListRet); - }; - auto result = node.dmnman->RecalculateAndRepairDiffs(start_index, stop_index, build_list_func, true); - - if (!result.verification_errors.empty()) { - LogPrintf("WARNING: Verification errors:\n%s\n", Join(result.verification_errors, "\n")); - } - - if (!result.repair_errors.empty()) { - // Critical errors occurred - reindex required - LogPrintf("Failed to repair masternode list diffs. Database corruption detected. " /* Continued */ - "Please restart with -reindex to rebuild the database.\n" - "Errors:\n%s\n", - Join(result.repair_errors, "\n")); - StartShutdown(); - return; - } - WITH_LOCK(::cs_main, node.dmnman->CompleteRepair()); - LogPrintf("Successfully repaired %d masternode list diffs, verified %d snapshots in %ds\n", - result.diffs_recalculated, result.snapshots_verified, - Ticks(SteadyClock::now() - start)); - } - } - - if (node.active_ctx) { - node.active_ctx->nodeman->Init(WITH_LOCK(::cs_main, return chainman.ActiveTip())); - // Now that nodeman->Init has set proTxHash, fan out the - // startup tip to all CValidationInterface subscribers. - // The earlier call only kicked CDSNotificationInterface - // directly because the full broadcast (NetQuorum, - // ActiveContext) depends on a valid proTxHash. - const CBlockIndex* tip = WITH_LOCK(::cs_main, return chainman.ActiveTip()); - const bool ibd = chainman.ActiveChainstate().IsInitialBlockDownload(); - GetMainSignals().InitializeCurrentBlockTip(tip, ibd); + FinishImportedStartup(node, args, skip_evodb_repair_on_reindex); + } catch (const evo::LocalStateError& e) { + LogPrintf("ERROR: Evo state unavailable during startup: %s\n", e.what()); + StartShutdown(); } }); #ifdef ENABLE_WALLET diff --git a/src/init.h b/src/init.h index b0c6feb26031..9a8a54d5e347 100644 --- a/src/init.h +++ b/src/init.h @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -69,6 +70,22 @@ bool AppInitInterfaces(node::NodeContext& node); * @pre Parameters should be parsed and config file should be read, AppInitLockDataDirectory should have been called. */ bool AppInitMain(node::NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info = nullptr); +/** + * Startup work that follows the initial block import: announce the tip, + * warm caches and repair EvoDB. Runs on the loadblk thread, bound to the + * active chainstate's Evo view. + */ +void FinishImportedStartup(node::NodeContext& node, const ArgsManager& args, bool skip_evodb_repair_on_reindex); +/** + * Release the databases and managers a chainstate load attempt opened, once + * the validation events it queued have run. + */ +void ReleaseChainstateLoadAttempt(node::NodeContext& node); +/** + * Run a node scheduler task as a round of active-chain work, bound to the + * active chainstate's Evo view once startup has published the Evo database. + */ +void RunSchedulerTask(const node::NodeContext& node, const std::function& task); void PrepareShutdown(node::NodeContext& node); /** diff --git a/src/instantsend/net_instantsend.cpp b/src/instantsend/net_instantsend.cpp index b5871d326546..23c22ce39793 100644 --- a/src/instantsend/net_instantsend.cpp +++ b/src/instantsend/net_instantsend.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include @@ -403,7 +404,7 @@ void NetInstantSend::ProcessInstantSendLock(NodeId from, const uint256& hash, co // We don't need the recovered sigs for the inputs anymore. This prevents unnecessary propagation of these sigs. // We only need the ISLOCK from now on to detect conflicts TruncateRecoveredSigsForInputs(*islock); - ResolveBlockConflicts(hash, *islock); + ResolveBlockConflicts(hash, islock); if (found_transaction) { RemoveMempoolConflictsForLock(hash, *islock); @@ -426,18 +427,20 @@ void NetInstantSend::ProcessInstantSendLock(NodeId from, const uint256& hash, co void NetInstantSend::WorkThreadMain() { while (!workInterrupt) { - bool fMoreWork = [&]() -> bool { - if (!m_is_manager.IsInstantSendEnabled()) return false; + bool fMoreWork{false}; + RunActiveEvoRound(m_chainman.EvoDb(), "isman", [&] { + if (!m_is_manager.IsInstantSendEnabled()) return; auto [more_work, locks] = m_is_manager.FetchPendingLocks(); if (!locks.empty()) { ProcessPendingISLocks(std::move(locks)); } + RetryUnresolvedConflicts(); if (m_signer) { m_signer->ProcessPendingRetryLockTxs(m_is_manager.PrepareTxToRetry()); } - return more_work; - }(); + fMoreWork = more_work; + }); if (!fMoreWork && !workInterrupt.sleep_for(WORK_THREAD_SLEEP_INTERVAL)) { return; @@ -513,6 +516,7 @@ void NetInstantSend::SynchronousUpdatedBlockTip(const CBlockIndex* pindexNew, co void NetInstantSend::UpdatedBlockTip(const CBlockIndex* pindexNew, const CBlockIndex* pindexFork, bool fInitialDownload) { + m_retry_conflicts = true; bool fDIP0008Active = pindexNew->pprev && pindexNew->pprev->nHeight >= Params().GetConsensus().DIP0008Height; if (m_chainlocks.IsEnabled() && fDIP0008Active) { @@ -579,12 +583,13 @@ void NetInstantSend::BlockDisconnected(const std::shared_ptr& pblo void NetInstantSend::NotifyChainLock(const CBlockIndex* pindex, const std::shared_ptr& clsig) { + m_retry_conflicts = true; HandleFullyConfirmedBlock(pindex); } -void NetInstantSend::ResolveBlockConflicts(const uint256& islockHash, const instantsend::InstantSendLock& islock) +void NetInstantSend::ResolveBlockConflicts(const uint256& islockHash, const instantsend::InstantSendLockPtr& islock) { - auto conflicts = m_is_manager.RetrieveISConflicts(islockHash, islock); + auto conflicts = m_is_manager.RetrieveISConflicts(islockHash, *islock); // Lets see if any of the conflicts was already mined into a ChainLocked block bool hasChainLockedConflict = false; @@ -602,44 +607,105 @@ void NetInstantSend::ResolveBlockConflicts(const uint256& islockHash, const inst // consensus and its better to sacrifice individual ISLOCKs then to sacrifice whole ChainLocks. if (hasChainLockedConflict) { LogPrintf("NetInstantSend::%s -- txid=%s, islock=%s: at least one conflicted TX already got a ChainLock\n", - __func__, islock.txid.ToString(), islockHash.ToString()); - m_is_manager.RemoveConflictingLock(islockHash, islock); + __func__, islock->txid.ToString(), islockHash.ToString()); + m_is_manager.RemoveConflictingLock(islockHash, *islock); return; } - bool hasTxForLock = m_is_manager.HasTxForLock(islockHash); - - bool activateBestChain = false; + UnresolvedConflicts unresolved{.islock = islock, .has_tx_for_lock = m_is_manager.HasTxForLock(islockHash)}; for (const auto& p : conflicts) { - const auto* pindex = p.first; ClearConflicting(p.second); + unresolved.blocks.push_back(p.first->GetBlockHash()); + } + InvalidateConflictingBlocks(islockHash, std::move(unresolved)); +} + +namespace { +//! Clears, when it goes out of scope, the failure flags an invalidation set, +//! unless that invalidation is meant to last. +class ResetTemporaryInvalidation +{ +public: + ResetTemporaryInvalidation(const ChainstateManager& chainman, CBlockIndex* pindex, bool temporary) : + m_chainman{chainman}, + m_pindex{pindex}, + m_temporary{temporary} + { + } + ResetTemporaryInvalidation(const ResetTemporaryInvalidation&) = delete; + ResetTemporaryInvalidation& operator=(const ResetTemporaryInvalidation&) = delete; + ~ResetTemporaryInvalidation() + { + if (!m_temporary) return; + LogPrintf("NetInstantSend::InvalidateConflictingBlocks -- resetting block %s\n", + m_pindex->GetBlockHash().ToString()); + LOCK(::cs_main); + m_chainman.ActiveChainstate().ResetBlockFailureFlags(m_pindex); + } + +private: + const ChainstateManager& m_chainman; + CBlockIndex* const m_pindex{nullptr}; + const bool m_temporary{false}; +}; +} // namespace + +void NetInstantSend::InvalidateConflictingBlocks(const uint256& islockHash, UnresolvedConflicts conflicts) +{ + // A chainstate that stalled on its own Evo state cannot move away from a + // conflicting block yet. Keep what is left for a later attempt. + const auto defer{[&](const BlockValidationState& state) { + if (!IsLocalEvoStateError(state)) { + // This should not have happened and we are in a state were it's not safe to continue anymore + assert(false); + } + LogPrintf("NetInstantSend::InvalidateConflictingBlocks -- islock=%s: chainstate stalled, retrying later\n", + islockHash.ToString()); + m_unresolved_conflicts.insert_or_assign(islockHash, std::move(conflicts)); + }}; - LogPrintf("NetInstantSend::%s -- invalidating block %s\n", __func__, pindex->GetBlockHash().ToString()); + while (!conflicts.blocks.empty()) { + const uint256 block_hash{conflicts.blocks.back()}; + LogPrintf("NetInstantSend::%s -- invalidating block %s\n", __func__, block_hash.ToString()); BlockValidationState state; // need non-const pointer - auto pindex2 = WITH_LOCK(::cs_main, return m_chainman.ActiveChainstate().m_blockman.LookupBlockIndex(pindex->GetBlockHash())); + auto pindex2 = WITH_LOCK(::cs_main, return m_chainman.ActiveChainstate().m_blockman.LookupBlockIndex(block_hash)); + // Without the locked transaction the invalidation is only temporary: + // however InvalidateBlock ends, a stall partway through included, the + // blocks it marked invalid are valid again afterwards. + const ResetTemporaryInvalidation reset{m_chainman, pindex2, !conflicts.has_tx_for_lock}; if (!m_chainman.ActiveChainstate().InvalidateBlock(state, pindex2)) { LogPrintf("NetInstantSend::%s -- InvalidateBlock failed: %s\n", __func__, state.ToString()); - // This should not have happened and we are in a state were it's not safe to continue anymore - assert(false); - } - if (hasTxForLock) { - activateBestChain = true; - } else { - LogPrintf("NetInstantSend::%s -- resetting block %s\n", __func__, pindex2->GetBlockHash().ToString()); - LOCK(::cs_main); - m_chainman.ActiveChainstate().ResetBlockFailureFlags(pindex2); + return defer(state); } + if (conflicts.has_tx_for_lock) conflicts.activate_best_chain = true; + conflicts.blocks.pop_back(); } - if (activateBestChain) { + if (conflicts.activate_best_chain) { BlockValidationState state; if (!m_chainman.ActiveChainstate().ActivateBestChain(state)) { LogPrintf("NetInstantSend::%s -- ActivateBestChain failed: %s\n", __func__, state.ToString()); - // This should not have happened and we are in a state were it's not safe to continue anymore - assert(false); + return defer(state); + } + } +} + +void NetInstantSend::RetryUnresolvedConflicts() +{ + if (!m_retry_conflicts.exchange(false) || m_unresolved_conflicts.empty()) return; + auto unresolved{std::exchange(m_unresolved_conflicts, {})}; + for (auto& [islock_hash, conflicts] : unresolved) { + const bool chainlocked{std::ranges::any_of(conflicts.blocks, [&](const uint256& block_hash) { + const CBlockIndex* pindex{WITH_LOCK(::cs_main, return m_chainman.m_blockman.LookupBlockIndex(block_hash))}; + return pindex && m_chainlocks.HasChainLock(pindex->nHeight, block_hash); + })}; + if (chainlocked) { + m_is_manager.RemoveConflictingLock(islock_hash, *conflicts.islock); + continue; } + InvalidateConflictingBlocks(islock_hash, std::move(conflicts)); } } diff --git a/src/instantsend/net_instantsend.h b/src/instantsend/net_instantsend.h index 89db71809080..1f04d276c6ef 100644 --- a/src/instantsend/net_instantsend.h +++ b/src/instantsend/net_instantsend.h @@ -6,9 +6,12 @@ #define BITCOIN_INSTANTSEND_NET_INSTANTSEND_H #include +#include #include #include +#include +#include #include #include #include @@ -80,6 +83,7 @@ class NetInstantSend final : public NetHandler, public CValidationInterface private: friend struct NetInstantSendTest; + friend struct NetInstantSendConflictsTest; struct BatchVerificationData; bool ValidateIncomingISLock(const instantsend::InstantSendLock& islock, NodeId node_id); @@ -102,7 +106,19 @@ class NetInstantSend final : public NetHandler, public CValidationInterface const Consensus::LLMQParams& llmq_params, int signOffset, bool ban, const std::vector& pend); - void ResolveBlockConflicts(const uint256& islockHash, const instantsend::InstantSendLock& islock); + //! Blocks with transactions that conflict with a lock, still to be + //! invalidated because the chainstate stalled on its Evo state. + struct UnresolvedConflicts { + instantsend::InstantSendLockPtr islock{}; + std::vector blocks{}; + bool has_tx_for_lock{false}; + bool activate_best_chain{false}; + }; + + void ResolveBlockConflicts(const uint256& islockHash, const instantsend::InstantSendLockPtr& islock); + void InvalidateConflictingBlocks(const uint256& islockHash, UnresolvedConflicts conflicts); + //! Retry the conflicts a stall left unresolved, once per tip or ChainLock change. + void RetryUnresolvedConflicts(); void TruncateRecoveredSigsForInputs(const instantsend::InstantSendLock& islock); @@ -118,6 +134,10 @@ class NetInstantSend final : public NetHandler, public CValidationInterface CTxMemPool& m_mempool; const CMasternodeSync& m_mn_sync; + //! Only the work thread resolves conflicts, so only it touches this map. + std::map m_unresolved_conflicts; + std::atomic m_retry_conflicts{false}; + std::thread workThread; CThreadInterrupt workInterrupt; }; diff --git a/src/interfaces/chain.h b/src/interfaces/chain.h index 75fa104be59c..052d2b380576 100644 --- a/src/interfaces/chain.h +++ b/src/interfaces/chain.h @@ -181,8 +181,10 @@ class Chain //! Check if block is chainlocked. virtual bool hasChainLock(int height, const uint256& hash) = 0; - //! Return list of MN Collateral from outputs - virtual std::vector listMNCollaterials(const std::vector>& outputs) = 0; + //! Return list of MN Collateral from outputs, or nullopt when the + //! masternode list is not available. + virtual std::optional> listMNCollaterials( + const std::vector>& outputs) = 0; //! Return the loader that registers wallets with the node's CoinJoin //! client manager, or nullptr when the node runs without one. @@ -279,8 +281,9 @@ class Chain virtual bool isNonStandardSpecialTx(const CTransactionRef& tx, std::string& reason) = 0; //! Whether v24 hard fork rules apply to the next block, which is what - //! enables version 2 asset lock transactions by consensus. - virtual bool isV24Active() = 0; + //! enables version 2 asset lock transactions by consensus. Nullopt when + //! this node cannot evaluate the deployment yet. + virtual std::optional isV24Active() = 0; //! Check if any block has been pruned. virtual bool havePruned() = 0; diff --git a/src/interfaces/node.h b/src/interfaces/node.h index 5713fceaa88e..ecb795f651c5 100644 --- a/src/interfaces/node.h +++ b/src/interfaces/node.h @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -149,8 +150,10 @@ class EVO { public: virtual ~EVO() {} + //! The masternode list at the chain tip, or nulls when it is not available. virtual std::pair getListAtChainTip() = 0; - virtual ProviderTxCapabilities getProviderTxCapabilities() = 0; + //! The provider transaction rules, or nullopt when this node cannot evaluate them yet. + virtual std::optional getProviderTxCapabilities() = 0; //! Validate provider endpoints against a capability snapshot without changing node or wallet state. //! Transaction creation validates them again against the then-active rules. virtual std::optional validateProviderNetInfo(const ProviderNetInfo& net_info, MnType type, @@ -196,13 +199,15 @@ class GOV int32_t m_no{0}; int32_t m_yes{0}; }; - virtual Votes getObjVotes(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) = 0; + //! Vote counts against the current masternode list, or nullopt when that list is not available. + virtual std::optional getObjVotes(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) = 0; struct UniqueVoters { uint16_t m_regular{0}; uint16_t m_evo{0}; }; - virtual UniqueVoters getObjUniqueVoters(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) = 0; - virtual std::vector getCurrentVotes(const uint256& hash) = 0; + virtual std::optional getObjUniqueVoters(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) = 0; + //! The current votes on an object, nullopt while the masternode list is unavailable. + virtual std::optional> getCurrentVotes(const uint256& hash) = 0; virtual bool existsObj(const uint256& hash) = 0; virtual bool isEnabled() = 0; virtual bool processVoteAndRelay(const CGovernanceVote& vote, std::string& error) = 0; @@ -212,7 +217,8 @@ class GOV int superblockmaturitywindow{0}; int lastsuperblock{0}; int nextsuperblock{0}; - int fundingthreshold{0}; + //! Absolute yes votes a proposal needs; nullopt when the masternode list is not available. + std::optional fundingthreshold; CAmount governancebudget{0}; int64_t targetSpacing{0}; int relayRequiredConfs{1}; @@ -224,7 +230,7 @@ class GOV std::unordered_set hashes; CAmount allocated{0}; }; - virtual FundableResult getFundableProposalHashes() = 0; + virtual std::optional getFundableProposalHashes() = 0; virtual std::optional createProposal(int32_t revision, int64_t created_time, const std::string& data_hex, std::string& error) = 0; virtual bool submitProposal(const uint256& parent, int32_t revision, int64_t created_time, const std::string& data_hex, @@ -248,7 +254,8 @@ class LLMQ CAmount m_limit{0}; CAmount m_locked{0}; }; - virtual CreditPoolCounts getCreditPoolCounts() = 0; + //! Nullopt when the credit pool at the tip is not available. + virtual std::optional getCreditPoolCounts() = 0; struct InstantSendCounts { size_t m_verified{0}; size_t m_unverified{0}; @@ -266,7 +273,8 @@ class LLMQ int32_t m_newest_height{0}; int32_t m_expiry_height{0}; }; - virtual std::vector getQuorumStats() = 0; + //! Nullopt when the quorums at the tip are not available. + virtual std::optional> getQuorumStats() = 0; struct PlatformQuorum { uint256 m_quorum_hash{}; std::vector m_pubkey{}; //!< serialized BLS public key (basic scheme) @@ -276,7 +284,8 @@ class LLMQ //! keys. Used by the GUI Platform client to verify Platform state-root //! quorum signatures against locally synced quorum data. This includes //! retained signing quorums that are no longer in the active signing set. - virtual std::vector getPlatformQuorums(uint8_t llmq_type) = 0; + //! Nullopt when the mined commitments are not available. + virtual std::optional> getPlatformQuorums(uint8_t llmq_type) = 0; //! Serialized InstantSend lock for the given txid, or empty if the //! transaction has no islock (used to build asset lock proofs). virtual std::vector getInstantSendLock(const uint256& txid) = 0; @@ -483,8 +492,9 @@ class Node //! Is loading blocks. virtual bool isLoadingBlocks() = 0; - //! Whether the v24 hard fork rules are active at the chain tip. - virtual bool isV24Active() = 0; + //! Whether the v24 hard fork rules are active at the chain tip; nullopt + //! when this node cannot evaluate the deployment yet. + virtual std::optional isV24Active() = 0; //! Set network active. virtual void setNetworkActive(bool active) = 0; diff --git a/src/interfaces/providertx.h b/src/interfaces/providertx.h index d23c71dee4c2..52cb18b6dc43 100644 --- a/src/interfaces/providertx.h +++ b/src/interfaces/providertx.h @@ -39,9 +39,9 @@ enum class ProviderTxErrorCode { struct ProviderTxError { ProviderTxErrorCode code{ProviderTxErrorCode::INTERNAL_ERROR}; - bilingual_str message; - std::string reject_reason; - std::optional transaction_error; + bilingual_str message{}; + std::string reject_reason{}; + std::optional transaction_error{}; }; template diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 641579241315..e31ea6c59cd8 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -228,8 +228,8 @@ class Wallet //! Lock all existing dust UTXOs that match the current threshold. virtual void lockExistingDustOutputs() = 0; - //! List protx coins. - virtual std::vector listProTxCoins() = 0; + //! List protx coins, or nullopt when the masternode list is not available. + virtual std::optional> listProTxCoins() = 0; //! Create transaction. virtual util::Result createTransaction(const std::vector& recipients, diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 225268291879..1ed7e3140950 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -988,6 +988,15 @@ class CRegTestParams : public CChainParams { {AssumeutxoHash{uint256S("0x8a5bdd92252fc6b24663244bbe958c947bb036dc1f94ccd15439f48d8d1cb4e3")}, EvoSnapshotHash{uint256{}}, 200}, }, }; + for (const auto& [height, data] : opts.assumeutxo_data) { + const bool duplicate_block{std::ranges::any_of(m_assumeutxo_data, [&](const auto& entry) { + return entry.second.blockhash == data.blockhash; + })}; + if (duplicate_block || !m_assumeutxo_data.emplace(height, data).second) { + throw std::runtime_error(strprintf("Duplicate height or block hash in -assumeutxodata (%d:%s)", height, + data.blockhash.ToString())); + } + } chainTxData = ChainTxData{ 0, diff --git a/src/kernel/chainparams.h b/src/kernel/chainparams.h index 777dc15d9627..f2f6097d64d6 100644 --- a/src/kernel/chainparams.h +++ b/src/kernel/chainparams.h @@ -59,6 +59,9 @@ struct AssumeutxoData { //! We need to hardcode the value here because this is computed cumulatively using block data, //! which we do not necessarily have at the time of snapshot load. const unsigned int nChainTx; + + //! The snapshot base block this entry authorizes, or null for any block at its height. + const uint256 blockhash{}; }; using MapAssumeutxo = std::map; @@ -240,6 +243,8 @@ class CChainParams std::unordered_map llmq_test_parameters{}; //! Name of the LLMQ to use for InstantSend (DIP0024) std::optional llmq_dip0024_instantsend{}; + //! Extra exact snapshot authorizations, keyed by base height + std::vector> assumeutxo_data{}; }; static std::unique_ptr RegTest(const RegTestOptions& options); diff --git a/src/llmq/blockprocessor.cpp b/src/llmq/blockprocessor.cpp index 82b0a87a81a1..7ef4e754b38f 100644 --- a/src/llmq/blockprocessor.cpp +++ b/src/llmq/blockprocessor.cpp @@ -5,10 +5,12 @@ #include #include +#include #include #include #include #include +#include #include #include @@ -48,6 +50,8 @@ static const std::string DB_MINED_COMMITMENT_BY_INVERSED_HEIGHT = "q_mcih"; static const std::string DB_MINED_COMMITMENT_BY_INVERSED_HEIGHT_Q_INDEXED = "q_mcihi"; static const std::string DB_BEST_BLOCK_UPGRADE = "q_bbu2"; +// Written only into a snapshot generation. +static const std::string DB_SCAN_COVERAGE = "q_scov"; bool EraseMinedCommitmentIfUnreferenced(CEvoDB& evo_db, const Chainstate& chainstate, gsl::not_null pindex, @@ -78,14 +82,18 @@ static bool SerializedEqual(const T& lhs, const T& rhs) std::equal(lhs_stream.begin(), lhs_stream.end(), rhs_stream.begin()); } -CQuorumBlockProcessor::CQuorumBlockProcessor(ChainstateManager& chainman, CDeterministicMNManager& dmnman, CEvoDB& evoDb, - CQuorumSnapshotManager& qsnapman, int8_t bls_threads) : +CQuorumBlockProcessor::CQuorumBlockProcessor(ChainstateManager& chainman, CDeterministicMNManager& dmnman, + CEvoDB& evoDb, CQuorumSnapshotManager& qsnapman, int8_t bls_threads) : m_chainman{chainman}, m_dmnman{dmnman}, m_evoDb{evoDb}, - m_qsnapman{qsnapman} + m_qsnapman{qsnapman}, + mapMinedCommitmentBlockCache{[&consensus = chainman.GetConsensus()] { + PerLlmqTypeCache cache; + cache.Init(consensus); + return cache; + }} { - mapMinedCommitmentBlockCache.Init(m_chainman.GetConsensus()); LogPrintf("BLS verification uses %d additional threads\n", bls_threads); m_bls_queue.StartWorkerThreads(bls_threads); } @@ -213,6 +221,10 @@ bool CQuorumBlockProcessor::ProcessBlock(Chainstate& chainstate, const CBlock& b { AssertLockHeld(::cs_main); + if (!chainstate.IsEvoViewBound()) { + throw EvoViewBindingError(strprintf("%s: not bound to the Evo view of %s", __func__, chainstate.ToString())); + } + const auto blockHash = pindex->GetBlockHash(); if (!DeploymentActiveAt(*pindex, m_chainman.GetConsensus(), Consensus::DEPLOYMENT_DIP0003)) { @@ -446,8 +458,9 @@ bool CQuorumBlockProcessor::ProcessCommitment(Chainstate& chainstate, int nHeigh DropQcHashesCache(); { + const EvoViewToken view{m_evoDb.CurrentView()}; LOCK(minableCommitmentsCs); - mapMinedCommitmentBlockCache.erase(qc.llmqType, qc.quorumHash); + mapMinedCommitmentBlockCache.For(view).erase(qc.llmqType, qc.quorumHash); minableCommitmentsByQuorum.erase(cacheKey); minableCommitments.erase(::SerializeHash(qc)); } @@ -461,17 +474,16 @@ bool CQuorumBlockProcessor::ProcessCommitment(Chainstate& chainstate, int nHeigh void CQuorumBlockProcessor::DropQcHashesCache() { LOCK(m_qc_hashes_cache_mutex); - m_quorums_cached.clear(); - m_qc_hashes_cached.clear(); - m_qc_indexed_hashes_cached.clear(); - m_qc_hashes_lru.clear(); + m_qc_hashes.Clear(); } std::optional> CQuorumBlockProcessor::GetQcHashes(const CBlockIndex* pindexPrev) const { auto quorums = GetMinedAndActiveCommitmentsUntilBlock(pindexPrev); + const EvoViewToken view{m_evoDb.CurrentView()}; LOCK(m_qc_hashes_cache_mutex); + auto& [m_quorums_cached, m_qc_hashes_lru, m_qc_hashes_cached, m_qc_indexed_hashes_cached] = m_qc_hashes.For(view); if (quorums == m_quorums_cached) { return std::make_pair(m_qc_hashes_cached, m_qc_indexed_hashes_cached); } @@ -519,6 +531,9 @@ std::optional> CQuorumBlockProcessor::Get bool CQuorumBlockProcessor::UndoBlock(const Chainstate& chainstate, const CBlock& block, gsl::not_null pindex) { AssertLockHeld(::cs_main); + if (!chainstate.IsEvoViewBound()) { + throw EvoViewBindingError(strprintf("%s: not bound to the chainstate's Evo view", __func__)); + } PreComputeQuorumMembers(m_dmnman, m_qsnapman, m_chainman, pindex, /*reset_cache=*/true); @@ -550,7 +565,8 @@ bool CQuorumBlockProcessor::UndoBlock(const Chainstate& chainstate, const CBlock // Only once this commitment's state change is complete; see ProcessCommitment. DropQcHashesCache(); - WITH_LOCK(minableCommitmentsCs, mapMinedCommitmentBlockCache.erase(qc.llmqType, qc.quorumHash)); + const EvoViewToken view{m_evoDb.CurrentView()}; + WITH_LOCK(minableCommitmentsCs, mapMinedCommitmentBlockCache.For(view).erase(qc.llmqType, qc.quorumHash)); // if a reorg happened, we should allow to mine this commitment later AddMineableCommitment(qc); @@ -641,6 +657,42 @@ uint256 CQuorumBlockProcessor::GetQuorumBlockHash(const Consensus::LLMQParams& l return pindex->GetBlockHash(); } +//! Whether `view` is a snapshot view lacking quorum history: it holds the +//! commitments mined above its base and none at or below it, where DIP3 may +//! already have let quorums be mined. +static bool LacksQuorumHistory(const EvoViewToken& view, const Consensus::Params& consensus) +{ + return view.identity == EvoDbIdentity::SNAPSHOT && view.base_height >= consensus.DIP0003Height; +} + +ScanSequenceEnd CQuorumBlockProcessor::SequenceEnd(Consensus::LLMQType llmqType, bool indexed, size_t sequence) const +{ + AssertLockNotHeld(m_evoDb.cs); + // A full view, or a snapshot view of a base no quorum can precede, holds + // every commitment mined on its chain. + if (!LacksQuorumHistory(m_evoDb.CurrentView(), m_chainman.GetConsensus())) return ScanSequenceEnd::END; + const auto llmq_params{m_chainman.GetParams().GetLLMQ(llmqType)}; + if (!llmq_params) return ScanSequenceEnd::UNKNOWN; + // A family the type never writes is empty: a type is indexed exactly when + // it rotates, and a rotating type is enabled exactly when rotation is + // (both follow DIP0024 at the cycle base). + if (llmq_params->useRotation != indexed) return ScanSequenceEnd::END; + const auto coverage{GetScanCoverage(llmqType)}; + return coverage ? coverage->End(sequence) : ScanSequenceEnd::UNKNOWN; +} + +void CQuorumBlockProcessor::RequireSatisfiedScan(Consensus::LLMQType llmqType, bool indexed, size_t sequence, + const char* func) const +{ + // A scan running out where its sequence ends has found everything mined; + // anywhere else it would need history the view lacks, so its result is + // unknown, not short. + if (SequenceEnd(llmqType, indexed, sequence) == ScanSequenceEnd::END) return; + throw BlockDataUnavailableError( + strprintf("%s: commitments of LLMQ type %d at or below the snapshot base are not available", func, + std23::to_underlying(llmqType))); +} + bool CQuorumBlockProcessor::HasMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorumHash) const { LOCK(::cs_main); @@ -654,9 +706,10 @@ bool CQuorumBlockProcessor::HasMinedCommitment(Consensus::LLMQType llmqType, con uint256 mined_block_hash; bool cached; + const EvoViewToken view{m_evoDb.CurrentView()}; { LOCK(minableCommitmentsCs); - cached = mapMinedCommitmentBlockCache.get(llmqType, quorumHash, mined_block_hash); + cached = mapMinedCommitmentBlockCache.For(view).get(llmqType, quorumHash, mined_block_hash); } if (!cached) { mined_block_hash = GetMinedCommitment(llmqType, quorumHash).second; @@ -664,7 +717,18 @@ bool CQuorumBlockProcessor::HasMinedCommitment(Consensus::LLMQType llmqType, con // outside ProcessCommitment's normal cache-invalidation path. if (!mined_block_hash.IsNull()) { LOCK(minableCommitmentsCs); - mapMinedCommitmentBlockCache.insert(llmqType, quorumHash, mined_block_hash); + mapMinedCommitmentBlockCache.For(view).insert(llmqType, quorumHash, mined_block_hash); + } else if (LacksQuorumHistory(view, m_chainman.GetConsensus())) { + // The snapshot view holds every commitment for a quorum above its + // base, but of the history at or below it only the newest: a miss + // there means "never mined" only where that can be proven. + const CBlockIndex* quorum_base{m_chainman.m_blockman.LookupBlockIndex(quorumHash)}; + if (quorum_base != nullptr && quorum_base->nHeight <= view.base_height && + !ProvenNeverMined(llmqType, quorum_base, view)) { + throw BlockDataUnavailableError( + strprintf("%s: commitment for quorum %s at or below the snapshot base is not available", __func__, + quorumHash.ToString())); + } } } @@ -672,6 +736,84 @@ bool CQuorumBlockProcessor::HasMinedCommitment(Consensus::LLMQType llmqType, con return mined_block != nullptr && chain.Contains(mined_block); } +bool CQuorumBlockProcessor::ProvenNeverMined(Consensus::LLMQType llmqType, gsl::not_null quorum_index, + const EvoViewToken& view) const +{ + AssertLockHeld(::cs_main); + const auto llmq_params_opt{m_chainman.GetParams().GetLLMQ(llmqType)}; + if (!llmq_params_opt) return true; + const auto& llmq_params{*llmq_params_opt}; + // Only this view's chain can have mined it, only at a quorum height of an + // enabled type. + const CBlockIndex* base{m_chainman.m_blockman.LookupBlockIndex(view.base)}; + if (base == nullptr) return false; + if (base->GetAncestor(quorum_index->nHeight) != quorum_index) return true; + const int offset{quorum_index->nHeight % llmq_params.dkgInterval}; + const bool rotation{IsQuorumRotationEnabled(llmq_params, quorum_index)}; + if (offset >= (rotation ? llmq_params.signingActiveQuorumCount : 1) || + !m_chainman.IsQuorumTypeEnabled(llmqType, quorum_index->pprev)) { + return true; + } + // The carried commitments are the newest of their sequence, so a quorum no + // older than the oldest of them would be among them, and a sequence that + // ends proves there is nothing older either. A family the scans do not + // read is not carried at all. + if (rotation != llmq_params.useRotation) return false; + const size_t sequence{rotation ? static_cast(offset) : 0}; + if (SequenceEnd(llmqType, rotation, sequence) == ScanSequenceEnd::END) return true; + const auto carried{rotation ? GetMinedCommitmentsByQuorumIndexUntilBlock(llmqType, base, offset, 2) + : GetMinedCommitmentsUntilBlock(llmqType, base, + std::max(llmq_params.signingActiveQuorumCount + 1, + llmq_params.keepOldConnections))}; + return !carried.empty() && quorum_index->nHeight >= carried.back()->nHeight; +} + +bool CQuorumBlockProcessor::SeedMinedCommitment(const EvoViewToken& view, Consensus::LLMQType llmqType, + const uint256& quorum_hash, const CFinalCommitment& commitment, + const uint256& mined_block_hash) +{ + AssertLockHeld(::cs_main); + if (!Assume(m_evoDb.HasActiveTransaction())) return false; + const EvoViewScope scope{view}; + const auto llmq_params{m_chainman.GetParams().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 height index exactly: it drives the scans and + // the first post-base CbTx quorum merkle root. + if (IsQuorumRotationEnabled(*llmq_params, quorum_base_index)) { + m_evoDb.Write(BuildInversedHeightKeyIndexed(llmqType, mined_index->nHeight, int{commitment.quorumIndex}), + quorum_base_index->nHeight); + } else { + m_evoDb.Write(BuildInversedHeightKey(llmqType, mined_index->nHeight), quorum_base_index->nHeight); + } + DropQcHashesCache(); + WITH_LOCK(minableCommitmentsCs, mapMinedCommitmentBlockCache.For(view).erase(llmqType, quorum_hash)); + return true; +} + +bool CQuorumBlockProcessor::SeedScanCoverage(const EvoViewToken& view, Consensus::LLMQType llmqType, + const ScanCoverageRecord& coverage) +{ + if (!Assume(m_evoDb.HasActiveTransaction())) return false; + const EvoViewScope scope{view}; + return m_evoDb.WriteDerived(std::make_pair(DB_SCAN_COVERAGE, llmqType), coverage); +} + +std::optional CQuorumBlockProcessor::GetScanCoverage(Consensus::LLMQType llmqType) const +{ + ScanCoverageRecord coverage; + if (!m_evoDb.Read(std::make_pair(DB_SCAN_COVERAGE, llmqType), coverage) || + coverage.schema_version != ScanCoverageRecord::SCHEMA_VERSION) { + return std::nullopt; + } + return coverage; +} + std::pair CQuorumBlockProcessor::GetMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorumHash) const { @@ -687,7 +829,7 @@ std::pair CQuorumBlockProcessor::GetMinedCommitment(C std::vector CQuorumBlockProcessor::GetMinedCommitmentsUntilBlock(Consensus::LLMQType llmqType, gsl::not_null pindex, size_t maxCount) const { AssertLockNotHeld(m_evoDb.cs); - LOCK(m_evoDb.cs); + WAIT_LOCK(m_evoDb.cs, evodb_lock); auto dbIt = m_evoDb.GetCurTransaction().NewIteratorUniquePtr(); @@ -725,15 +867,27 @@ std::vector CQuorumBlockProcessor::GetMinedCommitmentsUntilB dbIt->Next(); } + if (ret.size() < maxCount) { + dbIt.reset(); + REVERSE_LOCK(evodb_lock); + RequireSatisfiedScan(llmqType, /*indexed=*/false, /*sequence=*/0, __func__); + } return ret; } std::optional CQuorumBlockProcessor::GetLastMinedCommitmentsByQuorumIndexUntilBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex, int quorumIndex, size_t cycle) const { AssertLockNotHeld(m_evoDb.cs); - LOCK(m_evoDb.cs); + WAIT_LOCK(m_evoDb.cs, evodb_lock); auto dbIt = m_evoDb.GetCurTransaction().NewIteratorUniquePtr(); + const auto not_found{[&]() -> std::optional { + dbIt.reset(); + REVERSE_LOCK(evodb_lock); + RequireSatisfiedScan(llmqType, /*indexed=*/true, static_cast(quorumIndex), + "GetLastMinedCommitmentsByQuorumIndexUntilBlock"); + return std::nullopt; + }}; auto firstKey = BuildInversedHeightKeyIndexed(llmqType, pindex->nHeight, quorumIndex); auto lastKey = BuildInversedHeightKeyIndexed(llmqType, 0, quorumIndex); @@ -746,19 +900,19 @@ std::optional CQuorumBlockProcessor::GetLastMinedCommitments decltype(firstKey) curKey; int quorumHeight; if (!dbIt->GetKey(curKey) || curKey >= lastKey) { - return std::nullopt; + return not_found(); } if (std::get<0>(curKey) != DB_MINED_COMMITMENT_BY_INVERSED_HEIGHT_Q_INDEXED || std::get<1>(curKey) != llmqType) { - return std::nullopt; + return not_found(); } if (uint32_t nMinedHeight = std::numeric_limits::max() - be32toh_internal(std::get<3>(curKey)); nMinedHeight > static_cast(pindex->nHeight)) { - return std::nullopt; + return not_found(); } if (!dbIt->GetValue(quorumHeight)) { - return std::nullopt; + return not_found(); } const auto* pQuorumBaseBlockIndex = pindex->GetAncestor(quorumHeight); @@ -773,7 +927,38 @@ std::optional CQuorumBlockProcessor::GetLastMinedCommitments dbIt->Next(); } - return std::nullopt; + return not_found(); +} + +std::vector CQuorumBlockProcessor::GetMinedCommitmentsByQuorumIndexUntilBlock( + Consensus::LLMQType llmqType, const CBlockIndex* pindex, int quorumIndex, size_t maxCount) const +{ + AssertLockNotHeld(m_evoDb.cs); + LOCK(m_evoDb.cs); + + auto dbIt = m_evoDb.GetCurTransaction().NewIteratorUniquePtr(); + auto firstKey = BuildInversedHeightKeyIndexed(llmqType, pindex->nHeight, quorumIndex); + auto lastKey = BuildInversedHeightKeyIndexed(llmqType, 0, quorumIndex); + dbIt->Seek(firstKey); + + std::vector ret; + while (dbIt->Valid() && ret.size() < maxCount) { + decltype(firstKey) curKey; + int quorumHeight; + if (!dbIt->GetKey(curKey) || curKey >= lastKey || + std::get<0>(curKey) != DB_MINED_COMMITMENT_BY_INVERSED_HEIGHT_Q_INDEXED || + std::get<1>(curKey) != llmqType || std::get<2>(curKey) != quorumIndex) { + break; + } + if (std::numeric_limits::max() - be32toh_internal(std::get<3>(curKey)) > + static_cast(pindex->nHeight)) { + break; + } + if (!dbIt->GetValue(quorumHeight)) break; + ret.emplace_back(Assert(pindex->GetAncestor(quorumHeight))); + dbIt->Next(); + } + return ret; } std::vector CQuorumBlockProcessor::GetLastMinedCommitmentsPerQuorumIndexUntilBlock( @@ -831,6 +1016,38 @@ std::map> CQuorumBlockProce return ret; } +std::optional> TraverseScanSequences(bool indexed, + const std::vector& sequences, + size_t count) +{ + std::vector ret; + if (!indexed) { + if (sequences.empty()) return ret; + const auto& sequence{sequences.front()}; + if (sequence.records.size() >= count) + return std::vector{sequence.records.begin(), sequence.records.begin() + count}; + if (sequence.end == ScanSequenceEnd::UNKNOWN) return std::nullopt; + return sequence.records; + } + // GetMinedCommitmentsIndexedUntilBlock: rank by rank, each quorum index in + // ascending order, skipping exhausted indexes, until `count` or a rank at + // which every index is exhausted. + for (size_t rank{0}; ret.size() < count; ++rank) { + bool any{false}; + for (const auto& sequence : sequences) { + if (rank < sequence.records.size()) { + any = true; + ret.push_back(sequence.records[rank]); + if (ret.size() == count) return ret; + } else if (sequence.end == ScanSequenceEnd::UNKNOWN) { + return std::nullopt; + } + } + if (!any) break; + } + return ret; +} + bool CQuorumBlockProcessor::HasMineableCommitment(const uint256& hash) const { LOCK(minableCommitmentsCs); diff --git a/src/llmq/blockprocessor.h b/src/llmq/blockprocessor.h index c248a13fd29f..1eecff89b4e8 100644 --- a/src/llmq/blockprocessor.h +++ b/src/llmq/blockprocessor.h @@ -7,6 +7,7 @@ #include #include +#include #include // IWYU pragma: export #include #include @@ -20,6 +21,7 @@ #include #include +#include class BlockValidationState; class CBlock; @@ -43,6 +45,51 @@ using QcHashMap = std::map>; //! As above, but keyed by quorumIndex, for rotation-enabled types. using QcIndexedHashMap = std::map>; +/** How a scan sequence of mined commitments, as a snapshot generation holds + * it, continues past its oldest record. */ +enum class ScanSequenceEnd : uint8_t { + //! No older record exists. + END = 0, + //! Older records may exist; this node does not have them. + UNKNOWN = 1, +}; + +/** One sequence a commitment scan reads: the whole history of a type without + * rotation, or of one quorum index with it. */ +struct ScanSequence { + //! Quorum base blocks of the eligible records, newest mined first. + std::vector records; + ScanSequenceEnd end{ScanSequenceEnd::END}; +}; + +/** The quorum base blocks a scan of `count` returns from `sequences`, in the + * order the ordinary scan returns them: newest first without rotation, rank + * by rank in ascending quorum index with it. nullopt when an UNKNOWN end is + * reached before the prefix is complete, so the vector cannot be proven. */ +std::optional> TraverseScanSequences(bool indexed, + const std::vector& sequences, + size_t count); + +/** Local record of how each scan sequence of a type ends in a snapshot + * generation. Never part of the snapshot wire format. */ +struct ScanCoverageRecord { + static constexpr uint16_t SCHEMA_VERSION{1}; + uint16_t schema_version{SCHEMA_VERSION}; + //! ScanSequenceEnd of each sequence: one per quorum index with rotation, + //! one otherwise. + std::vector ends; + + //! An unrecorded or unknown value never proves an end. + ScanSequenceEnd End(size_t sequence) const + { + return sequence < ends.size() && ends[sequence] == static_cast(ScanSequenceEnd::END) + ? ScanSequenceEnd::END + : ScanSequenceEnd::UNKNOWN; + } + + SERIALIZE_METHODS(ScanCoverageRecord, obj) { READWRITE(obj.schema_version, obj.ends); } +}; + /** Erase a mined commitment unless another chainstate still contains its block. */ bool EraseMinedCommitmentIfUnreferenced(CEvoDB& evo_db, const Chainstate& chainstate, gsl::not_null pindex, @@ -65,7 +112,7 @@ class CQuorumBlockProcessor // Cache the block in which a commitment was mined. Membership in a // particular chain is checked on every call so reorgs need no cache flush. - mutable PerLlmqTypeCache mapMinedCommitmentBlockCache GUARDED_BY(minableCommitmentsCs); + mutable EvoViewCache> mapMinedCommitmentBlockCache GUARDED_BY(minableCommitmentsCs); // Memoizes GetQcHashes(). The whole-result cache is keyed on the set of active // quorum base blocks, the LRU on those base-block hashes; neither key identifies @@ -74,10 +121,13 @@ class CQuorumBlockProcessor // invalidation next to the writes it has to follow, and ties their lifetime to the // block index whose CBlockIndex* the outer cache stores. mutable Mutex m_qc_hashes_cache_mutex; - mutable std::map> m_quorums_cached GUARDED_BY(m_qc_hashes_cache_mutex); - mutable PerLlmqTypeCache> m_qc_hashes_lru GUARDED_BY(m_qc_hashes_cache_mutex); - mutable QcHashMap m_qc_hashes_cached GUARDED_BY(m_qc_hashes_cache_mutex); - mutable QcIndexedHashMap m_qc_indexed_hashes_cached GUARDED_BY(m_qc_hashes_cache_mutex); + struct QcHashesCache { + std::map> m_quorums_cached; + PerLlmqTypeCache> m_qc_hashes_lru; + QcHashMap m_qc_hashes_cached; + QcIndexedHashMap m_qc_indexed_hashes_cached; + }; + mutable EvoViewCache m_qc_hashes GUARDED_BY(m_qc_hashes_cache_mutex); public: CQuorumBlockProcessor() = delete; @@ -124,6 +174,21 @@ 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 how the scan sequences of a type end in `view`, inside the + * caller's open EvoDB transaction for that view. */ + bool SeedScanCoverage(const EvoViewToken& view, Consensus::LLMQType llmqType, const ScanCoverageRecord& coverage); + //! The scan coverage seeded in the calling operation's view, if any. + std::optional GetScanCoverage(Consensus::LLMQType llmqType) const; + //! How the current view's history of one scan sequence (a quorum index of + //! the `indexed` family, or the non-indexed one) ends: END where it holds + //! every commitment mined on its chain, UNKNOWN where older ones may exist + //! that it lacks. + ScanSequenceEnd SequenceEnd(Consensus::LLMQType llmqType, bool indexed, size_t sequence) const; + /** Seed a mined commitment and its height index into `view`, inside the + * caller's open EvoDB transaction for that view. */ + bool SeedMinedCommitment(const EvoViewToken& view, Consensus::LLMQType llmqType, const uint256& quorum_hash, + const CFinalCommitment& commitment, const uint256& mined_block_hash) + EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !minableCommitmentsCs, !m_qc_hashes_cache_mutex); /** * Serialized hashes of the commitments mined for the quorums active as of pindexPrev. @@ -142,9 +207,24 @@ class CQuorumBlockProcessor const CBlockIndex* pindex, size_t cycle) const; std::optional GetLastMinedCommitmentsByQuorumIndexUntilBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex, int quorumIndex, size_t cycle) const; + //! The up to `maxCount` most recent commitments of one quorum index mined + //! at or below `pindex`, newest first. + std::vector GetMinedCommitmentsByQuorumIndexUntilBlock(Consensus::LLMQType llmqType, + const CBlockIndex* pindex, + int quorumIndex, size_t maxCount) const; + private: //! Called from every site that writes or erases mined commitment state. void DropQcHashesCache() EXCLUSIVE_LOCKS_REQUIRED(!m_qc_hashes_cache_mutex); + //! Whether headers and the carried sequences of the snapshot view `view` + //! prove that the quorum at `quorum_index`, at or below its base, was + //! never mined. + bool ProvenNeverMined(Consensus::LLMQType llmqType, gsl::not_null quorum_index, + const EvoViewToken& view) const EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + //! A scan of one sequence of `llmqType` came up short in the current + //! view: raise BlockDataUnavailableError unless SequenceEnd() says it + //! ends there. + void RequireSatisfiedScan(Consensus::LLMQType llmqType, bool indexed, size_t sequence, const char* func) const; static bool GetCommitmentsFromBlock(const CBlock& block, gsl::not_null pindex, std::multimap& ret, BlockValidationState& state) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); bool ProcessCommitment(Chainstate& chainstate, int nHeight, const uint256& blockHash, const CFinalCommitment& qc, BlockValidationState& state, diff --git a/src/llmq/net_dkg.cpp b/src/llmq/net_dkg.cpp index da1f2481a0b4..86aca274222b 100644 --- a/src/llmq/net_dkg.cpp +++ b/src/llmq/net_dkg.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,7 @@ #include #include #include +#include #include namespace llmq { @@ -773,6 +775,7 @@ void NetDKG::PhaseHandlerThread(ActiveDKGSessionHandler& handler) { while (!handler.IsStopRequested()) { try { + const ActiveEvoViewScope evo_view{m_chainman.EvoDb()}; LogPrint(BCLog::LLMQ_DKG, "NetDKG::%s -- %s qi[%d] - starting HandleDKGRound\n", __func__, handler.params.name, handler.QuorumIndex()); HandleDKGRound(handler); @@ -780,6 +783,13 @@ void NetDKG::PhaseHandlerThread(ActiveDKGSessionHandler& handler) m_active->dkgdbgman.MarkAborted(handler.params.type, handler.QuorumIndex()); LogPrint(BCLog::LLMQ_DKG, "NetDKG::%s -- %s qi[%d] - aborted current DKG session\n", __func__, handler.params.name, handler.QuorumIndex()); + } catch (const evo::LocalStateError& e) { + LogPrintf("NetDKG::%s -- %s qi[%d] - Evo state unavailable: %s\n", __func__, handler.params.name, + handler.QuorumIndex(), e.what()); + // Retry after a second, unless asked to stop meanwhile. + for (int i{0}; i < 10 && !handler.IsStopRequested(); ++i) { + UninterruptibleSleep(std::chrono::milliseconds{100}); + } } } } diff --git a/src/llmq/net_quorum.cpp b/src/llmq/net_quorum.cpp index 193e959f91f1..7cf3fc873c12 100644 --- a/src/llmq/net_quorum.cpp +++ b/src/llmq/net_quorum.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -642,10 +643,18 @@ void NetQuorum::StartVvecSyncThread(CQuorumCPtr pQuorum) const return; } - workerPool.push([pQuorum = std::move(pQuorum), this](int threadId) mutable { - DataRecoveryThread(std::move(pQuorum), CQuorumDataRequest::QUORUM_VERIFICATION_VECTOR, - /*protx_hash=*/uint256(), /*start_offset=*/0); - }); + const auto release{[pQuorum] { pQuorum->ReleaseRecovery(); }}; + workerPool.push(WithCapturedEvoView( + [pQuorum = std::move(pQuorum), release, this](int threadId) mutable { + try { + DataRecoveryThread(std::move(pQuorum), CQuorumDataRequest::QUORUM_VERIFICATION_VECTOR, + /*protx_hash=*/uint256(), /*start_offset=*/0); + } catch (const evo::LocalStateError&) { + release(); + throw; + } + }, + release)); } void NetQuorum::TryStartVvecSyncThread(gsl::not_null block_index, CQuorumCPtr pQuorum, @@ -676,10 +685,18 @@ void NetQuorum::StartSkShareRecoveryThread(gsl::not_null pIn return; } - workerPool.push([pQuorum = std::move(pQuorum), pIndex, nDataMaskIn, this](int threadId) mutable { - const size_t size_offset = GetQuorumRecoveryStartOffset(*pQuorum, pIndex); - DataRecoveryThread(std::move(pQuorum), nDataMaskIn, m_role->GetProTxHash(), size_offset); - }); + const auto release{[pQuorum] { pQuorum->ReleaseRecovery(); }}; + workerPool.push(WithCapturedEvoView( + [pQuorum = std::move(pQuorum), pIndex, nDataMaskIn, release, this](int threadId) mutable { + try { + const size_t size_offset = GetQuorumRecoveryStartOffset(*pQuorum, pIndex); + DataRecoveryThread(std::move(pQuorum), nDataMaskIn, m_role->GetProTxHash(), size_offset); + } catch (const evo::LocalStateError&) { + release(); + throw; + } + }, + release)); } size_t NetQuorum::GetQuorumRecoveryStartOffset(const CQuorum& quorum, @@ -722,7 +739,7 @@ void NetQuorum::StartCleanupOldQuorumDataThread(gsl::not_null +#include + #include #include #include @@ -283,11 +285,17 @@ bool NetSigning::ProcessPendingRecoveredSigs() void NetSigning::WorkThreadSigning() { while (!workInterrupt) { - bool fMoreWork = ProcessPendingRecoveredSigs(); + bool fMoreWork{false}; + try { + const ActiveEvoViewScope evo_view{&m_sig_manager.EvoDb()}; + fMoreWork = ProcessPendingRecoveredSigs(); - constexpr auto CLEANUP_INTERVAL{5s}; - if (cleanupThrottler.TryCleanup(CLEANUP_INTERVAL)) { - m_sig_manager.Cleanup(); + constexpr auto CLEANUP_INTERVAL{5s}; + if (cleanupThrottler.TryCleanup(CLEANUP_INTERVAL)) { + m_sig_manager.Cleanup(); + } + } catch (const evo::LocalStateError& e) { + LogPrintf("NetSigning::%s -- Evo state unavailable: %s\n", __func__, e.what()); } // TODO Wakeup when pending signing is needed? @@ -323,10 +331,15 @@ void NetSigning::WorkThreadCleaning() assert(m_shares_manager); while (!workInterrupt) { - RemoveDiscouragedNodeStates(); + try { + const ActiveEvoViewScope evo_view{&m_sig_manager.EvoDb()}; + RemoveDiscouragedNodeStates(); - m_shares_manager->SendMessages(); - m_shares_manager->Cleanup(); + m_shares_manager->SendMessages(); + m_shares_manager->Cleanup(); + } catch (const evo::LocalStateError& e) { + LogPrintf("NetSigning::%s -- Evo state unavailable: %s\n", __func__, e.what()); + } workInterrupt.sleep_for(std::chrono::milliseconds(100)); } @@ -337,51 +350,61 @@ void NetSigning::WorkThreadDispatcher() assert(m_shares_manager); while (!workInterrupt) { - // Dispatch all pending signs (individual tasks) - { - auto signs = m_shares_manager->DispatchPendingSigns(); - // Dispatch all signs to worker pool - for (auto& work : signs) { - if (workInterrupt) break; - - worker_pool.push([this, work = std::move(work)](int) mutable { - auto rs = m_shares_manager->SignAndProcessSingleShare(std::move(work)); - ProcessRecoveredSig(rs, true); - }); + try { + // Tasks pushed below run under this round's binding. + const ActiveEvoViewScope evo_view{&m_sig_manager.EvoDb()}; + // Dispatch all pending signs (individual tasks) + { + auto signs = m_shares_manager->DispatchPendingSigns(); + // Dispatch all signs to worker pool + for (auto& work : signs) { + if (workInterrupt) break; + + worker_pool.push(WithCapturedEvoView([this, work = std::move(work)](int) mutable { + auto rs = m_shares_manager->SignAndProcessSingleShare(std::move(work)); + ProcessRecoveredSig(rs, true); + })); + } } - } - // Collect pending sig shares synchronously and dispatch each batch to a worker for parallel BLS verification. - // Batches awaiting verification are bounded so that under flood shares back up in the capped - // pending maps instead of migrating into the unbounded worker pool task queue. - // - // Each batch is bounded by actual share count (not unique-session count), so at most - // MAX_UNVERIFIED_BATCHES * MAX_SHARES_PER_BATCH shares can be sitting in / on the worker pool at once. - static constexpr int MAX_UNVERIFIED_BATCHES{4}; - static constexpr size_t MAX_SHARES_PER_BATCH{32}; - while (!workInterrupt && unverified_batches < MAX_UNVERIFIED_BATCHES) { - std::unordered_map> sigSharesByNodes; - std::unordered_map, CQuorumCPtr, StaticSaltedHasher> quorums; - - bool more_work = m_shares_manager->CollectPendingSigSharesToVerify(MAX_SHARES_PER_BATCH, sigSharesByNodes, quorums); - - if (sigSharesByNodes.empty()) { - break; + // Collect pending sig shares synchronously and dispatch each batch to a worker for parallel BLS + // verification. Batches awaiting verification are bounded so that under flood shares back up in the capped + // pending maps instead of migrating into the unbounded worker pool task queue. + // + // Each batch is bounded by actual share count (not unique-session count), so at most + // MAX_UNVERIFIED_BATCHES * MAX_SHARES_PER_BATCH shares can be sitting in / on the worker pool at once. + static constexpr int MAX_UNVERIFIED_BATCHES{4}; + static constexpr size_t MAX_SHARES_PER_BATCH{32}; + while (!workInterrupt && unverified_batches < MAX_UNVERIFIED_BATCHES) { + std::unordered_map> sigSharesByNodes; + std::unordered_map, CQuorumCPtr, StaticSaltedHasher> quorums; + + bool more_work = m_shares_manager->CollectPendingSigSharesToVerify(MAX_SHARES_PER_BATCH, + sigSharesByNodes, quorums); + + if (sigSharesByNodes.empty()) { + break; + } + + ++unverified_batches; + worker_pool.push(WithCapturedEvoView( + [this, sigSharesByNodes = std::move(sigSharesByNodes), quorums = std::move(quorums)](int) mutable { + // Ensures unverified_batches is decremented on every exit path, including exceptions. + struct UnverifiedBatchGuard { + std::atomic& count; + ~UnverifiedBatchGuard() { --count; } + } guard{unverified_batches}; + ProcessPendingSigShares(std::move(sigSharesByNodes), std::move(quorums)); + }, + [this] { --unverified_batches; })); + + if (!more_work) { + break; + } } - ++unverified_batches; - worker_pool.push([this, sigSharesByNodes = std::move(sigSharesByNodes), quorums = std::move(quorums)](int) mutable { - // Ensures unverified_batches is decremented on every exit path, including exceptions. - struct UnverifiedBatchGuard { - std::atomic& count; - ~UnverifiedBatchGuard() { --count; } - } guard{unverified_batches}; - ProcessPendingSigShares(std::move(sigSharesByNodes), std::move(quorums)); - }); - - if (!more_work) { - break; - } + } catch (const evo::LocalStateError& e) { + LogPrintf("NetSigning::%s -- Evo state unavailable: %s\n", __func__, e.what()); } // Always sleep briefly between checks diff --git a/src/llmq/quorumsman.cpp b/src/llmq/quorumsman.cpp index 6a0f13d67b52..5e14537cf007 100644 --- a/src/llmq/quorumsman.cpp +++ b/src/llmq/quorumsman.cpp @@ -36,10 +36,16 @@ CQuorumManager::CQuorumManager(CBLSWorker& _blsWorker, CDeterministicMNManager& quorumBlockProcessor{_quorumBlockProcessor}, m_qsnapman{qsnapman}, m_chainman{chainman}, - db{util::MakeDbWrapper({db_params.path / "llmq" / "quorumdb", db_params.memory, db_params.wipe, /*cache_size=*/1 << 20, db_params.options})} + m_evoDb{_evoDb}, + db{util::MakeDbWrapper({db_params.path / "llmq" / "quorumdb", db_params.memory, db_params.wipe, + /*cache_size=*/1 << 20, db_params.options})}, + m_view_caches{[&consensus = chainman.GetConsensus()] { + ViewCaches caches; + caches.mapQuorumsCache.Init(consensus, /*limit_by_connections=*/false); + return caches; + }} { AssertLockHeld(::cs_main); - mapQuorumsCache.Init(m_chainman.GetConsensus(), /*limit_by_connections=*/false); MigrateOldQuorumDB(_evoDb); } @@ -73,8 +79,9 @@ CQuorumPtr CQuorumManager::BuildQuorumFromCommitment(const Consensus::LLMQType l auto quorum = std::make_shared(llmq_params_opt.value(), blsWorker, std::make_unique(std::move(qc)), pQuorumBaseBlockIndex, minedBlockHash, members); + const EvoViewToken view{m_evoDb.CurrentView()}; if (populate_cache && llmq_params_opt->size == 1) { - WITH_LOCK(m_cs_maps, mapQuorumsCache.insert(llmqType, quorumHash, quorum)); + WITH_LOCK(m_cs_maps, m_view_caches.For(view).mapQuorumsCache.insert(llmqType, quorumHash, quorum)); return quorum; } @@ -98,7 +105,7 @@ CQuorumPtr CQuorumManager::BuildQuorumFromCommitment(const Consensus::LLMQType l QueueQuorumForWarming(quorum); } - WITH_LOCK(m_cs_maps, mapQuorumsCache.insert(llmqType, quorumHash, quorum)); + WITH_LOCK(m_cs_maps, m_view_caches.For(view).mapQuorumsCache.insert(llmqType, quorumHash, quorum)); return quorum; } @@ -146,6 +153,67 @@ bool CQuorumManager::HasQuorum(Consensus::LLMQType llmqType, const CQuorumBlockP return quorum_block_processor.HasMinedCommitment(llmqType, quorumHash, chain); } +std::optional NormalizeScanStart(const Consensus::LLMQParams& llmq_params, + gsl::not_null start) +{ + // Quorum sets can only change during the mining phase of DKG. + // Find the closest known block index. + const int quorumCycleStartHeight = start->nHeight - (start->nHeight % llmq_params.dkgInterval); + const int quorumCycleMiningStartHeight = quorumCycleStartHeight + llmq_params.dkgMiningWindowStart; + const int quorumCycleMiningEndHeight = quorumCycleStartHeight + llmq_params.dkgMiningWindowEnd; + + if (start->nHeight < quorumCycleMiningStartHeight) { + // too early for this cycle, use the previous one + // bail out if it's below genesis block + if (quorumCycleMiningEndHeight < llmq_params.dkgInterval) return std::nullopt; + return start->GetAncestor(quorumCycleMiningEndHeight - llmq_params.dkgInterval); + } + if (start->nHeight > quorumCycleMiningEndHeight) { + // we are past the mining phase of this cycle, use it + return start->GetAncestor(quorumCycleMiningEndHeight); + } + // everything else is inside the mining phase of this cycle, no adjustment needed + return start.get(); +} + +std::vector CQuorumManager::ProveScan(Consensus::LLMQType llmqType, + gsl::not_null start, size_t count) const +{ + const auto& llmq_params_opt = m_chainman.GetParams().GetLLMQ(llmqType); + if (count == 0 || !llmq_params_opt || !m_chainman.IsQuorumTypeEnabled(llmqType, start)) return {}; + const auto cutoff{NormalizeScanStart(*llmq_params_opt, start)}; + if (!cutoff) return {}; + return ProveScanFromCutoff(llmqType, *cutoff, count); +} + +std::vector CQuorumManager::ProveScanFromCutoff(Consensus::LLMQType llmqType, + gsl::not_null cutoff, + size_t count) const +{ + const auto llmq_params_opt{m_chainman.GetParams().GetLLMQ(llmqType)}; + assert(llmq_params_opt.has_value()); + const auto& llmq_params{*llmq_params_opt}; + const auto end_of = [&](size_t sequence) { + return quorumBlockProcessor.SequenceEnd(llmqType, llmq_params.useRotation, sequence); + }; + std::vector sequences; + if (llmq_params.useRotation) { + for (int quorum_index{0}; quorum_index < llmq_params.signingActiveQuorumCount; ++quorum_index) { + sequences.push_back( + {quorumBlockProcessor.GetMinedCommitmentsByQuorumIndexUntilBlock(llmqType, cutoff, quorum_index, count), + end_of(quorum_index)}); + } + } else { + sequences.push_back({quorumBlockProcessor.GetMinedCommitmentsUntilBlock(llmqType, cutoff, count), end_of(0)}); + } + auto proven{TraverseScanSequences(llmq_params.useRotation, sequences, count)}; + if (!proven) { + throw BlockDataUnavailableError(strprintf("quorum scan of %d for llmqType=%d from block %s is not covered", count, + std23::to_underlying(llmqType), cutoff->GetBlockHash().ToString())); + } + return std::move(*proven); +} + std::vector CQuorumManager::ScanQuorums(Consensus::LLMQType llmqType, size_t nCountRequested) const { const CBlockIndex* pindex = WITH_LOCK(::cs_main, return m_chainman.ActiveTip()); @@ -175,33 +243,66 @@ std::vector CQuorumManager::ScanQuorums(Consensus::LLMQType llmqTyp return {}; } - gsl::not_null pindexStore{pindexStart}; const auto& llmq_params_opt = Params().GetLLMQ(llmqType); assert(llmq_params_opt.has_value()); - - // Quorum sets can only change during the mining phase of DKG. - // Find the closest known block index. - const int quorumCycleStartHeight = pindexStart->nHeight - (pindexStart->nHeight % llmq_params_opt->dkgInterval); - const int quorumCycleMiningStartHeight = quorumCycleStartHeight + llmq_params_opt->dkgMiningWindowStart; - const int quorumCycleMiningEndHeight = quorumCycleStartHeight + llmq_params_opt->dkgMiningWindowEnd; - - if (pindexStart->nHeight < quorumCycleMiningStartHeight) { - // too early for this cycle, use the previous one - // bail out if it's below genesis block - if (quorumCycleMiningEndHeight < llmq_params_opt->dkgInterval) return {}; - pindexStore = pindexStart->GetAncestor(quorumCycleMiningEndHeight - llmq_params_opt->dkgInterval); - } else if (pindexStart->nHeight > quorumCycleMiningEndHeight) { - // we are past the mining phase of this cycle, use it - pindexStore = pindexStart->GetAncestor(quorumCycleMiningEndHeight); - } - // everything else is inside the mining phase of this cycle, no pindexStore adjustment needed + const auto cutoff{NormalizeScanStart(*llmq_params_opt, pindexStart)}; + if (!cutoff) return {}; + gsl::not_null pindexStore{*cutoff}; gsl::not_null pIndexScanCommitments{pindexStore}; size_t nScanCommitments{nCountRequested}; std::vector vecResultQuorums; + const EvoViewToken view{m_evoDb.CurrentView()}; + if (view.identity == EvoDbIdentity::SNAPSHOT) { + // Only the carried history exists at or below the base: prove the + // requested prefix from the original cutoff, never continue a shorter + // cached one, and cache only proven prefixes. + if (chain == nullptr) { + LOCK(m_cs_maps); + auto& scanQuorumsCache{m_view_caches.For(view).scanQuorumsCache}; + if (!scanQuorumsCache.IsInitialized()) { + scanQuorumsCache.Init(Params().GetConsensus(), [](const Consensus::LLMQParams& llmq) { + return llmq.max_cycles(llmq.keepOldConnections) * (llmq.dkgMiningWindowEnd - llmq.dkgMiningWindowStart); + }); + } + if (scanQuorumsCache.get(llmqType, pindexStore->GetBlockHash(), vecResultQuorums) && + vecResultQuorums.size() >= nCountRequested) { + return {vecResultQuorums.begin(), vecResultQuorums.begin() + nCountRequested}; + } + vecResultQuorums.clear(); + } + for (const CBlockIndex* pQuorumBaseBlockIndex : ProveScanFromCutoff(llmqType, pindexStore, nCountRequested)) { + const bool populate_cache{vecResultQuorums.size() < static_cast(llmq_params_opt->keepOldConnections)}; + CQuorumCPtr quorum; + if (chain) { + quorum = [&]() NO_THREAD_SAFETY_ANALYSIS { + return GetQuorum(llmqType, pQuorumBaseBlockIndex, *chain, populate_cache); + }(); + } else { + quorum = GetQuorum(llmqType, pQuorumBaseBlockIndex, populate_cache); + } + // The scan proved this quorum is mined: failing to build it is + // this node's state, never an empty (quorum-less) answer. + if (!quorum) { + throw BlockDataUnavailableError(strprintf("quorum llmqType=%d at block %s is not available", + std23::to_underlying(llmqType), + pQuorumBaseBlockIndex->GetBlockHash().ToString())); + } + vecResultQuorums.emplace_back(quorum); + } + if (!vecResultQuorums.empty() && chain == nullptr) { + LOCK(m_cs_maps); + const size_t cached{ + std::min(vecResultQuorums.size(), static_cast(llmq_params_opt->keepOldConnections))}; + m_view_caches.For(view).scanQuorumsCache.emplace(llmqType, pindexStore->GetBlockHash(), + {vecResultQuorums.begin(), vecResultQuorums.begin() + cached}); + } + return vecResultQuorums; + } if (chain == nullptr) { LOCK(m_cs_maps); + auto& scanQuorumsCache{m_view_caches.For(view).scanQuorumsCache}; if (!scanQuorumsCache.IsInitialized()) { // NOTE: We store it for each block hash in the DKG mining phase here // and not for a single quorum hash per quorum like we do for other caches. @@ -277,8 +378,9 @@ std::vector CQuorumManager::ScanQuorums(Consensus::LLMQType llmqTyp // because signing by old quorums requires the exact quorum hash // to be specified and quorum scanning isn't needed there. const size_t nCacheEndIndex = std::min(nCountResult, static_cast(llmq_params_opt->keepOldConnections)); - scanQuorumsCache.emplace(llmqType, pindexStore->GetBlockHash(), - {vecResultQuorums.begin(), vecResultQuorums.begin() + nCacheEndIndex}); + m_view_caches.For(view).scanQuorumsCache.emplace(llmqType, pindexStore->GetBlockHash(), + {vecResultQuorums.begin(), + vecResultQuorums.begin() + nCacheEndIndex}); } // Don't return more than nCountRequested elements const size_t nResultEndIndex = std::min(nCountResult, nCountRequested); @@ -299,34 +401,34 @@ std::vector CQuorumManager::ScanCommitments(Consensus::LLMQTyp return {}; } - gsl::not_null pindexStore{pindexStart}; const auto& llmq_params_opt = Params().GetLLMQ(llmqType); assert(llmq_params_opt.has_value()); - - // Quorum sets can only change during the mining phase of DKG. - // Find the closest known block index. - const int quorumCycleStartHeight = pindexStart->nHeight - (pindexStart->nHeight % llmq_params_opt->dkgInterval); - const int quorumCycleMiningStartHeight = quorumCycleStartHeight + llmq_params_opt->dkgMiningWindowStart; - const int quorumCycleMiningEndHeight = quorumCycleStartHeight + llmq_params_opt->dkgMiningWindowEnd; - - if (pindexStart->nHeight < quorumCycleMiningStartHeight) { - // too early for this cycle, use the previous one - // bail out if it's below genesis block - if (quorumCycleMiningEndHeight < llmq_params_opt->dkgInterval) return {}; - pindexStore = pindexStart->GetAncestor(quorumCycleMiningEndHeight - llmq_params_opt->dkgInterval); - } else if (pindexStart->nHeight > quorumCycleMiningEndHeight) { - // we are past the mining phase of this cycle, use it - pindexStore = pindexStart->GetAncestor(quorumCycleMiningEndHeight); - } - // everything else is inside the mining phase of this cycle, no pindexStore adjustment needed + const auto cutoff{NormalizeScanStart(*llmq_params_opt, pindexStart)}; + if (!cutoff) return {}; + gsl::not_null pindexStore{*cutoff}; gsl::not_null pIndexScanCommitments{pindexStore}; size_t nScanCommitments{nCountRequested}; std::vector vecResultCommitments; std::optional continuationQuorumHash; + const EvoViewToken view{m_evoDb.CurrentView()}; + if (view.identity == EvoDbIdentity::SNAPSHOT) { + // As in ScanQuorums: prove the requested prefix, uncached. + for (const CBlockIndex* pQuorumBaseBlockIndex : ProveScanFromCutoff(llmqType, pindexStore, nCountRequested)) { + auto [qc, _] = quorumBlockProcessor.GetMinedCommitment(llmqType, pQuorumBaseBlockIndex->GetBlockHash()); + if (qc.IsNull()) { + throw BlockDataUnavailableError(strprintf("commitment llmqType=%d at block %s is not available", + std23::to_underlying(llmqType), + pQuorumBaseBlockIndex->GetBlockHash().ToString())); + } + vecResultCommitments.emplace_back(std::move(qc)); + } + return vecResultCommitments; + } { LOCK(m_cs_maps); + auto& scanCommitmentsCache{m_view_caches.For(view).scanCommitmentsCache}; if (scanCommitmentsCache.empty()) { for (const auto& llmq : Params().GetConsensus().llmqs) { scanCommitmentsCache.try_emplace(llmq.type, llmq.max_cycles(llmq.keepOldConnections) * (llmq.dkgMiningWindowEnd - llmq.dkgMiningWindowStart)); @@ -399,7 +501,7 @@ std::vector CQuorumManager::ScanCommitments(Consensus::LLMQTyp // Don't cache more than keepOldConnections elements // because signing by old quorums requires the exact quorum hash // to be specified and quorum scanning isn't needed there. - auto& cache = scanCommitmentsCache[llmqType]; + auto& cache = m_view_caches.For(view).scanCommitmentsCache[llmqType]; const size_t nCacheEndIndex = std::min(nCountResult, static_cast(llmq_params_opt->keepOldConnections)); cache.emplace(pindexStore->GetBlockHash(), {vecResultCommitments.begin(), vecResultCommitments.begin() + nCacheEndIndex}); } @@ -527,8 +629,9 @@ CQuorumCPtr CQuorumManager::GetQuorum(Consensus::LLMQType llmqType, gsl::not_nul CQuorumPtr pQuorum; { + const EvoViewToken view{m_evoDb.CurrentView()}; LOCK(m_cs_maps); - if (mapQuorumsCache.get(llmqType, quorumHash, pQuorum)) { + if (m_view_caches.For(view).mapQuorumsCache.get(llmqType, quorumHash, pQuorum)) { return pQuorum; } } @@ -548,7 +651,8 @@ CQuorumCPtr CQuorumManager::GetQuorum(Consensus::LLMQType llmqType, } CQuorumPtr pQuorum; - if (LOCK(m_cs_maps); mapQuorumsCache.get(llmqType, quorumHash, pQuorum)) { + const EvoViewToken view{m_evoDb.CurrentView()}; + if (LOCK(m_cs_maps); m_view_caches.For(view).mapQuorumsCache.get(llmqType, quorumHash, pQuorum)) { return pQuorum; } @@ -609,8 +713,9 @@ CQuorumManager::DataResponseValidation CQuorumManager::ValidateDataResponse( CQuorumPtr CQuorumManager::GetCachedMutableQuorum(Consensus::LLMQType llmqType, const uint256& quorumHash) const { CQuorumPtr pQuorum; + const EvoViewToken view{m_evoDb.CurrentView()}; LOCK(m_cs_maps); - mapQuorumsCache.get(llmqType, quorumHash, pQuorum); + m_view_caches.For(view).mapQuorumsCache.get(llmqType, quorumHash, pQuorum); return pQuorum; } @@ -655,6 +760,8 @@ void CQuorumManager::MigrateOldQuorumDB(CEvoDB& evoDb) const LogPrint(BCLog::LLMQ, "CQuorumManager::%s -- start\n", __func__); + // NORMAL-only lifecycle operation: it uses the raw legacy keyspace and + // never sees snapshot-generation records. CDBBatch batch(*db); std::unique_ptr pcursor(evoDb.GetRawDB().NewIterator()); diff --git a/src/llmq/quorumsman.h b/src/llmq/quorumsman.h index 86d06ae275d9..92261859bf9f 100644 --- a/src/llmq/quorumsman.h +++ b/src/llmq/quorumsman.h @@ -7,6 +7,7 @@ #include #include +#include #include // IWYU pragma: export #include #include @@ -69,6 +70,7 @@ class CQuorumManager final CQuorumBlockProcessor& quorumBlockProcessor; CQuorumSnapshotManager& m_qsnapman; const ChainstateManager& m_chainman; + CEvoDB& m_evoDb; llmq::CDKGSessionManager* m_qdkgsman{nullptr}; llmq::QuorumRole* m_handler{nullptr}; @@ -83,10 +85,12 @@ class CQuorumManager final mutable size_t m_inbound_request_count GUARDED_BY(cs_data_requests){0}; mutable Mutex m_cs_maps; - mutable std::map>> scanCommitmentsCache - GUARDED_BY(m_cs_maps); - mutable PerLlmqTypeCache mapQuorumsCache GUARDED_BY(m_cs_maps); - mutable PerLlmqTypeCache> scanQuorumsCache GUARDED_BY(m_cs_maps); + struct ViewCaches { + std::map>> scanCommitmentsCache; + PerLlmqTypeCache mapQuorumsCache; + PerLlmqTypeCache> scanQuorumsCache; + }; + mutable EvoViewCache m_view_caches GUARDED_BY(m_cs_maps); // On mainnet, we have around 62 quorums active at any point; let's cache a little more than double that to be safe. // it maps `quorum_hash` to `pindex` @@ -129,6 +133,11 @@ class CQuorumManager final const uint256& quorumHash, const CChain& chain) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + //! The Evo view the calling operation reads. + EvoViewToken CurrentView() const { return m_evoDb.CurrentView(); } + //! The EvoDB this manager reads, for callers that bind an Evo view. + const CEvoDB& EvoDb() const { return m_evoDb; } + // all these methods will lock cs_main for a short period of time CQuorumCPtr GetQuorum(Consensus::LLMQType llmqType, const uint256& quorumHash) const EXCLUSIVE_LOCKS_REQUIRED(!cs_db, !m_cs_maps); @@ -152,6 +161,14 @@ class CQuorumManager final size_t nCountRequested) const EXCLUSIVE_LOCKS_REQUIRED(!cs_db, !m_cs_maps); + /** The quorum base blocks a scan of `count` from `start` returns, proven + * in the calling view: ScanQuorums' normalization and index-family + * dispatch over this view's commitment index, whose carried sequences + * end as recorded when its snapshot was seeded. Throws + * BlockDataUnavailableError when the prefix is not covered. */ + std::vector ProveScan(Consensus::LLMQType llmqType, gsl::not_null start, + size_t count) const; + bool IsMasternode() const; bool IsWatching() const; @@ -187,6 +204,8 @@ class CQuorumManager final gsl::not_null pindexStart, size_t nCountRequested, const CChain* chain) const EXCLUSIVE_LOCKS_REQUIRED(!cs_db, !m_cs_maps); + std::vector ProveScanFromCutoff(Consensus::LLMQType llmqType, + gsl::not_null cutoff, size_t count) const; bool BuildQuorumContributions(const CFinalCommitmentPtr& fqc, const std::shared_ptr& quorum) const; CQuorumPtr BuildQuorumFromCommitment(Consensus::LLMQType llmqType, @@ -204,6 +223,12 @@ class CQuorumManager final void MigrateOldQuorumDB(CEvoDB& evoDb) const EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs_db); }; +/** The block a scan from `start` reads up to: inside a DKG mining window the + * start itself, past it the window's end, before it the previous cycle's + * window end. nullopt when that precedes genesis. */ +std::optional NormalizeScanStart(const Consensus::LLMQParams& llmq_params, + gsl::not_null start); + // when selecting a quorum for signing and verification, we use CQuorumManager::SelectQuorum with this offset as // starting height for scanning. This is because otherwise the resulting signatures would not be verifiable by nodes // which are not 100% at the chain tip. diff --git a/src/llmq/signing.cpp b/src/llmq/signing.cpp index e6ceb6eeb7c2..465a370df6cf 100644 --- a/src/llmq/signing.cpp +++ b/src/llmq/signing.cpp @@ -429,6 +429,8 @@ void CRecoveredSigsDb::CleanupOldVotes(int64_t maxAge) ////////////////// +const CEvoDB& CSigningManager::EvoDb() const { return qman.EvoDb(); } + CSigningManager::CSigningManager(const CQuorumManager& _qman, const util::DbWrapperParams& db_params, int64_t max_recsigs_age) : db{db_params}, diff --git a/src/llmq/signing.h b/src/llmq/signing.h index b2ee2d22ba00..56f07cb6f85e 100644 --- a/src/llmq/signing.h +++ b/src/llmq/signing.h @@ -24,6 +24,7 @@ class Chainstate; class CDataStream; class CDBBatch; class CDBWrapper; +class CEvoDB; class CInv; class CTransaction; struct RPCResult; @@ -199,6 +200,9 @@ class CSigningManager CSigningManager(const CSigningManager&) = delete; CSigningManager& operator=(const CSigningManager&) = delete; explicit CSigningManager(const CQuorumManager& _qman, const util::DbWrapperParams& db_params, int64_t max_recsigs_age); + + //! The EvoDB the quorum manager reads, for workers that bind an Evo view. + const CEvoDB& EvoDb() const; ~CSigningManager(); bool AlreadyHave(const CInv& inv) const EXCLUSIVE_LOCKS_REQUIRED(!cs_pending); diff --git a/src/llmq/snapshot.cpp b/src/llmq/snapshot.cpp index bde0d32fb783..e889c46ddb41 100644 --- a/src/llmq/snapshot.cpp +++ b/src/llmq/snapshot.cpp @@ -9,9 +9,11 @@ #include #include #include +#include #include #include #include +#include #include #include @@ -22,6 +24,7 @@ using node::CBlockIndexHeightOnlyComparator; namespace { constexpr std::string_view DB_QUORUM_SNAPSHOT{"llmq_S"}; +constexpr std::string_view DB_QUORUM_MODIFIER{"llmq_M3"}; bool CheckBlockDataAvailable(gsl::not_null pindex, std::string& error) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) @@ -58,6 +61,11 @@ std::optional ConstructCycle(llmq::CQuorumSnapshotManager& qsna ret.m_snap = opt_snap.value(); } else { error = "Cannot find quorum snapshot"; + // Locally missing below a snapshot base, not the requester's fault. + if (qsnapman.IsCarriedOnly(ret.m_cycle_index)) { + error = strprintf("Quorum snapshot for block %s %s", ret.m_cycle_index->GetBlockHash().ToString(), + BLOCK_DATA_UNAVAILABLE_SUFFIX); + } return std::nullopt; } } @@ -74,6 +82,12 @@ void InsertBaseBlockSorted(std::vector& baseBlockIndexes, co } // anonymous namespace namespace llmq { +bool CQuorumSnapshotManager::IsCarriedOnly(const CBlockIndex* pindex) const +{ + const EvoViewToken view{m_evoDb.CurrentView()}; + return view.identity == EvoDbIdentity::SNAPSHOT && pindex->nHeight <= view.base_height; +} + bool BuildQuorumRotationInfo(CDeterministicMNManager& dmnman, CQuorumSnapshotManager& qsnapman, const ChainstateManager& chainman, const CQuorumManager& qman, const CQuorumBlockProcessor& qblockman, const CGetQuorumRotationInfo& request, @@ -292,7 +306,7 @@ std::vector CQuorumRotationInfo::GetCycles() CQuorumSnapshotManager::CQuorumSnapshotManager(CEvoDB& evoDb) : m_evoDb{evoDb}, - quorumSnapshotCache{32} + quorumSnapshotCache{[] { return Uint256LruHashMap{32}; }} { } @@ -303,14 +317,16 @@ std::optional CQuorumSnapshotManager::GetSnapshotForBlock(const CQuorumSnapshot snapshot = {}; auto snapshotHash = ::SerializeHash(std::make_pair(llmqType, pindex->GetBlockHash())); + const EvoViewToken view{m_evoDb.CurrentView()}; LOCK(snapshotCacheCs); + auto& cache{quorumSnapshotCache.For(view)}; // try using cache before reading from disk - if (quorumSnapshotCache.get(snapshotHash, snapshot)) { + if (cache.get(snapshotHash, snapshot)) { return snapshot; } if (m_evoDb.Read(std::make_pair(DB_QUORUM_SNAPSHOT, snapshotHash), snapshot)) { - quorumSnapshotCache.insert(snapshotHash, snapshot); + cache.insert(snapshotHash, snapshot); return snapshot; } @@ -320,11 +336,54 @@ std::optional CQuorumSnapshotManager::GetSnapshotForBlock(const void CQuorumSnapshotManager::StoreSnapshotForBlock(const Consensus::LLMQType llmqType, const CBlockIndex* pindex, const CQuorumSnapshot& snapshot) { auto snapshotHash = ::SerializeHash(std::make_pair(llmqType, pindex->GetBlockHash())); + const auto key{std::make_pair(DB_QUORUM_SNAPSHOT, snapshotHash)}; - // LOCK(::cs_main); AssertLockNotHeld(m_evoDb.cs); - LOCK2(snapshotCacheCs, m_evoDb.cs); - m_evoDb.GetRawDB().Write(std::make_pair(DB_QUORUM_SNAPSHOT, snapshotHash), snapshot); - quorumSnapshotCache.insert(snapshotHash, snapshot); + const EvoViewToken view{m_evoDb.CurrentView()}; + if (view.identity == EvoDbIdentity::SNAPSHOT && pindex->nHeight <= view.base_height) { + // At or below the snapshot base the snapshot view holds only carried + // state. A carried quarter must match the one just computed from + // complete inputs. An uncarried one is a reconstruction output, never a + // required input, so it is neither persisted nor cached. + CQuorumSnapshot carried; + if (m_evoDb.Read(key, carried) && ::SerializeHash(carried) != ::SerializeHash(snapshot)) { + throw evo::SnapshotStateMismatchError( + strprintf("carried quorum snapshot for %s at height %d differs from the computed one", + pindex->GetBlockHash().ToString(), pindex->nHeight)); + } + return; + } + + LOCK(snapshotCacheCs); + m_evoDb.WriteDurable(key, snapshot); + quorumSnapshotCache.For(view).insert(snapshotHash, snapshot); +} + +bool CQuorumSnapshotManager::SeedSnapshotForBlock(const EvoViewToken& view, const Consensus::LLMQType llmqType, + const CBlockIndex* pindex, const CQuorumSnapshot& snapshot) +{ + if (!Assume(pindex != nullptr) || !Assume(m_evoDb.HasActiveTransaction())) return false; + const EvoViewScope scope{view}; + const auto snapshot_hash{::SerializeHash(std::make_pair(llmqType, pindex->GetBlockHash()))}; + if (!m_evoDb.WriteDerived(std::make_pair(DB_QUORUM_SNAPSHOT, snapshot_hash), snapshot)) return false; + LOCK(snapshotCacheCs); + quorumSnapshotCache.For(view).erase(snapshot_hash); + return true; +} + +bool CQuorumSnapshotManager::SeedQuorumModifier(const EvoViewToken& view, Consensus::LLMQType llmq_type, + const uint256& work_block_hash, const uint256& modifier) +{ + if (!Assume(m_evoDb.HasActiveTransaction())) return false; + const EvoViewScope scope{view}; + return m_evoDb.WriteDerived(std::make_tuple(DB_QUORUM_MODIFIER, 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(DB_QUORUM_MODIFIER, llmq_type, work_block_hash), modifier)) return std::nullopt; + return modifier; } } // namespace llmq diff --git a/src/llmq/snapshot.h b/src/llmq/snapshot.h index 725072ad84e6..d6340d2b76c9 100644 --- a/src/llmq/snapshot.h +++ b/src/llmq/snapshot.h @@ -6,6 +6,7 @@ #define BITCOIN_LLMQ_SNAPSHOT_H #include +#include #include #include #include @@ -237,7 +238,8 @@ class CQuorumSnapshotManager CEvoDB& m_evoDb; - Uint256LruHashMap quorumSnapshotCache GUARDED_BY(snapshotCacheCs); + EvoViewCache> quorumSnapshotCache GUARDED_BY(snapshotCacheCs){ + [] { return Uint256LruHashMap{32}; }}; public: CQuorumSnapshotManager() = delete; @@ -246,8 +248,26 @@ class CQuorumSnapshotManager explicit CQuorumSnapshotManager(CEvoDB& evoDb); ~CQuorumSnapshotManager(); + //! The Evo view the calling operation reads, for callers that partition + //! their own caches of state derived from quorum snapshots. + EvoViewToken CurrentView() const { return m_evoDb.CurrentView(); } + std::optional GetSnapshotForBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex); void StoreSnapshotForBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex, const CQuorumSnapshot& snapshot); + //! Whether the calling operation reads the snapshot view and `pindex` is + //! at or below its base, where only carried quarters exist. + bool IsCarriedOnly(const CBlockIndex* pindex) const; + + /** Seed a carried rotation quarter into `view`, inside the caller's open + * EvoDB transaction for that view. */ + bool SeedSnapshotForBlock(const EvoViewToken& view, Consensus::LLMQType llmqType, const CBlockIndex* pindex, + const CQuorumSnapshot& snapshot); + /** Seed the exact score modifier of a work block into `view`, inside the + * caller's open EvoDB transaction for that view. */ + bool SeedQuorumModifier(const EvoViewToken& view, Consensus::LLMQType llmq_type, const uint256& work_block_hash, + const uint256& modifier); + //! The modifier seeded for a work block in the calling operation's view. + std::optional GetSeededQuorumModifier(Consensus::LLMQType llmq_type, const uint256& work_block_hash) const; }; } // namespace llmq diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 195a050375a1..b3d6a08ebf7b 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -6,6 +6,8 @@ #include #include +#include +#include #include #include #include @@ -16,6 +18,7 @@ #include #include +#include #include #include #include @@ -27,7 +30,8 @@ /** * Forward declarations */ -std::optional> GetNonNullCoinbaseChainlock(const CBlockIndex* pindex); +std::optional> GetNonNullCoinbaseChainlock(const CBlock& block, const CBlockIndex& index, + const Consensus::Params& consensus); namespace { using QuorumMembers = std::vector; @@ -84,36 +88,81 @@ arith_uint256 calculateQuorumScore(const CDeterministicMNCPtr& dmn, const uint25 return UintToArith256(h); } -uint256 GetHashModifierFromWorkBlock(const Consensus::LLMQParams& llmqParams, const CBlockIndex* pWorkBlockIndex) -{ - auto cbcl = GetNonNullCoinbaseChainlock(pWorkBlockIndex); - if (cbcl.has_value()) { - // We have a non-null CL signature: calculate modifier using this CL signature - auto& [bestCLSignature, bestCLHeightDiff] = cbcl.value(); - return ::SerializeHash(std::make_tuple(llmqParams.type, pWorkBlockIndex->nHeight, bestCLSignature)); - } - // No non-null CL signature found in coinbase: calculate modifier using block hash only - return ::SerializeHash(std::make_pair(llmqParams.type, pWorkBlockIndex->GetBlockHash())); -} - -uint256 GetHashModifier(const Consensus::LLMQParams& llmqParams, const Consensus::Params& consensus_params, - gsl::not_null pCycleQuorumBaseBlockIndex) +//! The modifier of a cycle whose work block precedes v20. +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); + Assume(!DeploymentActiveAfter(pWorkBlockIndex, consensus_params, Consensus::DEPLOYMENT_V20)); - if (DeploymentActiveAfter(pWorkBlockIndex, consensus_params, Consensus::DEPLOYMENT_V20)) { - // v20 is active: calculate modifier using the new way. - return GetHashModifierFromWorkBlock(llmqParams, pWorkBlockIndex); - } - - // v20 isn't active yet: calculate modifier using the usual way if (llmqParams.useRotation) { return ::SerializeHash(std::make_pair(llmqParams.type, pWorkBlockIndex->GetBlockHash())); } return ::SerializeHash(std::make_pair(llmqParams.type, pCycleQuorumBaseBlockIndex->GetBlockHash())); } +//! The ChainLock-derived modifier of a v20 work block from its own data. The +//! signature is hashed in the scheme of the block that carries it, never in +//! the one the process happens to run. +uint256 ModifierFromBlock(const Consensus::LLMQParams& llmq_params, const Consensus::Params& consensus_params, + const CBlock& block, const CBlockIndex& work_index) +{ + if (const auto chainlock{GetNonNullCoinbaseChainlock(block, work_index, consensus_params)}) { + CHashWriter hasher{SER_GETHASH, PROTOCOL_VERSION}; + hasher << llmq_params.type << work_index.nHeight; + chainlock->first.Serialize(hasher, /*specificLegacyScheme=*/!DeploymentActiveAt(work_index, consensus_params, + Consensus::DEPLOYMENT_V19)); + return hasher.GetHash(); + } + return ::SerializeHash(std::make_pair(llmq_params.type, work_index.GetBlockHash())); +} + +//! The modifier from the work block's own data, or nullopt when this node +//! cannot read the block. +std::optional ModifierFromWorkBlockData(const Consensus::LLMQParams& llmq_params, + const Consensus::Params& consensus_params, + gsl::not_null work_index) +{ + if (WITH_LOCK(::cs_main, return (work_index->nStatus & BLOCK_HAVE_DATA) == 0)) return std::nullopt; + CBlock block; + if (!node::ReadBlockFromDisk(block, work_index, consensus_params)) return std::nullopt; + return ModifierFromBlock(llmq_params, consensus_params, block, *work_index); +} + +//! The exact modifier of a v20 work block: the seeded value or the block's +//! own, which must agree when both exist. Only NORMAL keeps the block-hash +//! fallback for a block it cannot read; the snapshot view raises unavailable. +uint256 ResolveWorkBlockModifier(const Consensus::LLMQParams& llmq_params, const Consensus::Params& consensus_params, + gsl::not_null work_index, const llmq::CQuorumSnapshotManager& qsnapman) +{ + const auto seeded{qsnapman.GetSeededQuorumModifier(llmq_params.type, work_index->GetBlockHash())}; + const auto from_block{ModifierFromWorkBlockData(llmq_params, consensus_params, work_index)}; + if (seeded && from_block && *seeded != *from_block) { + throw evo::SnapshotStateMismatchError( + strprintf("seeded quorum score modifier for work block %s differs", work_index->GetBlockHash().ToString())); + } + if (from_block) return *from_block; + if (seeded) return *seeded; + if (qsnapman.CurrentView().identity == EvoDbIdentity::SNAPSHOT) { + throw BlockDataUnavailableError(strprintf("quorum score modifier of work block %s is not available", + work_index->GetBlockHash().ToString())); + } + return ::SerializeHash(std::make_pair(llmq_params.type, work_index->GetBlockHash())); +} + +//! The modifier of a cycle, through the exact work-block resolver once v20 is +//! active at its work block. +uint256 GetHashModifier(const Consensus::LLMQParams& llmq_params, const Consensus::Params& consensus_params, + gsl::not_null cycle_index, const llmq::CQuorumSnapshotManager& qsnapman) +{ + const CBlockIndex* work_index{cycle_index->GetAncestor(cycle_index->nHeight - llmq::WORK_DIFF_DEPTH)}; + if (DeploymentActiveAfter(work_index, consensus_params, Consensus::DEPLOYMENT_V20)) { + return ResolveWorkBlockModifier(llmq_params, consensus_params, Assert(work_index), qsnapman); + } + return CalculateHashModifier(llmq_params, consensus_params, cycle_index); +} + std::vector CalculateScoresForQuorum(QuorumMembers&& dmns, const uint256& modifier, const bool onlyEvoNodes) { std::vector scores; @@ -187,6 +236,7 @@ QuorumMembers CalculateQuorum(List&& mn_list, const uint256& modifier, size_t ma std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LLMQParams& llmqParams, CDeterministicMNManager& dmnman, + const llmq::CQuorumSnapshotManager& qsnapman, const Consensus::Params& consensus_params, const CBlockIndex* pCycleQuorumBaseBlockIndex, const llmq::CQuorumSnapshot& snapshot, int nHeight) @@ -201,7 +251,7 @@ std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LL const CBlockIndex* pWorkBlockIndex = pCycleQuorumBaseBlockIndex->GetAncestor( pCycleQuorumBaseBlockIndex->nHeight - llmq::WORK_DIFF_DEPTH); auto mn_list = dmnman.GetListForBlock(pWorkBlockIndex); - const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex); + const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex, qsnapman); auto sortedAllMns = CalculateQuorum(mn_list, modifier); std::vector usedMNs; @@ -289,7 +339,8 @@ std::vector GetQuorumQuarterMembersBySnapshot(const Consensus::LL } QuorumMembers ComputeQuorumMembers(Consensus::LLMQType llmqType, const CChainParams& chainparams, - const CDeterministicMNList& mn_list, const CBlockIndex* pQuorumBaseBlockIndex) + const CDeterministicMNList& mn_list, const CBlockIndex* pQuorumBaseBlockIndex, + const llmq::CQuorumSnapshotManager& qsnapman) { bool EvoOnly = (chainparams.GetConsensus().llmqTypePlatform == llmqType) && DeploymentActiveAfter(pQuorumBaseBlockIndex, chainparams.GetConsensus(), Consensus::DEPLOYMENT_V19); @@ -300,14 +351,14 @@ QuorumMembers ComputeQuorumMembers(Consensus::LLMQType llmqType, const CChainPar return {}; } - const auto modifier = GetHashModifier(llmq_params_opt.value(), chainparams.GetConsensus(), pQuorumBaseBlockIndex); + const auto modifier = GetHashModifier(llmq_params_opt.value(), chainparams.GetConsensus(), pQuorumBaseBlockIndex, + qsnapman); return CalculateQuorum(mn_list, modifier, llmq_params_opt->size, EvoOnly); } -void BuildQuorumSnapshot(const Consensus::LLMQParams& llmqParams, const Consensus::Params& consensus_params, - const CDeterministicMNList& allMns, const CDeterministicMNList& mnUsedAtH, - llmq::CQuorumSnapshot& quorumSnapshot, std::vector& skipList, - const CBlockIndex* pCycleQuorumBaseBlockIndex) +void BuildQuorumSnapshot(const Consensus::LLMQParams& llmqParams, const CDeterministicMNList& allMns, + const CDeterministicMNList& mnUsedAtH, llmq::CQuorumSnapshot& quorumSnapshot, + std::vector& skipList, const CBlockIndex* pCycleQuorumBaseBlockIndex, const uint256& modifier) { if (!llmqParams.useRotation || pCycleQuorumBaseBlockIndex->nHeight % llmqParams.dkgInterval != 0) { ASSERT_IF_DEBUG(false); @@ -316,7 +367,6 @@ void BuildQuorumSnapshot(const Consensus::LLMQParams& llmqParams, const Consensu const auto allMnsTotal = allMns.GetCounts().total(); quorumSnapshot.activeQuorumMembers.resize(allMnsTotal); - const auto modifier = GetHashModifier(llmqParams, consensus_params, pCycleQuorumBaseBlockIndex); auto sortedAllMns = CalculateQuorum(allMns, modifier); LogPrint(BCLog::LLMQ, "BuildQuorumSnapshot h[%d] numMns[%d]\n", pCycleQuorumBaseBlockIndex->nHeight, @@ -457,8 +507,9 @@ std::vector BuildNewQuorumQuarterMembers(const Consensus::LLMQPar if (storeSnapshot) { llmq::CQuorumSnapshot quorumSnapshot{}; - BuildQuorumSnapshot(llmqParams, util_params.m_chainman.GetConsensus(), allMns, MnsUsedAtH, quorumSnapshot, - skipList, util_params.m_base_index); + // Index the bitset in member-selection order, including when the + // modifier came from a seeded value. + BuildQuorumSnapshot(llmqParams, allMns, MnsUsedAtH, quorumSnapshot, skipList, util_params.m_base_index, modifier); util_params.m_qsnapman.StoreSnapshotForBlock(llmqParams.type, util_params.m_base_index, quorumSnapshot); } @@ -497,16 +548,28 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus opt_snap.has_value()) { prev_cycles[idx]->m_snap = opt_snap.value(); } else { + // Below a snapshot base only carried quarters exist. Computing or + // predicting from fewer quarters, or reporting them as not yet + // available, would misstate the result, so report a missing + // applicable quarter as unavailable instead. A cycle that never + // had one (before rotation or the type) keeps the legacy break. + const CBlockIndex* missing{prev_cycles[idx]->m_cycle_index}; + if (util_params.m_qsnapman.IsCarriedOnly(missing) && llmq::IsQuorumRotationEnabled(llmqParams, missing) && + util_params.m_chainman.IsQuorumTypeEnabled(llmqParams.type, missing->pprev)) { + throw BlockDataUnavailableError(strprintf("Quorum snapshot for block %s %s", + prev_cycles[idx]->m_cycle_index->GetBlockHash().ToString(), + BLOCK_DATA_UNAVAILABLE_SUFFIX)); + } if (predicting) return {}; // TODO: Check if it is triggered from outside (P2P, block validation) and maybe throw an exception // assert(false); 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, - cycleBaseHeight); + prev_cycles[idx]->m_snap, cycleBaseHeight); } auto newQuarterMembers = BuildNewQuorumQuarterMembers(llmqParams, util_params, allMns, previousQuarters, modifier, @@ -546,6 +609,18 @@ std::vector ComputeQuorumMembersByQuarterRotation(const Consensus namespace llmq { namespace utils { +uint256 GetQuorumModifierForWorkBlock(const Consensus::LLMQParams& llmq_params, const Consensus::Params& consensus_params, + gsl::not_null work_index, const CQuorumSnapshotManager& qsnapman) +{ + return ResolveWorkBlockModifier(llmq_params, consensus_params, work_index, qsnapman); +} + +uint256 GetWorkBlockModifier(const Consensus::LLMQParams& llmq_params, const Consensus::Params& consensus_params, + const CBlock& block, const CBlockIndex& work_index) +{ + return ModifierFromBlock(llmq_params, consensus_params, block, work_index); +} + BlsCheck::BlsCheck() = default; BlsCheck::BlsCheck(CBLSSignature sig, std::vector pubkeys, uint256 msg_hash, std::string id_string) : @@ -611,7 +686,7 @@ std::optional> ComputeQuorumMembersFromWorkBlo return std::nullopt; } - const auto modifier = GetHashModifierFromWorkBlock(llmq_params, pWorkBlockIndex.get()); + const auto modifier = ResolveWorkBlockModifier(llmq_params, consensus, pWorkBlockIndex, util_params.m_qsnapman); if (!llmq_params.useRotation) { // Canonical member selection gates EvoOnly on V19 at the future quorum base block; V19 @@ -634,9 +709,11 @@ std::optional> ComputeQuorumMembersFromWorkBlo QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParameters& util_params, bool reset_cache) { static RecursiveMutex cs_members; - static PerLlmqTypeCache mapQuorumMembers GUARDED_BY(cs_members); + static EvoViewCache> members_by_view GUARDED_BY(cs_members); static RecursiveMutex cs_indexed_members; - static PerLlmqTypeCache> mapIndexedQuorumMembers GUARDED_BY(cs_indexed_members); + static EvoViewCache>> indexed_members_by_view GUARDED_BY( + cs_indexed_members); + const EvoViewToken view{util_params.m_qsnapman.CurrentView()}; // A parentless base index (genesis) can never host a quorum. IsQuorumTypeEnabled() handles the // null, but say so explicitly here: this is reached with an attacker-supplied quorumHash via @@ -649,6 +726,7 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame std::vector quorumMembers; { LOCK(cs_members); + auto& mapQuorumMembers{members_by_view.For(view)}; if (!mapQuorumMembers.IsInitialized()) { mapQuorumMembers.Init(util_params.m_chainman.GetConsensus()); } @@ -664,8 +742,8 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame const auto& llmq_params = llmq_params_opt.value(); if (IsQuorumRotationEnabled(llmq_params, util_params.m_base_index)) { - if (LOCK(cs_indexed_members); !mapIndexedQuorumMembers.IsInitialized()) { - mapIndexedQuorumMembers.Init(util_params.m_chainman.GetConsensus()); + if (LOCK(cs_indexed_members); !indexed_members_by_view.For(view).IsInitialized()) { + indexed_members_by_view.For(view).Init(util_params.m_chainman.GetConsensus()); } /* * Quorums created with rotation are now created in a different way. All signingActiveQuorumCount are created @@ -690,24 +768,25 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame */ if (reset_cache) { LOCK(cs_indexed_members); - mapIndexedQuorumMembers.clear(llmqType); - } else if (LOCK(cs_indexed_members); mapIndexedQuorumMembers.get( + indexed_members_by_view.For(view).clear(llmqType); + } else if (LOCK(cs_indexed_members); indexed_members_by_view.For(view).get( llmqType, std::pair(pCycleQuorumBaseBlockIndex->GetBlockHash(), quorumIndex), quorumMembers)) { LOCK(cs_members); - mapQuorumMembers.insert(llmqType, util_params.m_base_index->GetBlockHash(), quorumMembers); + members_by_view.For(view).insert(llmqType, util_params.m_base_index->GetBlockHash(), quorumMembers); return quorumMembers; } 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); quorumMembers = q[quorumIndex]; LOCK(cs_indexed_members); + auto& mapIndexedQuorumMembers{indexed_members_by_view.For(view)}; for (const size_t i : util::irange(q.size())) { mapIndexedQuorumMembers.emplace(llmqType, std::make_pair(pCycleQuorumBaseBlockIndex->GetBlockHash(), i), std::move(q[i])); @@ -721,11 +800,11 @@ 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); - mapQuorumMembers.insert(llmqType, util_params.m_base_index->GetBlockHash(), quorumMembers); + members_by_view.For(view).insert(llmqType, util_params.m_base_index->GetBlockHash(), quorumMembers); return quorumMembers; } diff --git a/src/llmq/utils.h b/src/llmq/utils.h index 65c4c2682808..054fab9145a0 100644 --- a/src/llmq/utils.h +++ b/src/llmq/utils.h @@ -20,6 +20,7 @@ #include #include +class CBlock; class CBlockIndex; class CDeterministicMNManager; class ChainstateManager; @@ -43,6 +44,19 @@ struct UtilParameters { }; namespace utils { +/** The exact score modifier of a v20 work block: its seeded value or the one + * its coinbase ChainLock gives, checked against each other when both exist. + * Without either, the snapshot view raises BlockDataUnavailableError where + * NORMAL falls back to the block-hash modifier. */ +uint256 GetQuorumModifierForWorkBlock(const Consensus::LLMQParams& llmq_params, const Consensus::Params& consensus_params, + gsl::not_null work_index, + const CQuorumSnapshotManager& qsnapman); +/** The score modifier `block`, a v20 work block whose index is `work_index`, + * gives from its coinbase ChainLock, decoded with the block's own BLS scheme + * rather than the one the node is in. */ +uint256 GetWorkBlockModifier(const Consensus::LLMQParams& llmq_params, const Consensus::Params& consensus_params, + const CBlock& block, const CBlockIndex& work_index); + struct BlsCheck { CBLSSignature m_sig; std::vector m_pubkeys; diff --git a/src/net.cpp b/src/net.cpp index ef787baae989..25f45e1371fc 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -40,6 +40,7 @@ #include #include +#include #include #include #include @@ -59,6 +60,7 @@ #include #include #include +#include #include #include @@ -3150,7 +3152,12 @@ void CConnman::ThreadOpenConnections(const std::vector connect, CDe addrman.ResolveCollisions(); - auto mnList = dmnman.GetListAtChainTip(); + // Without the masternode list, connected masternodes cannot be + // skipped below; try again next round. + std::optional tip_list; + RunActiveEvoRound(&dmnman.EvoDb(), "opencon", [&] { tip_list = dmnman.GetListAtChainTip(); }); + if (!tip_list) continue; + const auto& mnList{*tip_list}; const auto current_time{NodeClock::now()}; int nTries = 0; @@ -3427,7 +3434,10 @@ void CConnman::ThreadOpenMasternodeConnections(CDeterministicMNManager& dmnman, } }); - auto mnList = dmnman.GetListAtChainTip(); + std::optional tip_list; + RunActiveEvoRound(&dmnman.EvoDb(), "mncon", [&] { tip_list = dmnman.GetListAtChainTip(); }); + if (!tip_list) continue; + const auto& mnList{*tip_list}; if (interruptNet) return; @@ -3674,7 +3684,20 @@ void CConnman::OpenMasternodeConnection(const CAddress &addrConnect, bool use_v2 Mutex NetEventsInterface::g_msgproc_mutex; -void CConnman::ThreadMessageHandler() +bool CConnman::ServicePeer(const CEvoDB& evodb, CNode& node, bool send) +{ + bool more_work{false}; + try { + const ActiveEvoViewScope evo_view{&evodb}; + more_work = m_msgproc->ProcessMessages(&node, flagInterruptMsgProc); + if (send && !flagInterruptMsgProc) m_msgproc->SendMessages(&node); + } catch (const evo::LocalStateError& e) { + LogPrint(BCLog::NET, "peer=%d: Evo state unavailable: %s\n", node.GetId(), e.what()); + } + return more_work; +} + +void CConnman::ThreadMessageHandler(const CEvoDB& evodb) { LOCK(NetEventsInterface::g_msgproc_mutex); @@ -3700,16 +3723,9 @@ void CConnman::ThreadMessageHandler() if (pnode->fDisconnect) continue; - // Receive messages - bool fMoreNodeWork = m_msgproc->ProcessMessages(pnode, flagInterruptMsgProc); + const bool send{!fSkipSendMessagesForMasternodes || !pnode->m_masternode_connection}; + const bool fMoreNodeWork{ServicePeer(evodb, *pnode, send)}; fMoreWork |= (fMoreNodeWork && !pnode->fPauseSend); - if (flagInterruptMsgProc) - return; - // Send messages - if (!fSkipSendMessagesForMasternodes || !pnode->m_masternode_connection) { - m_msgproc->SendMessages(pnode); - } - if (flagInterruptMsgProc) return; } @@ -4101,7 +4117,8 @@ bool CConnman::Start(CDeterministicMNManager& dmnman, CMasternodeMetaMan& mn_met } // Process messages - threadMessageHandler = std::thread(&util::TraceThread, "msghand", [this] { ThreadMessageHandler(); }); + threadMessageHandler = std::thread(&util::TraceThread, "msghand", + [this, &evodb = dmnman.EvoDb()] { ThreadMessageHandler(evodb); }); if (m_i2p_sam_session) { threadI2PAcceptIncoming = diff --git a/src/net.h b/src/net.h index 3b1ded7de0fb..3ebe8f128ce7 100644 --- a/src/net.h +++ b/src/net.h @@ -56,6 +56,7 @@ class BanMan; class CConnman; class CDeterministicMNList; class CDeterministicMNManager; +class CEvoDB; class CMasternodeMetaMan; class CMasternodeSync; class CNode; @@ -1619,7 +1620,13 @@ friend class CNode; void ThreadOpenConnections(const std::vector connect, CDeterministicMNManager& dmnman) EXCLUSIVE_LOCKS_REQUIRED(!m_addr_fetches_mutex, !m_added_nodes_mutex, !m_nodes_mutex, !m_reconnections_mutex, !m_unused_i2p_sessions_mutex, !mutexMsgProc, !cs_mapSocketToNode); - void ThreadMessageHandler() EXCLUSIVE_LOCKS_REQUIRED(!m_nodes_mutex, !mutexMsgProc); + void ThreadMessageHandler(const CEvoDB& evodb) EXCLUSIVE_LOCKS_REQUIRED(!m_nodes_mutex, !mutexMsgProc); + /** One message-handler turn for a peer: receive (its queued requests, + * orphans to reconsider, then its next message) and, if `send`, send. + * The turn is bound to the active Evo view, and a local Evo state error + * ends it, never the thread. Returns whether the peer has more work. */ + bool ServicePeer(const CEvoDB& evodb, CNode& node, bool send) + EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex, !m_nodes_mutex, !mutexMsgProc); void ThreadI2PAcceptIncoming(CMasternodeSync& mn_sync) EXCLUSIVE_LOCKS_REQUIRED(!m_nodes_mutex, !mutexMsgProc, !cs_mapSocketToNode); void AcceptConnection(const ListenSocket& hListenSocket, CMasternodeSync& mn_sync) EXCLUSIVE_LOCKS_REQUIRED(!m_nodes_mutex, !mutexMsgProc, !cs_mapSocketToNode); diff --git a/src/net_processing.cpp b/src/net_processing.cpp index d975be9e63b2..c92d6f19a226 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -52,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -1106,6 +1107,11 @@ class PeerManagerImpl final : public PeerManager void ProcessGetData(CNode& pfrom, Peer& peer, const std::atomic& interruptMsgProc) EXCLUSIVE_LOCKS_REQUIRED(!m_most_recent_block_mutex, peer.m_getdata_requests_mutex, NetEventsInterface::g_msgproc_mutex) LOCKS_EXCLUDED(::cs_main); + /** Serve the requests at the front of the peer's GETDATA queue, collecting + * the ones not found in vNotFound. */ + void ServeGetData(CNode& pfrom, Peer& peer, const std::atomic& interruptMsgProc, std::vector& vNotFound) + EXCLUSIVE_LOCKS_REQUIRED(!m_most_recent_block_mutex, peer.m_getdata_requests_mutex, + NetEventsInterface::g_msgproc_mutex) LOCKS_EXCLUDED(::cs_main); /** Process a new block. Perform any post-processing housekeeping */ void ProcessBlock(CNode& from, const std::shared_ptr& pblock, bool force_processing); @@ -2952,14 +2958,14 @@ CTransactionRef PeerManagerImpl::FindTxForGetData(const Peer& peer, const uint25 return {}; } -void PeerManagerImpl::ProcessGetData(CNode& pfrom, Peer& peer, const std::atomic& interruptMsgProc) +void PeerManagerImpl::ServeGetData(CNode& pfrom, Peer& peer, const std::atomic& interruptMsgProc, + std::vector& vNotFound) { AssertLockNotHeld(cs_main); auto tx_relay = peer.GetTxRelay(); std::deque::iterator it = peer.m_getdata_requests.begin(); - std::vector vNotFound; const CNetMsgMaker msgMaker(pfrom.GetCommonVersion()); const auto now{GetTime()}; @@ -2978,12 +2984,14 @@ void PeerManagerImpl::ProcessGetData(CNode& pfrom, Peer& peer, const std::atomic if (pfrom.fPauseSend) break; - const CInv &inv = *it; + const CInv inv{*it}; if (inv.type == MSG_BLOCK || inv.type == MSG_FILTERED_BLOCK || inv.type == MSG_CMPCT_BLOCK) { break; } - ++it; + // Dequeued before it is served: a lookup that throws leaves neither + // this request nor the ones served before it queued. + it = peer.m_getdata_requests.erase(it); if (tx_relay == nullptr && NetMessageViolatesBlocksOnly(inv.GetCommand())) { // Note that if we receive a getdata for non-block messages @@ -3100,15 +3108,28 @@ void PeerManagerImpl::ProcessGetData(CNode& pfrom, Peer& peer, const std::atomic // Only process one BLOCK item per call, since they're uncommon and can be // expensive to process. if (it != peer.m_getdata_requests.end() && !pfrom.fPauseSend) { - const CInv &inv = *it++; + const CInv inv{*it}; + it = peer.m_getdata_requests.erase(it); if (inv.IsGenBlkMsg()) { ProcessGetBlockData(pfrom, peer, inv); } // else: If the first item on the queue is an unknown type, we erase it // and continue processing the queue on the next call. } +} - peer.m_getdata_requests.erase(peer.m_getdata_requests.begin(), it); +void PeerManagerImpl::ProcessGetData(CNode& pfrom, Peer& peer, const std::atomic& interruptMsgProc) +{ + std::vector vNotFound; + // A lookup this node's Evo state cannot answer ends the batch. The + // requests found missing before it have left the queue, so they are still + // answered; the one that failed is not reported missing. + std::exception_ptr local_error; + try { + ServeGetData(pfrom, peer, interruptMsgProc, vNotFound); + } catch (const evo::LocalStateError&) { + local_error = std::current_exception(); + } if (!vNotFound.empty()) { // Let the peer know that we didn't find what it asked for, so it doesn't @@ -3125,8 +3146,9 @@ void PeerManagerImpl::ProcessGetData(CNode& pfrom, Peer& peer, const std::atomic // In normal operation, we often send NOTFOUND messages for parents of // transactions that we relay; if a peer is missing a parent, they may // assume we have them and request the parents from us. - m_connman.PushMessage(&pfrom, msgMaker.Make(NetMsgType::NOTFOUND, vNotFound)); + m_connman.PushMessage(&pfrom, CNetMsgMaker{pfrom.GetCommonVersion()}.Make(NetMsgType::NOTFOUND, vNotFound)); } + if (local_error) std::rethrow_exception(local_error); } void PeerManagerImpl::SendBlockTransactions(CNode& pfrom, Peer& peer, const CBlock& block, const BlockTransactionsRequest& req) { @@ -5718,9 +5740,16 @@ void PeerManagerImpl::ProcessMessage( llmq::CQuorumRotationInfo quorumRotationInfoRet; std::string strError; bool use_legacy_construction = pfrom.GetCommonVersion() < EFFICIENT_QRINFO_VERSION;; - const bool built{BuildQuorumRotationInfo(m_dmnman, *m_llmq_ctx.qsnapman, m_chainman, *m_llmq_ctx.qman, - *m_llmq_ctx.quorum_block_processor, cmd, use_legacy_construction, - quorumRotationInfoRet, strError)}; + bool built{false}; + try { + built = BuildQuorumRotationInfo(m_dmnman, *m_llmq_ctx.qsnapman, m_chainman, *m_llmq_ctx.qman, + *m_llmq_ctx.quorum_block_processor, cmd, use_legacy_construction, + quorumRotationInfoRet, strError); + } catch (const evo::LocalStateError& e) { + // This node's Evo state cannot answer: not the peer's fault, but + // charged below like any build that stopped partway. + strError = strprintf("%s %s", e.what(), BLOCK_DATA_UNAVAILABLE_SUFFIX); + } if (!pfrom.HasPermission(NetPermissionFlags::NoBan)) { // The token taken above paid for one diff. Charge the rest of the reply (the tip, H, the // target cycles and the snapshot diffs) now that its size is known. A build that failed diff --git a/src/node/blockstorage.cpp b/src/node/blockstorage.cpp index 822e983573cb..2b73e1f42aa2 100644 --- a/src/node/blockstorage.cpp +++ b/src/node/blockstorage.cpp @@ -251,6 +251,20 @@ void BlockManager::UpdatePruneLock(const std::string& name, const PruneLockInfo& m_prune_locks[name] = lock_info; } +bool BlockManager::DeletePruneLock(const std::string& name) +{ + AssertLockHeld(::cs_main); + return m_prune_locks.erase(name) > 0; +} + +std::optional BlockManager::GetPruneLock(const std::string& name) const +{ + AssertLockHeld(::cs_main); + const auto it{m_prune_locks.find(name)}; + if (it == m_prune_locks.end()) return std::nullopt; + return it->second; +} + CBlockIndex* BlockManager::InsertBlockIndex(const uint256& hash) { AssertLockHeld(cs_main); @@ -977,6 +991,12 @@ void ThreadImport(ChainstateManager& chainman, std::vector vImportFile for (Chainstate* chainstate : WITH_LOCK(::cs_main, return chainman.GetAll())) { BlockValidationState state; if (!chainstate->ActivateBestChain(state, nullptr)) { + if (IsLocalEvoStateError(state)) { + // The chainstate stays eligible; the other chainstates + // and the rest of startup proceed without it. + LogPrintf("Chainstate cannot advance yet (%s), continuing\n", state.ToString()); + continue; + } AbortNode(strprintf("Failed to connect best block (%s)", state.ToString())); return; } diff --git a/src/node/blockstorage.h b/src/node/blockstorage.h index 66eb4bfc35dd..0ab6c0314dbb 100644 --- a/src/node/blockstorage.h +++ b/src/node/blockstorage.h @@ -238,6 +238,11 @@ class BlockManager //! Create or update a prune lock identified by its name void UpdatePruneLock(const std::string& name, const PruneLockInfo& lock_info) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + + //! Delete a prune lock identified by its name. Returns true if the lock existed. + bool DeletePruneLock(const std::string& name) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + + std::optional GetPruneLock(const std::string& name) const EXCLUSIVE_LOCKS_REQUIRED(::cs_main); }; void CleanupBlockRevFiles(); diff --git a/src/node/chainstate.cpp b/src/node/chainstate.cpp index 6d8e9d042c01..b3be4e206618 100644 --- a/src/node/chainstate.cpp +++ b/src/node/chainstate.cpp @@ -6,14 +6,16 @@ #include #include -#include #include +#include +#include #include #include #include #include #include #include +#include #include #include #include @@ -28,6 +30,8 @@ #include #include #include +#include +#include #include #include @@ -48,10 +52,30 @@ static bool RecoverSnapshotCleanup(CEvoDB& evodb, const fs::path& data_dir, bili fs::path invalid{snapshot}; invalid += SNAPSHOT_INVALID_SUFFIX; + // A persisted rejection whose coins directory was not invalidated yet: + // finish the invalidation, then drop the rejected generation. A live + // generation owns the snapshot directory, so the rejected one's is gone. + if (evodb.HasRejectedGeneration()) { + if (fs::exists(snapshot) && !evodb.GetSnapshotGeneration()) { + LogPrintf("[snapshot] completing interrupted invalid-snapshot coins invalidation\n"); + try { + RenameDurably(snapshot, invalid); + } catch (const fs::filesystem_error& e) { + error = strprintf(_("Failed to finish interrupted invalid snapshot cleanup: %s"), e.what()); + return false; + } + } + if (!evodb.CollectRejectedGenerations()) { + error = _("Failed to remove rejected assumeutxo snapshot state from the Evo database."); + return false; + } + } + uint256 snapshot_tip; const bool has_snapshot_tip{evodb.ReadBestBlock(EvoDbIdentity::SNAPSHOT, snapshot_tip)}; uint256 ignored; - const bool has_metadata{has_snapshot_tip || evodb.HasDualChainstateMarker() || + const auto journal{evodb.ReadPromotionJournal()}; + const bool has_metadata{has_snapshot_tip || journal || evodb.HasDualChainstateMarker() || evodb.ReadSnapshotBaseMNListHash(ignored)}; // The first validated-cleanup rename completed. Roll it back so the normal @@ -69,28 +93,27 @@ static bool RecoverSnapshotCleanup(CEvoDB& evodb, const fs::path& data_dir, bili // Both validated-cleanup renames completed. The NORMAL coins directory now // contains the snapshot tip, while lifecycle markers still describe the dual - // state. Match those durable facts before promoting the markers. + // state. Match those durable facts before finishing. if (fs::exists(normal) && !fs::exists(snapshot) && has_metadata) { - uint256 coins_tip; - try { - CCoinsViewDB coins_db{DBParams{.path = normal, .cache_bytes = 1 << 20, .obfuscate = true}, CoinsViewOptions{}}; - coins_tip = coins_db.GetBestBlock(); - } catch (const std::exception& e) { - error = strprintf(_("Failed to inspect interrupted snapshot cleanup: %s"), e.what()); - return false; - } - // Caution: this condition can also match a crash between an - // invalid-snapshot rename and its marker discard when validation - // failed with the background tip AT the base block (HASH_MISMATCH / - // EVO_STATE_MISMATCH): the background coins tip and the SNAPSHOT - // marker both hold the base hash. Taking the promote branch there is - // deliberate and equivalent: PromoteSnapshotMarkers rewrites the - // NORMAL best-block with the value it already has and erases the same - // marker set DiscardSnapshotMarkers would. Keep the two functions' - // side effects equivalent under that overlap, or disambiguate here. - if (has_snapshot_tip && coins_tip == snapshot_tip) { - LogPrintf("[snapshot] completing interrupted snapshot marker promotion\n"); - if (!evodb.PromoteSnapshotMarkers(snapshot_tip)) { + // A promotion is journaled before its first rename, so only a + // journaled cleanup may promote; anything else is an interrupted + // invalid-snapshot cleanup. + if (journal) { + uint256 coins_tip; + try { + CCoinsViewDB coins_db{DBParams{.path = normal, .cache_bytes = 1 << 20, .obfuscate = true}, + CoinsViewOptions{}}; + coins_tip = coins_db.GetBestBlock(); + } catch (const std::exception& e) { + error = strprintf(_("Failed to inspect interrupted snapshot cleanup: %s"), e.what()); + return false; + } + if (coins_tip != journal->tip) { + error = _("Interrupted snapshot cleanup has inconsistent coins and EvoDB tips."); + return false; + } + LogPrintf("[snapshot] completing interrupted snapshot EvoDB promotion\n"); + if (!evodb.PromoteSnapshotMarkers(journal->tip)) { error = _("Failed to finish interrupted snapshot marker promotion."); return false; } @@ -105,8 +128,7 @@ static bool RecoverSnapshotCleanup(CEvoDB& evodb, const fs::path& data_dir, bili } // Invalid snapshot rename completed, but the synced marker discard did - // not. A stale _INVALID directory cannot mask a completed valid swap: - // the coins/snapshot-tip match above always takes precedence. + // not. if (fs::exists(invalid)) { LogPrintf("[snapshot] completing interrupted invalid-snapshot marker cleanup\n"); if (!evodb.DiscardSnapshotMarkers()) { @@ -132,7 +154,9 @@ static bool RecoverSnapshotCleanup(CEvoDB& evodb, const fs::path& data_dir, bili } } - // No metadata is otherwise the already-promoted/no-snapshot state and needs no work. + // No metadata is otherwise the already-promoted/no-snapshot state and needs + // no work. A journal with both coins directories still in place records a + // promotion that renamed nothing yet: completion re-runs and redoes it. return true; } @@ -173,6 +197,9 @@ static ChainstateLoadResult CompleteChainstateInitialization(ChainstateManager& if (options.check_interrupt && options.check_interrupt()) return {ChainstateLoadStatus::INTERRUPTED, {}}; return {ChainstateLoadStatus::FAILURE, _("Error loading block database")}; } + // The snapshot base is resolvable now: keep the bodies its evo inputs + // still need. + chainman.UpdateSnapshotPruneLock(); if (!chainman.BlockIndex().empty() && !chainman.m_blockman.LookupBlockIndex(chainman.GetConsensus().hashGenesisBlock)) { @@ -217,6 +244,7 @@ static ChainstateLoadResult CompleteChainstateInitialization(ChainstateManager& // block tree into BlockIndex()! for (Chainstate* chainstate : chainman.GetAll()) { + const EvoViewScope evo_view{chainstate->EvoView()}; LogPrintf("Initializing chainstate %s\n", chainstate->ToString()); chainstate->InitCoinsDB( @@ -267,7 +295,7 @@ static ChainstateLoadResult CompleteChainstateInitialization(ChainstateManager& } } - if (!special_tx.m_mnhfman->ForceSignalDBUpdate(chainman.ActiveTip())) { + if (!special_tx.m_mnhfman->ForceSignalDBUpdate(chainman.ActiveChainstate().EvoView(), chainman.ActiveTip())) { return {ChainstateLoadStatus::FAILURE, _("Error upgrading evo database for EHF")}; } @@ -284,6 +312,46 @@ static ChainstateLoadResult CompleteChainstateInitialization(ChainstateManager& return {ChainstateLoadStatus::SUCCESS, {}}; } +//! The live generation's retained evo section must still match the pin its +//! base is authorized by: the seeded state is only as good as that match. +static util::Result RevalidateRetainedEvoSection(const ChainstateManager& chainman, const CEvoDB& evodb) +{ + const auto generation{evodb.GetSnapshotGeneration()}; + if (!generation) return {}; + const auto refuse = [](const std::string& reason) -> util::Result { + LogPrintf("[snapshot] %s\n", reason); + return util::Error{_("The assumeutxo snapshot's Evo state no longer matches its authorization. Restart with " + "-reindex-chainstate to discard the snapshot.")}; + }; + const AssumeutxoData* au_data{ExpectedAssumeutxo(generation->base_height, chainman.GetParams())}; + if (!au_data || (!au_data->blockhash.IsNull() && au_data->blockhash != generation->base)) { + return refuse("no assumeutxo authorization for the snapshot base"); + } + const bool dip3_base{generation->base_height >= + chainman.GetConsensus().DeploymentHeight(Consensus::DEPLOYMENT_DIP0003)}; + const auto retained{evodb.ReadRetainedEvoSection()}; + if (!retained) { + if (dip3_base || au_data->evo_hash != EvoSnapshotHash{uint256::ZERO}) { + return refuse("the retained evo section is missing"); + } + return {}; + } + evo::EvoSnapshot snapshot; + try { + CDataStream stream{*retained, SER_DISK, CLIENT_VERSION}; + stream >> snapshot; + if (!stream.empty()) return refuse("trailing data after the retained evo section"); + } catch (const std::ios_base::failure& e) { + return refuse(strprintf("the retained evo section does not decode: %s", e.what())); + } + if (snapshot.base_block_hash != generation->base) return refuse("the retained evo section is for another base"); + if ((dip3_base || au_data->evo_hash != EvoSnapshotHash{uint256::ZERO}) && + EvoSnapshotHash{evo::GetEvoSnapshotHash(snapshot)} != au_data->evo_hash) { + return refuse("the retained evo section does not match its pin"); + } + return {}; +} + ChainstateLoadResult LoadChainstate(ChainstateManager& chainman, const CacheSizes& cache_sizes, const ChainstateLoadOptions& options, CEvoDB& evodb, CDeterministicMNManager& dmnman, CSpecialTxProcessor& special_tx) @@ -307,6 +375,19 @@ ChainstateLoadResult LoadChainstate(ChainstateManager& chainman, const CacheSize LOCK(cs_main); + if (!options.reindex && !options.reindex_chainstate) { + if (auto result{evodb.LoadSnapshotGenerations( + EvoSnapshotConsensusConfig::FromParams(chainman.GetParams()).Fingerprint())}; + !result) { + return {ChainstateLoadStatus::FAILURE, util::ErrorString(result)}; + } + } + // Evo inputs captured for a snapshot that never got published, e.g. after + // a crash, are of no use. + if (!evodb.GetSnapshotGeneration() && !evo::EraseAllEvoSnapshotCaptures(evodb)) { + return {ChainstateLoadStatus::FAILURE, + _("Error removing stale assumeutxo snapshot state from the Evo database")}; + } if (!options.dash_dbs_in_memory && !options.reindex && !options.reindex_chainstate) { bilingual_str recovery_error; if (!RecoverSnapshotCleanup(evodb, options.data_dir, recovery_error)) { @@ -334,6 +415,24 @@ ChainstateLoadResult LoadChainstate(ChainstateManager& chainman, const CacheSize if (!chainman.DetectSnapshotChainstate(options.mempool, snapshot_error)) { return {ChainstateLoadStatus::FAILURE, snapshot_error}; } + if (chainman.IsSnapshotActive()) { + if (auto result{RevalidateRetainedEvoSection(chainman, evodb)}; !result) { + return {ChainstateLoadStatus::FAILURE, util::ErrorString(result)}; + } + } + // A crash between publishing a generation and persisting the snapshot + // directory's base block leaves a generation that no chainstate uses. + if (evodb.GetSnapshotGeneration() && !chainman.IsSnapshotActive()) { + if (evodb.ReadPromotionJournal() || !evodb.DiscardSnapshotMarkers()) { + return {ChainstateLoadStatus::FAILURE, + _("Snapshot EvoDB state has no snapshot chainstate. Reindex is required.")}; + } + LogPrintf("[snapshot] discarded a snapshot EvoDB generation that never activated\n"); + if (!evo::EraseAllEvoSnapshotCaptures(evodb)) { + return {ChainstateLoadStatus::FAILURE, + _("Error removing stale assumeutxo snapshot state from the Evo database")}; + } + } auto [init_status, init_error] = CompleteChainstateInitialization(chainman, cache_sizes, options, evodb, dmnman, special_tx); @@ -351,8 +450,9 @@ ChainstateLoadResult LoadChainstate(ChainstateManager& chainman, const CacheSize // too risky to do in the middle of normal runtime. const auto snapshot_completion = chainman.MaybeCompleteSnapshotValidation(); - if (snapshot_completion == SnapshotCompletionResult::SKIPPED) { - // Do nothing; expected case. + if (snapshot_completion == SnapshotCompletionResult::SKIPPED || + snapshot_completion == SnapshotCompletionResult::EVO_DATA_UNAVAILABLE) { + // Do nothing; expected case, or completion is retried once the data is there. } else if (snapshot_completion == SnapshotCompletionResult::SUCCESS) { LogPrintf("[snapshot] cleaning up unneeded background chainstate, then reinitializing\n"); if (!chainman.ValidatedSnapshotCleanup()) { @@ -403,6 +503,7 @@ ChainstateLoadResult VerifyLoadedChainstate(ChainstateManager& chainman, const C LOCK(cs_main); for (Chainstate* chainstate : chainman.GetAll()) { + const EvoViewScope evo_view{chainstate->EvoView()}; if (!is_coinsview_empty(chainstate)) { const CBlockIndex* tip = chainstate->m_chain.Tip(); if (tip && tip->nTime > GetTime() + MAX_FUTURE_BLOCK_TIME) { diff --git a/src/node/context.h b/src/node/context.h index ddda10a4f60e..04d0385accd8 100644 --- a/src/node/context.h +++ b/src/node/context.h @@ -104,6 +104,11 @@ struct NodeContext { std::unique_ptr cj_walletman; std::unique_ptr dstxman; std::unique_ptr evodb; + //! The Evo database for dispatchers that start before the block databases + //! load (the scheduler, REST) to bind to. Startup publishes it once loading, + //! retries included, has finished and withdraws it before destroying it, + //! after those dispatchers have stopped. Until then their work runs unbound. + std::atomic evodb_for_dispatch{nullptr}; std::unique_ptr special_tx; std::unique_ptr dmnman; std::unique_ptr isman; diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index d0de956bde8c..b5422cbaef30 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -14,7 +14,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -63,8 +65,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -87,6 +89,9 @@ #include #include #include +#include +#include +#include #include #include @@ -130,6 +135,57 @@ namespace node { // All members of the classes in this namespace are intentionally public, as the // classes themselves are private. namespace { +bilingual_str EvoStateUnavailableMessage() +{ + return _("Masternode state is not available yet. See debug.log for details."); +} + +ProviderTxError UnavailableProviderTxError() +{ + return {.code = interfaces::ProviderTxErrorCode::INTERNAL_ERROR, .message = EvoStateUnavailableMessage()}; +} + +//! Set the misc warning, and log `e` once per operation and active Evo view. +std::string EvoStateWarningSource(std::string_view operation) { return strprintf("interfaces:%s", operation); } + +void ReportEvoStateUnavailable(const CEvoDB* evodb, std::string_view operation, const evo::LocalStateError& e) +{ + WarnLocalEvoStateUnavailable(EvoStateWarningSource(operation), strprintf("%s could not read it", operation)); + static Mutex mutex; + static std::set> logged; + const EvoViewToken view{evodb ? evodb->ActiveView() : EvoViewToken{}}; + if (WITH_LOCK(mutex, return logged.emplace(operation, view.db, view.identity, view.generation).second)) { + LogPrintf("ERROR: %s: Evo state unavailable: %s\n", operation, e.what()); + } +} + +/** Run a GUI-facing operation that reads Evo state on the active chainstate's + * view. When this node cannot provide that state, the operation reports it + * and returns `on_unavailable()`: the error must not reach Qt, and callers + * must not mistake it for data. */ +template +std::invoke_result_t WithActiveEvoView(const CEvoDB* evodb, std::string_view operation, Fn&& fn, + OnUnavailable&& on_unavailable) +{ + try { + const ActiveEvoViewScope evo_view{evodb}; + const auto read_again = [&] { + if (SetEvoStateWarning(EvoStateWarningSource(operation), std::nullopt)) uiInterface.NotifyAlertChanged(); + }; + if constexpr (std::is_void_v>) { + fn(); + read_again(); + } else { + auto result{fn()}; + read_again(); + return result; + } + } catch (const evo::LocalStateError& e) { + ReportEvoStateUnavailable(evodb, operation, e); + return on_unavailable(); + } +} + class MnEntryImpl : public MnEntry { private: @@ -263,17 +319,28 @@ class EVOImpl : public EVO public: std::pair getListAtChainTip() override { - const auto *tip = WITH_LOCK(::cs_main, return chainman().ActiveChain().Tip()); - if (tip && context().dmnman) { - MnListImpl mnList = context().dmnman->GetListForBlock(tip); - if (!mnList.getBlockHash().IsNull()) { - mnList.setContext(m_context); - return {std::make_shared(mnList), tip}; - } - } - return {nullptr, nullptr}; + return WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> std::pair { + const auto* tip = WITH_LOCK(::cs_main, return chainman().ActiveChain().Tip()); + if (tip && context().dmnman) { + MnListImpl mnList = context().dmnman->GetListForBlock(tip); + if (!mnList.getBlockHash().IsNull()) { + mnList.setContext(m_context); + return {std::make_shared(mnList), tip}; + } + } + return {nullptr, nullptr}; + }, + [] { return std::pair{nullptr, nullptr}; }); + } + std::optional getProviderTxCapabilities() override + { + return WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> std::optional { return evo::provider::GetCapabilities(context()); }, + [] { return std::nullopt; }); } - ProviderTxCapabilities getProviderTxCapabilities() override { return evo::provider::GetCapabilities(context()); } std::optional validateProviderNetInfo(const ProviderNetInfo& net_info, MnType type, uint16_t version, bool optional) override { @@ -281,60 +348,88 @@ class EVOImpl : public EVO } ProviderTxResult registerMasternode(Wallet& wallet, const ProviderRegistrationRequest& request) override { - return evo::provider::Register(context(), wallet, request); + return WithActiveEvoView( + context().evodb.get(), __func__, [&] { return evo::provider::Register(context(), wallet, request); }, + UnavailableProviderTxError); } ProviderTxResult prepareMasternodeRegistration( Wallet& wallet, const ProviderRegistrationRequest& request) override { - return evo::provider::PrepareRegistration(context(), wallet, request); + return WithActiveEvoView( + context().evodb.get(), __func__, + [&] { return evo::provider::PrepareRegistration(context(), wallet, request); }, UnavailableProviderTxError); } ProviderTxResult submitMasternodeRegistration( Wallet& wallet, const CTransactionRef& tx, const std::vector& collateral_signature) override { - return evo::provider::SubmitRegistration(context(), wallet, tx, collateral_signature); + return WithActiveEvoView( + context().evodb.get(), __func__, + [&] { return evo::provider::SubmitRegistration(context(), wallet, tx, collateral_signature); }, + UnavailableProviderTxError); } ProviderTxResult updateMasternodeService(Wallet& wallet, const ProviderUpdateServiceRequest& request) override { - return evo::provider::UpdateService(context(), wallet, request); + return WithActiveEvoView( + context().evodb.get(), __func__, [&] { return evo::provider::UpdateService(context(), wallet, request); }, + UnavailableProviderTxError); } ProviderTxResult updateMasternodeRegistrar(Wallet& wallet, const ProviderUpdateRegistrarRequest& request) override { - return evo::provider::UpdateRegistrar(context(), wallet, request); + return WithActiveEvoView( + context().evodb.get(), __func__, [&] { return evo::provider::UpdateRegistrar(context(), wallet, request); }, + UnavailableProviderTxError); } ProviderTxResult revokeMasternode(Wallet& wallet, const ProviderRevokeRequest& request) override { - return evo::provider::Revoke(context(), wallet, request); + return WithActiveEvoView( + context().evodb.get(), __func__, [&] { return evo::provider::Revoke(context(), wallet, request); }, + UnavailableProviderTxError); } ProviderTxResult prepareSharedRegistration(const SharedRegistrationRequest& request) override { - return evo::provider::PrepareSharedRegistration(context(), request); + return WithActiveEvoView( + context().evodb.get(), __func__, + [&] { return evo::provider::PrepareSharedRegistration(context(), request); }, UnavailableProviderTxError); } ProviderTxResult signShared(Wallet& wallet, const SharedSignRequest& request) override { - return evo::provider::SignShared(context(), wallet, request); + return WithActiveEvoView( + context().evodb.get(), __func__, [&] { return evo::provider::SignShared(context(), wallet, request); }, + UnavailableProviderTxError); } ProviderTxResult combineShared(Wallet& wallet, const SharedCombineRequest& request) override { - return evo::provider::CombineShared(context(), &wallet, request); + return WithActiveEvoView( + context().evodb.get(), __func__, [&] { return evo::provider::CombineShared(context(), &wallet, request); }, + UnavailableProviderTxError); } ProviderTxResult dissolveShared(Wallet& wallet, const SharedDissolveRequest& request) override { - return evo::provider::DissolveShared(context(), wallet, request); + return WithActiveEvoView( + context().evodb.get(), __func__, [&] { return evo::provider::DissolveShared(context(), wallet, request); }, + UnavailableProviderTxError); } ProviderTxResult prepareSharedDissolution(const SharedDissolvePrepareRequest& request) override { - return evo::provider::PrepareSharedDissolution(context(), request); + return WithActiveEvoView( + context().evodb.get(), __func__, + [&] { return evo::provider::PrepareSharedDissolution(context(), request); }, UnavailableProviderTxError); } ProviderTxResult updateShare(Wallet& wallet, const SharedUpdateShareRequest& request) override { - return evo::provider::UpdateShare(context(), wallet, request); + return WithActiveEvoView( + context().evodb.get(), __func__, [&] { return evo::provider::UpdateShare(context(), wallet, request); }, + UnavailableProviderTxError); } ProviderTxResult prepareSharedRegistrarUpdate( Wallet& wallet, const SharedRegistrarUpdatePrepareRequest& request) override { - return evo::provider::PrepareSharedRegistrarUpdate(context(), wallet, request); + return WithActiveEvoView( + context().evodb.get(), __func__, + [&] { return evo::provider::PrepareSharedRegistrarUpdate(context(), wallet, request); }, + UnavailableProviderTxError); } void setContext(NodeContext* context) override { @@ -358,43 +453,58 @@ class GOVImpl : public GOV context().govman->GetAllNewerThan(objs, nMoreThanTime, include_postponed); } } - Votes getObjVotes(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) override - { - Votes ret; - if (context().govman != nullptr && context().dmnman != nullptr) { - const auto& tip_mn_list{context().dmnman->GetListAtChainTip()}; - if (auto govobj{context().govman->FindGovernanceObject(obj.GetHash())}) { - ret.m_abs = govobj->GetAbstainCount(tip_mn_list, vote_signal); - ret.m_no = govobj->GetNoCount(tip_mn_list, vote_signal); - ret.m_yes = govobj->GetYesCount(tip_mn_list, vote_signal); - } else { - ret.m_abs = obj.GetAbstainCount(tip_mn_list, vote_signal); - ret.m_no = obj.GetNoCount(tip_mn_list, vote_signal); - ret.m_yes = obj.GetYesCount(tip_mn_list, vote_signal); - } - } - return ret; - } - UniqueVoters getObjUniqueVoters(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) override - { - if (context().govman != nullptr && context().dmnman != nullptr) { - const auto& tip_mn_list{context().dmnman->GetListAtChainTip()}; - if (auto govobj{context().govman->FindGovernanceObject(obj.GetHash())}) { - const auto count = govobj->GetUniqueVoterCount(tip_mn_list, vote_signal); - return {.m_regular = count.m_regular, .m_evo = count.m_evo}; - } else { - const auto count = obj.GetUniqueVoterCount(tip_mn_list, vote_signal); - return {.m_regular = count.m_regular, .m_evo = count.m_evo}; - } - } - return {0, 0}; + std::optional getObjVotes(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) override + { + return WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> std::optional { + Votes ret; + if (context().govman != nullptr && context().dmnman != nullptr) { + const auto& tip_mn_list{context().dmnman->GetListAtChainTip()}; + if (auto govobj{context().govman->FindGovernanceObject(obj.GetHash())}) { + ret.m_abs = govobj->GetAbstainCount(tip_mn_list, vote_signal); + ret.m_no = govobj->GetNoCount(tip_mn_list, vote_signal); + ret.m_yes = govobj->GetYesCount(tip_mn_list, vote_signal); + } else { + ret.m_abs = obj.GetAbstainCount(tip_mn_list, vote_signal); + ret.m_no = obj.GetNoCount(tip_mn_list, vote_signal); + ret.m_yes = obj.GetYesCount(tip_mn_list, vote_signal); + } + } + return ret; + }, + [] { return std::nullopt; }); + } + std::optional getObjUniqueVoters(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) override + { + return WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> std::optional { + if (context().govman != nullptr && context().dmnman != nullptr) { + const auto& tip_mn_list{context().dmnman->GetListAtChainTip()}; + if (auto govobj{context().govman->FindGovernanceObject(obj.GetHash())}) { + const auto count = govobj->GetUniqueVoterCount(tip_mn_list, vote_signal); + return UniqueVoters{.m_regular = count.m_regular, .m_evo = count.m_evo}; + } else { + const auto count = obj.GetUniqueVoterCount(tip_mn_list, vote_signal); + return UniqueVoters{.m_regular = count.m_regular, .m_evo = count.m_evo}; + } + } + return UniqueVoters{}; + }, + [] { return std::nullopt; }); } - std::vector getCurrentVotes(const uint256& hash) override + std::optional> getCurrentVotes(const uint256& hash) override { - if (context().govman != nullptr) { - return context().govman->GetCurrentVotes(hash, COutPoint{}); - } - return {}; + return WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> std::optional> { + if (context().govman != nullptr) { + return context().govman->GetCurrentVotes(hash, COutPoint{}); + } + return std::vector{}; + }, + [] { return std::nullopt; }); } bool existsObj(const uint256& hash) override { @@ -412,28 +522,41 @@ class GOVImpl : public GOV } bool processVoteAndRelay(const CGovernanceVote& vote, std::string& error) override { - if (context().govman != nullptr) { - CGovernanceException exception; - bool result = context().govman->ProcessVoteAndRelay(vote, exception); - if (!result) { - error = exception.GetMessage(); - } - return result; - } - error = "Governance manager not available"; - return false; + return WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> bool { + if (context().govman != nullptr) { + CGovernanceException exception; + bool result = context().govman->ProcessVoteAndRelay(vote, exception); + if (!result) { + error = exception.GetMessage(); + } + return result; + } + error = "Governance manager not available"; + return false; + }, + [&] { + error = EvoStateUnavailableMessage().original; + return false; + }); } GovernanceInfo getGovernanceInfo() override { GovernanceInfo info; if (context().chainman) { const Consensus::Params& consensusParams = context().chainman->GetConsensus(); + if (context().dmnman) { + info.fundingthreshold = WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> std::optional { + return static_cast(context().dmnman->GetListAtChainTip().GetCounts().m_valid_weighted / 10); + }, + [] { return std::nullopt; }); + } LOCK(::cs_main); CSuperblock::GetNearestSuperblocksHeights(context().chainman->ActiveHeight(), info.lastsuperblock, info.nextsuperblock); info.governancebudget = CSuperblock::GetPaymentsLimit(info.nextsuperblock); - if (context().dmnman) { - info.fundingthreshold = static_cast(context().dmnman->GetListAtChainTip().GetCounts().m_valid_weighted / 10); - } info.superblockcycle = consensusParams.nSuperblockCycle; info.superblockmaturitywindow = consensusParams.nSuperblockMaturityWindow; info.targetSpacing = consensusParams.nPowTargetSpacing; @@ -458,95 +581,132 @@ class GOVImpl : public GOV } return std::nullopt; } - FundableResult getFundableProposalHashes() override - { - FundableResult result; - if (context().govman != nullptr && context().chainman != nullptr && context().dmnman != nullptr) { - const auto tip_mn_list{context().dmnman->GetListAtChainTip()}; - if (const auto proposals{context().govman->GetApprovedProposals(tip_mn_list)}; !proposals.empty()) { - int32_t last_sb{0}, next_sb{0}; - CAmount budget{0}; - { - LOCK(::cs_main); - CSuperblock::GetNearestSuperblocksHeights(context().chainman->ActiveHeight(), last_sb, next_sb); - budget = CSuperblock::GetPaymentsLimit(next_sb); - } - for (const auto& proposal : proposals) { - UniValue json = proposal->GetJSONObject(); - CAmount payment_amount{0}; - try { - payment_amount = ParsePaymentAmount(json["payment_amount"].getValStr()); - } catch (...) { - continue; + std::optional getFundableProposalHashes() override + { + return WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> std::optional { + FundableResult result; + if (context().govman != nullptr && context().chainman != nullptr && context().dmnman != nullptr) { + const auto tip_mn_list{context().dmnman->GetListAtChainTip()}; + if (const auto proposals{context().govman->GetApprovedProposals(tip_mn_list)}; !proposals.empty()) { + int32_t last_sb{0}, next_sb{0}; + CAmount budget{0}; + { + LOCK(::cs_main); + CSuperblock::GetNearestSuperblocksHeights(context().chainman->ActiveHeight(), last_sb, next_sb); + budget = CSuperblock::GetPaymentsLimit(next_sb); + } + for (const auto& proposal : proposals) { + UniValue json = proposal->GetJSONObject(); + CAmount payment_amount{0}; + try { + payment_amount = ParsePaymentAmount(json["payment_amount"].getValStr()); + } catch (...) { + continue; + } + if (result.allocated + payment_amount > budget) { + // Budget is saturated, cannot fulfill proposal + continue; + } + result.allocated += payment_amount; + result.hashes.insert(proposal->GetHash()); + } + return result; } - if (result.allocated + payment_amount > budget) { - // Budget is saturated, cannot fulfill proposal - continue; - } - result.allocated += payment_amount; - result.hashes.insert(proposal->GetHash()); } return result; - } - } - return result; + }, + [] { return std::nullopt; }); } std::optional createProposal(int32_t revision, int64_t created_time, const std::string& data_hex, std::string& error) override { - CGovernanceObject govobj(uint256{}, revision, created_time, uint256{}, data_hex); - if (govobj.GetObjectType() != GovernanceObject::PROPOSAL) { - error = "Invalid object type, only proposals can be validated"; - return std::nullopt; - } - std::string strValidationError; - if (!governance::ValidateProposal(data_hex, strValidationError)) { - error = "Invalid proposal data: " + strValidationError; - return std::nullopt; - } - const ChainstateManager& chainman = *Assert(context().chainman); - { - LOCK(::cs_main); - std::string strError; - if (!govobj.IsValidLocally(Assert(context().dmnman)->GetListAtChainTip(), chainman, strError, false)) { - error = "Governance object is not valid - " + govobj.GetHash().ToString() + " - " + strError; + return WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> std::optional { + CGovernanceObject govobj(uint256{}, revision, created_time, uint256{}, data_hex); + if (govobj.GetObjectType() != GovernanceObject::PROPOSAL) { + error = "Invalid object type, only proposals can be validated"; + return std::nullopt; + } + std::string strValidationError; + if (!governance::ValidateProposal(data_hex, strValidationError)) { + error = "Invalid proposal data: " + strValidationError; + return std::nullopt; + } + const ChainstateManager& chainman = *Assert(context().chainman); + { + LOCK(::cs_main); + std::string strError; + if (!govobj.IsValidLocally(Assert(context().dmnman)->GetListAtChainTip(), chainman, strError, false)) { + error = "Governance object is not valid - " + govobj.GetHash().ToString() + " - " + strError; + return std::nullopt; + } + } + return govobj; + }, + [&] { + error = EvoStateUnavailableMessage().original; return std::nullopt; - } - } - return govobj; + }); } bool submitProposal(const uint256& parent, int32_t revision, int64_t created_time, const std::string& data_hex, const uint256& fee_txid, std::string& out_object_hash, std::string& error) override { - if (!context().govman || !context().dmnman || !context().chainman) { error = "Governance not available"; return false; } - if(!Assert(context().mn_sync)->IsBlockchainSynced()) { error = "Client not synced"; return false; } - const auto mnList = Assert(context().dmnman)->GetListAtChainTip(); - CGovernanceObject govobj(parent, revision, created_time, fee_txid, data_hex); - if (govobj.GetObjectType() == GovernanceObject::TRIGGER) { error = "Submission of triggers is not available"; return false; } - if (govobj.GetObjectType() == GovernanceObject::PROPOSAL) { - std::string strValidationError; - if (!governance::ValidateProposal(data_hex, strValidationError)) { error = "Invalid proposal data: " + strValidationError; return false; } - } - const CTxMemPool& mempool = *Assert(context().mempool); - bool fMissingConfirmations{false}; - { - LOCK2(cs_main, mempool.cs); - std::string strError; - if (!govobj.IsValidLocally(mnList, *Assert(context().chainman), strError, fMissingConfirmations, true) && !fMissingConfirmations) { - error = "Governance object is not valid - " + govobj.GetHash().ToString() + " - " + strError; + return WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> bool { + if (!context().govman || !context().dmnman || !context().chainman) { + error = "Governance not available"; + return false; + } + if (!Assert(context().mn_sync)->IsBlockchainSynced()) { + error = "Client not synced"; + return false; + } + const auto mnList = Assert(context().dmnman)->GetListAtChainTip(); + CGovernanceObject govobj(parent, revision, created_time, fee_txid, data_hex); + if (govobj.GetObjectType() == GovernanceObject::TRIGGER) { + error = "Submission of triggers is not available"; + return false; + } + if (govobj.GetObjectType() == GovernanceObject::PROPOSAL) { + std::string strValidationError; + if (!governance::ValidateProposal(data_hex, strValidationError)) { + error = "Invalid proposal data: " + strValidationError; + return false; + } + } + const CTxMemPool& mempool = *Assert(context().mempool); + bool fMissingConfirmations{false}; + { + LOCK2(cs_main, mempool.cs); + std::string strError; + if (!govobj.IsValidLocally(mnList, *Assert(context().chainman), strError, fMissingConfirmations, true) && + !fMissingConfirmations) { + error = "Governance object is not valid - " + govobj.GetHash().ToString() + " - " + strError; + return false; + } + } + if (!Assert(context().govman)->MasternodeRateCheck(govobj)) { + error = "Object creation rate limit exceeded"; + return false; + } + if (fMissingConfirmations) { + context().govman->AddPostponedObject(govobj); + context().govman->RelayObject(govobj); + } else { + context().govman->AddGovernanceObject(govobj, ""); + } + out_object_hash = govobj.GetHash().ToString(); + return true; + }, + [&] { + error = EvoStateUnavailableMessage().original; return false; - } - } - if (!Assert(context().govman)->MasternodeRateCheck(govobj)) { error = "Object creation rate limit exceeded"; return false; } - if (fMissingConfirmations) { - context().govman->AddPostponedObject(govobj); - context().govman->RelayObject(govobj); - } else { - context().govman->AddGovernanceObject(govobj, ""); - } - out_object_hash = govobj.GetHash().ToString(); - return true; + }); } void setContext(NodeContext* context) override { @@ -563,22 +723,27 @@ class LLMQImpl : public LLMQ NodeContext& context() { return *Assert(m_context); } public: - CreditPoolCounts getCreditPoolCounts() override - { - CreditPoolCounts ret{}; - if (!context().chainman || !context().special_tx) { - return ret; - } - const auto* pindex{WITH_LOCK(::cs_main, return context().chainman->ActiveChain().Tip())}; - if (!pindex || !pindex->pprev) { - return ret; - } - auto& cpoolman{*context().special_tx->m_cpoolman}; - const auto pool{cpoolman.GetCreditPool(pindex)}; - ret.m_locked = pool.locked; - ret.m_limit = pool.currentLimit; - ret.m_diff = pool.locked - cpoolman.GetCreditPool(pindex->pprev).locked; - return ret; + std::optional getCreditPoolCounts() override + { + return WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> std::optional { + CreditPoolCounts ret{}; + if (!context().chainman || !context().special_tx) { + return ret; + } + const auto* pindex{WITH_LOCK(::cs_main, return context().chainman->ActiveChain().Tip())}; + if (!pindex || !pindex->pprev) { + return ret; + } + auto& cpoolman{*context().special_tx->m_cpoolman}; + const auto pool{cpoolman.GetCreditPool(pindex)}; + ret.m_locked = pool.locked; + ret.m_limit = pool.currentLimit; + ret.m_diff = pool.locked - cpoolman.GetCreditPool(pindex->pprev).locked; + return ret; + }, + [] { return std::nullopt; }); } ChainLockInfo getBestChainLock() override { @@ -618,84 +783,96 @@ class LLMQImpl : public LLMQ return entry.GetTx().IsPlatformTransfer(); })); } - std::vector getQuorumStats() override - { - std::vector stats{}; - if (!context().llmq_ctx || !context().llmq_ctx->qman || !context().chainman) { - return stats; - } - const auto* pindex{WITH_LOCK(::cs_main, return context().chainman->ActiveChain().Tip())}; - if (!pindex) { - return stats; - } - for (const auto& type : llmq::GetEnabledQuorumTypes(*context().chainman, pindex)) { - const auto llmq_params{Params().GetLLMQ(type)}; - if (!llmq_params.has_value()) { - continue; - } - const auto quorums{context().llmq_ctx->qman->ScanQuorums(type, pindex, llmq_params->signingActiveQuorumCount)}; - double health{0.0}; - for (const auto& q : quorums) { - size_t numMembers = q->members.size(); - size_t numValidMembers = q->qc->CountValidMembers(); - health += (numMembers > 0) ? (double(numValidMembers) / double(numMembers)) : 0.0; - } - health = quorums.empty() ? 0.0 : (health / quorums.size()); - const int32_t newest_height{(!quorums.empty() && quorums[0]->m_quorum_base_block_index) - ? quorums[0]->m_quorum_base_block_index->nHeight : 0}; - const int32_t expiry_height{(newest_height > 0) - ? newest_height + llmq_params->signingActiveQuorumCount * llmq_params->dkgInterval - : 0}; - stats.emplace_back(QuorumInfo{ - .m_name = std::string(llmq_params->name), - .m_count = quorums.size(), - .m_health = health, - .m_rotates = llmq_params->useRotation, - .m_data_retention_blocks = llmq_params->max_store_depth(), - .m_newest_height = newest_height, - .m_expiry_height = expiry_height, - }); - } - return stats; - } - std::vector getPlatformQuorums(uint8_t llmq_type) override + std::optional> getQuorumStats() override { - std::vector ret; - if (!context().llmq_ctx || !context().llmq_ctx->quorum_block_processor || !context().chainman) { - return ret; - } - const auto* pindex{WITH_LOCK(::cs_main, return context().chainman->ActiveChain().Tip())}; - if (!pindex) { - return ret; - } - const auto type{static_cast(llmq_type)}; - const auto llmq_params{Params().GetLLMQ(type)}; - if (!llmq_params.has_value()) { - return ret; - } - // Drive proofs may be signed by an older Platform quorum while they - // are still consensus-valid and retained locally. Export the full - // retained-key window, not only the current signing-active set. - const auto quorum_count{static_cast(std::max(llmq_params->signingActiveQuorumCount, - llmq_params->keepOldKeys))}; - // Read mined final commitments directly: they already carry the quorum - // hash and public key, so there is no need to materialize full CQuorum - // objects (member lists, vvec/contribution reads, quorum cache inserts) - // via ScanQuorums. Newest-first, matching ScanQuorums' ordering. - const auto& qbp{*context().llmq_ctx->quorum_block_processor}; - const auto quorum_base_block_indexes{llmq_params->useRotation - ? qbp.GetMinedCommitmentsIndexedUntilBlock(type, pindex, quorum_count) - : qbp.GetMinedCommitmentsUntilBlock(type, pindex, quorum_count)}; - for (const auto* pQuorumBaseBlockIndex : quorum_base_block_indexes) { - const auto qc{qbp.GetMinedCommitment(type, pQuorumBaseBlockIndex->GetBlockHash()).first}; - if (!qc.quorumPublicKey.IsValid()) continue; - ret.emplace_back(PlatformQuorum{ - .m_quorum_hash = qc.quorumHash, - .m_pubkey = qc.quorumPublicKey.ToByteVector(/*specificLegacyScheme=*/false), - .m_height = pQuorumBaseBlockIndex->nHeight, - }); - } - return ret; + return WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> std::optional> { + std::vector stats{}; + if (!context().llmq_ctx || !context().llmq_ctx->qman || !context().chainman) { + return stats; + } + const auto* pindex{WITH_LOCK(::cs_main, return context().chainman->ActiveChain().Tip())}; + if (!pindex) { + return stats; + } + for (const auto& type : llmq::GetEnabledQuorumTypes(*context().chainman, pindex)) { + const auto llmq_params{Params().GetLLMQ(type)}; + if (!llmq_params.has_value()) { + continue; + } + const auto quorums{ + context().llmq_ctx->qman->ScanQuorums(type, pindex, llmq_params->signingActiveQuorumCount)}; + double health{0.0}; + for (const auto& q : quorums) { + size_t numMembers = q->members.size(); + size_t numValidMembers = q->qc->CountValidMembers(); + health += (numMembers > 0) ? (double(numValidMembers) / double(numMembers)) : 0.0; + } + health = quorums.empty() ? 0.0 : (health / quorums.size()); + const int32_t newest_height{(!quorums.empty() && quorums[0]->m_quorum_base_block_index) + ? quorums[0]->m_quorum_base_block_index->nHeight + : 0}; + const int32_t expiry_height{ + (newest_height > 0) ? newest_height + llmq_params->signingActiveQuorumCount * llmq_params->dkgInterval + : 0}; + stats.emplace_back(QuorumInfo{ + .m_name = std::string(llmq_params->name), + .m_count = quorums.size(), + .m_health = health, + .m_rotates = llmq_params->useRotation, + .m_data_retention_blocks = llmq_params->max_store_depth(), + .m_newest_height = newest_height, + .m_expiry_height = expiry_height, + }); + } + return stats; + }, + [] { return std::nullopt; }); + } + std::optional> getPlatformQuorums(uint8_t llmq_type) override + { + return WithActiveEvoView( + context().evodb.get(), __func__, + [&]() -> std::optional> { + std::vector ret; + if (!context().llmq_ctx || !context().llmq_ctx->quorum_block_processor || !context().chainman) { + return ret; + } + const auto* pindex{WITH_LOCK(::cs_main, return context().chainman->ActiveChain().Tip())}; + if (!pindex) { + return ret; + } + const auto type{static_cast(llmq_type)}; + const auto llmq_params{Params().GetLLMQ(type)}; + if (!llmq_params.has_value()) { + return ret; + } + // Drive proofs may be signed by an older Platform quorum while they + // are still consensus-valid and retained locally. Export the full + // retained-key window, not only the current signing-active set. + const auto quorum_count{ + static_cast(std::max(llmq_params->signingActiveQuorumCount, llmq_params->keepOldKeys))}; + // Read mined final commitments directly: they already carry the quorum + // hash and public key, so there is no need to materialize full CQuorum + // objects (member lists, vvec/contribution reads, quorum cache inserts) + // via ScanQuorums. Newest-first, matching ScanQuorums' ordering. + const auto& qbp{*context().llmq_ctx->quorum_block_processor}; + const auto quorum_base_block_indexes{ + llmq_params->useRotation ? qbp.GetMinedCommitmentsIndexedUntilBlock(type, pindex, quorum_count) + : qbp.GetMinedCommitmentsUntilBlock(type, pindex, quorum_count)}; + for (const auto* pQuorumBaseBlockIndex : quorum_base_block_indexes) { + const auto qc{qbp.GetMinedCommitment(type, pQuorumBaseBlockIndex->GetBlockHash()).first}; + if (!qc.quorumPublicKey.IsValid()) continue; + ret.emplace_back(PlatformQuorum{ + .m_quorum_hash = qc.quorumHash, + .m_pubkey = qc.quorumPublicKey.ToByteVector(/*specificLegacyScheme=*/false), + .m_height = pQuorumBaseBlockIndex->nHeight, + }); + } + return ret; + }, + [] { return std::nullopt; }); } std::vector getInstantSendLock(const uint256& txid) override { @@ -1110,10 +1287,15 @@ class NodeImpl : public Node return m_context->active_ctx != nullptr; } bool isLoadingBlocks() override { return node::fReindex || node::fImporting; } - bool isV24Active() override + std::optional isV24Active() override { - LOCK(::cs_main); - return DeploymentActiveAfter(chainman().ActiveChain().Tip(), chainman(), Consensus::DEPLOYMENT_V24); + return WithActiveEvoView( + m_context->evodb.get(), __func__, + [&]() -> std::optional { + LOCK(::cs_main); + return DeploymentActiveAfter(chainman().ActiveChain().Tip(), chainman(), Consensus::DEPLOYMENT_V24); + }, + [] { return std::nullopt; }); } void setNetworkActive(bool active) override { @@ -1447,21 +1629,27 @@ class ChainImpl : public Chain if (m_node.chainlocks == nullptr) return false; return m_node.chainlocks->HasChainLock(height, hash); } - std::vector listMNCollaterials(const std::vector>& outputs) override + std::optional> listMNCollaterials( + const std::vector>& outputs) override { - const CBlockIndex *tip = WITH_LOCK(::cs_main, return chainman().ActiveChain().Tip()); - CDeterministicMNList mnList{}; - if (tip != nullptr && m_node.dmnman != nullptr) { - mnList = m_node.dmnman->GetListForBlock(tip); - } - std::vector listRet; - for (const auto& [tx, index]: outputs) { - COutPoint nextOut{tx->GetHash(), index}; - if (CDeterministicMNManager::IsProTxWithCollateral(tx, index) || mnList.HasMNByCollateral(nextOut)) { - listRet.emplace_back(nextOut); - } - } - return listRet; + return WithActiveEvoView( + m_node.evodb.get(), __func__, + [&]() -> std::optional> { + const CBlockIndex* tip = WITH_LOCK(::cs_main, return chainman().ActiveChain().Tip()); + CDeterministicMNList mnList{}; + if (tip != nullptr && m_node.dmnman != nullptr) { + mnList = m_node.dmnman->GetListForBlock(tip); + } + std::vector listRet; + for (const auto& [tx, index] : outputs) { + COutPoint nextOut{tx->GetHash(), index}; + if (CDeterministicMNManager::IsProTxWithCollateral(tx, index) || mnList.HasMNByCollateral(nextOut)) { + listRet.emplace_back(nextOut); + } + } + return listRet; + }, + [] { return std::nullopt; }); } interfaces::CoinJoin::Loader* coinJoinLoader() override { return m_node.coinjoin_loader.get(); } bool findBlock(const uint256& hash, const FoundBlock& block) override @@ -1615,10 +1803,15 @@ class ChainImpl : public Chain if (!m_node.mempool || !m_node.mempool->m_require_standard) return false; return !IsStandardSpecialTx(*tx, reason); } - bool isV24Active() override + std::optional isV24Active() override { - LOCK(::cs_main); - return DeploymentActiveAfter(chainman().ActiveChain().Tip(), chainman(), Consensus::DEPLOYMENT_V24); + return WithActiveEvoView( + m_node.evodb.get(), __func__, + [&]() -> std::optional { + LOCK(::cs_main); + return DeploymentActiveAfter(chainman().ActiveChain().Tip(), chainman(), Consensus::DEPLOYMENT_V24); + }, + [] { return std::nullopt; }); } bool havePruned() override { diff --git a/src/node/miner.cpp b/src/node/miner.cpp index 4e06d4d84bb4..3941e9e08c27 100644 --- a/src/node/miner.cpp +++ b/src/node/miner.cpp @@ -27,13 +27,14 @@ #include #include -#include #include #include +#include #include #include -#include #include +#include +#include #include #include #include @@ -114,8 +115,8 @@ void BlockAssembler::resetBlock() } // Helper to calculate best chainlock -static bool CalcCbTxBestChainlock(const chainlock::Chainlocks& chainlocks, const CBlockIndex* pindexPrev, - uint32_t& bestCLHeightDiff, CBLSSignature& bestCLSignature) +static bool CalcCbTxBestChainlock(const chainlock::Chainlocks& chainlocks, const CSpecialTxProcessor& special_tx, + const CBlockIndex* pindexPrev, uint32_t& bestCLHeightDiff, CBLSSignature& bestCLSignature) { auto best_clsig = chainlocks.GetBestChainLock(); if (best_clsig.getHeight() < Params().GetConsensus().DeploymentHeight(Consensus::DEPLOYMENT_V19)) { @@ -129,7 +130,17 @@ static bool CalcCbTxBestChainlock(const chainlock::Chainlocks& chainlocks, const return true; } - auto prevBlockCoinbaseChainlock = GetNonNullCoinbaseChainlock(pindexPrev); + // The same reader block validation uses, so a snapshot chainstate mining on + // its base sees the carried ChainLock rather than "none". + const CoinbaseChainlock previous{special_tx.GetCoinbaseChainlock(pindexPrev)}; + if (previous.status == CoinbaseChainlock::Status::UNAVAILABLE) { + throw BlockDataUnavailableError( + strprintf("coinbase ChainLock of block %s is not available", pindexPrev->GetBlockHash().ToString())); + } + std::optional> prevBlockCoinbaseChainlock; + if (previous.status == CoinbaseChainlock::Status::CERTIFIED) { + prevBlockCoinbaseChainlock = std::make_pair(previous.signature, previous.height_diff); + } if (prevBlockCoinbaseChainlock.has_value()) { // Previous block Coinbase contains a non-null CL: We must insert the same sig or a better (newest) one if (best_clsig.IsNull()) { @@ -220,6 +231,7 @@ void BlockAssembler::FillBlockPayments(CMutableTransaction& txNew, const CBlockI std::unique_ptr BlockAssembler::CreateNewBlock(const CScript& scriptPubKeyIn) { + const EvoViewScope evo_view{m_chainstate.EvoView()}; const auto time_start{SteadyClock::now()}; resetBlock(); @@ -354,7 +366,8 @@ std::unique_ptr BlockAssembler::CreateNewBlock(const CScript& sc throw std::runtime_error(strprintf("%s: CalcCbTxMerkleRootQuorums failed: %s", __func__, state.ToString())); } if (fV20Active_context) { - if (CalcCbTxBestChainlock(m_chainlocks, pindexPrev, cbTx.bestCLHeightDiff, cbTx.bestCLSignature)) { + if (CalcCbTxBestChainlock(m_chainlocks, m_chainstate.m_special_tx, pindexPrev, cbTx.bestCLHeightDiff, + cbTx.bestCLSignature)) { LogPrintf("CreateNewBlock() h[%d] CbTx bestCLHeightDiff[%d] CLSig[%s]\n", nHeight, cbTx.bestCLHeightDiff, cbTx.bestCLSignature.ToString()); } else { // not an error @@ -811,6 +824,8 @@ void BlockAssembler::addPackageTxs(const CTxMemPool& mempool, int& nPackagesSele std::string error; try { if (!package_mn_list->ApplyTx(tx, state)) error = state.ToString(); + } catch (const evo::LocalStateError&) { + throw; } catch (const std::runtime_error& e) { // AddMN()/UpdateMN() report a unique-property conflict by throwing error = e.what(); diff --git a/src/qt/bitcoingui.cpp b/src/qt/bitcoingui.cpp index 0ad815bd8bcf..5cee4e5e533d 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -1863,7 +1863,9 @@ void BitcoinGUI::updateGovernanceCycleIcon() ? tr("~%1 (%2 blocks) left for superblock").arg(remaining_str).arg(remaining_blocks) : tr("~%1 (%2 blocks) left for voting").arg(voting_str).arg(voting_remaining))}; const auto tooltip2{[&]() { - const auto allocated_budget{m_node.gov().getFundableProposalHashes().allocated}; + const auto fundable{m_node.gov().getFundableProposalHashes()}; + if (!fundable) return tr("Budget commitment is not available yet"); + const auto allocated_budget{fundable->allocated}; const auto budget_pct{gov_info.governancebudget > 0 ? static_cast(static_cast(allocated_budget) / static_cast(gov_info.governancebudget) * 100.0) : 0}; diff --git a/src/qt/clientfeeds.cpp b/src/qt/clientfeeds.cpp index 3383c67a3b81..fec4224c8175 100644 --- a/src/qt/clientfeeds.cpp +++ b/src/qt/clientfeeds.cpp @@ -91,8 +91,13 @@ void CreditPoolFeed::fetch() return; } + // Unavailable counts keep the current data until a later fetch. + const auto counts{m_client_model.node().llmq().getCreditPoolCounts()}; + if (!counts) { + return; + } auto ret = std::make_shared(); - ret->m_counts = m_client_model.node().llmq().getCreditPoolCounts(); + ret->m_counts = *counts; ret->m_pending_unlocks = m_client_model.node().llmq().getPendingAssetUnlocks(); setData(std::move(ret)); @@ -132,8 +137,12 @@ void QuorumFeed::fetch() return; } + auto quorums{m_client_model.node().llmq().getQuorumStats()}; + if (!quorums) { + return; + } auto ret = std::make_shared(); - ret->m_quorums = m_client_model.node().llmq().getQuorumStats(); + ret->m_quorums = std::move(*quorums); setData(std::move(ret)); } @@ -220,22 +229,31 @@ void ProposalFeed::fetch() const auto nMinQuorum = static_cast(Params().GetConsensus().nGovernanceMinQuorum); ret->m_abs_vote_req = static_cast(std::max(nMinQuorum, data_mn->m_counts.m_valid_weighted / 10)); ret->m_gov_info = m_client_model.node().gov().getGovernanceInfo(); + auto fundable{m_client_model.node().gov().getFundableProposalHashes()}; + // Proposals are judged against the masternode list; without it, keep the + // current data until a later fetch. + if (!ret->m_gov_info.fundingthreshold || !fundable) { + return; + } std::vector govObjList; m_client_model.getAllGovernanceObjects(govObjList); for (const auto& govObj : govObjList) { if (govObj.GetObjectType() != GovernanceObject::PROPOSAL) { continue; // Skip triggers. } - ret->m_proposals.emplace_back(std::make_shared(m_client_model, govObj, ret->m_gov_info, ret->m_gov_info.requiredConfs, - /*is_broadcast=*/true)); + auto proposal{std::make_shared(m_client_model, govObj, ret->m_gov_info, ret->m_gov_info.requiredConfs, + /*is_broadcast=*/true)}; const auto voters = m_client_model.node().gov().getObjUniqueVoters(govObj, VOTE_SIGNAL_FUNDING); - ret->m_max_regular_voters = std::max(ret->m_max_regular_voters, voters.m_regular); - ret->m_max_evo_voters = std::max(ret->m_max_evo_voters, voters.m_evo); + if (!proposal->hasVotes() || !voters) { + return; + } + ret->m_proposals.emplace_back(std::move(proposal)); + ret->m_max_regular_voters = std::max(ret->m_max_regular_voters, voters->m_regular); + ret->m_max_evo_voters = std::max(ret->m_max_evo_voters, voters->m_evo); } - auto fundable{m_client_model.node().gov().getFundableProposalHashes()}; - ret->m_fundable_hashes = std::move(fundable.hashes); - ret->m_allocated = fundable.allocated; + ret->m_fundable_hashes = std::move(fundable->hashes); + ret->m_allocated = fundable->allocated; setData(std::move(ret)); } diff --git a/src/qt/masternodedialogs.cpp b/src/qt/masternodedialogs.cpp index 016433e8c08b..8ed7e5fa773e 100644 --- a/src/qt/masternodedialogs.cpp +++ b/src/qt/masternodedialogs.cpp @@ -339,12 +339,6 @@ void MasternodeActionDialog::finishSubmission(MasternodeOperationRunner::Submiss accept(); } -UpdateServiceDialog::UpdateServiceDialog(interfaces::Node& node, WalletModel* wallet_model, - const MasternodeEntry& entry, QWidget* parent) : - UpdateServiceDialog(node, wallet_model, entry, node.evo().getProviderTxCapabilities(), parent) -{ -} - UpdateServiceDialog::UpdateServiceDialog(interfaces::Node& node, WalletModel* wallet_model, const MasternodeEntry& entry, interfaces::ProviderTxCapabilities capabilities, QWidget* parent) : MasternodeActionDialog(node, wallet_model, entry, parent), @@ -509,12 +503,6 @@ void UpdateServiceDialog::submit() }); } -UpdateRegistrarDialog::UpdateRegistrarDialog(interfaces::Node& node, WalletModel* wallet_model, - const MasternodeEntry& entry, QWidget* parent) : - UpdateRegistrarDialog(node, wallet_model, entry, node.evo().getProviderTxCapabilities(), parent) -{ -} - UpdateRegistrarDialog::UpdateRegistrarDialog(interfaces::Node& node, WalletModel* wallet_model, const MasternodeEntry& entry, interfaces::ProviderTxCapabilities capabilities, QWidget* parent) : diff --git a/src/qt/masternodedialogs.h b/src/qt/masternodedialogs.h index 0375d5d9bbd5..1da9fcf80e55 100644 --- a/src/qt/masternodedialogs.h +++ b/src/qt/masternodedialogs.h @@ -136,15 +136,13 @@ class UpdateServiceDialog : public MasternodeActionDialog public: UpdateServiceDialog(interfaces::Node& node, WalletModel* wallet_model, const MasternodeEntry& entry, - QWidget* parent = nullptr); + interfaces::ProviderTxCapabilities capabilities, QWidget* parent = nullptr); protected: void validate() override; void submit() override; private: - UpdateServiceDialog(interfaces::Node& node, WalletModel* wallet_model, const MasternodeEntry& entry, - interfaces::ProviderTxCapabilities capabilities, QWidget* parent); std::optional buildNetInfo(QString& error) const; std::optional buildRequest(const CBLSSecretKey& operator_key, QString& error) const; @@ -172,15 +170,13 @@ class UpdateRegistrarDialog : public MasternodeActionDialog public: UpdateRegistrarDialog(interfaces::Node& node, WalletModel* wallet_model, const MasternodeEntry& entry, - QWidget* parent = nullptr); + interfaces::ProviderTxCapabilities capabilities, QWidget* parent = nullptr); protected: void validate() override; void submit() override; private: - UpdateRegistrarDialog(interfaces::Node& node, WalletModel* wallet_model, const MasternodeEntry& entry, - interfaces::ProviderTxCapabilities capabilities, QWidget* parent); std::optional buildRequest(QString& error) const; bool operatorChanged() const; bool votingChanged() const; diff --git a/src/qt/masternodelist.cpp b/src/qt/masternodelist.cpp index 43f89210cde3..492fa3bc62eb 100644 --- a/src/qt/masternodelist.cpp +++ b/src/qt/masternodelist.cpp @@ -254,8 +254,12 @@ void MasternodeList::updateRegistrationAvailability() } // isV24Active() takes cs_main and this runs on every block; activation is - // monotonic, so ask only until it is - if (!m_v24_active && clientModel != nullptr) m_v24_active = clientModel->node().isV24Active(); + // monotonic, so ask only until it is. An unknown state asks again later. + std::optional v24_state{m_v24_active}; + if (!m_v24_active && clientModel != nullptr) { + v24_state = clientModel->node().isV24Active(); + m_v24_active = v24_state.value_or(false); + } const bool v24_active{clientModel != nullptr && m_v24_active}; const bool can_shared{can_register && v24_active}; ui->btnSharedMasternode->setEnabled(can_shared); @@ -265,6 +269,8 @@ void MasternodeList::updateRegistrationAvailability() ui->btnSharedMasternode->setToolTip(tr("Managing shared masternodes requires a wallet with private keys.")); } else if (clientModel == nullptr) { ui->btnSharedMasternode->setToolTip(tr("Shared masternode management is unavailable until the node is ready.")); + } else if (!v24_state) { + ui->btnSharedMasternode->setToolTip(MasternodeWidgetUtil::masternodeStateUnavailableText()); } else if (!v24_active) { ui->btnSharedMasternode->setToolTip(tr("Shared masternodes require the v24 hard fork, which is not active on this network yet.")); } else { @@ -478,10 +484,22 @@ const MasternodeEntry* MasternodeList::selectedEntryForDialog() return entry != nullptr && clientModel != nullptr && walletModel != nullptr ? entry : nullptr; } +std::optional MasternodeList::providerTxCapabilities() +{ + auto capabilities{clientModel->node().evo().getProviderTxCapabilities()}; + if (!capabilities) { + QMessageBox::warning(this, tr("Masternode state unavailable"), + MasternodeWidgetUtil::masternodeStateUnavailableText()); + } + return capabilities; +} + void MasternodeList::onUpdateService() { if (const auto* entry{selectedEntryForDialog()}) { - UpdateServiceDialog dialog(clientModel->node(), walletModel, *entry, this); + const auto capabilities{providerTxCapabilities()}; + if (!capabilities) return; + UpdateServiceDialog dialog(clientModel->node(), walletModel, *entry, *capabilities, this); dialog.exec(); } } @@ -490,7 +508,9 @@ void MasternodeList::onUpdateRegistrar() { if (const auto* entry{selectedEntryForDialog()}; entry != nullptr && walletModel->wallet().isSpendable(PKHash(entry->keyIdOwnerRaw()))) { - UpdateRegistrarDialog dialog(clientModel->node(), walletModel, *entry, this); + const auto capabilities{providerTxCapabilities()}; + if (!capabilities) return; + UpdateRegistrarDialog dialog(clientModel->node(), walletModel, *entry, *capabilities, this); dialog.exec(); } } @@ -574,10 +594,11 @@ void MasternodeList::updateMasternodeList() return; } - std::set setOutpts; - for (const auto& outpt : walletModel->wallet().listProTxCoins()) { - setOutpts.emplace(outpt); - } + // Without the wallet's collaterals ownership cannot be told; keep the + // current list until a later update. + const auto protx_coins{walletModel->wallet().listProTxCoins()}; + if (!protx_coins) return; + const std::set setOutpts{protx_coins->begin(), protx_coins->end()}; QSet owned_mns; QHash my_share_counts; diff --git a/src/qt/masternodelist.h b/src/qt/masternodelist.h index 332aafcbb5d6..994c682670a5 100644 --- a/src/qt/masternodelist.h +++ b/src/qt/masternodelist.h @@ -5,6 +5,7 @@ #ifndef BITCOIN_QT_MASTERNODELIST_H #define BITCOIN_QT_MASTERNODELIST_H +#include #include #include @@ -17,6 +18,7 @@ #include #include +#include #include class ClientModel; @@ -142,6 +144,8 @@ class MasternodeList : public QWidget const MasternodeEntry* GetSelectedEntry(); const MasternodeEntry* selectedEntryForDialog(); + //! The provider transaction rules, or nullopt after telling the user they are not available yet + std::optional providerTxCapabilities(); Q_SIGNALS: void doubleClicked(const QModelIndex&); diff --git a/src/qt/masternodewidgets.cpp b/src/qt/masternodewidgets.cpp index 616dfaa10840..cc3699105fbd 100644 --- a/src/qt/masternodewidgets.cpp +++ b/src/qt/masternodewidgets.cpp @@ -56,6 +56,11 @@ bool isP2PKHorP2SHAddress(const QString& address) return std::holds_alternative(dest) || std::holds_alternative(dest); } +QString masternodeStateUnavailableText() +{ + return QObject::tr("Masternode state is not available on this node yet. Try again later."); +} + QLabel* makeTitle(const QString& text, QWidget* parent, double point_size) { auto* label{new QLabel(text, parent)}; diff --git a/src/qt/masternodewidgets.h b/src/qt/masternodewidgets.h index 2d527ad6d8a7..6818061ff2e3 100644 --- a/src/qt/masternodewidgets.h +++ b/src/qt/masternodewidgets.h @@ -44,6 +44,9 @@ bool isP2PKHorP2SHAddress(const QString& address); //! translated reason in `error` QString freshAddress(WalletModel* wallet_model, QString& error); +//! Shown when the node cannot evaluate its masternode state yet +QString masternodeStateUnavailableText(); + //! Spacing tokens (px) keeping one vertical rhythm across the dialogs' pages constexpr int GROUP_SPACING{16}; //!< between top-level groups of a page constexpr int TITLE_SPACING{8}; //!< between a group's title and its body diff --git a/src/qt/masternodewizard.cpp b/src/qt/masternodewizard.cpp index 8bf184d891d2..c43ed1342693 100644 --- a/src/qt/masternodewizard.cpp +++ b/src/qt/masternodewizard.cpp @@ -63,10 +63,21 @@ using MasternodeWidgetUtil::makePageLayout; using MasternodeWidgetUtil::makePageTitle; using MasternodeWidgetUtil::makeValue; -std::set RegisteredCollaterals(interfaces::Wallet& wallet) +//! Whether the provider transaction rules still have `version`. Rules this +//! node cannot evaluate yet count as changed. +bool CurrentProviderVersionIs(interfaces::Node& node, uint16_t version) +{ + const auto capabilities{node.evo().getProviderTxCapabilities()}; + return capabilities && capabilities->version == version; +} + +//! The wallet's coins registered as masternode collateral, or nullopt when the +//! masternode list is not available +std::optional> RegisteredCollaterals(interfaces::Wallet& wallet) { const auto registered{wallet.listProTxCoins()}; - return {registered.begin(), registered.end()}; + if (!registered) return std::nullopt; + return std::set{registered->begin(), registered->end()}; } //! Formats collateral amounts, which are whole coins in every display unit, so @@ -686,7 +697,8 @@ bool RegisterMasternodeWizard::isEvo() const bool RegisterMasternodeWizard::usesExtendedAddresses() const { - return m_node.evo().getProviderTxCapabilities().extended_addresses; + const auto capabilities{m_node.evo().getProviderTxCapabilities()}; + return capabilities && capabilities->extended_addresses; } bool RegisterMasternodeWizard::isExternalCollateral() const @@ -846,9 +858,12 @@ void RegisterMasternodeWizard::enterPage(Page page) } break; case PagePlatform: { + // Without the rules, no version is recorded and validating the page + // reports them unavailable. const auto capabilities{m_node.evo().getProviderTxCapabilities()}; - const bool v3{capabilities.extended_addresses}; - m_platform_provider_version = capabilities.version; + if (!capabilities) showError(MasternodeWidgetUtil::masternodeStateUnavailableText()); + const bool v3{capabilities && capabilities->extended_addresses}; + m_platform_provider_version = capabilities ? capabilities->version : 0; m_platform_extended_addresses = v3; m_platform_addr_box->setVisible(v3); m_platform_port_box->setVisible(!v3); @@ -918,7 +933,9 @@ bool RegisterMasternodeWizard::validatePage(Page page, QString& err) break; case PageService: { const QStringList list{MasternodeWidgetUtil::tokenizeEndpointList(m_service_edit->text())}; - if (list.isEmpty() && isEvo() && !usesExtendedAddresses()) { + if (list.isEmpty() && isEvo() && !m_node.evo().getProviderTxCapabilities()) { + err = MasternodeWidgetUtil::masternodeStateUnavailableText(); + } else if (list.isEmpty() && isEvo() && !usesExtendedAddresses()) { err = tr("EvoNodes need at least one service address to carry the Platform ports before v24 " "activation."); } else if (!isEvo()) { @@ -926,9 +943,10 @@ bool RegisterMasternodeWizard::validatePage(Page page, QString& err) } else if (!list.isEmpty()) { interfaces::ProviderNetInfo core_only; for (const QString& entry : list) core_only.core_p2p.push_back(entry.toStdString()); - const uint16_t version{m_node.evo().getProviderTxCapabilities().version}; - if (const auto error{m_node.evo().validateProviderNetInfo( - core_only, MnType::Regular, version, /*optional=*/true)}) { + if (const auto capabilities{m_node.evo().getProviderTxCapabilities()}; !capabilities) { + err = MasternodeWidgetUtil::masternodeStateUnavailableText(); + } else if (const auto error{m_node.evo().validateProviderNetInfo(core_only, MnType::Regular, + capabilities->version, /*optional=*/true)}) { err = QString::fromStdString(error->message.translated); } } @@ -1047,11 +1065,17 @@ interfaces::ProviderNetInfo RegisterMasternodeWizard::providerNetInfo() const bool RegisterMasternodeWizard::validateProviderNetInfo(bool optional, QString& err) const { - const uint16_t version{isEvo() && m_platform_provider_version != 0 ? - m_platform_provider_version : - m_node.evo().getProviderTxCapabilities().version}; - if (const auto error{m_node.evo().validateProviderNetInfo( - providerNetInfo(), isEvo() ? MnType::Evo : MnType::Regular, version, optional)}) { + std::optional version; + if (isEvo() && m_platform_provider_version != 0) { + version = m_platform_provider_version; + } else if (const auto capabilities{m_node.evo().getProviderTxCapabilities()}) { + version = capabilities->version; + } else { + err = MasternodeWidgetUtil::masternodeStateUnavailableText(); + return false; + } + if (const auto error{m_node.evo().validateProviderNetInfo(providerNetInfo(), isEvo() ? MnType::Evo : MnType::Regular, + *version, optional)}) { err = QString::fromStdString(error->message.translated); return false; } @@ -1181,7 +1205,14 @@ void RegisterMasternodeWizard::refreshCollateralCandidates() m_col_utxo_none->setVisible(true); return; } - refreshCollateralCandidates(RegisteredCollaterals(m_walletModel->wallet())); + const auto registered{RegisteredCollaterals(m_walletModel->wallet())}; + if (!registered) { + // Keep the current candidates: a registered collateral cannot be told + // from a free one yet. + showError(MasternodeWidgetUtil::masternodeStateUnavailableText()); + return; + } + refreshCollateralCandidates(*registered); } void RegisterMasternodeWizard::refreshCollateralCandidates(const std::set& registered_collaterals) @@ -1386,7 +1417,12 @@ std::optional RegisterMasternodeWizard: const COutPoint outpoint{hash, static_cast( m_col_utxo_combo->itemData(m_col_utxo_combo->currentIndex(), VOUT_ROLE).toInt())}; - if (RegisteredCollaterals(m_walletModel->wallet()).count(outpoint) != 0) { + const auto registered{RegisteredCollaterals(m_walletModel->wallet())}; + if (!registered) { + error = MasternodeWidgetUtil::masternodeStateUnavailableText(); + return std::nullopt; + } + if (registered->count(outpoint) != 0) { error = tr("The selected collateral output is already used by another masternode."); return std::nullopt; } @@ -1494,7 +1530,7 @@ void RegisterMasternodeWizard::startRegistration(bool skip_confirmation) goToPage(PageSecret); return; } - if (isEvo() && m_node.evo().getProviderTxCapabilities().version != m_platform_provider_version) { + if (isEvo() && !CurrentProviderVersionIs(m_node, m_platform_provider_version)) { goToPage(PagePlatform); showError(tr("The network's provider-transaction rules changed while this wizard was open. Review the " "Platform services page again before registering.")); @@ -1537,7 +1573,7 @@ void RegisterMasternodeWizard::startRegistration(bool skip_confirmation) void RegisterMasternodeWizard::startSubmit(bool skip_confirmation) { if (m_walletModel == nullptr || !m_runner || !m_prepared_tx) return; - if (isEvo() && m_node.evo().getProviderTxCapabilities().version != m_platform_provider_version) { + if (isEvo() && !CurrentProviderVersionIs(m_node, m_platform_provider_version)) { showError(tr("The network's provider-transaction rules changed while the collateral signature was being " "collected. Discard this prepared registration and review the Platform services page again.")); return; diff --git a/src/qt/proposallist.cpp b/src/qt/proposallist.cpp index 23a2287a1b71..471c1c01df6b 100644 --- a/src/qt/proposallist.cpp +++ b/src/qt/proposallist.cpp @@ -258,16 +258,23 @@ void ProposalList::updateProposalList() // Include unrelayed wallet proposals (0 confs, not yet broadcast) for (const auto& obj : getWalletProposals(/*pending=*/true)) { CGovernanceObject govObj(obj.hashParent, obj.revision, obj.time, obj.collateralHash, obj.GetDataAsHexString()); - ret.m_proposals.emplace_back(std::make_shared(*clientModel, govObj, ret.m_gov_info, queryCollateralDepth(obj.collateralHash), - /*is_broadcast=*/false)); + auto proposal{std::make_shared(*clientModel, govObj, ret.m_gov_info, + queryCollateralDepth(obj.collateralHash), + /*is_broadcast=*/false)}; + // Without vote counts keep the current list until a later update. + if (!proposal->hasVotes()) return; + ret.m_proposals.emplace_back(std::move(proposal)); } } else if (m_proposal_source == ProposalSource::Local) { // Discard network proposals and only include wallet proposals ret.m_proposals.clear(); for (const auto& obj : getWalletProposals(/*pending=*/std::nullopt)) { CGovernanceObject govObj(obj.hashParent, obj.revision, obj.time, obj.collateralHash, obj.GetDataAsHexString()); - ret.m_proposals.emplace_back(std::make_shared(*clientModel, govObj, ret.m_gov_info, queryCollateralDepth(obj.collateralHash), - /*is_broadcast=*/clientModel->node().gov().existsObj(obj.GetHash()))); + auto proposal{ + std::make_shared(*clientModel, govObj, ret.m_gov_info, queryCollateralDepth(obj.collateralHash), + /*is_broadcast=*/clientModel->node().gov().existsObj(obj.GetHash()))}; + if (!proposal->hasVotes()) return; + ret.m_proposals.emplace_back(std::move(proposal)); } } diff --git a/src/qt/proposalmodel.cpp b/src/qt/proposalmodel.cpp index 4e2ce36addb7..6f58ba9dffa8 100644 --- a/src/qt/proposalmodel.cpp +++ b/src/qt/proposalmodel.cpp @@ -43,8 +43,7 @@ QString formatProposalAmount(const BitcoinUnit& unit, const CAmount& amount) } // anonymous namespace Proposal::Proposal(ClientModel& client_model, const CGovernanceObject& govObj, - const interfaces::GOV::GovernanceInfo& govInfo, int collateral_confs, - bool is_broadcast) : + const interfaces::GOV::GovernanceInfo& govInfo, int collateral_confs, bool is_broadcast) : m_is_broadcast{is_broadcast}, m_block_height{client_model.getNumBlocks()}, m_collateral_confs{collateral_confs}, @@ -54,7 +53,8 @@ Proposal::Proposal(ClientModel& client_model, const CGovernanceObject& govObj, m_hash_object{QString::fromStdString(govObj.GetHash().ToString())}, m_hash_parent{QString::fromStdString(govObj.Object().hashParent.ToString())}, m_json{QString::fromStdString(govObj.GetInnerJson().write(2))}, - m_objHash{govObj.GetHash()} + m_objHash{govObj.GetHash()}, + m_votes{client_model.node().gov().getObjVotes(govObj, VOTE_SIGNAL_FUNDING)} { UniValue prop_data; if (!prop_data.read(govObj.GetDataAsPlainString())) { @@ -62,11 +62,16 @@ Proposal::Proposal(ClientModel& client_model, const CGovernanceObject& govObj, } m_funded_height = client_model.node().gov().getProposalFundedHeight(govObj.GetHash()); - m_votes = client_model.node().gov().getObjVotes(govObj, VOTE_SIGNAL_FUNDING); - for (const auto& vote : client_model.node().gov().getCurrentVotes(govObj.GetHash())) { - if (vote.GetSignal() == VOTE_SIGNAL_FUNDING) { - m_current_votes.emplace(vote.GetMasternodeOutpoint(), vote); + // Without the masternode list the votes are unknown, not absent: the + // proposal then reports no votes, and its feed keeps its current data. + if (const auto current_votes{client_model.node().gov().getCurrentVotes(govObj.GetHash())}) { + for (const auto& vote : *current_votes) { + if (vote.GetSignal() == VOTE_SIGNAL_FUNDING) { + m_current_votes.emplace(vote.GetMasternodeOutpoint(), vote); + } } + } else { + m_votes.reset(); } if (const UniValue& titleValue = prop_data.find_value("name"); titleValue.isStr()) { @@ -169,7 +174,8 @@ ProposalStatus Proposal::status(bool is_fundable) const if (m_block_height % m_gov_info.superblockcycle >= m_gov_info.superblockcycle - m_gov_info.superblockmaturitywindow) { return is_fundable ? ProposalStatus::Passing : ProposalStatus::Failing; } - if (getAbsoluteYesCount() < m_gov_info.fundingthreshold) { + // Only proposals judged against a known masternode list are listed. + if (Assume(m_gov_info.fundingthreshold) && getAbsoluteYesCount() < *m_gov_info.fundingthreshold) { return ProposalStatus::Voting; } return is_fundable ? ProposalStatus::Passing : ProposalStatus::Unfunded; diff --git a/src/qt/proposalmodel.h b/src/qt/proposalmodel.h index 06d6505bc559..4e9db45072ed 100644 --- a/src/qt/proposalmodel.h +++ b/src/qt/proposalmodel.h @@ -9,6 +9,7 @@ #include #include #include +#include #include @@ -47,7 +48,6 @@ class Proposal int m_block_height{0}; int m_collateral_confs{0}; interfaces::GOV::GovernanceInfo m_gov_info; - interfaces::GOV::Votes m_votes; std::map m_current_votes; QDateTime m_date_collateral{}; QDateTime m_endDate{}; @@ -61,6 +61,14 @@ class Proposal QString m_url{}; std::optional m_funded_height{}; uint256 m_objHash{}; + //! Nullopt when the masternode list to count votes against is not available + std::optional m_votes; + + interfaces::GOV::Votes votes() const + { + Assume(m_votes.has_value()); + return m_votes.value_or(interfaces::GOV::Votes{}); + } public: explicit Proposal(ClientModel& client_model, const CGovernanceObject& _govObj, @@ -75,10 +83,13 @@ class Proposal int collateralConfs() const; int paymentsRequested() const; int requiredConfs() const; - int32_t getAbsoluteYesCount() const { return m_votes.m_yes - m_votes.m_no; } - int32_t getAbstainCount() const { return m_votes.m_abs; } - int32_t getNoCount() const { return m_votes.m_no; } - int32_t getYesCount() const { return m_votes.m_yes; } + //! Whether the vote counts are known. Only proposals with known counts are + //! listed, so the counts below are read only for those. + bool hasVotes() const { return m_votes.has_value(); } + int32_t getAbsoluteYesCount() const { return votes().m_yes - votes().m_no; } + int32_t getAbstainCount() const { return votes().m_abs; } + int32_t getNoCount() const { return votes().m_no; } + int32_t getYesCount() const { return votes().m_yes; } ProposalStatus status(bool is_fundable) const; QDateTime collateralDate() const { return m_date_collateral; } QDateTime endDate() const { return m_endDate; } diff --git a/src/qt/sharedmncreatedialog.cpp b/src/qt/sharedmncreatedialog.cpp index dd018ce56dfa..ff5ce77a7102 100644 --- a/src/qt/sharedmncreatedialog.cpp +++ b/src/qt/sharedmncreatedialog.cpp @@ -1191,8 +1191,8 @@ void SharedMnCreateDialog::enterPage(Page page) QString gate; if (m_wallet_model == nullptr) { gate = tr("No wallet is available."); - } else if (!m_v24_active) { - gate = SharedMnV24InactiveMessage(); + } else if (!m_v24_active.value_or(false)) { + gate = SharedMnV24InactiveMessage(m_v24_active); } else if (!canSign()) { gate = tr("This wallet cannot sign. You can view, but not take part."); } @@ -1317,9 +1317,10 @@ bool SharedMnCreateDialog::validatePage(Page page, QString& err) for (const QString& entry : MasternodeWidgetUtil::tokenizeEndpointList(m_service_edit->text())) { net_info.core_p2p.push_back(entry.toStdString()); } - const uint16_t version{m_node.evo().getProviderTxCapabilities().version}; - if (const auto error{ - m_node.evo().validateProviderNetInfo(net_info, MnType::Regular, version, /*optional=*/true)}) { + if (const auto capabilities{m_node.evo().getProviderTxCapabilities()}; !capabilities) { + err = MasternodeWidgetUtil::masternodeStateUnavailableText(); + } else if (const auto error{m_node.evo().validateProviderNetInfo(net_info, MnType::Regular, + capabilities->version, /*optional=*/true)}) { err = QString::fromStdString(error->message.translated); } break; @@ -2180,8 +2181,8 @@ void SharedMnCreateDialog::pushSessionToWidgets() void SharedMnCreateDialog::startSession() { - if (!m_v24_active) { - showError(SharedMnV24InactiveMessage()); + if (!m_v24_active.value_or(false)) { + showError(SharedMnV24InactiveMessage(m_v24_active)); return; } if (m_wallet_model == nullptr) { diff --git a/src/qt/sharedmncreatedialog.h b/src/qt/sharedmncreatedialog.h index e7838a7f1624..a0b2b14323f6 100644 --- a/src/qt/sharedmncreatedialog.h +++ b/src/qt/sharedmncreatedialog.h @@ -357,7 +357,7 @@ private Q_SLOTS: interfaces::Node& m_node; WalletModel* const m_wallet_model; - const bool m_v24_active; + const std::optional m_v24_active; MnShareSession m_session; //! Null without a wallet, when nothing can be prepared or signed here MasternodeOperationRunner* const m_runner; diff --git a/src/qt/sharedmndialogs.cpp b/src/qt/sharedmndialogs.cpp index c68f9e7e75c8..0013e95a027e 100644 --- a/src/qt/sharedmndialogs.cpp +++ b/src/qt/sharedmndialogs.cpp @@ -406,8 +406,8 @@ void UpdateShareDialog::validate() if (!m_is_shared) { showError(tr("This masternode is not shared and has no share table.")); - } else if (!m_v24_active) { - showError(SharedMnV24InactiveMessage()); + } else if (!m_v24_active.value_or(false)) { + showError(SharedMnV24InactiveMessage(m_v24_active)); } else if (m_share_combo->count() == 0) { showError(NoShareKeysMessage()); } else if (!m_reward_edit->text().trimmed().isEmpty() && !problem.isEmpty()) { @@ -418,7 +418,7 @@ void UpdateShareDialog::validate() clearError(); } - setOkValid(m_v24_active && m_is_shared && has_share && problem.isEmpty() && + setOkValid(m_v24_active.value_or(false) && m_is_shared && has_share && problem.isEmpty() && !m_fee_source->selectedAddress().isEmpty()); } @@ -889,9 +889,9 @@ bool SharedMnDialog::gateButton(QPushButton* button) const button->setToolTip(tr("This wallet is watch-only and cannot sign.")); return false; } - if (!m_v24_active) { + if (!m_v24_active.value_or(false)) { button->setEnabled(false); - button->setToolTip(SharedMnV24InactiveMessage()); + button->setToolTip(SharedMnV24InactiveMessage(m_v24_active)); return false; } return true; diff --git a/src/qt/sharedmndialogs.h b/src/qt/sharedmndialogs.h index cd06b099f951..8c057e32a9d4 100644 --- a/src/qt/sharedmndialogs.h +++ b/src/qt/sharedmndialogs.h @@ -63,7 +63,7 @@ class UpdateShareDialog : public MasternodeActionDialog //! Fill the reward field with a fresh address of this wallet void useNewAddress(); - const bool m_v24_active; + const std::optional m_v24_active; const bool m_is_shared; const QString m_voting_address; const std::vector m_shares; @@ -238,7 +238,7 @@ class SharedMnDialog : public QDialog const CAmount m_early_penalty; const uint32_t m_early_period_blocks; const int m_registered_height; - const bool m_v24_active; + const std::optional m_v24_active; //! Null without a wallet, in which case every action button is gated off MasternodeOperationRunner* const m_runner; diff --git a/src/qt/sharedmnwidgets.cpp b/src/qt/sharedmnwidgets.cpp index 4b3289dbd94d..288c3e8fb29e 100644 --- a/src/qt/sharedmnwidgets.cpp +++ b/src/qt/sharedmnwidgets.cpp @@ -89,8 +89,13 @@ bool SharedMnWalletOwnsShare(const WalletModel* wallet_model, const interfaces:: return wallet_model != nullptr && wallet_model->wallet().isSpendable(PKHash(share.keyIDOwner)); } -QString SharedMnV24InactiveMessage() +QString SharedMnV24InactiveMessage(std::optional v24_active) { + if (!v24_active) { + return QCoreApplication::translate("SharedMnDialog", + "This node cannot tell yet whether the v24 upgrade is active. Try again " + "later."); + } return QCoreApplication::translate("SharedMnDialog", "Shared masternodes need the v24 upgrade, which is not active on this network " "yet."); diff --git a/src/qt/sharedmnwidgets.h b/src/qt/sharedmnwidgets.h index b8636cc00c4c..91d3f6d60709 100644 --- a/src/qt/sharedmnwidgets.h +++ b/src/qt/sharedmnwidgets.h @@ -45,7 +45,7 @@ BitcoinUnits::Unit SharedMnDisplayUnit(const WalletModel* wallet_model); bool SharedMnWalletOwnsShare(const WalletModel* wallet_model, const interfaces::MnShare& share); //! The one wording for the v24 activation gate -QString SharedMnV24InactiveMessage(); +QString SharedMnV24InactiveMessage(std::optional v24_active); //! Broadcast a fully signed transaction under the same fee cap "sendrawtransaction" //! applies. Returns why it was rejected, ready to show to the user, or an empty diff --git a/src/qt/test/masternodewidgettests.cpp b/src/qt/test/masternodewidgettests.cpp index 9dac30676fc7..8fe928e846eb 100644 --- a/src/qt/test/masternodewidgettests.cpp +++ b/src/qt/test/masternodewidgettests.cpp @@ -8,13 +8,16 @@ #include #include +#include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -24,7 +27,6 @@ #include #include #include -#include #include #include