Skip to content

feat(chain): implement fork choice over the consensus store - #32

Open
adust09 wants to merge 1 commit into
developfrom
feat/fork-choice-20260826-1758
Open

feat(chain): implement fork choice over the consensus store#32
adust09 wants to merge 1 commit into
developfrom
feat/fork-choice-20260826-1758

Conversation

@adust09

@adust09 adust09 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What

verity-chain owned the state transition but not the store architecture.md:173 says it owns. This adds fork choice: the Store, block import, the interval clock, LMD-GHOST head selection, the safe target, gossip vote admission, pruning, and attestation-target selection.

Transcribed from leanSpec src/lean_spec/spec/forks/lstar/fork_choice.py, timeline.py, and validator_duties.py at 0588c2d2.

Three decisions worth reviewing

The store is mutated in place. Everything else in the crate is a pure function returning a fresh value. The store is not: it is a long-lived aggregate with one writer, and it holds a State per unfinalized block, so copying it per imported block costs O(chain) per block for nothing. What the copy guaranteed is kept as a contract — an entry point returning Err leaves the store byte-for-byte as it found it — stated on each one and covered by unit tests.

The cryptography is split out, not stubbed. leanSpec verifies signatures inside three operations. This crate has no cryptographic dependency by design, so each is cut at exactly that point and the caller verifies in between:

leanSpec here the caller supplies
on_block on_block the block proof check
on_gossip_attestation validate_attestation_signer + record_attestation_signature the XMSS verify
on_gossip_aggregated_attestation record_aggregated_payload the aggregate verify

The interval-2 aggregator duty is absent for the same reason. Both seams close with verity-crypto. Signatures are held as opaque bytes: the store never interprets them, and fork choice weighs votes by who signed.

Conformance asserts the whole snapshot. All 123 vectors, 692 steps, against all ten fields of storeSnapshot rather than the shallower checks block — leanSpec's own docs say why: block membership is what makes over- and under-pruning observable, and weights must agree "even where two clients agree on the head". checks is asserted too, for the labels, the attestation target, and the block bodies.

What is not asserted, and why

Eleven vectors are not asserted on their vote pools. Both lists are named in the harness with the reason, and both fail the run if a listed vector stops diverging:

  • GENERATOR_POOL_EXCEEDS_WIRE (8) — the generator merges the votes its proposer aggregated into the store before applying the block. A replaying client sees only the block, and a body carries at most MAX_ATTESTATIONS_DATA distinct votes; in one vector it carries none while the proposer's pool held three. That state never reached the wire.
  • NEEDS_AGGREGATION (3) — they turn on the interval-2 duty above.

Everything else in those vectors — head, checkpoints, block membership, clock, block bodies — is still asserted. Two further steps expect INVALID_SIGNATURE, which this crate cannot produce; only those steps are skipped, not the vectors.

Also

  • RejectionReason gains the 20 fork-choice and gossip variants; four of leanSpec's 36 remain, from paths this workspace has not reached.
  • The JSON shapes both harnesses mirror move into tests/common, so the state-transition harness drops ~250 duplicated lines. It still passes unchanged.
  • CI's tar --wildcards takes the fork_choice tree.

Verification

cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test --workspace (61 unit tests), pre-commit hooks, and the full fixture job against the pinned production tarball:

fork choice: 123 vectors matched, 692 steps replayed, 11 not asserted on their vote pools
state transition: 73 vectors matched, 1 carried no block

The state transition answers whether a block is valid; fork choice answers
which of the valid chains is the one. This adds the store that question
needs — every block above finalization, their post-states, and the votes
cast over them — and the decisions that move its head.

The store is mutated in place while everything else in the crate is a pure
function. It is a long-lived aggregate with one writer, not a value passed
between them, and it holds a State per unfinalized block; copying it per
imported block would cost O(chain) per block and buy nothing. What the copy
guaranteed is kept as a contract instead: an entry point returning Err
leaves the store exactly as it found it, stated on each one and tested.

leanSpec verifies signatures inside three of these operations. This crate
has no cryptographic dependency by design, so each is split at exactly that
point and the caller verifies in between. The interval-2 aggregator duty is
absent for the same reason. Both seams close with verity-crypto.

Conformance replays all 123 fork-choice vectors, 692 steps, against all ten
fields of leanSpec's storeSnapshot rather than the shallower checks block —
a client whose weights are wrong but whose head happens to land right is
the failure that catches and headSlot does not. Eleven vectors are not
asserted on their vote pools, in two named lists with their reasons: eight
where the generator's proposer pool holds votes the block never carried,
and three that turn on the aggregator duty. The JSON shapes the harnesses
share move into tests/common.
@adust09
adust09 force-pushed the feat/fork-choice-20260826-1758 branch from eca29a9 to 0896e5a Compare August 27, 2026 03:01
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.

1 participant