Skip to content

ethpandaops/fcr-simulator

Repository files navigation

FCR Simulator

Replays historical Ethereum mainnet blocks through each CL client's Fast Confirmation Rule implementation and reports per-slot FCR output for cross-client comparison.

Engines

All engines live under engines/<name>/. Each has a build.sh that produces results/fcr-<name>.

Engine Upstream pin
lighthouse submodule engines/lighthouse/lighthouse (samcm/lighthouse fcr-simulator)
teku submodule engines/teku/teku (Nashatyrev/teku confirmation-2)
nimbus submodule engines/nimbus/nimbus-eth2 (samcm/nimbus-eth2 fcr-frozen-anchor-fix)
lodestar submodule engines/lodestar/lodestar (nazarhussain/lodestar)
grandine upstream grandinetech/grandine PR #656 via engines/grandine/grandine-engine.patch

Prysm is deferred: PR #15164 implements an older spec (adiasg/eth2.0-specs:3e3ef28), not consensus-specs#4747. Shipping a binary against the older algorithm would contaminate cross-engine comparison. Revisit once the upstream PR rebases.

Architecture

A Go orchestrator owns everything engine-agnostic — ERA file download, checkpoint state fetch, attestation source plan, output schema, JSONL → CSV merge. Engines are subprocesses that speak HTTP to a localhost beacon-API the orchestrator serves.

┌─────────────────────────────────────────────────────────┐
│  fcr-orchestrator (Go)                                  │
└────────────────────────────────┬────────────────────────┘
                                 │ HTTP (SSZ blocks/states + /fcr-sim/v1/plan)
       ┌──────────┬──────────┬───┴───────┬──────────┐
   fcr-lighthouse fcr-teku  fcr-nimbus  fcr-lodestar fcr-grandine
       (Rust)    (Java)     (Nim)       (TypeScript) (Rust)

Per sim slot N the engine: fetches block N, imports it, looks up plan[N].source_block_slot, extracts that block's attestations, injects them using its native equivalent of Lighthouse's AttestationFromBlock::True, runs recompute_head_at_slot(N+1), emits one JSONL record. The orchestrator validates each record, enriches with engine metadata, writes the final CSV / JSONL / manifest.

BLS, execution-layer, and blob/DA checks are bypassed in each engine — historical canonical-chain replay doesn't need them. The orchestrator hard-rejects any engine binary that doesn't declare fake_crypto in its --manifest-json build_flags.

The engine contract is enforced in code: CLI flags + manifest validation in cmd/fcr-orchestrator/main.go, HTTP endpoints in pkg/beaconapi/, output schema in pkg/schema/. Attestation-source planner spec next to its code at pkg/attplan/SPEC.md.

Attestation source modes

The orchestrator owns which block sources attestations for each sim slot:

  • next-non-missed (default): for sim slot N, the first non-missed block in N+1..N+lookahead-cap. --lookahead-cap=4 reproduces today's Lighthouse behavior; --lookahead-cap=32 covers the spec's full inclusion range.
  • strict-source-block-k-minus-1: source is exactly N+1 if it exists, else nothing.
  • greedy-lookahead: consumes every non-missed block in N+1..N+lookahead-cap, bounded to attestations for the FCR evaluation slot.
  • xatu-first-seen-singles: serves per-validator first-seen gossip votes from parquet instead of sourcing attestations from blocks. The orchestrator filters by raw_seen_ms <= --attestation-first-seen-deadline-ms (default 12000) and sends committee-free attesting_indices to the engine for direct fork-choice injection. All 5 engines implement this injection path. Use --attestation-first-seen-base as either a local base path or s3://bucket/prefix; files are expected below network=<network>/source=raw/epoch=<epoch>/data.parquet. --lookahead-cap is inert in this mode.

Build

git clone --recursive https://github.com/ethpandaops/fcr-simulator.git
cd fcr-simulator

# Orchestrator (seconds)
go build -o ./results/fcr-orchestrator ./cmd/fcr-orchestrator

