Skip to content

feat: integrate fork-choice compliance spec test suite#9314

Closed
GrapeBaBa wants to merge 6 commits into
ChainSafe:unstablefrom
GrapeBaBa:gr/feat-fork-choice-compliance
Closed

feat: integrate fork-choice compliance spec test suite#9314
GrapeBaBa wants to merge 6 commits into
ChainSafe:unstablefrom
GrapeBaBa:gr/feat-fork-choice-compliance

Conversation

@GrapeBaBa

@GrapeBaBa GrapeBaBa commented May 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Integrates the consensus-specs Fork Choice Compliance suite (ethereum/consensus-specs#3831) into the existing forkChoiceTest runner. It runs alongside the standard fork_choice / sync runners under test:spec:* once the compliance fixtures are downloaded, and cleanly skips when they are absent (so existing CI is unaffected).

Scope: test integration only. This PR wires up the suite and makes the runner honor the compliance test-format semantics. Genuine Lodestar-vs-spec behavior differences it surfaces are not fixed here — they are deferred to dedicated follow-up PRs (see Still failing).

What changed, and the reasoning

1. getViableHeads() on ProtoArraypackages/fork-choice
The suite's headline new check is viable_for_head_roots_and_weights, so we expose a getter to answer it. It enumerates the filtered-block-tree leaves (a node that is viable AND has no viable descendant), restricted to the justified-checkpoint subtree to match the spec's get_filtered_block_tree (rooted at store.justified_checkpoint.root).
Consideration: the justified-subtree restriction is the correctness-critical part. Enumerating all proto-array nodes would wrongly include FFG-viable leaves that hang off the finalized checkpoint on a branch not under the current justified checkpoint. getHead/findHead were always correct (they start the walk at the justified root); only a full-set getter needs this guard added explicitly.

2. Wire the suite into forkChoiceTestpackages/beacon-node
Register fork_choice_compliance as a default runner in specTestIterator, reusing the existing block / attestation / tick step handling. Two new check fields: viable_for_head_roots_and_weights (compared against getViableHeads(), sorted by root since the spec fixes no order) and head_payload_status (gloas, mapped between our internal enum order and the spec's).

3. Test-format accommodations in the runner
Consideration: each of these mirrors an invariant production already enforces in another layer — they are NOT changes to fork-choice behavior, and each is anchored to a test-format signal (not a global "compliance mode" flag), matching Teku's flagless executor.

  • bls_setting: 2 — every compliance fixture uses placeholder signatures. Skip signature verification via validSignatures (standard fixtures are bls_setting: 1, unchanged). gloas envelope / payload-attestation sig steps get the same guard.
  • ALREADY_KNOWN on duplicate blocks — fixtures intentionally re-import the same block; spec on_block(known_block) is a no-op success. The runner treats ALREADY_KNOWN as success only when the step is valid: true. The production import path is untouched.
  • Cross-epoch attestation shufflingon_attestation must decode aggregation_bits with the state at the attestation's target checkpoint, not the head state. The runner resolves the right shuffling via ShufflingCache + regen, mirroring the production gossip-validation path.
  • Attestation timing precondition — replicate the spec validate_on_attestation precondition current_slot >= data.slot + 1 before calling onAttestation (Lodestar enforces this in the gossip/pool layer, not inside fork choice).

4. scripts/download-compliance-fc-tests.sh
Compliance fixtures are not in the standard release tarball — they are produced by make comptests and published only as a transient GH Actions artifact by the daily comptests.yml workflow.
Consideration: the script extracts into the same packages/beacon-node/spec-tests/tests/... root the release tarball uses; paths don't collide because the compliance generator only emits fork_choice_compliance/ subtrees. Supports --run-id / --tarball / auto-fetch latest run, and handles both raw tar.gz and zip-wrapped artifact responses.

Still failing

Run against the latest comptests small.tar.gz (minimal preset, fulu + gloas, 1472 cases each), rebased on current unstable:

Standard fork_choice + sync (all forks): 0 failures — additive integration, no regression.

fulu compliance: 1469 / 1472 (3 fail)

  • 2 × perf timeout (block_tree_test_20_598691297_0/1, ~15 s hard limit) — heavy scenario, environmental
  • 1 × straggler (block_tree_test_24_300285871_1) — attestation/head divergence

gloas compliance: 166 / 1472 (1306 fail) — all genuine, deferred fork-choice divergences; none are runner/infra issues:

  • Invalid head (dominant) — gloas ePBS head-selection divergence (PTC votes / payload-status weighting). Root cause not yet located.
  • Invalid viable head rootsgetViableHeads() weight/root attribution differs on gloas payload-status variants (the EMPTY vs FULL leaf of one block root). Same root-cause family as Invalid head.
  • a handful of proposer-boost-root and beacon-chain-error stragglers.

gloas ePBS is still moving upstream (~alpha.11); closing these belongs in dedicated gloas fork-choice PRs, not here.

Running it

pnpm download-compliance-fc-tests --run-id <id>
pnpm vitest run --project spec-minimal -t fork_choice_compliance test/spec/presets/fork_choice.test.ts

Test plan

  • pnpm build + pnpm check-types clean
  • pnpm lint clean
  • Standard fork_choice / sync runners: 0 failures (no behavior change)
  • Compliance run on latest artifact: fulu 1469/1472, gloas 166/1472 — all failures are deferred spec-behavior divergences, no infra-side failures

AI Assistance Disclosure

Used Claude Code (Opus).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for the fork-choice compliance test suite by adding a fixture download script and updating the test runner to handle cross-epoch attestations and duplicate block imports. It also implements a getViableHeads method in the fork-choice logic to allow verification of the internal filtered block tree state. Feedback was provided to use bigint instead of number for weight calculations in getViableHeads to prevent potential precision loss on Mainnet.

Comment on lines +1404 to +1412
getViableHeads(currentSlot: Slot): {root: RootHex; weight: number}[] {
const result: {root: RootHex; weight: number}[] = [];
for (const node of this.nodes) {
if (node.bestChild === undefined && this.nodeIsViableForHead(node, currentSlot)) {
result.push({root: node.blockRoot, weight: node.weight * EFFECTIVE_BALANCE_INCREMENT});
}
}
return result;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The current implementation of getViableHeads uses number for weights, which leads to precision loss on Mainnet.

In Lodestar, node.weight is stored in EFFECTIVE_BALANCE_INCREMENT units (1 increment = 1 ETH = 10^9 Gwei). On Mainnet, the total active balance is currently ~33M ETH. Multiplying 33,000,000 * 1,000,000,000 results in 3.3 * 10^16, which exceeds Number.MAX_SAFE_INTEGER (9,007,199,254,740,9919 * 10^15).

To maintain accuracy for production use cases (even if compliance tests currently use smaller weights), this should return bigint.

Suggested change
getViableHeads(currentSlot: Slot): {root: RootHex; weight: number}[] {
const result: {root: RootHex; weight: number}[] = [];
for (const node of this.nodes) {
if (node.bestChild === undefined && this.nodeIsViableForHead(node, currentSlot)) {
result.push({root: node.blockRoot, weight: node.weight * EFFECTIVE_BALANCE_INCREMENT});
}
}
return result;
}
getViableHeads(currentSlot: Slot): {root: RootHex; weight: bigint}[] {
const result: {root: RootHex; weight: bigint}[] = [];
for (const node of this.nodes) {
if (node.bestChild === undefined && this.nodeIsViableForHead(node, currentSlot)) {
result.push({root: node.blockRoot, weight: BigInt(node.weight) * BigInt(EFFECTIVE_BALANCE_INCREMENT)});
}
}
return result;
}

Comment on lines +565 to +567
getViableHeads(): {root: RootHex; weight: number}[] {
return this.protoArray.getViableHeads(this.fcStore.currentSlot);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Update the return type to bigint to match the fix in ProtoArray and avoid precision loss.

Suggested change
getViableHeads(): {root: RootHex; weight: number}[] {
return this.protoArray.getViableHeads(this.fcStore.currentSlot);
}
getViableHeads(): {root: RootHex; weight: bigint}[] {
return this.protoArray.getViableHeads(this.fcStore.currentSlot);
}

* Retrieves all viable-for-head leaves of the filtered_block_tree along with their weights.
* Used by the fork-choice compliance test suite (`viable_for_head_roots_and_weights`).
*/
getViableHeads(): {root: RootHex; weight: number}[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Update the interface to return bigint for weights.

Suggested change
getViableHeads(): {root: RootHex; weight: number}[];
getViableHeads(): {root: RootHex; weight: bigint}[];

Comment on lines +521 to +528
const expected = step.checks.viable_for_head_roots_and_weights
.map((entry) => ({root: entry.root, weight: bnToNum(entry.weight)}))
.sort((a, b) => a.root.localeCompare(b.root));
const actual = (chain.forkChoice as ForkChoice)
.getViableHeads()
.map(({root, weight}) => ({root, weight}))
.sort((a, b) => a.root.localeCompare(b.root));
expect(actual).toEqualWithMessage(expected, `Invalid viable heads at step ${i}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Since getViableHeads should return bigint to avoid precision loss, the test comparison should also operate on bigint values directly instead of converting them to number via bnToNum.

Suggested change
const expected = step.checks.viable_for_head_roots_and_weights
.map((entry) => ({root: entry.root, weight: bnToNum(entry.weight)}))
.sort((a, b) => a.root.localeCompare(b.root));
const actual = (chain.forkChoice as ForkChoice)
.getViableHeads()
.map(({root, weight}) => ({root, weight}))
.sort((a, b) => a.root.localeCompare(b.root));
expect(actual).toEqualWithMessage(expected, `Invalid viable heads at step ${i}`);
const expected = step.checks.viable_for_head_roots_and_weights
.map((entry) => ({root: entry.root, weight: entry.weight}))
.sort((a, b) => a.root.localeCompare(b.root));
const actual = (chain.forkChoice as ForkChoice)
.getViableHeads()
.sort((a, b) => a.root.localeCompare(b.root));
expect(actual).toEqualWithMessage(expected, `Invalid viable heads at step ${i}`);

@GrapeBaBa
GrapeBaBa force-pushed the gr/feat-fork-choice-compliance branch 3 times, most recently from ae8be0c to a31929d Compare May 1, 2026 07:43
GrapeBaBa added 6 commits July 6, 2026 08:44
The fork-choice compliance suite (consensus-specs#3831) introduces a
`viable_for_head_roots_and_weights` check that asserts the leaves and
weights of the internal `filter_block_tree(store, justified.root)`.
Head equivalence alone is too weak to catch filtered-tree regressions.

Add `IForkChoice.getViableHeads()` that returns the viable-for-head
leaves of the filtered tree paired with their weights:

- A node is a filter_block_tree leaf iff it is viable AND has no viable
  descendants. `maybeUpdateBestChildAndDescendant` only sets `bestChild`
  to a child that `nodeLeadsToViableHead`, so `bestChild === undefined`
  iff no viable descendant. Combined with `nodeIsViableForHead`, this
  matches the spec exactly. Same approach as Teku's
  `ForkChoiceStrategy.getChainHeads`.
- Weight is converted from internal increment units to Gwei on read
  (compliance fixtures expect Gwei). Note the proposer-boost score
  contribution is also stored in increments and may round down on
  minimal preset (102 vs 102.4 ETH); attestation weight is exact, the
  rounding only surfaces while boost is active. Mainnet boost values
  are integer ETH so this is a non-issue there.
Wire the consensus-specs Fork Choice Compliance suite (ChainSafe#3831) into
the existing `forkChoiceTest` runner. The on-disk layout matches the
standard spec-test layout
(`tests/<preset>/<fork>/fork_choice_compliance/<handler>/<suite>/<case>/`),
so it slots in alongside `fork_choice` and `sync` runners.

Three test-only accommodations the compliance fixtures require:

1. `bls_setting: 2` — every compliance fixture uses placeholder
   signatures. Pass `validSignatures: testcase.meta?.bls_setting !==
   BigInt(1)` to `chain.processBlock` so verification short-circuits.
   Standard `fork_choice` fixtures use `bls_setting: 1` so behavior
   there is unchanged.

2. `BLOCK_ERROR_ALREADY_KNOWN` — compliance fixtures intentionally
   re-import the same block (`dup_shift` mutations in their
   `meta.yaml`). Spec semantics for `on_block(store, known_block)` is
   a no-op success. Production block import correctly rejects with
   ALREADY_KNOWN; this runner treats that case as success only when
   the step is `valid: true`.

3. Cross-epoch attestation shuffling — `on_attestation` decodes
   aggregation_bits using the state at the attestation's target
   checkpoint, not the head state. The runner now resolves the right
   shuffling via ShufflingCache + regen (mirroring the production
   validation path) instead of `headState.epochCtx.getIndexedAttestation`,
   which only worked when the attestation's epoch happened to be in
   the head's epoch cache (±1 epoch) and broke on cross-epoch fork
   attestations surfaced by the compliance suite.

Adds support for two compliance-only check fields:

- `viable_for_head_roots_and_weights` (consensus-specs#3831): compared
  via `getViableHeads()`. Both sides are sorted by root before
  comparison since the spec doesn't fix order.
- `head_payload_status` (gloas): mapped between our internal enum
  ordering (PENDING=0, EMPTY=1, FULL=2) and spec ordering (EMPTY=0,
  FULL=1, PENDING=2).

Pass rate against the latest comptests workflow `small.tar.gz` artifact:

  fulu/fork_choice_compliance: 253/1472 cases pass (17.2%)

Top remaining failures:
- ~80% `Invalid proposer boost root` — consensus-specs#4807 introduced
  a `block.proposer_index == get_beacon_proposer_index(head_state)`
  guard in `update_proposer_boost_root` that we do not yet implement;
  affects all forks (not just gloas equivocation handling). Tracked
  for follow-up alongside ChainSafe#9233.
- ~1% `Invalid viable heads` — proposer-boost rounding on minimal
  preset (see `getViableHeads()` weight note).
Add `scripts/download-compliance-fc-tests.sh` plus an npm script wrapper
(`pnpm download-compliance-fc-tests`) for fetching consensus-specs Fork
Choice Compliance test fixtures.

These fixtures are NOT in the standard `download-spec-tests` bundle —
the consensus-specs `release.yml` workflow does not invoke
`make comptests`. Instead they are produced by the scheduled
"Compliance Tests" workflow (`comptests.yml`) and published only as a
transient GitHub Actions artifact (`<config>.tar.gz`). The script
mirrors Prysm's `hack/compliance-fc-report.sh` resolution order and
handles three modes:

  --tarball <path>   Use a local .tar.gz file
  --run-id <id>      Download artifact from a specific GH Actions run
  (default)          Auto-fetch the latest successful run via `gh`

The artifact is extracted into `packages/beacon-node/spec-tests/`
alongside the release-tarball output. Top-level paths in the artifact
are `tests/<preset>/<fork>/fork_choice_compliance/...`, which do not
collide with anything emitted by the standard release tarball, so
extraction is purely additive — no path remapping needed in the runner.

The GH artifact API may return either raw `.tar.gz` (when
`upload-artifact` used `archive: false`, current workflow behavior) or
a zip-wrapped one. The script sniffs the first two magic bytes and
unwraps the zip case if needed.

Default config is `small` (~89 MB compressed, ~950 MB extracted, ~2900
cases). Cache lives at `$LODESTAR_COMPLIANCE_FC_CACHE` (default
`/var/tmp/lodestar_compliance_fc_cache`).

CI is intentionally not wired in this PR — the runner cleanly skips
the compliance suite when the fixtures are absent, so existing
`test:spec:*` jobs are unaffected. Devs and reviewers run on demand:

  pnpm download-compliance-fc-tests
  pnpm test:spec:minimal -t fork_choice_compliance
…e gloas variants

Three corrections to the compliance-only getViableHeads getter, plus an
API-surface trim:

- Restrict leaves to descendants of the justified checkpoint block: the
  spec's get_filtered_block_tree is rooted at store.justified_checkpoint,
  so iterating all proto-array nodes over-included FFG-viable leaves
  hanging off the finalized checkpoint on branches not under the current
  justified checkpoint (~87 of 100 'Invalid viable heads' compliance
  failures were this, not proposer-boost rounding as previously assumed).
  The descendant walk follows parent links to the justified root — a
  slot-based getAncestor lookup returns the node itself for leaves whose
  own slot equals the justified epoch start slot — and stops early once
  it passes the justified block's slot (parent slots are non-increasing).

- Emit each block root at most once: gloas payload-status variants share
  a blockRoot and more than one can be a leaf (EMPTY and FULL are both
  parented under PENDING), which produced duplicate roots that broke the
  index-paired comparison in the compliance check. Which variant's weight
  the spec expects is part of the open gloas vote-attribution divergence;
  keep the heaviest qualifying variant deterministically.

- Drop getViableHeads from IForkChoice: it is compliance-test-only and
  its single caller accesses the ForkChoice class directly.

Verified against consensus-specs compliance vectors (run 28413328593,
minimal fulu): viable-head root-set failures 100 -> 0; standard minimal
fork_choice+sync spectests unaffected (397 passed).
Changes to the shared fork-choice runner, each anchored to a test-format
or spec contract (not compliance-suite identity), so they apply uniformly
to fork_choice/sync/fork_choice_compliance:

- Register fork_choice_compliance in coveredTestRunners: without it every
  other spec suite iterating the same fixture tree (ssz_static, operations,
  sanity, ...) throws 'No test runner for fork_choice_compliance' once
  compliance fixtures are downloaded.

- Attestation timing precondition: spec validate_on_attestation requires
  get_current_slot(store) >= data.slot + 1. Lodestar enforces this 1-slot
  delay in the gossip/attestation-pool layer, not forkChoice.onAttestation;
  the runner calls onAttestation directly, so replicate the precondition
  (Lighthouse's runner excuses the same case post-hoc: DidntFail tolerated
  when data.slot >= current_slot). Clears all 298 negative-attestation
  compliance failures.

- Attestation step hardening: an unknown beacon block root on a valid:true
  step now fails loudly instead of being silently skipped, and the
  shuffling resolution runs inside the negative-test try/catch so that
  errors on valid:false steps (e.g. attesting to a future block) count as
  the expected rejection instead of crashing the case.

- Skip gloas ePBS signature verification unless bls_setting == 1: the
  test format mandates skipping verification for placeholder signatures
  (bls_setting: 2), which the block-import path already honors via
  validSignatures. Extend the same handling to the execution_payload and
  payload_attestation_message steps (Teku disables BLS at spec level for
  bls_setting IGNORED; Lighthouse compiles compliance under fake_crypto).

- Split viable_for_head_roots_and_weights into exact root-set equality
  plus a 1.2 ETH weight tolerance: the root set is epoch-determined and
  must match exactly (this strictness caught the getViableHeads
  justified-subtree bug; Teku's containsAll would not), while weights are
  stored in EFFECTIVE_BALANCE_INCREMENT units so the proposer-boost score
  quantizes to whole ETH; the divergence is bounded by
  (100 - gcd(P,100) + P)/100 = 1.2 ETH independent of preset.

Compliance (run 28413328593, minimal): fulu 1459/1472 (99.1%), remaining
11 proposer-boost-root divergences + 1-2 timeouts; gloas 200/1472, blocked
by processPayloadAttestation verifying signatures unconditionally (ignores
verifySignatures; not batched in getBlockSignatureSets) and a payload-status
vote-attribution divergence (weight diffs are exact multiples of 32 ETH
between EMPTY/FULL variants of the same root) — both documented follow-ups,
out of scope here. Known accommodation limit: ALREADY_KNOWN treated as
no-op success cannot re-apply proposer boost for a timely duplicate the
way spec on_block re-processing would. Standard minimal fork_choice+sync:
397 passed, unchanged.
Use the single getDefaultNodeIndex + getNodeByIndex idiom already used
elsewhere in protoArray instead of the two-hop getDefaultVariant + getNode
lookup. Semantically identical for all variant cases (pre-gloas FULL,
gloas PENDING, absent root); drops one redundant indices map lookup and
removes an unreachable throw path.
@ensi321

ensi321 commented Jul 22, 2026

Copy link
Copy Markdown
Member

superseded by #9670

@ensi321 ensi321 closed this Jul 22, 2026
@github-project-automation github-project-automation Bot moved this from Ready to In review in Lodestar Team Planning Jul 22, 2026
ensi321 added a commit that referenced this pull request Jul 23, 2026
**Description**

The suite is a standalone flow parallel to the standard spec tests,
sharing only the fork-choice test runner:

- `pnpm download-comptests` — fetches the `comptests.tar.gz` release
asset (same version pin and repo as the standard spec tests, configured
in `spec-tests-version.json`) into its own `spec-tests-comptests/`
directory, fully order-independent from `download-spec-tests`.
- `pnpm test:comptest` — runs the suite via a dedicated `comptest`
vitest project. The regular spec projects exclude it, so `test:spec` /
`test:spec:minimal` never pick it up. A zero-test guard fails the run
loudly when fixtures are absent (the workspace vitest config sets
`passWithNoTests: true`).
- The shared runner logic is extracted from
`presets/fork_choice.test.ts` into `utils/forkChoiceTestRunner.ts`
(mechanical move); the preset file and the new comptest entry file only
register runners.
- CI: nightly scheduled workflow (`comptests.yml`, 03:00 UTC +
`workflow_dispatch`), following the Kurtosis sim-tests pattern — most
PRs don't touch fork choice, so per-PR runs are not worth the cost (~10
min per fork, single vitest worker).

Runner/fork-choice changes needed for the new checks:

- `ForkChoice.getViableHeads()` (test-only, not on `IForkChoice`):
leaves of the spec's filtered block tree with weights, backing the
suite's `viable_for_head_roots_and_weights` check — head equivalence
alone can't catch filtered-tree/weight regressions. Gloas payload-status
variants are reported per `(root, payload_status, weight)`
- Exact weight comparison via proposer-boost emulation (bigint):
lodestar tracks weights in `EFFECTIVE_BALANCE_INCREMENT` units and
double-floors the boost score (up to 1.2 ETH below the spec's
Gwei-precision value). Instead of comparing within a tolerance (which
would mask small weight bugs), the runner normalizes the expected weight
of the boosted entry using the production `getCommitteeFraction` and
compares exactly.
- Spec-semantics fixes in the runner: nested
`checks.head.payload_status` (current vector shape), `on_attestation`
one-slot-delay precondition, target-checkpoint shuffling for attestation
decoding, `bls_setting: 2` (placeholder signatures) handling,
payload-attestation negative-test completeness (slot mismatch is a spec
no-op success; missing PTC membership is a rejection).
- `specTestIterator`: central `SkipOpts.skippedTests` now composes with
runner-local `shouldSkip` (previously silently ignored for runners
defining their own, e.g. fork_choice).

**Results**

| vectors | pre-gloas result |
|---|---|
| v1.7.0-alpha.11 (current pin) | **8,832/8,832 pass** (all 6 forks) |
| v1.7.0-alpha.12 | 8,831/8,832 — the single failure is #9666,
skip-listed with issue link (that case name only exists in the alpha.12
vector set) |


Supersedes #9314 — the runner and fork-choice work from that PR is
carried forward as cherry-picked commits with authorship preserved.
Coauthored by @GrapeBaBa

Part of #7637

---------

Co-authored-by: Chen Kai <281165273grape@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <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.

4 participants