Replays historical Ethereum mainnet blocks through each CL client's Fast Confirmation Rule implementation and reports per-slot FCR output for cross-client comparison.
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.
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.
The orchestrator owns which block sources attestations for each sim slot:
next-non-missed(default): for sim slot N, the first non-missed block inN+1..N+lookahead-cap.--lookahead-cap=4reproduces today's Lighthouse behavior;--lookahead-cap=32covers the spec's full inclusion range.strict-source-block-k-minus-1: source is exactlyN+1if it exists, else nothing.greedy-lookahead: consumes every non-missed block inN+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 byraw_seen_ms <= --attestation-first-seen-deadline-ms(default12000) and sends committee-freeattesting_indicesto the engine for direct fork-choice injection. All 5 engines implement this injection path. Use--attestation-first-seen-baseas either a local base path ors3://bucket/prefix; files are expected belownetwork=<network>/source=raw/epoch=<epoch>/data.parquet.--lookahead-capis inert in this mode.
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 coldThe orchestrator auto-runs engines/<engine>/build.sh if the corresponding binary is missing.
./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-simulatorxatu-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 4Beacon 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.
scripts/cross-client/ contains:
pick-epochs.py— deterministic 10-epoch mainnet sample (seed20260514, range[435000, 445000)).manifest-check.sh— fast contract validation across everyresults/fcr-<engine>binary.run.sh <engine>— run that engine over the sample, write per-epoch CSVs underresults/cross-client/<engine>/._run-1hr.sh,_run-12hr.sh— parallel 5-engine runs over a fixed window, writeresults/<window>/<engine>/run.csv.diff.py— per-slot cross-engine diff, emitsper-slot.csv+divergences.csv+ a per-column disagreement summary.
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.
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.