# Engines (each writes results/fcr-<name>)
bash engines/lighthouse/build.sh   # Rust,  ~10-20 min cold
bash engines/teku/build.sh         # Java,  ~5 min cold (needs JDK 21)
bash engines/nimbus/build.sh       # Nim,   ~5-10 min cold
bash engines/lodestar/build.sh     # Node,  ~3-5 min cold (needs Node 24+, pnpm)
bash engines/grandine/build.sh     # Rust,  ~10-15 min cold

The orchestrator auto-runs engines/<engine>/build.sh if the corresponding binary is missing.

Run

./results/fcr-orchestrator \
  --engine lighthouse \
  --network mainnet \
  --beacon-node-url http://your-beacon-node:5052 \
  --start-epoch 435000 --end-epoch 435100 \
  --warmup-epochs 10 --parallel 2 \
  --output results/results.csv --output-format both \
  --attestation-source-mode next-non-missed --lookahead-cap 4 \
  --cache-dir ~/.cache/fcr-simulator

xatu-first-seen-singles fixture run:

./results/fcr-orchestrator \
  --engine lighthouse \
  --network mainnet \
  --beacon-node-url "$BEACON_NODE_URL" \
  --start-epoch 349000 --end-epoch 349015 \
  --warmup-epochs 0 --parallel 1 \
  --attestation-source-mode xatu-first-seen-singles \
  --attestation-first-seen-base deploy/attestation-backfill/fixtures \
  --attestation-first-seen-deadline-ms 12000 \
  --output deploy/firstseen-test/results.csv --output-format both \
  --cache-dir deploy/firstseen-test/cache

--engine-binary defaults to ./results/fcr-<engine>; override via --engine-binary flag or FCR_ENGINE_BINARY env if you want a different path.

For long resumable runs, run.sh chunks the range and merges on completion:

./run.sh --start-epoch 435000 --end-epoch 440000 --chunk-size 1000 \
         --beacon-node-url http://your-beacon-node:5052 --parallel 4

Beacon node: must serve /eth/v2/debug/beacon/states/{slot} and /eth/v1/beacon/headers/{slot} for the warmup slots. Lighthouse needs --reconstruct-historic-states; Teku needs --data-storage-mode=ARCHIVE. Recent ranges (within ~256 epochs of head) often work without archive mode.

Per-worker RAM: ~2–3 GB for the Rust engines; Lodestar settles around 4 GB after the bounded-cache fix. --parallel should be at most num_cpus / 4.

Cross-client comparison

scripts/cross-client/ contains:

  • pick-epochs.py — deterministic 10-epoch mainnet sample (seed 20260514, range [435000, 445000)).
  • manifest-check.sh — fast contract validation across every results/fcr-<engine> binary.
  • run.sh <engine> — run that engine over the sample, write per-epoch CSVs under results/cross-client/<engine>/.
  • _run-1hr.sh, _run-12hr.sh — parallel 5-engine runs over a fixed window, write results/<window>/<engine>/run.csv.
  • diff.py — per-slot cross-engine diff, emits per-slot.csv + divergences.csv + a per-column disagreement summary.

Output

CSV starts with # fcr-simulator-csv-schema-version:4 followed by the header row. JSONL is one record per line. Schema is the SlotResult struct in pkg/schema/schema.go. Key columns: slot, epoch, has_block, block_root, head_root, confirmed_root, confirmed_slot, confirmed_non_canonical, confirmation_delay_slots, fast_confirmed, strict_one_slot_confirmed, source_block_slot, num_attestations_injected, engine_name, engine_version, engine_commit.

A sidecar <output>.manifest.json captures engine manifest, run config, ERA file hashes, and output hashes for reproducibility.

Datasets

Cross-client FCR replays are published as GitHub releases. Latest: v0.2.0 (12 months lighthouse + 4-5 months each of lodestar, grandine, nimbus, teku; schema v4). The release page has the by-month scoreboard and METADATA.json has per-CSV image SHAs and the full 24-column schema.

An older lighthouse-only run is mirrored at data.ethpandaops.io/fcr-simulator/lighthouse/mainnet/epochs-<start>-<end>.csv as five 640k-slot chunks covering epochs 349000 to 449000. Note this uses greedy-lookahead (block-sourced attestations with lookahead_cap=8), not xatu-first-seen-singles, so it's not directly comparable to v0.2.0.

References