An opinionated block-building simulation substrate for Commit-Boost. It stands up a real Ethereum devnet (helix relay + reth-rbuilder builder + the CB sidecar) via Kurtosis, exercises one PBS feature end to end, and returns a trustworthy verdict. The point is not "the devnet booted"; it is a verdict you can gate a release on. Nothing here is a mock.
Rust workspace (one lib, three bins):
| Target | Path | Role |
|---|---|---|
cb_testnet_verifier (lib) |
src/lib.rs |
shared: beacon, relay, metrics, discovery, checks/*, report |
cb-verify (bin, default-run) |
src/main.rs |
the verifier: discover -> observe -> run checks -> report -> exit code |
sim (bin) |
src/bin/sim/main.rs |
generate | preflight | triage | checks | doctor | diff |
cb-orchestrator (bin) |
src/orchestrator.rs |
parallel multi-enclave runner (just test-all) |
- docs/DESIGN.md - WHY the repo exists + the 7 design laws (each prevents a named smell). Read when a change feels like it is fighting the grain. Law 1 real-schema configs, Law 3 every scenario asserts its feature FIRED, Law 4 verdict logic is TDD-able without a devnet, Law 5 observability is a system property (never build an agent-only tool surface), Law 7 coverage is a matrix (EL/CL pairs).
- docs/ARCH.md - HOW it fits: end-to-end flow, module map, the config <-> fork seam, the verdict model.
- docs/CHECKS.md - the authoritative per-check contract (tier, source, pass/warn/fail/skip conditions).
- docs/DEVELOPING.md - the dev loop + how to add a check / a scenario.
- docs/fork-delta.md - what our
ethereum-packagefork changes vs upstream, file by file. - docs/local-kurtosis-e2e.md - the operational runbook + the paid-for incidents behind half the design.
- README.md - user-facing quick start (user-facing quick start).
Internal back-room (agent working material, not part of the public docs surface):
.agent/is a local, gitignored working area — present when you develop against this repo, NOT in a fresh public clone. Agents write working notes here; nothing in it ships.
- .agent/SWEEP-BACKLOG.md - the live queue AND the findings log (every live-devnet result gets recorded there). Highest-density source of hard-won facts; read the tail before starting anything.
- .agent/plans/INDEX.md - status of every plan (
livesteers,landedis history). - .agent/NORTH-STAR.md - the internal staged plan (P0-P5), ratified directions, and open scars behind DESIGN.
- Rust 1.91+, edition 2024. No Node, no Python.
cargo testis pure, hermetic, seconds (no docker). - Kurtosis CLI pinned to 1.18.1. The parsers read its human text tables (1.18.1 has no
--format jsonfor them) and a newer CLI writes aconfig-version: 9~/.config/kurtosis/kurtosis-config.ymlthat 1.18.1 cannot read.sim doctorchecks this for you. - No Kurtosis Rust SDK exists. All enclave ops are
std::process::Command+ text parsing (discovery.rs,triage.rs). Inherent, not a rewrite candidate. simis deliberately sync / no tokio (it shells out and blocks);cb-verifyandcb-orchestratorare tokio (concurrent HTTP polling / parallel enclaves).- The CB sidecar image is BUILT, not pulled (
just build-cb-image->commit-boost/commit-boost:kurtosisfrom the bundled./commit-boost-clientsubmodule). helix / reth-rbuilder / lighthouse are public pulls.
sim is a workspace bin, not a PATH command: invoke it as cargo run --quiet --bin sim -- <verb> or, after
cargo build --release, as ./target/release/sim <verb>. Written sim <verb> below for brevity.
sim doctor # host preflight: kurtosis 1.18.1, docker, memory, CB image, submodule
git submodule update --init # 3 submodules: ethereum-package (fork), commit-boost-client, helix
just build-cb-image # once: builds commit-boost/commit-boost:kurtosis from ./commit-boost-client
just e2e # cb-basic, end to end
just e2e configs/generated/cb-mux.yml # any scenariojust e2e = generate-configs + pull-images + just testnet <config>, which calls the launcher:
./scripts/run-and-verify.sh --config configs/generated/cb-basic.yml \
--json --live-metrics --min-epochs 1 --target-epoch 1 --keep --skip-finalization -vLauncher flags (all real, scripts/run-and-verify.sh): --config --enclave --package --keep --json --json-dir DIR --strict --live-metrics --skip-finalization --timeout --min-epochs --target-epoch -v.
It (0) pre-builds cb-verify OFF the critical path, (1) removes the stale enclave, (1b) runs
sim preflight as a gate, (1c) warns on low host memory (LOW_MEM_ABORT=1 to abort instead),
(2) kurtosis run (auto-fires sim triage on launch failure), (3) runs the pre-built cb-verify.
With --json and no --json-dir it saves the report to <repo>/<enclave>.json.
cb-verify direct (src/main.rs): --enclave --config --min-epochs --target-epoch --timeout --mev-threshold --json --verbose/-v --strict --live-metrics --show-logs --skip-finalization-check --output-dir. Note the launcher's flag is --skip-finalization, the binary's is --skip-finalization-check.
Against an already-running enclave (no observation window): just verify-now <enclave>,
just verify-with-config <config> <enclave>, just test-mux <enclave> <config>, just show-logs <enclave>.
configs/generated/*.yml are gitignored build products of the typed generator; the tracked truth is
tests/fixtures/golden-configs/. The named scenarios are Scenario::ALL
(src/bin/sim/genmodel/scenario.rs) — the frozen, byte-goldened regression set: cb-basic,
cb-basic-nethermind-prysm (Law 7 alt EL/CL pair), cb-multiple-relays (divergent per-relay subsidies),
cb-min-bid, cb-signer, cb-skip-sigverify, cb-sigverify-diff + cb-sigverify-diff-control (a real
ON/OFF differential), cb-timing-games, cb-extra-validation, cb-config-surface (several [pbs]
config knobs at once — registration batch/timeout/retry, relay-check), cb-ws-stream +
cb-ws-stream-nokey (the ws stream + its negative control), cb-mux (256 validators split across two
relays). The MEV-delivery gate is just sweep-gate (the green subset; --target-epoch 1 --skip-finalization for a fast window — a --min-epochs 0 window collapses to one slot and measures
delivery over a single slot, which passes/fails by luck).
Composable scenarios (src/bin/sim/genmodel/spec.rs, docs/composable-scenarios.md):
ScenarioSpec is a flat closed-enum surface that composes features freely and renders through the SAME seams
as the named scenarios (proven byte-identical for every named one via Scenario::to_spec() +
lower_reproduces_every_scenario). Use it for any combination that is not a frozen named scenario — e.g. the
ws stream on a specific client, or a client the named set does not cover. The clients axis covers all 5
mainstream CLs (geth-lighthouse, nethermind-prysm, geth-teku, geth-nimbus, geth-lodestar).
spec::curated() freezes a few high-value composed specs as goldens (tests/fixtures/curated-configs/).
sim generate # the named scenarios -> configs/generated/ (= just generate-configs)
sim generate --curated # + the curated composable coverage configs
sim generate cb-mux --out-dir /tmp/x
sim scenario --base cb-basic --set clients=geth-teku,get_header=stream # compose (stdout)
sim scenario --spec spec.json # full ScenarioSpec as JSON (the AI-drivable surface)
sim generate --check # drift gate: nonzero if on-disk configs != what the generator emits
sim preflight configs/generated/cb-mux.yml # ~1s: parse the rendered config with the REAL helix image
sim checks --list [--json] # the check contract, machine-readable
sim diff a.json b.json [--json] # verdict/provenance regression gate between two reports
sim triage <enclave> # each dead service's ROOT panic, as JSON
sim --log-format json <cmd> # structured event stream for agents (default: pretty).env (gitignored, see .env.example) overrides the embedded docker images and is read only at the
sim generate CLI boundary, so assembly stays pure.
A run emits a VerificationReport (src/report.rs) plus an exit code. Each CheckResult has id,
tier (1/2/3), result (PASS/FAIL/WARN/SKIP), detail, data.
The exit code keys ONLY on a tier-1 FAIL (report::exit_code): 2 = no tier-1 check ran at all
(setup/discovery failure), 1 = some tier-1 check FAILed, 0 otherwise. WARN and SKIP are
non-fatal at every tier and never move the exit code or the overall result.
The consequence a consumer MUST internalize: several checks that exist precisely to catch an anomaly
report it as WARN (relay equivocation in payload_hash_match, unverifiable routing in mux.routing,
a best-bid shortfall in relay.best_bid). A run that hits them still exits 0. Parse the JSON and
inspect each check's result; never gate on the exit code alone. --strict promotes selected soft
warnings to FAIL.
Escalation: cb_metrics checks whose id ends in _matrix, plus cb_relay_v2_unsupported, are
authored at tier 2 but promoted to tier 1 when they FAIL (src/checks/cb_metrics.rs), because a real
relay 5xx or a lost blinded-block submission is a genuine pipeline failure. So a matrix FAIL does gate
the exit code despite its nominal tier.
Per-check contract, data sources, and death-mode behavior: docs/CHECKS.md. sim preflight has its own
narrower 3-valued verdict (Pass / Fail{field} / Inconclusive) and only its Fail aborts a launch.
The method that has repeatedly paid off here: get the RAW evidence before believing a check's summary.
Every misdiagnosis in this repo's history was a check's detail string read as fact. data is closer to
truth than detail; raw logs and raw counters are closer still.
sim doctor # is it me or the box?
sim preflight <args-file> # ~1s real-image config parse, BEFORE a ~10min spend
sim triage <enclave> # broken enclave -> per-service root panic (skips masking lines,
# falls back to `docker logs` when kurtosis's broker masks it)
kurtosis enclave ls # ALWAYS check for a live run before touching anything
kurtosis enclave inspect <enclave> # services, statuses, mapped ports
kurtosis service logs <enclave> <service> -n 200000 | sed 's/\x1b\[[0-9;]*m//g'
kurtosis port print <enclave> <service> <port-name>Run with --keep (the just testnet default) so the enclave survives for inspection; tear down explicitly
with kurtosis enclave rm -f <enclave>.
- Logs need ANSI stripping. CB logs are colored; the parsers use
strip_ansiinternally, you need thesedabove by hand. Services arecommit-boost-001,helix-relay-N,el-{i}-{el}-{cl},cl-{i}-{cl}-{el}(the EL/CL pair is in the name; see Law 7). - Scrape CB's Prometheus counters directly for the RAW per-code view. The report BUCKETS status codes
(2xx/4xx/5xx/timeout/transport), which hides which exact code fired - that bucketing is what produced a
"47.5% relay 5xx" panic that was entirely CB's synthetic 555. Get the truth:
Other counters worth reading raw:
kurtosis service exec <enclave> commit-boost-001 "curl -s http://localhost:9090/metrics" \ | grep -E 'cb_pbs_relay_status_code_total|cb_pbs_beacon_node_status_code_total'
pbs_submit_block_v2_unsupported_total,cb_pbs_submit_block_v2_fallback_to_v1_total,cb_pbs_relay_latency. - Read the report JSON, not the terminal render:
<enclave>.jsonat the repo root after a--jsonrun (e.g.CB-Testnet.json). Thensim diff old.json new.jsonfor the delta. - Cross-check the two sides of a matrix.
relay_sidevsbeacon_sidein acb_*_matrixcheck'sdatais what distinguishes "the relay rejected us" from "the CL never asked": relay 4xx + beacon 5xx means CB forwarded and the relay refused, then returned 502 to the CL. - Middle ground before a full run:
just verify-now,just test-mux,sim preflight. A ~10-minute e2e is the final confirmation, never the debugger.
The signer runs as uid 10001 and the devnet's secrets/ dir is mode 600 root-owned. Kurtosis does
NOT chown files-artifacts on mount, so a non-root container cannot even traverse it, and CB's keystore
loaders skip unreadable entries with warn! rather than failing - producing a healthy signer holding
ZERO keys. Use the teku-keys + teku-secrets pair (755/777), which is also what the package's own
web3signer launcher relies on. Verified live; six package launchers force User(uid=0) for the same
reason.
Never let a grep's SILENCE mean success. cargo test ... | grep "test result:" prints nothing
when the build fails, which reads identically to "no output, fine". A broken test suite was committed
this way once. Gate on the EXIT CODE, not on matched lines:
set -o pipefail
cargo test --all-targets 2>&1 | grep -E "^test result:"; echo "TEST_EXIT=$?"
cargo clippy --all-targets -- -D warnings >/dev/null 2>&1; echo "CLIPPY_EXIT=$?"
cargo fmt --check >/dev/null 2>&1; echo "FMT_EXIT=$?"This is the same defect class the harness keeps finding in itself: a check that cannot distinguish "no signal" from "bad signal". Apply it to devnet runs too - an empty log tail is not a passing run.
- CB's synthetic status codes 555 and 556 are NOT relay-served. 555 =
TIMEOUT_ERROR_CODE, CB's own client-side deadline cancellation; 556 = WS transport error. They bucket separately (timeout,transport) and are excluded from the 5xx denominator. Counting them as relay 5xx tier-1-FAILed a fully green timing-games run. High rates WARN, never FAIL, not even under--strict. - helix
router_config.enabled_routesgates routes, and a missing route looks like a client bug.GetPayloadV2was absent from our generated helix config, so helix 404'd/eth/v2/builder/blinded_blocks, CB (correctly) refused to downgrade to v1, and prysm's entire MEV path died. Hours were spent suspecting nethermind/prysm; it was OUR config.cb_relay_v2_unsupportednow names it in one line. - A check's
detailstring can be flat wrong.cb_submit_blinded_block_matrixreported "proposer never chose a builder block" while the relay had rejected 26 blinded blocks - the opposite of reality. Fixed, but the lesson generalizes: trustdata, verify against raw logs and counters. A diagnosis naming the wrong component is worse than no diagnosis. - Kurtosis pinned at 1.18.1; a newer CLI's
config-version: 9config file breaks it (delete the file on downgrade, thenkurtosis analytics disable). - Pass
kurtosis runan ABSOLUTE package path from a script.run-and-verify.shresolves$REPO_DIR/ethereum-package; a relative./ethereum-packageresolves against the caller's cwd and fails with a confusing "no kurtosis.yml" error. ethereum-package/is a FORK (Commit-Boost/ethereum-package, branchcb-testing), pinned as a detached-HEAD submodule and load-bearing (empty without--init). Changes there must be pushed or every other clone breaks. There is noupstreamremote configured. See docs/fork-delta.md.commit-boost-client/andhelix/are also submodules (Commit-Boost/commit-boost-client@ a certifiedmain;gattaca-com/helix@develop), the build sources forjust build-cb-imageand local relay builds. Same push rule: bump the pin to a commit that exists on the remote or--recursiveclones break.configs/generated/is gitignored, butsrc/bin/sim/render.rsandgenmodel/scenario.rsinclude_str!configs/generated/cb-basic.ymlat COMPILE time (a code comment even calls it "TRACKED"; it is not). A fresh clone therefore cannot buildsimuntil that file exists, andsimis the generator. Recovery:cp tests/fixtures/golden-configs/cb-basic.yml configs/generated/(verified byte-identical), thenjust generate-configs.- helix's config schema drifts with the moving
:maintag. The source of truth is the running binary's serde metadata, not any checked-in mirror. Reconcile by parsing against the real image (that is exactly whatsim preflightis), never by editing to match a local helix checkout. - NEVER pattern-kill processes. No
pkill -f/killall: the pattern matches your own shell and you kill your session. Read the PID (ps -eo pid,args | grep <pattern>) and kill by explicit number. - A devnet may be live on this box.
kurtosis enclave lsfirst; neverkurtosis clean -aor remove an enclave you did not create.
The gates, all four, before any commit (mirror of CI plus the drift gate):
cargo test --all-targets && cargo clippy --all-targets -- -D warnings && cargo fmt --check
sim generate --check # config-generation drift gate (NOT in CI today; run it yourself)just ci = check + test + lint. Green just ci says nothing about the devnet path.
Adding a check - the Law-4 pure-classify_* seam (non-negotiable). Split every judgement into
(a) a pure classifier taking already-fetched data and returning a CheckResult, unit-tested on both
sides of every boundary, and (b) a thin async fetch shell holding zero verdict logic. Worked examples:
cb_metrics::{collect_endpoint_stats, classify_endpoint} (the cleanest seam in the repo),
feature_fired::{classify_marker_feature, classify_skip_sigverify}, payload_matching::classify_payload_matches,
best_bid::classify_best_bid, mux_routing::classify_mux_routing. Then wire the fetch fn into
run_verification in src/main.rs. The anti-pattern is welding the verdict into the await path:
chain_health and relay_pipeline still do this and are the standing gap. Pick the tier deliberately
(it is the severity contract) and prefer WARN over a silent PASS when the check could not actually
verify anything - that is how false-greens ship. Full recipe: docs/DEVELOPING.md §2.
Adding a scenario: add the variant to Scenario + Scenario::ALL in src/bin/sim/genmodel/scenario.rs,
fill name()/comment()/relays()/cb_block()/network_params(), build the CB TOML via CbParams +
cb_toml in genmodel/cb.rs (the helix block in genmodel/helix.rs is shared and byte-identical across
scenarios), run just generate-configs, then copy the output into tests/fixtures/golden-configs/.
The golden-fixture byte-identity test is the oracle (every_scenario_matches_its_golden): it proves the
generator reproduces its own output deterministically, NOT that the config is a working devnet - only a real
run proves that. And Law 3: ship the scenario with a check that positively asserts its feature FIRED
(see src/checks/feature_fired.rs), or it is a non-test. Full recipe: docs/DEVELOPING.md §3.
Where things live: src/checks/ (the verdicts), src/bin/sim/ (generate/preflight/triage/checks/
doctor/diff + genmodel/), src/report.rs (report shape + exit_code), src/discovery.rs (kurtosis text
parsing), tests/fixtures/ (log fixtures + golden configs), scripts/run-and-verify.sh (the launcher).
If you change behavior, flags, check ids/tiers/verdicts, scenario names, or the config-generation contract, you MUST update this file AND its companion doc IN THE SAME COMMIT:
| You changed | Also update, same commit |
|---|---|
| a check's id, tier, verdict conditions, or data source | docs/CHECKS.md and src/bin/sim/checks_catalog.rs |
a CLI flag, a just target, the launcher |
this file + README.md |
| a scenario, the generator, the helix/CB blocks | tests/fixtures/golden-configs/ (regenerate) + this file's scenario list + README.md + the genmodel rows in docs/ARCH.md |
the ScenarioSpec surface, its knobs/clients, or curated() |
docs/composable-scenarios.md + this file's "Composable scenarios" + README.md + tests/fixtures/curated-configs/ (BLESS_CURATED=1, only after a live run) |
| a design law | docs/DESIGN.md |
| a ratified direction, a staged-plan status | .agent/NORTH-STAR.md |
| a live-devnet result or a new defect | .agent/SWEEP-BACKLOG.md (findings log) |
| a plan shipped | .agent/plans/INDEX.md (live -> landed) |
sim checks --list is a hand-maintained static catalog with NO compiler enforcement (checks_catalog.rs
says so at the top): the checks are constructed imperatively across several modules with no registry to
reflect on, so drift is caught only by review. This is not hypothetical - cb_relay_v2_unsupported shipped
in src/checks/cb_metrics.rs while missing from BOTH checks_catalog.rs and docs/CHECKS.md, in the very
commit that added it (caught on the next read, now synced). Adding a check is a THREE-file
change. If a real registry ever lands in src/checks, derive the catalog from it and delete this note.
Never hard-code a COUNT in prose ("six scenarios", "all nine", "the two client pairs"). Counts drift the
moment a scenario or client is added and go silently wrong across every doc that repeated them (this happened:
"six"/"nine"/"6 scenarios" were stale in README, ARCH.md, and the runbook simultaneously). Reference the
source of truth instead — Scenario::ALL, the ClientPair variants, curated() — and describe, don't tally.
New hard-won gotchas belong in the "Known traps" section above, with the evidence that bought them. A trap that lives only in a commit message will be paid for twice.
No em dashes in prose, plain language, hypothesis-driven iteration, many small files. Pure cores with tests on both sides of every boundary; thin IO shells around them.