feat: integrate fork-choice compliance spec test suite#9314
Conversation
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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,991 ≈ 9 * 10^15).
To maintain accuracy for production use cases (even if compliance tests currently use smaller weights), this should return bigint.
| 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; | |
| } |
| getViableHeads(): {root: RootHex; weight: number}[] { | ||
| return this.protoArray.getViableHeads(this.fcStore.currentSlot); | ||
| } |
There was a problem hiding this comment.
Update the return type to bigint to match the fix in ProtoArray and avoid precision loss.
| 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}[]; |
| 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}`); |
There was a problem hiding this comment.
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.
| 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}`); |
ae8be0c to
a31929d
Compare
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.
a31929d to
b1bf28a
Compare
|
superseded by #9670 |
**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>
Summary
Integrates the consensus-specs Fork Choice Compliance suite (ethereum/consensus-specs#3831) into the existing
forkChoiceTestrunner. It runs alongside the standardfork_choice/syncrunners undertest: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 ProtoArray —packages/fork-choiceThe 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'sget_filtered_block_tree(rooted atstore.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/findHeadwere 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
forkChoiceTest—packages/beacon-nodeRegister
fork_choice_complianceas a default runner inspecTestIterator, reusing the existing block / attestation / tick step handling. Two new check fields:viable_for_head_roots_and_weights(compared againstgetViableHeads(), sorted by root since the spec fixes no order) andhead_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 viavalidSignatures(standard fixtures arebls_setting: 1, unchanged). gloas envelope / payload-attestation sig steps get the same guard.ALREADY_KNOWNon duplicate blocks — fixtures intentionally re-import the same block; specon_block(known_block)is a no-op success. The runner treatsALREADY_KNOWNas success only when the step isvalid: true. The production import path is untouched.on_attestationmust decodeaggregation_bitswith 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.validate_on_attestationpreconditioncurrent_slot >= data.slot + 1before callingonAttestation(Lodestar enforces this in the gossip/pool layer, not inside fork choice).4.
scripts/download-compliance-fc-tests.shCompliance fixtures are not in the standard release tarball — they are produced by
make comptestsand published only as a transient GH Actions artifact by the dailycomptests.ymlworkflow.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 emitsfork_choice_compliance/subtrees. Supports--run-id/--tarball/ auto-fetch latest run, and handles both rawtar.gzand zip-wrapped artifact responses.Still failing
Run against the latest comptests
small.tar.gz(minimal preset, fulu + gloas, 1472 cases each), rebased on currentunstable:Standard
fork_choice+sync(all forks): 0 failures — additive integration, no regression.fulu compliance: 1469 / 1472 (3 fail)
block_tree_test_20_598691297_0/1, ~15 s hard limit) — heavy scenario, environmentalblock_tree_test_24_300285871_1) — attestation/head divergencegloas 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 roots—getViableHeads()weight/root attribution differs on gloas payload-status variants (the EMPTY vs FULL leaf of one block root). Same root-cause family asInvalid head.gloas ePBS is still moving upstream (~
alpha.11); closing these belongs in dedicated gloas fork-choice PRs, not here.Running it
Test plan
pnpm build+pnpm check-typescleanpnpm lintcleanfork_choice/syncrunners: 0 failures (no behavior change)AI Assistance Disclosure
Used Claude Code (Opus).