Skip to content

feat: emit, pin, seed and verify the v4 evo snapshot section - #7601

Draft
PastaPastaPasta wants to merge 16 commits into
dashpay:developfrom
PastaPastaPasta:feat/evo-snapshot-load
Draft

PastaPastaPasta wants to merge 16 commits into
dashpay:developfrom
PastaPastaPasta:feat/evo-snapshot-load

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Aug 13, 2026 •

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

This is the third PR of the reworked AssumeUTXO Evo-snapshot series: #7887, then #7593, then this one. It is stacked on #7593 and stays a draft until both of those merge. Only the top 4 commits belong to this PR.

#7593 defines the v4 Evo section, how it is built, and how it is validated against the chain. This PR wires it into the snapshot lifecycle:

Two independent designs (Claude Opus and GPT) were reconciled into one design, which this PR implements. One requirement drives it. Anything in the section that isn't committed by the pinned Evo hash, headers or CbTx is untrusted. A snapshot node must accept and reject exactly what a fully validated node does, and it must stop rather than guess when it can't decide. Completion then checks the whole section independently before promotion.

What was done?

  1. feat(rpc): emit the v4 evo section in dumptxoutset
    • dumptxoutset writes the v4 Evo section after the coins.
    • Until the completion comparison exists, a gate refuses to validate or promote any DIP3-active snapshot. The gate stays through commits 2 and 3, and commit 4 replaces it with the comparison. So no commit in this stack can promote a post-DIP3 snapshot unchecked.
  2. feat(validation): require a matching evo pin; add regtest -assumeutxodata
    • Any post-DIP3 section needs a matching nonzero Evo pin, and regtest's null-pin bypass is removed.
    • The regtest-only -assumeutxodata=<height>:<hash_serialized>:<evo_hash>:<nchaintx>:<blockhash> option lets tests pin their own snapshots. It is rejected on other networks.
  3. feat(validation): seed into an unpublished generation, dry-run, then publish
    • The validated section is seeded into an unpublished generation.
    • A dry run reconstructs the quorum, list and credit state the first blocks above the base will need, from cold caches.
    • The generation is published only if the dry run succeeds.
    • On restart, an unpublished generation is garbage-collected. A published one has its deployment fingerprint checked, its retained section bytes checked again against the pin, and is then rebound.
    • Leftovers from a crash before publication are erased at startup.
  4. feat(validation): rebuild and compare the full evo section at completion
    • When the background chainstate reaches the base, the canonical v4 section is rebuilt from NORMAL-only inputs and compared byte for byte, in addition to the existing UTXO comparison.
    • Inputs that come from block bodies (modifiers, the credit window, the base credit pool and the base coinbase) are captured as the background chainstate connects them.
    • A prune lock keeps those bodies until the comparison is done. It is released when the comparison data is unavailable, so pruning can't stall for good.
    • A mismatch rejects the snapshot through the existing lifecycle.

Snapshot-data unavailability is never turned into block invalidity or a peer penalty.

Deferred to M5 (loadtxoutset), each with a stated gate

  • InstantSend conflict-and-retry end to end. The stall path itself is covered here.
  • The GUI in dual operation, and stale labels on retained GUI data. Users can't reach dual operation until loadtxoutset.
  • Reseeding tipIndex after a rejection. Rejection currently shuts the node down.
  • Optionally hashing the raw section bytes before decoding. The current order is bounded decode → Validate → pin.

How Has This Been Tested?

macOS arm64, depends build, --enable-debug.

  • Builds and suites: every commit builds and passes its touched suites. At the head, the full test_dash (1176 cases), feature_assumeutxo_dash.py, rpc_dumptxoutset.py and feature_reindex.py pass, and dash-chainstate links.
  • Fixture: a new unit-test fixture builds a deterministic DIP3/V20 regtest chain with four masternodes and fixed keys. It produces validly signed quorum commitments, ChainLocks, asset unlocks and MNHF signals, checked by the real consensus code paths.
  • Tests using that fixture:
    • snapshot(h) with a real next-cycle commitment;
    • a failed DKG, and a ten-cycle failed-DKG run;
    • a base at h+5;
    • the B+1 ChainLock rejections (bad-cbtx-null-clsig, bad-cbtx-older-clsig, and a valid block accepted);
    • credit-limit withdrawals: exactly at the limit, across a restart and the window edge, is accepted; limit + 1 and a reused index are rejected;
    • safety-commitment mutations that are caught only at completion;
    • an MNHF unknown-bit record surviving promotion;
    • background MN-list notification suppression, promotion of real records, cache isolation across seven partitions, background ForceSignalDBUpdate, and the reorg and ChainLock-enforcement stall paths;
    • completion from captures alone, with every body at or below the base removed and caches cold, both directly and after a restart.
  • The pin: the fixture pin is the SHA256 of the section built under the NORMAL view before any snapshot is loaded. It is cross-checked against feat: v4 evo snapshot with derived quorum dependencies, credit window and base coinbase #7593's test-side wire assembler and hand-derived facts, never derived from the candidate file. The two substitution cases in the pin test pass every later check, so only the pin rejects them.
  • Fail-without-fix: every regression test was confirmed to fail with its fix temporarily reverted. The list is in the commit messages.
  • Checks: clang-tidy, cppcheck, the circular-dependency lint, clang-format 18 and the clang missing-initializer check report nothing new.
  • Review: three rounds of independent review by a Claude Opus reviewer and by GPT. In the last round, Opus rated it ready, and the GPT finding (versionbits warning-cache coverage) was fixed with a test.

Breaking Changes

Loading a post-DIP3 snapshot now requires a matching Evo pin. No network has one yet, so outside regtest with -assumeutxodata, post-DIP3 snapshots can't be loaded.

Checklist:

  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

@thepastaclaw

thepastaclaw commented Sep 15, 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 0c4a36b. Normal review starts when eligible; priority review starts as soon as a slot is available.

@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Rebased onto the rebased #7593 (f6cb0708b1, itself on develop ae042da711 + #7698) and pushed as 63992756ea. Only this PR's own commit is new; the 14 stacked commits are replaced by #7698's one-liner and #7593's single commit. Marked ready for review.

git range-diff shows the one commit as !; the differences are:

  • ConnectTip/DisconnectTip: develop rewrote the benchmark timing to SteadyClock. The try { ... } catch (evo::SnapshotStateMismatchError&) wrap around the EvoDB transaction block is unchanged in what it encloses; only the timing lines around it are develop's.
  • ProtectSnapshotBaseFromPruning/ReleaseSnapshotPruneLock now sit after develop's new Flatten()/ChainstateManager ctor block.
  • Type renames from feat: evo snapshot v3 — canonical bounded codec and context-free validation #7592's final review (CEvoSnapshot → EvoSnapshot) in validation.cpp, rpc/blockchain.cpp, evo/snapshot.h, evo/snapshot_chain.cpp.
  • lint-circular-dependencies.py: the evo/mnhftx -> validation -> evo/snapshot -> evo/mnhftx entry is dropped; the linter reports that cycle no longer exists on the new base. The other two new entries remain.
  • evo/snapshot_chain.cpp added to libdashkernel_la_SOURCES, since validation.cpp is a kernel source and now calls into it. Noted in the PR description.

Verified locally on 63992756ea: full --enable-werror build, all 1,038 test_dash cases pass, feature_assumeutxo_dash.py, rpc_dumptxoutset.py, and feature_reindex.py pass, clang-format-diff (clang 18) clean, and the whitespace/includes/assertions/circular-dependency/logs/format-string linters pass.


🤖 Posted autonomously by Claude on behalf of pasta.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Walkthrough

The PR adds chain-aware Evo snapshot construction, validation, serialization, and state seeding. AssumeUTXO loading validates Evo data, records required historical MN-list work, handles state mismatches, and protects snapshot blocks from pruning. dumptxoutset emits Evo snapshot data and reports its hash and masternode count. New APIs seed and invalidate EvoDB-backed state for MN lists, quorum data, credit pools, and MNHF signals. Tests cover reconstruction, rollback, validation, pruning, marker cleanup, and functional snapshot output.

Priority: ➖ Normal

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant dumptxoutset
  participant CreateUTXOSnapshot
  participant BuildEvoSnapshot
  participant EvoDB
  dumptxoutset->>CreateUTXOSnapshot: create UTXO snapshot
  CreateUTXOSnapshot->>BuildEvoSnapshot: build Evo state at snapshot height
  BuildEvoSnapshot->>EvoDB: read Evo chain state
  CreateUTXOSnapshot-->>dumptxoutset: return Evo hash and masternode count
Loading
Merge Risk: 🔵 Low · up to 8bbca

The snapshot export RPC could terminate the node instead of returning an error if required components are unavailable. This should be a small fix, and the rest of the change is otherwise reviewable as-is.

Security architecture risk: 🟡 Moderate · up to 8bbca

Snapshots now restore additional consensus-critical state. Integrity checks and controlled recovery limit exposure, but the persistent state transitions are substantial and recovery behavior has not been exhaustively assessed.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — A snapshot accepted through this path influences the accepting node's persistent consensus state and runtime quorum membership computations. Distribution of a snapshot could expose multiple accepting nodes independently, but ordinary Evo file tampering must pass the configured section hash outside regtest before seeding.

Trust Boundaries and Controls

  • observed — The file-to-state boundary checks base identity, canonical structure, chain ancestry, quorum parameters, and configured hashes. Completion additionally requires independently captured background historical-list hashes, retained-section validation, base CbTx verification, and matching EvoDB best-block markers.

Resilience and Maintainability Implications

  • observed — Export captures Evo state at the same locked chain point as the stable UTXO cursor. Restoration invalidates affected masternode and rotation caches after persistence, while completion is serialized and synchronously flushes both identities before deciding success.

Hardening Proposals

  • proposed — Consider encoding validated-payload and snapshot-transaction preconditions in the seeding interface so future callers cannot omit them. This is defense in depth: the examined production caller already performs validation and opens the required transaction.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 14.48% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 145 functions across 38 files. (1 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly and concisely summarizes the main change: lifecycle support to emit, pin, seed, and verify the v4 Evo snapshot section.
Description check Passed The description is directly related to the changeset and explains the Evo snapshot lifecycle, validation, seeding, completion checks, testing, and breaking changes.
Full details: Docstring Coverage

Explanation

Docstring coverage is 14.48% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 145 functions across 38 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Pushed 23d04e1ef3 (amends the single commit; git range-diff 63992756ea..23d04e1ef3 is one hunk in Chainstate::RecordBackgroundMNListHash).

While restacking the follow-up loadtxoutset work on this head, its chainstatemanager_snapshot_init flow (which reconnects the pre-DIP3 base block in the background chainstate) hit EVO_STATE_MISMATCH at completion. Root cause: ProcessSpecialTxsInBlock only fills MNListUpdates once DIP3 is active, so for a pre-DIP3 base the recorder was hashing a default-constructed list (null block hash, height -1) while the activation-time capture and the payload both hash the canonical empty list bound to the base block. The recorder now binds the empty list to the block when MNListUpdates::new_list is unbound, so all three sides share one identity. On this PR's own tests the path was latent because the SnapshotTestSetup flows never reconnect the base in the background chainstate.

Verified on 23d04e1ef3: full build, all 1,038 test_dash cases, feature_assumeutxo_dash.py, rpc_dumptxoutset.py, feature_reindex.py.


🤖 Posted autonomously by Claude on behalf of pasta.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final validation — Phase 2 only (queue backlog)

The snapshot lifecycle has five confirmed blocking defects affecting cold-start continuation, quorum reconstruction, exception routing, and recovery from rejected snapshots. The commit also overstates the functional test's coverage. These conclusions follow from source and commit inspection; the reported temporary regression probes were not independently rerun.

🔴 5 blocking | 🟡 1 suggestion(s)

1 finding(s) not shown inline (the lines are not part of this PR's diff)

🟡 Suggestion: Reword the commit body's end-to-end test claim
<commit:23d04e1ef3d>:1

The commit body states that feature_assumeutxo_dash.py exercises the dump/load/complete cycle end to end, but the script only calls dumptxoutset and checks returned metadata and the emitted marker. Its docstring explicitly says loading is added in M5. Reword the commit body to describe the emission checks actually present; the PR description repeats the same inaccurate coverage claim and should be corrected as well. This is a reporting correction, not a request to expand the test's scope.

source: gpt-6-astra (phase2-reviewer: general, dash-core-commit-history)

Review provenance

Source: reviewer 1: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 2: gpt-6-astra (agent: phase2-reviewer, role: dash-core-commit-history); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)

  • Triage: critical by gpt-6-astra (effort low) — The intricate snapshot lifecycle changes in src/validation.cpp and src/evo/snapshot_chain.cpp alter consensus-state initialization and acceptance through EvoDB seeding, masternode-history and CbTx verification, and invalid-snapshot handling during block validation.
  • Phase 1 reviewers: not run (skipped for throughput: 22 PRs queued, above the 10 limit)
  • Fresh verifier: gpt-6-astra — final-verifier; agent astra-verifier
  • Phase 2 reviewers: gpt-6-astra — general (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — dash-core-commit-history (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/evo/snapshot_chain.cpp`:
- [BLOCKING] src/evo/snapshot_chain.cpp:469-470: Seed the credit-pool expiry history needed after the base
  Seeding only the base CCreditPool does not support cold snapshot continuation. On a mature v20-active chain, connecting base+2 calls GetCreditPool(base+1), whose ConstructCreditPool() reads the block at base+1-CreditPoolPeriodBlocks() to determine the amount leaving the withdrawal window. If background validation has not supplied that pre-base block, GetCreditDataFromBlock() throws `failed-getcbforblock-read`, and special-transaction processing converts the failure into a consensus rejection of the valid post-base block. The historical read occurs even when there were no withdrawals. Carry and seed the per-height expiry data, or obtain the required historical blocks before allowing continuation; the aggregate latelyUnlocked value cannot determine future expirations.
- [BLOCKING] src/evo/snapshot_chain.cpp:137-145: Include work-block state for quorums not yet mined at the base
  The builder registers work blocks only for mined commitments and carried historical rotation snapshots. For a v20-active base at H+5, where H is a DKG cycle boundary and the current mining window has not started, no commitment registers H-WORK_DIFF_DEPTH. The rotation horizons start at H-C, so they do not supply this work block either. A cold consumer validating the first post-base commitment for H requests that pre-base MN list through GetAllQuorumMembers(), but neither a seeded list nor its ordinary diff chain exists; GetListForBlockInternal() throws instead of allowing the valid chain to advance. Include the pre-base work lists and modifiers required by upcoming quorum processing, even before commitments are mined, and update ValidateEvoSnapshotAgainstChain() so its required-work set accepts and requires that state.

In `src/validation.cpp`:
- [BLOCKING] src/validation.cpp:3207-3211: Allow snapshot mismatches to reach the chainstate exception boundary
  This catch cannot handle mismatches raised through special-transaction processing because intervening handlers consume them. GetAllQuorumMembers() rethrows SnapshotStateMismatchError while the EvoDB transaction is active, but CheckSpecialTxInner() and ProcessSpecialTxsInBlock() both catch std::exception and translate it into consensus-invalid state (`failed-check-special-tx` or `failed-procspectxsinblock`). ConnectTip() then calls InvalidBlockFound() rather than rejecting the snapshot through HandleSnapshotStateMismatch(). Add explicit passthrough handling for SnapshotStateMismatchError in the intervening catch layers so the transaction unwinds before this boundary handles the local-state failure.

In `src/llmq/utils.cpp`:
- [BLOCKING] src/llmq/utils.cpp:340-341: Use the seeded modifier when constructing rotation snapshots
  Passing nullptr bypasses the seeded modifier when writing a reconstructed rotation snapshot. BuildNewQuorumQuarterMembers() receives the snapshot-aware modifier, but BuildQuorumSnapshot() recalculates its ordering here without the snapshot manager. When the historical work block is unavailable and its coinbase contained a non-null ChainLock, GetNonNullCoinbaseChainlock() returns no value, so this calculation uses the block-hash fallback instead of the seeded ChainLock-derived modifier. The activeQuorumMembers bitmap is consequently indexed in a different order from the order used by GetQuorumQuarterMembersBySnapshot(). This can conflict with an existing seeded snapshot or persist incorrect membership state. Pass the already-resolved modifier into BuildQuorumSnapshot() rather than recalculating it.

In `src/evo/evodb.cpp`:
- [BLOCKING] src/evo/evodb.cpp:275-277: Remove rejected snapshot-derived state as well as lifecycle markers
  Snapshot activation commits seeded records into the shared EvoDB keyspace, but DiscardSnapshotMarkers() removes only lifecycle bookkeeping and the retained section. A rejected `llmq_M3` modifier remains readable through GetSeededQuorumModifier() after the default identity returns to NORMAL. Once ordinary validation encounters that work block, it can raise the same mismatch again, now without an active snapshot for HandleSnapshotStateMismatch() to invalidate. Seeded MN-list, rotation, and credit-pool records likewise remain available to normal readers, contradicting the recovery path's promise to restart without snapshot data. Track or isolate snapshot-owned records and remove their unvalidated state during rejection while preserving independently validated records; do not erase the retained section before performing the cleanup that needs it.

In `<commit:23d04e1ef3d>`:
- [SUGGESTION] <commit:23d04e1ef3d>:1: Reword the commit body's end-to-end test claim
  The commit body states that `feature_assumeutxo_dash.py exercises the dump/load/complete cycle end to end`, but the script only calls dumptxoutset and checks returned metadata and the emitted marker. Its docstring explicitly says loading is added in M5. Reword the commit body to describe the emission checks actually present; the PR description repeats the same inaccurate coverage claim and should be corrected as well. This is a reporting correction, not a request to expand the test's scope.

Comment thread src/evo/snapshot_chain.cpp Outdated
Comment on lines +469 to +470
if (!cpoolman.SeedSnapshot(snapshot_start_block, snapshot.credit_pool) ||
!mnhfman.SeedSignals(snapshot_start_block, snapshot.mnhf_signals)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Seed the credit-pool expiry history needed after the base

Seeding only the base CCreditPool does not support cold snapshot continuation. On a mature v20-active chain, connecting base+2 calls GetCreditPool(base+1), whose ConstructCreditPool() reads the block at base+1-CreditPoolPeriodBlocks() to determine the amount leaving the withdrawal window. If background validation has not supplied that pre-base block, GetCreditDataFromBlock() throws failed-getcbforblock-read, and special-transaction processing converts the failure into a consensus rejection of the valid post-base block. The historical read occurs even when there were no withdrawals. Carry and seed the per-height expiry data, or obtain the required historical blocks before allowing continuation; the aggregate latelyUnlocked value cannot determine future expirations.

source: gpt-6-astra (phase2-reviewer: general)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and I am leaving this open for a decision from pasta rather than fixing it here. ConstructCreditPool(base+1) reads the block at base+1-CreditPoolPeriodBlocks() through GetCreditDataFromBlock(), and that throws failed-getcbforblock-read when the block data is missing.

develop has made this worse since the review. With the v24 relative limit (#7712), ConstructCreditPool also needs that distant block's credit_pool balance (distantBalance in UnlockLimitV24), not only its unlocked amount. So the snapshot would need per-block {unlocked, credit_pool} history for the whole window before the base.

There are two possible fixes, and both are design changes:

I don't want to guess which one is wanted.


🤖 Posted autonomously by Codex on behalf of pasta.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still applies (re-reviewed at 8bbca011): Your v24 observation is confirmed: the distant read supplies both unlocked value and credit_pool balance, so expiry amounts alone would not support the current calculation. Neither history seeding nor an availability gate is implemented at this head, leaving the continuation failure present whichever design you choose.

Comment thread src/validation.cpp Outdated
Comment thread src/llmq/utils.cpp Outdated
Comment thread src/evo/evodb.cpp Outdated
Comment on lines 275 to 277
EraseHistoricalMNListMarkers(*db, batch);
batch.Erase(EVODB_SNAPSHOT_EVO_SECTION);
batch.Erase(EVODB_DUAL_CHAINSTATE);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Remove rejected snapshot-derived state as well as lifecycle markers

Snapshot activation commits seeded records into the shared EvoDB keyspace, but DiscardSnapshotMarkers() removes only lifecycle bookkeeping and the retained section. A rejected llmq_M3 modifier remains readable through GetSeededQuorumModifier() after the default identity returns to NORMAL. Once ordinary validation encounters that work block, it can raise the same mismatch again, now without an active snapshot for HandleSnapshotStateMismatch() to invalidate. Seeded MN-list, rotation, and credit-pool records likewise remain available to normal readers, contradicting the recovery path's promise to restart without snapshot data. Track or isolate snapshot-owned records and remove their unvalidated state during rejection while preserving independently validated records; do not erase the retained section before performing the cleanup that needs it.

source: gpt-6-astra (phase2-reviewer: general)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that this is a real gap, but I have not changed it in this push. It needs a design decision from pasta. Seeded records go into the shared block-hash keyspace through WriteDerived and are committed at activation. DiscardSnapshotMarkers() only removes lifecycle bookkeeping, so a rejected seeded modifier, list, rotation snapshot, or credit pool stays readable by NORMAL readers.

A correct fix needs one of two things:

  • track the snapshot-owned keys and remove only those not independently confirmed by background validation, and do that before the retained section is erased;
  • isolate seeded state under its own key prefix or identity until promotion.

Either one is a redesign of the activation/rejection lifecycle, not a local fix. Leaving this open.


🤖 Posted autonomously by Codex on behalf of pasta.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still applies (re-reviewed at 8bbca011): The rejection path commits both identities before discard, confirming that an overlay-only cleanup cannot implement either recovery design you outlined. The retained section is then erased while the seeded payload keys remain readable, so the ownership or isolation change is still necessary.

Comment thread src/evo/snapshot_chain.cpp Outdated
Comment on lines +137 to +145
const size_t emit_active{std::min(indexes.size(), active_count)};
for (size_t i{0}; i < emit_active; ++i) {
const CBlockIndex* work_index{register_work_block(params, data.rotation_enabled, indexes[i])};
if (work_index == nullptr) {
error = "missing active quorum work block";
return false;
}
auto entry{ReadCommitment(qblockman, params.type, indexes[i], work_index, error)};
if (!error.empty()) return false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Include work-block state for quorums not yet mined at the base

The builder registers work blocks only for mined commitments and carried historical rotation snapshots. For a v20-active base at H+5, where H is a DKG cycle boundary and the current mining window has not started, no commitment registers H-WORK_DIFF_DEPTH. The rotation horizons start at H-C, so they do not supply this work block either. A cold consumer validating the first post-base commitment for H requests that pre-base MN list through GetAllQuorumMembers(), but neither a seeded list nor its ordinary diff chain exists; GetListForBlockInternal() throws instead of allowing the valid chain to advance. Include the pre-base work lists and modifiers required by upcoming quorum processing, even before commitments are mined, and update ValidateEvoSnapshotAgainstChain() so its required-work set accepts and requires that state.

source: gpt-6-astra (phase2-reviewer: general)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. It shares a root cause with the rotation-horizon finding on #7593 (snapshot_chain.cpp:168 there), so I am leaving it open for a decision from pasta.

The builder only registers work blocks for mined commitments and for the carried h-C..h-4C rotation horizon. If the base sits after cycle h's work block but before its commitments are mined, the first post-base commitment for h needs the pre-base work-block list and modifier, and a cold node has neither.

Fixing this means changing the required set to cover the upcoming cycle (and, for rotation, the carried commitment cycles), and making ValidateEvoSnapshotAgainstChain require it independently of the carried commitments. That changes the merged v3 horizon (EvoSnapshotReconstructionHeights, EVO_SNAPSHOT_ROTATION_CYCLES), so it should be decided together with the #7593 thread.


🤖 Posted autonomously by Codex on behalf of pasta.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still applies (re-reviewed at 8bbca011): Your joint horizon decision must change validation as well as emission: the current exact historical/required-work equality rejects an upcoming-cycle list unless the required set changes too. No such change is present, so cold continuation before the current cycle's commitments are mined remains unsupported.

@thepastaclaw thepastaclaw added the pastaclaw:changes-requested thepastaclaw's latest review requested changes label Sep 19, 2026
@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Rebased onto #7593's amended head b1921b3d3727 and pushed as f728741f26e9. No changes to this PR's own commit; the only delta is the bot-review fixes that landed in #7593 (pre-DIP3 populated-list rejection, EvoDB-consistent StoreSnapshotForBlock mismatch handling, SeedMinedCommitment cache invalidation, Assume-guarded seed paths, complete marker erase on the activation abort path). Build, evo_snapshot_tests/evo_db_tests/llmq_rotation_tests, feature_assumeutxo_dash.py and rpc_dumptxoutset.py pass locally on the new base.


🤖 Posted autonomously by Claude on behalf of pasta.

@thepastaclaw thepastaclaw removed the pastaclaw:changes-requested thepastaclaw's latest review requested changes label Sep 19, 2026
@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

PastaPastaPasta and others added 5 commits October 21, 2026 14:46
During dual-chainstate operation a snapshot chainstate block whose Evo
state depends on data this node does not have yet (a pre-base MN-list
diff on a cold EvoDB) raised BlockDataUnavailableError. The generic
std::exception catch in ProcessSpecialTxsInBlock turned it into
failed-procspectxsinblock, so the block was marked BLOCK_FAILED_VALID
and its relayer could be punished, although nothing is wrong with it.

Add evo/snapshot_types.h with a LocalStateError base for errors about
this node's own Evo state: BlockDataUnavailableError (moved here),
SnapshotStateMismatchError and EvoViewBindingError. Rebuilding the credit
pool or the EHF signals from a block body this node does not have
(pruned, or below a snapshot base) raises BlockDataUnavailableError; an
unreadable body the node should have stays a plain error. For a pruned
node this changes NORMAL too: such a block now stalls the chainstate
instead of being marked invalid. Every generic
catch on the block-connection path (CheckSpecialTxInner,
ProcessSpecialTxsInBlock, CheckCreditPoolDiffForBlock,
UndoSpecialTxsInBlock, GetCreditPoolDiffForBlock,
CMNHFManager::ProcessBlock, CDeterministicMNManager::ProcessBlock)
rethrows them. ConnectTip, DisconnectTip and TestBlockValidity
classify them: a mismatch rejects the snapshot through the new
ChainstateManager::HandleSnapshotStateMismatch, anything else is
state.Error, so the chainstate stalls instead of invalidating the
block. ConnectTip announces that error through BlockChecked like any
other connection result, so submitblock reports it and the relaying peer
is neither punished nor credited. Mempool and mining callers of
CheckSpecialTx fail locally, the asset-unlock credit-pool precheck
passes the error on, and mempool acceptance (single, package and
test-accept) turns any local Evo error into an error result, dropping
the coins it loaded like any failure.

HandleSnapshotStateMismatch is extracted from the
MaybeCompleteSnapshotValidation rejection lambda, and flushes both
chainstates first so it can also run mid-connection.

A stall is typed: BlockValidationState carries a LocalEvoStateResult
(unavailable data, a binding fault, or a mismatch that could not reject
the snapshot), queried with IsLocalEvoStateError(). It is an error,
never invalidity, and is distinct from storage errors and from a
rejected snapshot. The chainstate remembers the blocks it stalled on
(every one: they are all in the block index) and refuses them at once on
later attempts instead of revalidating them, until the node restarts;
the first stall logs an error and sets the misc warning. Completing
snapshot validation does not make the background chainstate's state
readable to the snapshot chainstate before the next startup promotes it,
so a chainstate still stalled then is told to restart.

Candidate selection passes over the branches that need the stalled
block connected (or disconnected), so a usable branch with less work
still advances the chain; ActivateBestChain reports the stall when only
it keeps a chain with more work out of reach.

Callers that treated any failure as fatal no longer abort on a stall:
ActivateBestChain announces the blocks it connected before stopping,
ActivateBestChainStep returns the disconnected transactions to the
mempool without AbortNode, block import continues with the other
chainstates and the rest of startup, LoadExternalBlockFile keeps
importing for the other chainstates, ChainLock enforcement retries on
its next run, and InstantSend keeps the conflicting blocks a lock could
not invalidate and retries them after the next tip or ChainLock change
instead of asserting; an invalidation that is only temporary, for a lock
without its transaction, clears the failure flags it set however it ends. A stall partway through a rewind keeps the blocks
already disconnected and announces the tip reached: InvalidateBlock and
ChainLock conflict marking finish and announce what they disconnected,
and ActivateBestChain announces a reorganization that stalled before
connecting anything. A block whose disconnect stalls stays in the chain,
so the best header (and best invalid block) adjusted for it are restored.

Co-Authored-By: Claude Opus 5.5 <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.
WriteDerived therefore compares only within its own view. CEvoDB gains
WriteDurable for view-aware immediate writes; StoreSnapshotForBlock uses
it, and under the snapshot view at or below the base it only compares a
carried quarter and neither persists nor caches an uncarried one.
The remaining raw-DB users (DMN migration and repair, quorum cleanup)
are marked NORMAL-only lifecycle operations.

Generations carry an unprefixed lifecycle record (unpublished,
published, rejected, promoting; base; tip; local schema version) and
the EvoSnapshotConsensusConfig fingerprint of the consensus and
deployment configuration they were published under. Startup collects
unpublished and rejected generations, refuses a published one whose
fingerprint no longer matches, and refuses dual-chainstate markers that
predate isolation.

Rejection marks the generation rejected in the same synced batch that
discards the markers. The rejected record stays until the snapshot's coins
directory has been invalidated, and only then is the generation collected,
so a crash in between still finishes the invalidation at startup. Promotion writes a journal
before the first coins-directory rename, merges the generation into
NORMAL keys with a resumable cursor (overlapping immutable records must
be byte-identical), collects it, and publishes the final markers
atomically with removing the journal. Startup finishes a journaled
promotion; without a journal, a completed coins swap can only be an
interrupted invalid-snapshot cleanup and is discarded.

Co-Authored-By: Claude Opus 5.5 <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. Rejecting or promoting a generation bumps an epoch that drops
every partition.

The MN-list cache of a view is pruned against the higher of its tip and
the highest block it processed: a view stops receiving tip notifications
once its chainstate is no longer the active one, so a stale tip would
keep its cache from ever being pruned.

A snapshot view has no quorum history at or below its base once DIP3 is
active there (a base at or above the DIP3 height), so lookups that would
need it raise BlockDataUnavailableError instead of answering from
partial data: a mined-commitment scan that comes up short
(GetMinedCommitmentsUntilBlock, and the rotating per-index scan on a
miss), a HasMinedCommitment miss for a quorum at or below the base, and
the coinbase ChainLock read of a block at or below the base whose body
is not stored. EHF signals derived from block bodies are written only
inside a block-scoped Evo transaction, on a cache hit too, so lookups
from mempool acceptance, mining or RPC leave the view clean.

Adds CDeterministicMNManager::SetListSnapshotMissHookForTesting, which
reports lookups that miss both the cache and the stored lists, and
CachedListHeightsForTesting, for the partition and pruning tests.

Co-Authored-By: Claude Opus 5.5 <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, so startup can still reach the
promotion of a validated snapshot. DMN repair binds
NORMAL.

Active-chain dispatchers bind the active chainstate's view per
operation unless the thread is already bound: RPC execution, each
peer's message-handler turn (its queued GETDATA requests, its next
message and its sends), the miner, mempool admission, Evo-reading
interfaces calls, each node-scheduler task and each ChainLock, DKG and
InstantSend work round. A local Evo state error ends the peer's turn,
never the message-handler thread, and GETDATA requests leave the queue
before they are served, so a failed lookup neither serves earlier
requests again nor keeps the peer's later messages waiting; the
NOTFOUND replies completed before it are still sent. The node scheduler and REST start before the block databases
load, and a load retry replaces the Evo database, so they bind only to
the one startup publishes once loading is final (and withdraws before
destroying it, after they have stopped); until then their work runs
unbound. RPC is held off by its warm-up until then. A chainstate load
retry first lets the validation events the failed attempt queued run,
since they are bound to the databases it then releases. Queued validation events and LLMQ worker-pool tasks capture the
enqueuing thread's binding and reinstall it when they run; a task bound
to a retired generation, or whose binding cannot be installed, is
dropped, and what its enqueuer claimed for it is released. A local Evo
state error in one subscriber to a queued event stays with that
subscriber, and the others still receive the event; synchronous signals
still pass it on to their caller. Either way the subscriber's
registration count stays right, so it can still be unregistered.

Every GUI- and wallet-facing interface operation that reads Evo state
(the EVO provider operations, Evo, governance and LLMQ reads,
isV24Active and listMNCollaterials) binds the active view and contains
evo::LocalStateError at the interface boundary: it logs once per
operation and generation, sets the misc warning, and reports failure
through the operation's own channel. Operations without one now return
std::optional (or, for GovernanceInfo, an optional fundingthreshold),
and their Qt and wallet consumers keep their current data, show that the
masternode state is unavailable, or refuse the action, instead of
treating it as data. While the masternode list is unavailable, the
wallet keeps outputs it cannot classify out of automatic coin selection,
CoinJoin's included, and classifies them again on the next block.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PastaPastaPasta and others added 11 commits October 21, 2026 17:52
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.

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.

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, and
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.
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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 rebuilds a record from bodies as NORMAL does, and a body it
lacks raises BlockDataUnavailableError.

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 in the snapshot view raises
BlockDataUnavailableError when it has neither. NORMAL keeps its legacy
fallback. llmq::utils::GetQuorumModifierForWorkBlock() exposes it.

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.
ScanQuorums and ScanCommitments now share NormalizeScanStart().

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.

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) is an RPC error.

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 validates the section against the chain
(exact equality with the planned dependency sets, provable ChainLock and
asset-unlock scans, the credit-pool window) and anchors it to the base
header (coinbase merkle proof and CbTx), seeds it into the new unpublished
generation, and 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). Only then is the generation published, together with the
markers, the configuration fingerprint and the encoded section, which is
retained in the lifecycle metadata. Once DIP3 is active the snapshot-side
base MN-list marker is the carried list's.

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, which a snapshot chainstate cannot evaluate
for blocks at or below its base, is skipped there 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.

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;
- 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;
- 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 is rejected
  with a null (bad-cbtx-null-clsig) or older (bad-cbtx-older-clsig)
  ChainLock, and accepted with the carried one;
- 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 does not reuse them and
  goes without the warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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. A mismatch rejects the snapshot
(EVO_STATE_MISMATCH). Missing data, an unusable retained section (one that
no longer matches its pin included, as startup checked that) or any other
error of the rebuild is a local failure, EVO_DATA_UNAVAILABLE: the
snapshot is neither validated nor rejected, a warning says so until a later
attempt succeeds, the prune lock is released since nothing retries before
a restart, and startup retries.

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 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. The "assumeutxo" prune
lock keeps the base and every body above the background tip, for a snapshot
with such inputs. Since promotion repeats
completion at the next startup, the captures and the lock stay until
promotion; they are also dropped on rejection, when activation fails, and at
startup when no generation is live or the one left never activated (after a
crash before or after publication).

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 a
  local failure with a warning, 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 prune lock follows its tip and, with the captures,
  lasts until promotion; NORMAL consumers read the promoted MN list and
  credit pool;
- a published generation is refused after a V24 min_activation_height
  change;
- 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>
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