Skip to content

feat: evo snapshot v3 — canonical bounded codec and context-free validation - #7592

Open
PastaPastaPasta wants to merge 5 commits into
dashpay:developfrom
PastaPastaPasta:feat/evo-snapshot-format
Open

feat: evo snapshot v3 — canonical bounded codec and context-free validation#7592
PastaPastaPasta wants to merge 5 commits into
dashpay:developfrom
PastaPastaPasta:feat/evo-snapshot-format

Conversation

@PastaPastaPasta

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Part of the AssumeUTXO M4 decomposition (#7579, now draft — see the series map there). For a Dash node, a UTXO snapshot alone is not enough to operate at the base block: the node also needs the deterministic MN list, quorum commitments, rotation state, credit pool, and MNHF signals that consensus at that height depends on. This PR defines the evo snapshot v3 format: the versioned interchange encoding for that state, its DoS-hardened decoder, and every validation invariant that needs no chain context. It deliberately contains no chain access and no lifecycle wiring — building a snapshot from chain state and validating one against the chain come in the next PR of the series; dumptxoutset/load integration after that. Reviewing this PR is reviewing the wire format and its trust boundary, nothing else.

What was done?

  • src/evo/snapshot.{h,cpp}: the CEvoSnapshot types, canonical serialization, bounded validating deserialization, and CEvoSnapshot::Validate() (context-free invariants), plus ReconstructHistoricalMNLists(), CanonicalMNListHash(), GetEvoSnapshotHash(), and VerifyEvoSnapshotCbTx() (pure CbTx cross-checks over decoded content).
  • AssumeutxoData gains an EvoSnapshotHash field: the hard-coded expected hash of the canonical evo section, the same security anchor role hash_serialized plays for the UTXO set.
  • CDeterministicMNList gains ApplyDiffForSnapshot() and GetHeightForSnapshotCodec(); CRangesSet gains a bounded validating unserializer; OverrideStream gains GetStream() for the per-object decode budgets.
  • A ubsan suppression for vendored immer's HAMT merge (shift-base), reachable only through the unit tests' deliberately hash-colliding MN fixtures, in the style of the existing vendored-library entries.
  • A one-line explicit cast in ReadFixedBitSet whose trailing-bits mask otherwise trips clang's implicit-sign-change check for any bitset size not a multiple of eight — these tests are the first to decode such bitsets under the sanitizer job.
  • ~650 lines of unit tests: round-trip and representation independence, a golden serialized value, canonical-order and counter rejection, diff-chain determinism, cumulative decode budgets, a context-free validation matrix, claimed-size bounds, and CbTx cross-checks.

Why a bespoke codec instead of the classes' own serializers (raised in #7579 review): (1) snapshot content is hashed and cross-checked (the completion-time MN-list comparison and CbTx checks), so the encoding must be a pure function of set content — hence canonical proTxHash ordering rather than container iteration order; (2) the snapshot file is untrusted by definition and read once, so its decoder validates and bounds everything, while the EvoDB/P2P deserializers are trusted hot paths that would pay that tax per block; (3) a versioned interchange format must not silently drift when in-memory serialization changes. Note per-object serializers are reused — CDeterministicMN, commitments, and the credit pool decode through their own SERIALIZE_METHODS wrapped in a budgeted stream; only the container level (ordering, bounds, budgets) is bespoke.

Open question for reviewers (from #7579 feedback on header surface): the codec helpers are Stream-templated and therefore header-bound; I can move them into an evo::detail namespace to shrink the nominal API if preferred — say the word and it's a small mechanical commit.

How Has This Been Tested?

Full unit suite on a --enable-werror build, plus the snapshot/netinfo/util suites under a --with-sanitizers=undefined,integer build with the repo's ubsan suppressions (which is what surfaced the ReadFixedBitSet and immer items above).

Breaking Changes

None. The format is new and nothing constructs or consumes it on-chain yet; the AssumeutxoData field is populated with a null placeholder for the existing regtest entries.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation
  • I have assigned this pull request to a milestone (for repository code-owners and collaborators only)

PastaPastaPasta and others added 5 commits August 13, 2026 15:10
…e validation

First code PR of the assumeutxo M4 series (dashpay#7579 decomposition): the versioned interchange format for Dash's evo state alongside a UTXO snapshot - canonical serialization, DoS-bounded validating decode, and every validation invariant that needs no chain context. Chain-aware building/validation and dump/load integration follow in the next PRs of the series.

Canonical ordering exists because snapshot content is hashed and cross-checked; per-object serializers are reused through a bounded stream wrapper, with bespoke code only at container level (ordering, bounds, per-entry budgets); decode-time checks deliberately stay out of the trusted hot EvoDB deserializers. AssumeutxoData gains the EvoSnapshotHash anchor the format is pinned by. Includes the aggregate rotation skip-list bound (lists accumulate across every quorum index and wrap the combined MN list), the CRangesSet bounded unserializer, and a vendored-immer shift-base ubsan suppression reachable only through the deliberately hash-colliding test fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… sign change

The mask for rejecting out-of-range trailing bits promotes through operator~ to a negative int before its implicit conversion back to uint8_t, which clang's implicit-integer-sign-change check reports for every bitset whose size is not a multiple of eight. The evo snapshot unit tests are the first to deserialize such bitsets under the sanitizer job. Same bits, stated explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The codec PR shipped UnserializeBounded without its unit coverage; add the malformed/canonical decode matrix and the round-trip checks from the original series.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@PastaPastaPasta
PastaPastaPasta force-pushed the feat/evo-snapshot-format branch from d97a8d6 to 5fedfc9 Compare August 13, 2026 20:18
@PastaPastaPasta
PastaPastaPasta marked this pull request as ready for review August 13, 2026 20:19
@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 Aug 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@PastaPastaPasta, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c8edd9cf-d27e-4fe0-a767-8a67514f993b

📥 Commits

Reviewing files that changed from the base of the PR and between 51496f0 and 5fedfc9.

📒 Files selected for processing (15)
  • src/Makefile.am
  • src/Makefile.test.include
  • src/chainparams.cpp
  • src/chainparams.h
  • src/evo/deterministicmns.cpp
  • src/evo/deterministicmns.h
  • src/evo/snapshot.cpp
  • src/evo/snapshot.h
  • src/serialize.h
  • src/streams.h
  • src/test/evo_netinfo_tests.cpp
  • src/test/evo_snapshot_tests.cpp
  • src/test/util_tests.cpp
  • src/util/ranges_set.h
  • test/sanitizer_suppressions/ubsan

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

@thepastaclaw

thepastaclaw commented Aug 13, 2026

Copy link
Copy Markdown

⛔ Blockers found — Opus deferred (commit 5fedfc9)
Canonical validated blockers: 3

@github-actions

Copy link
Copy Markdown

Potential PR merge conflicts

This is advisory only. It does not block CI, but it marks PRs that will likely need a rebase depending on merge order.

If this PR merges first

These open PRs will likely need a rebase:

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Preliminary review — Codex only

The bounded codec is well tested, but three in-scope correctness gaps remain: MNHF input ordering is not canonicalized at the trust boundary, context-free MNHF and credit-pool invariants are omitted, and valid commitments for supported overridden LLMQ parameters cannot be decoded. These issues prevent the v3 format from meeting its stated canonical and chain-configuration requirements.
Source: reviewer backend gpt-5.6-sol; final verifier backend gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking

🤖 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.h`:
- [BLOCKING] src/evo/snapshot.h:567-572: Reject noncanonical MNHF signal ordering
  The decoder inserts signals directly into a `std::map`, which normalizes their order and rejects only duplicate keys. The same signal set can therefore be supplied in any permutation, accepted by `Unserialize()`, and reserialized in sorted order. This conflicts with the decoder's `require_canonical_order` contract and with the strict ordering checks applied to the other top-level collections, allowing multiple accepted wire representations for the same snapshot.
- [BLOCKING] src/evo/snapshot.h:488-499: Do not validate commitment sizes against static default LLMQ parameters
  `SnapshotLLMQParams()` obtains the compile-time entry from `Consensus::available_llmqs`, and `ReadMinedQuorumCommitment()` requires both commitment bitsets to have exactly that entry's default `size`. However, `-llmqtestparams`, the related regtest overrides, and `-llmqdevnetparams` modify the effective size stored in `Params().GetLLMQ(type)`, which consensus commitment validation uses. A commitment produced under any nondefault supported size is therefore rejected by this decoder: a larger size exceeds the read bound, while a smaller size fails the exact-size comparison. Since this layer is intentionally context-free, it should enforce a format-level maximum and internal bitset consistency, then leave exact sizing to the later chain-aware validation using the effective chain parameters.

In `src/evo/snapshot.cpp`:
- [BLOCKING] src/evo/snapshot.cpp:193-204: Validate credit-pool amounts and MNHF signal values
  `Validate()` bounds only the MNHF map's cardinality and performs no semantic validation of the credit-pool amounts. A snapshot with a signal such as `(255, -1)`, a signal height above the snapshot height, or negative/out-of-range `locked`, `currentLimit`, and `latelyUnlocked` values currently passes validation and can receive a canonical snapshot hash. Consensus-produced signals always use bits below `VERSIONBITS_NUM_BITS` and heights between zero and the snapshot height. Credit-pool construction produces money-range nonnegative amounts with `currentLimit <= locked`; enforcing those properties here also prevents an untrusted seeded value from entering later signed credit-pool arithmetic.

Comment thread src/evo/snapshot.h
Comment on lines +567 to +572
const size_t signal_count{ReadBoundedCompactSize(s, Consensus::MAX_VERSION_BITS_DEPLOYMENTS, "MNHF signals")};
for (size_t i{0}; i < signal_count; ++i) {
std::pair<uint8_t, int> signal;
s >> signal;
if (!mnhf_signals.emplace(signal).second) throw std::ios_base::failure("duplicate MNHF signal bit");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: Reject noncanonical MNHF signal ordering

The decoder inserts signals directly into a std::map, which normalizes their order and rejects only duplicate keys. The same signal set can therefore be supplied in any permutation, accepted by Unserialize(), and reserialized in sorted order. This conflicts with the decoder's require_canonical_order contract and with the strict ordering checks applied to the other top-level collections, allowing multiple accepted wire representations for the same snapshot.

Suggested change
const size_t signal_count{ReadBoundedCompactSize(s, Consensus::MAX_VERSION_BITS_DEPLOYMENTS, "MNHF signals")};
for (size_t i{0}; i < signal_count; ++i) {
std::pair<uint8_t, int> signal;
s >> signal;
if (!mnhf_signals.emplace(signal).second) throw std::ios_base::failure("duplicate MNHF signal bit");
}
const size_t signal_count{ReadBoundedCompactSize(s, Consensus::MAX_VERSION_BITS_DEPLOYMENTS, "MNHF signals")};
uint8_t previous_signal_bit{0};
bool have_previous_signal{false};
for (size_t i{0}; i < signal_count; ++i) {
std::pair<uint8_t, int> signal;
s >> signal;
if ((have_previous_signal && signal.first <= previous_signal_bit) ||
!mnhf_signals.emplace(signal).second) {
throw std::ios_base::failure("noncanonical MNHF signal order");
}
previous_signal_bit = signal.first;
have_previous_signal = true;
}

source: ['codex']

Comment thread src/evo/snapshot.cpp
Comment on lines +193 to +204
void CEvoSnapshot::Validate(bool require_canonical_order) const
{
if (version != EVO_SNAPSHOT_VERSION) throw std::ios_base::failure("unsupported evo snapshot version");
if (base_block_hash.IsNull() || mn_list.GetBlockHash() != base_block_hash) {
throw std::ios_base::failure("evo snapshot base block mismatch");
}
ValidateCanonicalMNInvariants(mn_list);
if (quorums.size() > Consensus::available_llmqs.size() ||
historical_mn_list_diffs.size() > EvoSnapshotMaxHistoricalMNLists() ||
quorum_modifiers.size() > EVO_SNAPSHOT_MAX_MODIFIERS ||
mnhf_signals.size() > Consensus::MAX_VERSION_BITS_DEPLOYMENTS) {
throw std::ios_base::failure("oversized evo snapshot collection");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: Validate credit-pool amounts and MNHF signal values

Validate() bounds only the MNHF map's cardinality and performs no semantic validation of the credit-pool amounts. A snapshot with a signal such as (255, -1), a signal height above the snapshot height, or negative/out-of-range locked, currentLimit, and latelyUnlocked values currently passes validation and can receive a canonical snapshot hash. Consensus-produced signals always use bits below VERSIONBITS_NUM_BITS and heights between zero and the snapshot height. Credit-pool construction produces money-range nonnegative amounts with currentLimit <= locked; enforcing those properties here also prevents an untrusted seeded value from entering later signed credit-pool arithmetic.

Suggested change
void CEvoSnapshot::Validate(bool require_canonical_order) const
{
if (version != EVO_SNAPSHOT_VERSION) throw std::ios_base::failure("unsupported evo snapshot version");
if (base_block_hash.IsNull() || mn_list.GetBlockHash() != base_block_hash) {
throw std::ios_base::failure("evo snapshot base block mismatch");
}
ValidateCanonicalMNInvariants(mn_list);
if (quorums.size() > Consensus::available_llmqs.size() ||
historical_mn_list_diffs.size() > EvoSnapshotMaxHistoricalMNLists() ||
quorum_modifiers.size() > EVO_SNAPSHOT_MAX_MODIFIERS ||
mnhf_signals.size() > Consensus::MAX_VERSION_BITS_DEPLOYMENTS) {
throw std::ios_base::failure("oversized evo snapshot collection");
ValidateCanonicalMNInvariants(mn_list);
if (!MoneyRange(credit_pool.locked) ||
!MoneyRange(credit_pool.currentLimit) ||
!MoneyRange(credit_pool.latelyUnlocked) ||
credit_pool.currentLimit > credit_pool.locked) {
throw std::ios_base::failure("invalid evo snapshot credit pool");
}
const int snapshot_height{mn_list.GetHeightForSnapshotCodec()};
for (const auto& [bit, height] : mnhf_signals) {
if (bit >= VERSIONBITS_NUM_BITS || height < 0 || height > snapshot_height) {
throw std::ios_base::failure("invalid evo snapshot MNHF signal");
}
}
if (quorums.size() > Consensus::available_llmqs.size() ||
historical_mn_list_diffs.size() > EvoSnapshotMaxHistoricalMNLists() ||
quorum_modifiers.size() > EVO_SNAPSHOT_MAX_MODIFIERS ||
mnhf_signals.size() > Consensus::MAX_VERSION_BITS_DEPLOYMENTS) {
throw std::ios_base::failure("oversized evo snapshot collection");
}

source: ['codex']

Comment thread src/evo/snapshot.h
Comment on lines +488 to +499
const auto& params{SnapshotLLMQParams(llmq_type)};
const size_t total_count{SnapshotCommitmentCount(params, rotation_enabled)};
const size_t expected_active{static_cast<size_t>(params.signingActiveQuorumCount)};
const size_t active_count{ReadBoundedCompactSize(s, expected_active, "active commitments")};
active_commitments.reserve(active_count);
for (size_t i{0}; i < active_count; ++i) {
active_commitments.emplace_back(ReadMinedQuorumCommitment(s, params));
}
const size_t safety_count{ReadBoundedCompactSize(s, total_count - expected_active, "safety commitments")};
safety_commitments.reserve(safety_count);
for (size_t i{0}; i < safety_count; ++i) {
safety_commitments.emplace_back(ReadMinedQuorumCommitment(s, params));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: Do not validate commitment sizes against static default LLMQ parameters

SnapshotLLMQParams() obtains the compile-time entry from Consensus::available_llmqs, and ReadMinedQuorumCommitment() requires both commitment bitsets to have exactly that entry's default size. However, -llmqtestparams, the related regtest overrides, and -llmqdevnetparams modify the effective size stored in Params().GetLLMQ(type), which consensus commitment validation uses. A commitment produced under any nondefault supported size is therefore rejected by this decoder: a larger size exceeds the read bound, while a smaller size fails the exact-size comparison. Since this layer is intentionally context-free, it should enforce a format-level maximum and internal bitset consistency, then leave exact sizing to the later chain-aware validation using the effective chain parameters.

source: ['codex']

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