From 487cd611db335decb75073fecb302b8c24305a36 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 15 Oct 2026 00:31:17 -0500 Subject: [PATCH 01/16] fix(evo): pass snapshot-data and mismatch errors through generic catches During dual-chainstate operation a snapshot chainstate block whose Evo state depends on data this node does not have yet (a pre-base MN-list diff on a cold EvoDB) raised BlockDataUnavailableError. The generic std::exception catch in ProcessSpecialTxsInBlock turned it into failed-procspectxsinblock, so the block was marked BLOCK_FAILED_VALID and its relayer could be punished, although nothing is wrong with it. Add evo/snapshot_types.h with a LocalStateError base for errors about this node's own Evo state: BlockDataUnavailableError (moved here), SnapshotStateMismatchError and EvoViewBindingError. Rebuilding the credit pool or the EHF signals from a block body this node does not have (pruned, or below a snapshot base) raises BlockDataUnavailableError; an unreadable body the node should have stays a plain error. For a pruned node this changes NORMAL too: such a block now stalls the chainstate instead of being marked invalid. Every generic catch on the block-connection path (CheckSpecialTxInner, ProcessSpecialTxsInBlock, CheckCreditPoolDiffForBlock, UndoSpecialTxsInBlock, GetCreditPoolDiffForBlock, CMNHFManager::ProcessBlock, CDeterministicMNManager::ProcessBlock) rethrows them. ConnectTip, DisconnectTip and TestBlockValidity classify them: a mismatch rejects the snapshot through the new ChainstateManager::HandleSnapshotStateMismatch, anything else is state.Error, so the chainstate stalls instead of invalidating the block. ConnectTip announces that error through BlockChecked like any other connection result, so submitblock reports it and the relaying peer is neither punished nor credited. Mempool and mining callers of CheckSpecialTx fail locally, the asset-unlock credit-pool precheck passes the error on, and mempool acceptance (single, package and test-accept) turns any local Evo error into an error result, dropping the coins it loaded like any failure. HandleSnapshotStateMismatch is extracted from the MaybeCompleteSnapshotValidation rejection lambda, and flushes both chainstates first so it can also run mid-connection. A stall is typed: BlockValidationState carries a LocalEvoStateResult (unavailable data, a binding fault, or a mismatch that could not reject the snapshot), queried with IsLocalEvoStateError(). It is an error, never invalidity, and is distinct from storage errors and from a rejected snapshot. The chainstate remembers the blocks it stalled on (every one: they are all in the block index) and refuses them at once on later attempts instead of revalidating them, until the node restarts; the first stall logs an error and sets the misc warning. Completing snapshot validation does not make the background chainstate's state readable to the snapshot chainstate before the next startup promotes it, so a chainstate still stalled then is told to restart. Candidate selection passes over the branches that need the stalled block connected (or disconnected), so a usable branch with less work still advances the chain; ActivateBestChain reports the stall when only it keeps a chain with more work out of reach. Callers that treated any failure as fatal no longer abort on a stall: ActivateBestChain announces the blocks it connected before stopping, ActivateBestChainStep returns the disconnected transactions to the mempool without AbortNode, block import continues with the other chainstates and the rest of startup, LoadExternalBlockFile keeps importing for the other chainstates, ChainLock enforcement retries on its next run, and InstantSend keeps the conflicting blocks a lock could not invalidate and retries them after the next tip or ChainLock change instead of asserting; an invalidation that is only temporary, for a lock without its transaction, clears the failure flags it set however it ends. A stall partway through a rewind keeps the blocks already disconnected and announces the tip reached: InvalidateBlock and ChainLock conflict marking finish and announce what they disconnected, and ActivateBestChain announces a reorganization that stalled before connecting anything. A block whose disconnect stalls stays in the chain, so the best header (and best invalid block) adjusted for it are restored. Co-Authored-By: Claude Opus 5.5 --- src/Makefile.am | 1 + src/consensus/validation.h | 23 +- src/evo/creditpool.cpp | 8 + src/evo/deterministicmns.cpp | 2 + src/evo/deterministicmns.h | 14 +- src/evo/mnhftx.cpp | 8 + src/evo/snapshot_types.h | 53 +++ src/evo/specialtxman.cpp | 15 + src/instantsend/net_instantsend.cpp | 111 ++++- src/instantsend/net_instantsend.h | 22 +- src/node/blockstorage.cpp | 6 + src/node/miner.cpp | 2 + .../validation_chainstatemanager_tests.cpp | 399 +++++++++++++++++- src/validation.cpp | 372 ++++++++++++---- src/validation.h | 52 ++- 15 files changed, 965 insertions(+), 123 deletions(-) create mode 100644 src/evo/snapshot_types.h diff --git a/src/Makefile.am b/src/Makefile.am index ad095c971a9f..c0ff282ec7e0 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -244,6 +244,7 @@ BITCOIN_CORE_H = \ evo/simplifiedmns.h \ evo/smldiff.h \ evo/snapshot.h \ + evo/snapshot_types.h \ evo/specialtx.h \ evo/specialtx_filter.h \ evo/specialtxman.h \ 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/creditpool.cpp b/src/evo/creditpool.cpp index cafe65b023f4..9ba53bc27240 100644 --- a/src/evo/creditpool.cpp +++ b/src/evo/creditpool.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -80,6 +81,11 @@ static std::optional GetCreditDataFromBlock(const gsl::n 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"); } @@ -418,6 +424,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/deterministicmns.cpp b/src/evo/deterministicmns.cpp index 8241cdcab704..c862e4be1807 100644 --- a/src/evo/deterministicmns.cpp +++ b/src/evo/deterministicmns.cpp @@ -751,6 +751,8 @@ bool CDeterministicMNManager::ProcessBlock(const CBlock& block, gsl::not_nullnHeight; mnListDiffsCache.emplace(pindex->GetBlockHash(), diff); 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"); diff --git a/src/evo/deterministicmns.h b/src/evo/deterministicmns.h index f8ab114c6312..2c9d93bc71f3 100644 --- a/src/evo/deterministicmns.h +++ b/src/evo/deterministicmns.h @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -738,19 +739,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 diff --git a/src/evo/mnhftx.cpp b/src/evo/mnhftx.cpp index 806add0fd78f..5e3e13d5d039 100644 --- a/src/evo/mnhftx.cpp +++ b/src/evo/mnhftx.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -254,6 +255,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 +304,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; 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..d8ba4871e7a9 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -285,6 +286,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"); @@ -1078,6 +1086,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"); @@ -1111,6 +1121,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 +1159,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/instantsend/net_instantsend.cpp b/src/instantsend/net_instantsend.cpp index b5871d326546..4eb2c5a23f95 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); @@ -433,6 +434,7 @@ void NetInstantSend::WorkThreadMain() if (!locks.empty()) { ProcessPendingISLocks(std::move(locks)); } + RetryUnresolvedConflicts(); if (m_signer) { m_signer->ProcessPendingRetryLockTxs(m_is_manager.PrepareTxToRetry()); } @@ -513,6 +515,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 +582,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 +606,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/node/blockstorage.cpp b/src/node/blockstorage.cpp index 822e983573cb..c5d90892077c 100644 --- a/src/node/blockstorage.cpp +++ b/src/node/blockstorage.cpp @@ -977,6 +977,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/miner.cpp b/src/node/miner.cpp index 4e06d4d84bb4..e87bf34e897f 100644 --- a/src/node/miner.cpp +++ b/src/node/miner.cpp @@ -811,6 +811,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/test/validation_chainstatemanager_tests.cpp b/src/test/validation_chainstatemanager_tests.cpp index bd84033c6a3f..2ebeb70cead4 100644 --- a/src/test/validation_chainstatemanager_tests.cpp +++ b/src/test/validation_chainstatemanager_tests.cpp @@ -3,15 +3,23 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. // #include +#include #include +#include #include +#include +#include #include #include +#include #include +#include #include #include +#include #include #include +#include #include #include #include @@ -19,8 +27,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -33,6 +43,7 @@ #include +#include #include #include @@ -50,8 +61,83 @@ void SeedSnapshotMarker(CEvoDB& evodb, const uint256& hash) BOOST_REQUIRE(evodb.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); } +//! The regtest assumeutxo fixture predates DIP3. Activate DIP3 from height 1 +//! for the lifetime of this guard, so the cold EvoDB holds no MN-list history +//! for blocks that now count as DIP3-active. +class ScopedDIP3FromGenesis +{ + Consensus::Params& m_params{const_cast(Params().GetConsensus())}; + const int m_old_height{m_params.DIP0003Height}; + +public: + ScopedDIP3FromGenesis() { m_params.DIP0003Height = 1; } + ~ScopedDIP3FromGenesis() { m_params.DIP0003Height = m_old_height; } +}; + +//! Turn a pre-DIP3 template into one whose coinbase is a CbTx, as required +//! once DIP3 is active, and solve it again. +CBlock MakeCbTxBlock(CBlock block, int height, CCbTx::Version version = CCbTx::Version::MERKLE_ROOT_QUORUMS) +{ + CCbTx cbtx; + cbtx.nVersion = version; + cbtx.nHeight = height; + CMutableTransaction coinbase{*block.vtx[0]}; + coinbase.nVersion = CTransaction::SPECIAL_VERSION; + coinbase.nType = TRANSACTION_COINBASE; + SetTxPayload(coinbase, cbtx); + block.vtx[0] = MakeTransactionRef(std::move(coinbase)); + block.hashMerkleRoot = BlockMerkleRoot(block); + while (!CheckProofOfWork(block.GetHash(), block.nBits, Params().GetConsensus())) + ++block.nNonce; + return block; +} + +//! The last tip validation announced, synchronously and through the queue. +class TipAnnouncements final : public CValidationInterface +{ +public: + const CBlockIndex* synchronous{nullptr}; + const CBlockIndex* queued{nullptr}; + +protected: + void SynchronousUpdatedBlockTip(const CBlockIndex* new_tip, const CBlockIndex*, bool) override + { + synchronous = new_tip; + } + void UpdatedBlockTip(const CBlockIndex* new_tip, const CBlockIndex*, bool) override { queued = new_tip; } +}; + +//! The validation result announced for one block. +class BlockCheckedCatcher final : public CValidationInterface +{ +public: + explicit BlockCheckedCatcher(const uint256& hash) : + m_hash{hash} + { + } + std::optional state{}; + +protected: + void BlockChecked(const CBlock& block, const BlockValidationState& checked) override + { + if (block.GetHash() == m_hash) state = checked; + } + +private: + const uint256 m_hash{}; +}; + } // namespace +//! Reaches InstantSend's conflicting-block invalidation. +struct NetInstantSendConflictsTest { + //! Invalidate `block_hash` for a lock whose transaction is unknown. + static void InvalidateTemporarily(NetInstantSend& net, const uint256& block_hash) + { + net.InvalidateConflictingBlocks(GetRandHash(), {.blocks = {block_hash}, .has_tx_for_lock = false}); + } +}; + BOOST_FIXTURE_TEST_SUITE(validation_chainstatemanager_tests, ChainTestingSetup) //! Basic tests for ChainstateManager. @@ -456,7 +542,284 @@ struct SnapshotTestSetup : TestChain100Setup { //! Test basic snapshot activation. BOOST_FIXTURE_TEST_CASE(chainstatemanager_activate_snapshot, SnapshotTestSetup) { - this->SetupSnapshot(); + auto [background_chainstate, snapshot_chainstate] = this->SetupSnapshot(); + ChainstateManager& chainman = *Assert(m_node.chainman); + + // Stay outside every regtest LLMQ mining window (dkgInterval 24, windows + // end at offset 20), so the block needs no quorum commitment. + while (WITH_LOCK(::cs_main, return (snapshot_chainstate->m_chain.Height() + 1) % 24) < 21) { + mineBlocks(1); + } + const CBlockIndex* snapshot_tip{WITH_LOCK(::cs_main, return snapshot_chainstate->m_chain.Tip())}; + const CBlock template_block{CreateBlock({}, GetScriptForRawPubKey(coinbaseKey.GetPubKey()), *snapshot_chainstate)}; + + // With DIP3 active at the base, the snapshot chainstate's next block needs + // the pre-base MN list, whose diffs this cold EvoDB never derived. That is + // missing local data: the chainstate must stall with a local error, and + // the block must not be marked invalid. + std::optional dip3_active{std::in_place}; + int evo_state_errors{0}; + const auto log_callback{LogInstance().PushBackCallback([&](const std::string& line) { + if (line.find("local Evo state error") != std::string::npos) ++evo_state_errors; + })}; + const auto block{std::make_shared(MakeCbTxBlock(template_block, snapshot_tip->nHeight + 1))}; + BOOST_CHECK(!chainman.ProcessNewBlock(block, /*force_processing=*/true, /*new_block=*/nullptr)); + BlockValidationState state; + BOOST_CHECK(!snapshot_chainstate->ActivateBestChain(state, block)); + BOOST_CHECK(IsLocalEvoStateError(state)); + BOOST_CHECK(state.GetLocalEvoStateResult() == LocalEvoStateResult::UNAVAILABLE); + BOOST_CHECK_EQUAL(state.GetRejectReason(), "evo-local-unavailable"); + BOOST_CHECK(GetWarnings(/*verbose=*/true).original.find("Snapshot state unavailable") != std::string::npos); + + // Longer branches, each stalling at its own first block, are out of reach + // together: no stall makes the chainstate forget another. + const auto child_of{[&](const CBlock& parent, int height = 0) { + if (!height) height = snapshot_tip->nHeight + 2; + CBlock child_template{template_block}; + child_template.hashPrevBlock = parent.GetHash(); + CMutableTransaction child_coinbase{*child_template.vtx[0]}; + child_coinbase.vin[0].scriptSig = CScript{} << height << CScriptNum{1}; + child_template.vtx[0] = MakeTransactionRef(std::move(child_coinbase)); + return std::make_shared(MakeCbTxBlock(child_template, height)); + }}; + BOOST_CHECK(!chainman.ProcessNewBlock(child_of(*block), /*force_processing=*/true, /*new_block=*/nullptr)); + // However many there are: every stalled block is remembered. + constexpr int stalled_branches{40}; + for (int i{1}; i < stalled_branches; ++i) { + CBlock other_template{template_block}; + other_template.nTime += i; + const auto other{std::make_shared(MakeCbTxBlock(other_template, snapshot_tip->nHeight + 1))}; + BOOST_CHECK(!chainman.ProcessNewBlock(other, /*force_processing=*/true, /*new_block=*/nullptr)); + BOOST_CHECK(!chainman.ProcessNewBlock(child_of(*other), /*force_processing=*/true, /*new_block=*/nullptr)); + } + + // Startup's block import goes on past the stalled chainstate instead of + // aborting the node. + static std::atomic exit_status{EXIT_SUCCESS}; + BOOST_REQUIRE(InitShutdownState(exit_status)); + node::ThreadImport(chainman, /*vImportFiles=*/{}, *m_node.args, /*mempool_path=*/{}); + BOOST_CHECK(!ShutdownRequested()); + AbortShutdown(); + + // Retrying cannot succeed before the snapshot's Evo state changes, so each + // stalled block was validated once and every later attempt refused it + // outright. + LogInstance().DeleteCallback(log_callback); + BOOST_CHECK_EQUAL(evo_state_errors, stalled_branches); + { + LOCK(::cs_main); + const CBlockIndex* index{chainman.m_blockman.LookupBlockIndex(block->GetHash())}; + BOOST_REQUIRE(index); + BOOST_CHECK_EQUAL(index->nStatus & BLOCK_FAILED_MASK, 0U); + BOOST_CHECK_EQUAL(snapshot_chainstate->m_chain.Tip(), snapshot_tip); + } + + // The stalls keep out only the branches through the stalled blocks: a + // block that needs no unavailable data still advances the chain, although + // every stalled branch has more work. + dip3_active.reset(); + const auto usable{std::make_shared(template_block)}; + BOOST_CHECK(chainman.ProcessNewBlock(usable, /*force_processing=*/true, /*new_block=*/nullptr)); + BOOST_CHECK_EQUAL(WITH_LOCK(::cs_main, return snapshot_chainstate->m_chain.Tip()->GetBlockHash()), usable->GetHash()); + + // A chainstate that stalls partway back still announces the tip it got + // to, whether it was invalidating a block, moving away from a block that + // conflicts with a ChainLock, or reorganizing. Undoing `usable` stalls once + // it changes the MN list: that needs the pre-base list. + CBlockIndex* usable_index{WITH_LOCK(::cs_main, return chainman.m_blockman.LookupBlockIndex(usable->GetHash()))}; + { + LOCK(::cs_main); + CDeterministicMNListDiff diff; + diff.removedMns.emplace(0); + // The key mirrors DB_LIST_DIFF in evo/deterministicmns.cpp. + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + m_node.evodb->Write(std::make_pair(std::string{"dmn_D4"}, usable->GetHash()), diff); + tx->Commit(); + } + TipAnnouncements tips; + RegisterValidationInterface(&tips); + const auto check_announced{[&](const CBlockIndex* expected_tip) { + SyncWithValidationInterfaceQueue(); + BOOST_CHECK_EQUAL(WITH_LOCK(::cs_main, return snapshot_chainstate->m_chain.Tip()), expected_tip); + BOOST_CHECK_EQUAL(tips.synchronous, expected_tip); + BOOST_CHECK_EQUAL(tips.queued, expected_tip); + }}; + + mineBlocks(1); + CBlockIndex* above_usable{WITH_LOCK(::cs_main, return snapshot_chainstate->m_chain.Tip())}; + dip3_active.emplace(); + BOOST_CHECK(!snapshot_chainstate->InvalidateBlock(state, usable_index)); + BOOST_CHECK(IsLocalEvoStateError(state)); + check_announced(usable_index); + + dip3_active.reset(); + WITH_LOCK(::cs_main, snapshot_chainstate->ResetBlockFailureFlags(above_usable)); + BOOST_REQUIRE(snapshot_chainstate->ActivateBestChain(state)); + BOOST_REQUIRE_EQUAL(WITH_LOCK(::cs_main, return snapshot_chainstate->m_chain.Tip()), above_usable); + + // An InstantSend lock without its transaction invalidates its conflicting + // blocks only for a moment: stalling partway leaves none of the blocks it + // disconnected marked invalid. + { + NetInstantSend net{m_node.peerman.get(), *m_node.isman, nullptr, *m_node.llmq_ctx->sigman, + *m_node.llmq_ctx->qman, *m_node.chainlocks, chainman, *m_node.mempool, + *m_node.mn_sync}; + dip3_active.emplace(); + NetInstantSendConflictsTest::InvalidateTemporarily(net, usable->GetHash()); + dip3_active.reset(); + LOCK(::cs_main); + BOOST_CHECK_EQUAL(snapshot_chainstate->m_chain.Tip(), usable_index); + BOOST_CHECK_EQUAL(above_usable->nStatus & BLOCK_FAILED_MASK, 0U); + BOOST_CHECK_EQUAL(usable_index->nStatus & BLOCK_FAILED_MASK, 0U); + } + BOOST_REQUIRE(snapshot_chainstate->ActivateBestChain(state)); + BOOST_REQUIRE_EQUAL(WITH_LOCK(::cs_main, return snapshot_chainstate->m_chain.Tip()), above_usable); + + // Enforcing a ChainLock on a stalled sibling moves the chain away from + // `usable`. + const CBlockIndex* stalled_sibling{WITH_LOCK(::cs_main, return chainman.m_blockman.LookupBlockIndex(block->GetHash()))}; + snapshot_chainstate->EnforceBlock(state, stalled_sibling); + check_announced(usable_index); + BOOST_CHECK(WITH_LOCK(::cs_main, return above_usable->nStatus & BLOCK_CONFLICT_CHAINLOCK)); + + // A reorganization from a new tip above `usable` to a longer branch that + // forks at `usable` disconnects that tip, then stalls connecting the + // branch. + mineBlocks(1); + dip3_active.emplace(); + const auto reorg_first{child_of(*usable)}; + const auto reorg_second{child_of(*reorg_first, snapshot_tip->nHeight + 3)}; + chainman.ProcessNewBlock(reorg_first, /*force_processing=*/true, /*new_block=*/nullptr); + BOOST_CHECK(!chainman.ProcessNewBlock(reorg_second, /*force_processing=*/true, /*new_block=*/nullptr)); + check_announced(usable_index); + + UnregisterValidationInterface(&tips); + dip3_active.reset(); + SetMiscWarning(Untranslated("")); +} + +//! A chainstate whose first disconnect stalls has not moved, so its best +//! header stays where it was, whether it was invalidating that block or moving +//! away from it for a ChainLock. +BOOST_FIXTURE_TEST_CASE(chainstatemanager_stall_keeps_best_header, SnapshotTestSetup) +{ + auto [background_chainstate, snapshot_chainstate] = this->SetupSnapshot(); + ChainstateManager& chainman = *Assert(m_node.chainman); + // Stay outside every regtest LLMQ mining window, so the sibling below + // needs no quorum commitment. + while (WITH_LOCK(::cs_main, return (snapshot_chainstate->m_chain.Height() + 1) % 24) < 21) { + mineBlocks(1); + } + const CBlock sibling_template{CreateBlock({}, GetScriptForRawPubKey(coinbaseKey.GetPubKey()), *snapshot_chainstate)}; + mineBlocks(1); + CBlockIndex* tip{WITH_LOCK(::cs_main, return snapshot_chainstate->m_chain.Tip())}; + BOOST_REQUIRE_EQUAL(WITH_LOCK(::cs_main, return chainman.m_best_header), tip); + // Undoing the tip once it changes the MN list needs the pre-base list. + { + LOCK(::cs_main); + CDeterministicMNListDiff diff; + diff.removedMns.emplace(0); + // The key mirrors DB_LIST_DIFF in evo/deterministicmns.cpp. + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + m_node.evodb->Write(std::make_pair(std::string{"dmn_D4"}, tip->GetBlockHash()), diff); + tx->Commit(); + } + ScopedDIP3FromGenesis dip3_active; + const auto check_unmoved{[&] { + LOCK(::cs_main); + BOOST_CHECK_EQUAL(snapshot_chainstate->m_chain.Tip(), tip); + BOOST_CHECK_EQUAL(chainman.m_best_header, tip); + }}; + + BlockValidationState state; + BOOST_CHECK(!snapshot_chainstate->InvalidateBlock(state, tip)); + BOOST_CHECK(IsLocalEvoStateError(state)); + check_unmoved(); + + const auto sibling{std::make_shared(MakeCbTxBlock(sibling_template, tip->nHeight))}; + chainman.ProcessNewBlock(sibling, /*force_processing=*/true, /*new_block=*/nullptr); + const CBlockIndex* sibling_index{WITH_LOCK(::cs_main, return chainman.m_blockman.LookupBlockIndex(sibling->GetHash()))}; + BOOST_REQUIRE(sibling_index); + snapshot_chainstate->EnforceBlock(state, sibling_index); + check_unmoved(); + SetMiscWarning(Untranslated("")); +} + +//! A historical block body this node does not have is local state, not +//! invalidity: a snapshot chainstate that has to rebuild the credit pool or the +//! EHF signals from it stalls, and the block it was connecting stays valid. +BOOST_FIXTURE_TEST_CASE(chainstatemanager_missing_body_stalls, SnapshotTestSetup) +{ + auto [background_chainstate, snapshot_chainstate] = this->SetupSnapshot(); + ChainstateManager& chainman = *Assert(m_node.chainman); + CBlockIndex* tip{WITH_LOCK(::cs_main, return snapshot_chainstate->m_chain.Tip())}; + const CBlock template_block{CreateBlock({}, GetScriptForRawPubKey(coinbaseKey.GetPubKey()), *snapshot_chainstate)}; + // Prune the tip's body. + const uint32_t tip_status{WITH_LOCK(::cs_main, return tip->nStatus)}; + { + LOCK(::cs_main); + chainman.m_blockman.m_have_pruned = true; + tip->nStatus &= ~(BLOCK_HAVE_DATA | BLOCK_HAVE_UNDO); + } + + // With V20 active, the next block needs the credit pool at the tip, which + // the snapshot view has to rebuild from the bodies. + ScopedDIP3FromGenesis dip3_active; + Consensus::Params& consensus{const_cast(Params().GetConsensus())}; + struct V20HeightRestore { + Consensus::Params& params; + const int height; + ~V20HeightRestore() { params.V20Height = height; } + } v20_restore{consensus, std::exchange(consensus.V20Height, 1)}; + const auto block{std::make_shared( + MakeCbTxBlock(template_block, tip->nHeight + 1, CCbTx::Version::CLSIG_AND_BALANCE))}; + // Connecting it announces the stall as a local error, which blames no peer. + const auto checked{std::make_shared(block->GetHash())}; + RegisterSharedValidationInterface(checked); + BOOST_CHECK(!chainman.ProcessNewBlock(block, /*force_processing=*/true, /*new_block=*/nullptr)); + UnregisterSharedValidationInterface(checked); + BOOST_REQUIRE(checked->state); + BOOST_CHECK(IsLocalEvoStateError(*checked->state)); + BlockValidationState state; + BOOST_CHECK(!snapshot_chainstate->ActivateBestChain(state, block)); + BOOST_CHECK(IsLocalEvoStateError(state)); + { + LOCK(::cs_main); + const CBlockIndex* index{chainman.m_blockman.LookupBlockIndex(block->GetHash())}; + BOOST_REQUIRE(index); + BOOST_CHECK_EQUAL(index->nStatus & BLOCK_FAILED_MASK, 0U); + BOOST_CHECK_EQUAL(snapshot_chainstate->m_chain.Tip(), tip); + } + + // Checking a child's EHF signals rebuilds those of the block it extends + // from the bodies, and stops on a pruned body the same way. + CBlockIndex* stalled{WITH_LOCK(::cs_main, return chainman.m_blockman.LookupBlockIndex(block->GetHash()))}; + CBlock child_template{template_block}; + child_template.hashPrevBlock = block->GetHash(); + CMutableTransaction child_coinbase{*child_template.vtx[0]}; + child_coinbase.vin[0].scriptSig = CScript{} << (tip->nHeight + 2) << CScriptNum{1}; + child_template.vtx[0] = MakeTransactionRef(std::move(child_coinbase)); + const auto child{std::make_shared( + MakeCbTxBlock(child_template, tip->nHeight + 2, CCbTx::Version::CLSIG_AND_BALANCE))}; + chainman.ProcessNewBlock(child, /*force_processing=*/true, /*new_block=*/nullptr); + const uint32_t stalled_status{WITH_LOCK(::cs_main, return stalled->nStatus)}; + { + LOCK(::cs_main); + stalled->nStatus &= ~BLOCK_HAVE_DATA; + const CBlockIndex* child_index{chainman.m_blockman.LookupBlockIndex(child->GetHash())}; + BOOST_REQUIRE(child_index); + const auto discard{m_node.evodb->BeginTransaction(EvoDbIdentity::NORMAL)}; + BlockValidationState mnhf_state; + BOOST_CHECK_THROW(m_node.special_tx->m_mnhfman->ProcessBlock(*child, child_index, /*fJustCheck=*/true, mnhf_state), + BlockDataUnavailableError); + } + { + LOCK(::cs_main); + stalled->nStatus = stalled_status; + tip->nStatus = tip_status; + chainman.m_blockman.m_have_pruned = false; + } + SetMiscWarning(Untranslated("")); } BOOST_FIXTURE_TEST_CASE(chainstatemanager_reconsider_block_candidates, SnapshotTestSetup) @@ -932,6 +1295,7 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_completion, SnapshotTestSetup WITH_LOCK(::cs_main, BOOST_CHECK(chainman.IsSnapshotValidated())); BOOST_CHECK(chainman.IsSnapshotActive()); + // Cache should have been rebalanced and reallocated to the "only" remaining // chainstate. BOOST_CHECK(active_cs.m_coinstip_cache_size_bytes > tip_cache_before_complete); @@ -995,6 +1359,39 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_completion, SnapshotTestSetup } } +//! What background validation derived reaches the snapshot chainstate only +//! when the next startup promotes it. Until then a block the snapshot +//! chainstate stalled on stays out of reach, and the node says so. +BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_completion_keeps_stall, SnapshotTestSetup) +{ + this->SetupSnapshot(); + ChainstateManager& chainman = *Assert(m_node.chainman); + Chainstate& snapshot_cs{chainman.ActiveChainstate()}; + // Stay outside every regtest LLMQ mining window, so the block needs no + // quorum commitment, only the pre-base MN list (DIP3 active at the base). + while (WITH_LOCK(::cs_main, return (snapshot_cs.m_chain.Height() + 1) % 24) < 21) { + mineBlocks(1); + } + const CBlockIndex* stall_parent{WITH_LOCK(::cs_main, return snapshot_cs.m_chain.Tip())}; + const CBlock stall_template{CreateBlock({}, GetScriptForRawPubKey(coinbaseKey.GetPubKey()), snapshot_cs)}; + const auto stalled{std::make_shared(MakeCbTxBlock(stall_template, stall_parent->nHeight + 1))}; + const auto stalls_on_it{[&] { + ScopedDIP3FromGenesis dip3_active; + BlockValidationState stall_state; + chainman.ProcessNewBlock(stalled, /*force_processing=*/true, /*new_block=*/nullptr); + return !snapshot_cs.ActivateBestChain(stall_state, stalled) && IsLocalEvoStateError(stall_state); + }}; + BOOST_REQUIRE(stalls_on_it()); + const std::string warning_at_stall{GetWarnings(/*verbose=*/true).original}; + + BOOST_REQUIRE_EQUAL(WITH_LOCK(::cs_main, return chainman.MaybeCompleteSnapshotValidation([](bilingual_str) {})), + SnapshotCompletionResult::SUCCESS); + BOOST_CHECK(stalls_on_it()); + BOOST_CHECK(GetWarnings(/*verbose=*/true).original != warning_at_stall); + BOOST_CHECK_EQUAL(WITH_LOCK(::cs_main, return snapshot_cs.m_chain.Tip()), stall_parent); + SetMiscWarning(Untranslated("")); +} + BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_completion_incorrect_base_mn_list, SnapshotTestSetup) { auto [validation_chainstate, snapshot_chainstate] = this->SetupSnapshot(); diff --git a/src/validation.cpp b/src/validation.cpp index 470dc215b5dc..bfd0713b6268 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -67,6 +67,7 @@ #include #include #include +#include #include #include #include @@ -424,7 +425,12 @@ void Chainstate::MaybeUpdateMempoolForReorg( DisconnectedBlockTransactions& disconnectpool, bool fAddToMempool) { - if (!m_mempool) return; + if (!m_mempool) { + // Only reachable with a non-empty pool when a snapshot rejected + // mid-step handed its mempool back to the background chainstate. + disconnectpool.clear(); + return; + } AssertLockHeld(cs_main); AssertLockHeld(m_mempool->cs); @@ -945,6 +951,8 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) } catch (const EvoDbInconsistencyError& e) { // Local EvoDB corruption (the node is already aborting): not a statement about the tx return state.Error(e.what()); + } catch (const evo::LocalStateError&) { + throw; } catch (const std::exception& e) { // Reconstruction failed locally (block read, inconsistent pool); the tx is not at // fault and the peer is not punished @@ -1740,7 +1748,17 @@ MempoolAcceptResult AcceptToMemoryPool(Chainstate& active_chainstate, const CTra std::vector coins_to_uncache; auto args = MemPoolAccept::ATMPArgs::SingleAccept(chainparams, accept_time, bypass_limits, coins_to_uncache, test_accept); - MempoolAcceptResult result = MemPoolAccept(pool, active_chainstate).AcceptSingleTransaction(tx, args); + MempoolAcceptResult result = [&]() EXCLUSIVE_LOCKS_REQUIRED(cs_main) { + try { + return MemPoolAccept(pool, active_chainstate).AcceptSingleTransaction(tx, args); + } catch (const evo::LocalStateError& e) { + // This node's Evo state cannot judge the transaction: an error, not + // invalidity, and the coins it loaded are dropped like any failure's. + TxValidationState state; + state.Error(strprintf("evo-local-unavailable: %s", e.what())); + return MempoolAcceptResult::Failure(state); + } + }(); if (result.m_result_type != MempoolAcceptResult::ResultType::VALID || test_accept) { if (result.m_result_type != MempoolAcceptResult::ResultType::VALID) { LogPrint(BCLog::MEMPOOL, "%s: %s %s (%s)\n", __func__, tx->GetHash().ToString(), result.m_state.GetRejectReason(), result.m_state.GetDebugMessage()); @@ -1771,17 +1789,23 @@ PackageMempoolAcceptResult ProcessNewPackage(Chainstate& active_chainstate, CTxM const CChainParams& chainparams = active_chainstate.m_chainman.GetParams(); auto result = [&]() EXCLUSIVE_LOCKS_REQUIRED(cs_main) { AssertLockHeld(cs_main); - if (test_accept) { - auto args = MemPoolAccept::ATMPArgs::PackageTestAccept(chainparams, GetTime(), coins_to_uncache); - return MemPoolAccept(pool, active_chainstate).AcceptMultipleTransactions(package, args); - } else { - auto args = MemPoolAccept::ATMPArgs::PackageChildWithParents(chainparams, GetTime(), coins_to_uncache); - return MemPoolAccept(pool, active_chainstate).AcceptPackage(package, args); + try { + if (test_accept) { + auto args = MemPoolAccept::ATMPArgs::PackageTestAccept(chainparams, GetTime(), coins_to_uncache); + return MemPoolAccept(pool, active_chainstate).AcceptMultipleTransactions(package, args); + } else { + auto args = MemPoolAccept::ATMPArgs::PackageChildWithParents(chainparams, GetTime(), coins_to_uncache); + return MemPoolAccept(pool, active_chainstate).AcceptPackage(package, args); + } + } catch (const evo::LocalStateError& e) { + PackageValidationState state; + state.Error(strprintf("evo-local-unavailable: %s", e.what())); + return PackageMempoolAcceptResult(state, {}); } }(); // Uncache coins pertaining to transactions that were not submitted to the mempool. - if (test_accept || result.m_state.IsInvalid()) { + if (test_accept || !result.m_state.IsValid()) { for (const COutPoint& hashTx : coins_to_uncache) { active_chainstate.CoinsTip().Uncache(hashTx); } @@ -3453,6 +3477,78 @@ void Chainstate::UpdateTip(const CBlockIndex* pindexNew) UpdateTipLog(coins_tip, pindexNew, params, m_evoDb, __func__, "", warning_messages.original); } +/** Classify an error about this node's own Evo state at a chainstate boundary. + * Unavailable data, binding faults and a mismatch that could not reject the + * snapshot stall the chainstate (IsLocalEvoStateError); a rejected snapshot + * is an ordinary error, as the node is shutting down. None of them marks the + * block invalid or blames a peer. */ +static bool LocalEvoStateError(ChainstateManager& chainman, const evo::LocalStateError& e, BlockValidationState& state) +{ + LogPrintf("ERROR: local Evo state error: %s\n", e.what()); + if (dynamic_cast(&e)) { + if (chainman.HandleSnapshotStateMismatch(e.what())) { + return state.Error("invalid assumeutxo evo snapshot state"); + } + return state.LocalEvoStateError(LocalEvoStateResult::MISMATCH, "evo-local-mismatch"); + } + if (dynamic_cast(&e)) { + return state.LocalEvoStateError(LocalEvoStateResult::BINDING, "evo-local-binding"); + } + return state.LocalEvoStateError(LocalEvoStateResult::UNAVAILABLE, "evo-local-unavailable"); +} + +void WarnLocalEvoStateUnavailable(const std::string& detail) +{ + SetMiscWarning(strprintf(_("Snapshot state unavailable: %s. See debug.log for details."), detail)); + uiInterface.NotifyAlertChanged(); +} + +const Chainstate::EvoStall* Chainstate::FindEvoStall(const CBlockIndex* pindex) const +{ + AssertLockHeld(::cs_main); + const auto it{m_evo_stalls.find(pindex)}; + return it != m_evo_stalls.end() ? &it->second : nullptr; +} + +bool Chainstate::RefuseStalledBlock(const CBlockIndex* pindex, BlockValidationState& state) const +{ + AssertLockHeld(::cs_main); + const EvoStall* stall{FindEvoStall(pindex)}; + if (!stall) return false; + state.LocalEvoStateError(stall->result, stall->reject_reason); + return true; +} + +const CBlockIndex* Chainstate::EvoStallObstructing(const CBlockIndex* candidate) const +{ + AssertLockHeld(::cs_main); + for (const auto& [block, _] : m_evo_stalls) { + if (!FindEvoStall(block)) continue; + const bool needs_stalled_block{candidate->GetAncestor(block->nHeight) == block}; + // A stalled block in the chain could not be disconnected; one outside + // it could not be connected. + if (m_chain.Contains(block) ? !needs_stalled_block : needs_stalled_block) return block; + } + return nullptr; +} + +void Chainstate::RecordEvoStall(const CBlockIndex* pindex, const BlockValidationState& state) +{ + AssertLockHeld(::cs_main); + if (!IsLocalEvoStateError(state)) return; + std::erase_if(m_evo_stalls, + [&](const auto& entry) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { return !FindEvoStall(entry.first); }); + m_evo_stalls.insert_or_assign(pindex, EvoStall{state.GetLocalEvoStateResult(), state.GetRejectReason()}); + if (m_evo_stalls.size() > MANY_EVO_STALLS && !std::exchange(m_many_evo_stalls_warned, true)) { + LogPrintf("WARNING: %s has stalled on more than %d blocks\n", ToString(), MANY_EVO_STALLS); + } + if (std::exchange(m_evo_stall_warned, true)) return; + const std::string detail{strprintf("%s stalled at block %s (height %d), %s", ToString(), + pindex->GetBlockHash().ToString(), pindex->nHeight, state.GetRejectReason())}; + LogPrintf("ERROR: %s; it will not retry this block before restart\n", detail); + WarnLocalEvoStateUnavailable(detail); +} + /** Disconnect m_chain's tip. * After calling, the mempool will be in an inconsistent state, with * transactions from disconnected blocks being added to disconnectpool. You @@ -3473,6 +3569,7 @@ bool Chainstate::DisconnectTip(BlockValidationState& state, DisconnectedBlockTra CBlockIndex *pindexDelete = m_chain.Tip(); assert(pindexDelete); assert(pindexDelete->pprev); + if (RefuseStalledBlock(pindexDelete, state)) return false; // Read block from disk. std::shared_ptr pblock = std::make_shared(); CBlock& block = *pblock; @@ -3490,7 +3587,7 @@ bool Chainstate::DisconnectTip(BlockValidationState& state, DisconnectedBlockTra !DeploymentActiveAt(*pindexDelete, m_chainman.GetConsensus(), Consensus::DEPLOYMENT_V19)}; // Apply the block atomically to the chain state. const auto time_start{SteadyClock::now()}; - { + try { auto dbTx = m_evoDb.BeginTransaction(EvoDbIdentity()); CCoinsViewCache view(&CoinsTip()); @@ -3502,6 +3599,10 @@ bool Chainstate::DisconnectTip(BlockValidationState& state, DisconnectedBlockTra bool flushed = view.Flush(); assert(flushed); dbTx->Commit(); + } catch (const evo::LocalStateError& e) { + LocalEvoStateError(m_chainman, e, state); + RecordEvoStall(pindexDelete, state); + return false; } LogPrint(BCLog::BENCHMARK, "- Disconnect block: %.2fms\n", Ticks(SteadyClock::now() - time_start)); @@ -3615,6 +3716,7 @@ bool Chainstate::ConnectTip(BlockValidationState& state, CBlockIndex* pindexNew, if (m_mempool) AssertLockHeld(m_mempool->cs); assert(pindexNew->pprev == m_chain.Tip()); + if (RefuseStalledBlock(pindexNew, state)) return false; // ConnectBlock commits its BLS-scheme switch on success, but the tip isn't // advanced until m_chain.SetTip() below. If a step in between (e.g. // FlushStateToDisk) fails, restore the scheme so it stays in sync with the @@ -3642,14 +3744,23 @@ bool Chainstate::ConnectTip(BlockValidationState& state, CBlockIndex* pindexNew, LogPrint(BCLog::BENCHMARK, " - Load block from disk: %.2fms\n", Ticks(time_2 - time_1)); { - auto dbTx = m_evoDb.BeginTransaction(EvoDbIdentity()); - + std::unique_ptr dbTx; CCoinsViewCache view(&CoinsTip()); - bool rv = ConnectBlock(blockConnecting, state, pindexNew, view); + bool rv{false}; + try { + dbTx = m_evoDb.BeginTransaction(EvoDbIdentity()); + rv = ConnectBlock(blockConnecting, state, pindexNew, view); + } catch (const evo::LocalStateError& e) { + // Roll back first: a snapshot mismatch can only reject the snapshot + // with no Evo transaction open. + dbTx.reset(); + rv = LocalEvoStateError(m_chainman, e, state); + } GetMainSignals().BlockChecked(blockConnecting, state); if (!rv) { if (state.IsInvalid()) InvalidBlockFound(pindexNew, state); + RecordEvoStall(pindexNew, state); LogError("%s: ConnectBlock %s failed, %s\n", __func__, pindexNew->GetBlockHash().ToString(), state.ToString()); return false; } @@ -3733,19 +3844,23 @@ bool Chainstate::ConnectTip(BlockValidationState& state, CBlockIndex* pindexNew, * Return the tip of the chain with the most work in it, that isn't * known to be invalid (it's however far from certain to be valid). */ -CBlockIndex* Chainstate::FindMostWorkChain() +CBlockIndex* Chainstate::FindMostWorkChain(const CBlockIndex** evo_stall_obstruction) { AssertLockHeld(::cs_main); do { CBlockIndex *pindexNew = nullptr; - // Find the best candidate header. - { - std::set::reverse_iterator it = setBlockIndexCandidates.rbegin(); - if (it == setBlockIndexCandidates.rend()) - return nullptr; - pindexNew = *it; + // Find the best candidate header. Candidates a local Evo stall keeps + // out of reach stay candidates for when the stall ends. + for (auto it{setBlockIndexCandidates.rbegin()}; it != setBlockIndexCandidates.rend(); ++it) { + const CBlockIndex* obstruction{EvoStallObstructing(*it)}; + if (obstruction == nullptr) { + pindexNew = *it; + break; + } + if (evo_stall_obstruction && *evo_stall_obstruction == nullptr) *evo_stall_obstruction = obstruction; } + if (pindexNew == nullptr) return nullptr; // Check whether all blocks on the path between the currently active chain and the candidate are valid. // Just going until the active chain is an optimization, as we know all blocks in it are valid already. @@ -3828,14 +3943,18 @@ bool Chainstate::ActivateBestChainStep(BlockValidationState& state, CBlockIndex* DisconnectedBlockTransactions disconnectpool{MAX_DISCONNECTED_TX_POOL_BYTES}; while (m_chain.Tip() && m_chain.Tip() != pindexFork) { if (!DisconnectTip(state, &disconnectpool)) { - // This is likely a fatal error, but keep the mempool consistent, - // just in case. Only remove from the mempool in this case. - MaybeUpdateMempoolForReorg(disconnectpool, false); - - // If we're unable to disconnect a block during normal operation, - // then that is a failure of our local system -- we should abort - // rather than stay on a less work chain. - AbortNode(state, "Failed to disconnect block; see debug.log for details"); + // A local Evo state stall leaves this chainstate running where it + // is, so the disconnected transactions go back to the mempool. + // Anything else is likely a fatal error, but keep the mempool + // consistent, just in case: only remove from it then. + MaybeUpdateMempoolForReorg(disconnectpool, IsLocalEvoStateError(state)); + + // Unless the chainstate stalled, we're unable to disconnect a block + // during normal operation, which is a failure of our local system + // -- we should abort rather than stay on a less work chain. + if (!IsLocalEvoStateError(state)) { + AbortNode(state, "Failed to disconnect block; see debug.log for details"); + } return false; } fBlocksDisconnected = true; @@ -3871,10 +3990,13 @@ bool Chainstate::ActivateBestChainStep(BlockValidationState& state, CBlockIndex* fContinue = false; break; } else { - // A system error occurred (disk space, database error, ...). + // A system error occurred (disk space, database error, ...), + // or this chainstate stalled on its own Evo state. // Make the mempool consistent with the current tip, just in case - // any observers try to use it before shutdown. - MaybeUpdateMempoolForReorg(disconnectpool, false); + // any observers try to use it before shutdown. A stalled + // chainstate keeps running, so it takes the disconnected + // transactions back. + MaybeUpdateMempoolForReorg(disconnectpool, IsLocalEvoStateError(state)); return false; } } else { @@ -3966,6 +4088,8 @@ bool Chainstate::ActivateBestChain(BlockValidationState& state, std::shared_ptr< CBlockIndex *pindexMostWork = nullptr; CBlockIndex *pindexNewTip = nullptr; + bool stalled{false}; + const CBlockIndex* evo_stall_obstruction{nullptr}; int nStopAtHeight = gArgs.GetIntArg("-stopatheight", DEFAULT_STOPATHEIGHT); do { // Block until the validation queue drains. This should largely @@ -3988,19 +4112,27 @@ bool Chainstate::ActivateBestChain(BlockValidationState& state, std::shared_ptr< ConnectTrace connectTrace; // Destructed before cs_main is unlocked if (pindexMostWork == nullptr) { - pindexMostWork = FindMostWorkChain(); + evo_stall_obstruction = nullptr; + pindexMostWork = FindMostWorkChain(&evo_stall_obstruction); } // Whether we have anything to do at all. if (pindexMostWork == nullptr || pindexMostWork == m_chain.Tip()) { + // Only a local Evo stall keeps a better candidate out of reach. + if (evo_stall_obstruction && RefuseStalledBlock(evo_stall_obstruction, state)) { + stalled = true; + } break; } bool fInvalidFound = false; std::shared_ptr nullBlockPtr; if (!ActivateBestChainStep(state, pindexMostWork, pblock && pblock->GetHash() == pindexMostWork->GetBlockHash() ? pblock : nullBlockPtr, fInvalidFound, connectTrace)) { - // A system error occurred - return false; + // A system error occurred, or this chainstate stalled on + // its own Evo state. A stall still announces the blocks + // this step connected before it stops. + if (!IsLocalEvoStateError(state)) return false; + stalled = true; } blocks_connected = true; @@ -4022,13 +4154,16 @@ bool Chainstate::ActivateBestChain(BlockValidationState& state, std::shared_ptr< // if at all, so we should catch it here. // // Break this do-while to ensure we don't advance past the base snapshot. - if (m_disabled) { + if (m_disabled || stalled) { break; } } while (!m_chain.Tip() || (starting_tip && CBlockIndexWorkComparator()(m_chain.Tip(), starting_tip))); - if (!blocks_connected) return true; + if (!blocks_connected) return !stalled; const CBlockIndex* pindexFork = m_chain.FindFork(starting_tip); + // A stall can leave the chain only rewound. Announce that the way + // InvalidateBlock does. + if (pindexFork == pindexNewTip && pindexNewTip != starting_tip) pindexFork = nullptr; bool fInitialDownload = IsInitialBlockDownload(); // Notify external listeners about the new tip. @@ -4042,6 +4177,7 @@ bool Chainstate::ActivateBestChain(BlockValidationState& state, std::shared_ptr< uiInterface.NotifyBlockTip(GetSynchronizationState(fInitialDownload), pindexNewTip); } } + if (stalled) return false; // When we reach this point, we switched to a new tip (stored in pindexNewTip). if (nStopAtHeight && pindexNewTip && pindexNewTip->nHeight >= nStopAtHeight) StartShutdown(); @@ -4114,6 +4250,7 @@ bool Chainstate::InvalidateBlock(BlockValidationState& state, CBlockIndex* pinde CBlockIndex* to_mark_failed = pindex; bool pindex_was_in_chain = false; + bool stalled{false}; int disconnected = 0; // We do not allow ActivateBestChain() to run while InvalidateBlock() is @@ -4164,6 +4301,8 @@ bool Chainstate::InvalidateBlock(BlockValidationState& state, CBlockIndex* pinde pindex_was_in_chain = true; CBlockIndex *invalid_walk_tip = m_chain.Tip(); const CBlockIndex* pindexOldTip = m_chain.Tip(); + CBlockIndex* const best_header{m_chainman.m_best_header}; + CBlockIndex* const best_invalid{m_chainman.m_best_invalid}; if (pindex == m_chainman.m_best_header) { m_chainman.m_best_invalid = m_chainman.m_best_header; @@ -4180,7 +4319,16 @@ bool Chainstate::InvalidateBlock(BlockValidationState& state, CBlockIndex* pinde // and we're not doing a very deep invalidation (in which case // keeping the mempool up to date is probably futile anyway). MaybeUpdateMempoolForReorg(disconnectpool, /* fAddToMempool = */ (++disconnected <= 10) && ret); - if (!ret) return false; + if (!ret) { + if (!IsLocalEvoStateError(state)) return false; + // A stalled chainstate stays where it got to: pindex is still in + // the chain, and the blocks already disconnected are finished and + // announced below. + m_chainman.m_best_header = best_header; + m_chainman.m_best_invalid = best_invalid; + stalled = true; + break; + } assert(invalid_walk_tip->pprev == m_chain.Tip()); if (pindexOldTip == m_chainman.m_best_header) { @@ -4259,7 +4407,7 @@ bool Chainstate::InvalidateBlock(BlockValidationState& state, CBlockIndex* pinde if (pindex_was_in_chain) { uiInterface.NotifyBlockTip(GetSynchronizationState(IsInitialBlockDownload()), to_mark_failed->pprev); } - return true; + return !stalled; } void Chainstate::EnforceBlock(BlockValidationState& state, const CBlockIndex *pindex) @@ -4280,6 +4428,9 @@ void Chainstate::EnforceBlock(BlockValidationState& state, const CBlockIndex *pi } if (!MarkConflictingBlock(state, jt->second)) { LogPrintf("Chainstate::%s -- MarkConflictingBlock failed: %s\n", __func__, state.ToString()); + // A stall leaves the chainstate where it is; the next ChainLock + // enforcement tries again. + if (IsLocalEvoStateError(state)) return; // This should not have happened and we are in a state were it's not safe to continue anymore assert(false); } @@ -4301,7 +4452,9 @@ bool Chainstate::MarkConflictingBlock(BlockValidationState& state, CBlockIndex * // We first disconnect backwards and then mark the blocks as conflicting. bool pindex_was_in_chain = false; + bool stalled{false}; CBlockIndex *conflicting_walk_tip = m_chain.Tip(); + CBlockIndex* const best_header{m_chainman.m_best_header}; if (pindex == m_chainman.m_best_header) { m_chainman.m_best_header = m_chainman.m_best_header->pprev; @@ -4316,6 +4469,16 @@ bool Chainstate::MarkConflictingBlock(BlockValidationState& state, CBlockIndex * // ActivateBestChain considers blocks already in m_chain // unconditionally valid already, so force disconnect away from it. if (!DisconnectTip(state, &disconnectpool)) { + // A stalled chainstate stays where it got to: the blocks already + // disconnected are marked and announced below. + if (IsLocalEvoStateError(state)) { + if (pindexOldTip != conflicting_walk_tip) { + stalled = true; + break; + } + // Nothing moved: pindex is still the tip. + m_chainman.m_best_header = best_header; + } // It's probably hopeless to try to make the mempool consistent // here if DisconnectTip failed, but we can try. MaybeUpdateMempoolForReorg(disconnectpool, false); @@ -4328,15 +4491,17 @@ bool Chainstate::MarkConflictingBlock(BlockValidationState& state, CBlockIndex * // Now mark the blocks we just disconnected as descendants conflicting // (note this may not be all descendants). - while (pindex_was_in_chain && conflicting_walk_tip != pindex) { + while (pindex_was_in_chain && conflicting_walk_tip != pindex && conflicting_walk_tip != m_chain.Tip()) { conflicting_walk_tip->nStatus |= BLOCK_CONFLICT_CHAINLOCK; setBlockIndexCandidates.erase(conflicting_walk_tip); conflicting_walk_tip = conflicting_walk_tip->pprev; } - // Mark the block itself as conflicting. - pindex->nStatus |= BLOCK_CONFLICT_CHAINLOCK; - setBlockIndexCandidates.erase(pindex); + if (!stalled) { + // Mark the block itself as conflicting. + pindex->nStatus |= BLOCK_CONFLICT_CHAINLOCK; + setBlockIndexCandidates.erase(pindex); + } // DisconnectTip will add transactions to disconnectpool; try to add these // back to the mempool. @@ -4353,7 +4518,7 @@ bool Chainstate::MarkConflictingBlock(BlockValidationState& state, CBlockIndex * it++; } - ConflictingChainFound(pindex); + if (!stalled) ConflictingChainFound(pindex); if (this == &m_chainman.ActiveChainstate()) { GetMainSignals().SynchronousUpdatedBlockTip(m_chain.Tip(), nullptr, IsInitialBlockDownload()); GetMainSignals().UpdatedBlockTip(m_chain.Tip(), nullptr, IsInitialBlockDownload()); @@ -4361,9 +4526,9 @@ bool Chainstate::MarkConflictingBlock(BlockValidationState& state, CBlockIndex * // Only notify about a new block tip if the active chain was modified. if (pindex_was_in_chain) { - uiInterface.NotifyBlockTip(GetSynchronizationState(IsInitialBlockDownload()), pindex->pprev); + uiInterface.NotifyBlockTip(GetSynchronizationState(IsInitialBlockDownload()), m_chain.Tip()); } - return true; + return !stalled; } void Chainstate::ResetBlockFailureFlags(CBlockIndex *pindex, bool ignore_chainlocks) { @@ -5146,7 +5311,7 @@ bool TestBlockValidity(BlockValidationState& state, CBlockIndex* pindexPrev, bool fCheckPOW, bool fCheckMerkleRoot) -{ +try { AssertLockHeld(cs_main); assert(pindexPrev && pindexPrev == chainstate.m_chain.Tip()); @@ -5188,6 +5353,8 @@ bool TestBlockValidity(BlockValidationState& state, assert(state.IsValid()); return true; +} catch (const evo::LocalStateError& e) { + return LocalEvoStateError(chainstate.m_chainman, e, state); } /* This function is called from the RPC code for pruneblockchain */ @@ -5744,7 +5911,7 @@ void ChainstateManager::LoadExternalBlockFile( bool genesis_activation_failure = false; for (auto c : GetAll()) { BlockValidationState state; - if (!c->ActivateBestChain(state, nullptr)) { + if (!c->ActivateBestChain(state, nullptr) && !IsLocalEvoStateError(state)) { genesis_activation_failure = true; break; } @@ -5767,6 +5934,8 @@ void ChainstateManager::LoadExternalBlockFile( BlockValidationState state; if (!c->ActivateBestChain(state, pblock)) { LogPrint(BCLog::REINDEX, "failed to activate chain (%s)\n", state.ToString()); + // A stalled chainstate must not stop the import for the others. + if (IsLocalEvoStateError(state)) continue; activation_failure = true; break; } @@ -6652,6 +6821,67 @@ bool ChainstateManager::PopulateAndValidateSnapshot( return true; } +bool ChainstateManager::HandleSnapshotStateMismatch(const std::string& reason, + std::function shutdown_fnc) +{ + LOCK(::cs_main); + if (!m_snapshot_chainstate || m_active_chainstate != m_snapshot_chainstate.get() || + !IsUsable(m_snapshot_chainstate.get()) || !IsUsable(m_ibd_chainstate.get()) || + m_snapshot_chainstate->m_evoDb.HasActiveTransaction()) { + return false; + } + + const int snapshot_tip_height{m_snapshot_chainstate->m_chain.Height()}; + const int snapshot_base_height{GetSnapshotBaseHeight().value_or(snapshot_tip_height)}; + bilingual_str user_error = + strprintf(_("%s failed to validate the -assumeutxo snapshot state. " + "This indicates a hardware problem, or a bug in the software, or a " + "bad software modification that allowed an invalid snapshot to be " + "loaded. As a result of this, the node will shut down and stop using any " + "state that was built on the snapshot, resetting the chain height " + "from %d to %d. On the next " + "restart, the node will resume syncing from %d " + "without using any snapshot data. " + "Please report this incident to %s, including how you obtained the snapshot. " + "The invalid snapshot chainstate will be left on disk in case it is " + "helpful in diagnosing the issue that caused this error."), + PACKAGE_NAME, snapshot_tip_height, snapshot_base_height, snapshot_base_height, PACKAGE_BUGREPORT); + + LogPrintf("[snapshot] evo state mismatch: %s\n", reason); + LogPrintf("[snapshot] !!! %s\n", user_error.original); + LogPrintf("[snapshot] deleting snapshot, reverting to validated chain, and stopping node\n"); + + // DiscardSnapshotMarkers needs both identities' transaction trees fully + // committed; a rejection raised mid-connection has not flushed them yet. + m_ibd_chainstate->ForceFlushStateToDisk(); + m_snapshot_chainstate->ForceFlushStateToDisk(); + const bool evodb_synced{m_ibd_chainstate->m_evoDb.CommitRootTransaction(EvoDbIdentity::NORMAL, /*sync=*/true) && + m_snapshot_chainstate->m_evoDb.CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true)}; + if (!evodb_synced) { + user_error += Untranslated("\nFailed to sync EvoDB before invalidating the snapshot."); + } + + m_active_chainstate = m_ibd_chainstate.get(); + // Hand the mempool back so the again-active background chainstate owns + // it for the remainder of this (shutting-down) run. + m_ibd_chainstate->m_mempool = m_snapshot_chainstate->m_mempool; + m_snapshot_chainstate->m_mempool = nullptr; + m_snapshot_chainstate->m_disabled = true; + assert(!IsUsable(m_snapshot_chainstate.get())); + assert(IsUsable(m_ibd_chainstate.get())); + + auto rename_result = m_snapshot_chainstate->InvalidateCoinsDBOnDisk(); + if (!rename_result) { + user_error += Untranslated("\n") + util::ErrorString(rename_result); + } else if (!evodb_synced || !m_ibd_chainstate->m_evoDb.DiscardSnapshotMarkers()) { + // Startup recovery completes the discard for the renamed directory. + LogPrintf("[snapshot] failed to remove invalid snapshot EvoDB markers\n"); + } + + shutdown_fnc(user_error); + return true; +} + // Currently, this function holds cs_main for its duration, which could be for // multiple minutes due to the ComputeUTXOStats call. This hold is necessary // because we need to avoid advancing the background validation chainstate @@ -6688,7 +6918,6 @@ SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( LogPrintf("[snapshot] on-disk snapshot base block is missing from the block index\n"); return SnapshotCompletionResult::BASE_BLOCKHASH_MISMATCH; } - const int snapshot_tip_height = this->ActiveHeight(); const int snapshot_base_height = *snapshot_base_height_opt; const CBlockIndex& index_new = *Assert(m_ibd_chainstate->m_chain.Tip()); @@ -6716,41 +6945,8 @@ SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( } auto handle_invalid_snapshot = [&]() EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { - bilingual_str user_error = strprintf(_( - "%s failed to validate the -assumeutxo snapshot state. " - "This indicates a hardware problem, or a bug in the software, or a " - "bad software modification that allowed an invalid snapshot to be " - "loaded. As a result of this, the node will shut down and stop using any " - "state that was built on the snapshot, resetting the chain height " - "from %d to %d. On the next " - "restart, the node will resume syncing from %d " - "without using any snapshot data. " - "Please report this incident to %s, including how you obtained the snapshot. " - "The invalid snapshot chainstate will be left on disk in case it is " - "helpful in diagnosing the issue that caused this error."), - PACKAGE_NAME, snapshot_tip_height, snapshot_base_height, snapshot_base_height, PACKAGE_BUGREPORT - ); - - LogPrintf("[snapshot] !!! %s\n", user_error.original); - LogPrintf("[snapshot] deleting snapshot, reverting to validated chain, and stopping node\n"); - - m_active_chainstate = m_ibd_chainstate.get(); - // Hand the mempool back so the again-active background chainstate owns - // it for the remainder of this (shutting-down) run. - m_ibd_chainstate->m_mempool = m_snapshot_chainstate->m_mempool; - m_snapshot_chainstate->m_mempool = nullptr; - m_snapshot_chainstate->m_disabled = true; - assert(!this->IsUsable(m_snapshot_chainstate.get())); - assert(this->IsUsable(m_ibd_chainstate.get())); - - auto rename_result = m_snapshot_chainstate->InvalidateCoinsDBOnDisk(); - if (!rename_result) { - user_error += Untranslated("\n") + util::ErrorString(rename_result); - } else if (!m_ibd_chainstate->m_evoDb.DiscardSnapshotMarkers()) { - LogPrintf("[snapshot] failed to remove invalid snapshot EvoDB markers\n"); - } - - shutdown_fnc(user_error); + const bool handled{HandleSnapshotStateMismatch("snapshot completion mismatch", shutdown_fnc)}; + Assume(handled); }; if (index_new.GetBlockHash() != snapshot_blockhash) { @@ -6858,6 +7054,16 @@ SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation( snapshot_blockhash.ToString()); m_ibd_chainstate->m_disabled = true; + // What background validation derived reaches the snapshot chainstate only + // when the next startup promotes it: until then the snapshot chainstate + // reads its own generation, and a block it stalled on stays out of reach. + if (std::ranges::any_of(m_snapshot_chainstate->m_evo_stalls, [&](const auto& stall) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + return m_snapshot_chainstate->FindEvoStall(stall.first) != nullptr; + })) { + const std::string detail{"snapshot validation completed; restart to resume from the validated chain"}; + LogPrintf("WARNING: %s\n", detail); + WarnLocalEvoStateUnavailable(detail); + } this->MaybeRebalanceCaches(); return SnapshotCompletionResult::SUCCESS; diff --git a/src/validation.h b/src/validation.h index fb672d7621ac..0697af71a100 100644 --- a/src/validation.h +++ b/src/validation.h @@ -385,6 +385,19 @@ bool TestBlockValidity(BlockValidationState& state, bool fCheckPOW = true, bool fCheckMerkleRoot = true) EXCLUSIVE_LOCKS_REQUIRED(cs_main); +/** Whether `state` reports that a chainstate stalled on this node's own Evo + * state. The block is not known to be invalid and storage is not known to be + * broken, so callers must neither abort nor treat the block as bad; the + * chainstate stays where it is until its Evo state changes. */ +inline bool IsLocalEvoStateError(const BlockValidationState& state) +{ + return state.IsError() && state.GetLocalEvoStateResult() != LocalEvoStateResult::NONE; +} + +/** Tell the user, through the misc warning, that some snapshot state cannot be + * used yet. `detail` says what is affected. */ +void WarnLocalEvoStateUnavailable(const std::string& detail); + enum class VerifyDBResult { SUCCESS, CORRUPTED_BLOCK_DB, @@ -524,6 +537,23 @@ class Chainstate //! Cached result of LookupBlockIndex(*m_from_snapshot_blockhash) const CBlockIndex* m_cached_snapshot_base GUARDED_BY(::cs_main) {nullptr}; + //! The blocks this chainstate stalled on because of its own Evo state (see + //! IsLocalEvoStateError). Trying one again cannot succeed until that state + //! changes, so ConnectTip and DisconnectTip refuse it at once instead of + //! revalidating it, and candidate selection passes over the branches that + //! need it. Cleared on restart; snapshot completion keeps them and asks + //! for a restart. + struct EvoStall { + LocalEvoStateResult result; + std::string reject_reason; + }; + std::map m_evo_stalls GUARDED_BY(::cs_main); + //! Every stalled block is in the block index, so the stalls are bounded + //! like the block candidates; this many only earns a warning. + static constexpr size_t MANY_EVO_STALLS{32}; + bool m_evo_stall_warned GUARDED_BY(::cs_main){false}; + bool m_many_evo_stalls_warned GUARDED_BY(::cs_main){false}; + public: //! Reference to a BlockManager instance which itself is shared across all //! Chainstate instances. @@ -788,9 +818,22 @@ class Chainstate bool ActivateBestChainStep(BlockValidationState& state, CBlockIndex* pindexMostWork, const std::shared_ptr& pblock, bool& fInvalidFound, ConnectTrace& connectTrace) EXCLUSIVE_LOCKS_REQUIRED(cs_main, m_mempool->cs); bool ConnectTip(BlockValidationState& state, CBlockIndex* pindexNew, const std::shared_ptr& pblock, ConnectTrace& connectTrace, DisconnectedBlockTransactions& disconnectpool) EXCLUSIVE_LOCKS_REQUIRED(cs_main, m_mempool->cs); + //! The stall recorded for `pindex`, unless it no longer applies. + const EvoStall* FindEvoStall(const CBlockIndex* pindex) const EXCLUSIVE_LOCKS_REQUIRED(cs_main); + //! Fail with the recorded stall if this chainstate stalled on `pindex`. + bool RefuseStalledBlock(const CBlockIndex* pindex, BlockValidationState& state) const + EXCLUSIVE_LOCKS_REQUIRED(cs_main); + //! A block this chainstate stalled on that reaching `candidate` needs + //! connected, or disconnected; nullptr if there is none. + const CBlockIndex* EvoStallObstructing(const CBlockIndex* candidate) const EXCLUSIVE_LOCKS_REQUIRED(cs_main); + //! Remember a local Evo state stall at `pindex`, warning about the first one. + void RecordEvoStall(const CBlockIndex* pindex, const BlockValidationState& state) EXCLUSIVE_LOCKS_REQUIRED(cs_main); void InvalidBlockFound(CBlockIndex* pindex, const BlockValidationState& state) EXCLUSIVE_LOCKS_REQUIRED(cs_main); - CBlockIndex* FindMostWorkChain() EXCLUSIVE_LOCKS_REQUIRED(cs_main); + //! The best candidate no local Evo stall obstructs. Sets + //! `*evo_stall_obstruction` to the stalled block in the way of the first + //! better candidate it passed over. + CBlockIndex* FindMostWorkChain(const CBlockIndex** evo_stall_obstruction = nullptr) EXCLUSIVE_LOCKS_REQUIRED(cs_main); bool RollforwardBlock(const CBlockIndex* pindex, CCoinsViewCache& inputs) EXCLUSIVE_LOCKS_REQUIRED(cs_main); @@ -1098,6 +1141,13 @@ class ChainstateManager [](bilingual_str msg) { AbortNode(msg.original, msg); }) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + /** Reject the active assumeutxo snapshot chainstate, revert to the + * background chainstate and shut down. Returns false when no usable + * snapshot is active or an EvoDB transaction must unwind first. */ + bool HandleSnapshotStateMismatch( + const std::string& reason, + std::function shutdown_fnc = [](bilingual_str msg) { AbortNode(msg.original, msg); }); + //! The most-work chain. Chainstate& ActiveChainstate() const; CChain& ActiveChain() const EXCLUSIVE_LOCKS_REQUIRED(GetMutex()) { return ActiveChainstate().m_chain; } From 4158f5b904499199601a29b758d2e7f6a610adea Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 15 Oct 2026 00:36:08 -0500 Subject: [PATCH 02/16] refactor(evo): add EvoDB view tokens and scopes An EvoViewToken names the Evo state one operation works on: an EvoDB, an identity, a generation and the snapshot base. EvoViewScope binds the calling thread's accesses on that EvoDB to a token until it goes out of scope; scopes nest and restore the enclosing binding on exit. A transaction the same thread holds open on the EvoDB also binds it, and a scope and a transaction must agree: a conflict in either order raises EvoViewBindingError. CEvoDB::CurrentView() resolves the open transaction, then the innermost scope, then the default identity. Nothing binds scopes yet, so behavior is unchanged: unbound access still resolves to the default identity. Co-Authored-By: Claude Opus 5.5 --- src/evo/evodb.cpp | 60 ++++++++++++++++++++++++++++++++++++--- src/evo/evodb.h | 55 +++++++++++++++++++++++++++++++++-- src/test/evo_db_tests.cpp | 54 +++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 7 deletions(-) diff --git a/src/evo/evodb.cpp b/src/evo/evodb.cpp index 84fc87902d02..3345c149940e 100644 --- a/src/evo/evodb.cpp +++ b/src/evo/evodb.cpp @@ -5,6 +5,7 @@ #include #include +#include #include @@ -58,12 +59,60 @@ 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; +} + +EvoViewToken CEvoDB::MakeView(EvoDbIdentity identity) const { return EvoViewToken{.db = this, .identity = identity}; } + +EvoViewToken CEvoDB::ResolveView() const +{ + if (HoldsTransaction()) return MakeView(*active_transaction); + if (const EvoViewToken * scoped{EvoViewScope::Innermost(*this)}) return *scoped; + return MakeView(m_default_identity); +} + +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,6 +120,9 @@ 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"); + } active_transaction = identity; active_transaction_thread = std::this_thread::get_id(); GetContext(identity); diff --git a/src/evo/evodb.h b/src/evo/evodb.h index f9485eb09b89..e59c09722b12 100644 --- a/src/evo/evodb.h +++ b/src/evo/evodb.h @@ -6,16 +6,18 @@ #define BITCOIN_EVO_EVODB_H #include +#include #include #include +#include #include +#include #include #include #include #include -class uint256; namespace util { struct DbWrapperParams; } // namespace util @@ -50,6 +52,38 @@ 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{}; + + bool operator==(const EvoViewToken&) const = default; +}; + +/** 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); + +private: + const EvoViewToken m_token; + const EvoViewScope* const m_outer; +}; + class CEvoDBScopedCommitter { private: @@ -105,7 +139,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 +164,18 @@ 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(); } + //! The view this thread's Evo accesses resolve to: its open transaction, + //! else its innermost EvoViewScope, else the default identity. + EvoViewToken CurrentView() const EXCLUSIVE_LOCKS_REQUIRED(!cs); + //! The current view of a chainstate with the given identity. + EvoViewToken ViewFor(EvoDbIdentity identity) 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 diff --git a/src/test/evo_db_tests.cpp b/src/test/evo_db_tests.cpp index 87cf6a3b4db0..6cf14563e5af 100644 --- a/src/test/evo_db_tests.cpp +++ b/src/test/evo_db_tests.cpp @@ -237,6 +237,60 @@ BOOST_AUTO_TEST_CASE(open_transaction_does_not_capture_other_threads) BOOST_CHECK(other_thread == PayloadFor(30)); } +BOOST_AUTO_TEST_CASE(view_scopes_nest_and_bind_transactions) +{ + LOCK(::cs_main); + CEvoDB db{util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evodb_view_scope", .memory = true, .wipe = true}}; + const EvoViewToken normal{db.ViewFor(EvoDbIdentity::NORMAL)}; + const EvoViewToken snapshot{db.ViewFor(EvoDbIdentity::SNAPSHOT)}; + WritePayload(db, EvoDbIdentity::SNAPSHOT, 50); + + // Unbound access keeps resolving to the default identity. + BOOST_CHECK(db.CurrentView() == normal); + Payload value; + { + EvoViewScope outer{snapshot}; + BOOST_CHECK(db.CurrentView() == snapshot); + BOOST_CHECK(db.Read(PayloadKey(50), value)); + { + EvoViewScope inner{normal}; + BOOST_CHECK(db.CurrentView() == normal); + BOOST_CHECK(!db.Read(PayloadKey(50), value)); + // A transaction matching the innermost scope is accepted and + // names the same view. + auto tx = db.BeginTransaction(EvoDbIdentity::NORMAL); + BOOST_CHECK(db.CurrentView() == normal); + tx->Commit(); + } + // Leaving the inner scope restores the outer binding. + BOOST_CHECK(db.CurrentView() == snapshot); + BOOST_CHECK(db.Read(PayloadKey(50), value)); + + // A transaction for the other identity conflicts with the scope. + BOOST_CHECK_THROW(db.BeginTransaction(EvoDbIdentity::NORMAL), EvoViewBindingError); + BOOST_CHECK(!db.HasActiveTransaction()); + } + BOOST_CHECK(db.CurrentView() == normal); + + // A scope opened inside a conflicting transaction is refused, and the + // binding in force is left untouched. + auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); + BOOST_CHECK_THROW(EvoViewScope{normal}, EvoViewBindingError); + BOOST_CHECK(db.CurrentView() == snapshot); + { + EvoViewScope agreeing{snapshot}; + BOOST_CHECK(db.CurrentView() == snapshot); + } + tx->Commit(); + + // A scope binds only the CEvoDB its token names. + CEvoDB other{ + util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evodb_view_scope_other", .memory = true, .wipe = true}}; + EvoViewScope other_scope{other.ViewFor(EvoDbIdentity::SNAPSHOT)}; + BOOST_CHECK(db.CurrentView() == normal); + BOOST_CHECK(other.CurrentView() == other.ViewFor(EvoDbIdentity::SNAPSHOT)); +} + BOOST_AUTO_TEST_CASE(snapshot_markers_can_be_discarded) { LOCK(::cs_main); From ca6a1b99d7944e39ba721618b296cfca80b4bb9c Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 15 Oct 2026 00:58:33 -0500 Subject: [PATCH 03/16] feat(evo): store snapshot-identity state in a generation namespace with journaled promotion The snapshot chainstate shared the NORMAL keyspace. Its records above the snapshot base (MN lists, mined commitments, credit pools) stayed readable by NORMAL after the snapshot was rejected, so the background chainstate could consume them instead of deriving its own state, or abort on a WriteDerived mismatch. Every key the SNAPSHOT identity writes is now stored as ("S", generation, key), and its reads and iterators see only that namespace: there is no NORMAL fallback, and NORMAL iterators step over all generations. WriteDerived therefore compares only within its own view. CEvoDB gains WriteDurable for view-aware immediate writes; StoreSnapshotForBlock uses it, and under the snapshot view at or below the base it only compares a carried quarter and neither persists nor caches an uncarried one. The remaining raw-DB users (DMN migration and repair, quorum cleanup) are marked NORMAL-only lifecycle operations. Generations carry an unprefixed lifecycle record (unpublished, published, rejected, promoting; base; tip; local schema version) and the EvoSnapshotConsensusConfig fingerprint of the consensus and deployment configuration they were published under. Startup collects unpublished and rejected generations, refuses a published one whose fingerprint no longer matches, and refuses dual-chainstate markers that predate isolation. Rejection marks the generation rejected in the same synced batch that discards the markers. The rejected record stays until the snapshot's coins directory has been invalidated, and only then is the generation collected, so a crash in between still finishes the invalidation at startup. Promotion writes a journal before the first coins-directory rename, merges the generation into NORMAL keys with a resumable cursor (overlapping immutable records must be byte-identical), collects it, and publishes the final markers atomically with removing the journal. Startup finishes a journaled promotion; without a journal, a completed coins swap can only be an interrupted invalid-snapshot cleanup and is discarded. Co-Authored-By: Claude Opus 5.5 --- src/Makefile.am | 3 + src/evo/deterministicmns.cpp | 6 + src/evo/evodb.cpp | 468 ++++++++++++++++-- src/evo/evodb.h | 348 ++++++++++--- src/evo/snapshot_config.cpp | 40 ++ src/evo/snapshot_config.h | 86 ++++ src/llmq/quorumsman.cpp | 2 + src/llmq/snapshot.cpp | 33 +- src/llmq/snapshot.h | 3 + src/llmq/utils.cpp | 9 + src/node/chainstate.cpp | 87 +++- src/test/evo_db_tests.cpp | 269 ++++++++-- .../validation_chainstatemanager_tests.cpp | 330 +++++++++++- src/validation.cpp | 73 ++- 14 files changed, 1535 insertions(+), 222 deletions(-) create mode 100644 src/evo/snapshot_config.cpp create mode 100644 src/evo/snapshot_config.h diff --git a/src/Makefile.am b/src/Makefile.am index c0ff282ec7e0..d8c0ec68432c 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -244,6 +244,7 @@ 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 \ @@ -571,6 +572,7 @@ libbitcoin_node_a_SOURCES = \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ evo/snapshot.cpp \ + evo/snapshot_config.cpp \ evo/specialtx.cpp \ evo/specialtx_filter.cpp \ evo/specialtxman.cpp \ @@ -1300,6 +1302,7 @@ libdashkernel_la_SOURCES = \ evo/providertx_util.cpp \ evo/simplifiedmns.cpp \ evo/smldiff.cpp \ + evo/snapshot_config.cpp \ evo/specialtx.cpp \ evo/specialtx_filter.cpp \ evo/specialtxman.cpp \ diff --git a/src/evo/deterministicmns.cpp b/src/evo/deterministicmns.cpp index c862e4be1807..961b91f114b6 100644 --- a/src/evo/deterministicmns.cpp +++ b/src/evo/deterministicmns.cpp @@ -1024,6 +1024,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); @@ -1045,6 +1047,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 @@ -1492,6 +1496,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; diff --git a/src/evo/evodb.cpp b/src/evo/evodb.cpp index 3345c149940e..cadf88827395 100644 --- a/src/evo/evodb.cpp +++ b/src/evo/evodb.cpp @@ -4,9 +4,14 @@ #include +#include +#include #include #include +#include +#include +#include #include CEvoDBScopedCommitter::CEvoDBScopedCommitter(CEvoDB& _evoDB, EvoDbIdentity identity) : @@ -35,10 +40,54 @@ 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{})); } CEvoDB::~CEvoDB() = default; @@ -47,9 +96,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; } @@ -86,7 +140,17 @@ const EvoViewToken* EvoViewScope::Innermost(const CEvoDB& db) return nullptr; } -EvoViewToken CEvoDB::MakeView(EvoDbIdentity identity) const { return EvoViewToken{.db = this, .identity = identity}; } +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 { @@ -123,9 +187,9 @@ std::unique_ptr CEvoDB::BeginTransaction(EvoDbIdentity id 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); } @@ -143,7 +207,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(); } @@ -162,6 +230,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); @@ -190,7 +259,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() @@ -199,19 +269,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) @@ -238,59 +299,374 @@ 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); + 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; } - if (snapshot_tip != expected_snapshot_tip) 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) +{ + 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 (!db->WriteBatch(batch, /*fSync=*/true)) return false; + m_snapshot_generation = record; + return true; +} + +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 (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_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); 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; + 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}; - batch.Erase(std::make_pair(EVODB_BEST_BLOCK, uint8_t{1})); + 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.Write(EVODB_BEST_BLOCK, journal.tip); batch.Erase(EVODB_SNAPSHOT_MNLIST_HASH); batch.Erase(EVODB_BACKGROUND_MNLIST_HASH); 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; return true; } diff --git a/src/evo/evodb.h b/src/evo/evodb.h index e59c09722b12..63612f0420a7 100644 --- a/src/evo/evodb.h +++ b/src/evo/evodb.h @@ -8,8 +8,11 @@ #include #include #include +#include +#include #include #include +#include #include #include @@ -34,6 +37,17 @@ 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"; +// 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, @@ -59,10 +73,169 @@ struct EvoViewToken { 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 @@ -106,17 +279,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} { } @@ -197,62 +374,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) { @@ -267,6 +425,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; @@ -299,29 +460,79 @@ 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 + * and the consensus-configuration fingerprint it is valid for. */ + bool PublishSnapshotGeneration(const uint256& config_fingerprint, const std::optional& base_mn_list_hash) + 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); } @@ -331,6 +542,29 @@ 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); + //! 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); + + //! 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}; }; #endif // BITCOIN_EVO_EVODB_H 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/llmq/quorumsman.cpp b/src/llmq/quorumsman.cpp index 6a0f13d67b52..dd0d7abd6705 100644 --- a/src/llmq/quorumsman.cpp +++ b/src/llmq/quorumsman.cpp @@ -655,6 +655,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/snapshot.cpp b/src/llmq/snapshot.cpp index bde0d32fb783..e7aeb59668ec 100644 --- a/src/llmq/snapshot.cpp +++ b/src/llmq/snapshot.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -58,6 +59,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 +80,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, @@ -320,11 +332,26 @@ 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); + 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.insert(snapshotHash, snapshot); } } // namespace llmq diff --git a/src/llmq/snapshot.h b/src/llmq/snapshot.h index 725072ad84e6..12bafc2bf9a8 100644 --- a/src/llmq/snapshot.h +++ b/src/llmq/snapshot.h @@ -248,6 +248,9 @@ class CQuorumSnapshotManager 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; }; } // namespace llmq diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 195a050375a1..2b4c39110194 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -497,6 +497,15 @@ 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 the input as + // unavailable instead. + if (util_params.m_qsnapman.IsCarriedOnly(prev_cycles[idx]->m_cycle_index)) { + 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); diff --git a/src/node/chainstate.cpp b/src/node/chainstate.cpp index 6d8e9d042c01..d410ab2b384d 100644 --- a/src/node/chainstate.cpp +++ b/src/node/chainstate.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include @@ -48,10 +49,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 +90,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 +125,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 +151,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; } @@ -307,6 +328,13 @@ 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)}; + } + } 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 +362,15 @@ ChainstateLoadResult LoadChainstate(ChainstateManager& chainman, const CacheSize if (!chainman.DetectSnapshotChainstate(options.mempool, snapshot_error)) { return {ChainstateLoadStatus::FAILURE, snapshot_error}; } + // 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"); + } auto [init_status, init_error] = CompleteChainstateInitialization(chainman, cache_sizes, options, evodb, dmnman, special_tx); diff --git a/src/test/evo_db_tests.cpp b/src/test/evo_db_tests.cpp index 6cf14563e5af..244b249799dc 100644 --- a/src/test/evo_db_tests.cpp +++ b/src/test/evo_db_tests.cpp @@ -2,6 +2,7 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. +#include #include #include #include @@ -10,6 +11,7 @@ #include #include +#include #include #include #include @@ -49,6 +51,42 @@ void WriteMarker(CEvoDB& db, EvoDbIdentity identity, const uint256& hash) EXCLUS tx->Commit(); } +const uint256 CONFIG_FINGERPRINT{uint256::ONE}; + +//! Bind the SNAPSHOT identity to a new published generation. +void PublishGeneration(CEvoDB& db, uint32_t base_height = 0) +{ + BOOST_REQUIRE(db.CreateSnapshotGeneration(BlockHash(base_height), base_height)); + BOOST_REQUIRE(db.PublishSnapshotGeneration(CONFIG_FINGERPRINT, std::nullopt)); +} + +//! Raw keys of the whole database, in order. +std::vector> RawKeys(CEvoDB& db) +{ + std::vector> keys; + std::unique_ptr it{db.GetRawDB().NewIterator()}; + for (it->SeekToFirst(); it->Valid(); it->Next()) { + const DataStream key{it->GetKey()}; + keys.emplace_back(key.begin(), key.end()); + } + return keys; +} + +//! Heights of the test payloads one view's iterator yields. +std::vector IteratePayloads(CEvoDB& db, EvoDbIdentity identity) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) +{ + std::vector heights; + auto tx = db.BeginTransaction(identity); + LOCK(db.cs); + auto it{db.GetCurTransaction().NewIteratorUniquePtr()}; + for (it->Seek(PayloadKey(0)); it->Valid(); it->Next()) { + std::pair key; + if (!it->GetKey(key) || key.first != PayloadKey(0).first) break; + heights.push_back(static_cast(UintToArith256(key.second).GetLow64())); + } + return heights; +} + } // namespace BOOST_FIXTURE_TEST_SUITE(evo_db_tests, BasicTestingSetup) @@ -72,12 +110,6 @@ BOOST_AUTO_TEST_CASE(own_overlay_tombstone) Payload value; BOOST_CHECK(!db.Read(key, value)); } - { - auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); - Payload value; - BOOST_REQUIRE(db.Read(key, value)); - BOOST_CHECK(value == PayloadFor(1)); - } } BOOST_AUTO_TEST_CASE(transaction_less_access_uses_default_identity) @@ -85,6 +117,7 @@ BOOST_AUTO_TEST_CASE(transaction_less_access_uses_default_identity) LOCK(::cs_main); CEvoDB db{util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evodb_default_identity", .memory = true, .wipe = true}}; const auto key = PayloadKey(7); + PublishGeneration(db); // Committed to the SNAPSHOT overlay but not yet flushed to disk. WritePayload(db, EvoDbIdentity::SNAPSHOT, 7); @@ -115,47 +148,52 @@ BOOST_AUTO_TEST_CASE(transaction_less_access_uses_default_identity) } } -BOOST_AUTO_TEST_CASE(write_derived_verifies_other_unflushed_overlay) +BOOST_AUTO_TEST_CASE(write_derived_compares_within_its_own_view) { LOCK(::cs_main); - const fs::path path = m_args.GetDataDirBase() / "evodb_derived_overlay"; + CEvoDB db{util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evodb_derived_view", .memory = true, .wipe = true}}; + PublishGeneration(db); const auto key = PayloadKey(2); const auto payload = PayloadFor(2); - Payload mismatch = payload; - mismatch.push_back(0xff); + Payload other = payload; + other.push_back(0xff); { - CEvoDB db{util::DbWrapperParams{.path = path, .memory = false, .wipe = true}}; - { - auto tx = db.BeginTransaction(EvoDbIdentity::NORMAL); - BOOST_REQUIRE(db.WriteDerived(key, payload)); - tx->Commit(); - } - { - auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); - BOOST_CHECK(!db.WriteDerived(key, mismatch)); - BOOST_REQUIRE(db.WriteDerived(key, payload)); - tx->Commit(); - } - BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); - // Destroying db drops NORMAL's unflushed context. The reopened value - // therefore proves SNAPSHOT's identical overlap did not suppress its write. + auto tx = db.BeginTransaction(EvoDbIdentity::NORMAL); + BOOST_REQUIRE(db.WriteDerived(key, payload)); + // An identical rewrite is accepted, a differing one refused. + BOOST_CHECK(db.WriteDerived(key, payload)); + BOOST_CHECK(!db.WriteDerived(key, other)); + tx->Commit(); + } + BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::NORMAL)); + { + // The NORMAL record is invisible to the snapshot view: it neither + // conflicts with nor supplies the snapshot's own record. + auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); + BOOST_REQUIRE(db.WriteDerived(key, other)); + BOOST_CHECK(!db.WriteDerived(key, payload)); + tx->Commit(); + } + BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); + for (const auto& [identity, expected] : + {std::make_pair(EvoDbIdentity::NORMAL, payload), std::make_pair(EvoDbIdentity::SNAPSHOT, other)}) { + auto tx = db.BeginTransaction(identity); + Payload value; + BOOST_REQUIRE(db.Read(key, value)); + BOOST_CHECK(value == expected); } - - CEvoDB reloaded{util::DbWrapperParams{.path = path, .memory = false, .wipe = false}}; - Payload value; - BOOST_REQUIRE(reloaded.Read(key, value)); - BOOST_CHECK(value == payload); } BOOST_AUTO_TEST_CASE(write_derived_rejects_disk_mismatch) { LOCK(::cs_main); CEvoDB db{util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evodb_derived_mismatch", .memory = true, .wipe = true}}; + PublishGeneration(db); const auto key = PayloadKey(3); - WritePayload(db, EvoDbIdentity::NORMAL, 3); - BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::NORMAL)); + WritePayload(db, EvoDbIdentity::SNAPSHOT, 3); + BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); Payload mismatch = PayloadFor(3); @@ -163,12 +201,69 @@ BOOST_AUTO_TEST_CASE(write_derived_rejects_disk_mismatch) BOOST_CHECK(!db.WriteDerived(key, mismatch)); } +BOOST_AUTO_TEST_CASE(generation_namespace_isolation) +{ + LOCK(::cs_main); + const fs::path path = m_args.GetDataDirBase() / "evodb_isolation"; + const auto raw_keys_before_snapshot = [&]() EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { + CEvoDB db{util::DbWrapperParams{.path = path, .memory = false, .wipe = true}}; + WritePayload(db, EvoDbIdentity::NORMAL, 1); + WritePayload(db, EvoDbIdentity::NORMAL, 3); + BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::NORMAL)); + return RawKeys(db); + }(); + + { + CEvoDB db{util::DbWrapperParams{.path = path, .memory = false, .wipe = false}}; + BOOST_REQUIRE(db.LoadSnapshotGenerations(CONFIG_FINGERPRINT)); + PublishGeneration(db); + WritePayload(db, EvoDbIdentity::SNAPSHOT, 2); + WritePayload(db, EvoDbIdentity::SNAPSHOT, 3); + BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); + } + + CEvoDB db{util::DbWrapperParams{.path = path, .memory = false, .wipe = false}}; + BOOST_REQUIRE(db.LoadSnapshotGenerations(CONFIG_FINGERPRINT)); + // Neither view reads the other's records, including a key both wrote. + Payload value; + { + auto tx = db.BeginTransaction(EvoDbIdentity::NORMAL); + BOOST_CHECK(db.Read(PayloadKey(1), value)); + BOOST_CHECK(!db.Read(PayloadKey(2), value)); + } + { + auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); + BOOST_CHECK(!db.Read(PayloadKey(1), value)); + BOOST_CHECK(db.Read(PayloadKey(2), value)); + } + // Iterators stay inside their own keyspace. + BOOST_CHECK(IteratePayloads(db, EvoDbIdentity::NORMAL) == (std::vector{1, 3})); + BOOST_CHECK(IteratePayloads(db, EvoDbIdentity::SNAPSHOT) == (std::vector{2, 3})); + + // Rejection keeps the namespace until the coins directory is invalidated; + // collecting it is idempotent. + BOOST_REQUIRE(db.DiscardSnapshotMarkers()); + BOOST_CHECK(!db.GetSnapshotGeneration()); + BOOST_REQUIRE(db.LoadSnapshotGenerations(CONFIG_FINGERPRINT)); + BOOST_CHECK(db.HasRejectedGeneration()); + BOOST_REQUIRE(db.CollectRejectedGenerations()); + BOOST_REQUIRE(db.CollectRejectedGenerations()); + BOOST_CHECK(!db.HasRejectedGeneration()); + auto raw_keys{RawKeys(db)}; + // Only the generation counter remains of the snapshot. + DataStream counter_key{}; + counter_key << EVODB_GENERATION_NEXT; + std::erase(raw_keys, std::vector(counter_key.begin(), counter_key.end())); + BOOST_CHECK(raw_keys == raw_keys_before_snapshot); +} + BOOST_AUTO_TEST_CASE(marker_flush_independence) { LOCK(::cs_main); const fs::path path = m_args.GetDataDirBase() / "evodb_markers"; { CEvoDB db{util::DbWrapperParams{.path = path, .memory = false, .wipe = true}}; + PublishGeneration(db); WriteMarker(db, EvoDbIdentity::NORMAL, BlockHash(10)); WriteMarker(db, EvoDbIdentity::SNAPSHOT, BlockHash(100)); BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::NORMAL)); @@ -180,6 +275,7 @@ BOOST_AUTO_TEST_CASE(marker_flush_independence) } { CEvoDB db{util::DbWrapperParams{.path = path, .memory = false, .wipe = false}}; + BOOST_REQUIRE(db.LoadSnapshotGenerations(CONFIG_FINGERPRINT)); BOOST_CHECK(db.VerifyBestBlock(EvoDbIdentity::NORMAL, BlockHash(11))); BOOST_CHECK(db.VerifyBestBlock(EvoDbIdentity::SNAPSHOT, BlockHash(100))); @@ -189,6 +285,7 @@ BOOST_AUTO_TEST_CASE(marker_flush_independence) } { CEvoDB db{util::DbWrapperParams{.path = path, .memory = false, .wipe = false}}; + BOOST_REQUIRE(db.LoadSnapshotGenerations(CONFIG_FINGERPRINT)); BOOST_CHECK(db.VerifyBestBlock(EvoDbIdentity::NORMAL, BlockHash(11))); BOOST_CHECK(db.VerifyBestBlock(EvoDbIdentity::SNAPSHOT, BlockHash(102))); } @@ -216,6 +313,7 @@ BOOST_AUTO_TEST_CASE(open_transaction_does_not_capture_other_threads) { LOCK(::cs_main); CEvoDB db{util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evodb_tx_thread", .memory = true, .wipe = true}}; + PublishGeneration(db); db.SetDefaultIdentity(EvoDbIdentity::SNAPSHOT); WritePayload(db, EvoDbIdentity::SNAPSHOT, 30); @@ -241,6 +339,7 @@ BOOST_AUTO_TEST_CASE(view_scopes_nest_and_bind_transactions) { LOCK(::cs_main); CEvoDB db{util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evodb_view_scope", .memory = true, .wipe = true}}; + PublishGeneration(db); const EvoViewToken normal{db.ViewFor(EvoDbIdentity::NORMAL)}; const EvoViewToken snapshot{db.ViewFor(EvoDbIdentity::SNAPSHOT)}; WritePayload(db, EvoDbIdentity::SNAPSHOT, 50); @@ -286,6 +385,7 @@ BOOST_AUTO_TEST_CASE(view_scopes_nest_and_bind_transactions) // A scope binds only the CEvoDB its token names. CEvoDB other{ util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evodb_view_scope_other", .memory = true, .wipe = true}}; + PublishGeneration(other); EvoViewScope other_scope{other.ViewFor(EvoDbIdentity::SNAPSHOT)}; BOOST_CHECK(db.CurrentView() == normal); BOOST_CHECK(other.CurrentView() == other.ViewFor(EvoDbIdentity::SNAPSHOT)); @@ -297,31 +397,31 @@ BOOST_AUTO_TEST_CASE(snapshot_markers_can_be_discarded) const fs::path path = m_args.GetDataDirBase() / "evodb_marker_rollback"; { CEvoDB db{util::DbWrapperParams{.path = path, .memory = false, .wipe = true}}; + BOOST_REQUIRE(db.CreateSnapshotGeneration(BlockHash(40), 40)); WriteMarker(db, EvoDbIdentity::SNAPSHOT, BlockHash(40)); + BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); + BOOST_REQUIRE(db.PublishSnapshotGeneration(CONFIG_FINGERPRINT, BlockHash(4))); { - auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); - db.WriteSnapshotBaseMNListHash(BlockHash(4)); + auto tx = db.BeginTransaction(EvoDbIdentity::NORMAL); db.WriteBackgroundMNListHash(BlockHash(40), BlockHash(4)); - db.WriteDualChainstateMarker(); tx->Commit(); } - BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); + BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::NORMAL)); BOOST_REQUIRE(db.HasDualChainstateMarker()); + // Unflushed snapshot writes are dropped with the generation. + WritePayload(db, EvoDbIdentity::SNAPSHOT, 41); - // Abandoning snapshot activation after the markers were committed must - // leave no trace: a stale dual-chainstate marker breaks legacy + // Abandoning snapshot activation after the generation was published + // must leave no trace: a stale dual-chainstate marker breaks legacy // bootstrapping and a stale best-block marker would revive a future // snapshot directory. - { - auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); - db.EraseSnapshotMarkers(); - tx->Commit(); - } - BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); + BOOST_REQUIRE(db.AbandonSnapshotGeneration()); } CEvoDB reopened{util::DbWrapperParams{.path = path, .memory = false, .wipe = false}}; + BOOST_REQUIRE(reopened.LoadSnapshotGenerations(CONFIG_FINGERPRINT)); uint256 hash; uint256 hash2; + BOOST_CHECK(!reopened.GetSnapshotGeneration()); BOOST_CHECK(!reopened.ReadBestBlock(EvoDbIdentity::SNAPSHOT, hash)); BOOST_CHECK(!reopened.ReadSnapshotBaseMNListHash(hash)); BOOST_CHECK(!reopened.ReadBackgroundMNListHash(hash, hash2)); @@ -337,16 +437,32 @@ BOOST_AUTO_TEST_CASE(snapshot_marker_promotion_and_discard) const uint256 mn_list_hash = BlockHash(3); WriteMarker(db, EvoDbIdentity::NORMAL, normal_tip); + WritePayload(db, EvoDbIdentity::NORMAL, 30); + { + auto tx = db.BeginTransaction(EvoDbIdentity::NORMAL); + db.Write(EVODB_LLMQ_BEST_BLOCK, normal_tip); + tx->Commit(); + } + BOOST_REQUIRE(db.CreateSnapshotGeneration(normal_tip, 30)); { auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); db.WriteBestBlock(EvoDbIdentity::SNAPSHOT, snapshot_tip); - db.WriteSnapshotBaseMNListHash(mn_list_hash); - db.WriteDualChainstateMarker(); + // A record both views derived identically, one only the snapshot + // chainstate wrote, and the mutable singleton both views advance. + db.Write(PayloadKey(30), PayloadFor(30)); + db.Write(PayloadKey(300), PayloadFor(300)); + db.Write(EVODB_LLMQ_BEST_BLOCK, snapshot_tip); tx->Commit(); } BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::NORMAL)); BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); + BOOST_REQUIRE(db.PublishSnapshotGeneration(CONFIG_FINGERPRINT, mn_list_hash)); + // Promotion needs the journal written before the coins renames, at the tip + // the generation's Evo state was flushed at. + BOOST_CHECK(!db.PromoteSnapshotMarkers(snapshot_tip)); + BOOST_CHECK(!db.PrepareSnapshotPromotion(BlockHash(299))); + BOOST_REQUIRE(db.PrepareSnapshotPromotion(snapshot_tip)); BOOST_REQUIRE(db.PromoteSnapshotMarkers(snapshot_tip)); // Promotion is idempotent across a restart after the synced batch lands. BOOST_REQUIRE(db.PromoteSnapshotMarkers(snapshot_tip)); @@ -355,15 +471,19 @@ BOOST_AUTO_TEST_CASE(snapshot_marker_promotion_and_discard) BOOST_CHECK(!db.ReadBestBlock(EvoDbIdentity::SNAPSHOT, value)); BOOST_CHECK(!db.ReadSnapshotBaseMNListHash(value)); BOOST_CHECK(!db.HasDualChainstateMarker()); - + BOOST_CHECK(!db.ReadPromotionJournal()); + // Snapshot-written records are now NORMAL state; the singleton takes the + // promoted value. + BOOST_CHECK(IteratePayloads(db, EvoDbIdentity::NORMAL) == (std::vector{30, 300})); + uint256 llmq_best_block; + BOOST_REQUIRE(db.Read(EVODB_LLMQ_BEST_BLOCK, llmq_best_block)); + BOOST_CHECK_EQUAL(llmq_best_block, snapshot_tip); + + BOOST_REQUIRE(db.CreateSnapshotGeneration(snapshot_tip, 300)); WriteMarker(db, EvoDbIdentity::SNAPSHOT, BlockHash(301)); - { - auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); - db.WriteSnapshotBaseMNListHash(BlockHash(4)); - db.WriteDualChainstateMarker(); - tx->Commit(); - } + WritePayload(db, EvoDbIdentity::SNAPSHOT, 301); BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); + BOOST_REQUIRE(db.PublishSnapshotGeneration(CONFIG_FINGERPRINT, BlockHash(4))); BOOST_REQUIRE(db.DiscardSnapshotMarkers()); // Discard is likewise safe to retry. BOOST_REQUIRE(db.DiscardSnapshotMarkers()); @@ -371,6 +491,49 @@ BOOST_AUTO_TEST_CASE(snapshot_marker_promotion_and_discard) BOOST_CHECK(!db.ReadBestBlock(EvoDbIdentity::SNAPSHOT, value)); BOOST_CHECK(!db.ReadSnapshotBaseMNListHash(value)); BOOST_CHECK(!db.HasDualChainstateMarker()); + BOOST_CHECK(IteratePayloads(db, EvoDbIdentity::NORMAL) == (std::vector{30, 300})); +} + +BOOST_AUTO_TEST_CASE(promotion_refuses_conflicting_records) +{ + LOCK(::cs_main); + CEvoDB db{ + util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evodb_promotion_conflict", .memory = true, .wipe = true}}; + WritePayload(db, EvoDbIdentity::NORMAL, 5); + BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::NORMAL)); + BOOST_REQUIRE(db.CreateSnapshotGeneration(BlockHash(5), 5)); + { + auto tx = db.BeginTransaction(EvoDbIdentity::SNAPSHOT); + db.WriteBestBlock(EvoDbIdentity::SNAPSHOT, BlockHash(6)); + db.Write(PayloadKey(5), PayloadFor(6)); + tx->Commit(); + } + BOOST_REQUIRE(db.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); + BOOST_REQUIRE(db.PublishSnapshotGeneration(CONFIG_FINGERPRINT, std::nullopt)); + BOOST_REQUIRE(db.PrepareSnapshotPromotion(BlockHash(6))); + BOOST_CHECK(!db.PromoteSnapshotMarkers(BlockHash(6))); + // The conflict is in the first merge batch, so nothing was merged: + // NORMAL keeps its own record and tip. (Batches merged before a later + // conflict stay merged; startup then keeps failing closed.) + Payload value; + BOOST_REQUIRE(db.Read(PayloadKey(5), value)); + BOOST_CHECK(value == PayloadFor(5)); + BOOST_CHECK(db.HasDualChainstateMarker()); +} + +BOOST_AUTO_TEST_CASE(generation_ids_never_wrap) +{ + LOCK(::cs_main); + CEvoDB db{util::DbWrapperParams{.path = m_args.GetDataDirBase() / "evodb_generation_ids", .memory = true, .wipe = true}}; + // Generation 0 is the NORMAL keyspace: the last id is never handed out, + // so the counter can never wrap into it. + for (const uint32_t next : {std::numeric_limits::max(), uint32_t{0}}) { + BOOST_REQUIRE(db.GetRawDB().Write(EVODB_GENERATION_NEXT, next)); + BOOST_CHECK(!db.CreateSnapshotGeneration(BlockHash(1), 1)); + BOOST_CHECK(!db.GetSnapshotGeneration()); + } + BOOST_REQUIRE(db.GetRawDB().Write(EVODB_GENERATION_NEXT, std::numeric_limits::max() - 1)); + BOOST_CHECK(db.CreateSnapshotGeneration(BlockHash(1), 1)); } BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/validation_chainstatemanager_tests.cpp b/src/test/validation_chainstatemanager_tests.cpp index 2ebeb70cead4..2252c84bccaf 100644 --- a/src/test/validation_chainstatemanager_tests.cpp +++ b/src/test/validation_chainstatemanager_tests.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,8 @@ #include #include #include +#include +#include #include #include @@ -52,9 +55,16 @@ using node::SnapshotMetadata; namespace { -void SeedSnapshotMarker(CEvoDB& evodb, const uint256& hash) +//! Point the snapshot generation's best-block marker at `hash`, first +//! publishing a generation based at `hash` if none is live. +void SeedSnapshotMarker(CEvoDB& evodb, const uint256& hash, int base_height = -1) { LOCK(::cs_main); + if (!evodb.GetSnapshotGeneration()) { + BOOST_REQUIRE(evodb.CreateSnapshotGeneration(hash, base_height)); + BOOST_REQUIRE(evodb.PublishSnapshotGeneration(EvoSnapshotConsensusConfig::FromParams(Params()).Fingerprint(), + std::nullopt)); + } auto tx = evodb.BeginTransaction(EvoDbIdentity::SNAPSHOT); evodb.WriteBestBlock(EvoDbIdentity::SNAPSHOT, hash); tx->Commit(); @@ -537,6 +547,71 @@ struct SnapshotTestSetup : TestChain100Setup { } return *Assert(m_node.chainman); } + + //! Load the chainstates like startup does, reporting failure instead of + //! asserting success. + node::ChainstateLoadResult TryLoadChainstate() + { + MakeDashChainContexts(/*llmq_dbs_wipe=*/false); + return node::LoadChainstate(*Assert(m_node.chainman), m_cache_sizes, ChainstateLoadOptionsForTest(), + *m_node.evodb, *m_node.dmnman, *m_node.special_tx); + } +}; + +//! Evo records the snapshot chainstate writes for one of its blocks above the +//! base: a full MN list and a mined quorum commitment. +struct SnapshotViewRecords { + static constexpr auto LLMQ_TYPE{Consensus::LLMQType::LLMQ_TEST}; + + //! Block hash, not index: the block index is reloaded across restarts. + uint256 block_hash; + uint256 quorum_hash; + CDeterministicMNList list; + + // These keys mirror DB_LIST_SNAPSHOT in evo/deterministicmns.cpp and + // DB_MINED_COMMITMENT in llmq/blockprocessor.cpp (file-local there). + auto ListKey() const { return std::make_pair(std::string{"dmn_S3"}, block_hash); } + auto CommitmentKey() const { return std::make_pair(std::string{"q_mc"}, std::make_pair(LLMQ_TYPE, quorum_hash)); } + + static SnapshotViewRecords Write(CEvoDB& evodb, const CBlockIndex* block) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) + { + SnapshotViewRecords records{block->GetBlockHash(), GetRandHash(), + CDeterministicMNList{block->GetBlockHash(), block->nHeight, 1}}; + { + auto tx = evodb.BeginTransaction(EvoDbIdentity::SNAPSHOT); + evodb.Write(records.ListKey(), records.list); + const llmq::CFinalCommitment qc{Params().GetLLMQ(LLMQ_TYPE).value(), records.quorum_hash}; + evodb.Write(records.CommitmentKey(), std::make_pair(qc, block->GetBlockHash())); + tx->Commit(); + } + BOOST_REQUIRE(evodb.CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true)); + return records; + } + + //! Which of these records the NORMAL view reads, directly and through + //! the commitment consumer. + std::pair ReadByNormal(node::NodeContext& node) const + { + LOCK(::cs_main); + CEvoDB& evodb{*node.evodb}; + EvoViewScope normal{evodb.ViewFor(EvoDbIdentity::NORMAL)}; + CDeterministicMNList stored_list; + const bool list_read{evodb.Read(ListKey(), stored_list)}; + return {list_read, node.llmq_ctx->quorum_block_processor->HasMinedCommitment(LLMQ_TYPE, quorum_hash, + node.chainman->ActiveChain())}; + } + + //! Whether the MN-list manager, under NORMAL with DIP3 active, serves the + //! stored list for the block rather than building its own. Needs a + //! single chainstate: dual operation refuses to bootstrap a missing list. + bool ListServedByNormal(node::NodeContext& node) const + { + LOCK(::cs_main); + EvoViewScope normal{node.evodb->ViewFor(EvoDbIdentity::NORMAL)}; + ScopedDIP3FromGenesis dip3_active; + const CBlockIndex* block{Assert(node.chainman->m_blockman.LookupBlockIndex(block_hash))}; + return SerializeHash(node.dmnman->GetListForBlock(block)) == SerializeHash(list); + } }; //! Test basic snapshot activation. @@ -1288,6 +1363,12 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_completion, SnapshotTestSetup const uint256 snapshot_tip_hash = WITH_LOCK(chainman.GetMutex(), return chainman.ActiveTip()->GetBlockHash()); + // Evo state the snapshot chainstate wrote above its base stays out of + // NORMAL until promotion. + const SnapshotViewRecords snapshot_records{ + WITH_LOCK(::cs_main, return SnapshotViewRecords::Write(*m_node.evodb, chainman.ActiveChainstate().m_chain[150]))}; + BOOST_CHECK(snapshot_records.ReadByNormal(m_node) == std::make_pair(false, false)); + res = WITH_LOCK(::cs_main, return chainman.MaybeCompleteSnapshotValidation(mock_shutdown)); BOOST_CHECK_EQUAL(res, SnapshotCompletionResult::SUCCESS); @@ -1335,6 +1416,9 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_completion, SnapshotTestSetup BOOST_CHECK(!m_node.evodb->ReadBestBlock(EvoDbIdentity::SNAPSHOT, obsolete_marker)); BOOST_CHECK(!m_node.evodb->ReadSnapshotBaseMNListHash(obsolete_marker)); BOOST_CHECK(!m_node.evodb->HasDualChainstateMarker()); + // Promotion made the snapshot chainstate's post-base Evo state NORMAL. + BOOST_CHECK(snapshot_records.ReadByNormal(m_node) == std::make_pair(true, true)); + BOOST_CHECK(snapshot_records.ListServedByNormal(m_node)); const Chainstate& active_cs2 = chainman_restarted.ActiveChainstate(); @@ -1460,6 +1544,8 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_cleanup_recovers_first_rename this->SimulateNodeRestart(); const fs::path data_dir{gArgs.GetDataDirNet()}; + // ValidatedSnapshotCleanup journals the promotion before its first rename. + BOOST_REQUIRE(m_node.evodb->PrepareSnapshotPromotion(snapshot_tip)); fs::rename(data_dir / "chainstate", data_dir / "chainstate_todelete"); this->LoadVerifyActivateChainstate(); @@ -1481,6 +1567,8 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_cleanup_recovers_completed_sw this->SimulateNodeRestart(); const fs::path data_dir{gArgs.GetDataDirNet()}; + // ValidatedSnapshotCleanup journals the promotion before its first rename. + BOOST_REQUIRE(m_node.evodb->PrepareSnapshotPromotion(snapshot_tip)); fs::rename(data_dir / "chainstate", data_dir / "chainstate_todelete"); fs::rename(data_dir / "chainstate_snapshot", data_dir / "chainstate"); @@ -1513,11 +1601,89 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_cleanup_recovers_invalid_rena WITH_LOCK(::cs_main, return restarted.ActiveChainstate().CoinsTip().GetBestBlock()))); } -//! Pin the documented promote/discard overlap in RecoverSnapshotCleanup: a -//! crash between an invalid-snapshot rename and its marker discard while the +//! At or below the base, the snapshot view holds only carried rotation +//! quarters. Reading an uncarried one as an input, to reconstruct or to +//! predict members, is unavailable data rather than fewer quarters or "not +//! available yet"; NORMAL keeps its legacy behavior. +BOOST_FIXTURE_TEST_CASE(chainstatemanager_uncarried_quarter_is_unavailable, SnapshotTestSetup) +{ + Chainstate* snapshot_chainstate{std::get<1>(this->SetupSnapshot())}; + ChainstateManager& chainman = *Assert(m_node.chainman); + LOCK(::cs_main); + const auto rotating{Consensus::LLMQType::LLMQ_TEST_DIP0024}; + const CBlockIndex* cycle_base{snapshot_chainstate->m_chain[96]}; + const CBlockIndex* next_work{snapshot_chainstate->m_chain[120 - llmq::WORK_DIFF_DEPTH]}; + const llmq::UtilParameters reconstruct{*m_node.dmnman, *m_node.llmq_ctx->qsnapman, chainman, cycle_base}; + const llmq::UtilParameters predict{*m_node.dmnman, *m_node.llmq_ctx->qsnapman, chainman, next_work}; + // Prediction needs V20's work-block modifier. + Consensus::Params& consensus{const_cast(Params().GetConsensus())}; + struct V20HeightRestore { + Consensus::Params& params; + const int height; + ~V20HeightRestore() { params.V20Height = height; } + } v20_restore{consensus, std::exchange(consensus.V20Height, 1)}; + { + EvoViewScope snapshot_view{m_node.evodb->ViewFor(EvoDbIdentity::SNAPSHOT)}; + BOOST_CHECK_THROW(llmq::utils::GetAllQuorumMembers(rotating, reconstruct), BlockDataUnavailableError); + BOOST_CHECK_THROW(llmq::utils::ComputeQuorumMembersFromWorkBlock(rotating, predict, next_work, 120), + BlockDataUnavailableError); + } + { + EvoViewScope background_view{m_node.evodb->ViewFor(EvoDbIdentity::NORMAL)}; + BOOST_CHECK_NO_THROW(llmq::utils::GetAllQuorumMembers(rotating, reconstruct)); + BOOST_CHECK(!llmq::utils::ComputeQuorumMembersFromWorkBlock(rotating, predict, next_work, 120)); + } +} + +//! A rejection is persisted before the snapshot's coins directory is renamed. A +//! crash in between must leave startup able to finish the invalidation and run +//! on NORMAL alone, without a reindex. +BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_cleanup_recovers_rejection_before_rename, SnapshotTestSetup) +{ + this->SetupSnapshot(); + this->SimulateNodeRestart(); + BOOST_REQUIRE(m_node.evodb->DiscardSnapshotMarkers()); + const fs::path data_dir{gArgs.GetDataDirNet()}; + BOOST_REQUIRE(fs::exists(data_dir / "chainstate_snapshot")); + + BOOST_CHECK(std::get<0>(this->TryLoadChainstate()) == node::ChainstateLoadStatus::SUCCESS); + BOOST_CHECK(fs::exists(data_dir / "chainstate_snapshot_INVALID")); + BOOST_CHECK(!fs::exists(data_dir / "chainstate_snapshot")); + BOOST_CHECK(!m_node.evodb->HasRejectedGeneration()); + BOOST_CHECK(WITH_LOCK(::cs_main, return !m_node.chainman->IsSnapshotActive())); +} + +//! A rejected generation left behind by a failed collection must not take the +//! coins directory of a later, live snapshot with it at the next startup. +BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_cleanup_keeps_live_snapshot_dir, SnapshotTestSetup) +{ + this->SetupSnapshot(); + const uint256 snapshot_tip{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip()->GetBlockHash())}; + { + // The record of an earlier snapshot whose collection failed. + LOCK(::cs_main); + CEvoDB& evodb{*m_node.evodb}; + EvoGenerationRecord rejected; + rejected.generation = 1000; + rejected.state = EvoGenerationState::REJECTED; + EvoViewScope normal_view{evodb.ViewFor(EvoDbIdentity::NORMAL)}; + BOOST_REQUIRE(evodb.WriteDurable(std::make_pair(EVODB_GENERATION_RECORD, rejected.generation), rejected)); + } + this->SimulateNodeRestart(); + + // Startup drops the stale record, and the live snapshot carries on (here + // it completes and is promoted) instead of being invalidated. + BOOST_CHECK(std::get<0>(this->TryLoadChainstate()) == node::ChainstateLoadStatus::SUCCESS); + BOOST_CHECK(!fs::exists(gArgs.GetDataDirNet() / "chainstate_snapshot_INVALID")); + BOOST_CHECK(!m_node.evodb->HasRejectedGeneration()); + BOOST_CHECK_EQUAL(WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip()->GetBlockHash()), snapshot_tip); +} + +//! A crash between an invalid-snapshot rename and its marker discard while the //! SNAPSHOT marker equals the background coins tip (completion failed with the -//! snapshot chainstate still at its base) makes recovery take the promote -//! branch. That must produce the same end state a completed discard would. +//! snapshot chainstate still at its base) looks like a completed coins swap. +//! Without a promotion journal, recovery must discard the snapshot generation +//! rather than merge it into NORMAL state. BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_cleanup_overlap_promote_equals_discard, SnapshotTestSetup) { Chainstate* background_chainstate = std::get<0>(this->SetupSnapshot()); @@ -1559,6 +1725,8 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_cleanup_recovers_promoted_swa this->SimulateNodeRestart(); const fs::path data_dir{gArgs.GetDataDirNet()}; + // ValidatedSnapshotCleanup journals the promotion before its first rename. + BOOST_REQUIRE(m_node.evodb->PrepareSnapshotPromotion(snapshot_tip)); fs::rename(data_dir / "chainstate", data_dir / "chainstate_todelete"); fs::rename(data_dir / "chainstate_snapshot", data_dir / "chainstate"); BOOST_REQUIRE(m_node.evodb->PromoteSnapshotMarkers(snapshot_tip)); @@ -1568,6 +1736,138 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_cleanup_recovers_promoted_swa BOOST_CHECK(m_node.evodb->VerifyBestBlock(EvoDbIdentity::NORMAL, snapshot_tip)); } +//! Stop promotion at `failpoint` as if the node crashed there; the next startup +//! must finish it from the journal without losing any snapshot-written Evo +//! state. +static void CheckPromotionCrashRecovery(SnapshotTestSetup& setup, CEvoDB::PromotionFailpoint failpoint) +{ + node::NodeContext& node{setup.m_node}; + setup.SetupSnapshot(); + ChainstateManager& chainman = *Assert(node.chainman); + const uint256 snapshot_tip = WITH_LOCK(::cs_main, return chainman.ActiveTip()->GetBlockHash()); + const SnapshotViewRecords snapshot_records{ + WITH_LOCK(::cs_main, return SnapshotViewRecords::Write(*node.evodb, chainman.ActiveChainstate().m_chain[150]))}; + BOOST_REQUIRE_EQUAL(WITH_LOCK(::cs_main, return chainman.MaybeCompleteSnapshotValidation([](bilingual_str) {})), + SnapshotCompletionResult::SUCCESS); + + node.evodb->SetMergeBatchSizeForTesting(1); + node.evodb->SetPromotionFailpointForTesting(failpoint); + setup.SimulateNodeRestart(); + BOOST_CHECK(std::get<0>(setup.TryLoadChainstate()) == node::ChainstateLoadStatus::FAILURE_FATAL); + node.evodb->SetPromotionFailpointForTesting(CEvoDB::PromotionFailpoint::NONE); + + const auto journal{node.evodb->ReadPromotionJournal()}; + BOOST_REQUIRE(journal); + BOOST_CHECK_EQUAL(journal->tip, snapshot_tip); + if (failpoint == CEvoDB::PromotionFailpoint::MID_MERGE) { + BOOST_CHECK(journal->phase == EvoPromotionPhase::MERGING); + BOOST_CHECK(!journal->cursor.empty()); + } else { + BOOST_CHECK(journal->phase == EvoPromotionPhase::MERGED); + } + BOOST_CHECK(node.evodb->HasDualChainstateMarker()); + + if (failpoint == CEvoDB::PromotionFailpoint::AFTER_COLLECT) { + // The unfinished promotion is still bound to the configuration it was + // published under, even with its namespace already collected. + Consensus::Params& consensus{const_cast(Params().GetConsensus())}; + const int dip1_height{consensus.DIP0001Height}; + setup.SimulateNodeRestart(); + ++consensus.DIP0001Height; + const auto [status, error] = setup.TryLoadChainstate(); + consensus.DIP0001Height = dip1_height; + BOOST_CHECK(status == node::ChainstateLoadStatus::FAILURE); + BOOST_CHECK(error.original.find("consensus configuration changed") != std::string::npos); + BOOST_CHECK(node.evodb->ReadPromotionJournal()); + } + + setup.SimulateNodeRestart(); + setup.LoadVerifyActivateChainstate(); + const fs::path data_dir{gArgs.GetDataDirNet()}; + BOOST_CHECK(!fs::exists(data_dir / "chainstate_snapshot")); + BOOST_CHECK(!fs::exists(data_dir / "chainstate_todelete")); + BOOST_CHECK(node.evodb->VerifyBestBlock(EvoDbIdentity::NORMAL, snapshot_tip)); + BOOST_CHECK(!node.evodb->HasDualChainstateMarker()); + BOOST_CHECK(!node.evodb->ReadPromotionJournal()); + BOOST_CHECK(!node.evodb->GetSnapshotGeneration()); + BOOST_CHECK(snapshot_records.ReadByNormal(node) == std::make_pair(true, true)); + BOOST_CHECK(snapshot_records.ListServedByNormal(node)); +} + +BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_promotion_crash_mid_merge, SnapshotTestSetup) +{ + CheckPromotionCrashRecovery(*this, CEvoDB::PromotionFailpoint::MID_MERGE); +} + +BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_promotion_crash_after_merge, SnapshotTestSetup) +{ + CheckPromotionCrashRecovery(*this, CEvoDB::PromotionFailpoint::AFTER_MERGE); +} + +BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_promotion_crash_after_collect, SnapshotTestSetup) +{ + CheckPromotionCrashRecovery(*this, CEvoDB::PromotionFailpoint::AFTER_COLLECT); +} + +//! A journaled promotion that crashed before its first coins rename is redone +//! by the next startup. +BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_cleanup_recovers_prepared_promotion, SnapshotTestSetup) +{ + this->SetupSnapshot(); + ChainstateManager& chainman = *Assert(m_node.chainman); + const uint256 snapshot_tip = WITH_LOCK(::cs_main, return chainman.ActiveTip()->GetBlockHash()); + BOOST_REQUIRE_EQUAL(WITH_LOCK(::cs_main, return chainman.MaybeCompleteSnapshotValidation([](bilingual_str) {})), + SnapshotCompletionResult::SUCCESS); + + this->SimulateNodeRestart(); + BOOST_REQUIRE(m_node.evodb->PrepareSnapshotPromotion(snapshot_tip)); + this->LoadVerifyActivateChainstate(); + + BOOST_CHECK(!fs::exists(gArgs.GetDataDirNet() / "chainstate_snapshot")); + BOOST_CHECK(m_node.evodb->VerifyBestBlock(EvoDbIdentity::NORMAL, snapshot_tip)); + BOOST_CHECK(!m_node.evodb->HasDualChainstateMarker()); + BOOST_CHECK(!m_node.evodb->ReadPromotionJournal()); +} + +//! A published snapshot generation is valid only for the consensus +//! configuration it was loaded under. Changing any covered field, including +//! ones an earlier fingerprint omitted, must make startup refuse it before +//! rebinding it. +BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_refused_after_consensus_change, SnapshotTestSetup) +{ + this->SetupSnapshot(); + const uint256 snapshot_tip{WITH_LOCK(::cs_main, return m_node.chainman->ActiveTip()->GetBlockHash())}; + Consensus::Params& consensus{const_cast(Params().GetConsensus())}; + const auto llmq_test{std::ranges::find(consensus.llmqs, Consensus::LLMQType::LLMQ_TEST, &Consensus::LLMQParams::type)}; + BOOST_REQUIRE(llmq_test != consensus.llmqs.end()); + + const auto refused_under = [&](int& field) { + this->SimulateNodeRestart(); + const int original{field}; + ++field; + const auto [status, error] = this->TryLoadChainstate(); + field = original; + BOOST_CHECK(status == node::ChainstateLoadStatus::FAILURE); + BOOST_CHECK(error.original.find("consensus configuration changed") != std::string::npos); + BOOST_CHECK(!m_node.evodb->GetSnapshotGeneration()); + BOOST_CHECK(WITH_LOCK(::cs_main, return !m_node.chainman->IsSnapshotActive())); + // Refusing is not discarding: the snapshot survives for a restart under + // the original configuration. + BOOST_CHECK(m_node.evodb->HasDualChainstateMarker()); + BOOST_CHECK(fs::exists(gArgs.GetDataDirNet() / "chainstate_snapshot")); + }; + refused_under(consensus.vDeployments[Consensus::DEPLOYMENT_V24].min_activation_height); + refused_under(consensus.DIP0001Height); + refused_under(llmq_test->size); + + // Under the original configuration the generation is accepted again; with + // the background chainstate at the base, startup completes and promotes it. + this->SimulateNodeRestart(); + this->LoadVerifyActivateChainstate(); + BOOST_CHECK(m_node.evodb->VerifyBestBlock(EvoDbIdentity::NORMAL, snapshot_tip)); + BOOST_CHECK(!m_node.evodb->HasDualChainstateMarker()); +} + BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_base_is_cached, SnapshotTestSetup) { this->SetupSnapshot(); @@ -1620,14 +1920,8 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_completion_without_base_list_ // derivable and so no lifecycle hash markers were captured: completion // must fall back to the UTXO-set hash alone instead of quarantining a // valid snapshot. - { - LOCK(::cs_main); - auto tx = m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT); - m_node.evodb->Erase(EVODB_SNAPSHOT_MNLIST_HASH); - m_node.evodb->Erase(EVODB_BACKGROUND_MNLIST_HASH); - tx->Commit(); - } - BOOST_REQUIRE(WITH_LOCK(::cs_main, return m_node.evodb->CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true))); + BOOST_REQUIRE(m_node.evodb->GetRawDB().Erase(EVODB_SNAPSHOT_MNLIST_HASH, /*fSync=*/true)); + BOOST_REQUIRE(m_node.evodb->GetRawDB().Erase(EVODB_BACKGROUND_MNLIST_HASH, /*fSync=*/true)); const auto res = WITH_LOCK(::cs_main, return chainman.MaybeCompleteSnapshotValidation([](bilingual_str) {})); @@ -1657,6 +1951,10 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_completion_hash_mismatch, Sna fs::path snapshot_chainstate_dir = gArgs.GetDataDirNet() / "chainstate_snapshot"; BOOST_CHECK(fs::exists(snapshot_chainstate_dir)); + // Evo state the snapshot chainstate wrote above its base. + const SnapshotViewRecords snapshot_records{ + WITH_LOCK(::cs_main, return SnapshotViewRecords::Write(*m_node.evodb, chainman.ActiveChainstate().m_chain[150]))}; + { ASSERT_DEBUG_LOG("failed to validate the -assumeutxo snapshot state"); res = WITH_LOCK(::cs_main, @@ -1694,6 +1992,12 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_completion_hash_mismatch, Sna BOOST_CHECK_EQUAL(chainman_restarted.ActiveHeight(), 210); } + // NORMAL advanced past the base over the same blocks. It must build its + // own Evo state there, never consuming what the rejected snapshot wrote. + BOOST_CHECK(snapshot_records.ReadByNormal(m_node) == std::make_pair(false, false)); + BOOST_CHECK(!snapshot_records.ListServedByNormal(m_node)); + BOOST_CHECK(!m_node.evodb->GetSnapshotGeneration()); + BOOST_TEST_MESSAGE( "Ensure we can mine blocks on top of the \"new\" IBD chainstate"); mineBlocks(10); diff --git a/src/validation.cpp b/src/validation.cpp index bfd0713b6268..8c46718bfe5d 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -67,6 +67,7 @@ #include #include #include +#include #include #include #include @@ -6478,8 +6479,14 @@ bool ChainstateManager::ActivateSnapshot( static_cast(current_coinstip_cache_size * SNAPSHOT_CACHE_PERC)); } - bool snapshot_ok = this->PopulateAndValidateSnapshot( - *snapshot_chainstate, coins_file, metadata); + bool snapshot_ok; + try { + snapshot_ok = this->PopulateAndValidateSnapshot(*snapshot_chainstate, coins_file, metadata); + } catch (...) { + // Never leave a generation bound to a snapshot that did not activate. + WITH_LOCK(::cs_main, this->ActiveChainstate().m_evoDb.AbandonSnapshotGeneration()); + throw; + } // If not in-memory, persist the base blockhash for use during subsequent // initialization. @@ -6493,19 +6500,13 @@ bool ChainstateManager::ActivateSnapshot( LOCK(::cs_main); this->MaybeRebalanceCaches(); - // PopulateAndValidateSnapshot commits the snapshot best-block and - // dual-chainstate markers as its last step, so a later failure (e.g. - // WriteSnapshotBaseBlockhash on an unwritable datadir) would leave them - // behind on a node that is back to a single chainstate. Erasing them is - // a no-op when they were never written. - CEvoDB& evo_db = this->ActiveChainstate().m_evoDb; - { - auto db_tx = evo_db.BeginTransaction(EvoDbIdentity::SNAPSHOT); - evo_db.EraseSnapshotMarkers(); - db_tx->Commit(); - } - if (!evo_db.CommitRootTransaction(EvoDbIdentity::SNAPSHOT)) { - AbortNode("Failed to roll back the snapshot EvoDB markers"); + // PopulateAndValidateSnapshot publishes the snapshot generation and + // its dual-chainstate markers as its last step, so a later failure + // (e.g. WriteSnapshotBaseBlockhash on an unwritable datadir) would + // leave them behind on a node that is back to a single chainstate. + // Abandoning is a no-op when no generation was created. + if (!this->ActiveChainstate().m_evoDb.AbandonSnapshotGeneration()) { + AbortNode("Failed to roll back the snapshot EvoDB generation"); } // PopulateAndValidateSnapshot can return (in error) before the leveldb datadir @@ -6521,6 +6522,9 @@ bool ChainstateManager::ActivateSnapshot( "Manually remove it before restarting.\n", fs::PathToString(*snapshot_datadir))); } } + if (!this->ActiveChainstate().m_evoDb.CollectRejectedGenerations()) { + LogPrintf("[snapshot] failed to remove the abandoned snapshot EvoDB generation; retrying at startup\n"); + } return false; } @@ -6802,17 +6806,21 @@ bool ChainstateManager::PopulateAndValidateSnapshot( LogPrintf("[snapshot] failed to sync background EvoDB state\n"); return false; } + // Everything the snapshot chainstate writes lives in its own generation + // namespace, so a rejected snapshot can never leak into NORMAL state. + if (!snapshot_chainstate.m_evoDb.CreateSnapshotGeneration(base_blockhash, base_height)) { + LogPrintf("[snapshot] failed to create the snapshot EvoDB generation\n"); + return false; + } { auto db_tx = snapshot_chainstate.m_evoDb.BeginTransaction(EvoDbIdentity::SNAPSHOT); snapshot_chainstate.m_evoDb.WriteBestBlock(EvoDbIdentity::SNAPSHOT, base_blockhash); - if (base_mn_list_hash.has_value()) { - snapshot_chainstate.m_evoDb.WriteSnapshotBaseMNListHash(*base_mn_list_hash); - } - snapshot_chainstate.m_evoDb.WriteDualChainstateMarker(); db_tx->Commit(); } - if (!snapshot_chainstate.m_evoDb.CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true)) { - LogPrintf("[snapshot] failed to commit snapshot EvoDB marker\n"); + if (!snapshot_chainstate.m_evoDb.CommitRootTransaction(EvoDbIdentity::SNAPSHOT, /*sync=*/true) || + !snapshot_chainstate.m_evoDb.PublishSnapshotGeneration( + EvoSnapshotConsensusConfig::FromParams(GetParams()).Fingerprint(), base_mn_list_hash)) { + LogPrintf("[snapshot] failed to publish the snapshot EvoDB generation\n"); return false; } @@ -6870,12 +6878,18 @@ bool ChainstateManager::HandleSnapshotStateMismatch(const std::string& reason, assert(!IsUsable(m_snapshot_chainstate.get())); assert(IsUsable(m_ibd_chainstate.get())); + // Record the rejection before restoring NORMAL: a snapshot directory left + // behind without its generation can no longer be activated. If the + // rejection does not persist, startup recovery finishes it for the renamed + // directory. + if (!evodb_synced || !m_ibd_chainstate->m_evoDb.DiscardSnapshotMarkers()) { + LogPrintf("[snapshot] failed to record the snapshot rejection in EvoDB\n"); + } auto rename_result = m_snapshot_chainstate->InvalidateCoinsDBOnDisk(); if (!rename_result) { user_error += Untranslated("\n") + util::ErrorString(rename_result); - } else if (!evodb_synced || !m_ibd_chainstate->m_evoDb.DiscardSnapshotMarkers()) { - // Startup recovery completes the discard for the renamed directory. - LogPrintf("[snapshot] failed to remove invalid snapshot EvoDB markers\n"); + } else if (!m_ibd_chainstate->m_evoDb.CollectRejectedGenerations()) { + LogPrintf("[snapshot] failed to remove the rejected snapshot EvoDB generation; retrying at startup\n"); } shutdown_fnc(user_error); @@ -7270,7 +7284,9 @@ Chainstate* ChainstateManager::ActivateExistingSnapshot(CTxMemPool* mempool, uin assert(!m_snapshot_chainstate); CEvoDB& evo_db = this->ActiveChainstate().m_evoDb; uint256 snapshot_evo_tip; - if (!evo_db.ReadBestBlock(EvoDbIdentity::SNAPSHOT, snapshot_evo_tip)) { + const auto generation{evo_db.GetSnapshotGeneration()}; + if (!generation || generation->base != base_blockhash || + !evo_db.ReadBestBlock(EvoDbIdentity::SNAPSHOT, snapshot_evo_tip)) { LogPrintf("[snapshot] snapshot EvoDB marker is missing for base block %s\n", base_blockhash.ToString()); return nullptr; @@ -7383,6 +7399,13 @@ bool ChainstateManager::ValidatedSnapshotCleanup() // if they want to continue operation. this->ResetChainstates(); + // Journal the promotion before the first rename, so recovery can tell a + // promotion from an interrupted invalid-snapshot cleanup. + if (!evo_db.PrepareSnapshotPromotion(snapshot_tip)) { + LogPrintf("[snapshot] failed to journal the snapshot EvoDB promotion\n"); + return false; + } + // No chainstates should be considered usable. assert(this->GetAll().size() == 0); From c42edcff3dd2b01e67d4c53a3da56e0ce3be4aa2 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 15 Oct 2026 01:15:30 -0500 Subject: [PATCH 04/16] fix(evo): partition Evo-derived caches by view Isolating the snapshot generation's records is not enough while the managers keep one cache for both chainstates: a list, quorum member set, mined-commitment location or deployment state derived under the snapshot view at a block the background chainstate also reaches was served to NORMAL lookups, which then never derived their own. EvoViewCache keeps one partition per Evo view (EvoDB, identity, generation). Every cache keyed by a block or quorum hash now resolves the calling operation's view and uses only that partition: the MN-list lists, diffs, tip and initial-snapshot anchor; quorum snapshots; credit pools; MNHF signals; mined-commitment locations and the qc-hash caches; the quorum member caches; the quorum manager's quorum, scan and commitment-scan caches; the versionbits and warning caches; and the previous-coinbase ChainLock statics. The versionbits partition and the EHF signals SignalHeight reads both come from the calling thread's view. Caches of data parsed from block bytes and the vvec/skshare stores stay shared. Rejecting or promoting a generation bumps an epoch that drops every partition. The MN-list cache of a view is pruned against the higher of its tip and the highest block it processed: a view stops receiving tip notifications once its chainstate is no longer the active one, so a stale tip would keep its cache from ever being pruned. A snapshot view has no quorum history at or below its base once DIP3 is active there (a base at or above the DIP3 height), so lookups that would need it raise BlockDataUnavailableError instead of answering from partial data: a mined-commitment scan that comes up short (GetMinedCommitmentsUntilBlock, and the rotating per-index scan on a miss), a HasMinedCommitment miss for a quorum at or below the base, and the coinbase ChainLock read of a block at or below the base whose body is not stored. EHF signals derived from block bodies are written only inside a block-scoped Evo transaction, on a cache hit too, so lookups from mempool acceptance, mining or RPC leave the view clean. Adds CDeterministicMNManager::SetListSnapshotMissHookForTesting, which reports lookups that miss both the cache and the stored lists, and CachedListHeightsForTesting, for the partition and pruning tests. Co-Authored-By: Claude Opus 5.5 --- src/Makefile.am | 1 + src/evo/cbtx.cpp | 11 + src/evo/creditpool.cpp | 8 +- src/evo/creditpool.h | 4 +- src/evo/deterministicmns.cpp | 97 ++++--- src/evo/deterministicmns.h | 50 +++- src/evo/evodb.cpp | 9 + src/evo/evodb.h | 10 + src/evo/mnhftx.cpp | 46 +-- src/evo/mnhftx.h | 6 +- src/evo/specialtxman.cpp | 23 +- src/evo/viewcache.h | 64 +++++ src/llmq/blockprocessor.cpp | 76 +++-- src/llmq/blockprocessor.h | 14 +- src/llmq/quorumsman.cpp | 35 ++- src/llmq/quorumsman.h | 15 +- src/llmq/snapshot.cpp | 10 +- src/llmq/snapshot.h | 8 +- src/llmq/utils.cpp | 21 +- src/net_processing.cpp | 14 +- src/test/evo_deterministicmns_tests.cpp | 31 +- .../validation_chainstatemanager_tests.cpp | 269 ++++++++++++++++++ src/validation.cpp | 12 +- src/validation.h | 66 ++++- 24 files changed, 754 insertions(+), 146 deletions(-) create mode 100644 src/evo/viewcache.h diff --git a/src/Makefile.am b/src/Makefile.am index d8c0ec68432c..6cecd8ffc96b 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -250,6 +250,7 @@ BITCOIN_CORE_H = \ evo/specialtx_filter.h \ evo/specialtxman.h \ evo/types.h \ + evo/viewcache.h \ external_signer.h \ flat-database.h \ flatfile.h \ diff --git a/src/evo/cbtx.cpp b/src/evo/cbtx.cpp index 10f23bf6deb1..47e940c358e9 100644 --- a/src/evo/cbtx.cpp +++ b/src/evo/cbtx.cpp @@ -4,6 +4,8 @@ #include +#include +#include #include #include #include @@ -190,6 +192,15 @@ 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; } diff --git a/src/evo/creditpool.cpp b/src/evo/creditpool.cpp index 9ba53bc27240..217405f9ae4a 100644 --- a/src/evo/creditpool.cpp +++ b/src/evo/creditpool.cpp @@ -130,8 +130,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 @@ -142,7 +143,7 @@ std::optional CCreditPoolManager::GetFromCache(const CBlockIndex& b if (block_index.nHeight % DISK_SNAPSHOT_PERIOD == 0) { if (evoDb.Read(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block_hash), pool)) { LOCK(cache_mutex); - creditPoolCache.insert(block_hash, pool); + creditPoolCache.For(view).insert(block_hash, pool); return pool; } } @@ -172,8 +173,9 @@ 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); } CCreditPool CCreditPoolManager::ConstructCreditPool(const gsl::not_null block_index, CCreditPool prev) diff --git a/src/evo/creditpool.h b/src/evo/creditpool.h index 060a570e17ca..516a191a5cb7 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 @@ -118,7 +119,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; diff --git a/src/evo/deterministicmns.cpp b/src/evo/deterministicmns.cpp index 961b91f114b6..9f540048c727 100644 --- a/src/evo/deterministicmns.cpp +++ b/src/evo/deterministicmns.cpp @@ -706,8 +706,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 +720,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 +750,9 @@ 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) { @@ -792,15 +794,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}; @@ -822,10 +825,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; @@ -835,28 +839,32 @@ 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); // 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; @@ -882,22 +890,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; @@ -914,24 +922,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); } } } @@ -943,10 +949,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) @@ -974,19 +981,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; } @@ -1002,15 +1008,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); } } @@ -1184,8 +1190,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()); @@ -1528,10 +1533,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 2c9d93bc71f3..010b0c43e0c5 100644 --- a/src/evo/deterministicmns.h +++ b/src/evo/deterministicmns.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -22,6 +23,7 @@ #include #include +#include #include #include #include @@ -758,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; @@ -779,14 +793,33 @@ 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); 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 @@ -824,8 +857,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 cadf88827395..5efdc97f9f5f 100644 --- a/src/evo/evodb.cpp +++ b/src/evo/evodb.cpp @@ -88,6 +88,7 @@ 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, DataStream{})); + ++g_view_cache_epoch; } CEvoDB::~CEvoDB() = default; @@ -140,6 +141,12 @@ const EvoViewToken* EvoViewScope::Innermost(const CEvoDB& db) return nullptr; } +std::optional EvoViewScope::InnermostAny() +{ + if (g_innermost_scope == nullptr) return std::nullopt; + return g_innermost_scope->m_token; +} + EvoViewToken CEvoDB::MakeView(EvoDbIdentity identity) const { if (identity == EvoDbIdentity::SNAPSHOT && m_snapshot_generation) { @@ -506,6 +513,7 @@ bool CEvoDB::DiscardBoundGeneration() // still finish the invalidation. if (!db->WriteBatch(batch, /*fSync=*/true)) return false; BindSnapshotGeneration(std::nullopt); + ++g_view_cache_epoch; return true; } @@ -668,5 +676,6 @@ bool CEvoDB::PromoteSnapshotMarkers(const uint256& expected_snapshot_tip) // 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 63612f0420a7..e34cdf9f2466 100644 --- a/src/evo/evodb.h +++ b/src/evo/evodb.h @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -251,6 +252,8 @@ class EvoViewScope //! 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; @@ -344,6 +347,11 @@ class CEvoDB 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. EvoViewToken CurrentView() const EXCLUSIVE_LOCKS_REQUIRED(!cs); @@ -561,6 +569,8 @@ class CEvoDB bool MergeGeneration(EvoPromotionJournal& journal) EXCLUSIVE_LOCKS_REQUIRED(cs); bool DiscardBoundGeneration() EXCLUSIVE_LOCKS_REQUIRED(cs); + static inline std::atomic g_view_cache_epoch{0}; + //! 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}; diff --git a/src/evo/mnhftx.cpp b/src/evo/mnhftx.cpp index 5e3e13d5d039..593637581d9a 100644 --- a/src/evo/mnhftx.cpp +++ b/src/evo/mnhftx.cpp @@ -340,54 +340,62 @@ 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); - } +} + +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::ForceSignalDBUpdate(const CBlockIndex* tip) diff --git a/src/evo/mnhftx.h b/src/evo/mnhftx.h index 8a0bc91455a7..7d2508d9a548 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; @@ -134,6 +136,8 @@ class CMNHFManager : public AbstractEHFManager 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 returns list of signals available on previous block. diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index d8ba4871e7a9..ff950f2e462d 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -165,23 +166,27 @@ bool CheckCbTxBestChainlock(const CCbTx& cbTx, const CBlockIndex* pindex, const 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); @@ -208,8 +213,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); @@ -225,8 +229,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"); 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/llmq/blockprocessor.cpp b/src/llmq/blockprocessor.cpp index 82b0a87a81a1..c0a9f40e24af 100644 --- a/src/llmq/blockprocessor.cpp +++ b/src/llmq/blockprocessor.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -78,14 +79,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); } @@ -446,8 +451,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 +467,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); } @@ -550,7 +555,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 +647,25 @@ 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; +} + +//! A height-index scan such a view cannot satisfy would need the missing +//! history, so its result is unknown, not short. +static void RequireSatisfiedScan(const EvoViewToken& view, const Consensus::Params& consensus, const char* func, + Consensus::LLMQType llmqType) +{ + if (!LacksQuorumHistory(view, consensus)) 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 +679,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 +690,16 @@ 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())) { + // A quorum based at or below the base may have been mined there, in + // history the view lacks: the miss does not mean it never was. + const CBlockIndex* quorum_base{m_chainman.m_blockman.LookupBlockIndex(quorumHash)}; + if (quorum_base != nullptr && quorum_base->nHeight <= view.base_height) { + throw BlockDataUnavailableError( + strprintf("%s: commitment for quorum %s at or below the snapshot base is not available", __func__, + quorumHash.ToString())); + } } } @@ -687,6 +722,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); + const EvoViewToken view{m_evoDb.CurrentView()}; LOCK(m_evoDb.cs); auto dbIt = m_evoDb.GetCurTransaction().NewIteratorUniquePtr(); @@ -725,12 +761,18 @@ std::vector CQuorumBlockProcessor::GetMinedCommitmentsUntilB dbIt->Next(); } + if (ret.size() < maxCount) RequireSatisfiedScan(view, m_chainman.GetConsensus(), __func__, llmqType); return ret; } std::optional CQuorumBlockProcessor::GetLastMinedCommitmentsByQuorumIndexUntilBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex, int quorumIndex, size_t cycle) const { AssertLockNotHeld(m_evoDb.cs); + const EvoViewToken view{m_evoDb.CurrentView()}; + const auto not_found{[&]() -> std::optional { + RequireSatisfiedScan(view, m_chainman.GetConsensus(), "GetLastMinedCommitmentsByQuorumIndexUntilBlock", llmqType); + return std::nullopt; + }}; LOCK(m_evoDb.cs); auto dbIt = m_evoDb.GetCurTransaction().NewIteratorUniquePtr(); @@ -746,19 +788,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 +815,7 @@ std::optional CQuorumBlockProcessor::GetLastMinedCommitments dbIt->Next(); } - return std::nullopt; + return not_found(); } std::vector CQuorumBlockProcessor::GetLastMinedCommitmentsPerQuorumIndexUntilBlock( diff --git a/src/llmq/blockprocessor.h b/src/llmq/blockprocessor.h index c248a13fd29f..aec1efb90d89 100644 --- a/src/llmq/blockprocessor.h +++ b/src/llmq/blockprocessor.h @@ -7,6 +7,7 @@ #include #include +#include #include // IWYU pragma: export #include #include @@ -65,7 +66,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 +75,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; diff --git a/src/llmq/quorumsman.cpp b/src/llmq/quorumsman.cpp index dd0d7abd6705..45e80cf062dc 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; } @@ -200,8 +207,10 @@ std::vector CQuorumManager::ScanQuorums(Consensus::LLMQType llmqTyp size_t nScanCommitments{nCountRequested}; std::vector vecResultQuorums; + const EvoViewToken view{m_evoDb.CurrentView()}; 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 +286,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); @@ -325,8 +335,10 @@ std::vector CQuorumManager::ScanCommitments(Consensus::LLMQTyp std::vector vecResultCommitments; std::optional continuationQuorumHash; + const EvoViewToken view{m_evoDb.CurrentView()}; { 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 +411,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 +539,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 +561,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 +623,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; } diff --git a/src/llmq/quorumsman.h b/src/llmq/quorumsman.h index 86d06ae275d9..a43cf073c2b1 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,9 @@ 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(); } + // 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); diff --git a/src/llmq/snapshot.cpp b/src/llmq/snapshot.cpp index e7aeb59668ec..4028de0b4547 100644 --- a/src/llmq/snapshot.cpp +++ b/src/llmq/snapshot.cpp @@ -304,7 +304,7 @@ std::vector CQuorumRotationInfo::GetCycles() CQuorumSnapshotManager::CQuorumSnapshotManager(CEvoDB& evoDb) : m_evoDb{evoDb}, - quorumSnapshotCache{32} + quorumSnapshotCache{[] { return Uint256LruHashMap{32}; }} { } @@ -315,14 +315,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; } @@ -352,6 +354,6 @@ void CQuorumSnapshotManager::StoreSnapshotForBlock(const Consensus::LLMQType llm LOCK(snapshotCacheCs); m_evoDb.WriteDurable(key, snapshot); - quorumSnapshotCache.insert(snapshotHash, snapshot); + quorumSnapshotCache.For(view).insert(snapshotHash, snapshot); } } // namespace llmq diff --git a/src/llmq/snapshot.h b/src/llmq/snapshot.h index 12bafc2bf9a8..607ad355e39f 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,6 +248,10 @@ 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 diff --git a/src/llmq/utils.cpp b/src/llmq/utils.cpp index 2b4c39110194..fac6da96ef8d 100644 --- a/src/llmq/utils.cpp +++ b/src/llmq/utils.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -643,9 +644,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 @@ -658,6 +661,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()); } @@ -673,8 +677,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 @@ -699,11 +703,11 @@ 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; } @@ -717,6 +721,7 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame 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])); @@ -734,7 +739,7 @@ QuorumMembers GetAllQuorumMembers(Consensus::LLMQType llmqType, const UtilParame } 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/net_processing.cpp b/src/net_processing.cpp index d975be9e63b2..ebdbbc98165a 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -52,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -5718,9 +5719,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/test/evo_deterministicmns_tests.cpp b/src/test/evo_deterministicmns_tests.cpp index 9cc1f4bf6dac..a58f8eda0c1c 100644 --- a/src/test/evo_deterministicmns_tests.cpp +++ b/src/test/evo_deterministicmns_tests.cpp @@ -20,9 +20,9 @@ #include #include #include -#include #include #include +#include #include #include #include @@ -35,6 +35,7 @@ #include #include #include +#include #include @@ -2604,6 +2605,34 @@ struct TestChainV24PendingSetup : public TestChainV24SignalBeforeV19Setup { } }; +// A view whose tip notifications stopped, as NORMAL's do once a snapshot +// chainstate is active, still evicts cached lists as it processes blocks. +BOOST_AUTO_TEST_CASE(list_cache_follows_processed_blocks_past_a_stale_tip) +{ + TestChainDIP3Setup setup; + auto& chainman{*Assert(setup.m_node.chainman)}; + auto& dmnman{*Assert(setup.m_node.dmnman)}; + const CScript coinbase_script{GetScriptForRawPubKey(setup.coinbaseKey.GetPubKey())}; + const CBlockIndex* stale_tip{WITH_LOCK(::cs_main, return chainman.ActiveTip())}; + // Lists a live quorum may still use stay cached for this many blocks. + int quorum_window{0}; + for (const auto& params : Params().GetConsensus().llmqs) { + quorum_window = std::max(quorum_window, params.dkgInterval * (params.keepOldConnections + 1)); + } + for (int i{0}; i < 4 * quorum_window; ++i) { + setup.CreateAndProcessBlock({}, coinbase_script); + SyncWithValidationInterfaceQueue(); + dmnman.UpdatedBlockTip(stale_tip); + } + const int processed_height{WITH_LOCK(::cs_main, return chainman.ActiveHeight())}; + // Cleanup runs once the cache holds more than MAX_CACHED_LISTS (64) lists, + // so lists added since the last cleanup are at most that many blocks old. + for (const int height : dmnman.CachedListHeightsForTesting()) { + BOOST_CHECK_MESSAGE(height == stale_tip->nHeight || height + quorum_window + 64 >= processed_height, + "list at height " << height << " outlived its quorums"); + } +} + // DIP3 can only be activated with legacy scheme (v19 is activated later) BOOST_AUTO_TEST_CASE(dip3_activation_legacy) { diff --git a/src/test/validation_chainstatemanager_tests.cpp b/src/test/validation_chainstatemanager_tests.cpp index 2252c84bccaf..93aa5cca90ab 100644 --- a/src/test/validation_chainstatemanager_tests.cpp +++ b/src/test/validation_chainstatemanager_tests.cpp @@ -3,8 +3,10 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. // #include +#include #include #include +#include #include #include #include @@ -620,6 +622,54 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_activate_snapshot, SnapshotTestSetup) auto [background_chainstate, snapshot_chainstate] = this->SetupSnapshot(); ChainstateManager& chainman = *Assert(m_node.chainman); + // Evo-derived caches are partitioned by view: state warmed under the + // snapshot view at blocks the background chainstate also reaches is never + // served to NORMAL lookups, which must derive their own. + { + LOCK(::cs_main); + ScopedDIP3FromGenesis dip3_active; + const auto llmq_type{Consensus::LLMQType::LLMQ_TEST}; + const CBlockIndex* list_block{background_chainstate->m_chain[100]}; + const CBlockIndex* quorum_base{background_chainstate->m_chain[96]}; + const llmq::UtilParameters util_params{*m_node.dmnman, *m_node.llmq_ctx->qsnapman, chainman, quorum_base}; + auto& qblockman{*m_node.llmq_ctx->quorum_block_processor}; + const uint256 quorum_hash{GetRandHash()}; + std::shared_ptr snapshot_versionbits; + { + EvoViewScope snapshot_view{m_node.evodb->ViewFor(EvoDbIdentity::SNAPSHOT)}; + m_node.dmnman->SetListForBlockForTesting( + CDeterministicMNList{list_block->GetBlockHash(), list_block->nHeight, 1}); + m_node.dmnman->SetListForBlockForTesting( + CDeterministicMNList{quorum_base->GetBlockHash(), quorum_base->nHeight, 0}); + llmq::utils::GetAllQuorumMembers(llmq_type, util_params); + { + auto tx = m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT); + const llmq::CFinalCommitment qc{Params().GetLLMQ(llmq_type).value(), quorum_hash}; + m_node.evodb->Write(std::make_pair(std::string{"q_mc"}, std::make_pair(llmq_type, quorum_hash)), + std::make_pair(qc, list_block->GetBlockHash())); + tx->Commit(); + } + BOOST_REQUIRE(qblockman.HasMinedCommitment(llmq_type, quorum_hash, snapshot_chainstate->m_chain)); + // A query above the base walks period boundaries at or below it. + chainman.m_versionbitscache.State(snapshot_chainstate->m_chain.Tip(), chainman.GetConsensus(), + Consensus::DEPLOYMENT_V24); + snapshot_versionbits = chainman.m_versionbitscache.ForCurrentView(); + } + + EvoViewScope normal_view{m_node.evodb->ViewFor(EvoDbIdentity::NORMAL)}; + std::vector list_misses; + m_node.dmnman->SetListSnapshotMissHookForTesting( + [&](const CBlockIndex* pindex) { list_misses.push_back(pindex); }); + // NORMAL has no MN-list history for these blocks, so deriving its own + // lists fails as unavailable rather than reusing the snapshot's. + BOOST_CHECK_THROW(m_node.dmnman->GetListForBlock(list_block), BlockDataUnavailableError); + BOOST_CHECK_THROW(llmq::utils::GetAllQuorumMembers(llmq_type, util_params), BlockDataUnavailableError); + BOOST_CHECK(list_misses == (std::vector{list_block, quorum_base})); + BOOST_CHECK(!qblockman.HasMinedCommitment(llmq_type, quorum_hash, snapshot_chainstate->m_chain)); + BOOST_CHECK(chainman.m_versionbitscache.ForCurrentView() != snapshot_versionbits); + m_node.dmnman->SetListSnapshotMissHookForTesting(nullptr); + } + // Stay outside every regtest LLMQ mining window (dkgInterval 24, windows // end at offset 20), so the block needs no quorum commitment. while (WITH_LOCK(::cs_main, return (snapshot_chainstate->m_chain.Height() + 1) % 24) < 21) { @@ -866,6 +916,32 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_missing_body_stalls, SnapshotTestSetup BOOST_CHECK_EQUAL(snapshot_chainstate->m_chain.Tip(), tip); } + // The block above the snapshot base checks its coinbase ChainLock against + // the base's, whose body a snapshot node does not have: it cannot tell a + // missing ChainLock from an unreadable one. + { + LOCK(::cs_main); + const int snapshot_base_height{m_node.evodb->ViewFor(EvoDbIdentity::SNAPSHOT).base_height}; + CBlockIndex* snapshot_base{snapshot_chainstate->m_chain[snapshot_base_height]}; + const uint32_t snapshot_base_status{snapshot_base->nStatus}; + snapshot_base->nStatus &= ~(BLOCK_HAVE_DATA | BLOCK_HAVE_UNDO); + EvoViewScope snapshot_view{m_node.evodb->ViewFor(EvoDbIdentity::SNAPSHOT)}; + CCbTx cbtx; + cbtx.nVersion = CCbTx::Version::CLSIG_AND_BALANCE; + cbtx.nHeight = snapshot_base_height + 1; + BlockValidationState cl_state; + BOOST_CHECK_THROW(CheckCbTxBestChainlock(cbtx, snapshot_chainstate->m_chain[snapshot_base_height + 1], + consensus, snapshot_chainstate->m_chain, *m_node.llmq_ctx->qman, + *m_node.chainlocks, cl_state), + BlockDataUnavailableError); + // The quorum modifier of a work block at or below the base reads the + // same coinbase through the same lookup. + BOOST_CHECK_THROW(GetNonNullCoinbaseChainlock(snapshot_base), BlockDataUnavailableError); + snapshot_base->nStatus = snapshot_base_status; + // With the body there, the coinbase is read. + BOOST_CHECK_NO_THROW(GetNonNullCoinbaseChainlock(snapshot_base)); + } + // Checking a child's EHF signals rebuilds those of the block it extends // from the bodies, and stops on a pruned body the same way. CBlockIndex* stalled{WITH_LOCK(::cs_main, return chainman.m_blockman.LookupBlockIndex(block->GetHash()))}; @@ -894,6 +970,26 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_missing_body_stalls, SnapshotTestSetup tip->nStatus = tip_status; chainman.m_blockman.m_have_pruned = false; } + + // With the bodies back, a lookup outside any transaction (an RPC asking for + // deployment info) rebuilds the snapshot view's EHF signals from them. It + // persists nothing, so the next flush finds the view clean; a lookup in a + // block-scoped transaction persists the cached signals. + const auto signals_key{std::make_pair(std::string{"mnhf_s2"}, tip->GetBlockHash())}; + { + LOCK(::cs_main); + EvoViewScope snapshot_view{m_node.evodb->ViewFor(EvoDbIdentity::SNAPSHOT)}; + BOOST_CHECK_NO_THROW(m_node.special_tx->m_mnhfman->GetSignalsStage(tip)); + BOOST_CHECK(m_node.evodb->CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); + BOOST_CHECK(!m_node.evodb->Exists(signals_key)); + { + auto tx{m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT)}; + m_node.special_tx->m_mnhfman->GetSignalsStage(tip); + tx->Commit(); + } + BOOST_CHECK(m_node.evodb->CommitRootTransaction(EvoDbIdentity::SNAPSHOT)); + BOOST_CHECK(m_node.evodb->Exists(signals_key)); + } SetMiscWarning(Untranslated("")); } @@ -1239,6 +1335,179 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_mined_commitment_is_chain_aware, Snaps BOOST_CHECK(qblockman.ProcessBlock(*background_chainstate, block, snapshot_mined_block, state, /*fJustCheck=*/true, /*fBLSChecks=*/false)); } + + // Block templates, built before DIP3 is moved to genesis below. + const CBlock signal_template{CreateBlock({}, GetScriptForRawPubKey(coinbaseKey.GetPubKey()), *snapshot_chainstate)}; + const CBlockIndex* snapshot_tip{WITH_LOCK(::cs_main, return snapshot_chainstate->m_chain.Tip())}; + + // Before DIP3 no quorum is mined, so while DIP3 activates above the base, + // the snapshot view's empty history at or below it is authoritative. + const auto platform_type{Params().GetConsensus().llmqTypePlatform}; + { + LOCK(::cs_main); + EvoViewScope snapshot_view{m_node.evodb->ViewFor(EvoDbIdentity::SNAPSHOT)}; + BOOST_CHECK(qblockman.GetMinedCommitmentsUntilBlock(platform_type, snapshot_tip, 1).empty()); + } + + // With DIP3 active at the base, a commitment only NORMAL has, for a quorum + // at or below the base, is history the snapshot view lacks: a miss there is + // unavailable data, not a quorum that was never mined. Above the base, a + // miss is authoritative. + ScopedDIP3FromGenesis dip3_active; + const CBlockIndex* earlier_base{ + WITH_LOCK(::cs_main, return background_chainstate->m_chain[quorum_base->nHeight - llmq_params.dkgInterval])}; + const uint256 normal_only_hash{earlier_base->GetBlockHash()}; + { + LOCK(::cs_main); + const CBlockIndex* normal_mined_block{ + background_chainstate->m_chain[earlier_base->nHeight + llmq_params.dkgMiningWindowStart]}; + auto tx = m_node.evodb->BeginTransaction(EvoDbIdentity::NORMAL); + m_node.evodb->Write(std::make_pair(std::string{"q_mc"}, std::make_pair(llmq_type, normal_only_hash)), + std::make_pair(llmq::CFinalCommitment{llmq_params, normal_only_hash}, + normal_mined_block->GetBlockHash())); + tx->Commit(); + } + { + LOCK(::cs_main); + EvoViewScope background_view{m_node.evodb->ViewFor(EvoDbIdentity::NORMAL)}; + BOOST_CHECK(qblockman.HasMinedCommitment(llmq_type, normal_only_hash, background_chainstate->m_chain)); + } + { + LOCK(::cs_main); + EvoViewScope snapshot_view{m_node.evodb->ViewFor(EvoDbIdentity::SNAPSHOT)}; + BOOST_CHECK_THROW(qblockman.HasMinedCommitment(llmq_type, normal_only_hash, snapshot_chainstate->m_chain), + BlockDataUnavailableError); + BOOST_CHECK_THROW(qman.GetQuorum(llmq_type, normal_only_hash, snapshot_chainstate->m_chain), + BlockDataUnavailableError); + BOOST_CHECK(!qblockman.HasMinedCommitment(llmq_type, snapshot_mined_block->GetBlockHash(), + snapshot_chainstate->m_chain)); + } + + // So a block whose EHF signal names that quorum stalls instead of failing. + ChainstateManager& chainman{*Assert(m_node.chainman)}; + Consensus::Params& consensus{const_cast(Params().GetConsensus())}; + struct V20HeightRestore { + Consensus::Params& params; + const int height; + ~V20HeightRestore() { params.V20Height = height; } + } v20_restore{consensus, std::exchange(consensus.V20Height, 1)}; + MNHFTxPayload signal; + signal.signal.versionBit = consensus.vDeployments[Consensus::DEPLOYMENT_V24].bit; + signal.signal.quorumHash = normal_only_hash; + CMutableTransaction signal_tx; + signal_tx.nVersion = CTransaction::SPECIAL_VERSION; + signal_tx.nType = TRANSACTION_MNHF_SIGNAL; + SetTxPayload(signal_tx, signal); + CBlock signal_block{MakeCbTxBlock(signal_template, snapshot_tip->nHeight + 1, CCbTx::Version::CLSIG_AND_BALANCE)}; + signal_block.vtx.push_back(MakeTransactionRef(std::move(signal_tx))); + signal_block.hashMerkleRoot = BlockMerkleRoot(signal_block); + while (!CheckProofOfWork(signal_block.GetHash(), signal_block.nBits, consensus)) + ++signal_block.nNonce; + const auto signal_block_ref{std::make_shared(signal_block)}; + chainman.ProcessNewBlock(signal_block_ref, /*force_processing=*/true, /*new_block=*/nullptr); + BlockValidationState state; + BOOST_CHECK(!snapshot_chainstate->ActivateBestChain(state, signal_block_ref)); + BOOST_CHECK(IsLocalEvoStateError(state)); + { + LOCK(::cs_main); + const CBlockIndex* index{chainman.m_blockman.LookupBlockIndex(signal_block.GetHash())}; + BOOST_REQUIRE(index); + BOOST_CHECK_EQUAL(index->nStatus & BLOCK_FAILED_MASK, 0U); + BOOST_CHECK_EQUAL(snapshot_chainstate->m_chain.Tip(), snapshot_tip); + } + + // A scan by height the snapshot view cannot satisfy from the commitments + // mined above the base is unknown, not short: a Platform quorum mined at or + // below the base, recorded only in NORMAL, is missing from its result. The + // view is isolated from NORMAL, so its answer cannot depend on that record; + // NORMAL resolving it shows the history the scan needs does exist. + { + LOCK(::cs_main); + const CBlockIndex* platform_mined_block{ + background_chainstate->m_chain[earlier_base->nHeight + llmq_params.dkgMiningWindowStart]}; + auto tx = m_node.evodb->BeginTransaction(EvoDbIdentity::NORMAL); + // The keys mirror DB_MINED_COMMITMENT and DB_MINED_COMMITMENT_BY_INVERSED_HEIGHT + // in llmq/blockprocessor.cpp. + m_node.evodb->Write(std::make_pair(std::string{"q_mc"}, std::make_pair(platform_type, normal_only_hash)), + std::make_pair(llmq::CFinalCommitment{Params().GetLLMQ(platform_type).value(), normal_only_hash}, + platform_mined_block->GetBlockHash())); + m_node.evodb->Write(std::make_tuple(std::string{"q_mcih"}, platform_type, + htobe32_internal(std::numeric_limits::max() - + platform_mined_block->nHeight)), + earlier_base->nHeight); + tx->Commit(); + } + { + LOCK(::cs_main); + EvoViewScope background_view{m_node.evodb->ViewFor(EvoDbIdentity::NORMAL)}; + BOOST_CHECK(qblockman.GetMinedCommitmentsUntilBlock(platform_type, background_chainstate->m_chain.Tip(), 1) == + std::vector{earlier_base}); + } + { + LOCK(::cs_main); + EvoViewScope snapshot_view{m_node.evodb->ViewFor(EvoDbIdentity::SNAPSHOT)}; + BOOST_CHECK_THROW(qblockman.GetMinedCommitmentsUntilBlock(platform_type, snapshot_tip, 1), + BlockDataUnavailableError); + BOOST_CHECK_THROW(qman.ScanQuorums(platform_type, snapshot_tip, 1, snapshot_chainstate->m_chain), + BlockDataUnavailableError); + } + + // So a block withdrawing from Platform under that quorum stalls instead of + // failing as signed by a quorum too old. + CMutableTransaction unlock_tx; + unlock_tx.nVersion = CTransaction::SPECIAL_VERSION; + unlock_tx.nType = TRANSACTION_ASSET_UNLOCK; + unlock_tx.vout.emplace_back(COIN, GetScriptForRawPubKey(coinbaseKey.GetPubKey())); + SetTxPayload(unlock_tx, + CAssetUnlockPayload{CAssetUnlockPayload::INITIAL_VERSION, /*index=*/1, /*fee=*/1000, + static_cast(snapshot_tip->nHeight), normal_only_hash, CBLSSignature{}}); + CBlock unlock_block{MakeCbTxBlock(signal_template, snapshot_tip->nHeight + 1, CCbTx::Version::CLSIG_AND_BALANCE)}; + unlock_block.vtx.push_back(MakeTransactionRef(std::move(unlock_tx))); + unlock_block.hashMerkleRoot = BlockMerkleRoot(unlock_block); + while (!CheckProofOfWork(unlock_block.GetHash(), unlock_block.nBits, consensus)) + ++unlock_block.nNonce; + const auto unlock_block_ref{std::make_shared(unlock_block)}; + chainman.ProcessNewBlock(unlock_block_ref, /*force_processing=*/true, /*new_block=*/nullptr); + BOOST_CHECK(!snapshot_chainstate->ActivateBestChain(state, unlock_block_ref)); + BOOST_CHECK(IsLocalEvoStateError(state)); + { + LOCK(::cs_main); + const CBlockIndex* index{chainman.m_blockman.LookupBlockIndex(unlock_block.GetHash())}; + BOOST_REQUIRE(index); + BOOST_CHECK_EQUAL(index->nStatus & BLOCK_FAILED_MASK, 0U); + BOOST_CHECK_EQUAL(snapshot_chainstate->m_chain.Tip(), snapshot_tip); + } + + // The same holds for the rotating per-index scan, and a scan that the + // commitments mined above the base do satisfy is complete. + const auto rotating_type{Consensus::LLMQType::LLMQ_TEST_DIP0024}; + { + LOCK(::cs_main); + EvoViewScope background_view{m_node.evodb->ViewFor(EvoDbIdentity::NORMAL)}; + BOOST_CHECK(!qblockman.GetLastMinedCommitmentsByQuorumIndexUntilBlock(rotating_type, snapshot_tip, 0, 0)); + } + { + LOCK(::cs_main); + auto tx = m_node.evodb->BeginTransaction(EvoDbIdentity::SNAPSHOT); + // The keys mirror DB_MINED_COMMITMENT_BY_INVERSED_HEIGHT(_Q_INDEXED). + const uint32_t mined_key{htobe32_internal(std::numeric_limits::max() - snapshot_mined_block->nHeight)}; + m_node.evodb->Write(std::make_tuple(std::string{"q_mcih"}, platform_type, mined_key), quorum_base->nHeight); + m_node.evodb->Write(std::make_tuple(std::string{"q_mcihi"}, rotating_type, 0, mined_key), quorum_base->nHeight); + tx->Commit(); + } + { + LOCK(::cs_main); + EvoViewScope snapshot_view{m_node.evodb->ViewFor(EvoDbIdentity::SNAPSHOT)}; + BOOST_CHECK_THROW(qblockman.GetLastMinedCommitmentsByQuorumIndexUntilBlock(rotating_type, snapshot_tip, 0, 1), + BlockDataUnavailableError); + BOOST_CHECK(qblockman.GetLastMinedCommitmentsByQuorumIndexUntilBlock(rotating_type, snapshot_tip, 0, 0) == + quorum_base); + BOOST_CHECK(qblockman.GetMinedCommitmentsUntilBlock(platform_type, snapshot_tip, 1) == + std::vector{quorum_base}); + BOOST_CHECK_THROW(qblockman.GetMinedCommitmentsUntilBlock(platform_type, snapshot_tip, 2), + BlockDataUnavailableError); + } + SetMiscWarning(Untranslated("")); } BOOST_FIXTURE_TEST_CASE(chainstatemanager_evodb_snapshot_only_flush_restart, SnapshotTestSetup) diff --git a/src/validation.cpp b/src/validation.cpp index 8c46718bfe5d..037efd73333a 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -3464,7 +3464,8 @@ void Chainstate::UpdateTip(const CBlockIndex* pindexNew) const CBlockIndex* pindex = pindexNew; for (int bit = 0; bit < VERSIONBITS_NUM_BITS; bit++) { WarningBitsConditionChecker checker(m_chainman, bit); - ThresholdState state = checker.GetStateFor(pindex, params.GetConsensus(), m_chainman.m_warningcache.at(bit)); + ThresholdState state = checker.GetStateFor(pindex, params.GetConsensus(), + m_chainman.m_warningcache.For(m_evoDb.CurrentView()).at(bit)); if (state == ThresholdState::ACTIVE || state == ThresholdState::LOCKED_IN) { const bilingual_str warning = strprintf(_("Unknown new rules activated (versionbit %i)"), bit); if (state == ThresholdState::ACTIVE) { @@ -6348,9 +6349,18 @@ Chainstate& ChainstateManager::InitializeChainstate(CTxMemPool* mempool, m_ibd_chainstate = std::make_unique( mempool, m_blockman, *this, evoDb, special_tx, chainlocks, isman); m_active_chainstate = m_ibd_chainstate.get(); + m_versionbitscache.SetEvoDb(&evoDb); return *m_active_chainstate; } +std::shared_ptr EvoViewVersionBitsCache::ForCurrentView() const +{ + const CEvoDB* evodb{WITH_LOCK(m_mutex, return m_evodb)}; + const EvoViewToken view{evodb ? evodb->CurrentView() : EvoViewToken{}}; + LOCK(m_mutex); + return m_parts.For(view); +} + const AssumeutxoData* ExpectedAssumeutxo( const int height, const CChainParams& chainparams) { diff --git a/src/validation.h b/src/validation.h index 0697af71a100..fe887d8ff5ba 100644 --- a/src/validation.h +++ b/src/validation.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include // IWYU pragma: export @@ -903,6 +904,60 @@ enum class SnapshotCompletionResult { EVO_STATE_MISMATCH, }; +/** VersionBitsCache partitioned by Evo view. EHF signals that gate deployment + * states are Evo state, so a state computed under one view is never served to + * another. The calling thread's view also selects the EHF signals that + * SignalHeight reads, so the partition and its inputs always agree. */ +class EvoViewVersionBitsCache +{ +public: + //! Bind the EvoDB whose views key the partitions. + void SetEvoDb(const CEvoDB* evodb) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + LOCK(m_mutex); + m_evodb = evodb; + } + + std::shared_ptr ForCurrentView() const EXCLUSIVE_LOCKS_REQUIRED(!m_mutex); + + BIP9Stats Statistics(const CBlockIndex* pindex, const Consensus::Params& params, Consensus::DeploymentPos pos, + std::vector* signalling_blocks = nullptr) const EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + return ForCurrentView()->Statistics(pindex, params, pos, signalling_blocks); + } + static uint32_t Mask(const Consensus::Params& params, Consensus::DeploymentPos pos) + { + return VersionBitsCache::Mask(params, pos); + } + ThresholdState State(const CBlockIndex* pindexPrev, const Consensus::Params& params, + Consensus::DeploymentPos pos) const EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + return ForCurrentView()->State(pindexPrev, params, pos); + } + int StateSinceHeight(const CBlockIndex* pindexPrev, const Consensus::Params& params, + Consensus::DeploymentPos pos) const EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + return ForCurrentView()->StateSinceHeight(pindexPrev, params, pos); + } + int32_t ComputeBlockVersion(const CBlockIndex* pindexPrev, const Consensus::Params& params) const + EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + return ForCurrentView()->ComputeBlockVersion(pindexPrev, params); + } + void Clear() EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + LOCK(m_mutex); + m_parts.Clear(); + } + +private: + mutable Mutex m_mutex; + const CEvoDB* m_evodb GUARDED_BY(m_mutex){nullptr}; + // Shared, so a partition dropped by another thread outlives its users. + mutable EvoViewCache> m_parts GUARDED_BY(m_mutex){ + [] { return std::make_shared(); }}; +}; + /** * Provides an interface for creating and interacting with one or two * chainstates: an IBD chainstate generated by downloading blocks, and @@ -991,7 +1046,7 @@ class ChainstateManager const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(cs_main); friend Chainstate; - std::array m_warningcache GUARDED_BY(::cs_main); + EvoViewCache> m_warningcache GUARDED_BY(::cs_main); //! Returns nullptr if no snapshot has been loaded. const CBlockIndex* GetSnapshotBaseBlock() const EXCLUSIVE_LOCKS_REQUIRED(::cs_main); @@ -1166,9 +1221,10 @@ class ChainstateManager } /** - * Track versionbit status + * Track versionbit status. EHF-gated deployments depend on Evo state, so + * the cache is partitioned by the calling operation's Evo view. */ - mutable VersionBitsCache m_versionbitscache; + mutable EvoViewVersionBitsCache m_versionbitscache; //! @returns true if a snapshot-based chainstate is in use. Also implies //! that a background validation chainstate is also in use. @@ -1326,13 +1382,13 @@ class ChainstateManager template bool DeploymentActiveAfter(const CBlockIndex* pindexPrev, const ChainstateManager& chainman, DEP dep) { - return DeploymentActiveAfter(pindexPrev, chainman.GetConsensus(), dep, chainman.m_versionbitscache); + return DeploymentActiveAfter(pindexPrev, chainman.GetConsensus(), dep, *chainman.m_versionbitscache.ForCurrentView()); } template bool DeploymentActiveAt(const CBlockIndex& index, const ChainstateManager& chainman, DEP dep) { - return DeploymentActiveAt(index, chainman.GetConsensus(), dep, chainman.m_versionbitscache); + return DeploymentActiveAt(index, chainman.GetConsensus(), dep, *chainman.m_versionbitscache.ForCurrentView()); } template From 1bd2c2663f31d056d6ad742141b88666d8930dbc Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 15 Oct 2026 01:29:04 -0500 Subject: [PATCH 05/16] fix(evo): bind non-active paths and dispatcher boundaries explicitly Evo accesses resolved their view from an open transaction or, failing that, the active chainstate. Work for the background chainstate outside a transaction, and work handed to another thread, silently used the active (snapshot) view instead. ForceSignalDBUpdate, for one, opened a NORMAL transaction at the active snapshot tip and flushed the NORMAL root. Every Chainstate method that reads Evo state (ActivateBestChain, ConnectTip, DisconnectTip, ReplayBlocks, LoadChainTip, RollforwardBlock, FlushStateToDisk, InvalidateBlock, EnforceBlock), TestBlockValidity, VerifyDB and the per-chainstate startup loops now bind that chainstate's view. ForceSignalDBUpdate takes the intended chainstate's view and uses its identity for the transaction and the root commit. Signals that view cannot rebuild are left to be rebuilt on demand, so startup can still reach the promotion of a validated snapshot. DMN repair binds NORMAL. Active-chain dispatchers bind the active chainstate's view per operation unless the thread is already bound: RPC execution, each peer's message-handler turn (its queued GETDATA requests, its next message and its sends), the miner, mempool admission, Evo-reading interfaces calls, each node-scheduler task and each ChainLock, DKG and InstantSend work round. A local Evo state error ends the peer's turn, never the message-handler thread, and GETDATA requests leave the queue before they are served, so a failed lookup neither serves earlier requests again nor keeps the peer's later messages waiting; the NOTFOUND replies completed before it are still sent. The node scheduler and REST start before the block databases load, and a load retry replaces the Evo database, so they bind only to the one startup publishes once loading is final (and withdraws before destroying it, after they have stopped); until then their work runs unbound. RPC is held off by its warm-up until then. A chainstate load retry first lets the validation events the failed attempt queued run, since they are bound to the databases it then releases. Queued validation events and LLMQ worker-pool tasks capture the enqueuing thread's binding and reinstall it when they run; a task bound to a retired generation, or whose binding cannot be installed, is dropped, and what its enqueuer claimed for it is released. A local Evo state error in one subscriber to a queued event stays with that subscriber, and the others still receive the event; synchronous signals still pass it on to their caller. Either way the subscriber's registration count stays right, so it can still be unregistered. Every GUI- and wallet-facing interface operation that reads Evo state (the EVO provider operations, Evo, governance and LLMQ reads, isV24Active and listMNCollaterials) binds the active view and contains evo::LocalStateError at the interface boundary: it logs once per operation and generation, sets the misc warning, and reports failure through the operation's own channel. Operations without one now return std::optional (or, for GovernanceInfo, an optional fundingthreshold), and their Qt and wallet consumers keep their current data, show that the masternode state is unavailable, or refuse the action, instead of treating it as data. While the masternode list is unavailable, the wallet keeps outputs it cannot classify out of automatic coin selection, CoinJoin's included, and classifies them again on the next block. A chainstate's remembered Evo stall lapses once its view changes. Co-Authored-By: Claude Opus 5.5 --- src/chainlock/handler.cpp | 35 +- src/chainlock/signing.cpp | 3 + src/evo/deterministicmns.cpp | 11 +- src/evo/deterministicmns.h | 2 + src/evo/evodb.cpp | 27 + src/evo/evodb.h | 107 +++ src/evo/mnhftx.cpp | 21 +- src/evo/mnhftx.h | 6 +- src/init.cpp | 244 +++--- src/init.h | 17 + src/instantsend/net_instantsend.cpp | 9 +- src/interfaces/chain.h | 11 +- src/interfaces/node.h | 32 +- src/interfaces/providertx.h | 6 +- src/interfaces/wallet.h | 4 +- src/llmq/net_dkg.cpp | 10 + src/llmq/net_quorum.cpp | 37 +- src/llmq/net_signing.cpp | 119 +-- src/llmq/quorumsman.h | 2 + src/llmq/signing.cpp | 2 + src/llmq/signing.h | 4 + src/net.cpp | 43 +- src/net.h | 9 +- src/net_processing.cpp | 35 +- src/node/chainstate.cpp | 4 +- src/node/context.h | 5 + src/node/interfaces.cpp | 701 +++++++++++------- src/node/miner.cpp | 1 + src/qt/bitcoingui.cpp | 4 +- src/qt/clientfeeds.cpp | 36 +- src/qt/masternodedialogs.cpp | 12 - src/qt/masternodedialogs.h | 8 +- src/qt/masternodelist.cpp | 37 +- src/qt/masternodelist.h | 4 + src/qt/masternodewidgets.cpp | 5 + src/qt/masternodewidgets.h | 3 + src/qt/masternodewizard.cpp | 72 +- src/qt/proposallist.cpp | 15 +- src/qt/proposalmodel.cpp | 22 +- src/qt/proposalmodel.h | 21 +- src/qt/sharedmncreatedialog.cpp | 15 +- src/qt/sharedmncreatedialog.h | 2 +- src/qt/sharedmndialogs.cpp | 10 +- src/qt/sharedmndialogs.h | 4 +- src/qt/sharedmnwidgets.cpp | 7 +- src/qt/sharedmnwidgets.h | 2 +- src/qt/test/masternodewidgettests.cpp | 29 +- src/qt/test/sharedmnwalkthroughtests.cpp | 2 +- src/rest.cpp | 15 +- src/rpc/evo.cpp | 7 +- src/rpc/server.cpp | 6 + src/scheduler.cpp | 6 +- src/scheduler.h | 15 + src/test/evo_db_tests.cpp | 52 ++ src/test/interfaces_tests.cpp | 64 +- src/test/scheduler_tests.cpp | 28 + src/test/util/net.h | 2 + src/test/validation_chainstate_tests.cpp | 86 ++- .../validation_chainstatemanager_tests.cpp | 153 ++++ src/test/validationinterface_tests.cpp | 72 ++ src/validation.cpp | 22 +- src/validation.h | 11 +- src/validationinterface.cpp | 42 +- src/wallet/coinjoin.cpp | 6 +- src/wallet/interfaces.cpp | 2 +- src/wallet/rpc/spend.cpp | 6 +- src/wallet/spend.cpp | 3 +- src/wallet/test/wallet_tests.cpp | 65 ++ src/wallet/wallet.cpp | 34 +- src/wallet/wallet.h | 14 +- 70 files changed, 1912 insertions(+), 616 deletions(-) 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/evo/deterministicmns.cpp b/src/evo/deterministicmns.cpp index 9f540048c727..c78c0e29c90a 100644 --- a/src/evo/deterministicmns.cpp +++ b/src/evo/deterministicmns.cpp @@ -1201,6 +1201,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; @@ -1309,11 +1311,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(); diff --git a/src/evo/deterministicmns.h b/src/evo/deterministicmns.h index 010b0c43e0c5..5eea3cfd7214 100644 --- a/src/evo/deterministicmns.h +++ b/src/evo/deterministicmns.h @@ -796,6 +796,8 @@ class CDeterministicMNManager return GetListForBlockInternal(pindex, View()); }; CDeterministicMNList GetListAtChainTip() 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) { diff --git a/src/evo/evodb.cpp b/src/evo/evodb.cpp index 5efdc97f9f5f..da6dd26ea8c0 100644 --- a/src/evo/evodb.cpp +++ b/src/evo/evodb.cpp @@ -147,6 +147,30 @@ std::optional EvoViewScope::InnermostAny() 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) { @@ -320,6 +344,7 @@ void CEvoDB::BindSnapshotGeneration(std::optional record) transaction_contexts.erase(it); } m_snapshot_generation = std::move(record); + UpdateLiveGeneration(); if (!m_snapshot_generation) m_default_identity = EvoDbIdentity::NORMAL; } @@ -378,6 +403,7 @@ bool CEvoDB::PublishSnapshotGeneration(const uint256& config_fingerprint, const if (base_mn_list_hash) batch.Write(EVODB_SNAPSHOT_MNLIST_HASH, *base_mn_list_hash); if (!db->WriteBatch(batch, /*fSync=*/true)) return false; m_snapshot_generation = record; + UpdateLiveGeneration(); return true; } @@ -570,6 +596,7 @@ bool CEvoDB::PrepareSnapshotPromotion(const uint256& snapshot_tip) batch.Write(GenerationRecordKey(record.generation), record); if (!db->WriteBatch(batch, /*fSync=*/true)) return false; m_snapshot_generation = record; + UpdateLiveGeneration(); return true; } diff --git a/src/evo/evodb.h b/src/evo/evodb.h index e34cdf9f2466..aa1429f4eb46 100644 --- a/src/evo/evodb.h +++ b/src/evo/evodb.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -357,6 +358,16 @@ class CEvoDB EvoViewToken CurrentView() 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); @@ -562,6 +573,7 @@ class CEvoDB } 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); @@ -570,6 +582,7 @@ class CEvoDB 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); @@ -577,4 +590,98 @@ class CEvoDB 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 593637581d9a..32fa9db5b5fd 100644 --- a/src/evo/mnhftx.cpp +++ b/src/evo/mnhftx.cpp @@ -398,19 +398,32 @@ void CMNHFManager::AddToCache(const Signals& signals, const CBlockIndex* const p mnhfCache.For(view).insert(pindex->GetBlockHash(), signals); } -bool CMNHFManager::ForceSignalDBUpdate(const CBlockIndex* tip) +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 7d2508d9a548..8ee97b180578 100644 --- a/src/evo/mnhftx.h +++ b/src/evo/mnhftx.h @@ -132,7 +132,11 @@ 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); + //! 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); private: void AddToCache(const Signals& signals, const CBlockIndex* const pindex) EXCLUSIVE_LOCKS_REQUIRED(!cs_cache); 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 4eb2c5a23f95..23c22ce39793 100644 --- a/src/instantsend/net_instantsend.cpp +++ b/src/instantsend/net_instantsend.cpp @@ -427,8 +427,9 @@ 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()) { @@ -438,8 +439,8 @@ void NetInstantSend::WorkThreadMain() 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; 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/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.h b/src/llmq/quorumsman.h index a43cf073c2b1..442761c80d33 100644 --- a/src/llmq/quorumsman.h +++ b/src/llmq/quorumsman.h @@ -135,6 +135,8 @@ class CQuorumManager final //! 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 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/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 ebdbbc98165a..c92d6f19a226 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -1107,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); @@ -2953,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()}; @@ -2979,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 @@ -3101,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 @@ -3126,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) { diff --git a/src/node/chainstate.cpp b/src/node/chainstate.cpp index d410ab2b384d..d94852b5ebe3 100644 --- a/src/node/chainstate.cpp +++ b/src/node/chainstate.cpp @@ -238,6 +238,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( @@ -288,7 +289,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")}; } @@ -440,6 +441,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..a6010792244c 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,45 @@ 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. +void ReportEvoStateUnavailable(const CEvoDB* evodb, std::string_view operation, const evo::LocalStateError& e) +{ + WarnLocalEvoStateUnavailable(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}; + return fn(); + } catch (const evo::LocalStateError& e) { + ReportEvoStateUnavailable(evodb, operation, e); + return on_unavailable(); + } +} + class MnEntryImpl : public MnEntry { private: @@ -263,17 +307,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 +336,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 +441,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 +510,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 +569,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; - } - if (result.allocated + payment_amount > budget) { - // Budget is saturated, cannot fulfill proposal - 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; } - 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 +711,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 +771,96 @@ class LLMQImpl : public LLMQ return entry.GetTx().IsPlatformTransfer(); })); } - std::vector getQuorumStats() override + std::optional> 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::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 +1275,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 +1617,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 +1791,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 e87bf34e897f..07e346f2be0f 100644 --- a/src/node/miner.cpp +++ b/src/node/miner.cpp @@ -220,6 +220,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(); 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