Skip to content

feat: load AssumeUTXO snapshots with loadtxoutset and validate them in the background (regtest only) - #8078

Draft
PastaPastaPasta wants to merge 53 commits into
dashpay:developfrom
PastaPastaPasta:feat/assumeutxo-loader
Draft

PastaPastaPasta wants to merge 53 commits into
dashpay:developfrom
PastaPastaPasta:feat/assumeutxo-loader

Conversation

@PastaPastaPasta

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

The AssumeUTXO series so far (#7887, #7593, #7601) isolates Evo state per chainstate, defines and verifies a v4 Evo snapshot section, and seeds and promotes it. But nothing in the tree can actually load a snapshot. There is no loadtxoutset, no background block download, and no node ever runs two chainstates outside unit fixtures. So the central claim of the series, that a snapshot node accepts and rejects exactly what a fully validated node does and stops when it cannot decide, has only been tested against fixtures that fake the activation.

This PR adds the loader and the background validation on top of that series: the bitcoin#27596 family (loadtxoutset, getchainstates, ChainstateRole on validation-interface events, background block download, indexing and pruning across two chainstates) with its follow-ups, the Dash refusals the design requires, and an end-to-end test on a Dash masternode network. Running real two-chainstate nodes exposed defects that the fixtures could not reach; their fixes are in this PR and listed under "What was done".

loadtxoutset and getchainstates are hidden and regtest-only, and dumptxoutset stays hidden. No network except regtest authorizes a snapshot, because no network has a pin, and -assumeutxodata is refused off regtest. So nothing a mainnet or testnet user can reach changes.

Stack position. This is the fourth layer of a stack. It depends on #8049, #8067 (bitcoin#28685), #8066 and #8069, then #7887, #7593 and #7601, none of which has merged. Against develop the PR therefore shows 54 commits; only the top 25 belong to this PR. It stays a draft until the layers below it merge. The next layer, the upstream hardening ports, is a separate draft PR on top of this one.

This PR reverts the mempool handback of ab3b6711675 (#7553). That commit hands the snapshot chainstate's mempool to the validated chainstate when a snapshot is rejected. The mempool's transactions were accepted against the snapshot tip, so with -checkmempool (the regtest default) the check after completion aborted on a child of a transaction mined above the base, and a rejection raised while the snapshot chainstate connects a block ran historical blocks against that mempool. Keeping the mempool with the rejected chainstate, as upstream does, aborts too, because Dash's mid-step rejection resets that chainstate's coins views while its mempool update is still unwinding. So the mempool is detached from both chainstates on rejection instead. Develop cannot reach the handback without the loader, so no change on develop is needed.

What was done?

25 commits: 15 upstream merges and 10 Dash commits.

Upstream ports (each commit message lists the upstream commits and the Dash adaptations):

Dash adaptations of bitcoin#27596 (the full list is in its commit message):

  • The RPCs are hidden and regtest-only. Events of the background chainstate carry their role, and the Dash subscribers (ChainLock handler and signer, InstantSend, CoinJoin, masternode notifications, the coinbase ChainLock check in net processing) ignore background events as the wallet and ZMQ do.
  • Indexes, the governance collateral index included, follow the indexing chainstate.
  • Activation needs strictly more work than the active tip, at load and at the switch. Upstream's comparator breaks ties by nSequenceId, which would admit an equal-work sibling and make startup completion reject an honest snapshot.
  • The minimum prune share of a chainstate is capped at the -prune budget, so a single regtest chainstate with -prune=550 still prunes (Dash's minimum is 945 MiB).
  • An active-chainstate Evo stall no longer stops the background chainstate: ProcessNewBlock still runs its ActivateBestChain, otherwise background validation, and the promotion that ends the stall, would freeze.
  • Interim, removed by assumeutxo: Get rid of faked nTx and nChainTx values bitcoin/bitcoin#29370 in the next PR: CheckBlockIndex skips the monotonic nChainTx check at and below the base while a snapshot chainstate exists, and BlockManager::m_faked_tx_height records the highest block loaded with a faked count.
  • Dropped: blockfile segmentation (a node with a snapshot chainstate does not prune), the testnet and signet parameters, the demo script and the release notes (the RPCs are hidden).

Other Dash commits:

  1. feat(validation): refuse snapshots on pruned nodes, masternodes and with legacy indexes: refusals at load and again at the switch, and at startup while a snapshot chainstate exists. This also refuses a second snapshot after a rejection in the same run (it used to abort on assert(!m_snapshot_chainstate)), and a snapshot while chainstate_snapshot_INVALID exists.
  2. fix(governance): wait for the collateral index to follow the active chainstate: the index reported itself synced at the background tip, so governance processed proposals whose collateral lies above it and penalized honest peers with Misbehaving(20). IsCollateralSynced() now also requires the index to follow the active chainstate. gobject submit and gobject prepare refuse with RPC_MISC_ERROR while it is behind.
  3. fix(validation): serialize snapshot activations: a second concurrent loadtxoutset could delete the first one's directory.
  4. fix(validation): replace a leftover chainstate_todelete at promotion: after a reindex a leftover directory made promotion fail on the rename.
  5. fix(validation): keep the validated chainstate's block candidates when a snapshot is rejected: blocks the snapshot chain connected above the base were missing from the candidates, so the next CheckBlockIndex aborted. Inherited from assumeutxo: background validation completion bitcoin/bitcoin#25740; bitcoin/master still has it.
  6. fix(validation): detach the mempool when a snapshot is rejected: described above.
  7. test: retry InstantSend conflicts that a snapshot stall deferred.
  8. test: add an end-to-end AssumeUTXO test on a Dash masternode network (feature_assumeutxo_dash.py). Three cold nodes that know only the headers load dumps at bases 3, 23 and 18 blocks into a rotation cycle (the last inside the mining window). It covers a mutated coin, a mutated Evo section and a pin with the wrong hash (all rejected); -reindex and -reindex-chainstate in dual mode; rejection at completion with a mempool child of an above-base transaction (the node stops with exit 1 and recovers); a real InstantSend conflict on the snapshot chain; a basic-scheme ChainLock accepted while the background chainstate is before V19; an EHF signal of an uncarried quorum stalling the snapshot chain, across a restart, with background validation still completing; restart and promotion.
  9. test: check the registration wizard recovers once masternode state is readable (Qt).

Not in this PR: pins for any network, un-hiding the RPCs, dual-mode pruning, user documentation, the upstream ports that fix inherited behaviour found later (next PR), and the production-enablement items recorded in the plan.

How Has This Been Tested?

Environment: gcc-11 --enable-werror (release and BDB --enable-debug configurations, Qt, sqlite, zmq) and clang-19 with -Wthread-safety -Werror, in containers on Linux x86_64. "Fork CI" is the CI workflow on PastaPastaPasta/dash for the same commit.

  • Final head (9957dfae3b1a): fork CI run 42609423696 passed. The previous head 0e72434f306, which differs only by a test-only change, also passed (run 42596251639).
  • Unit: make check exit 0 with 173 Boost suites, no errors; test_dash-qt 163 passed, 0 failed, 2 skipped. Each review round also ran per-commit gcc-11 builds with the targeted suites for the commits it changed.
  • Functional: test_runner.py -j8 --extended --exclude feature_pruning,feature_dbcrash without failfast: 340 tests. The last full run on a head with the same C++ ran 325 passed, 13 skipped, 2 failed. One failure was feature_assumeutxo_dash.py (the test-only flake fixed in the last commit: node 0's wallet could spend the parent output before the child was signed); the other, p2p_platform_ban.py, is a DKG-timing flake under load that passes 3/3 alone at the tip and 3/3 at the previous head. The preceding full run on the earlier L4a head had 0 failures in 340 tests. The end-to-end test alone passed 12/12 stress runs at the final head, with the child signed every time.
  • Fail-before checks (each in its own copy, with the fix reverted and tests kept):
    • the mempool detachment: keeping the mempool with the rejected chainstate aborts chainstatemanager_rejection_mid_connect_keeps_active_bls_scheme at validation.h:667; reverting validation: Check if mempool exists before size check in ActivateSnapshot bitcoin/bitcoin#30388 aborts chainstatemanager_evo_snapshot_refused_when_loaded in ActivateSnapshot; restoring the handback makes the end-to-end test's node exit with -6 on mempoolDuplicate.HaveCoin(txin.prevout) (txmempool.cpp:1436) instead of exit 1, and its control run passes;
    • the governance recheck: removing it fails the new assertion [20 != 0].
  • Static checks: ci/dash/lint.sh over the 54 commits, cppcheck-dash.py and clang-tidy 19 on validation.cpp report nothing; a negative control for the clang-tidy method reports a planted finding.
  • Reviews: a GPT-based reviewer and a Claude Opus reviewer each read the whole layer. After their findings were resolved, the GPT reviewer found the final design ready, conditional only on the test-only wallet lock, which was then applied and stress-tested.
  • Known flake, not caused by this PR: feature_masternode_shares.py can fail at line 408 with "Timeout downloading block" in node1's log. It is a test race (a block still in flight when the mock time is bumped past the download timeout) exposed by what looks like a lost wakeup in the net layer on develop. The reviewers judged it not caused by this PR.
  • Not covered by a test: a mid-disconnect rejection (checked by reading the code only), and DumpMempool followed by a reload of a detached mempool (the end-to-end test runs with -persistmempool=0).

Breaking Changes

None for any network except regtest. On regtest with a snapshot directory present, the node refuses -prune, -addressindex, -spentindex, -timestampindex and -masternodeblsprivkey until a restart has promoted the snapshot, and -assumeutxodata is refused everywhere else. A single regtest chainstate with a small -prune target again prunes to exactly that target.

Checklist:

  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

@thepastaclaw

thepastaclaw commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

🕓 Review not started yet because this PR is a draft.

  • Request normal review — click when the PR is ready for review.
  • Request priority review — click to move this review to the front of the queue.

Commit 23122e9. Normal review starts when eligible; priority review starts as soon as a slot is available.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

PastaPastaPasta and others added 26 commits November 9, 2026 19:08
…ncomplete

While the snapshot chainstate is active and unvalidated, every DKG phase
handler thread spun with no delay: WaitForNextPhase(nullopt, Initialized),
the first call of each round, threw AbortPhaseException on its first check,
and PhaseHandlerThread caught it and restarted the round at once. Each
iteration took cs_main, threw and caught an exception, called MarkAborted
and, under -debug=llmq-dkg, logged four lines.

No round can start in that state (InitNewQuorum refuses it), so the
round-start wait now sleeps and re-checks instead of aborting. A round in
progress is still abandoned, a stop request still ends the wait, and nodes
not running on an unvalidated snapshot execute the same code as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BLS operations that are not given a scheme read the process-wide
bls::bls_legacy_scheme. Add bls::EffectiveLegacyScheme(), which returns
a thread-local override when one is set (bls::ScopedLegacySchemeOverride)
and the process-wide value otherwise, and route every implicit read
through it: the CBLSWrapper and CBLSLazyWrapper serialization defaults,
the implicit aggregate and verify overloads, sig share signing, recovered
sigs, DKG contributions, the coinbase ChainLock decode cache, the miner
and `verifyrecsig`.

Work that runs on another thread captures the scheme where it is created:
utils::BlsCheck (run by the commitment CCheckQueue), CBLSBatchVerifier,
and CBLSWorker's AsyncSign and AsyncVerifySig jobs, using new
explicit-scheme VerifyInsecureAggregated/VerifySecureAggregated
overloads. AsyncVerifySig no longer batches jobs of different schemes.

Nothing sets an override yet, so behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ConnectBlock establishes the BLS scheme of the block it connects by
writing the process-wide bls::bls_legacy_scheme, and ConnectTip and
DisconnectTip restore it only on return. A background chainstate
validating pre-V19 history under a post-V19 snapshot therefore leaves
the whole process on the legacy scheme for most of each background block
connection, while other threads verify ChainLocks, recovered signatures
and InstantSend locks for the active chain outside cs_main. Valid
ChainLocks then fail (Misbehaving(10) and a poisoned seen set) and
batched recovered-signature verification bans honest peers.

ScopedBLSLegacyScheme now writes the process-wide scheme only for a
scope that may commit the active chainstate's tip. Every other scope (a
background chainstate, a TestBlockValidity dry run, VerifyDB,
RollforwardBlock), and any scope nested in one, sets a thread-local
override instead. The V19 switch in Process/UndoSpecialTxsInBlock goes
through bls::SetEffectiveLegacyScheme(), and the EHF signal rebuild at
startup uses a scoped override instead of flipping the global.
ConnectTip and DisconnectTip Assume() that no thread-local scope
encloses a move of the active chainstate's tip.

With a single chainstate the active chain's path is unchanged. The
thread-local scopes give their own thread the values the process-wide
flag used to take, but other threads no longer see them; for example a
`verifychain 4` reaching below V19 no longer switches the running node
to legacy while it runs. VerifyDB no longer touches the process-wide
scheme at all, so VerifyLoadedChainstate's re-store of it after VerifyDB
(already unreachable, because VerifyDB restored the scheme on return) is
removed.

The existing background-connect test never connected a block (the
snapshot setup drops the data below the base). It now restores one
block's data, connects it, and checks from inside ConnectTip that
another thread still verifies a basic signature and batch. The V19
boundary test also runs a level-4 VerifyDB across V19 and checks that
the process-wide scheme stays basic throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…changes

The process-wide bls::bls_legacy_scheme describes the active
chainstate's tip, and while the node runs only the active chainstate's
ConnectTip and DisconnectTip update it. ActivateSnapshot and the
invalid-snapshot fallback in MaybeCompleteSnapshotValidation switch the
active chainstate without doing so. After a node whose tip is pre-V19,
or that has no coins yet, activates a post-V19 snapshot, every thread
that verifies for the active chain stays on the legacy scheme until the
snapshot chainstate connects a block above its base: valid ChainLocks
fail and recovered-signature batches mark honest sources bad. The
fallback leaves the reverse mismatch.

Set the process-wide scheme from the new active tip right after both
switches. The fallback runs inside the background chainstate's
ConnectTip, under that thread's override, so the helper writes the
global directly instead of going through SetEffectiveLegacyScheme().

ActivateExistingSnapshot and InitializeChainstate need no call: they
run at init, before the tip is loaded and before any consumer thread
starts, and VerifyLoadedChainstate establishes a post-V19 tip's scheme.

On develop only tests activate a snapshot, so this is latent until a
snapshot loader lands. The new tests activate a post-V19 snapshot over a
legacy process and a pre-V19 snapshot over a basic one, and fall back
from an invalid snapshot under a thread-local override; each checks the
process-wide scheme, and the first and last also check that a fresh
thread accepts a signature in the new active tip's scheme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GetMinedCommitmentsUntilBlock stops silently when an index entry cannot
be read, so a short result cannot tell a young chain from a damaged
index. A caller that must not mistake a damaged index for a short one
needs that difference.

ScanMinedCommitmentsUntilBlock and its indexed variant return the same
quorum bases plus `complete`: true when the requested number of entries
was read or the walk left the type's index range, false when an entry
inside the range could not be read or cannot be trusted: a value that
does not deserialize, an impossible base height, quorum bases that do
not strictly decrease along an index (a commitment is mined within its
own quorum's DKG interval) or repeat across quorum indexes, a LevelDB
read error, or a table block that the iterator stepped over because it
failed its checksum. The bases read before that point are kept, except
those a contradiction implicates: either entry of a pair that does not
decrease, and both entries naming a repeated base, may be the wrong
one, so neither is returned. Once the window is full no further entry
or iterator status decides coverage.

CDBIterator::HasError() exposes LevelDB's iterator status for this,
since Valid() alone reads a skipped corrupt block as the end of the
data. The range end is detected by comparing the raw key prefix before
deserializing, and malformed values are reported instead of asserted
on. Existing GetMinedCommitments* callers are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getPlatformQuorums exported every retained Platform quorum key (1440 on
mainnet) with no notion of when each quorum could sign. A verifier that
accepts any of them accepts state signed by a quorum that was never in
the signing set at the state's Core height, and an empty or short list
reads the same as "this quorum does not exist", which would ban an
honest endpoint on a node that is merely behind or has a damaged index.
It has no production caller.

getPlatformQuorum(llmq_type, quorum_hash, core_chain_locked_height)
replaces it and answers FOUND (with the 48-byte basic-scheme key),
INVALID or UNAVAILABLE:

- FOUND: the quorum is in the window at that height, the newest
  commitments mined at or below it: signingActiveQuorumCount + 2 for a
  non-rotating type, and 3 * signingActiveQuorumCount for a rotating
  type, read cycle by cycle across its quorum indexes. Each is a
  quorum's signing-active period plus two DKG intervals of slack.
- INVALID: not the Platform type; a height above INT32_MAX; a height
  more than those DKG intervals ((signingActiveQuorumCount + 2), or 3
  for a rotating type) below a ChainLock this node knows, clamped to
  the tip; or a quorum hash that is no block of the active chain below
  that height, when the window was read in full and the block at that
  height is covered by the best ChainLock whose block this node knows.
- UNAVAILABLE: everything else. That includes missing node services, a
  height above the tip, a height that only the tip shows stale because
  no ChainLock is known (after a restart during a ChainLock stall,
  Platform may still carry the stalled one), and a selected commitment
  record that cannot be read, is missing, is inconsistent with its
  index entry or carries an invalid key. Mined commitments passed
  consensus validation, so those record states are local faults, never
  evidence against the quorum, and a database read error is answered
  rather than thrown.

A quorum outside the window is UNAVAILABLE, not INVALID, when it was
mined at or below the height. Platform verifies a block against its
last committed validator set, and Tenderdash installs a new set only
when it commits, so after a Core-height jump the first Platform block
carrying the new height is signed by the outgoing quorum however far it
has fallen behind the window. The lookup counts a quorum as never mined
by then only when its hash is no block of the active chain below the
height.

That proof covers this node's active chain only. Above a ChainLock that
chain may be a competing branch, and a ChainLock's height alone does
not rule that out: a CLSIG can name a block this node does not know or
a branch it has not activated yet. A quorum found in the window is
answered regardless of ChainLock coverage.

Both ChainLock reads happen before taking cs_main: the best lock whose
block is known, which alone can lock part of the active chain, and the
best height, which bounds staleness only and is clamped to the tip,
since a ChainLock can arrive before its block. The staleness bound is
the pure llmq::PlatformQuorumMinSignedHeight, the window size is
llmq::PlatformEligibleQuorumCount, and both follow
llmq::PlatformEligibleIntervals. For non-rotating types a lookup reads
at most signingActiveQuorumCount + 2 index entries and one commitment
record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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; a
body the node has (BLOCK_HAVE_DATA) but cannot read raises
BlockDataReadError. The readers take the data flag and the block position
under one cs_main lock. 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, a read error stops the
node like a block ConnectTip cannot read (AbortNode), and 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. A package is refused with the
mempool error evo-local-unavailable, which testmempoolaccept reports as
its package error and submitpackage as its failure.

HandleSnapshotStateMismatch is extracted from the
MaybeCompleteSnapshotValidation rejection lambda, and flushes both
chainstates first so it can also run mid-connection. When it does,
ConnectTip and DisconnectTip keep the BLS scheme it set for the again
active background chainstate instead of restoring the rejected one's.

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. Without a
snapshot, Evo state is missing only because the block data it is derived
from was pruned, and the warning says so and suggests -reindex. 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. A stalled block found
invalid or conflicting with a ChainLock since obstructs nothing: it is
forgotten, and a branch through it is removed like any unusable one.

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 never releases cs_main with the chain rewound
below the candidates it passed: it lists them again, as InvalidateBlock
does, and selects again with the stall remembered, so it normally
reconnects the chain it started from before it reports the stall. 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…th 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. Like the database iterator it wraps, a namespace iterator
reports read errors (HasError). WriteDerived therefore compares only
within its own view. CEvoDB gains WriteImmediate for view-aware writes
outside a transaction, unsynced like the raw write it replaces;
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, 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.

LevelDB skips a table block that fails its checksum and its iterator
stays valid at the next one, reporting the error only through its
status. Promotion, collection and generation loading check it before
each key and at the end, and stop with their journal and records in
place: otherwise a promotion could publish its tip without the records
it never copied, and a collection erase the record of a generation it
did not finish.

Every key a generation is seeded with is recorded in its seed manifest
with its family's merge policy (CEvoDB::WriteSeed). Promotion never
copies a seeded key into NORMAL, which derives that state itself. Where
NORMAL holds the same key, a masternode list must be canonically equal
(the same masternodes, internal ids and registration counter, however
the maps are laid out) and a byte-deterministic record byte-equal;
snapshot-only scaffolding is never compared, and a policy this version
does not know fails the promotion. An unseeded key comes from a block
above the base, which NORMAL never connects while the generation is
live, so one NORMAL also holds fails the promotion as well; only the
LLMQ best-block singleton is superseded.

A refusal for a changed consensus configuration names the generation,
its base block and height, its phase, that the changed component is
unknown because only the aggregate fingerprint is recorded, and how to
recover. It is made before startup changes anything on disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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. Retiring a generation drops its partitions at the next lookup and
leaves NORMAL's, so a failed snapshot load no longer empties the active
masternode list until the next block. Promoting a generation, like
opening a CEvoDB, bumps an epoch that drops every partition.
DeploymentActiveAfter/At through a ChainstateManager decide a buried
deployment by height alone, without resolving a view.

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 and the
stall tests that need one lookup to fail. With the hook, a test also
rejects a snapshot in the middle of a block connection and checks that
the process-wide BLS scheme stays the background chainstate's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 when it is a snapshot view, so
startup can still reach the promotion of a validated snapshot; NORMAL,
which lacks history only when it was pruned, still fails startup, as
does a body that cannot be read. DMN repair binds
NORMAL.

Active-chain dispatchers bind the active chainstate's view per operation
unless the thread is already bound, with a single view too, so a turn
that a snapshot publication overtakes finishes in the view it started
in. While a snapshot generation is live, the view is read under cs_main,
together with the active tip where the turn needs one
(ActiveEvoViewScope::Tip()), so a turn never pairs one chainstate's view
with another's tip. With a single view there is nothing to pair, and a
turn that needs no tip binds without waiting for cs_main.

The dispatchers are: 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 failing work round, the LLMQ signing loops' and each DKG phase
handler's included, is logged once per operation, view generation and
reason, and its recovery once the operation has gone a minute without
failing. A local Evo state error in a message handler drops that
message, like any exception there, without punishing the peer; one from
the peer's queued GETDATA requests ends receiving for that turn. Neither
ends the message-handler thread, and the peer's sends (including its
discouragement and ping timeout) still run. 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; it is
answered with NOTFOUND together with the requests found missing before
it, and the requests after it are served on the next turn. 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. getPlatformQuorum runs Q's lookup in the bound
view as well, and answers UNAVAILABLE when it cannot, or when a snapshot
was published after it bound its view. 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
block builds its wallet batch only for that work or for a block it
scans.

A chainstate's remembered Evo stall lapses once its view changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With two chainstates, an Evo access that no transaction or scope binds
says nothing about which chainstate it serves. Resolving it to the
default identity silently computed one chainstate's state from the
other's data.

While a snapshot generation is live, an unbound read, write, iterator
or cache lookup, and one bound to a retired generation, now raises
EvoViewBindingError before touching any state. The check is a plain
throw, so it holds in release builds. Manager entry points that take an
explicit chainstate (ProcessSpecialTxsInBlock, UndoSpecialTxsInBlock and
the quorum block processor's ProcessBlock and UndoBlock) refuse to run
unless the calling thread is bound to that chainstate's view, raising
EvoViewBindingError on connect and undo alike, so the chainstate
boundaries classify it as a local stall. With a
single chainstate the NORMAL default is unchanged unless
-checkevobinding is set.

A binding fault is a local error everywhere: state.Error during
validation, RPC_INTERNAL_ERROR over RPC, an aborted operation for P2P
and the miner, a logged and ended task for queued and scheduled work,
and an initialization failure at startup. It never becomes an empty
success, consensus invalidity, a peer penalty or a process abort.

A new test extends the snapshot chain past DIP3 and V20 activation and
the first disk-snapshot period with a registered masternode, and checks
that the real MN-list, credit-pool and EHF state it writes stays in its
generation and its cache partitions, and that NORMAL rebuilds all of it
after the snapshot is rejected and the node restarts.

-checkevobinding (default: on for regtest, and forced on in the unit
test fixtures) applies the same rule with a single chainstate: an
unbound access raises EvoViewBindingError before any snapshot exists, so
tests and regtest exercise the bindings everywhere. Test code that
drives the node the way a dispatcher does binds like one:
NotifyMNListTip delivers a tip notification in the active view, tests
and fuzz targets that drive a peer through the test connman bind its
turn as ServicePeer does, and test bodies that call into the node
directly open an active-chain turn. The interfaces sweep covers
getPlatformQuorum.

The release note records the externally visible changes:
RPC_INTERNAL_ERROR for local Evo errors, the package error, the
read-error shutdown, the pruned-data wording, the stall that replaces
invalidity on a pruned node, and strict binding on regtest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pshot base

During dual operation a quorum based at or below the snapshot base is in
history the snapshot view does not have, and selecting it throws a local
Evo state error. That error escaped BuildVerificationBatch, ended the work
round, and dropped every lock FetchPendingLocks had already taken off the
queue, so one such lock cost up to 31 others.

Catch it per lock instead. The lock is left out of the batch and of the
results: it cannot be verified in this run, and its peer is not to blame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…base as complete

RequireSatisfiedScan treated any short height-index scan under a snapshot
view based at or above DIP3 as needing the missing history. A type that was
not enabled up to the base has no commitments there, so its scan is
complete, but it still threw: a CbTx quorum-root check over such a type
stalled the snapshot chainstate until promotion.

Require the type to be enabled at the end of the missing range or at DIP3
activation. Deployment-gated types stay enabled once enabled, and the types
DIP0024 retires are enabled from DIP3 on. The check runs before the EvoDB
lock is taken, because deployment state can read EvoDB.

The non-throwing mined-commitment scans that getPlatformQuorum uses
(ScanMinedCommitments[Indexed]UntilBlock) report a walk that ends short
in history the view lacks as incomplete by the same rule, so a quorum
that may have been mined at or below the base is UNAVAILABLE there, not
INVALID. A contradiction still leaves out the entries it implicates, as in
a full view. A quorum mined below the base is a block of the active chain
below any later height, so it stays UNAVAILABLE once a window above the
base has left it behind, too.

The base is checked first, and the DIP3 ancestor is only looked up when
that check fails: a chain that does not reach genesis, such as the
testnet-height chain quorum_proofs_tests builds, has no such ancestor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and view

GUI models poll the wrapped operations, and every failed poll set the misc
warning again and notified the GUI. Raise it together with the log line,
once per operation and active Evo view. On a node without a snapshot it
says that block data was pruned and suggests -reindex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ction

Startup recovery of a persisted snapshot rejection renamed
chainstate_snapshot to chainstate_snapshot_INVALID. When an earlier
rejection had left that directory behind, the rename failed and startup
aborted until the user removed one by hand.

Keep the earlier directory, which may be the one the user was asked to
report, and durably delete the interrupted one instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is not saved

When HandleSnapshotStateMismatch could not sync EvoDB or persist the
rejection, the snapshot generation stayed bound and the default Evo
identity stayed SNAPSHOT, although the IBD chainstate was active again.
For the rest of the run, dispatches bound to the active view read the
rejected generation, and so did every operation that had bound it before
the rejection: a DKG round waiting at its start for background
validation, a message-handler turn waiting for cs_main, a queued event or
task.

In that case, revoke the generation for the rest of the run
(CEvoDB::RevokeSnapshotGeneration). In memory it is retired as a saved
rejection retires it: views bound to it no longer resolve, the default
identity is NORMAL, the node no longer counts as running two chainstates,
and what the generation has not written is dropped. Its records stay on
disk, so startup recovery still rejects it, and until then no new
generation can be created.

A test makes the rejection fail to persist through a new CEvoDB test
failpoint while a DKG-style round bound to the snapshot view waits at its
start. The round resumes and fails instead of reading the rejected
generation, active-view reads see NORMAL, and after a restart startup
recovery rejects the generation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ap work

The v3 Evo section carried a fixed rotation horizon (h-C..h-4C) and treated
parameter-derived counts as maxima. A carried safety quorum from h-2C reads
quarters down to h-5C, the next cycle reads the quarter of h itself, and a
quorum index whose DKG keeps failing has carried commitments many cycles back,
so the snapshot chainstate either computed different members from too few
quarters or refused a base the exporter accepted.

Bump EVO_SNAPSHOT_VERSION to 4 and reject v3. PlanEvoSnapshotDependencies()
derives, for one type at base B from the carried commitments, the exact set of
rotation quarters (S_T), work blocks (W_T) and bootstrap entries: the work
blocks of h and N that nothing carried references. The builder, the chain
validator and later the completion rebuild all use it. The builder fails when
an applicable quarter is missing (the no-store paths of the ordinary
computation), and the validator requires exact set equality instead of the old
tolerance, plus a quarter only where the ordinary computation stores one.
Both refuse a base whose next cycle N reads work data at or below B while
some activation input (a buried deployment, the DIP0024 quorum height, or a
versionbits period boundary) can still change before N: a type enabled, or
switching rotation, only after B would need inputs nothing carries.

QuorumSnapshotData gains bootstrap_work (at most two entries, ascending by
cycle height); rotation quarters are bounded by 3K+4, historical lists by the
per-type 4K+6 / K+2 work-block bounds, and modifiers by their sum. Seeding APIs
take the target view token and write inside its open transaction through
CEvoDB::WriteSeed, which records each key in the generation's seed
manifest: masternode lists as MN_LIST; mined commitments, their height
index, rotation quarters, the credit pool and the MNHF signals at the
base as BYTE_DETERMINISTIC; score modifiers as SCAFFOLDING.
SeedEvoSnapshotState() seeds a decoded section. Under the snapshot view a list
lookup at or below the base that misses the carried full lists raises
BlockDataUnavailableError instead of walking absent diffs or guessing an empty
list. Score modifiers prefer a seeded exact value when the work block's data
is unavailable and check it against the block otherwise.

Also carried over from the earlier dashpay#7593 head: GetDeterministicMNListHash()
and the background base-list marker use the canonical codec hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pool after block X reads the {credit_pool, unlocked} pair of block X - P
(CreditPoolPeriodBlocks()) from that block's body. A snapshot chainstate has no
bodies at or below its base, so constructing the pool of B + 2 already failed
with failed-getcbforblock-read.

v4 gains credit_history: the window entries of [max(0, B - P + 1), B], oldest
first, built from the block bodies. A pre-DIP3 block contributes {0, 0}; an
unreadable or malformed DIP3-active block is an export error. Context-free
validation requires money-range amounts, a checked sum equal to the pool's
latelyUnlocked, and a last entry equal to the pool's balance. The chain
validator requires exactly min(P, B + 1) entries whenever DIP3 is active at B,
zero entries before v20, and an empty base pool while v20 is inactive.

CCreditPoolManager::SeedHistory() stores the entries in the snapshot view, as
snapshot-only scaffolding that promotion never copies into NORMAL.
ConstructCreditPool() resolves the distant block through a view-bound reader:
at or below the base in the snapshot view it uses the carried entry (missing:
unavailable; compared against the body when that is readable: mismatch), never
inserting it into the cache of entries parsed from bodies. Above the base and
in NORMAL it reads the body as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hainLock checks

CheckCbTxBestChainlock() compares a block's coinbase ChainLock with the one
its parent's coinbase carries. For the first block above a snapshot base the
parent is the base itself, whose body the snapshot chainstate does not have:
the ordinary reader then answers "no ChainLock" and B+1 may drop or regress
the certified height. The section also had no anchor to the base header of
its own.

v4 gains the base coinbase (length-delimited, at most 1,000,000 bytes checked
before allocating, exactly one canonical transaction and nothing after it) and
its index-0 merkle branch (at most 32 nodes). Context-free validation requires
a coinbase special transaction larger than 64 bytes whose CbTx height is the
list's, and no MN or quorum state without one. The chain validator requires the
coinbase exactly when DIP3 is active at the base and proves it through the
branch against the base header. VerifyEvoSnapshotBaseBlock() is the load
helper: the same proof, then the CbTx checks over a payload decoded with the
BLS scheme in force at the base (DecodeCbTxPayload()), independent of the
scheme the loading node happens to be in.

CSpecialTxProcessor::GetCoinbaseChainlock() returns certified, none or
unavailable. In the snapshot view it serves the base from the seeded coinbase
and answers unavailable for a v20 block without data; NORMAL keeps the legacy
"none". CheckCbTxBestChainlock() uses it in place of the
GetNonNullCoinbaseChainlock() fallback and raises BlockDataUnavailableError on
unavailable. The miner's CbTx ChainLock choice uses the same reader, so a
template built on the base carries the base's ChainLock forward instead of a
null one that validation would reject. The asset-unlock status RPC uses it
too. The mnlistdiff/QRINFO quorum ChainLock info, which cannot reach the
processor without an include cycle, reports a work block without a body in the
snapshot view as unavailable (no peer penalty) instead of serving a null
signature. GetNonNullCoinbaseChainlock() decodes with the scheme in force at
the block it reads, and Validate() applies the decoder's coinbase size cap.
SeedEvoSnapshotState() now takes the special-tx processor, which owns every
manager it seeds.

Both coinbase readers take a block's status and position in one cs_main
scope, and a block this node has but cannot read raises BlockDataReadError
instead of reading as "no ChainLock".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e stage below it

The builder exported GetSignalsStage(B), the record already filtered for the
block after B. Seeding it as DB_SIGNALS_v2(B) made the snapshot chainstate
filter it a second time, and a signal that the deployment table filters at
B + 1 (a bit reused by a deployment whose window has not started) vanished
from the carried record while NORMAL keeps it, so completion could never
agree.

Export GetForBlock(B), the raw record, which SeedSignals() writes as
DB_SIGNALS_v2(B); GetSignalsStage(B) then filters it for B + 1 exactly as
NORMAL does. GetForBlock() becomes public for the builder.

Below the base the snapshot view holds no record, and a node that loaded the
snapshot without the bodies could not rebuild one, so a versionbits query of
an EHF deployment there (the next cycle's context check at the base,
StateSinceHeight, getdeploymentinfo) failed. A record is the parent's stage
plus the block's new signals, and the stage drops a signal only when a
deployment on its bit starts after the signal's block: a rule on the signal
alone. So for a block X on the base's chain, the base record's signals mined
at or below X, filtered, are X's own stage. The snapshot view answers
GetSignalsStage(X) that way, never caching or writing the derived record,
and never returns it from GetForBlock(), whose raw record still needs X's
body. A block off the base's chain (a side branch forking below the base)
does not inherit the base's signals: it is rebuilt from its own branch on
top of the derived record of its fork point, or is unavailable. A view that
carries no base record (a snapshot without an evo section) rebuilds from the
blocks as before. The snapshot view also never writes any signal record at
or below its base other than the seeded one, which promotion would find
overlapping NORMAL's, and SeedSignals() refuses a signal mined above the
base.

Versionbits evaluates the signal height after the always- and never-active
returns, so a deployment that cannot change state never reads it.

The exporter runs outside any block transaction, where a rebuilt record is
held only in the cache; it exports that record all the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…napshot view

A v20 score modifier commits to the work block's coinbase ChainLock. When the
block cannot be read, the ordinary calculation silently uses the block-hash
modifier instead, and so did the prediction path
(ComputeQuorumMembersFromWorkBlock), even where the snapshot carries the exact
value. In the snapshot view that computes different members than the network
without any error.

One resolver, keyed by (type, work block), now serves GetHashModifier(),
quarter decoding and the prediction path: it returns the seeded value or
the block's own, checks them against each other when both exist
(SnapshotStateMismatchError), and raises BlockDataUnavailableError when
it has neither, in every view: NORMAL no longer guesses the block-hash
modifier for a work block it pruned. A work block the node has but
cannot read raises BlockDataReadError in either view; the resolver and
the exporter's ExactWorkModifier() read its status and position in one
cs_main scope. llmq::utils::GetQuorumModifierForWorkBlock() exposes the
resolver.

A quorum object computed its members eagerly, so without that fallback a
pruned node could no longer build one for an old quorum, and would stall
on an MNHF signal it signed: LLMQ_400_85's active quorums reach further
back than a pruned node keeps. Where NORMAL cannot resolve the work
block's modifier (WorkBlockModifierUnavailableError), a quorum is now
built from its mined commitment alone. It verifies signatures with the
commitment's key, and CQuorum::Members(), which replaces the public
member vector, raises BlockDataUnavailableError for it, as do
IsMember(), IsValidMember() and GetMemberIndex(), so nothing reads a
guessed or empty list. Such a quorum takes no verification vector or
contributions from QDATA, is not queued for key-share warming, and has
no contributions for the old-data cleanup to keep. Commitment
verification and quorum rotation still need the members and stay
unavailable without them. The quorum health figures of the GUI and
quorum listextended count such a quorum's commitment member slots. A
release note describes the change for pruned nodes.

The snapshot view raised unavailable for any quarter missing at or below the
base. Only an applicable one (rotation and the type enabled at that cycle) is
an input the ordinary computation stored; a missing inapplicable quarter keeps
the legacy break there, as NORMAL does, so a cycle near rotation's start still
reconstructs.

Both the resolver and the exporter decode the work block's CbTx ChainLock in
the block's own BLS scheme. A background chainstate connects pre-v19 blocks
with the process-wide scheme set to legacy, and a member computation on
another thread then read a v19 ChainLock as absent and fell back to the
block-hash modifier.

The member-computation fixtures live in evo_snapshot_tests.cpp, so the tests
do too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ScanQuorums() and ScanCommitments() read the mined-commitment index. In the
snapshot view that index holds only the carried commitments at or below the
base, so a scan reaching past them returned a short or (with rotation) a
different vector, and quorum selection for a ChainLock or an asset unlock
silently disagreed with the network.

A carried scan sequence (the type's history without rotation, one quorum
index with it) ends either in END, when the canonical selection carries fewer
records than it takes (K, or two per index), or in UNKNOWN. A missing index is
END; a rotating type without rotation at the base has an empty indexed family.
TraverseScanSequences() replays the ordinary selection over such sequences
(newest first, or rank by rank in ascending index order) and proves the
identical vector, including a legitimately short history, or reports that an
UNKNOWN end was reached first.

Export and load apply one gate: every ChainLock verification scan for the
heights B + 1 may certify (from the base's certified height, or from genesis
when it certifies none; none at all without v20 at B + 1) with n = a and
SIGN_HEIGHT_OFFSET, and the Platform asset-unlock scan at B with n = a + 1,
must be proven from the section; the exporter also compares the proven vectors
with its full index. Coverage only grows with the cutoff for a fixed type and
count, so only the first start of each enablement context is proven. Bases
that fail are refused.

At runtime, the snapshot view's scans call CQuorumManager::ProveScan() over
the view's commitment index, whose ends are recorded locally at seeding
(ScanCoverageRecord, never on the wire). Every request proves its own prefix:
cached prefixes are proven ones, a shorter cache entry is re-proven from the
original cutoff instead of continued, there is no keepOldConnections warm-up
to fail a covered smaller request, and an uncovered prefix raises
BlockDataUnavailableError, as does a proven quorum this node fails to build.
Records above the base extend the sequences.

CQuorumBlockProcessor::SequenceEnd() is the one answer to how a view's history
of a scan sequence ends, and both ProveScan() and the height-index primitives'
short-scan gate (RequireSatisfiedScan) read it. It is END:
- in NORMAL, and in a snapshot view whose base precedes DIP3, which hold
  every commitment mined on their chain;
- for a family the type never writes. A type is indexed exactly when it
  rotates, and a rotating type is enabled exactly when rotation is at its cycle
  base, so a rotating type read non-indexed before DIP0024 (a base before
  activation) is empty;
- where the seeded coverage says so: a type the payload omits (disabled at B
  with no history, e.g. one enabled only later), an under-filled type, or a
  quorum index that never formed one.
A short scan ending there is an answer, so B + 1 reads what NORMAL reads.
Anywhere else it stalls, as a need deeper than the payload carries does.
END rests on the carried counts, which only the mandatory evo-hash pin binds.
RequireSatisfiedScan keeps the header check that a type enabled at neither
end of the missing range has nothing in it; both checks now run with the
EvoDB lock released. The shared quorum and commitment scan (ScanMined) and
ProveScan() normalize the start with one NormalizeScanStart(), and in the
snapshot view the shared scan takes the proven path for both result types.

Lookups by quorum hash follow the same rule. An MNHF signal may be signed by
any historical quorum of its type. In the snapshot view HasMinedCommitment
raises BlockDataUnavailableError (the chainstate stalls) for a quorum at or
below the base without a commitment, unless headers and the carried sequences
prove it was never mined: not on the base's chain, not a quorum height of an
enabled type, or no older than the oldest carried record of an ending or
newest-first sequence. Such a miss stays a plain miss, so a block relying on
that quorum is still invalid.

RequireSatisfiedScan's rule is ShortScanIsComplete(), which the non-throwing
scans getPlatformQuorum reads (ScanMinedCommitments[Indexed]UntilBlock)
use too: a walk that ends short is complete exactly when the throwing
scans would not throw, so a sequence whose recorded coverage ends lets a
hash that is no block of the active chain below the height be INVALID,
and one whose end is unknown keeps it UNAVAILABLE.

getPlatformQuorum over a seeded generation: carried commitments are found
at the base; a window reaching past the oldest carried one stays unavailable
for quorums NORMAL would find there and for unmined ones alike; a window full
again from commitments above the base, or a history whose seeded coverage
ends, makes an unmined hash invalid once a ChainLock anchors it, while a
carried quorum that has left the window stays unavailable; after
promotion NORMAL decides. Promotion, also interrupted and resumed, copies no
seed-only record (modifiers, credit window, base coinbase, scan coverage)
into NORMAL. With the quorum keys, the key-only quorum test also checks
that a pruned node accepts an MNHF signal its quorum signed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dumptxoutset builds the evo section from the dumped chainstate's own view
at the same cs_main-pinned point as the coins cursor, appends it after the
last coin as [marker][EvoSnapshot], and reports a summary of it. A base the
section cannot represent (for example a needed rotation quarter is missing,
or the ChainLock scans cannot be proven), or a section failing the chain
checks a loading node applies, is an RPC error. PreflightEvoSnapshot() is
the one chain check export and load share: ValidateEvoSnapshotAgainstChain()
and, once DIP3 is active at the base, VerifyEvoSnapshotBaseBlock(). It reads
the calling thread's Evo view, so the exporter runs it on NORMAL and the
loader in the generation it seeds.

Snapshot load decodes a trailing section and requires it to end the file;
a UTXO-only file still loads. The section is not used yet.

Until a later commit compares the whole section at completion, a DIP3-active
snapshot is never validated or promoted: completion returns the new
EVO_DATA_UNAVAILABLE, which startup treats like a skip.

feature_assumeutxo_dash.py dumps a rotating-quorum chain at a base before
its own cycle's mining window and at the block before the next cycle, and
checks the carried rotation cycles and bootstrap entries against the
dependency rule, the credit-pool window length, the base coinbase, and that
the reported hash is the SHA256 of the section bytes in the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…data

A DIP3-active snapshot base now requires the evo section and a non-null
pinned evo hash that the decoded section matches, and a non-null pin
requires a section. The pin comes only from chainparams or, on regtest, from
the new -assumeutxodata=<height>:<hash_serialized>:<evo_hash>:<nchaintx>:<blockhash>
option, never from the file. Each such entry is bound to its base block, and
the option authorizes nothing on other networks. A pre-DIP3 UTXO-only
snapshot loads as before.

EvoSnapshotTestSetup is a deterministic DIP3/v20 chain with four fixed-key
masternodes whose base at 219 is pinned through -assumeutxodata. The pin is
the SHA256 of the section as a test-side assembler writes the format table
field by field, and the section holds what the fixture implies (its
masternodes, quarters and credit window). A null pin, a UTXO-only file, and
a section with a substituted field that every later check accepts are each
refused before any generation is created. The honest snapshot activates, and
with background validation already at its base it is still not validated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…publish

After the pin check, snapshot load seeds the section into the new
unpublished generation and, in that generation's view, runs the chain checks
export runs (PreflightEvoSnapshot(): exact equality with the planned
dependency sets, provable ChainLock and asset-unlock scans, the credit-pool
window, and the anchor to the base header through the coinbase merkle proof
and CbTx). Deployment state at or below the base then comes from the carried
signal record, never from history a node with only the headers lacks: such a
node could not load a base whose next cycle reads work data at or below it.
Load then dry-runs the quorum member computations the snapshot
chainstate may need at or right after the base from the seeded state alone:
every carried quorum, the base's cycle, and the next cycle when its work
block is at or below the base (refused if a v20 work block's members cannot
be computed). Once DIP3 is active the snapshot-side base MN-list marker is
the carried list's.

The active chainstate keeps running whenever activation releases cs_main.
Preparation therefore first flushes it, coins before Evo state, so the NORMAL
records it then commits never reach disk ahead of the coins, and registers
the base before releasing cs_main: until the switch the active chainstate
records the base's MN list as the background chainstate would. Activation
then waits, at most a few seconds, for the dispatch turns of other threads
to end (so its caller must not hold cs_main), and switches in one cs_main
hold: the mempool must still be empty, the active tip must be the base or
have less work than it (an equal-work sibling at the base height would be
loaded as the background tip at the next startup, whose completion would
then reject the snapshot), and no block of the base's chain may be invalid
or conflict with a ChainLock; only then are the
generation published (with the markers, the configuration fingerprint and
the encoded section, retained in the lifecycle metadata), the base recorded
in the snapshot directory and the block index faked, and the snapshot view
told its tip before it becomes the default. A turn still running in NORMAL
finishes there. A turn counts itself without a lock; one that ends takes
the waiter's mutex and notifies only while activation waits. Every failure
before the switch, from opening the snapshot's coins database on, undoes the
generation, the caches and the directory; none leaves faked block-index
state behind.
Rejection, completion and cleanup flush each chainstate's coins, checked,
before syncing its Evo state.

A local Evo state error while populating (state this node cannot derive or
read) refuses the snapshot as bad input does, instead of escaping
activation.

On restart, an unpublished generation is collected as before; a published
one is used only while its retained section still decodes and matches the
pin of its base. evo/snapshot_chain.cpp joins the kernel sources, since
validation now calls it.

A DIP3-active snapshot is still never validated or promoted. The
unknown-version warning check, where it needs Evo state the chainstate
lacks, is skipped instead of escaping block connection.

The "snapshot state unavailable" warning no longer takes the single misc
warning slot. Each stalled chainstate and each failing interfaces::
operation has its own; a stall's stays with its chainstate (at completion
it says to restart), an operation's goes on its next success. Setting or
clearing one notifies the GUI only when the warnings change, so an
operation the GUI keeps polling while it fails notifies once.

A simulated restart in the tests drops the Evo view caches, as a real one
does: their tips point into the block index it unloads.

The failed-open case meets a leak in CDBWrapper's constructor: it throws
before its destructor could free the LevelDB cache, filter policy and logger
GetOptions allocated. LeakSanitizer ignores those allocations.

Tests, on the deterministic DIP3 fixture, and on a variant whose quorums
really form (commitments signed with the fixture's operator keys and a
quorum key it keeps, ChainLocks and asset unlocks signed with that key);
a node "with only the headers" also starts with an empty NORMAL EvoDB:
- a node with only the headers loads the snapshot and connects, across a
  restart, the blocks a synced node mined up to B+P+2, through the next
  cycle; members of the base and next cycle match the synced node's;
- such a node loads the section a synced node dumps at every base of a
  24-block cycle, including those inside the next cycle's work window;
- a work-block body the node has but cannot read refuses the load cleanly;
- at the block before the next cycle, the bootstrapped next cycle's members
  are predicted from the seeded state and match the synced node's;
- a crash after seeding, before or after publication, leaves no live
  generation after restart, and NORMAL carries on;
- right after activation the snapshot view serves the base's MN list as its
  tip list;
- the switch is refused when the active chainstate meanwhile took a mempool
  transaction, the base's chain became invalid, the chainstate moved to a
  sibling of the base with the same work, or it went past the base, and a
  snapshot coins database that fails to open fails the load; each time the
  node is left as before, and the load then succeeds at the base;
- the switch waits for a dispatch turn that ends within the bound; one that
  outlives it carries on in NORMAL without failing or stopping the node;
- a load refused by its dry run on a node with only the headers marks no
  block assumed-valid and fakes no transaction count;
- a crash right after preparing restarts without an Evo/coins mismatch,
  although the chainstate had connected blocks since its last flush;
- a carried credit-window record that has gone missing stalls the
  chainstate with a local error, without marking the block invalid, and
  raises a warning that leaves others in place;
- a retained section that no longer matches the pin is refused at startup;
- with real quorums, the next cycle's signed commitments, verified against
  members from the carried quarters (including the base cycle's), and the
  ChainLocks in every coinbase are accepted, and stay in the snapshot view,
  for a base right before the next cycle and for one five blocks after its
  own cycle, before the next cycle's work block;
- each quorum cache the snapshot chainstate warms at or below the base
  (quarters, members, quorums, quorum and commitment scans, mined-commitment
  blocks, commitment indexes, CbTx commitment hashes, the remembered coinbase
  ChainLock) stays in its view: NORMAL finds nothing there or cannot tell
  until it has connected those blocks, then answers as the synced node did,
  also when the snapshot view holds a different commitment;
- with a rotating index whose DKG fails for ten cycles, the members of its
  ten- and eleven-cycle-old quorums are computed at activation, with cold
  caches, as the synced node's; the ten-cycle-old quarter, only an output,
  is not kept, and without a quarter they need the members are unavailable;
- on a node without the base body, the block after the base, building on
  the real ChainLock the carried base coinbase holds, connects;
- withdrawals at exactly the limit, before the base and across a restart
  and the window edge after it, are accepted; on both sides of that edge
  anything above the synced node's limit, and a carried index, are rejected;
- a rejected snapshot's commitments are rebuilt by NORMAL from the blocks;
- NORMAL's MN-list cache stays bounded during a long background sync;
- a carried record missing for a reorg or ChainLock enforcement stalls
  both (a local Evo state error, with the warning) without marking anything
  invalid, until a restart;
- background validation reaching the base validates nothing yet;
- an unknown bit active, and warned about, on the synced node: once that
  node, still holding NORMAL's warning-cache states, loads a snapshot without
  the bodies below its base, the snapshot chainstate computes the states
  itself from the headers and the carried signal record, and warns too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PastaPastaPasta and others added 27 commits November 9, 2026 19:08
Completion now rebuilds the entire evo section from the state background
validation derived and requires its hash to equal the retained section's,
which must still equal the pin. This replaces the interim refusal to
validate a DIP3-active snapshot. The rebuild and the shared preflight report
why they failed: missing input, local corruption, an internal error, or a
disagreement (at export, where nothing is compared, a base the section
cannot be built for). Only a disagreement rejects the snapshot
(EVO_STATE_MISMATCH). Missing input (EVO_DATA_UNAVAILABLE) leaves it
neither validated nor rejected, with a warning that says the next restart
retries. Data this node
has but cannot read or finds inconsistent (a retained section that is gone,
does not decode or no longer matches its pin, which startup checked; an
unreadable body; EvoDB best blocks that differ from their coins tips) stops
the node with EVO_LOCAL_FAILURE, and an internal error (including a missing
record of the background chainstate's list at the base) with
EVO_INTERNAL_ERROR; neither rejects the snapshot, and startup then fails with
the same advice instead of retrying at every restart. The advice says that
-reindex-chainstate discards the snapshot. A coin of the background
chainstate that cannot be read, so that its UTXO set hash cannot be
computed, is local corruption too. Upstream rejects the snapshot there,
which would leave the node on the coins it cannot read and tell the user the
snapshot was invalid; this is a deliberate divergence. A snapshot still in
use that cannot be rejected stops the node rather than being kept.

The inputs only block bodies provide (the v20 work-block modifiers, the
credit-pool window, the base coinbase and its branch, and the base's credit
pool, which folds in blocks back to the last pool on disk) are captured as the
background chainstate connects those blocks, in its block transaction, or
at activation for blocks it has already connected; activation refuses a
base whose such body is gone. The capture plan refuses a credit window
reaching below genesis, since the chain checks run only after it. The
rebuild prefers the captures over the bodies. A local Evo error while
capturing the base pool stalls the background chainstate as other local Evo
errors do. Capturing follows the registered plan, so a block the still-active
chainstate connects between preparation and the switch is captured too. Since
promotion repeats completion at the next startup, the captures stay until
promotion; they are also dropped on rejection, when activation fails (after
flushing that chainstate's coins), and at startup when no generation is live
or the one left never activated (after a crash before or after publication).
No prune lock is kept for them: it would not cover what either chainstate
reads, and pruning is to be refused while a snapshot is in use.

The MN-list work captures and the old comparison block that the design
removes do not exist on this base; the existing base MN-list marker
comparison is kept.

Tests, on the deterministic DIP3 fixture, with sections pinned as they are:
- a mutated currentLimit, credit-pool index range, signal height, rotation
  quarter bit or credit-window entry passes every load gate and the snapshot
  chainstate's own blocks, and is rejected only at completion, with no block
  marked invalid; a wrong modifier is, on a node without the work-block
  bodies at load;
- a corrupted capture is a mismatch; a missing one whose block is gone is
  missing input with a warning naming the retry, after which completion
  succeeds once the body is back; activation refuses a base whose connected input body is
  gone; with real quorums, completion succeeds from the captures alone once
  every body at or below the base is gone and the caches are cold, both for
  those activation wrote on a synced node (a withdrawal in the window) and
  for those the background chainstate of a node with only the headers wrote
  as it connected the blocks, across a restart;
- the honest run on a node with only the headers completes across a
  restart and dropped caches; the background chainstate announces none of
  the MN-list changes it connects and its ForceSignalDBUpdate writes only
  its own view; the captures last until promotion; NORMAL consumers read the
  promoted MN list and credit pool;
- a published generation is refused after a V24 min_activation_height
  change, naming the generation, base, phase and recovery and saying the
  changed part is unknown, with nothing changed on disk; so is a promotion
  interrupted mid-merge, whose failing recovery names the way out; both
  resume under the original configuration;
- blocks the still-active chainstate connects between preparation and the
  switch, through the base, are captured and its base list recorded, so
  completion succeeds;
- a crash right after a refused switch restarts without an Evo/coins
  mismatch, although the chainstate connected blocks meanwhile;
- an unreadable body, a changed retained section and mismatched best blocks
  stop the node as local corruption, an internal error and a missing base
  list record as internal errors, none rejecting the snapshot; startup then
  fails the same way, fails as local corruption on an unreadable background
  coin, and promotes once the data is whole again;
- with real quorums: a carried safety commitment with another public key,
  signature or mined block in the same window is rejected only at
  completion; the honest section completes, the next cycle's mined
  commitment is NORMAL's after promotion, and a quorum-signed signal for an
  unused bit mined at the base is carried raw and kept by promotion;
- a crash before or after publication leaves no captures after restart;
- a chainstate stalled on a missing carried record keeps its warning past
  completion, and after the restart that promotes the snapshot the warning
  is gone and the stalled block connects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
edbed31 chainparams: add signet assumeutxo param at height 160_000
b8cafe3 chainparams: add testnet assumeutxo param at height 2_500_000
99839bb doc: add note about confusing HaveTxsDownloaded name
7ee46a7 contrib: add script to demo/test assumeutxo
42cae39 test: add feature_assumeutxo functional test
0f64bac rpc: add getchainstates
bb05857 refuse to activate a UTXO snapshot if mempool not empty
ce585a9 rpc: add loadtxoutset
62ac519 validation: do not activate snapshot if behind active chain
9511fb3 validation: assumeutxo: swap m_mempool on snapshot activation
7fcd215 blockstorage: segment normal/assumedvalid blockfiles
4c3b8ca validation: populate nChainTx value for assumedvalid chainstates
49ef778 test: adjust chainstate tests to use recognized snapshot base
1019c39 validation: pruning for multiple chainstates
373cf91 validation: indexing changes for assumeutxo
1fffdd7 net_processing: validationinterface: ignore some events for bg chain
fbe0a7d wallet: validationinterface: only handle active chain notifications
f073917 validationinterface: only send zmq notifications for active
4d8f4dc validation: pass ChainstateRole for validationinterface calls
1e59acd validation: only call UpdatedBlockTip for active chainstate
c6af23c validation: add ChainstateRole
9f2318c validation: MaybeRebalanceCaches when chain leaves IBD
434495a chainparams: add blockhash to AssumeutxoData
c711ca1 assumeutxo: remove snapshot during -reindex{-chainstate}
c93ef43 bugfix: correct is_snapshot_cs in VerifyDB
b73d3bb net_processing: Request assumeutxo background chain blocks

Pull request description:

  Part of the assumeutxo project: the loadtxoutset RPC, background block
  download, ChainstateRole on validation-interface notifications, indexing and
  pruning across two chainstates.

Dash adaptations:
- loadtxoutset and getchainstates are hidden and regtest-only, and
  dumptxoutset stays hidden: no network but regtest authorizes a snapshot.
- Validation-interface events of the background chainstate, which used to be
  suppressed, now carry their role. The Dash subscribers (ChainLock handler and
  signer, InstantSend, CoinJoin and masternode notifications, the coinbase
  ChainLock check in net_processing) ignore background events, as the wallet
  and zmq do, so they see only the active chain as before.
- Indexes, the governance collateral index included, follow the indexing
  chainstate. GovCollateralIndex::CanSync is evaluated on it at startup, so a
  restart while the background chainstate is syncing keeps governance on. The
  indexes are restarted from init's list of running indexes.
- Snapshot activation needs strictly more work than the active tip, at load
  and at the switch, where it replaces the switch's interim rule. The work
  comparator would let a base through that has the same work as an active tip
  on another branch: it breaks the tie by nSequenceId, which is assigned only
  when a block's data is received, so a headers-only base keeps its initial
  value and ranks ahead of any equal-work block received in this run. Startup
  completion would then find the active tip at the base height and reject the
  honest snapshot. The unit fixtures that load the snapshot of their own tip
  hide it from the active chain where activation compares work, but not while
  the snapshot is prepared, so that preparation still captures what the active
  chain has connected.
- AssumeutxoData keeps its evo pin. The regtest entries at 110 and 200 get their
  block hashes. feature_assumeutxo.py authorizes its snapshot with
  -assumeutxodata instead of an entry at 299: the blocks it mines depend on how
  long its syncs take, which move the mock time on, so the snapshot differs
  between runs. It checks the dump's nChainTx and coin count against the node
  rather than fixed values, and expects txindex and the governance collateral
  index, which Dash nodes keep by default, among the synced indexes. Two of
  the blocks the loading nodes lack below the base carry a transaction besides
  the coinbase, so that their background sync meets the nChainTx case below.
  A fourth node lets its snapshot chainstate connect the blocks above the
  base, drops the snapshot with -reindex-chainstate before it is validated and
  then syncs normally, which meets the second nChainTx case below.
- CheckBlockIndex skips 4c3b8ca's monotonic nChainTx check for blocks at
  or below the snapshot base while a snapshot chainstate exists. Their nTx and
  nChainTx can still be the values faked at activation, or derived from them:
  receiving a block recomputes its own nChainTx but not that of a faked child,
  so the check aborted a node whose downloaded blocks below the base held two
  or more transactions more than were faked. Above the base the counts never
  exceed the real ones and start from the pinned nChainTx at the base, so the
  check stays there. The faked counts are persisted, though: a snapshot
  chainstate dropped by -reindex-chainstate, or by a rejection and a restart,
  leaves them in the block index, and the plain sync that follows failed the
  check the same way. BlockManager::LoadBlockIndex() therefore records the
  highest block it loads with a faked count (nTx set without
  BLOCK_VALID_TRANSACTIONS, the compatibility case bitcoin#29370 describes), and the
  check also skips a block whose parent is at or below it. After the load a
  block's count changes only when the block itself or its parent is received,
  so a pair that went stale has a faked parent, which also covers the block
  right above the highest faked one. A fork that branches below the base,
  crosses the base height and arrives out of order can still fail the check,
  until bitcoin#29370 stops faking the counts and replaces these exceptions.
- A local Evo stall of the active chainstate does not stop the background
  chainstate, as in the import loops: ProcessNewBlock() still runs the
  background chainstate's ActivateBestChain() after the active one stalls, and
  then returns false. Every other failure returns early, as upstream does. A
  stall lasts until a restart, so returning early on it would freeze background
  validation, and with it the promotion that ends the stall.
- ChainstateRole's kernel/chain.h, now included by validation and
  node/blockstorage, closes an include cycle through Dash's blockfilter ->
  evo/specialtx_filter -> evo/assetlocktx path; it is listed in
  lint-circular-dependencies.py.
- The nChainTx bootstrap at startup skips a base missing from the block index,
  which ChainstateManager::LoadBlockIndex() reports as a startup error.
- The mempool swap and the -reindex snapshot deletion are already in place:
  ActivateSnapshot() moves the mempool at the switch, and startup deletes a
  snapshot chainstate before detecting it when reindexing. The VerifyDB fix is
  already present.
- FindFilesToPrune() caps the minimum share of each chainstate at the -prune
  budget itself. Upstream's MIN_DISK_SPACE_FOR_BLOCK_FILES equals its -prune
  minimum. Dash's is 945 MiB, but regtest accepts -prune=550, so a single
  regtest chainstate's target rose to 945 MiB and it stopped pruning
  (wallet_pruning.py). One chainstate again prunes to exactly its budget. With
  two, mainnet and testnet split the budget as upstream does, using Dash's
  constant.
- Dropped: the blockfile segmentation (7fcd215), which only serves pruning
  each chainstate separately, while a node with a snapshot chainstate does not
  prune; the testnet and signet parameters; the demo script, for lack of a
  network with a snapshot; and the release notes, as the RPCs are hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7e40032 tests: assumeutxo: accept final height from either chainstate
5bd2010 test: assumeutxo: avoid race in functional test
7005a01 test: add wait_for_connect to BitcoinTestFramework.connect_nodes

Pull request description:

  Fixes some races in the assumeutxo functional test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a list of chainstates

a9ef702 assumeutxo: change getchainstates RPC to return a list of chainstates

Pull request description:

  Current `getchainstates` RPC returns "normal" and "snapshot" fields which are
  not ideal because it requires new "normal" and "snapshot" terms to explain,
  and the field names will be confusing when a snapshot chainstate becomes
  validated. Return a list of chainstates ordered by work with a "validated"
  field for each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5d227a6 rpc: Use Ensure(Any)Chainman in assumeutxo related RPCs
710e5db doc: Drop references to assumevalid in assumeutxo docs
1ff1c34 test: Rename wait_until_helper to wait_until_helper_internal
a482f86 chain: Rename HaveTxsDownloaded to HaveNumChainTxs
82e48d2 blockstorage: Let FlushChainstateBlockFile return true in case of missing cursor
73700fb validation, test: Improve and document nChainTx check for testability
2c9354f doc: Add snapshot chainstate removal warning to reindexing documentation
4e915e9 test: Improvements of feature_assumeutxo
a47fbe7 doc: Add and edit some comments around assumeutxo
0a39b8c validation: remove unused mempool param in DetectSnapshotChainstate

Pull request description:

  Addressing comments from bitcoin#27596

Dash adaptations:
- 82e48d2 is dropped: it changes FlushChainstateBlockFile, which comes
  with the blockfile segmentation this tree does not take from bitcoin#27596.
- 73700fb keeps bitcoin#27596's monotonic nChainTx check in CheckBlockIndex
  instead of requiring nChainTx to equal the sum of nTx. Until bitcoin#29370 removes
  the faking of nTx at snapshot activation, a headers-only chain below a base
  whose blocks hold more than one transaction each fails the stricter check.
  The corrected regtest nChainTx values (counting the genesis coinbase) are
  taken, for the 200 entry too.
- The wait_until_helper rename covers the Dash-only functional tests as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssumeUTXO parameters fails

2e31250 test: check that loading snapshot not matching AssumeUTXO parameters fails

Pull request description:

  This PR adds a first test for loading an invalid snapshot: the snapshot
  base block is not in the assumeutxo parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tadata constructor, fix test, add test

fafde92 test: Check snapshot file with wrong number of coins
faa90f6 refactor: Remove unused nchaintx from SnapshotMetadata constructor

Pull request description:

  Remove unused nchaintx from SnapshotMetadata constructor and add a test.

develop's partial merge of this PR (ed19c19) already removed nchaintx from
the SnapshotMetadata constructor and dumptxoutset; this adds the
feature_assumeutxo.py test it left out.

Dash adaptation: the evo section follows the coins, so with one coin too many
the last one is read from the section and fails one of the coin checks rather
than at the end of the file; the test matches the common part of those logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…esn't match AssumeUTXO parameters

9620cb4 assumeutxo: fail early if snapshot block hash doesn't match AssumeUTXO parameters

Pull request description:

  Fail early in `loadtxoutset` if the snapshot block hash is not one of the
  AssumeUTXO parameters, instead of waiting up to ten minutes for its header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fa68571 test: Add assumeutxo test for wrong hash

Pull request description:

  Add a test for a snapshot file whose first outpoint hash was changed, so its
  content hash does not match the AssumeUTXO parameters.

Dash adaptation: the snapshot differs between runs (see bitcoin#27596's port),
so the test checks the expected content hash in the log, not the changed file's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
621db2f test: assumeutxo file with unknown block hash

Pull request description:

  Check that a snapshot file whose base block hash is unknown is refused right
  away as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… index + de-duplications

d322368 test: De-dublicate/optimize assumeutxo test for further extensions
0a576d6 test: check au file with changed outpoint index

Pull request description:

  Extend the invalid-snapshot cases with a changed outpoint index and share the
  error checking between the cases.

Dash adaptation: as for bitcoin#28647, the cases do not list the content hash
of each changed file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two cases of bitcoin#28685's "assumeutxo: Check deserialized coins for out of
range values" commit, which change a coin's height and coinbase flag in the
snapshot file: the UTXO set hash commits to both since bitcoin#28685 (in the base of
this stack), so the snapshot is refused.

The backport of bitcoin#28685 left these hunks out because this test did not exist
yet. As for bitcoin#28647, the test checks the expected content hash only.
The first coin differs between runs, so instead of upstream's fixed bytes,
which may equal the original or change the code's length, the cases flip the
coinbase flag and the height's lowest bit in the code's last byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nvalid hash

811067c test: add coverage for snapshot chainstate not matching AssumeUTXO parameters
4a5be10 assumeutxo, blockstorage: prevent core dump on invalid hash

Pull request description:

  A snapshot chainstate whose base block hash is not in the AssumeUTXO
  parameters stops startup with an error instead of an Assert crash.

Dash adaptations: the block manager reports the error through AbortNode, as
its other fatal errors. Startup finds the bogus chainstate_snapshot directory
of the test earlier, because the node has no EvoDB state for it, so the test
expects that error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on the assumeutxo invalid hash dump - follow-up bitcoin#28698

7de7685 test, assumeutxo: Use assert_debug_log for error details

Pull request description:

  Check the logged error details of the invalid snapshot chainstate case with
  assert_debug_log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
997b9a7 test: add assumeutxo wallet test

Pull request description:

  Add a wallet test for assumeutxo: a wallet backup cannot be restored while
  the background chainstate is still syncing, and can once it is done.

Dash adaptation: node 1 authorizes node 0's snapshot with -assumeutxodata, as in
feature_assumeutxo.py, instead of the test checking a fixed UTXO set hash.

The wallet already writes its best block before a backup (the wallet hunk of
bitcoin#30678), so a backup taken at the snapshot base needs no rescan and loads
during the background sync. The test takes bitcoin#30678's test change with it: that
backup loads, and one taken before the base is refused until the background
sync is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pshot completion

bc43eca test: add functional test for balance after snapshot completion
226d03d validation: Send correct notification during snapshot completion

Pull request description:

  When background validation completes during an ActivateBestChain() call,
  GetRole() returns NORMAL instead of BACKGROUND for the background chainstate
  when its BlockConnected notifications are sent. The wallet, which ignores
  background notifications, then sets its last processed block to the old
  snapshot height. The role is now captured before ActivateBestChainStep().

Dash adaptations:
- The role is captured right before each ActivateBestChainStep() call inside
  Dash's inner loop, which can run more than one step after a stall.
- The Dash subscribers that ignore background notifications (the ChainLock
  handler and signer, InstantSend, CoinJoin, net_processing) no longer handle
  the blocks of the background chainstate's final step either.
- wallet_assumeutxo.py checks the third node's wallet by its last processed
  block instead of its balance: the test does not fund the wallet, which
  needs bitcoin#29354, and ensure_for() needs bitcoin#30893. The third node
  authorizes the snapshot with -assumeutxodata like node 1. Its snapshot chain
  reaches the tip by submitblock before it connects to node 0: without the
  snapshot-first block download of bitcoin#29519, active blocks arriving after
  completion would set the wallet's last processed block again and hide the
  bug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ith legacy indexes

A pruned node keeps neither the blocks the background chainstate needs nor
those Evo state below the snapshot base is read from, a masternode cannot run
its duties from an unvalidated snapshot chainstate, and the -addressindex,
-spentindex and -timestampindex indexes are not supported together with a
snapshot chainstate.

Snapshot activation now refuses all of these, in any prune mode, together with
the conditions the switch already re-checked: a non-empty mempool, a base
without more work than the active tip, and an invalid or ChainLock-conflicting
block in the base's chain. They are checked when the snapshot is loaded, before
any state is set up, and again at the switch under the same cs_main hold as
before. The load check also refuses a second snapshot after one was rejected
in this run, which used to reach the switch and abort on
assert(!m_snapshot_chainstate), and a snapshot while the chainstate of one
rejected earlier is still in chainstate_snapshot_INVALID. A rejection moves
the snapshot chainstate there and tells the user it is kept, but a second
rejection could not move its own there, and startup recovery then deleted it.

At startup, a node with a snapshot chainstate directory, validated or not,
refuses to start with -prune, one of those indexes or -masternodeblsprivkey
until a restart has promoted the snapshot. -assumeutxodata is refused off
regtest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hainstate

While a snapshot is validated in the background, the governance collateral
index, like every index, follows the background chainstate, and reports
itself synced once it reaches that chainstate's tip. Governance read that as
"every block up to the active tip has been read": it stopped holding
governance announcements back and processed proposals whose collateral lies
above the background tip, which the index cannot have yet. The lookup failed
as "Can't find collateral tx", and the honest peer that relayed each such
proposal got Misbehaving(20). This happened both after loading a snapshot and
after a restart while it was not yet validated.

GovCollateralIndex now records the chainstate it was initialized on, in
CustomInit(), within the same cs_main hold in which BaseIndex::Init() sets
m_synced, and IsCollateralSynced() also requires that chainstate to be the
active one. Governance therefore waits until background validation has
completed and the index, restarted on the active chainstate, has caught up.
With a single chainstate, and after a rejection, nothing changes. A cached
flag would not do, as the active chainstate changes at runtime activation,
which the index does not observe. CanSync() still decides at startup on the
indexing chainstate.

IsCollateralSynced() takes cs_main itself. Each caller either holds cs_main
already, so the acquisition is recursive and records no lock-order pair (the
collateral check, AlreadyHave(), the INV loop, CheckPostponedObjects(),
governance message processing), or holds no lock that is taken after cs_main
elsewhere (the sync manager's tick, the release of held announcements in
SendMessages(), the RPCs).

While the index is behind, CheckPostponedObjects() leaves postponed proposals
alone, since a failed lookup would drop them. The new functional test cannot
reach this, as mnsync stays at its governance step while the index is behind;
it was verified from the source. gobject prepare and gobject submit refuse
with RPC_MISC_ERROR instead of failing on the lookup.

Snapshot activation can leave the index behind after ProcessMessage() checked
it. The object handler checks again in the cs_main hold in which it consumes
the request authorization and processes the object, and when the index is
behind it drops the object without consuming the authorization or penalizing
the sender. Votes need no such check: processing one never looks up proposal
collateral.

feature_assumeutxo_refusals.py has a peer that serves no blocks announce a
valid proposal whose collateral is above the background tip: the node holds
the announcement without penalty, and gobject submit refuses. Once background
validation completes and the index catches up, the node requests the proposal
from that peer and accepts it. The test framework's
CGovernanceObject.serialize() is fixed, and msg_govobj added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two activations could run at once: the snapshot chainstate is only installed
at the end, so the check for an existing one let a second loadtxoutset through.
The second one failed, at the latest on the first one's database lock, and its
cleanup then rebalanced the caches, deleted the snapshot directory and
abandoned the generation of the activation still running.

ActivateSnapshot now refuses, before it changes anything, while another
activation is running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Promotion renames the background chainstate directory to chainstate_todelete
and deletes it at the end. When that deletion failed, or the node stopped
before it, the directory stays; the next startup removes it, unless it
reindexes. A snapshot loaded after such a reindex then failed its promotion
on the rename, and startup aborted until the user removed the directory.

The leftover is of no use, so promotion removes it durably before the rename.
A failure to remove it aborts the node with its own message, as a failed
rename does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n a snapshot is rejected

The rejection makes the background chainstate active, but its candidates
held only ancestors of the base. Blocks the snapshot chain had connected
above the base were missing, so the next CheckBlockIndex aborted (with
-checkblockindex, the regtest default).

Re-list them with RestoreBlockIndexCandidates, the rule startup applies.

Inherited from bitcoin#25740; bitcoin/master still has it, so there is no
upstream fix to backport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…check in ActivateSnapshot

33c48c1 validation: Check if mempool exists before asserting in ActivateSnapshot (TheCharlatan)

Pull request description:

  The mempool is an optional component of the chainstate manager, so don't assume its presence and instead check if it is there first.

Dash: pulled forward from the later assumeutxo backports. After a
snapshot rejection the active chainstate has no mempool (next commit),
so a second activation in the same run must reach the "a snapshot
chainstate already exists" refusal instead of this assert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rejected snapshot handed its mempool to the validated chainstate. The
mempool's transactions were accepted against the snapshot tip, so with
-checkmempool (the regtest default) the check that follows completion
aborted on a child of a transaction mined above the base, instead of
the node stopping with its error. After a rejection raised while the
snapshot chainstate connects a block, the validated chainstate can be
far below the base, and it met that mempool with historical blocks.

Leaving the mempool with the rejected chainstate is not enough: a
rejection raised mid-step returns through that chainstate's mempool
update after its coins views were reset.

Detach the mempool from both chainstates for the rest of the run.
Transactions are refused with no-mempool. Nothing is removed, so no
InstantSend lock is dropped through the removal notification, and
DumpMempool still saves the mempool for the next startup to
re-validate.

This drops the handback from "validation: move the mempool to the
snapshot chainstate on activation" (dashpay#7553); develop cannot reach it
without the snapshot loader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onger holds the mempool

A snapshot rejection detaches the mempool from both chainstates, and a
snapshot activation moves it from the background chainstate to the
snapshot one. A mempool load (importmempool, or the load at startup)
that started on the other chainstate, or after the rejection, then
reached AcceptToMemoryPool's assertion that the chainstate has a
mempool. Stop the load instead. bitcoin/master has the same gap after a
rejection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chainstatemanager_activate_snapshot deferred an InstantSend conflict on a
stalled snapshot chainstate but never retried it. Keep the NetInstantSend
alive and retry the deferred conflict through RetryUnresolvedConflicts:

- on the next tip, while the stall persists, the retry defers it again and
  leaves no block invalid and the lock in place;
- once a ChainLock covers the conflicting block, the retry drops the lock
  and leaves the chain where it is.

A retry that succeeds once the unavailability clears is not reachable in a
run: stalls stick until restart, and the queue is in memory.

The work thread's call to RetryUnresolvedConflicts stays covered by review
only: catching its removal would mean running the thread on a timer, which
makes the test timing-dependent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feature_assumeutxo_dash.py checked only the evo section dumptxoutset writes.
Extend it so that three cold nodes, which know only the headers, load node 0's
dumps at bases 3, 23 and 18 blocks into a rotation cycle (the last inside the
mining window), with the regtest default -checkblockindex:

- node X refuses a mutated coin, a mutated evo section, and a pin with either
  hash of another dump. -reindex and then -reindex-chainstate drop snapshots
  whose chains connected blocks above the base, and plain IBD follows.
- node R loads a section that adds an EHF signal, under a pin taken from that
  section. Background validation over P2P rejects it at completion: the node
  stops, keeps chainstate_snapshot_INVALID, misbehaves no peer and marks no
  block invalid. After the restart it refuses another snapshot while the
  rejected one is kept, then syncs from blocks.
- node A takes a transaction spending snapshot coins into its mempool with an
  InstantSend lock from a carried quorum, and connects InstantSend,
  withdrawal and EHF traffic above the base. A carried platform quorum
  verifies the withdrawal's signature. An InstantSend lock disconnects a
  block A mined with a conflicting spend. With the background chainstate
  just past blocks before v19, a basic-scheme ChainLock is accepted with no
  misbehaviour. An EHF signal of a quorum the snapshot does not carry stalls
  the snapshot chain, also across a restart; background validation still
  completes in the same run, and an InstantSend lock two cycles old verifies
  after completion (bitcoin#31556). A masternode key is refused while the
  snapshot is unpromoted, and the restart that promotes it connects the
  stalled block.

The network adds two EvoNodes for platform quorums and activates v19 at
height 60, so a background chainstate can be before v19 while the snapshot
chain is after it.

The test now takes about 100 s, so test_runner.py lists it first among the
base tests under five minutes.
… readable

registeredCollateralExclusion checks that the wizard keeps its collateral
candidates and shows the "state unavailable" label while the masternode list
cannot be read, as it cannot under a snapshot view below the base. Check
the other half: once the list is readable again, a refresh shows the
candidates and clears the label.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants