fix(digstore-chain): pinned-root verification without full lineage walk (#1431) - #31
Merged
Merged
Conversation
Push-early WIP checkpoint (§1.8). Adds verify_pinned_root API next. Co-Authored-By: Claude <noreply@anthropic.com>
…ge walk (#1431) The local /s/<store>:<root> read (§5.3 loopback tier) hard-fails because the full singleton-lineage walk (sync_datastore) aborts when any single intermediate generation's spend is unparseable (#747: "parse next store: missing child"), so even a valid pinned root becomes unreadable. Add digstore_chain::singleton::verify_pinned_root(chain, store_id, pinned_root): a bounded, fail-closed chain-anchor check that locates the current unspent singleton directly via its launcher-id hint (every generation is hinted to launcher_id == store_id per DataStore::get_recreation_memos) and reads the on-chain root from the ONE spend that created the tip — no per-generation walk. Returns Ok iff pinned_root equals the current on-chain root; Err (never a false Ok) on any chain-read failure, absent singleton, or root mismatch. This is the strongest anti-rollback stance (only the current root passes; stale + fabricated roots rejected), satisfying §5.3 / NC-9. Additive: existing from_spend / sync_datastore / current_root semantics unchanged (the #747 walk bug itself is left to its own fix). Regression suite builds a real 3-generation store on the simulator and proves verify_pinned_root chain-anchors the current root even when the full walk is broken by a missing intermediate spend. SPEC.md §9.1 documents the contract. Version 0.18.0 -> 0.19.0 (minor: new public API). Closes #1431 Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
marked this pull request as ready for review
July 21, 2026 20:38
MichaelTaylor3d
commented
Jul 21, 2026
MichaelTaylor3d
left a comment
Contributor
Author
There was a problem hiding this comment.
Correctness review (loop-reviewer) — PASS
Fresh independent correctness pass on verify_pinned_root (#1431). Security + adversarial legs run in parallel; this leg is correctness + test validity.
Findings:
- Correctness — OK. Locates the current unspent singleton via one
unspent_coins_by_hint(store_id)query, reads the tip's root from the tip's PARENT spend viafrom_spend, and compares topinned_root.Ok(())only on an exact match against the live current root; every other path returnsErr. Parent→child linkage guaranteesfrom_spend(parent_spend)reconstructs precisely the candidate coin, so no extra coin-id cross-check is needed. - Anti-rollback — OK. Verified the primitive: both the real coinset impl (
get_coin_records_by_hint(.., Some(false))+ a!cr.spentguard) and the MockChain return ONLY unspent coins. A singleton has exactly one unspent tip, so stale/spent generations can never be a candidate — a stale root always mismatches.stale_root_is_rejectedexercises this. from_spend(.., &[])— verified safe (not a bug).sync_datastorepasses the parent'sdelegated_puzzles; this fn passes&[]. Confirmed against chia-sdk-driver 0.18.0DataStore::from_spend:new_metadata(root_hash) andlauncher_idare computed independently ofparent_delegated_puzzles; that arg only populates the returneddelegated_puzzlesfield, which this fn never reads. So&[]can neither yield a wrong root nor cause a false parse failure.- Tests — genuine, not vacuous.
pinned_root_verifies_when_full_walk_is_brokenremoves the eve->gen1 spend, assertssync_datastorereally aborts ("singleton spend not found"), and thatverify_pinned_rootstill succeeds (it only needs the tip's parent = gen1 spend, which remains).stale_/never_seen_/unreachable_genuinely hit the mismatch / no-candidate branches. - Additive/§5.1 — OK. New public fn only;
from_spend/sync_datastore/current_rootuntouched; 0.18.0->0.19.0 minor correct. Reuse (#1343): composed existing primitives, no third lineage impl. Excellent rustdoc; SPEC §9.1 added. - Checks — all green at d6823f2 (build&test ubuntu+windows, version-increment, CodeQL rust/js/actions, commitlint, dig-client-wasm, supply-chain audit).
Verdict: PASS. One non-gating coverage suggestion inline (resolved).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
digstore_chain::singleton::verify_pinned_root(chain, store_id, pinned_root) -> Result<()>— a fail-closed, bounded chain-anchor check that verifies a pinned root is the store's CURRENT on-chain generation WITHOUT walking the full singleton lineage.Root cause (#747): the local
/s/<store>:<root>read re-derives the current root viasync_datastore's full forward walk (launcher -> eve -> ... -> tip), which aborts the moment ONE intermediate generation's spend is unparseable ("parse next store: missing child"), so even a valid pinned root becomes unreadable. This unblocks the MVP loopback read tier (#836/#852). The #747 walk bug itself is left to its own fix — this is the additive without-walk path that sidesteps it.How it works
Every datastore generation is created hinted to its
launcher_id(firstDataStore::get_recreation_memosmemo, ==store_id). So the current unspent singleton is located with ONEunspent_coins_by_hint(store_id)query and its on-chain root read from the single spend that created the tip (the tip's parent) — no per-generation walk.pinned_rootis accepted only when it equals that current root.Fail-closed / anti-rollback (§5.3 / NC-9)
Returns
Err— never a falseOk— on any chain-read failure, absent unspent singleton, or root mismatch. Only the current on-chain root passes; stale (superseded) and never-on-chain roots are rejected. Historical-but-real generations are intentionally out of scope (would require the enumeration this API avoids).Tests (§2.2 regression-first)
5 tests build a REAL 3-generation store on the Chia simulator:
pinned_current_root_verifies— current root chain-anchors.pinned_root_verifies_when_full_walk_is_broken— the #747 regression: with an intermediate spend removed,sync_datastoreaborts ("singleton spend not found") yetverify_pinned_rootstill succeeds.stale_root_is_rejected,never_seen_root_is_rejected,unreachable_chain_fails_closed— fail-closed paths.Blast radius
Additive new public fn on
digstore_chain::singleton;from_spend/sync_datastore/current_rootsemantics unchanged. Consumer: dig-node-core (git-rev dep). SPEC.md §9.1 added. Workspace version 0.18.0 -> 0.19.0 (minor: new public API).Closes #1431 (super-repo tracking issue).
🤖 loop lane #1431